Compare commits

...

8 Commits

Author SHA1 Message Date
mes123456
122142c26c chore: add migration progress dashboard and prevent planner idle
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
2026-09-13 08:52:31 +08:00
mes123456
edcf0fef9a feat(web): add source-first Python autopilot and optimize provider waits
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
2026-09-08 10:59:49 +08:00
mes123456
a69e41457a Add full Blender desktop parity validation workflow
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
2026-09-08 05:18:12 +08:00
mes123456
39b188c574 Fix incremental build fingerprint invalidation
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
2026-09-07 18:56:30 +08:00
mes123456
ca2695b92f Keep WASM readiness visible in status bar
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
2026-09-07 18:29:46 +08:00
mes123456
98cebd381a Align web editor with Blender 5.2 interface
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
2026-09-07 18:11:57 +08:00
mes123456
70721187c0 Bind validation report to fallback sources
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
2026-09-07 17:19:37 +08:00
mes123456
93c01c8f09 Complete ffmpeg movie fallback wiring 2026-09-07 17:17:44 +08:00
44 changed files with 11069 additions and 519 deletions

View File

@@ -7,8 +7,25 @@
1. 阅读 `docs/EXECUTION_QUEUE.md`。
2. 运行 `node tools/web/print-task-context.mjs`;它只会输出当前任务、parent manifest、parent status 和聚焦命令。
3. 只打开输出中列出的任务卡、生产入口、测试入口和最小 fixture。
4. 开始前运行 `node tools/web/check-task-context.mjs`;预算或 parent `nextTask` 不一致时停止并修正证据,不要手工推进队列。
4. 全部功能实现前不运行上下文检查或其他验证命令。直接依据当前任务卡与 parent `nextTask` 工作,不要手工推进队列。
默认上下文预算:队列 4 KiB、任务卡 8 KiB、parent manifest 24 KiB、parent status 6 KiB,合计约 3,500 tokens。历史和长期文档只能按任务卡列出的具体小节追加读取。
完成任务时运行 `node tools/web/check-task-context.mjs --task <task-id> --write`,并仅提交任务报告、manifest、status 和聚焦实现/测试变更。下一任务只认新 manifest 的 `nextTask`。
实现阶段只记录任务报告、manifest、status 和聚焦实现变更,不运行验证命令。下一任务只认新 manifest 的 `nextTask`。
## 移植铁律
- 为了正常推进移植项目工作,用户已授权执行代理在无人值守流程中自主完成必要工作,包括自行修改 Blender 源码、Web/WASM 适配层、协议、Worker、前端入口、构建脚本和任务交接产物;无需为常规实现动作重复请求确认。所有修改仍须遵守本文件其余铁律,并如实记录未验证状态。
- 功能先打通,只有全部功能实现完成后,用户才在使用时验证。全部功能完成是主要工作,中途不进行任何形式的验证,包括测试、回归、冒烟、浏览器验收、类型检查、独立语法检查、上下文检查和证据审计。优先复用本机源码和产物,集中实现真实业务入口;仅为生成所需产物进行增量编译,不把编译成功当作功能完成或验收通过。未验证功能如实记录,不得伪造通过结果。
- 遇到阻塞项,必须优先利用用户提供的 Blender 源码及本地资料定位并修复;不得仅因缺少执行契约、适配层或验收证据就停止推进或反复空转。暂未解决时记录具体失败业务、源码依据和下一步修复动作,保留已有产物并继续独立实现任务。全部功能实现完成后,才进入用户使用验证阶段。
- OCCT WASM 已提供,几何算法必须统一到这一内核出口;优先复用已有 OCCT WASM 产物、绑定和适配接口,不另造或并行维护同类几何算法实现。
- Three.js 只负责显示,消费几何内核输出;不得承担几何算法、拓扑计算或几何布尔运算。
- 优先复用本机现有源码、WASM、模块和有效证据,不重复造轮子。先核对产物版本、接口与哈希;仅在产物缺失、失效或必要修改时增量构建受影响模块。
- 坚持以功能模块业务和功能路由带动桌面 Blender 到 Web Blender 的移植,按“模块 -> 用户工作流 -> 功能路由 -> 源码/operator case”落实实现与验收。
- 遇到问题先检索用户提供的 `blender-5.2.0` 对应源码,再查当前模块相关资料;源码行为优先于路由分类、前端近似实现或外部资料。
- 源码检索使用聚焦工具,可通过 `--unit <source-unit-id>` 限定当前源码单元;禁止默认加载完整源码 inventory 或历史计划。
- 计时、索引、构建和验证是执行保障,不能代替业务功能完成;报告必须说明工作流、具体功能路由及源码依据。

View File

@@ -0,0 +1,205 @@
# 桌面版 Blender → Web Blender 全功能与界面对标验证流程
版本:`2026-09-08`
基准:官方 Blender `5.2.0 LTS`(`USD=True`)
浏览器范围:Chromium;禁止用 Firefox/WebKit 结果替代本流程。
执行原则:以功能模块为单位增量执行;已通过的模块只复用证据,不重复启动完整验证。
## 1. 目标与完成定义
本流程用于判断 Web Blender 是否在“功能行为、工作区结构、交互语义和失败边界”上对标桌面版 Blender。完成不是“页面能打开”或“控件名称相同”,而是每个模块都有桌面基准、Web 接线和可追溯证据。
一个模块只有同时满足以下条件才标记 `READY`:
1. 桌面 Blender 5.2 正例已生成可复用 fixture、字段快照或像素/结构基准。
2. Web 路径调用编译后的 Blender WebEngine/WASM Main;不得用 JS 重实现 Blender 业务逻辑。
3. 正例至少包含一次真实 mutation 或精确 Main reader/context 读取。
4. 负例在提交前失败,且 revision、对象数、资源数等状态保持不变。
5. 需要持久化的模块完成 undo/redo、save/reopen;需要 Worker 的模块完成重启/释放证据。
6. Chromium focused gate、manifest、源码 unit 绑定和 SHA-256 均已落盘。
`PARTIAL` 表示界面或协议已接线,但仍有桌面行为、字段或资源未闭环;`BLOCKED` 只用于桌面能力明确不可在浏览器执行、依赖外部资源或安全策略拒绝的路径。禁止用“隐藏按钮”“跳过验证”或伪造结果提升状态。
## 2. 固定基准与证据
### 2.1 桌面基准
固定使用本机官方二进制:
```bash
/tmp/blender-5.2.0-linux-x64/blender --version
/tmp/blender-5.2.0-linux-x64/blender -b --factory-startup --python-expr \
'import bpy,json; print(json.dumps({w.name:[a.type for s in w.screens for a in s.areas] for w in bpy.data.workspaces}, sort_keys=True))'
```
记录二进制和归档 SHA-256。默认 Layout 以及每个工作区的 Area 集合必须保留原始输出;截图只用于人工视觉复核,结构判断以 API 输出为准。
### 2.2 Web 基准
每个批次记录:
- `gitCommit`、`releaseId`、源码 `sourceTreeSha256`;
- WASM 构件 SHA-256、入口 HTML/CSS/JS SHA-256;
- Chromium 版本、viewport、`crossOriginIsolated`、请求失败列表;
- 任务 manifest、focused gate 退出码、正/负例结果和恢复结果。
生产入口必须先通过:
```bash
node tools/web/print-task-context.mjs
node tools/web/check-task-context.mjs
node tools/web/check-context-governance.mjs
```
源码检索强制使用批次入口,禁止全量源码扫描:
```bash
node tools/web/search-wbv2-batch-source.mjs --task <task-id> --pattern '<pattern>' \
--runtime web/app/src/app/App.tsx \
--runtime web/protocol/scene-ir.ts
```
`--runtime` 只能使用该工具中的 allowlist;完整 inventory 仅在最终审计命令显式执行。
## 3. 桌面工作区结构基准
下表是 Blender 5.2 `bpy.data.workspaces` 的 Area 类型集合。Web 必须保留这些区域的语义和相对布局;移动端可折叠非主区域,但不得改变桌面工作区定义。
| 工作区 | 桌面 Area 集合 | Web 对应表面 | 默认模式 |
| --- | --- | --- | --- |
| Layout | 3D View、Outliner、Properties、Dope Sheet/Timeline | 3D Viewport、Outliner、Properties、Timeline | Object |
| Modeling | 3D View、Outliner、Properties | 3D Viewport、Outliner、Properties | Edit |
| Sculpting | 3D View、Outliner、Properties | 3D Viewport、Outliner、Properties | Sculpt |
| UV Editing | Image Editor、3D View、Outliner、Properties | Image Editor、3D Viewport、Outliner、Properties | Edit |
| Texture Paint | Image Editor、3D View、Outliner、Properties | Image Editor、3D Viewport、Outliner、Properties | Texture Paint |
| Shading | File Browser、Image Editor、Node Editor、3D View、Outliner、Properties | File Browser、Image Editor、Shader Editor、3D Viewport、Outliner、Properties | Object |
| Animation | 2×3D View、Dope Sheet、Outliner、Properties | Camera View、3D Viewport、Dope Sheet、Outliner、Properties | Object |
| Rendering | Image Editor、Properties、Dope Sheet/Timeline | Render Result、Properties、Timeline | Object |
| Compositing | Image Editor、Node Editor、Properties、Dope Sheet/Timeline | Image Editor、Compositor、Properties、Timeline | Object |
| Geometry Nodes | Geometry Node Editor、Spreadsheet、3D View、Outliner、Properties、Timeline | Geometry Nodes、Spreadsheet、3D Viewport、Outliner、Properties、Timeline | Object |
| Scripting | Info、Console、Text Editor、3D View、2×Outliner、Properties | Info、Python Console、Text Editor、3D Viewport、2×Outliner、Properties | Object |
结构门禁必须检查:
- 工作区切换后 `data-workspace`、模式和编辑器集合一致;
- 区域可见矩形不重叠,右侧 Properties/Outliner 位置符合基准;
- 1440×900、1280×720、360×640、320×568 均无根横向溢出;
- 新增编辑器表面具备标题、菜单、键盘焦点和严重级别 WCAG 通过证据;
- 诊断信息不遮挡主编辑区域,工程状态只在 Status Bar 或明确的诊断区域显示。
## 4. 功能模块验证顺序
按依赖从底到顶执行,每个模块只执行一次 focused gate;同一模块的多个命令共享一个 fixture、一个 WASM adapter、一个 dispatcher 和一个 evidence JSON。
### A. 文件、场景和对象
- `.blend` 打开、分段读取、取消、恢复、最近项目、保存、另存为、关闭;
- 对象创建、复制/链接复制、删除、重命名、可见性、父子关系、Collection;
- Scene/Main reader、对象变换、原点、数据块和资源清单。
优先复用:`v1-user-loop`、`file-import-progress`、`dirty-state`、`recent-projects-recovery`、`main-datablocks-reader-production`、`wbv2-object-*-production`。
### B. Mesh、UV、非 Mesh 与编辑域
- Vertex/Edge/Face 选择、Merge/Dissolve/Extrude/Inset/Bevel/Loop Cut/Subdivide;
- UV unwrap、Curve/Surface/Metaball、Point Cloud、Volume/NanoVDB;
- Grease Pencil 选择、框选、图层/帧/笔画;
- Sculpt stroke、Vertex Paint/Weight Paint、PBVH 可见性。
每个 mutation 必须检查 revision 单调递增、非法索引 no-mutation、undo/redo 和 save/reopen。
### C. 材质、Shader、Geometry Nodes 与资源
- Material slot、Principled、Shader graph、图片/字体/声音/纹理/粒子;
- Geometry Node graph、allowlist、field budget、Spreadsheet;
- Asset catalog、library link/override/append、OPFS 资源 hash、LOD cache。
Shader、Geometry Nodes、Asset/Library 只允许调用对应 protocol/Worker/Main reader;UI 节点卡片是编辑表面,不能代替实际 WASM graph mutation。
### D. 动画、Sequencer、灯光、渲染与合成
- Action/FCurve/Driver/Keying Set/NLA/Marker;
- Sequencer strip、媒体 codec probe、音频恢复和最终导出;
- Camera、Light subtype/field、Render routing、Render reference、Compositor CPU/WebGPU;
- 渲染像素、资源预算、设备丢失、OOM、网络中断和重启恢复。
像素类 gate 必须复用已有 golden;浏览器仅执行 focused render,不在每个普通 API 回合重复启动浏览器。
### E. Scripting、安全和发布
- Text Editor、Python Console 的只读/受策略控制入口;
- manifest canonical、签名、权限、sandbox scope/budget、host call、取消、dispose、recovery;
- CSP、COOP/COEP/CORP、Range、缓存、离线归档、回滚和发布 metadata。
脚本策略拒绝必须是显式 `BLOCKED`,不能通过前端按钮绕过。
## 5. 单模块验收契约
每个模块的 manifest 使用以下最小结构:
```json
{
"task": "WBV2-…",
"desktop": { "binarySha256": "…", "fixture": "…", "receipt": "…" },
"web": { "wasmSha256": "…", "browser": "chromium", "gate": "…" },
"cases": {
"positive": { "status": "READY", "before": "…", "after": "…" },
"negative": { "status": "REJECTED", "revisionUnchanged": true },
"undoRedo": { "status": "EXACT" },
"saveReopen": { "status": "EXACT" },
"recovery": { "status": "READY" }
},
"sourceCoverage": { "unitCount": 0, "verifiedUnits": 0, "unverifiedUnits": 0, "sourceTreeSha256": "…" },
"nextTask": "…"
}
```
失败时保持 `in_progress` 或 `blocked`,不得写入 `status=done`,不得人工修改 `nextTask`。
## 6. 高效率执行规则
1. 回合启动只读取一次失败账本和当前批次 manifest;命令事件更新内存对象,回合结束集中写入。
2. 上下文 manifest 先比较 `bytes`/`mtime_ns`,未变化直接输出 `CONTEXT_PACKET_REUSED`。
3. 卡片按需读取;只有最终审计才解析完整任务树。
4. 实现回合只运行当前源码/依赖接线、最小 fixture、focused gate 和 `validate_incremental_context.py`。
5. 默认每回合最多 48 个工具命令、读取/探索不超过 16 个;达到预算先写 checkpoint。
6. 代理回合只返回结果和 `phase=CONTINUE`,rotate/sync/prepare-next 由外层 scheduler 统一执行。
7. 最近 3 次周期按 `(module,tier,family)` 自适应批次大小;超时或超过 900 秒减半,低于 300 秒且无 focused gate 失败增加 4。
8. 已通过的模块只复用 manifest、receipt、构件 hash 和截图;除非输入指纹变化,不重跑相同 gate。
## 7. 本次执行入口与交付
实现工作区改造后,按以下顺序验证:
```bash
npm --prefix web run typecheck
npm --prefix web run lint
node --test web/tests/unit/ui-schema.test.mjs
CHROME_PATH=/usr/bin/google-chrome WEB_TEST_PORT=4173 \
npx playwright test --config playwright.config.ts --workers=1 \
tests/e2e/responsive-layout.spec.ts \
tests/e2e/ui-context.spec.ts \
tests/e2e/keyboard-accessibility.spec.ts
npm --prefix web run build
CHROME_PATH=/usr/bin/google-chrome-stable WEB_ORIGIN=https://49.232.250.35:58014 \
node tools/web/check-deployed-web-blender.mjs
```
发布前确认:
- 当前 release metadata 的 commit、入口、CSS/JS、WASM hash 相互绑定;
- 线上 `/`、`/wasm-module-manifest.json`、WASM Range 和隔离头通过;
- Chromium 真实打开 `basic_scene.blend`,对象 mutation 正常;
- 线上截图覆盖 Layout、UV/Texture Paint、Shading、Animation、Rendering、Compositing、Geometry Nodes、Scripting;
- 回滚目标仍保留,任何失败不切换 `current`。
## 8. 当前实现状态记录方式
本流程本身不是“全部功能已完成”的声明。每次执行后必须同步:
- `docs/status/<task>.md`:本次模块、通过/失败/阻断和下一任务;
- `tests/golden/<task>/manifest.json`:构件、测试、hash、source coverage;
- `tests/golden/<task>/task-context.json`:上下文指纹和交接;
- 最终审计报告:按工作区、功能族、内部子模块分别统计 `READY/PARTIAL/BLOCKED`。
只有最终审计报告中所有声明为 `READY` 的功能都有桌面 receipt、Web 行为 gate 和界面 gate,才能对外宣称“桌面版 Blender 功能与界面完整对标”。

View File

@@ -0,0 +1,44 @@
# Blender 桌面版 → Web Blender 全面对标验证状态
日期:`2026-09-08`
状态:`IN_PROGRESS`
流程文档:[BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md](../BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md)
## 本轮完成
- 使用本机官方 Blender 5.2.0 LTS API 导出 11 个工作区 Area 基准。
- Web 协议增加每个工作区的编辑器集合、相对区域和默认模式。
- Web UI 增加并接线 Image Editor、Render Result、Shader Editor、Compositor、Geometry Nodes、File Browser、Spreadsheet、Text Editor、Python Console、Info、双 3D View 工作区表面。
- Modeling/Sculpting/UV Editing/Texture Paint/Shading/Animation/Rendering/Compositing/Geometry Nodes/Scripting 均可切换,且保留现有 Layout 主功能入口。
- 新增工作区表面仅作为编辑器 UI 和上下文入口;业务 mutation 仍必须通过已存在的 WASM Main/protocol/Worker 路径,不能用表面占位冒充功能完成。
## 增量验证结果
| 验证 | 结果 |
| --- | --- |
| `npm --prefix web run typecheck` | PASS |
| `npm --prefix web run lint` | PASS |
| `node --test web/tests/unit/ui-schema.test.mjs` | PASS,2/2 |
| `responsive-layout.spec.ts` | PASS,6/6 |
| `ui-context.spec.ts` | PASS |
| `keyboard-accessibility.spec.ts` | PASS |
| 11 工作区编辑器集合 | PASS |
| 工作区区域交叠检查 | PASS,0 overlap |
| 1440/1280/360/320 根宽度 | PASS,无横向溢出 |
| 新增编辑器表面 WCAG serious/critical | PASS |
## 当前边界
本轮完成的是桌面版工作区和编辑器界面对标,以及现有功能模块入口的正确分区。以下内容仍必须按流程文档逐模块执行 focused gate 后才能声明 `READY`:
- Shader/Compositor/Geometry Nodes 编辑器中的真实 graph mutation;
- Spreadsheet 与 Main reader 的字段一致性;
- Rendering/Compositor 的像素 golden、资源预算和设备故障;
- Scripting 的真实权限、签名、sandbox、取消和 dispose;
- 每个功能族的负例 no-mutation、undo/redo、save/reopen、恢复证据。
这些路径已有测试入口和协议实现时优先复用已有证据;只有输入指纹或模块 manifest 变化时才重跑。
## 交付门禁
本轮未推进 `nextTask`,未宣称全部功能完成。完成全量对标时必须继续写入模块 manifest、桌面 receipt、Chromium gate、源码 coverage 和 release metadata,并执行流程文档第 7 节的发布检查。

View File

@@ -1,13 +1,20 @@
# WBV2-P1424-001 status
- status: `implemented`
- status: `in_progress`
- reconciliation: `2026-09-08`; runtime acceptance and handoff are incomplete
- workflow modules: `16/16`
- implementation tasks: `34/34`
- implementation closure: `34/34` product tasks and `16/16` workflow modules
- route closure: `3745/3745` source units classified; `WEB_MAIN=1698`, `SERVER_NATIVE=1613`, `BROWSER_EQUIVALENT=434`, `UNAVAILABLE=0`
- behavioral evidence: `1025` verified, `2720` unverified; this is explicitly separate from implementation completion
- verification policy: `deferred_until_user_reports` (focused Chromium/native evidence only for reported defects)
- incremental gates: typecheck, route catalog, runtime audit, command registry, provider syntax/bindings, and real server `describe`/`submit` all `PASS`
- prior implementation report: `3745/3745` source units classified; this is not current dynamic inventory coverage or runtime acceptance
- recorded dynamic inventory: unitCount `3802`, verifiedUnits `1025`, unverifiedUnits `2777`; bound unit delta for this reconciliation `0`
- sourceTreeSha256: `311791df5e2b96c31ee9cefd5ab0c9a8ded87309f1a814c3b92dec328a607d6a` (existing inventory, not regenerated this turn)
- prior verification policy: `deferred_until_user_reports`; this does not satisfy the active `nextTask.md` acceptance gate
- prior incremental checks: implementation report records typecheck, route catalog, runtime audit, command registry, provider syntax/bindings and server `describe`/`submit` as `PASS`; these were not replayed during reconciliation
- WASM lifecycle: `3` completed modules preloaded; `2` debug modules loaded on demand
- follow-up: user-reported defects become focused tasks; no full matrix/browser rerun is scheduled automatically
- current checks: task-context, context-governance, execution-control-plane, task-index and diff checks passed
- repaired: regenerated task-context and rebound its previously stale manifest SHA-256
- failed gate: `node tools/web/check-blender-next-task-plan.mjs` exited `1` on the `web/package.json` artifact SHA-256
- missing acceptance: executable production/test task entrypoints and hash-bound Chromium, negative no-mutation, recovery, save/reopen and determinism receipts
- checkpoint: `tests/golden/WBV2-P1424-001/resume-report.json`; queue preserved, no capability increment, no commit or push
- follow-up: reconcile the plan artifact gate and bind focused runtime acceptance through repository generators; preserve historical evidence
- nextTask: `NONE`

168
my-tools/AUTOPILOT.md Normal file
View File

@@ -0,0 +1,168 @@
# Python Blender Web Autopilot
This supervisor advances the authoritative `docs/EXECUTION_QUEUE.md` task toward
source-backed Blender Web behavior. Product backlog entries are bounded work
items inside that task, not a replacement queue. Blender source/Main remains the
specification. A task, route assignment, successful build or agent-written
`ready` result is never counted as completed migration.
## Commands
Run from the repository root with Python 3.11 or newer on Linux:
```bash
python3 my-tools/run_web_product_migration.py doctor
python3 my-tools/run_web_product_migration.py start
python3 my-tools/run_web_product_migration.py status
python3 my-tools/run_web_product_migration.py timings
python3 my-tools/run_web_product_migration.py pause
python3 my-tools/run_web_product_migration.py reconcile
python3 my-tools/run_web_product_migration.py resume
```
`start` detaches from the terminal and transfers an exclusive OS lock to its
child without an ownership gap. `run --once` performs at most one implementation
attempt in the foreground. `--max-cycles N` bounds a run; zero means unlimited
cycles within the token and failure budgets. The existing
`bash my-tools/start_unattended.sh` product entrypoint uses this supervisor.
`stop` aliases `pause`. It terminates the active process group and records the
outcome. Interrupted implementation/commit/push work enters `needs_reconcile`.
`reconcile` is read-only. After inspecting the referenced edits, process receipts
and remote HEAD where relevant, `resume --retry-blocked` explicitly acknowledges
that state and resets the affected failure budget. An unreadable checkpoint or
unknown write outcome is never silently replayed.
For login/boot service management:
```bash
python3 my-tools/run_web_product_migration.py service
systemctl --user daemon-reload
systemctl --user enable --now blender-web-autopilot
```
The generated service captures the executable search path and restarts unexpected
process failures. Exit codes 2/3/4 prevent restart loops for known blockers,
unknown outcomes and budget exhaustion. Unattended operation without a logged-in
user also requires the host's user-service lingering configuration.
## Execution Contract
1. Read and validate the current queue, parent manifest and compact context.
2. Select a bounded current-task workflow, prioritizing audited production gaps.
3. Run one noninteractive Codex CLI turn to implement source/protocol/UI code and
prepare focused tests. The configured model is inherited; no model override,
API credential or SSH password is embedded in the program.
4. Require actual process exit 0, `turn.completed` and schema-valid output.
Finding a result file alone never ends or accepts a worker.
5. Independently run source build, typecheck, lint, Chromium, positive/negative,
undo/redo, save/reopen and determinism checks. Duplicate argv/cwd pairs run
once. Build parallelism is capped at eight jobs.
6. Require fresh runtime case receipts, native Main state observations and
SHA-256-bound compiled/runtime artifacts. Bind the validation logs and input
files in a supervisor-generated receipt.
7. Reuse receipts only while every input and artifact hash still matches.
Complete untouched workflows before revisiting shared-file invalidations.
8. After all current workflows pass, run a separate handoff turn restricted to
repository generators and evidence files, then independently run governance
gates and verify the resulting manifest. Advance only to its `nextTask`.
9. Commit exact declared handoff paths using `git commit --only`, retaining
unrelated staged edits. Pre-existing dirty paths cannot be silently included.
Push without force and require remote `main` HEAD to equal the local commit.
A successor is generated through repository tools, never inferred from a task
number. With unverified source units and no valid successor, the supervisor
reports `planning_required`; it does not announce full desktop parity.
## Policy And Evidence
Configuration: `my-tools/wbv2_autopilot_policy.json`. Defaults are 30 minutes per
worker/check, three attempts per failing workflow, 500,000 reported input/output
tokens per run, 32 MiB per process log, eight build jobs, and six push retries.
The token limit is checked between turns, so the last turn can exceed it.
`autoCommit` and `autoPush` control repository publication. All mandatory runtime
check categories remain required even when policy is edited.
The supervisor uses the existing authenticated `codex exec` CLI with
`workspace-write` sandbox and no interactive approvals. Worker stdout is JSONL;
structured output is requested with `--output-schema` and
`--output-last-message`. Consult the installed `codex exec --help` for the
supported CLI flags in this environment.
State is under `tests/golden/WBV2/autopilot/`:
- `state.json`: atomic checkpoint, job states, PIDs and reasons.
- `owner.lock`: OS-backed exclusive ownership with process identity.
- `baseline.json`: initial HEAD and pre-existing dirty paths for publication.
- `events.jsonl`, `daemon.log`: concise lifecycle events and detached output.
- `runs/<attempt>/`: worker output, check logs, process exit receipts, fresh
behavioral receipts and the final verified receipt.
`node tools/web/print-wbv2-live-status.mjs --json` includes Python supervisor
state. The existing Python status display no longer infers completion from
34/34 implemented backlog entries.
Production implementation workers do not SSH, deploy or mutate the live website.
They verify new code against disposable local runtime fixtures. The production
origin is available as `WBV2_DEPLOYED_ORIGIN`; release deployment remains a
separate operation after tested artifacts and a rollback target exist. Configure
deployment credentials through the existing host credential facilities, never
inside this policy or logs.
## Verification
```bash
python3 -m unittest discover -s my-tools -p test_wbv2_autopilot.py -v
python3 -m py_compile my-tools/wbv2_autopilot.py my-tools/run_web_product_migration.py
node tools/web/run-wbv2-scheduler.mjs --preflight-only
git diff --check
```
The tests exercise supervisor behavior with synthetic fixtures and real child
processes. They are not Blender migration coverage. Actual coverage is earned
only by source-backed native/Chromium module validation and repository binding.
## Source-First Workflow Rule
Business modules and function routes drive implementation: module -> user
workflow -> function route -> native source/operator cases. Search the supplied
`blender-5.2.0` source and task-scoped references before editing Web behavior.
Use `search-wbv2-batch-source.mjs --unit <id> --pattern <symbol>` for a bounded
source search. Worker results must name `workflow`, exact catalog `routeIds`
and native `sourceReferences`; the independent receipt binds those files.
## Timing And Provider Backoff
Displayed time labels use Beijing time, `YYYY-MM-DD HH:MM`. Machine checkpoints
retain precise timestamps; reports express elapsed time in minutes.
```bash
python3 my-tools/run_web_product_migration.py timings
python3 my-tools/run_web_product_migration.py timings --json --write
python3 my-tools/benchmark_wbv2_autopilot.py
```
`timings` is read-only unless `--write` is given. It reports each completed step,
phase totals/shares, maximum durations and the current process. JSONL tool spans
separate provider wait, model response, reads, edits and command execution.
Nested spans are not added to process totals again. Old logs cannot reconstruct
per-item timestamps or unrecorded gaps.
Short commands wait for process completion directly. Heartbeats are written
every five seconds rather than on every poll. Provider events are read
incrementally with a default observation interval of 0.25 seconds. Shared
evidence paths are hashed once per fingerprint operation.
Before any agent action, overload/concurrency/rate-limit errors end the waiting
process promptly. No first action within 60 seconds also ends the request.
Automatic retry requires a known JSONL handshake, no actions or unknown output,
and unchanged source/protected fingerprints. Retry delays are 30, 60, 120 and
at most 240 seconds; four provider failures stop with `provider_blocked`.
Provider failures do not consume implementation attempts. Silence after actions
has a 180-second limit unless a tool is active; unknown outcomes require
reconciliation. Build/test limits and behavioral gates remain in force.
After a reviewed maintenance commit, `resume --retry-blocked --refresh-baseline`
can refresh the Git baseline only when no workflow edits or receipts are recorded.
It retains remaining dirty paths as protected pre-existing work, and cannot
absorb partially implemented or already verified workflow changes.

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Compare recorded scheduling timings with real read-only context checks."""
import contextlib
import io
import json
from pathlib import Path
import statistics
import tempfile
from wbv2_autopilot import Autopilot, CONTROL, POLICY, ROOT, read_json, write_json
from wbv2_timing import timing_report
def main():
policy = read_json(ROOT / POLICY)
before = timing_report(ROOT / CONTROL)
samples = []
with tempfile.TemporaryDirectory(prefix="wbv2-timing-") as temporary:
root = Path(temporary)
runner = Autopilot(root, policy)
with contextlib.redirect_stdout(io.StringIO()):
for script in ["check-task-context.mjs", "check-context-governance.mjs"]:
for iteration in range(3):
result = runner.run_process(["node", str(ROOT / "tools/web" / script)], root, root / f"{script}-{iteration}.log", 30, phase="context_check")
if result["exitCode"] != 0:
raise RuntimeError(f"context benchmark failed: {script}")
samples.append({"script": script, "seconds": result["durationSeconds"]})
old = [row["seconds"] for row in before["steps"] if row["phase"] == "context_check"]
median_old = statistics.median(old)
median_new = statistics.median(sample["seconds"] for sample in samples)
report = {
"schemaVersion": 1,
"operation": "AUTOPILOT_PERFORMANCE_OPTIMIZATION",
"observedAt": timing_report(ROOT / CONTROL)["observedAt"],
"timezone": "Asia/Shanghai",
"labelPrecision": "minute",
"baseline": before,
"benchmark": {
"scope": "Same read-only context entrypoints: historical observed baseline versus three current samples per entrypoint; not whole-project throughput.",
"samples": samples,
"baselineMedianMinutes": round(median_old / 60, 6),
"optimizedMedianMinutes": round(median_new / 60, 6),
"observedSpeedup": round(median_old / median_new, 2),
"observedReductionPercent": round((1 - median_new / median_old) * 100, 2),
},
"providerPolicy": {key: policy[key] for key in ["modelStartTimeoutSeconds", "modelIdleTimeoutSeconds", "providerBackoffSeconds", "providerMaxBackoffSeconds", "maxProviderFailures"]},
"sourcePolicy": {"driver": policy["strategy"], "authority": policy["sourceAuthority"], "sourceFirst": policy["sourceFirst"]},
"limitations": ["Provider availability/account concurrency is external; these changes do not remove upstream limits.", "No source capability completion or inventory increment is claimed.", "Legacy JSONL lacks per-item timestamps; new runs record observed per-item intervals separately."],
}
destination = ROOT / "tests/golden/WBV2-P1424-001/performance-optimization-report.json"
write_json(destination, report)
print(json.dumps({"report": str(destination.relative_to(ROOT)), "observedAt": report["observedAt"], **report["benchmark"]}, indent=2))
if __name__ == "__main__":
main()

View File

@@ -1,373 +1,7 @@
#!/usr/bin/env python3
"""Implement the Web product backlog through bounded non-interactive Codex turns."""
"""Compatibility entrypoint for the independently verified Python supervisor."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import signal
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from wbv2_runtime import ROOT
CONTROL = ROOT / "tests/golden/WBV2"
BACKLOG = CONTROL / "web-product-backlog.json"
STATUS = CONTROL / "web-product-migration-status.json"
LOCK = CONTROL / "web-product-migration.lock"
PID = CONTROL / "web-product-migration.pid"
LOG_ROOT = CONTROL / "product-migration"
ACTIVE_PROCESS: subprocess.Popen[str] | None = None
def now() -> str:
return datetime.now(timezone.utc).isoformat()
def read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + f".tmp-{os.getpid()}")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(path)
def alive(pid: object) -> bool:
if not isinstance(pid, int) or pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def acquire_lock() -> None:
try:
handle = os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
try:
owner = read_json(LOCK).get("pid")
except (OSError, json.JSONDecodeError):
owner = None
if not alive(owner):
LOCK.unlink(missing_ok=True)
return acquire_lock()
raise SystemExit(f"web product migration already running: pid={owner}")
os.write(handle, (json.dumps({"pid": os.getpid(), "startedAt": now()}) + "\n").encode())
os.close(handle)
def file_hash(path: str) -> str | None:
absolute = ROOT / path
if not absolute.is_file():
return None
return hashlib.sha256(absolute.read_bytes()).hexdigest()
def refresh_modules(backlog: dict) -> None:
states = {task["id"]: task["status"] for task in backlog.get("tasks", [])}
for module in backlog.get("workflowModules", []):
task_ids = module.get("taskIds", [])
if not task_ids:
continue
task_states = [states.get(task_id, "pending") for task_id in task_ids]
if all(state == "implemented" for state in task_states):
module["status"] = "implemented"
elif any(state == "active" for state in task_states):
module["status"] = "active"
elif any(state == "blocked" for state in task_states):
module["status"] = "blocked"
else:
module["status"] = "partial"
def finalize_product_task(backlog: dict) -> None:
task_id = str(backlog.get("queueTask", "WBV2-P1420-001"))
parent_id = str(backlog.get("rootTask", "WBV2-P1420-000"))
index = read_json(CONTROL / "task-index.json")
plan_path = ROOT / index["source"]["path"]
plan = read_json(plan_path)
for record in plan.get("tasks", []):
if record.get("id") == task_id:
record["status"] = "done"
record["ready"] = False
plan["status"] = "PRODUCT_IMPLEMENTATION_COMPLETE"
write_json(plan_path, plan)
parent_manifest_path = ROOT / "tests/golden" / parent_id / "manifest.json"
parent_manifest = read_json(parent_manifest_path)
if isinstance(parent_manifest.get("artifacts", {}).get("plan"), dict):
parent_manifest["artifacts"]["plan"]["sha256"] = file_hash(str(plan_path.relative_to(ROOT)))
write_json(parent_manifest_path, parent_manifest)
output = ROOT / "tests/golden" / task_id
report_path = output / "product-implementation-report.json"
status_path = ROOT / "docs/status" / f"{task_id}.md"
task_context_path = output / "task-context.json"
changed_files = []
for task in backlog.get("tasks", []):
result_path = ROOT / task["resultPath"]
result = read_json(result_path) if result_path.is_file() else {}
changed_files.extend(result.get("changedFiles", []))
changed_files = list(dict.fromkeys(path for path in changed_files if isinstance(path, str)))
write_json(report_path, {
"schemaVersion": 1,
"task": task_id,
"operation": "WEB_PRODUCT_IMPLEMENTATION_COMPLETE",
"status": "implemented",
"strategy": backlog.get("strategy"),
"verificationPolicy": backlog.get("verificationPolicy"),
"workflowModules": backlog.get("workflowModules", []),
"implementationTasks": backlog.get("tasks", []),
"changedFiles": changed_files,
"sourceInventory": backlog.get("sourceInventory"),
})
status_path.write_text(
f"# {task_id} status\n\n- status: `implemented`\n- workflow modules: `{len(backlog.get('workflowModules', []))}/{len(backlog.get('workflowModules', []))}`\n- implementation tasks: `{len(backlog.get('tasks', []))}/{len(backlog.get('tasks', []))}`\n- verification: `deferred_until_user_reports`\n- nextTask: `NONE`\n",
encoding="utf-8",
)
artifacts = {
"parentManifest": {"path": f"tests/golden/{parent_id}/manifest.json", "sha256": file_hash(f"tests/golden/{parent_id}/manifest.json")},
"backlog": {"path": str(BACKLOG.relative_to(ROOT)), "sha256": file_hash(str(BACKLOG.relative_to(ROOT)))},
"strategy": {"path": "docs/WEB_PRODUCT_MIGRATION_STRATEGY.md", "sha256": file_hash("docs/WEB_PRODUCT_MIGRATION_STRATEGY.md")},
"report": {"path": str(report_path.relative_to(ROOT)), "sha256": file_hash(str(report_path.relative_to(ROOT)))},
"status": {"path": str(status_path.relative_to(ROOT)), "sha256": file_hash(str(status_path.relative_to(ROOT)))},
"plan": {"path": str(plan_path.relative_to(ROOT)), "sha256": file_hash(str(plan_path.relative_to(ROOT)))},
}
if task_context_path.is_file():
artifacts["taskContext"] = {"path": str(task_context_path.relative_to(ROOT)), "sha256": file_hash(str(task_context_path.relative_to(ROOT)))}
write_json(output / "manifest.json", {
"schemaVersion": 1,
"task": task_id,
"parentTask": parent_id,
"operation": "WEB_PRODUCT_IMPLEMENTATION_COMPLETE",
"status": "done",
"implementationComplete": True,
"verificationDeferred": True,
"artifacts": artifacts,
"nextTask": None,
})
subprocess.run(["node", "tools/web/generate-v2-task-index.mjs", "--plan-path", str(plan_path.relative_to(ROOT))], cwd=ROOT, check=True)
def write_status(backlog: dict, status: str, **extra: object) -> None:
tasks = backlog.get("tasks", [])
modules = backlog.get("workflowModules", [])
write_json(STATUS, {
"schemaVersion": 1,
"pid": os.getpid(),
"status": status,
"updatedAt": now(),
"tasks": {
"total": len(tasks),
"implemented": sum(task.get("status") == "implemented" for task in tasks),
"active": [task["id"] for task in tasks if task.get("status") == "active"],
"pending": sum(task.get("status") == "pending" for task in tasks),
"blocked": sum(task.get("status") == "blocked" for task in tasks),
},
"workflowModules": {
"total": len(modules),
"implemented": sum(module.get("status") == "implemented" for module in modules),
"active": [module["id"] for module in modules if module.get("status") == "active"],
"blocked": [module["id"] for module in modules if module.get("status") == "blocked"],
},
**extra,
})
def prompt(task: dict) -> str:
backlog = read_json(BACKLOG)
queue_task = str(backlog.get("queueTask", "WBV2-P1420-001"))
attempt = int(task.get("attempts", 1))
retry_rules = ""
if attempt > 1:
retry_rules = f"""
Retry continuation (attempt {attempt}):
- A previous attempt may already have changed owned files. Treat those edits as the starting point; do not recreate or discard them.
- After the mandatory queue/print/check commands, use at most 2 additional read/search commands.
- Inspect only the exact unfinished edited regions, fix the remaining integration issue, and write the result JSON immediately.
- Do not repeat broad discovery, reread unchanged context, or polish unrelated code.
"""
return f"""Implement exactly one Web Blender product workflow task in {ROOT}.
Task: {task['id']} - {task['title']}
Commands to expose: {', '.join(task['commands'])}
Owned paths: {', '.join(task['ownedPaths'])}
Implementation requirement: {task['implementation']}
Rules:
- Follow the repository AGENTS.md minimal-context instructions first.
- The authoritative queue task is {queue_task}; this product task is one bounded subtask inside it. Run the default task-context check once, and never pass the product subtask id to check-task-context.
- Preserve all existing dirty changes. Do not revert or rewrite unrelated work.
- Read only the owned paths and their direct imported types/helpers.
- For scoped searches, pass each owned path explicitly through the batch search runtime option; never pass a bare --runtime flag.
- Hard budget: at most 16 tool commands, including at most 8 read/search commands. After the read budget, implement immediately.
- Do not print or reread the complete diff; inspect only the edited regions once before writing the result.
- Implement production behavior through the existing Blender Main/WASM command routes; do not simulate Blender behavior in JavaScript.
- Do not run tests, builds, typecheck, lint, Chromium, full validators, or coverage binding. The user explicitly deferred verification to real usage.
- Do not modify docs/EXECUTION_QUEUE.md, nextTask.md, source coverage ledgers, manifests, or this backlog.
- Do not commit or push. The outer unattended scheduler owns rotation and repository handoff.
- Finish the implementation in this turn. Do not stop at a plan.
- At the end write {task['resultPath']} as JSON with schemaVersion=1, task={task['id']!r}, status='implemented', changedFiles, and a concise summary.
{retry_rules}
"""
def run_task(task: dict, heartbeat_seconds: float) -> tuple[int, bool]:
global ACTIVE_PROCESS
before = {path: file_hash(path) for path in task["ownedPaths"]}
result_path = ROOT / task["resultPath"]
result_path.unlink(missing_ok=True)
log_path = LOG_ROOT / f"{task['id']}.codex.jsonl"
LOG_ROOT.mkdir(parents=True, exist_ok=True)
command = [
"codex", "exec", "--dangerously-bypass-approvals-and-sandbox",
"--ephemeral", "--cd", str(ROOT), "--json", "-",
]
completed_turn = False
result_seen_at: float | None = None
task_started_at = time.monotonic()
with log_path.open("w", encoding="utf-8") as log:
process = subprocess.Popen(
command,
cwd=ROOT,
stdin=subprocess.PIPE,
stdout=log,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
ACTIVE_PROCESS = process
assert process.stdin is not None
process.stdin.write(prompt(task))
process.stdin.close()
while process.poll() is None:
write_status(read_json(BACKLOG), "implementing", activeTask=task["id"], childPid=process.pid, log=str(log_path.relative_to(ROOT)))
log.flush()
if result_path.is_file():
if result_seen_at is None:
result_seen_at = time.monotonic()
try:
with log_path.open("rb") as current_log:
end = current_log.seek(0, os.SEEK_END)
current_log.seek(max(0, end - 8192))
completed_turn = b'"type":"turn.completed"' in current_log.read()
except OSError:
completed_turn = False
if time.monotonic() - result_seen_at >= 5:
completed_turn = True
if completed_turn:
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
except ProcessLookupError:
pass
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
break
if time.monotonic() - task_started_at >= 900:
try:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
except ProcessLookupError:
pass
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait()
break
time.sleep(heartbeat_seconds)
code = 0 if completed_turn else (process.returncode or 0)
ACTIVE_PROCESS = None
after = {path: file_hash(path) for path in task["ownedPaths"]}
changed = any(before[path] != after[path] for path in before)
result_ok = False
if result_path.is_file():
try:
result = read_json(result_path)
result_ok = result.get("task") == task["id"] and result.get("status") == "implemented"
except json.JSONDecodeError:
pass
return code, result_ok and (changed or completed_turn)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--once", action="store_true", help="run at most one task")
parser.add_argument("--max-attempts", type=int, default=3)
parser.add_argument("--heartbeat-seconds", type=float, default=5.0)
args = parser.parse_args()
if args.max_attempts < 1 or args.heartbeat_seconds <= 0:
parser.error("limits must be positive")
acquire_lock()
PID.write_text(f"{os.getpid()}\n", encoding="ascii")
stopped = False
def stop(signum: int, _frame: object) -> None:
nonlocal stopped
stopped = True
if ACTIVE_PROCESS and ACTIVE_PROCESS.poll() is None:
try:
os.killpg(ACTIVE_PROCESS.pid, signum)
except ProcessLookupError:
pass
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
try:
ran = 0
while not stopped:
backlog = read_json(BACKLOG)
for task in backlog.get("tasks", []):
if task.get("status") == "active":
task["status"] = "pending"
refresh_modules(backlog)
pending = next((task for task in backlog.get("tasks", []) if task.get("status") == "pending"), None)
if pending is None:
refresh_modules(backlog)
write_json(BACKLOG, backlog)
blocked = [task for task in backlog.get("tasks", []) if task.get("status") == "blocked"]
if not blocked:
finalize_product_task(backlog)
write_status(backlog, "blocked" if blocked else "implementation_complete")
return 2 if blocked else 0
pending["status"] = "active"
pending["attempts"] = int(pending.get("attempts", 0)) + 1
pending["startedAt"] = now()
refresh_modules(backlog)
write_json(BACKLOG, backlog)
write_status(backlog, "implementing", activeTask=pending["id"])
code, implemented = run_task(pending, args.heartbeat_seconds)
backlog = read_json(BACKLOG)
task = next(item for item in backlog["tasks"] if item["id"] == pending["id"])
if code == 0 and implemented:
task["status"] = "implemented"
task["completedAt"] = now()
task.pop("lastError", None)
elif int(task.get("attempts", 0)) >= args.max_attempts:
task["status"] = "blocked"
task["lastError"] = f"codex exit={code}; implementation result or owned-path change missing"
else:
task["status"] = "pending"
task["lastError"] = f"codex exit={code}; retry scheduled"
refresh_modules(backlog)
write_json(BACKLOG, backlog)
ran += 1
if args.once and ran >= 1:
write_status(backlog, "idle", lastTask=pending["id"], exitCode=code)
return 0 if implemented else 2
finally:
PID.unlink(missing_ok=True)
LOCK.unlink(missing_ok=True)
return 0
from wbv2_autopilot import main
if __name__ == "__main__":

View File

@@ -49,16 +49,13 @@ def main() -> int:
product = status["progress"].get("productMigration", {})
product_pid = product.get("pid")
product_alive = isinstance(product_pid, int) and (Path("/proc") / str(product_pid)).exists()
product_complete = (
product.get("workflowTotal", 0) > 0
and product.get("workflowImplemented") == product.get("workflowTotal")
and product.get("taskImplemented") == product.get("taskTotal")
)
product_complete = status.get("autopilot", {}).get("state") == "complete"
status["myTools"]["productMigrationAlive"] = product_alive
status["unattendedState"] = (
"IMPLEMENTING" if product_alive and product.get("status") == "implementing"
"WAITING_PROVIDER" if product_alive and product.get("status") == "waiting_provider"
else "IMPLEMENTING" if product_alive and product.get("status") in {"implementing", "validating", "finalizing", "preflight", "retry_pending"}
else "COMPLETE" if product_complete
else "BLOCKED" if product.get("status") == "blocked"
else "BLOCKED" if product.get("status") in {"blocked", "provider_blocked", "needs_reconcile", "planning_required", "budget_exhausted", "ready_to_publish"}
else "PLANNING" if status.get("state") == "IDLE" and watchdog_alive and watchdog.get("status") == "waiting_for_source_batch"
else status.get("state", "UNKNOWN")
)
@@ -112,9 +109,9 @@ def main() -> int:
for line in format_progress(status["progress"], status["fastPath"]):
print(line)
if status.get("unattendedState") == "IMPLEMENTING":
print(" message: IMPLEMENTING: one workflow task is being edited; verification is deferred to user operation.")
print(" message: IMPLEMENTING: bounded source work and independent runtime acceptance are in progress.")
elif status.get("unattendedState") == "COMPLETE":
print(" message: COMPLETE: the Web workflow implementation backlog is closed; user-reported defects become focused follow-up tasks.")
print(" message: COMPLETE: the supervisor accepted terminal runtime evidence and found no unverified source units.")
elif status.get("unattendedState") == "PLANNING":
print(" message: PLANNING: watchdog heartbeat is active; exact source gaps are queued without false coverage.")
else:

View File

@@ -79,19 +79,8 @@ start_watchdog() {
}
start_product_migration() {
local pid_file="$CONTROL_DIR/web-product-migration.pid"
if test -f "$pid_file"; then
local product_pid
product_pid="$(tr -d '[:space:]' < "$pid_file")"
if test -n "$product_pid" && kill -0 "$product_pid" 2>/dev/null; then
printf 'product migration already running: %s\n' "$product_pid"
return
fi
mv "$pid_file" "$pid_file.stale-$(date +%s)"
fi
nohup setsid python3 "$ROOT_DIR/my-tools/run_web_product_migration.py" \
> "$CONTROL_DIR/web-product-migration.stdout.log" 2>&1 < /dev/null &
printf 'product migration launch pid: %s\n' "$!"
python3 "$ROOT_DIR/my-tools/run_web_product_migration.py" start \
--max-cycles "$SCHEDULER_MAX_ITERATIONS"
}
if test "$MIGRATION_MODE" = "source-verification"; then

View File

@@ -0,0 +1,375 @@
"""Supervisor regression tests; synthetic receipts are not Blender coverage."""
import contextlib
import copy
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
from wbv2_autopilot import Autopilot, CONTROL, Lease, POLICY, REQUIRED, ProviderUnavailable, inventory_proof, manifest_proof, queue_context, sha, validate_command, write_json
from wbv2_timing import WorkerTrace, display_times, minute_label, timing_report
class SupervisorTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.policy = json.loads((Path(__file__).with_name("wbv2_autopilot_policy.json")).read_text())
self.policy.update(heartbeatSeconds=0.01, retryDelaySeconds=0, autoCommit=False, autoPush=False)
self.task = "WBV2-P42-001"
self.parent = "tests/golden/WBV2-P42-000/manifest.json"
self.file("docs/EXECUTION_QUEUE.md", f"| 当前任务 | `{self.task}` |\n| parent manifest | `{self.parent}` |\n")
self.file(f"docs/tasks/{self.task}.md", "one bounded source workflow\n")
self.file("source.cpp", "int real_source;\n")
self.native_source = "blender-5.2.0/source/blender/web_engine/test_fixture.cpp"
self.file(self.native_source, "int supervisor_test_fixture;\n")
self.file("engine.wasm", "test fixture, not production WASM\n")
write_json(self.root / POLICY, self.policy)
write_json(self.root / self.parent, {"task": "WBV2-P42-000", "status": "done", "nextTask": self.task})
write_json(self.root / "tests/golden/WBV2/dynamic-inventory.json", {"units": [{"id": "operator:TEST"}], "coverage": {"unverifiedUnits": 1}})
write_json(self.root / "web/app/src/capabilities/blender-feature-route-catalog.json", {"entries": [{"id": "operator:TEST", "route": "WEB_MAIN"}]})
self.runner = Autopilot(self.root, self.policy)
self.context = queue_context(self.root)
self.job = {"id": "SOURCE-JOB", "ownedPaths": ["source.cpp"], "title": "test source", "queueTask": self.task}
def file(self, path, content):
p = self.root / path
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content)
return p
def ready_result(self, run_dir):
kinds = ["positive", "negative", "undo_redo", "save_reopen", "determinism"]
cases = [{"id": kind, "kind": kind, "checkId": "native", "receipt": str((run_dir / "cases" / f"{kind}.json").relative_to(self.root)), "unitIds": ["operator:TEST"]} for kind in kinds]
result = {"status": "ready", "summary": "synthetic supervisor fixture", "changedFiles": ["source.cpp", "check.py"], "sourceUnits": ["operator:TEST"], "dependencies": ["source.cpp", "check.py"], "artifacts": ["engine.wasm"], "checks": [{"id": "native", "kinds": sorted(REQUIRED), "argv": ["python3", "check.py"], "cwd": "."}], "cases": cases}
result.update(workflow="Synthetic supervisor workflow", routeIds=["operator:TEST"], sourceReferences=[self.native_source])
self.file("check.py", """import hashlib, json, os
from pathlib import Path
root = Path.cwd()
counter = root / 'executions.txt'
counter.write_text(str(int(counter.read_text()) + 1) if counter.exists() else '1')
out = Path(os.environ['WBV2_RECEIPT_DIR'])
out.mkdir(parents=True, exist_ok=True)
a, b = 'a' * 64, 'b' * 64
observations = {'positive': {'before': a, 'after': b}, 'negative': {'before': a, 'after': a}, 'undo_redo': {'before': a, 'after': b, 'undo': a, 'redo': b}, 'save_reopen': {'saved': b, 'reopened': b}, 'determinism': {'first': b, 'second': b}}
for kind, values in observations.items():
receipt = {'status': 'PASS', 'runtime': 'CHROMIUM_WEBENGINE', 'caseId': kind, 'kind': kind, 'unitIds': ['operator:TEST'], 'observations': values, 'artifacts': [{'path': 'engine.wasm', 'sha256': hashlib.sha256((root / 'engine.wasm').read_bytes()).hexdigest()}]}
(out / (kind + '.json')).write_text(json.dumps(receipt))
""")
return result
def worker_fixture(self, prompt, run_dir):
return self.ready_result(run_dir)
def execute_fixture(self):
with patch.object(self.runner, "worker", side_effect=self.worker_fixture), contextlib.redirect_stdout(io.StringIO()):
return self.runner.attempt(self.context, self.job)
def test_parent_pointer_mismatch_is_rejected(self):
write_json(self.root / self.parent, {"status": "done", "nextTask": "WBV2-P99-001"})
with self.assertRaisesRegex(ValueError, "does not authorize"):
queue_context(self.root)
def test_route_complete_or_deferred_is_not_runtime_acceptance(self):
p = self.root / f"tests/golden/{self.task}/manifest.json"
write_json(p, {"task": self.task, "status": "done", "nextTask": None, "implementationComplete": True, "verificationDeferred": True})
self.assertFalse(manifest_proof(self.root, self.task)["ok"])
def test_terminal_manifest_checks_artifact_bytes(self):
p = self.root / f"tests/golden/{self.task}/manifest.json"
write_json(p, {"task": self.task, "status": "done", "acceptanceComplete": True, "nextTask": None, "artifacts": {"source": {"path": "source.cpp", "sha256": sha(self.root / "source.cpp")}}})
self.assertTrue(manifest_proof(self.root, self.task)["ok"])
self.file("source.cpp", "changed")
self.assertFalse(manifest_proof(self.root, self.task)["ok"])
def test_policy_cannot_disable_required_checks(self):
self.policy["requiredChecks"] = ["typecheck"]
with self.assertRaisesRegex(ValueError, "mandatory"):
Autopilot(self.root, self.policy)
def test_zero_summary_does_not_hide_unverified_units_or_drift(self):
p = self.root / "tests/golden/WBV2/dynamic-inventory.json"
write_json(p, {"units": [{"id": "operator:TEST", "coverageStatus": "UNVERIFIED"}], "coverage": {"unverifiedUnits": 0}})
self.assertFalse(inventory_proof(self.root)["ok"])
write_json(p, {"units": [{"id": "operator:TEST", "coverageStatus": "VERIFIED", "coverage": {}}], "coverage": {"unverifiedUnits": 0}})
self.assertEqual(1, inventory_proof(self.root)["unitsWithInvalidReferences"])
def test_lock_excludes_concurrent_owner_and_reuses_inode(self):
p = self.root / "owner.lock"
with Lease(p):
inode = p.stat().st_ino
with self.assertRaisesRegex(RuntimeError, "owns"):
with Lease(p):
pass
with Lease(p):
self.assertEqual(inode, p.stat().st_ino)
def test_command_rejects_shell_inline_code_other_browsers_and_escape(self):
for argv, cwd in [(["bash", "-c", "true"], "."), (["node", "-e", "process.exit(0)"], "."), (["npm", "run", "test", "--", "--project=webkit"], "."), (["git", "reset", "--hard"], "."), (["python3", "test.py"], "../outside")]:
with self.subTest(argv=argv), self.assertRaises(ValueError):
validate_command(self.root, argv, cwd)
def test_symlink_escape_is_rejected(self):
(self.root / "external").symlink_to("/tmp", target_is_directory=True)
with self.assertRaises(ValueError):
validate_command(self.root, ["npm", "run", "test"], "external")
def test_agent_ready_without_behavior_matrix_is_not_success(self):
with patch.object(self.runner, "worker", return_value={"status": "ready", "summary": "done", "sourceUnits": ["operator:TEST"], "checks": []}), contextlib.redirect_stdout(io.StringIO()):
self.assertFalse(self.runner.attempt(self.context, self.job))
self.assertEqual("retry_pending", self.runner.state["jobs"]["SOURCE-JOB"]["state"])
def test_fresh_runtime_receipts_and_hashes_are_required(self):
run_dir = self.runner.directory / "runs/test"
result = self.ready_result(run_dir)
self.file(result["cases"][0]["receipt"], "{}")
with self.assertRaisesRegex(ValueError, "fresh"):
self.runner.validate_result(result, run_dir)
def test_real_process_checks_create_receipts_and_cache_invalidates(self):
self.assertTrue(self.execute_fixture())
record = self.runner.state["jobs"]["SOURCE-JOB"]
self.assertTrue(self.runner.receipt_valid(record))
self.assertEqual("1", (self.root / "executions.txt").read_text())
self.file("source.cpp", "changed native source")
self.assertFalse(self.runner.receipt_valid(record))
def test_identical_checks_are_run_once(self):
def worker(prompt, run_dir):
result = self.ready_result(run_dir)
duplicate = copy.deepcopy(result["checks"][0])
duplicate["id"] = "same-command"
result["checks"].append(duplicate)
return result
with patch.object(self.runner, "worker", side_effect=worker), contextlib.redirect_stdout(io.StringIO()):
self.assertTrue(self.runner.attempt(self.context, self.job))
self.assertEqual("1", (self.root / "executions.txt").read_text())
def test_source_mutation_during_validation_is_not_accepted(self):
def worker(prompt, run_dir):
result = self.ready_result(run_dir)
with (self.root / "check.py").open("a") as stream:
stream.write("\n(root / 'source.cpp').write_text('drift')\n")
return result
with patch.object(self.runner, "worker", side_effect=worker), contextlib.redirect_stdout(io.StringIO()):
self.assertFalse(self.runner.attempt(self.context, self.job))
self.assertEqual("needs_reconcile", self.runner.state["state"])
def test_protected_queue_edit_requires_reconciliation(self):
def worker(prompt, run_dir):
self.file("docs/EXECUTION_QUEUE.md", "changed")
return self.ready_result(run_dir)
with patch.object(self.runner, "worker", side_effect=worker), contextlib.redirect_stdout(io.StringIO()):
self.assertFalse(self.runner.attempt(self.context, self.job))
self.assertEqual("needs_reconcile", self.runner.state["state"])
def test_failed_check_never_marks_job_verified_and_retries_are_bounded(self):
def worker(prompt, run_dir):
result = self.ready_result(run_dir)
self.file("check.py", "raise SystemExit(7)\n")
return result
with patch.object(self.runner, "worker", side_effect=worker), contextlib.redirect_stdout(io.StringIO()):
for _ in range(3):
self.assertFalse(self.runner.attempt(self.context, self.job))
self.assertEqual("blocked", self.runner.state["state"])
self.assertEqual(3, self.runner.state["jobs"]["SOURCE-JOB"]["attempts"])
def test_negative_case_with_mutation_fails(self):
self.assertTrue(self.execute_fixture())
record = self.runner.state["jobs"]["SOURCE-JOB"]
run_dir = self.root / record["runDirectory"]
result = self.ready_result(run_dir)
p = run_dir / "cases/negative.json"
receipt = json.loads(p.read_text())
receipt["observations"]["after"] = "b" * 64
write_json(p, receipt)
with self.assertRaisesRegex(ValueError, "behavior assertion"):
self.runner.verify_cases(result)
def test_worker_result_does_not_override_nonzero_process_exit(self):
run_dir = self.runner.directory / "runs/failed-worker"
run_dir.mkdir(parents=True)
def process(*args, **kwargs):
self.file(str((run_dir / "worker.jsonl").relative_to(self.root)), '{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":2}}\n')
write_json(run_dir / "worker-result.json", {"status": "ready"})
return {"exitCode": 9, "interrupted": False, "timedOut": False, "logLimit": False}
with patch.object(self.runner, "run_process", side_effect=process), self.assertRaisesRegex(ValueError, "exit=9"):
self.runner.worker("test", run_dir)
self.assertEqual(12, self.runner.run_tokens)
def test_unknown_previous_writer_is_never_replayed(self):
self.runner.state["state"] = "implementing"
with patch.object(self.runner, "worker") as worker:
self.assertEqual(3, self.runner.run(max_cycles=1))
worker.assert_not_called()
self.assertEqual("needs_reconcile", self.runner.state["state"])
def test_timeout_kills_descendants_before_late_publication(self):
child = self.file("late.py", "import time\nfrom pathlib import Path\ntime.sleep(1)\nPath('late-result').write_text('wrong')\n")
parent = self.file("hang.py", f"import subprocess, sys, time\nsubprocess.Popen([sys.executable, {str(child)!r}])\ntime.sleep(30)\n")
self.runner.checkpoint("validating")
with contextlib.redirect_stdout(io.StringIO()):
result = self.runner.run_process([sys.executable, str(parent)], self.root, self.runner.directory / "timeout.log", 0.1)
self.assertTrue(result["timedOut"])
time.sleep(1.1)
self.assertFalse((self.root / "late-result").exists())
def test_dirty_user_file_cannot_be_included_in_automatic_commit(self):
self.policy["autoCommit"] = True
write_json(self.runner.directory / "baseline.json", {"head": "none", "dirtyPaths": ["source.cpp"]})
with self.assertRaisesRegex(ValueError, "pre-existing edits"):
self.runner.publish(self.context, ["source.cpp"])
def test_commit_excludes_unrelated_staged_changes(self):
def git(*args):
return subprocess.check_output(["git", *args], cwd=self.root, stderr=subprocess.DEVNULL, text=True).strip()
git("init", "-b", "main")
git("config", "user.name", "Supervisor Test")
git("config", "user.email", "supervisor@example.invalid")
self.file("user.txt", "original")
git("add", ".")
git("commit", "-m", "fixture")
self.file("user.txt", "user staged edit")
git("add", "user.txt")
self.runner.remember_baseline()
self.file("source.cpp", "verified implementation")
self.policy["autoCommit"] = True
self.assertTrue(self.runner.publish(self.context, ["source.cpp"]))
self.assertEqual("source.cpp", git("diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"))
self.assertEqual("user.txt", git("diff", "--cached", "--name-only"))
self.assertEqual("user staged edit", (self.root / "user.txt").read_text())
def test_daemon_lease_handoff_keeps_single_owner(self):
lock = self.root / "daemon.lock"
module_dir = str(Path(__file__).parent)
with Lease(lock) as lease:
fd = lease.stream.fileno()
code = f"from pathlib import Path\nimport time\nfrom wbv2_autopilot import Lease\nwith Lease(Path({str(lock)!r}), {fd}):\n print('owned', flush=True)\n time.sleep(0.4)\n"
child = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True, pass_fds=(fd,), env={**os.environ, "PYTHONPATH": module_dir})
lease.transferred = True
try:
self.assertEqual("owned", child.stdout.readline().strip())
with self.assertRaises(RuntimeError):
with Lease(lock):
pass
self.assertEqual(0, child.wait(timeout=3))
finally:
if child.poll() is None:
child.kill()
child.wait()
child.stdout.close()
with Lease(lock):
pass
def test_legacy_preflight_is_read_only_and_rejects_incomplete_terminal(self):
repo = Path(__file__).resolve().parents[1]
for name in ("run-wbv2-scheduler.mjs", "wbv2-failure-ledger.mjs"):
self.file("tools/web/" + name, (repo / "tools/web" / name).read_text())
self.file("tools/web/check-task-context.mjs", "process.exit(0);\n")
write_json(self.root / "tests/golden/WBV2/task-index.json", {"activeTask": None, "entries": {self.task: {}}})
write_json(self.root / f"tests/golden/{self.task}/manifest.json", {"task": self.task, "status": "in_progress", "nextTask": None})
checkpoint = self.file("tests/golden/WBV2/continuous-scheduler.json", '{"state":"sentinel"}\n')
before = checkpoint.read_bytes()
result = subprocess.run(["node", "tools/web/run-wbv2-scheduler.mjs", "--preflight-only"], cwd=self.root, capture_output=True, text=True, timeout=10)
self.assertEqual(2, result.returncode, result.stderr)
self.assertIn("implementation_entrypoint_missing", result.stdout)
self.assertEqual(before, checkpoint.read_bytes())
self.assertFalse((self.root / "tests/golden/WBV2/continuous-scheduler.lock").exists())
def test_time_labels_are_beijing_minutes(self):
self.assertEqual("2026-09-08 10:10", minute_label("2026-09-08T02:10:59.999Z"))
self.assertEqual({"updatedAt": "2026-09-08 10:10", "durationSeconds": 0.04}, display_times({"updatedAt": "2026-09-08T02:10:59+00:00", "durationSeconds": 0.04}))
def test_quick_command_has_no_one_second_polling_floor(self):
script = self.file("quick.py", "import time\ntime.sleep(0.03)\n")
self.policy.update(heartbeatSeconds=5, monitorPollSeconds=0.25)
with patch.object(self.runner, "checkpoint", wraps=self.runner.checkpoint) as checkpoints, contextlib.redirect_stdout(io.StringIO()):
result = self.runner.run_process([sys.executable, str(script)], self.root, self.runner.directory / "quick.log", 5)
self.assertLess(result["durationSeconds"], 0.9)
self.assertEqual(2, checkpoints.call_count)
def model_script(self, source):
script = self.file("model_fixture.py", source)
self.policy.update(monitorPollSeconds=0.02, modelStartTimeoutSeconds=0.15, modelIdleTimeoutSeconds=0.15)
with contextlib.redirect_stdout(io.StringIO()):
return self.runner.run_process([sys.executable, str(script)], self.root, self.runner.directory / "model.log", 5, monitor_worker=True, phase="model_request")
def test_provider_error_before_actions_releases_process_early(self):
result = self.model_script("import time\nprint('{\"type\":\"turn.started\"}', flush=True)\nprint('{\"type\":\"error\",\"message\":\"Concurrency limit exceeded\"}', flush=True)\ntime.sleep(10)\n")
self.assertEqual("provider_unavailable", result["providerAbort"])
self.assertTrue(result["workerTrace"]["safeToRetry"])
self.assertLess(result["durationSeconds"], 1)
def test_model_start_without_progress_is_bounded(self):
result = self.model_script("import time\nprint('{\"type\":\"turn.started\"}', flush=True)\ntime.sleep(10)\n")
self.assertEqual("model_start_timeout", result["providerAbort"])
self.assertTrue(result["workerTrace"]["safeToRetry"])
def test_stalled_model_after_actions_requires_reconciliation(self):
result = self.model_script("import time\nprint('{\"type\":\"turn.started\"}', flush=True)\nprint('{\"type\":\"item.completed\",\"item\":{\"id\":\"edit\",\"type\":\"file_change\"}}', flush=True)\ntime.sleep(10)\n")
self.assertEqual("model_idle_timeout", result["providerAbort"])
self.assertFalse(result["workerTrace"]["safeToRetry"])
def test_active_tool_is_not_mistaken_for_model_idle(self):
result = self.model_script("import time\nprint('{\"type\":\"turn.started\"}', flush=True)\nprint('{\"type\":\"item.started\",\"item\":{\"id\":\"build\",\"type\":\"command_execution\",\"command\":\"cmake --build build\"}}', flush=True)\ntime.sleep(0.25)\nprint('{\"type\":\"item.completed\",\"item\":{\"id\":\"build\",\"type\":\"command_execution\",\"exit_code\":0}}', flush=True)\n")
self.assertIsNone(result["providerAbort"])
self.assertEqual(0, result["exitCode"])
self.assertEqual("build", result["workerTrace"]["items"][0]["phase"])
def test_provider_failures_have_separate_bounded_budget(self):
with patch.object(self.runner, "worker", side_effect=ProviderUnavailable("rate limit")), contextlib.redirect_stdout(io.StringIO()):
for failure in range(1, 5):
self.assertFalse(self.runner.attempt(self.context, self.job))
record = self.runner.state["jobs"][self.job["id"]]
self.assertEqual(0, record["attempts"])
self.assertEqual(failure, record["providerFailures"])
self.assertEqual("provider_blocked", self.runner.state["state"])
def test_provider_failure_with_source_changes_is_not_retried(self):
def changed(*args):
self.file("source.cpp", "unexpected change")
raise ProviderUnavailable("overload")
with patch.object(self.runner, "worker", side_effect=changed), contextlib.redirect_stdout(io.StringIO()):
self.assertFalse(self.runner.attempt(self.context, self.job))
self.assertEqual("needs_reconcile", self.runner.state["state"])
def test_jsonl_partial_reads_and_nested_tool_timing(self):
trace = WorkerTrace()
p = self.file("events.jsonl", '{"type":"turn.star')
trace.read(p, 0.1)
self.assertFalse(trace.safe_to_retry)
with p.open("a") as stream:
stream.write('ted"}\n')
trace.read(p, 0.2)
self.assertTrue(trace.safe_to_retry)
trace.feed({"type": "item.started", "item": {"id": "read", "type": "command_execution", "command": "rg pattern source.cpp"}}, 1)
trace.feed({"type": "item.completed", "item": {"id": "read", "type": "command_execution", "exit_code": 0}}, 1.4)
self.assertAlmostEqual(0.4, trace.items[0]["durationSeconds"])
self.assertEqual("reading", trace.items[0]["phase"])
event = {"event": "process.finished", "argv": ["codex", "exec"], "log": "model.log", "startedAt": "2026-09-08T02:10:00+00:00", "finishedAt": "2026-09-08T02:10:02+00:00", "durationSeconds": 2, "workerTrace": trace.snapshot(2)}
self.file(CONTROL + "/events.jsonl", json.dumps(event) + "\n")
report = timing_report(self.runner.directory)
self.assertAlmostEqual(2 / 60, report["totalMeasuredMinutes"], places=4)
self.assertEqual("2026-09-08 10:10", report["steps"][0]["start"])
def test_business_routes_and_native_source_are_mandatory(self):
run_dir = self.runner.directory / "runs/contract"
result = self.ready_result(run_dir)
result["routeIds"] = []
with self.assertRaisesRegex(ValueError, "business workflow"):
self.runner.validate_result(result, run_dir)
result["routeIds"] = ["operator:TEST"]
result["sourceReferences"] = ["source.cpp"]
with self.assertRaisesRegex(ValueError, "native Blender source"):
self.runner.validate_result(result, run_dir)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Read-only WBV2 migration progress dashboard.
The dashboard deliberately separates implementation, build, and verification;
build receipts never count as completed business behavior.
"""
from __future__ import annotations
import argparse, json, re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def read(path, default=None):
try: return json.loads((ROOT / path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): return default
def snapshot():
queue = (ROOT / "docs/EXECUTION_QUEUE.md").read_text(encoding="utf-8")
task = re.search(r"\| 当前任务 \| `([^`]+)`", queue)
parent = re.search(r"\| parent manifest \| `([^`]+)`", queue)
backlog = read("tests/golden/WBV2/web-product-backlog.json", {}) or {}
state = read("tests/golden/WBV2/autopilot/state.json", {}) or {}
assignments = read("tests/golden/WBV2/autopilot/local-task-assignments.json", {}) or {}
jobs = state.get("jobs", {})
tasks = backlog.get("tasks", [])
counts = {}
for item in tasks: counts[item.get("status", "unknown")] = counts.get(item.get("status", "unknown"), 0) + 1
needs = [a for a in assignments.get("assignments", []) if a.get("state") == "needs_implementation"]
builds = {k: v for k, v in jobs.items() if v.get("state") == "built_pending_verification"}
verified = [k for k, v in jobs.items() if v.get("state") == "verified"]
return {"task": task.group(1) if task else None, "parentManifest": parent.group(1) if parent else None,
"runner": {"state": state.get("state"), "pid": state.get("pid"), "activeJob": state.get("activeJob"),
"verificationTiming": state.get("verificationTiming")},
"backlog": {"total": len(tasks), "byStatus": counts, "needsImplementation": [x.get("id") for x in tasks if x.get("status") == "pending"]},
"business": {"needsImplementation": [x.get("job") for x in needs], "readyForValidation": len(builds), "verified": verified},
"builds": {"pendingVerification": sorted(builds), "stale": sum(1 for v in jobs.values() if v.get("state") == "stale")},
"recommendations": [f"实现并绑定: {x.get('job')}" for x in needs] or ["没有新的本地实现缺口;由代理优先完成自动验证"]}
def main():
parser = argparse.ArgumentParser(description="查看 Blender Web 移植真实进度")
parser.add_argument("--json", action="store_true", help="输出机器可读 JSON")
args = parser.parse_args()
data = snapshot()
if args.json:
print(json.dumps(data, ensure_ascii=False, indent=2)); return
print(f"WBV2 task: {data['task']} parent: {data['parentManifest']}")
print(f"Runner: {data['runner']['state']} pid={data['runner']['pid']} active={data['runner']['activeJob'] or '-'} verification={data['runner']['verificationTiming']}")
print("Backlog: " + ", ".join(f"{k}={v}" for k, v in sorted(data['backlog']['byStatus'].items())) + f" total={data['backlog']['total']}")
print(f"Business: needs_implementation={len(data['business']['needsImplementation'])} ready_for_validation={data['business']['readyForValidation']} verified={len(data['business']['verified'])}")
print(f"Builds: pending_verification={len(data['builds']['pendingVerification'])} stale={data['builds']['stale']}")
for item in data["recommendations"]: print(f"Next: {item}")
if __name__ == "__main__": main()

946
my-tools/wbv2_autopilot.py Normal file
View File

@@ -0,0 +1,946 @@
#!/usr/bin/env python3
"""Persistent, independently verified Blender Web migration supervisor."""
from __future__ import annotations
import argparse
from contextlib import contextmanager
import fcntl
import hashlib
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from wbv2_timing import WorkerTrace, command_phase, display_times, format_report, minute_label, timing_report
ROOT = Path(__file__).resolve().parents[1]
CONTROL = "tests/golden/WBV2/autopilot"
POLICY = "my-tools/wbv2_autopilot_policy.json"
REQUIRED = {"source_build", "typecheck", "lint", "chromium", "positive", "negative", "undo_redo", "save_reopen", "determinism"}
ACTIVE = {"implementing", "validating", "finalizing", "committing", "committed", "pushing", "pushed"}
class ProviderUnavailable(ValueError):
"""No agent action started before a bounded provider failure."""
def now():
return datetime.now(timezone.utc).isoformat()
def read_json(path, default=None):
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
if default is not None:
return default
raise
def write_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + f".tmp-{os.getpid()}")
with temporary.open("w", encoding="utf-8") as stream:
json.dump(value, stream, ensure_ascii=True, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
temporary.replace(path)
def sha(path):
if not path.is_file():
return None
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def contained(root, relative):
if not isinstance(relative, str) or not relative or Path(relative).is_absolute() or ".." in Path(relative).parts:
raise ValueError(f"invalid relative path: {relative!r}")
result = (root / relative).resolve()
if not result.is_relative_to(root.resolve()):
raise ValueError(f"path escapes repository: {relative}")
return result
def alive(pid):
if not isinstance(pid, int) or pid <= 0:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def identity(pid):
try:
return Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[19]
except (OSError, IndexError):
return None
class Lease:
def __init__(self, path, inherited=None):
self.path = path
self.inherited = inherited
self.transferred = False
def __enter__(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self.stream = os.fdopen(self.inherited, "a+") if self.inherited is not None else self.path.open("a+")
if os.fstat(self.stream.fileno()).st_ino != self.path.stat().st_ino:
self.stream.close()
raise RuntimeError("inherited lease does not match the owner lock")
try:
fcntl.flock(self.stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
self.stream.close()
raise RuntimeError("another Python autopilot owns the project") from None
self.stream.seek(0)
self.stream.truncate()
json.dump({"pid": os.getpid(), "identity": identity(os.getpid()), "startedAt": now()}, self.stream)
self.stream.flush()
return self
def __exit__(self, *_):
# Never unlink a flock inode while another process can hold it open.
if not self.transferred:
fcntl.flock(self.stream, fcntl.LOCK_UN)
self.stream.close()
class ProjectFence:
"""Share the existing scheduler's exclusive marker with Python runs."""
def __init__(self, root):
self.directory = root / "tests/golden/WBV2"
self.path = self.directory / "continuous-scheduler.lock"
def __enter__(self):
self.directory.mkdir(parents=True, exist_ok=True)
for name in ("continuous-scheduler.lock", "continuous-guardian.lock", "continuous-supervisor.lock", "web-product-migration.lock"):
owner = read_json(self.directory / name, {})
if alive(owner.get("pid")):
raise RuntimeError(f"another project runner is active: {name}")
try:
with self.path.open("x", encoding="utf-8") as stream:
json.dump({"pid": os.getpid(), "identity": identity(os.getpid()), "runtime": "python-autopilot", "startedAt": now()}, stream)
except FileExistsError:
raise RuntimeError("stale scheduler lock requires reconciliation before Python can claim it") from None
return self
def __exit__(self, *_):
owner = read_json(self.path, {})
if owner.get("pid") == os.getpid() and owner.get("runtime") == "python-autopilot":
self.path.unlink()
def queue_context(root):
queue = (root / "docs/EXECUTION_QUEUE.md").read_text(encoding="utf-8")
task = re.search(r"\| 当前任务 \| `([^`]+)` \|", queue)
parent = re.search(r"\| parent manifest \| `([^`]+)` \|", queue)
if not task or not parent or not re.fullmatch(r"WBV2-P\d+-\d+", task[1]):
raise ValueError("authoritative queue pointer is malformed")
manifest = read_json(contained(root, parent[1]))
if manifest.get("status") != "done" or manifest.get("nextTask") != task[1]:
raise ValueError("parent manifest does not authorize the current task")
card = f"docs/tasks/{task[1]}.md"
if not (root / card).is_file():
raise ValueError("current task card is missing")
return {"task": task[1], "parentManifest": parent[1], "card": card, "parentSha256": sha(root / parent[1])}
def manifest_proof(root, task):
manifest = read_json(contained(root, f"tests/golden/{task}/manifest.json"), {})
reasons = []
if manifest.get("task") != task or manifest.get("status") != "done":
reasons.append("task_not_done")
if manifest.get("verificationDeferred") or manifest.get("acceptanceComplete") is not True:
reasons.append("runtime_acceptance_not_complete")
artifacts = manifest.get("artifacts", {})
if not isinstance(artifacts, dict) or not artifacts:
reasons.append("artifacts_missing")
else:
for name, item in artifacts.items():
try:
if not item.get("sha256") or sha(contained(root, item["path"])) != item["sha256"]:
reasons.append(f"artifact_drift:{name}")
except (KeyError, ValueError, AttributeError):
reasons.append(f"artifact_invalid:{name}")
return {"ok": not reasons, "reasons": reasons, "nextTask": manifest.get("nextTask")}
def inventory_proof(root):
inventory = read_json(root / "tests/golden/WBV2/dynamic-inventory.json")
units = inventory.get("units", [])
unverified = sum(unit.get("coverageStatus") != "VERIFIED" for unit in units)
invalid, checked = 0, {}
for unit in units:
if unit.get("coverageStatus") != "VERIFIED":
continue
matches = True
for key in ("evidenceManifest", "productionEvidence"):
item = unit.get("coverage", {}).get(key, {})
try:
signature = (item["path"], item["sha256"])
if signature not in checked:
checked[signature] = bool(item["sha256"]) and sha(contained(root, item["path"])) == item["sha256"]
matches = matches and checked[signature]
except (KeyError, ValueError):
matches = False
invalid += not matches
return {"ok": bool(units) and unverified == 0 and invalid == 0, "unitCount": len(units), "recordedVerified": len(units) - unverified, "unverifiedUnits": unverified, "unitsWithInvalidReferences": invalid}
def result_schema():
strings = {"type": "array", "items": {"type": "string"}}
def obj(properties):
return {"type": "object", "additionalProperties": False, "required": list(properties), "properties": properties}
check = obj({"id": {"type": "string"}, "kinds": strings, "argv": strings, "cwd": {"type": "string"}})
case = obj({"id": {"type": "string"}, "kind": {"type": "string"}, "checkId": {"type": "string"}, "receipt": {"type": "string"}, "unitIds": strings})
return obj({"status": {"type": "string", "enum": ["ready", "blocked"]}, "summary": {"type": "string"}, "workflow": {"type": "string"}, "routeIds": strings, "sourceReferences": strings, "changedFiles": strings, "sourceUnits": strings, "dependencies": strings, "artifacts": strings, "checks": {"type": "array", "items": check}, "cases": {"type": "array", "items": case}})
def validate_command(root, argv, cwd):
if not argv or not all(isinstance(v, str) and v and "\0" not in v for v in argv):
raise ValueError("command must be an argv array")
directory = root if cwd == "." else contained(root, cwd)
if not directory.is_dir():
raise ValueError("command cwd does not exist")
if argv[0] not in {"node", "python3", "npm", "cmake", "ninja", "ctest", "git"}:
raise ValueError("unsupported validation executable")
if any(arg in {"-e", "--eval", "-c", "--command", "--input-type=module"} for arg in argv[1:]):
raise ValueError("inline code is not a validation entrypoint")
if any(re.search(r"firefox|webkit", arg, re.I) for arg in argv):
raise ValueError("only Chromium is permitted")
if argv[0] == "git" and argv != ["git", "diff", "--check"]:
raise ValueError("validation cannot change Git state")
if argv[0] in {"node", "python3"}:
scripts = [arg for arg in argv[1:] if arg.endswith((".mjs", ".js", ".py"))]
if not scripts or any(not (directory / p).resolve().is_relative_to(root) or not (directory / p).is_file() for p in scripts):
raise ValueError("a concrete repository validation script is required")
if argv[0] == "npm" and "run" not in argv:
raise ValueError("only repository npm run scripts are permitted")
return argv, directory
class Autopilot:
def __init__(self, root=ROOT, policy=None):
self.root = root.resolve()
self.directory = self.root / CONTROL
self.policy = policy or read_json(self.root / POLICY)
if self.policy.get("sourceFirst") is not True:
raise ValueError("source-first business workflow policy is mandatory")
if not REQUIRED.issubset(self.policy.get("requiredChecks", [])):
raise ValueError("policy cannot omit mandatory behavioral gates")
for key in ("workerTimeoutSeconds", "checkTimeoutSeconds", "heartbeatSeconds", "maxAttempts", "maxTokensPerRun", "maxLogBytes", "maxBuildJobs", "monitorPollSeconds", "modelStartTimeoutSeconds", "modelIdleTimeoutSeconds", "providerBackoffSeconds", "providerMaxBackoffSeconds", "maxProviderFailures"):
if not isinstance(self.policy.get(key), (float, int)) or self.policy[key] <= 0:
raise ValueError(f"invalid positive policy limit: {key}")
self.state_path = self.directory / "state.json"
self.state = read_json(self.state_path, {"schemaVersion": 1, "state": "idle", "jobs": {}, "tokensUsed": 0})
self.stop_requested = False
self.child = None
self.run_tokens = 0
def checkpoint(self, state, **values):
self.state.update(state=state, updatedAt=now(), pid=os.getpid(), identity=identity(os.getpid()), **values)
write_json(self.state_path, self.state)
write_json(self.root / "tests/golden/WBV2/web-product-migration-status.json", {"schemaVersion": 2, "pid": os.getpid(), "status": state, "updatedAt": now(), "activeTask": self.state.get("activeJob"), "checkpoint": str(self.state_path.relative_to(self.root)), "verifiedJobs": sum(j.get("state") == "verified" for j in self.state["jobs"].values()), "reason": self.state.get("reason")})
def event(self, kind, **values):
self.directory.mkdir(parents=True, exist_ok=True)
record = {"time": now(), "event": kind, **values}
line = json.dumps(record, ensure_ascii=True)
with (self.directory / "events.jsonl").open("a", encoding="utf-8") as stream:
stream.write(line + "\n")
print(json.dumps(display_times({**record, "time": minute_label(record["time"])}), ensure_ascii=True), flush=True)
@contextmanager
def timed(self, phase):
started, wall = time.monotonic(), now()
try:
yield
finally:
self.event("timing.span", phase=phase, startedAt=wall, finishedAt=now(), durationSeconds=round(time.monotonic() - started, 6))
def request_stop(self, *_):
self.stop_requested = True
if self.child and self.child.poll() is None:
try:
os.killpg(self.child.pid, signal.SIGTERM)
except ProcessLookupError:
pass
def run_process(self, argv, directory, log, timeout, stdin=None, env=None, monitor_worker=False, phase=None):
log.parent.mkdir(parents=True, exist_ok=True)
started = time.monotonic()
phase = phase or command_phase(argv)
result = {"argv": argv, "cwd": str(directory.relative_to(self.root)), "phase": phase, "startedAt": now(), "log": str(log.relative_to(self.root))}
trace = WorkerTrace() if monitor_worker else None
provider_abort = None
next_heartbeat = started + self.policy["heartbeatSeconds"]
last_provider_error = None
interrupted = timed_out = log_limit = False
self.event("process.started", **result)
with log.open("wb") as output:
self.child = subprocess.Popen(argv, cwd=directory, env={**os.environ, **(env or {})}, stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
self.checkpoint(self.state["state"], childPid=self.child.pid, childIdentity=identity(self.child.pid), currentProcess={**result, "status": "running", "elapsedSeconds": 0, "phase": "provider_wait" if trace else phase})
if stdin is not None:
try:
self.child.stdin.write(stdin.encode())
self.child.stdin.close()
except BrokenPipeError:
pass
while self.child.poll() is None:
elapsed = time.monotonic() - started
if trace:
trace.read(log, elapsed)
if trace.provider_error and not trace.actions_seen:
provider_abort = "provider_unavailable"
elif not trace.actions_seen and elapsed >= self.policy.get("modelStartTimeoutSeconds", 60):
provider_abort = "model_start_timeout"
elif trace.actions_seen and not trace.active_items and elapsed - trace.last_progress >= self.policy.get("modelIdleTimeoutSeconds", 180):
provider_abort = "model_idle_timeout"
interrupted = self.stop_requested or (self.directory / "pause.request").exists()
timed_out = elapsed > timeout
log_limit = log.stat().st_size > self.policy["maxLogBytes"]
if interrupted or timed_out or log_limit or provider_abort:
try:
os.killpg(self.child.pid, signal.SIGTERM)
self.child.wait(timeout=5)
except ProcessLookupError:
pass
except subprocess.TimeoutExpired:
os.killpg(self.child.pid, signal.SIGKILL)
self.child.wait()
break
changed_error = trace and trace.provider_error != last_provider_error
if time.monotonic() >= next_heartbeat or changed_error:
current = {**result, "status": "running", "elapsedSeconds": round(elapsed, 3)}
if trace:
current.update(phase="provider_wait" if not trace.actions_seen else "agent_work", providerError=trace.provider_error, firstActionSeconds=trace.first_action, idleSeconds=round(elapsed - trace.last_progress, 3), activeItems=list(trace.active_items.values()))
last_provider_error = trace.provider_error
self.checkpoint(self.state["state"], heartbeatAt=now(), currentProcess=current)
next_heartbeat = time.monotonic() + self.policy["heartbeatSeconds"]
wait_for = max(0.001, min(self.policy.get("monitorPollSeconds", 0.25), next_heartbeat - time.monotonic(), timeout - elapsed))
try:
self.child.wait(timeout=wait_for)
except subprocess.TimeoutExpired:
pass
code = self.child.wait()
interrupted = interrupted or self.stop_requested or (self.directory / "pause.request").exists()
if trace:
trace.read(log, time.monotonic() - started)
try:
os.killpg(self.child.pid, signal.SIGTERM)
time.sleep(0.1)
os.killpg(self.child.pid, signal.SIGKILL)
except ProcessLookupError:
pass
self.child = None
elapsed = time.monotonic() - started
result.update(exitCode=code, durationSeconds=round(elapsed, 6), interrupted=interrupted, timedOut=timed_out, logLimit=log_limit, providerAbort=provider_abort, logSha256=sha(log), finishedAt=now())
if trace:
result["workerTrace"] = trace.snapshot(elapsed)
self.state.pop("childPid", None)
self.state.pop("childIdentity", None)
write_json(log.with_suffix(log.suffix + ".process.json"), result)
self.event("process.finished", **result)
self.checkpoint(self.state["state"], currentProcess={"phase": phase, "startedAt": result["startedAt"], "finishedAt": result["finishedAt"], "status": "finished", "durationSeconds": result["durationSeconds"], "providerAbort": provider_abort})
return result
def protected_hashes(self, context):
files = ["docs/EXECUTION_QUEUE.md", "nextTask.md", context["parentManifest"], "tests/golden/WBV2/web-product-backlog.json", "tests/golden/WBV2/dynamic-inventory.json", "tests/golden/WBV2/task-index.json", "tests/golden/WBV2-P0-002/parity-map.json", "my-tools/wbv2_autopilot.py", POLICY, f"tests/golden/{context['task']}/manifest.json"]
inventory = read_json(self.root / "tests/golden/WBV2/dynamic-inventory.json", {})
for unit in inventory.get("units", []):
for key in ("evidenceManifest", "productionEvidence"):
artifact = unit.get("coverage", {}).get(key)
if artifact:
files.append(artifact["path"])
files.append("my-tools/wbv2_timing.py")
with self.timed("evidence_hashing"):
return {p: sha(self.root / p) for p in sorted(set(files))}
def jobs(self, context):
backlog = read_json(self.root / "tests/golden/WBV2/web-product-backlog.json", {})
jobs = [dict(j) for j in backlog.get("tasks", [])] if backlog.get("queueTask") == context["task"] else [{"id": context["task"], "title": "Implement the current source-backed module", "ownedPaths": [], "implementation": "Follow the current module card and its source/case contract."}]
priority = self.policy.get("priority", [])
jobs.sort(key=lambda j: priority.index(j["id"]) if j["id"] in priority else len(priority))
for job in jobs:
if not re.fullmatch(r"[A-Z0-9_-]+", job["id"]):
raise ValueError("unsafe job identifier")
job["queueTask"] = context["task"]
return jobs
def receipt_valid(self, record):
if record.get("state") != "verified" or not record.get("receipt"):
return False
try:
receipt = read_json(contained(self.root, record["receipt"]))
if sha(self.root / record["receipt"]) != record.get("receiptSha256"):
return False
return all(sha(contained(self.root, p)) == digest for p, digest in receipt["inputs"].items()) and all(sha(contained(self.root, a["path"])) == a["sha256"] for a in receipt["artifacts"])
except (OSError, KeyError, ValueError):
return False
def choose_job(self, jobs):
# Complete untouched workflows before revisiting shared-file changes.
for job in jobs:
if self.state["jobs"].get(job["id"], {}).get("state") != "verified":
return job
return next((job for job in jobs if not self.receipt_valid(self.state["jobs"][job["id"]])), None)
def worker_prompt(self, context, job, run_dir, previous):
receipt_dir = str((run_dir / "cases").relative_to(self.root))
return f"""Implement one bounded Blender Web workflow in {self.root}.
Queue: {json.dumps(context, ensure_ascii=True)}
Workflow inside this task: {json.dumps(job, ensure_ascii=True)}
Previous attempt: {json.dumps(previous, ensure_ascii=True)[:4000]}
Known findings: {json.dumps(self.policy.get('knownFindings', []), ensure_ascii=True)}
Follow AGENTS.md: read the queue, print-task-context, check-task-context, then only named owned paths and bounded source searches.
Drive this task by its business module, user workflow and concrete function routes, then the exact source/operator case matrix.
FIRST search the supplied Blender 5.2 sources and task-scoped related references to resolve behavior before editing the Web implementation.
Use node tools/web/search-wbv2-batch-source.mjs --unit <source-unit-id> --pattern <symbol> to read bounded native source locations.
Blender 5.2 source/Main is authoritative. Never simulate Blender in JS or count route classification, timing, builds or bookkeeping as completed behavior.
Preserve dirty files. Reconcile previous edits before changing code. Never replay an unknown generator or external write.
Implement production code and executable focused tests, adding only directly related helpers/tests.
Do not modify queue, parent manifests, nextTask.md, backlog, historical evidence, source inventory or autopilot control files.
Do not commit, push, SSH, deploy, install services, start agents or change the live website.
Do not run expensive validation yourself: the independent supervisor executes your checks after your process exits successfully.
Return ready only when implementation and runnable tests are prepared; ready is NOT completion.
Checks use argv arrays and repo-relative cwd, with no shell or inline code and only Chromium.
Required check kinds: {', '.join(sorted(REQUIRED))}. Reuse existing build/test entrypoints; one command may cover multiple kinds.
Tests must prove positive Main mutation or exact Main reader, negative no-mutation, undo/redo, save/reopen and determinism.
Declare cases for positive, negative, undo_redo, save_reopen and determinism, bound to checkId and exact source inventory unitIds.
Tests must write fresh case receipts under {receipt_dir}/ at validation time using WBV2_RECEIPT_DIR. Do NOT write these receipts now.
Case JSON: status=PASS, runtime=CHROMIUM_WEBENGINE or BLENDER_NATIVE, caseId, kind, unitIds, observations, artifacts=[{{path,sha256}}].
observations use SHA-256 of actual canonical native Main snapshots: positive before/after differ, or reader expected/actual equal with reader=true; negative before/after equal; undo_redo before/after/undo/redo prove reversal; save_reopen saved/reopened equal; determinism first/second equal. At least one case must run in Chromium.
Use actual native/Chromium receipts, screenshots, .blend or WASM artifacts. Test assertions must verify behavior, not static markers.
List source/protocol/test/fixture/build dependencies for hash invalidation and compiled WASM/native paths in artifacts.
Return workflow (the concrete user workflow), routeIds (exact current route catalog IDs) and sourceReferences (native Blender files actually used).
sourceUnits must be existing dynamic inventory IDs. Do not claim verified coverage; the supervisor checks and hashes results.
Return blocked with a concrete reason if prerequisites cannot be established.
"""
def worker(self, prompt, run_dir):
schema_path, output_path = run_dir / "result.schema.json", run_dir / "worker-result.json"
write_json(schema_path, result_schema())
command = ["codex", "exec", "--sandbox", self.policy["sandbox"], "-c", 'approval_policy="never"', "--ephemeral", "--cd", str(self.root), "--json", "--output-schema", str(schema_path), "--output-last-message", str(output_path), "-"]
proc = self.run_process(command, self.root, run_dir / "worker.jsonl", self.policy["workerTimeoutSeconds"], stdin=prompt, monitor_worker=True, phase="handoff_model" if self.state["state"] == "finalizing" else "model_request")
completed = False
for line in (run_dir / "worker.jsonl").read_text(encoding="utf-8", errors="replace").splitlines():
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") == "turn.completed":
completed = True
usage = event.get("usage", {})
tokens = int(usage.get("input_tokens", 0)) + int(usage.get("output_tokens", 0))
self.run_tokens += tokens
self.state["tokensUsed"] = self.state.get("tokensUsed", 0) + tokens
if proc["interrupted"] or proc["timedOut"] or proc["logLimit"]:
self.checkpoint("needs_reconcile", reason="worker interrupted; inspect edits and process receipt before resuming")
raise InterruptedError(self.state["reason"])
trace = proc.get("workerTrace", {})
if proc.get("providerAbort") or trace.get("providerError"):
if trace.get("safeToRetry"):
raise ProviderUnavailable(trace.get("providerError") or proc["providerAbort"])
if proc.get("providerAbort"):
raise InterruptedError("model stalled after actions or unrecognized output; inspect the bounded trace before retry")
if proc["exitCode"] != 0 or not completed:
raise ValueError(f"worker failed: exit={proc['exitCode']}, turnCompleted={completed}")
return read_json(output_path)
def validate_result(self, result, run_dir):
if result.get("status") != "ready":
raise ValueError(result.get("summary", "worker reported blocked"))
known = {u["id"] for u in read_json(self.root / "tests/golden/WBV2/dynamic-inventory.json")["units"]}
units = set(result.get("sourceUnits", []))
if not units or not units.issubset(known):
raise ValueError("missing or unknown source unit bindings")
catalog = read_json(self.root / "web/app/src/capabilities/blender-feature-route-catalog.json")
route_ids = set(result.get("routeIds", []))
if not result.get("workflow", "").strip() or not route_ids or not route_ids.issubset({entry["id"] for entry in catalog["entries"]}) or units != route_ids:
raise ValueError("a concrete business workflow and source-bound function routes are required")
references = result.get("sourceReferences", [])
if not references or any(not p.startswith("blender-5.2.0/source/blender/") or not contained(self.root, p).is_file() for p in references):
raise ValueError("native Blender source references are required before Web implementation")
kinds, ids = set(), {}
for check in result.get("checks", []):
if not re.fullmatch(r"[A-Za-z0-9_-]+", check["id"]) or check["id"] in ids:
raise ValueError("check IDs must be unique and path-safe")
ids[check["id"]] = check
kinds.update(check["kinds"])
validate_command(self.root, check["argv"], check["cwd"])
if not REQUIRED.issubset(kinds):
raise ValueError(f"missing validation kinds: {sorted(REQUIRED - kinds)}")
case_kinds, case_ids, paths, covered = set(), set(), set(), set()
positive_units = set()
for case in result.get("cases", []):
destination = contained(self.root, case["receipt"])
if not destination.is_relative_to(run_dir / "cases") or destination.exists():
raise ValueError("behavioral receipts must be fresh validation output in this attempt")
if case["checkId"] not in ids or case["kind"] not in ids[case["checkId"]]["kinds"] or case["id"] in case_ids or str(destination) in paths:
raise ValueError("invalid or duplicate case/check binding")
case_ids.add(case["id"])
paths.add(str(destination))
case_kinds.add(case["kind"])
if not case["unitIds"] or not set(case["unitIds"]).issubset(units):
raise ValueError("invalid source-unit case binding")
covered.update(case["unitIds"])
if case["kind"] == "positive":
positive_units.update(case["unitIds"])
if not {"positive", "negative", "undo_redo", "save_reopen", "determinism"}.issubset(case_kinds) or covered != units or positive_units != units:
raise ValueError("incomplete behavioral case matrix")
for p in result.get("changedFiles", []) + result.get("dependencies", []) + result.get("artifacts", []):
contained(self.root, p)
if not any(p.endswith((".wasm", "/blender", "/web_engine")) for p in result.get("artifacts", [])):
raise ValueError("a compiled Blender/WASM artifact is required")
def verify_cases(self, result):
artifacts = []
chromium = False
for case in result["cases"]:
p = contained(self.root, case["receipt"])
receipt = read_json(p)
if receipt.get("status") != "PASS" or receipt.get("runtime") not in {"CHROMIUM_WEBENGINE", "BLENDER_NATIVE"}:
raise ValueError(f"runtime case failed: {case['id']}")
if receipt.get("caseId") != case["id"] or receipt.get("kind") != case["kind"] or set(receipt.get("unitIds", [])) != set(case["unitIds"]):
raise ValueError("runtime receipt does not match the requested case")
chromium = chromium or receipt["runtime"] == "CHROMIUM_WEBENGINE"
observations = receipt.get("observations", {})
def hashes(*keys):
values = [observations.get(key, "") for key in keys]
if not all(isinstance(v, str) and re.fullmatch(r"[a-f0-9]{64}", v) for v in values):
raise ValueError(f"missing native state hashes: {case['id']}")
return values
kind = case["kind"]
if kind == "positive" and observations.get("reader"):
left, right = hashes("expected", "actual")
valid = left == right
elif kind in {"positive", "negative"}:
left, right = hashes("before", "after")
valid = (left != right) if kind == "positive" else (left == right)
elif kind == "undo_redo":
before, after, undo, redo = hashes("before", "after", "undo", "redo")
valid = before != after and before == undo and after == redo
elif kind == "save_reopen":
saved, reopened = hashes("saved", "reopened")
valid = saved == reopened
elif kind == "determinism":
first, second = hashes("first", "second")
valid = first == second
else:
valid = False
if not valid:
raise ValueError(f"native behavior assertion failed: {case['id']}")
if not receipt.get("artifacts"):
raise ValueError("runtime case has no production artifacts")
for artifact in receipt["artifacts"]:
if not artifact.get("sha256") or sha(contained(self.root, artifact["path"])) != artifact["sha256"]:
raise ValueError(f"runtime artifact hash drift: {case['id']}")
artifacts.append(artifact)
artifacts.append({"path": case["receipt"], "sha256": sha(p)})
if not chromium:
raise ValueError("no Chromium runtime case receipt")
return artifacts
def attempt(self, context, job):
previous = self.state["jobs"].get(job["id"], {})
count = int(previous.get("attempts", 0)) + 1
run_dir = self.directory / "runs" / f"{job['id']}-{uuid.uuid4().hex[:12]}"
run_dir.mkdir(parents=True)
record = {"state": "implementing", "attempts": count, "providerFailures": previous.get("providerFailures", 0), "runDirectory": str(run_dir.relative_to(self.root))}
self.state["jobs"][job["id"]] = record
protected = self.protected_hashes(context)
with self.timed("source_fingerprint"):
owned_before = {p: sha(contained(self.root, p)) for p in job.get("ownedPaths", [])}
write_json(run_dir / "before-worker.json", {"protected": protected, "owned": owned_before})
self.checkpoint("implementing", activeJob=job["id"], queue=context, reason=None)
try:
result = self.worker(self.worker_prompt(context, job, run_dir, previous), run_dir)
if self.protected_hashes(context) != protected:
raise InterruptedError("worker changed protected queue/evidence files")
self.validate_result(result, run_dir)
inputs = sorted(set(job.get("ownedPaths", []) + result["changedFiles"] + result["dependencies"] + result["sourceReferences"] + [context["card"], context["parentManifest"], POLICY]))
before = {p: sha(contained(self.root, p)) for p in inputs}
if any(value is None for value in before.values()):
raise ValueError("declared dependency does not exist")
record["state"] = "validating"
self.checkpoint("validating")
checks, cache = [], {}
env = {"WBV2_RECEIPT_DIR": str(run_dir / "cases"), "WBV2_DEPLOYED_ORIGIN": self.policy["origin"], "WEB_BUILD_JOBS": str(min(self.policy["maxBuildJobs"], 8)), "CMAKE_BUILD_PARALLEL_LEVEL": str(min(self.policy["maxBuildJobs"], 8))}
for check in result["checks"]:
argv, cwd = validate_command(self.root, check["argv"], check["cwd"])
key = json.dumps([argv, str(cwd)])
if key not in cache:
cache[key] = self.run_process(argv, cwd, run_dir / f"check-{check['id']}.log", self.policy["checkTimeoutSeconds"], env=env, phase="validation:" + "+".join(sorted(check["kinds"])))
else:
self.event("check.reused", checkId=check["id"], originalLog=cache[key]["log"], avoidedSeconds=cache[key]["durationSeconds"])
receipt = {**cache[key], "id": check["id"], "kinds": check["kinds"]}
checks.append(receipt)
if receipt["exitCode"] != 0 or receipt["interrupted"] or receipt["timedOut"] or receipt["logLimit"]:
raise ValueError(f"check {check['id']} failed: exit={receipt['exitCode']}, timeout={receipt['timedOut']}")
if self.protected_hashes(context) != protected or any(sha(self.root / p) != digest for p, digest in before.items()):
raise InterruptedError("source/protected evidence changed during validation")
artifacts = self.verify_cases(result)
for p in result["artifacts"]:
digest = sha(contained(self.root, p))
if digest is None:
raise ValueError(f"compiled artifact missing: {p}")
artifacts.append({"path": p, "sha256": digest})
for check in checks:
artifacts.append({"path": check["log"], "sha256": check["logSha256"]})
receipt_path = run_dir / "verified-receipt.json"
write_json(receipt_path, {"schemaVersion": 1, "queueTask": context["task"], "job": job["id"], "workflow": result["workflow"], "routeIds": result["routeIds"], "sourceReferences": result["sourceReferences"], "status": "verified", "verifiedAt": now(), "sourceUnits": result["sourceUnits"], "inputs": before, "checks": checks, "artifacts": artifacts, "coverageIncrementClaimed": 0, "summary": result["summary"]})
record.update(state="verified", receipt=str(receipt_path.relative_to(self.root)), receiptSha256=sha(receipt_path), changedFiles=result["changedFiles"], attempts=0, providerFailures=0)
self.checkpoint("ready", activeJob=None)
self.event("job.verified", job=job["id"], receipt=record["receipt"])
return True
except ProviderUnavailable as error:
if self.protected_hashes(context) != protected or any(sha(self.root / p) != value for p, value in owned_before.items()):
record.update(state="needs_reconcile", reason="provider failure coincided with source/evidence changes")
self.checkpoint("needs_reconcile", reason=record["reason"])
return False
failures = int(previous.get("providerFailures", 0)) + 1
delay = min(self.policy["providerMaxBackoffSeconds"], self.policy["providerBackoffSeconds"] * 2 ** min(failures - 1, 20))
retry_at = datetime.fromtimestamp(time.time() + delay, timezone.utc).isoformat()
state = "provider_blocked" if failures >= self.policy["maxProviderFailures"] else "waiting_provider"
record.update(state=state, attempts=int(previous.get("attempts", 0)), providerFailures=failures, reason=str(error), nextRetryAt=retry_at)
self.checkpoint(state, reason=str(error), nextRetryAt=retry_at, providerFailures=failures)
self.event("provider.unavailable", job=job["id"], failures=failures, nextRetryAt=retry_at, reason=str(error), noAgentActions=True)
return False
except InterruptedError as error:
record.update(state="needs_reconcile", reason=str(error))
self.checkpoint("needs_reconcile", reason=str(error))
return False
except (ValueError, OSError, KeyError, TypeError) as error:
record.update(state="blocked" if count >= self.policy["maxAttempts"] else "retry_pending", reason=str(error))
self.checkpoint("paused" if self.stop_requested else record["state"], reason=str(error))
self.event("job.failed", job=job["id"], attempts=count, reason=str(error))
return False
def git(self, args):
log = self.directory / "git" / f"{uuid.uuid4().hex}.log"
result = self.run_process(["git", *args], self.root, log, 120)
if result["interrupted"] or result["timedOut"] or result["logLimit"]:
raise InterruptedError("Git command interrupted; reconcile its outcome before continuing")
output = log.read_text(encoding="utf-8", errors="surrogateescape")
if result["exitCode"] != 0:
raise subprocess.CalledProcessError(result["exitCode"], ["git", *args], output=output)
return output
def remember_baseline(self, refresh=False):
location = self.directory / "baseline.json"
if location.exists() and not refresh:
return
dirty = set(self.git(["diff", "--name-only", "-z", "HEAD"]).split("\0"))
dirty.update(self.git(["ls-files", "--others", "--exclude-standard", "-z"]).split("\0"))
write_json(location, {"head": self.git(["rev-parse", "HEAD"]).strip(), "dirtyPaths": sorted(dirty - {""}), "recordedAt": now()})
def publish(self, context, changed_files):
if not self.policy.get("autoCommit"):
self.checkpoint("ready_to_publish", reason="automatic commit disabled")
return False
baseline = read_json(self.directory / "baseline.json")
paths = sorted(set(changed_files))
if not paths:
raise ValueError("handoff has no declared files to commit")
for p in paths:
contained(self.root, p)
if p == ".git" or p.startswith(".git/"):
raise ValueError("cannot publish Git internals")
dirty_overlap = set(paths) & set(baseline["dirtyPaths"])
if dirty_overlap:
raise ValueError(f"publication would include pre-existing edits: {sorted(dirty_overlap)[:12]}")
if self.git(["branch", "--show-current"]).strip() != "main":
raise ValueError("automatic publication requires branch main")
if self.git(["rev-parse", "HEAD"]).strip() != baseline["head"]:
raise ValueError("HEAD changed since the supervisor baseline")
self.checkpoint("committing", publishPaths=paths)
self.git(["add", "-f", "--", *paths])
# --only excludes unrelated staged changes from this commit.
self.git(["commit", "--only", "-m", f"feat(web): verified workflow {context['task']}", "--", *paths])
head = self.git(["rev-parse", "HEAD"]).strip()
self.checkpoint("committed", commit=head)
if self.policy.get("autoPush"):
self.checkpoint("pushing")
pushed = False
for attempt in range(self.policy.get("pushRetries", 6) + 1):
result = self.run_process(["git", "push", "origin", "HEAD:refs/heads/main"], self.root, self.directory / f"push-{attempt}.log", 120)
try:
remote = self.git(["ls-remote", "--refs", "origin", "refs/heads/main"]).split()[0]
except (subprocess.CalledProcessError, IndexError):
remote = None
if remote == head:
pushed = True
self.checkpoint("pushed", remoteHead=remote)
break
if result["interrupted"] or self.stop_requested:
break
deadline = time.monotonic() + min(30, 2 ** attempt)
while time.monotonic() < deadline and not self.stop_requested:
time.sleep(0.2)
if not pushed:
raise InterruptedError("push outcome not confirmed; reconcile remote HEAD before continuing")
baseline["head"] = head
write_json(self.directory / "baseline.json", baseline)
return True
def finalize(self, context):
run_dir = self.directory / "runs" / f"handoff-{uuid.uuid4().hex[:12]}"
run_dir.mkdir(parents=True)
records = self.state["jobs"]
if not records or not all(self.receipt_valid(r) for r in records.values()):
raise ValueError("cannot finalize stale workflow receipts")
references = [{"path": r["receipt"], "sha256": r["receiptSha256"]} for r in records.values()]
self.checkpoint("finalizing")
prompt = f"""Finalize only the already verified Blender Web task {context['task']}.
Read the minimal queue/context/card first. Current pointer: {json.dumps(context)}.
Independent runtime receipts: {json.dumps(references)}.
Use repository generators to write aggregate report, status, manifest, task-context and publish the successor pointer.
Bind these verified receipts and their source/unit/build/test hashes. Set acceptanceComplete=true only if the module contract is fully met.
No source changes, tests, historical inventory/provenance rewrites, blind hash refresh, commit, push, SSH or production deployment.
Do not rewrite the parent manifest. Preserve dirty files. Never use deferred verification or route counts as completion.
If source inventory remains unverified and the current module is complete, use the repository generator to select the next source-backed module; do not invent task IDs or declare full parity.
Return structured status=ready and exact changedFiles; other array fields may be empty for this handoff-only turn.
If a required generator/evidence is unavailable, return blocked with the exact cause. Do not advance the queue on failure.
"""
result = self.worker(prompt, run_dir)
if result.get("status") != "ready":
raise ValueError(result.get("summary", "handoff worker blocked"))
if sha(self.root / context["parentManifest"]) != context["parentSha256"]:
raise InterruptedError("handoff changed the parent manifest")
for record in records.values():
receipt = read_json(self.root / record["receipt"])
for p, digest in receipt["inputs"].items():
if p == context["card"]:
continue
if sha(self.root / p) != digest:
raise InterruptedError("handoff changed a verified implementation dependency")
for index, argv in enumerate(self.policy["handoffChecks"]):
process = self.run_process(argv, self.root, run_dir / f"gate-{index}.log", self.policy["checkTimeoutSeconds"])
if process["exitCode"] != 0 or process["interrupted"] or process["timedOut"] or process["logLimit"]:
raise ValueError(f"handoff gate failed: {argv}")
proof = manifest_proof(self.root, context["task"])
if not proof["ok"]:
raise ValueError(f"aggregate acceptance missing: {proof['reasons']}")
after = queue_context(self.root)
if proof["nextTask"] is not None and after["task"] != proof["nextTask"]:
raise ValueError("generator did not publish the authorized successor")
if proof["nextTask"] is None and after["task"] != context["task"]:
raise ValueError("terminal handoff changed the queue unexpectedly")
changed = list(result["changedFiles"])
for record in records.values():
changed.extend(record.get("changedFiles", []))
receipt = read_json(self.root / record["receipt"])
changed.append(record["receipt"])
changed.extend(a["path"] for a in receipt["artifacts"])
if not self.publish(context, changed):
return False
self.checkpoint("handed_off", before=context, after=after)
return True
def doctor(self):
context = queue_context(self.root)
jobs = self.jobs(context)
return {"schemaVersion": 1, "queue": context, "taskCompletion": manifest_proof(self.root, context["task"]), "inventory": inventory_proof(self.root), "jobCount": len(jobs), "nextJob": (self.choose_job(jobs) or {}).get("id"), "tools": {n: bool(shutil.which(n)) for n in ("python3", "node", "git", "codex", "cmake")}, "policy": POLICY, "deployment": "implementation workers do not alter the live server", "state": self.state.get("state")}
def wait_for_provider(self, record):
retry_at = record.get("nextRetryAt")
if not retry_at:
return
remaining = max(0, datetime.fromisoformat(retry_at).timestamp() - time.time())
if remaining == 0:
return
self.checkpoint("waiting_provider", nextRetryAt=retry_at, reason=record.get("reason"))
deadline, heartbeat = time.monotonic() + remaining, time.monotonic()
with self.timed("provider_backoff"):
while time.monotonic() < deadline and not self.stop_requested and not (self.directory / "pause.request").exists():
if time.monotonic() >= heartbeat:
self.checkpoint("waiting_provider", heartbeatAt=now())
heartbeat = time.monotonic() + self.policy["heartbeatSeconds"]
time.sleep(min(0.25, max(0, deadline - time.monotonic())))
def run(self, max_cycles=0):
inherited = os.environ.pop("WBV2_AUTOPILOT_LEASE_FD", None)
with Lease(self.directory / "owner.lock", int(inherited) if inherited else None), ProjectFence(self.root):
if self.state.get("state") in ACTIVE | {"needs_reconcile"}:
self.checkpoint("needs_reconcile", reason="previous writer has no completed handoff; run read-only reconcile")
return 3
if self.state.get("state") in {"blocked", "provider_blocked"}:
return 2
if (self.directory / "pause.request").exists():
self.checkpoint("paused")
return 0
signal.signal(signal.SIGINT, self.request_stop)
signal.signal(signal.SIGTERM, self.request_stop)
self.remember_baseline()
cycles = 0
last_success = True
self.checkpoint("preflight", reason=None)
try:
while not self.stop_requested and (max_cycles == 0 or cycles < max_cycles):
if self.run_tokens >= self.policy["maxTokensPerRun"]:
self.checkpoint("budget_exhausted", reason="per-run token budget reached")
return 4
if (self.directory / "pause.request").exists():
self.checkpoint("paused")
return 0
context = queue_context(self.root)
prior = self.state.get("queue")
if prior and prior["task"] != context["task"]:
proof = manifest_proof(self.root, prior["task"])
if not proof["ok"] or proof["nextTask"] != context["task"]:
raise ValueError("queue advanced without an accepted parent handoff")
self.state["jobs"] = {}
self.checkpoint("preflight", queue=context)
for index, argv in enumerate(self.policy["preflight"]):
proc = self.run_process(argv, self.root, self.directory / f"preflight-{index}.log", 120)
if proc["exitCode"] != 0 or proc["interrupted"] or proc["timedOut"] or proc["logLimit"]:
raise ValueError(f"context preflight failed: {argv}")
job = self.choose_job(self.jobs(context))
if job is None:
if not self.finalize(context):
return 2
proof = manifest_proof(self.root, context["task"])
if proof["nextTask"] is None:
inventory = inventory_proof(self.root)
if not inventory["ok"]:
self.checkpoint("planning_required", reason="no successor but source inventory remains unverified; parity not complete")
return 2
self.checkpoint("complete", reason="accepted terminal manifest and no unverified source units")
return 0
continue
record = self.state["jobs"].get(job["id"], {})
if record.get("state") in {"blocked", "provider_blocked"}:
self.checkpoint(record["state"], activeJob=job["id"], reason=record.get("reason"))
return 2
if record.get("state") == "waiting_provider":
self.wait_for_provider(record)
if self.stop_requested or (self.directory / "pause.request").exists():
self.checkpoint("paused")
return 2
success = self.attempt(context, job)
last_success = success
cycles += 1
if self.state["state"] in {"needs_reconcile", "blocked", "provider_blocked", "paused"}:
return 3 if self.state["state"] == "needs_reconcile" else 2
if not success and self.state["state"] != "waiting_provider":
with self.timed("retry_backoff"):
deadline = time.monotonic() + self.policy.get("retryDelaySeconds", 10)
while time.monotonic() < deadline and not self.stop_requested:
time.sleep(0.2)
final_state = "paused" if self.stop_requested else "idle" if last_success else self.state["state"]
self.checkpoint(final_state, reason="stop requested" if self.stop_requested else self.state.get("reason") or "cycle limit reached")
return 0 if last_success and not self.stop_requested else 2
except InterruptedError as error:
self.checkpoint("needs_reconcile", reason=str(error))
return 3
except (ValueError, OSError, KeyError, RuntimeError, subprocess.CalledProcessError) as error:
self.checkpoint("blocked", reason=str(error))
self.event("supervisor.blocked", reason=str(error))
return 2
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["run", "start", "status", "timings", "doctor", "pause", "stop", "resume", "reconcile", "service"], nargs="?", default="run")
parser.add_argument("--once", action="store_true")
parser.add_argument("--max-cycles", type=int, default=0)
parser.add_argument("--retry-blocked", action="store_true")
parser.add_argument("--refresh-baseline", action="store_true", help="adopt a reviewed maintenance commit before any workflow edits/receipts")
parser.add_argument("--json", action="store_true", help="JSON timing report")
parser.add_argument("--write", action="store_true", help="write timing report snapshots")
args = parser.parse_args(argv)
if args.max_cycles < 0:
parser.error("--max-cycles must be non-negative")
runner = Autopilot()
directory = runner.directory
if args.action == "doctor":
print(json.dumps(runner.doctor(), ensure_ascii=False, indent=2))
return 0
if args.action == "status":
state = dict(runner.state)
state["alive"] = alive(state.get("pid")) and identity(state["pid"]) == state.get("identity")
state["timezone"] = "Asia/Shanghai"
print(json.dumps(display_times(state), ensure_ascii=False, indent=2))
return 0
if args.action == "timings":
report = timing_report(directory, runner.state)
if args.write:
write_json(directory / "timing-report.json", report)
(directory / "timing-report.md").write_text(format_report(report) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2) if args.json else format_report(report))
return 0
if args.action == "service":
def quote(value):
if "\n" in value or "\r" in value:
raise ValueError("newline in systemd value")
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%") + '"'
unit = Path.home() / ".config/systemd/user/blender-web-autopilot.service"
unit.parent.mkdir(parents=True, exist_ok=True)
executable = Path(__file__).with_name("run_web_product_migration.py")
unit.write_text("[Unit]\nDescription=Blender Web source migration supervisor\nAfter=network-online.target\n\n[Service]\nType=simple\n" + f"WorkingDirectory={quote(str(ROOT))}\nExecStart={quote(sys.executable)} {quote(str(executable))} run\nEnvironment={quote('PATH=' + os.environ.get('PATH', '/usr/bin:/bin'))}\n" + "Restart=on-failure\nRestartPreventExitStatus=2 3 4\nRestartSec=10\nKillMode=control-group\nTimeoutStopSec=15\n\n[Install]\nWantedBy=default.target\n", encoding="utf-8")
print(f"Created {unit}; enable with systemctl --user daemon-reload and systemctl --user enable --now blender-web-autopilot")
return 0
if args.action in {"pause", "stop"}:
directory.mkdir(parents=True, exist_ok=True)
write_json(directory / "pause.request", {"requestedAt": now()})
owner = read_json(directory / "owner.lock", {})
if alive(owner.get("pid")) and identity(owner["pid"]) == owner.get("identity"):
os.kill(owner["pid"], signal.SIGTERM)
deadline = time.monotonic() + 10
while alive(owner["pid"]) and identity(owner["pid"]) == owner.get("identity") and time.monotonic() < deadline:
time.sleep(0.1)
print("Pause requested; interrupted work requires reconciliation before retry.")
return 0
if args.action == "reconcile":
print(json.dumps({"queue": queue_context(ROOT), "state": runner.state, "childAlive": alive(runner.state.get("childPid")), "receipts": {key: runner.receipt_valid(record) for key, record in runner.state["jobs"].items()}, "decision": "read-only; inspect interrupted edits before resume --retry-blocked"}, ensure_ascii=False, indent=2))
return 0
if args.action == "resume":
with Lease(directory / "owner.lock"):
if alive(runner.state.get("childPid")) and identity(runner.state["childPid"]) == runner.state.get("childIdentity"):
parser.error("previous child is alive; stop and reconcile first")
if runner.state["state"] in ACTIVE | {"needs_reconcile", "blocked", "provider_blocked"} and not args.retry_blocked:
parser.error("run reconcile; --retry-blocked acknowledges the inspected repository state")
for record in runner.state["jobs"].values():
if record.get("state") in ACTIVE | {"needs_reconcile", "blocked", "provider_blocked"} or (args.retry_blocked and record.get("state") != "verified"):
record.update(state="retry_pending", attempts=0, providerFailures=0)
record.pop("nextRetryAt", None)
if args.refresh_baseline and any(record.get("receipt") or record.get("changedFiles") for record in runner.state["jobs"].values()):
parser.error("cannot refresh a baseline containing workflow edits or verified receipts")
(directory / "pause.request").unlink(missing_ok=True)
if args.refresh_baseline:
runner.remember_baseline(refresh=True)
runner.checkpoint("idle", reason="explicit resume")
if args.action in {"start", "resume"}:
with Lease(directory / "owner.lock") as lease:
if (directory / "pause.request").exists():
parser.error("paused; use resume")
if runner.state["state"] in ACTIVE | {"needs_reconcile", "blocked", "provider_blocked"}:
parser.error("run reconcile before restarting interrupted or blocked work")
command = [sys.executable, str(Path(__file__).with_name("run_web_product_migration.py")), "run", "--max-cycles", str(1 if args.once else args.max_cycles)]
fd = lease.stream.fileno()
with (directory / "daemon.log").open("ab") as log:
child = subprocess.Popen(command, cwd=ROOT, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, pass_fds=(fd,), env={**os.environ, "WBV2_AUTOPILOT_LEASE_FD": str(fd)})
lease.transferred = True
print(json.dumps({"launchedPid": child.pid, "statusCommand": "python3 my-tools/run_web_product_migration.py status", "log": str(directory / "daemon.log")}))
return 0
return runner.run(1 if args.once else args.max_cycles)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"strategy": "business_module_and_function_route",
"sourceAuthority": "blender-5.2.0/source/blender",
"sourceFirst": true,
"origin": "https://49.232.250.35:58014",
"sandbox": "workspace-write",
"workerTimeoutSeconds": 1800,
"checkTimeoutSeconds": 1800,
"heartbeatSeconds": 5,
"monitorPollSeconds": 0.25,
"modelStartTimeoutSeconds": 60,
"modelIdleTimeoutSeconds": 180,
"providerBackoffSeconds": 30,
"providerMaxBackoffSeconds": 240,
"maxProviderFailures": 4,
"maxAttempts": 3,
"maxTokensPerRun": 500000,
"maxLogBytes": 33554432,
"maxBuildJobs": 8,
"retryDelaySeconds": 10,
"autoCommit": true,
"autoPush": true,
"pushRetries": 6,
"priority": ["SERVER-OPERATOR-HTTP-PROVIDER", "SERVER-OPERATOR-SCHEMA-PALETTE", "BROWSER-EQUIVALENT-RUNTIME"],
"requiredChecks": ["source_build", "typecheck", "lint", "chromium", "positive", "negative", "undo_redo", "save_reopen", "determinism"],
"preflight": [["node", "tools/web/check-task-context.mjs"], ["node", "tools/web/check-context-governance.mjs"]],
"handoffChecks": [
["node", "tools/web/check-task-context.mjs"],
["node", "tools/web/check-context-governance.mjs"],
["node", "tools/web/check-source-dynamic-inventory.mjs"],
["node", "tools/web/check-execution-control-plane.mjs"],
["node", "tools/web/check-task-index.mjs"],
["node", "tools/web/check-blender-next-task-plan.mjs"],
["git", "diff", "--check"]
],
"knownFindings": [
"The live /api/server-operator-job POST describe returns HTTP 405. Implement deployment integration and test a disposable local deployment; do not alter the live server during implementation.",
"SCREEN_OT_area_split switches Modeling to Layout without adding an editor. Implement the actual source-equivalent behavior, not a generic action-group alias.",
"Of 1025 recorded VERIFIED units, 902 have drifted direct evidence references. Reverify behavior and create current receipts; never rewrite historical hashes to silence a gate."
]
}

View File

@@ -0,0 +1,106 @@
"""Deterministic local task admission; never invokes a model or invents receipts."""
import copy
import hashlib
import json
from pathlib import Path
from wbv2_task_dispatch import prepared_contract
from wbv2_preparation import prepare_module
class LocalPlanner:
def __init__(self, root, directory):
self.root = Path(root).resolve()
self.directory = Path(directory)
def _path(self, relative):
path = (self.root / relative).resolve()
if not path.is_relative_to(self.root):
raise ValueError("planner path escapes repository")
return path
def _write(self, path, value):
from wbv2_autopilot import read_json, write_json
if path.is_file() and read_json(path) == value:
return False
write_json(path, value)
return True
def refresh(self, context, jobs, verified_jobs):
from wbv2_autopilot import read_json, sha
verified_jobs = set(verified_jobs)
backlog = read_json(self.root / "tests/golden/WBV2/web-product-backlog.json", {})
backlog_status = {item.get("id"): item.get("status") for item in backlog.get("tasks", []) if isinstance(item, dict)}
assignments = []
created = []
for job in jobs:
identifier = job["id"]
destination = self.directory / "local-plans" / (identifier + ".json")
candidate, admission_findings = prepared_contract(self.root, context, job)
existing = read_json(destination) if destination.is_file() else None
# A pending backlog item is a business gap even when an old local
# plan or build receipt exists. Do not let compilation history
# promote it to runnable work.
if backlog_status.get(identifier) == "pending":
preparation = prepare_module(self.root, self.directory, job)
assignments.append({"job": identifier, "module": job.get("module"),
"state": "needs_implementation",
"reason": "backlog task remains pending",
"ownedPaths": job.get("ownedPaths", []),
"admissionFindings": admission_findings,
"preparation": preparation})
continue
if candidate and (existing is None or existing.get("generator") == "local-contract-admission-v1"):
if self._write(destination, candidate):
created.append(identifier)
verified_jobs.discard(identifier)
if identifier in verified_jobs:
state, reason = "verified", None
elif destination.is_file():
state, reason = "ready", None
elif identifier == "SERVER-OPERATOR-SCHEMA-PALETTE":
prerequisite = "SERVER-OPERATOR-HTTP-PROVIDER"
test_path = "web/tests/e2e/server-operator-schema-palette.spec.ts"
base = self.directory / "local-plans" / (prerequisite + ".json")
if prerequisite not in verified_jobs or not base.is_file():
state, reason = "waiting_dependency", prerequisite
elif not self._path(test_path).is_file():
state, reason = "needs_test_entrypoint", test_path
else:
contract = read_json(base)
if contract["task"] != context["task"] or contract["parentSha256"] != context["parentSha256"]:
raise ValueError("local recipe parent mismatch")
result = copy.deepcopy(contract["result"])
result["workflow"] = "F3 selects a native operator, loads RNA properties, executes Add Object, and preserves native Main history"
result["summary"] = "Local UI/schema contract prepared; completion requires independent Chromium and native Main evidence"
for check in result["checks"]:
if check["id"] == "chromium-main":
check["id"] = "chromium-schema-palette"
check["argv"].append("tests/e2e/server-operator-schema-palette.spec.ts")
check["timeoutSeconds"] = 180
check["cacheInputs"] = sorted(set(check.get("cacheInputs", result["dependencies"]) + [test_path]))
for case in result["cases"]:
if case["checkId"] == "chromium-main":
case["checkId"] = "chromium-schema-palette"
result["dependencies"] = sorted(set(result["dependencies"] + [test_path, "my-tools/wbv2_local_planner.py"]))
result["changedFiles"] = [test_path]
candidate = {"schemaVersion": 1, "task": context["task"], "job": identifier, "parentSha256": context["parentSha256"], "generator": "schema-palette-v1", "sourceContract": {"path": str(base.relative_to(self.root)), "sha256": sha(base)}, "acceptance": "pending local behavior verification", "result": result}
if self._write(destination, candidate):
created.append(identifier)
state, reason = "ready", None
else:
state, reason = "needs_local_recipe", "executable checks and source-bound behavior cases are not defined"
if state == "ready":
dependencies = job.get("dependsOn", [])
if not isinstance(dependencies, list) or not all(isinstance(value, str) for value in dependencies):
state, reason = "invalid_dependencies", "task dependencies must be a list of job IDs"
elif any(value not in verified_jobs for value in dependencies):
state, reason = "waiting_dependency", ", ".join(value for value in dependencies if value not in verified_jobs)
preparation = prepare_module(self.root, self.directory, job) if state == "needs_local_recipe" else None
assignments.append({"job": identifier, "module": job.get("module"), "state": state, "reason": reason, "ownedPaths": job.get("ownedPaths", []), "admissionFindings": admission_findings, "preparation": preparation})
if (candidate or existing or {}).get("result", {}).get("contractKind") == "compilation_only":
assignments[-1]["contractKind"] = "compilation_only"
assignments[-1]["verificationDeferred"] = True
report = {"schemaVersion": 1, "task": context["task"], "parentSha256": context["parentSha256"], "executionMode": "local_only", "assignments": assignments, "readyCount": sum(item["state"] == "ready" for item in assignments), "verifiedCount": sum(item["state"] == "verified" for item in assignments), "blockedCount": sum(item["state"] not in {"ready", "verified"} for item in assignments)}
report["compilationReadyCount"] = sum(item["state"] == "ready" and item.get("contractKind") == "compilation_only" for item in assignments)
changed = self._write(self.directory / "local-task-assignments.json", report)
return {"created": created, "changed": changed, "readyCount": report["readyCount"], "blockedCount": report["blockedCount"]}

211
my-tools/wbv2_timing.py Normal file
View File

@@ -0,0 +1,211 @@
"""Incremental process telemetry and minute-labelled execution reports."""
from __future__ import annotations
import json
import re
import shlex
from collections import defaultdict
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
LOCAL_ZONE = ZoneInfo("Asia/Shanghai")
def minute_label(value):
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(LOCAL_ZONE).strftime("%Y-%m-%d %H:%M")
except (ValueError, TypeError):
return value
def display_times(value):
if isinstance(value, list):
return [display_times(item) for item in value]
if not isinstance(value, dict):
return value
return {key: minute_label(item) if key.endswith("At") and isinstance(item, str) else display_times(item) for key, item in value.items()}
def command_phase(argv):
if not argv:
return "other"
if argv[0] == "codex":
return "model_request"
if argv[0] == "git":
return "git_" + (argv[1] if len(argv) > 1 else "other")
if any("task-context" in arg or "context-governance" in arg for arg in argv):
return "context_check"
if argv[0] in {"cmake", "ninja"}:
return "build"
return "command"
def item_phase(item):
kind = item.get("type", "unknown")
if kind == "file_change":
return "editing"
if kind != "command_execution":
return "tool" if kind != "agent_message" else "model_response"
try:
argv = shlex.split(item.get("command", ""))
if len(argv) >= 3 and argv[0].endswith(("bash", "sh")) and argv[1] in {"-lc", "-c"}:
argv = shlex.split(argv[2])
except ValueError:
return "command"
if not argv:
return "command"
if argv[0] in {"cat", "sed", "rg", "ls", "head", "tail", "wc", "find", "stat"} or any("search-wbv2" in arg or "print-task-context" in arg for arg in argv):
return "reading"
if "apply_patch" in argv[0]:
return "editing"
if any("test" in arg or "lint" in arg or "typecheck" in arg for arg in argv):
return "testing"
return command_phase(argv)
class WorkerTrace:
"""Times observed JSONL events; never infers model CPU time from silence."""
def __init__(self):
self.offset = 0
self.pending = b""
self.turn_started = False
self.actions_seen = False
self.unknown_output = False
self.first_event = None
self.first_action = None
self.last_progress = 0.0
self.last_output = 0.0
self.provider_error = None
self.errors = 0
self.active_items = {}
self.items = []
self.counts = defaultdict(int)
def feed(self, event, elapsed):
kind = event.get("type", "unknown")
if not isinstance(kind, str):
self.unknown_output = True
return
self.counts[kind] += 1
self.first_event = elapsed if self.first_event is None else self.first_event
if kind == "turn.started":
self.turn_started = True
if kind in {"error", "turn.failed"}:
self.errors += 1
error = event.get("error")
message = event.get("message") or (error.get("message", "") if isinstance(error, dict) else error) or ""
if not isinstance(message, str):
message = str(message)
if re.search(r"concurrency limit|overload|rate.?limit|too many requests|\b429\b|\b503\b|service unavailable", message, re.I):
self.provider_error = re.sub(r"sk-[A-Za-z0-9_-]+", "[redacted]", message)[:600]
if kind.startswith("item."):
self.actions_seen = True
self.first_action = elapsed if self.first_action is None else self.first_action
self.last_progress = elapsed
item = event.get("item") or {}
if not isinstance(item, dict):
self.unknown_output = True
return
key = item.get("id")
if kind == "item.started" and key:
self.active_items[key] = {"id": key, "phase": item_phase(item), "startedSeconds": elapsed}
elif kind == "item.completed":
record = self.active_items.pop(key, {"id": key, "phase": item_phase(item), "startedSeconds": elapsed})
record.update(finishedSeconds=elapsed, durationSeconds=max(0, elapsed - record["startedSeconds"]), status=item.get("status"), exitCode=item.get("exit_code"))
self.items.append(record)
if kind == "turn.completed":
self.last_progress = elapsed
def read(self, path, elapsed):
with path.open("rb") as stream:
stream.seek(self.offset)
chunk = stream.read()
self.offset = stream.tell()
if not chunk:
return
self.last_output = elapsed
lines = (self.pending + chunk).split(b"\n")
self.pending = lines.pop()
for line in lines:
if not line.strip():
continue
try:
event = json.loads(line)
if not isinstance(event, dict):
raise ValueError("non-object event")
self.feed(event, elapsed)
except (ValueError, UnicodeDecodeError):
self.unknown_output = True
@property
def safe_to_retry(self):
return self.turn_started and not self.actions_seen and not self.unknown_output and not self.pending.strip()
def snapshot(self, elapsed):
return {
"firstEventSeconds": self.first_event,
"firstActionSeconds": self.first_action,
"providerWaitSeconds": self.first_action if self.first_action is not None else elapsed,
"idleSeconds": max(0, elapsed - self.last_progress),
"actionsSeen": self.actions_seen,
"safeToRetry": self.safe_to_retry,
"providerError": self.provider_error,
"errorCount": self.errors,
"eventCounts": dict(self.counts),
"activeItems": list(self.active_items.values()),
"items": self.items,
}
def timing_report(directory, state=None):
rows, worker_details, seen, intervals = [], [], set(), []
path = directory / "events.jsonl"
if path.exists():
with path.open(encoding="utf-8") as stream:
for line in stream:
try:
event = json.loads(line)
except ValueError:
continue
if event.get("event") not in {"process.finished", "timing.span"}:
continue
key = (event.get("log"), event.get("startedAt"), event.get("phase"))
if key in seen:
continue
seen.add(key)
phase = event.get("phase") or command_phase(event.get("argv", []))
seconds = max(0, event.get("durationSeconds", 0))
try:
intervals.append((datetime.fromisoformat(event["startedAt"]), datetime.fromisoformat(event.get("finishedAt") or event["time"])))
except (KeyError, ValueError):
pass
rows.append({"phase": phase, "start": minute_label(event.get("startedAt")), "finish": minute_label(event.get("finishedAt") or event.get("time")), "seconds": seconds, "minutes": round(seconds / 60, 4), "exitCode": event.get("exitCode"), "log": event.get("log"), "providerAbort": event.get("providerAbort")})
if event.get("workerTrace"):
worker_details.append({"log": event.get("log"), **event["workerTrace"]})
totals = defaultdict(lambda: {"count": 0, "seconds": 0.0, "maxSeconds": 0.0})
for row in rows:
total = totals[row["phase"]]
total["count"] += 1
total["seconds"] += row["seconds"]
total["maxSeconds"] = max(total["maxSeconds"], row["seconds"])
measured = sum(row["seconds"] for row in rows)
wall = (max(end for _, end in intervals) - min(start for start, _ in intervals)).total_seconds() if intervals else 0
summary = [{"phase": phase, **{k: round(v, 6) if isinstance(v, float) else v for k, v in total.items()}, "minutes": round(total["seconds"] / 60, 4), "sharePercent": round(total["seconds"] / measured * 100, 2) if measured else 0} for phase, total in totals.items()]
summary.sort(key=lambda row: row["seconds"], reverse=True)
current = dict((state or {}).get("currentProcess") or {})
if current.get("status") == "running" and current.get("startedAt"):
current["elapsedMinutes"] = round((datetime.now(timezone.utc) - datetime.fromisoformat(current["startedAt"])).total_seconds() / 60, 4)
return {"schemaVersion": 1, "observedAt": datetime.now(LOCAL_ZONE).strftime("%Y-%m-%d %H:%M"), "timezone": "Asia/Shanghai", "labelPrecision": "minute", "totalMeasuredMinutes": round(measured / 60, 4), "wallClockMinutes": round(wall / 60, 4), "outsideMeasuredStepsMinutes": round(max(0, wall - measured) / 60, 4), "summary": summary, "steps": rows, "workers": worker_details, "current": display_times(current), "accounting": "Top-level measured spans only; worker item durations are nested and must not be added again. Outside measured steps includes unrecorded retry, scheduling or manual pause gaps; legacy logs cannot attribute them further."}
def format_report(report):
lines = [f"Observed: {report['observedAt']} (Asia/Shanghai)", "Phase | Count | Minutes | Share", "--- | ---: | ---: | ---:"]
lines += [f"{r['phase']} | {r['count']} | {r['minutes']:.4f} | {r['sharePercent']:.2f}%" for r in report["summary"]]
lines += [f"Measured: {report['totalMeasuredMinutes']:.4f} minutes; outside measured steps: {report['outsideMeasuredStepsMinutes']:.4f} minutes."]
lines += ["", "Start | Finish | Phase | Minutes | Exit", "--- | --- | --- | ---: | ---:"]
lines += [f"{r['start']} | {r['finish']} | {r['phase']} | {r['minutes']:.4f} | {r['exitCode']}" for r in report["steps"]]
if report["current"]:
lines += ["", "Current: " + json.dumps(report["current"], ensure_ascii=False)]
return "\n".join(lines)

View File

@@ -0,0 +1,60 @@
{
"schemaVersion": 1,
"operation": "PYTHON_UNATTENDED_SUPERVISOR_IMPLEMENTATION",
"observedOn": "2026-09-08",
"updatedAt": "2026-09-08 10:55",
"timezone": "Asia/Shanghai",
"queueTask": "WBV2-P1424-001",
"parentManifest": "tests/golden/WBV2-P1424-000/manifest.json",
"programImplemented": true,
"blenderMigrationComplete": false,
"queueAdvancedByThisChange": false,
"sourceInventory": {
"unitCount": 3802,
"verifiedUnits": 1025,
"unverifiedUnits": 2777,
"unitsWithInvalidEvidenceReferences": 902,
"sourceTreeSha256": "311791df5e2b96c31ee9cefd5ab0c9a8ded87309f1a814c3b92dec328a607d6a",
"boundUnitDelta": 0
},
"checks": [
{ "command": "python3 -m unittest discover -s my-tools -p test_wbv2_autopilot.py", "exitCode": 0, "passedTests": 33 },
{ "command": "npm --prefix web run test:context-governance", "exitCode": 0, "passedTests": 11 },
{ "command": "python3 -m py_compile my-tools/wbv2_autopilot.py my-tools/run_web_product_migration.py my-tools/show_blender_web_scheduler.py", "exitCode": 0 },
{ "command": "node tools/web/run-wbv2-scheduler.mjs --preflight-only", "exitCode": 2, "expected": true, "reason": "incomplete task has no implementation entrypoint; index termination is no longer false completion" },
{ "command": "bash -n my-tools/start_unattended.sh", "exitCode": 0 },
{ "command": "git diff --check", "exitCode": 0 }
],
"launchSnapshot": {
"historicalSnapshot": true,
"command": "python3 my-tools/run_web_product_migration.py start",
"exitCode": 0,
"pid": 2035708,
"childPid": 2035763,
"state": "implementing",
"activeJob": "SERVER-OPERATOR-HTTP-PROVIDER",
"statusCommand": "python3 my-tools/run_web_product_migration.py status",
"checkpoint": "tests/golden/WBV2/autopilot/state.json"
},
"artifacts": {
"supervisor": { "path": "my-tools/wbv2_autopilot.py", "sha256": "c1ef9f130fac5913480c4c5a73ddced87275b8f6956585d631cedf3beac79acb" },
"timing": { "path": "my-tools/wbv2_timing.py", "sha256": "3bdfd71ba16a28fda9fad0a3cc993a8c64aa93d9dcfc65c0ed581b4f507dfdfd" },
"policy": { "path": "my-tools/wbv2_autopilot_policy.json", "sha256": "430cfee026deaf3d72bb3417bc640ed0e4f37c1ad1860a947cc54b2dd4586dc7" },
"tests": { "path": "my-tools/test_wbv2_autopilot.py", "sha256": "5733cdc2041a0df94942d48f677249a008fae0e75c07b12d113ecfb16ceb3e9e" },
"testLog": { "path": "tests/golden/WBV2-P1424-001/autopilot-tests.log", "sha256": "fba75567456bcf394e2dbb73fbbd46b7c6b96268d804c7a7b60b3c0cbd12d1c3" },
"entrypoint": { "path": "my-tools/run_web_product_migration.py", "sha256": "310a740d4d76d2a0f330a9c9d1f5583d412a38894382aea69e4eeb53b163d828" },
"documentation": { "path": "my-tools/AUTOPILOT.md", "sha256": "c8888bcae987dfde1bbffc020d659e1c675ad800655725aede7b11c9545fdd94" },
"performance": { "path": "tests/golden/WBV2-P1424-001/performance-optimization-report.json", "sha256": "6cccd401292d4f0f008a4716aa5f222ae0c55e716aee23fe34bfd21acbe6896f" }
},
"limits": {
"workerAndCheckTimeoutSeconds": 1800,
"modelStartTimeoutSeconds": 60,
"modelIdleTimeoutSeconds": 180,
"providerFailureBudget": 4,
"attemptsPerWorkflow": 3,
"reportedTokensPerRun": 500000,
"buildJobs": 8,
"runtime": "Chromium only",
"liveDeployment": "not performed by implementation workers"
}
}

View File

@@ -0,0 +1,21 @@
....{"time": "2026-09-08 10:54", "event": "process.started", "argv": ["git", "diff", "--name-only", "-z", "HEAD"], "cwd": ".", "phase": "git_diff", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/ffbfeed16e5a4a3e90ec5d90850e3a2e.log"}
{"time": "2026-09-08 10:54", "event": "process.finished", "argv": ["git", "diff", "--name-only", "-z", "HEAD"], "cwd": ".", "phase": "git_diff", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/ffbfeed16e5a4a3e90ec5d90850e3a2e.log", "exitCode": 0, "durationSeconds": 0.002486, "interrupted": false, "timedOut": false, "logLimit": false, "providerAbort": null, "logSha256": "7529b91ac69a737f5f0a199392966ea417fd1a690365e2d1ef9bf19df008979a", "finishedAt": "2026-09-08 10:54"}
{"time": "2026-09-08 10:54", "event": "process.started", "argv": ["git", "ls-files", "--others", "--exclude-standard", "-z"], "cwd": ".", "phase": "git_ls-files", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/5273e581d053477cba6c9780836eb952.log"}
{"time": "2026-09-08 10:54", "event": "process.finished", "argv": ["git", "ls-files", "--others", "--exclude-standard", "-z"], "cwd": ".", "phase": "git_ls-files", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/5273e581d053477cba6c9780836eb952.log", "exitCode": 0, "durationSeconds": 0.001814, "interrupted": false, "timedOut": false, "logLimit": false, "providerAbort": null, "logSha256": "1fc2ea0a9701bb979fde2750cccf75dd64902f090932bc6617d69532bf03ad05", "finishedAt": "2026-09-08 10:54"}
{"time": "2026-09-08 10:54", "event": "process.started", "argv": ["git", "rev-parse", "HEAD"], "cwd": ".", "phase": "git_rev-parse", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/35eef3bff75f46b5b1243731e96356c6.log"}
{"time": "2026-09-08 10:54", "event": "process.finished", "argv": ["git", "rev-parse", "HEAD"], "cwd": ".", "phase": "git_rev-parse", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/35eef3bff75f46b5b1243731e96356c6.log", "exitCode": 0, "durationSeconds": 0.001906, "interrupted": false, "timedOut": false, "logLimit": false, "providerAbort": null, "logSha256": "1fdd00ec61025cc7d1255f9b001245011b6d2afba27f91cc94fa578d4684c11c", "finishedAt": "2026-09-08 10:54"}
{"time": "2026-09-08 10:54", "event": "process.started", "argv": ["git", "branch", "--show-current"], "cwd": ".", "phase": "git_branch", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/c83a2294ac1f4387aa63f7fe4381485d.log"}
{"time": "2026-09-08 10:54", "event": "process.finished", "argv": ["git", "branch", "--show-current"], "cwd": ".", "phase": "git_branch", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/c83a2294ac1f4387aa63f7fe4381485d.log", "exitCode": 0, "durationSeconds": 0.001841, "interrupted": false, "timedOut": false, "logLimit": false, "providerAbort": null, "logSha256": "6403203dd5a0867eb14d104ee8a73730bd72dd9ad92e78d996a6dba0a5dcfc01", "finishedAt": "2026-09-08 10:54"}
{"time": "2026-09-08 10:54", "event": "process.started", "argv": ["git", "rev-parse", "HEAD"], "cwd": ".", "phase": "git_rev-parse", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/4d1296bf4b9240469c5f08036a2f10d8.log"}
{"time": "2026-09-08 10:54", "event": "process.finished", "argv": ["git", "rev-parse", "HEAD"], "cwd": ".", "phase": "git_rev-parse", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/4d1296bf4b9240469c5f08036a2f10d8.log", "exitCode": 0, "durationSeconds": 0.001801, "interrupted": false, "timedOut": false, "logLimit": false, "providerAbort": null, "logSha256": "1fdd00ec61025cc7d1255f9b001245011b6d2afba27f91cc94fa578d4684c11c", "finishedAt": "2026-09-08 10:54"}
{"time": "2026-09-08 10:54", "event": "process.started", "argv": ["git", "add", "-f", "--", "source.cpp"], "cwd": ".", "phase": "git_add", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/a85a3037de4e4423887db775e64c03cd.log"}
{"time": "2026-09-08 10:54", "event": "process.finished", "argv": ["git", "add", "-f", "--", "source.cpp"], "cwd": ".", "phase": "git_add", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/a85a3037de4e4423887db775e64c03cd.log", "exitCode": 0, "durationSeconds": 0.00186, "interrupted": false, "timedOut": false, "logLimit": false, "providerAbort": null, "logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "finishedAt": "2026-09-08 10:54"}
{"time": "2026-09-08 10:54", "event": "process.started", "argv": ["git", "commit", "--only", "-m", "feat(web): verified workflow WBV2-P42-001", "--", "source.cpp"], "cwd": ".", "phase": "git_commit", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/32b2d67b4286444790afce197b4652da.log"}
{"time": "2026-09-08 10:54", "event": "process.finished", "argv": ["git", "commit", "--only", "-m", "feat(web): verified workflow WBV2-P42-001", "--", "source.cpp"], "cwd": ".", "phase": "git_commit", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/32b2d67b4286444790afce197b4652da.log", "exitCode": 0, "durationSeconds": 0.004922, "interrupted": false, "timedOut": false, "logLimit": false, "providerAbort": null, "logSha256": "4a31687c5f083e9a80e973f3c90f27e979ad11c21ce645b45fbf77f105a5e39a", "finishedAt": "2026-09-08 10:54"}
{"time": "2026-09-08 10:54", "event": "process.started", "argv": ["git", "rev-parse", "HEAD"], "cwd": ".", "phase": "git_rev-parse", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/f322aaa82f25411ea2e03697d042d681.log"}
{"time": "2026-09-08 10:54", "event": "process.finished", "argv": ["git", "rev-parse", "HEAD"], "cwd": ".", "phase": "git_rev-parse", "startedAt": "2026-09-08 10:54", "log": "tests/golden/WBV2/autopilot/git/f322aaa82f25411ea2e03697d042d681.log", "exitCode": 0, "durationSeconds": 0.001736, "interrupted": false, "timedOut": false, "logLimit": false, "providerAbort": null, "logSha256": "d5bbe3d9e19db362f6e234daf00ffde3e332d2bba64c421d7ff72300822ecc89", "finishedAt": "2026-09-08 10:54"}
.............................
----------------------------------------------------------------------
Ran 33 tests in 2.770s
OK

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@@ -0,0 +1,272 @@
{
"observedAt": "2026-09-08T01:24:58.634Z",
"origin": "https://49.232.250.35:58014",
"browser": "Chromium",
"steps": [
{
"name": "startup",
"status": "PASS",
"durationMs": 34167,
"detail": {
"title": "Blender 5.2.0 LTS - Web",
"secureContext": true,
"crossOriginIsolated": true,
"scripts": [
"https://49.232.250.35:58014/assets/index-DpqZcb_t.js"
]
}
},
{
"name": "open_fixture",
"status": "PASS",
"durationMs": 692,
"detail": {
"objects": 3,
"coverage": "Feature Routes\n3802/3802 source units classified\nMain 1755 · Server 1613 · Browser 434 · Unavailable 0\nRoute gaps 0 · Evidence 1025 verified / 2777 unverified"
}
},
{
"name": "add_cube",
"status": "PASS",
"durationMs": 391,
"detail": {
"objects": 4
}
},
{
"name": "undo_redo",
"status": "PASS",
"durationMs": 446,
"detail": {
"undoObjects": 3,
"redoObjects": 4
}
},
{
"name": "save_reopen",
"status": "PASS",
"durationMs": 1185,
"detail": {
"suggestedFilename": "blender-web.blend",
"byteLength": 205638,
"sha256": "29f7f3f90e04454b47c2bb715515bb41badfa87e4b73e73e95345d5517222f90",
"reopenedObjects": 4
}
},
{
"name": "duplicate_delete",
"status": "PASS",
"durationMs": 773,
"detail": {
"duplicatedObjects": 5,
"deletedObjects": 4
}
},
{
"name": "f3_local_operator_properties",
"status": "PASS",
"durationMs": 1121,
"detail": {
"form": "MESH_OT_primitive_cube_add\n执行",
"controls": 0
}
},
{
"name": "f3_server_operator_properties",
"status": "FAIL",
"durationMs": 1332,
"error": "{\"alert\":[\"SERVER_OPERATOR_HTTP_FAILED: 405\"],\"controls\":0}\n\n1 !== 0\n"
},
{
"name": "browser_equivalent_area_split",
"status": "FAIL",
"durationMs": 1492,
"error": "Area split switched workspace instead of splitting the active editor\n+ actual - expected\n\n+ 'Layout'\n- 'Modeling'\n"
},
{
"name": "workspace_surfaces",
"status": "PASS",
"durationMs": 2469,
"detail": {
"scope": "workspace presence only; not full functional verification",
"surfaces": [
{
"workspace": "Layout",
"editors": [
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
},
{
"workspace": "Modeling",
"editors": [
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
},
{
"workspace": "Sculpting",
"editors": [
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
},
{
"workspace": "UV Editing",
"editors": [
"Image Editor",
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
},
{
"workspace": "Texture Paint",
"editors": [
"Image Editor",
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
},
{
"workspace": "Shading",
"editors": [
"Image Editor",
"Shader Editor",
"File Browser",
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
},
{
"workspace": "Animation",
"editors": [
"3D Viewport",
"3D Viewport",
"Outliner",
"Properties",
"Dope Sheet"
]
},
{
"workspace": "Rendering",
"editors": [
"Render Result",
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
},
{
"workspace": "Compositing",
"editors": [
"Image Editor",
"Compositor",
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
},
{
"workspace": "Geometry Nodes",
"editors": [
"Geometry Nodes",
"Spreadsheet",
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
},
{
"workspace": "Scripting",
"editors": [
"Text Editor",
"Python Console",
"Info",
"Outliner",
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
}
]
}
},
{
"name": "desktop_render",
"status": "PASS",
"durationMs": 131,
"detail": {
"canvases": [
{
"width": 1119,
"height": 713,
"rect": {
"x": 0,
"y": 58,
"width": 1119,
"height": 713,
"top": 58,
"right": 1119,
"bottom": 771,
"left": 0
}
}
],
"bodyWidth": 1440,
"viewportWidth": 1440,
"wasmResources": [
{
"url": "https://49.232.250.35:58014/vendor/blender/single/web_engine.wasm",
"duration": 135.66500002145767,
"transferSize": 369064
}
]
}
}
],
"errors": [],
"failedRequests": [],
"badResponses": [
{
"url": "https://49.232.250.35:58014/api/server-operator-job",
"status": 405
}
],
"areaSplitObservation": {
"before": {
"workspace": "Modeling",
"editors": [
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
},
"after": {
"workspace": "Layout",
"editors": [
"3D Viewport",
"Outliner",
"Properties",
"Timeline"
]
}
},
"finishedAt": "2026-09-08T01:25:42.978Z"
}

View File

@@ -0,0 +1,172 @@
{
"schemaVersion": 1,
"operation": "DEPLOYED_BLENDER_COMPLETION_AUDIT",
"observedAt": "2026-09-08T01:29:54.546Z",
"origin": "https://49.232.250.35:58014/",
"assessment": "Core online editing is functional; full desktop Blender migration is not complete or established by current evidence.",
"codeOrServerConfigurationChanged": false,
"productionDataModified": false,
"testMutationScope": "Fresh isolated Chromium context and repository-owned basic_scene.blend; no server operator submit job was executed.",
"newVerifiedInventoryUnits": 0,
"declaredImplementation": {
"modules": 16,
"implementedModules": 16,
"tasks": 34,
"implementedTasks": 34,
"verificationPolicy": "deferred_until_user_reports"
},
"inventory": {
"unitCount": 3802,
"sourceTreeSha256": "311791df5e2b96c31ee9cefd5ab0c9a8ded87309f1a814c3b92dec328a607d6a",
"routeCounts": {
"BROWSER_EQUIVALENT": 434,
"SERVER_NATIVE": 1613,
"WEB_MAIN": 1755
},
"behavioralEvidence": {
"status": "PARTIAL",
"verifiedUnits": 1025,
"unverifiedUnits": 2777,
"policy": "route classification does not replace exact behavioral evidence"
},
"recordedEvidencePercent": 26.96,
"byKind": {
"modifier-source": {
"total": 97,
"recordedVerified": 95,
"unverified": 2
},
"node": {
"total": 497,
"recordedVerified": 497,
"unverified": 0
},
"operator-macro": {
"total": 58,
"recordedVerified": 0,
"unverified": 58
},
"operator": {
"total": 2018,
"recordedVerified": 31,
"unverified": 1987
},
"rna-struct": {
"total": 979,
"recordedVerified": 306,
"unverified": 673
},
"web-command": {
"total": 153,
"recordedVerified": 96,
"unverified": 57
}
},
"sourceInventoryCheck": {
"command": "node tools/web/check-source-dynamic-inventory.mjs",
"exitCode": 0
},
"routeCatalogCheck": {
"command": "node tools/web/check-blender-feature-route-catalog.mjs",
"exitCode": 0
}
},
"deployment": {
"releaseId": "731af06aee6a2d2b61b902db9fd4b7bd5b04474e4689c48baf213fd5fcb3769f",
"wasmModuleManifestMatchesLocal": true,
"testedWasmSha256": "1c89a1fd0e00e30330d151cb9a42d9d54aabd5ed2c9ae45dd3b1961d9a0c75d5",
"deploymentCheck": {
"command": "node tools/web/check-deployed-web-blender.mjs",
"exitCode": 0,
"nativeObjectMutation": [
3,
4
],
"wasmRangeStatus": 206
},
"sshReadOnlyInspection": {
"service": "blender-web-58014.service",
"active": true,
"entrypoint": "/var/www/blender-web/current/tools/web/deployment-http-server.mjs",
"packagedRuntimeFiles": [
"deployment-http-server.mjs"
],
"observedPostStatus": 405,
"allowedMethods": [
"GET",
"HEAD"
],
"connectionClosed": true
}
},
"findings": [
{
"priority": "high",
"code": "SERVER_OPERATOR_PROVIDER_NOT_CONNECTED",
"endpoint": "/api/server-operator-job",
"evidence": "POST describe requests for mesh.primitive_cube_add and OBJECT_OT_modifier_apply returned 405; Chromium F3 showed SERVER_OPERATOR_HTTP_FAILED: 405.",
"impact": "The configured shared provider path blocks SERVER_NATIVE actions; catalog assigns 1613 units to this path. This does not mean 1613 distinct operations were individually tested.",
"sourceReference": "web/app/src/engine-client/WebEngineClient.ts:387"
},
{
"priority": "high",
"code": "BROWSER_ROUTE_SEMANTICS_INCOMPLETE",
"evidence": "From Modeling, SCREEN_OT_area_split switched to Layout and retained the same four editor areas.",
"impact": "Action-group classification does not implement each desktop operator. All 434 BROWSER_EQUIVALENT assignments require semantic acceptance; one mismatch was reproduced.",
"sourceReferences": [
"web/app/src/app/App.tsx:167",
"web/app/src/app/App.tsx:3534"
]
},
{
"priority": "high",
"code": "BEHAVIORAL_EVIDENCE_REFERENCE_DRIFT",
"evidence": "183/373 directly referenced artifacts have SHA-256 drift; 902/1025 recorded verified units are affected.",
"impact": "Recorded VERIFIED counts cannot be treated as current release acceptance. Matching references alone also do not establish full behavioral parity."
}
],
"limitations": [
"This is a focused production smoke test, not a full Blender operator/property/context matrix.",
"Workspace tabs were checked for presence only. Render quality, simulations, animation results, node evaluation, add-ons, Python compatibility, large-scene performance and context-specific editing were not exhaustively tested.",
"The local F3 cube form was present with zero typed property controls; its PASS result only establishes form presence.",
"Final screenshots after workspace switching are observations, not a stable-frame visual correctness assertion.",
"No queue, historical inventory, completion overlay, production source or server configuration was changed by this audit."
],
"artifacts": [
{
"path": "tests/golden/WBV2-P1424-001/deployed-audit/browser-area-split-result.png",
"byteLength": 187298,
"sha256": "0e31ca29f519c50a1f6c5a91d1c8436bbb56efcff108b093af4e77797e09bb50"
},
{
"path": "tests/golden/WBV2-P1424-001/deployed-audit/chromium-report.json",
"byteLength": 6565,
"sha256": "867e63b65bef497593fefcd5884f0666752b3c59f0d4e18354d1b64d7cdeef94"
},
{
"path": "tests/golden/WBV2-P1424-001/deployed-audit/desktop-final.png",
"byteLength": 111507,
"sha256": "9eac6d31197eb681e237503bcc20a046e26b73c64bd7e737892a4bf74bb2edca"
},
{
"path": "tests/golden/WBV2-P1424-001/deployed-audit/desktop-open.png",
"byteLength": 178549,
"sha256": "0ecdceb9b8b04fc3eb4d9fb752ab7746b26e2d974520a78ba6b81b7e1456639c"
},
{
"path": "tests/golden/WBV2-P1424-001/deployed-audit/evidence-integrity.json",
"byteLength": 246111,
"sha256": "4de65962c7e1a4ea93b04d200e62dc4a4ad986424bc6503e39dd007ab0143ecc"
},
{
"path": "tests/golden/WBV2-P1424-001/deployed-audit/f3-server-result.png",
"byteLength": 180454,
"sha256": "600f61e19edef7bb476113fec5b75c09845ce42b60fcf97343fce21668920980"
},
{
"path": "tests/golden/WBV2-P1424-001/deployed-audit/roundtrip.blend",
"byteLength": 205638,
"sha256": "29f7f3f90e04454b47c2bb715515bb41badfa87e4b73e73e95345d5517222f90"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

View File

@@ -2,10 +2,11 @@
"schemaVersion": 1,
"task": "WBV2-P1424-001",
"parentTask": "WBV2-P1424-000",
"operation": "WEB_PRODUCT_IMPLEMENTATION_COMPLETE",
"status": "done",
"operation": "WEB_PRODUCT_RUNTIME_CLOSURE_CHECKPOINT",
"status": "in_progress",
"implementationComplete": true,
"verificationDeferred": true,
"verificationDeferred": false,
"acceptanceComplete": false,
"artifacts": {
"parentManifest": {
"path": "tests/golden/WBV2-P1424-000/manifest.json",
@@ -25,7 +26,7 @@
},
"status": {
"path": "docs/status/WBV2-P1424-001.md",
"sha256": "570be0c64b5e05b534262b6932282f4b9984ca218e1e26719b8cacee39190cdc"
"sha256": "afdfe45e55c68a8c62956d5e075b522055b0fb8edefe37a480f830eabce2cf29"
},
"plan": {
"path": "tests/golden/WBV2-P1424-000/runtime-closure-plan.json",
@@ -33,7 +34,11 @@
},
"taskContext": {
"path": "tests/golden/WBV2-P1424-001/task-context.json",
"sha256": "f97153c590b294b8fa03dbd34c1bed8cd2808550aef549f6a37dfb7deb00ecb6"
"sha256": "77c7e929437b3588f05d62c3106d198fe4e326da85d230ee74c13ec0f050aa87"
},
"resumeReport": {
"path": "tests/golden/WBV2-P1424-001/resume-report.json",
"sha256": "9b96bfcc5ca87ab052b87c03cba0f146c21af2ce9a4e6b07edf164b97581e891"
}
},
"nextTask": null

View File

@@ -0,0 +1,201 @@
{
"schemaVersion": 1,
"operation": "AUTOPILOT_PERFORMANCE_OPTIMIZATION",
"observedAt": "2026-09-08 10:52",
"timezone": "Asia/Shanghai",
"labelPrecision": "minute",
"baseline": {
"schemaVersion": 1,
"observedAt": "2026-09-08 10:52",
"timezone": "Asia/Shanghai",
"labelPrecision": "minute",
"totalMeasuredMinutes": 14.1828,
"summary": [
{
"phase": "model_request",
"count": 2,
"seconds": 845.907,
"maxSeconds": 688.854,
"minutes": 14.0985,
"sharePercent": 99.41
},
{
"phase": "context_check",
"count": 4,
"seconds": 4.02,
"maxSeconds": 1.006,
"minutes": 0.067,
"sharePercent": 0.47
},
{
"phase": "git_ls-files",
"count": 1,
"seconds": 1.007,
"maxSeconds": 1.007,
"minutes": 0.0168,
"sharePercent": 0.12
},
{
"phase": "git_diff",
"count": 1,
"seconds": 0.029,
"maxSeconds": 0.029,
"minutes": 0.0005,
"sharePercent": 0.0
},
{
"phase": "git_rev-parse",
"count": 1,
"seconds": 0.002,
"maxSeconds": 0.002,
"minutes": 0.0,
"sharePercent": 0.0
}
],
"steps": [
{
"phase": "git_diff",
"start": "2026-09-08 10:10",
"finish": "2026-09-08 10:10",
"seconds": 0.029,
"minutes": 0.0005,
"exitCode": 0,
"log": "tests/golden/WBV2/autopilot/git/638a9273e10249f98ef046be57a81b32.log",
"providerAbort": null
},
{
"phase": "git_ls-files",
"start": "2026-09-08 10:10",
"finish": "2026-09-08 10:10",
"seconds": 1.007,
"minutes": 0.0168,
"exitCode": 0,
"log": "tests/golden/WBV2/autopilot/git/f4402401e72c4c958f487095a6561c1f.log",
"providerAbort": null
},
{
"phase": "git_rev-parse",
"start": "2026-09-08 10:10",
"finish": "2026-09-08 10:10",
"seconds": 0.002,
"minutes": 0.0,
"exitCode": 0,
"log": "tests/golden/WBV2/autopilot/git/b3a0220f89e34fe693cf6ddcddc331aa.log",
"providerAbort": null
},
{
"phase": "context_check",
"start": "2026-09-08 10:10",
"finish": "2026-09-08 10:10",
"seconds": 1.006,
"minutes": 0.0168,
"exitCode": 0,
"log": "tests/golden/WBV2/autopilot/preflight-0.log",
"providerAbort": null
},
{
"phase": "context_check",
"start": "2026-09-08 10:10",
"finish": "2026-09-08 10:10",
"seconds": 1.004,
"minutes": 0.0167,
"exitCode": 0,
"log": "tests/golden/WBV2/autopilot/preflight-1.log",
"providerAbort": null
},
{
"phase": "model_request",
"start": "2026-09-08 10:10",
"finish": "2026-09-08 10:21",
"seconds": 688.854,
"minutes": 11.4809,
"exitCode": 1,
"log": "tests/golden/WBV2/autopilot/runs/SERVER-OPERATOR-HTTP-PROVIDER-2b37d20fd07b/worker.jsonl",
"providerAbort": null
},
{
"phase": "context_check",
"start": "2026-09-08 10:21",
"finish": "2026-09-08 10:21",
"seconds": 1.005,
"minutes": 0.0167,
"exitCode": 0,
"log": "tests/golden/WBV2/autopilot/preflight-0.log",
"providerAbort": null
},
{
"phase": "context_check",
"start": "2026-09-08 10:21",
"finish": "2026-09-08 10:21",
"seconds": 1.005,
"minutes": 0.0167,
"exitCode": 0,
"log": "tests/golden/WBV2/autopilot/preflight-1.log",
"providerAbort": null
},
{
"phase": "model_request",
"start": "2026-09-08 10:21",
"finish": "2026-09-08 10:24",
"seconds": 157.053,
"minutes": 2.6176,
"exitCode": 0,
"log": "tests/golden/WBV2/autopilot/runs/SERVER-OPERATOR-HTTP-PROVIDER-4683ba870ac5/worker.jsonl",
"providerAbort": null
}
],
"workers": [],
"current": {},
"accounting": "Top-level measured spans only; worker item durations are nested and must not be added again. Legacy logs cannot recover per-event timing or unrecorded gaps."
},
"benchmark": {
"scope": "Same read-only context entrypoints: historical observed baseline versus three current samples per entrypoint; not whole-project throughput.",
"samples": [
{
"script": "check-task-context.mjs",
"seconds": 0.035114
},
{
"script": "check-task-context.mjs",
"seconds": 0.033912
},
{
"script": "check-task-context.mjs",
"seconds": 0.033473
},
{
"script": "check-context-governance.mjs",
"seconds": 0.065781
},
{
"script": "check-context-governance.mjs",
"seconds": 0.033451
},
{
"script": "check-context-governance.mjs",
"seconds": 0.065589
}
],
"baselineMedianMinutes": 0.01675,
"optimizedMedianMinutes": 0.000575,
"observedSpeedup": 29.12,
"observedReductionPercent": 96.57
},
"providerPolicy": {
"modelStartTimeoutSeconds": 60,
"modelIdleTimeoutSeconds": 180,
"providerBackoffSeconds": 30,
"providerMaxBackoffSeconds": 240,
"maxProviderFailures": 4
},
"sourcePolicy": {
"driver": "business_module_and_function_route",
"authority": "blender-5.2.0/source/blender",
"sourceFirst": true
},
"limitations": [
"Provider availability/account concurrency is external; these changes do not remove upstream limits.",
"No source capability completion or inventory increment is claimed.",
"Legacy JSONL lacks per-item timestamps; new runs record observed per-item intervals separately."
]
}

View File

@@ -0,0 +1,53 @@
# 无人值守执行耗时与优化
统计日期:2026-09-08。所有时间标签为北京时间,精确到分钟。
| 步骤 | 开始 | 结束 | 耗时(分钟) | 结果 |
| --- | --- | --- | ---: | --- |
| Git 基线准备 | 10:10 | 10:10 | 0.0173 | 完成 |
| 首轮上下文与治理检查 | 10:10 | 10:10 | 0.0335 | 通过 |
| 首次模型请求 | 10:10 | 10:21 | 11.4809 | 服务过载,退出 1,未执行代理操作 |
| 第二轮上下文与治理检查 | 10:21 | 10:21 | 0.0335 | 通过 |
| 第二次模型请求 | 10:21 | 10:24 | 2.6176 | 账号并发限制,维护暂停,未完成代理回合 |
| 代码修改、源码构建、Chromium 验收、交接 | 未开始 | 未开始 | 未产生样本 | 不计为完成 |
两次模型请求共占已测步骤耗时的 99.41%。旧日志没有逐事件时间戳,
未记录的重试和调度间隔不能伪造为已测步骤。主因是外部模型服务过载与
账号并发限制,尚无证据表明编译或浏览器测试是当前瓶颈。
## 已实施优化
- 新增分阶段计时和逐项事件观测,区分模型等待、读取、修改、命令、构建、
各类验收、证据哈希、重试退避和 Git 交接;嵌套耗时不重复累计。
- 没有代理操作时,明确过载或限流立即结束等待进程;无首次操作上限为
1 分钟。通过事件记录和源码哈希确认没有操作后才允许自动重试。
- 服务故障使用独立预算,按 0.5、1、2、最多 4 分钟退避,连续 4 次失败
停在 `provider_blocked`,不消耗功能实现尝试,不推进完成状态。
- 短命令等待进程结束,不再固定休眠 1 秒;心跳按配置间隔写入,重复证据
路径在单次指纹计算中只读取一次。
- 相同上下文检查的新样本中位耗时为 0.000575 分钟,旧样本为 0.016750
分钟,减少 96.57%,约 29.12 倍。此比较不是整个项目的吞吐量提升。
- 保持源码构建、类型检查、lint、Chromium、正负例、undo/redo、save/reopen
和确定性验收;没有通过减少功能验收来获得加速。
## 执行铁律
由功能模块业务与功能路由驱动移植,先检索用户提供的 Blender 源码和
相关资料,再解决 Web 实现问题。根 `AGENTS.md`、调度策略、代理契约和
验收记录均已绑定此规则,要求记录工作流、具体路由和原生源码文件。
验证:33 项监督程序回归测试、11 项上下文治理测试通过。本段新增源码
覆盖为 0;动态 inventory 为 3802 单元,账面已验证 1025、未验证 2777,
其中 902 个已验证单元的旧证据引用有漂移。sourceTreeSha256:
`311791df5e2b96c31ee9cefd5ab0c9a8ded87309f1a814c3b92dec328a607d6a`。
可复查命令:
```bash
python3 my-tools/run_web_product_migration.py timings
python3 my-tools/run_web_product_migration.py timings --json --write
python3 my-tools/benchmark_wbv2_autopilot.py
```
优化减少无效等待并改善调度开销,不能消除外部账号并发限制。移植任务
仍为 `WBV2-P1424-001`,完整行为验收未完成,队列没有推进。

View File

@@ -0,0 +1,73 @@
{
"schemaVersion": 1,
"task": "WBV2-P1424-001",
"parentTask": "WBV2-P1424-000",
"operation": "RUNTIME_CLOSURE_HANDOFF_RECONCILIATION",
"observedOn": "2026-09-08",
"status": "in_progress",
"acceptanceComplete": false,
"queueAdvanced": false,
"nextTask": null,
"implementation": {
"previousReport": "tests/golden/WBV2-P1424-001/product-implementation-report.json",
"reportedTasks": 34,
"reportedImplementedTasks": 34,
"reportedWorkflowModules": 16,
"newCapabilities": 0,
"productionCommandsReplayed": false
},
"sourceInventory": {
"manifestPath": "tests/golden/WBV2/dynamic-inventory-manifest.json",
"manifestSha256": "bf3e5de1ddc5569a19445a4ca88a5da32ce32b330082d42f8d17467d76d57ed2",
"basis": "existing source inventory reported by check-execution-control-plane; not regenerated in this reconciliation",
"unitCount": 3802,
"verifiedUnits": 1025,
"unverifiedUnits": 2777,
"sourceTreeSha256": "311791df5e2b96c31ee9cefd5ab0c9a8ded87309f1a814c3b92dec328a607d6a",
"boundUnitDelta": 0,
"previousImplementationReportUnitCount": 3745
},
"checks": [
{ "command": "node tools/web/print-task-context.mjs", "exitCode": 0 },
{ "command": "node tools/web/check-task-context.mjs", "exitCode": 0 },
{ "command": "npm --prefix web run test:task-context", "exitCode": 0, "passedTests": 6 },
{ "command": "npm --prefix web run test:context-governance", "exitCode": 0, "passedTests": 11 },
{ "command": "node tools/web/check-execution-control-plane.mjs", "exitCode": 0 },
{ "command": "node tools/web/check-task-index.mjs", "exitCode": 0 },
{ "command": "node tools/web/check-blender-next-task-plan.mjs", "exitCode": 1 },
{ "command": "node tools/web/run-wbv2-scheduler.mjs --preflight-only", "exitCode": 0, "output": "empty; no runtime acceptance evidence" },
{ "command": "node tools/web/check-task-context.mjs --task WBV2-P1424-001 --write", "exitCode": 0 },
{ "command": "git diff --check", "exitCode": 0 }
],
"repairedEvidence": {
"path": "tests/golden/WBV2-P1424-001/task-context.json",
"previousManifestSha256": "f97153c590b294b8fa03dbd34c1bed8cd2808550aef549f6a37dfb7deb00ecb6",
"regeneratedSha256": "77c7e929437b3588f05d62c3106d198fe4e326da85d230ee74c13ec0f050aa87",
"regenerationChangedBytes": false
},
"remainingBlockers": [
{
"code": "PLAN_ARTIFACT_HASH_DRIFT",
"command": "node tools/web/check-blender-next-task-plan.mjs",
"path": "web/package.json",
"expectedSha256": "40b7f14d4f568ae94bedde83e02115c127e0c714e5eb32a6a97b1e1084c4fa68",
"actualSha256": "b334ff7ab822bd9a9c6ad1302fdb6432648f26d1cf1dbfe86825c2cf1947387e",
"recovery": "Reconcile this gate through an authorized current evidence generator without rewriting historical inventory/provenance or reverting existing workspace changes."
},
{
"code": "RUNTIME_ACCEPTANCE_NOT_BOUND",
"evidence": "The implementation report defers verification; the three runtime result files contain implementation summaries but no behavioral receipts. The task card/context only declares print-task-context as its executable command.",
"missingBindings": [
"focused production and test entrypoints",
"Chromium SERVER_NATIVE mutated Main and F3 typed properties",
"browser-equivalent workflow execution",
"missing-provider, malformed-envelope, hash-mismatch, timeout, cancellation and context-only no-mutation cases",
"undo/redo, save/reopen, determinism and aggregate runtime receipts"
],
"recovery": "Bind the current runtime closure to executable source-backed entrypoints and focused acceptance receipts through repository task generators before declaring completion."
}
],
"historicalEvidenceModified": false,
"commitCreated": false,
"pushAttempted": false
}

View File

@@ -1,8 +1,8 @@
{
"schemaVersion": 1,
"reportId": "WBV2-FULL-SOURCE-PARITY-VALIDATION-001",
"generatedAt": "2026-09-07T09:10:38.474Z",
"gitCommit": "f68c6c9678d67641d14ea6b17b53c0cfa24adb51",
"generatedAt": "2026-09-07T09:19:37.040Z",
"gitCommit": "93c01c8f09e734f69a41451394d443ebbece2233",
"sourceParityStatus": "PARTIAL",
"webProductStatus": "READY",
"releaseStatus": "READY",

View File

@@ -514,7 +514,7 @@
"artifact": "app",
"path": "web/app/src/app/App.tsx",
"originalSha256": "74216aac70992f8d879e983237ca324b998008582f0cd4658e90994cf9fae0a8",
"observedSha256": "41279de5f12071d914a185c0813f537b8db85ae229f9ce61724fa36cac7cdf31",
"observedSha256": "c9d93c4d396974d9310308b29558d934dfd308c700ab6e63380a0cd51bb5271e",
"provenanceState": "BLOCKED_SOURCE_LOST"
},
{
@@ -578,7 +578,7 @@
"artifact": "app",
"path": "web/app/src/app/App.tsx",
"originalSha256": "043ff3a2f39ef3a1303791081b80851b427e7db5dfd9af2161926dd6f1ea9ca4",
"observedSha256": "41279de5f12071d914a185c0813f537b8db85ae229f9ce61724fa36cac7cdf31",
"observedSha256": "c9d93c4d396974d9310308b29558d934dfd308c700ab6e63380a0cd51bb5271e",
"provenanceState": "BLOCKED_SOURCE_LOST"
},
{

View File

@@ -81,6 +81,8 @@ const pushVerification = (task) => {
return { path: file, localHead: match?.[1] ?? null, remoteHead: match?.[2] ?? null, equal: Boolean(match && match[1] === match[2]) };
};
const collect = () => {
const autopilot = readJson(`${controlRoot}/autopilot/state.json`);
const autopilotLock = lockInfo(`${controlRoot}/autopilot/owner.lock`);
const queue = currentPointer();
const scheduler = readJson(schedulerCheckpointPath);
const guardian = readJson(guardianCheckpointPath);
@@ -88,21 +90,23 @@ const collect = () => {
const schedulerLock = lockInfo(schedulerLockPath);
const guardianLock = lockInfo(guardianLockPath);
const supervisorLock = lockInfo(supervisorLockPath);
const pythonOwnsScheduler = autopilotLock.alive && schedulerLock.pid === autopilotLock.pid;
const handoff = latestHandoff();
const active = schedulerLock.alive || (guardianLock.alive && supervisorLock.alive);
const active = autopilotLock.alive || schedulerLock.alive || (guardianLock.alive && supervisorLock.alive);
let terminalQueue = false;
try {
const manifest = queue.task ? readJson(`tests/golden/${queue.task}/manifest.json`) : null;
const index = readJson("tests/golden/WBV2/task-index.json");
terminalQueue = !active && index?.activeTask === null && manifest?.status === "done" && manifest?.nextTask === null;
terminalQueue = !active && index?.activeTask === null && manifest?.status === "done" && manifest?.nextTask === null && manifest?.acceptanceComplete === true && manifest?.verificationDeferred !== true;
} catch { /* A malformed pointer remains visible as a normal checkpoint failure. */ }
const checkpointBlocked = scheduler?.state === "blocked" || guardian?.state === "blocked" || supervisor?.state === "blocked";
// A terminal task can leave a historical blocked checkpoint behind (for
// example, after a push retry). The watchdog owns the next planner attempt;
// do not report that stale checkpoint as a live execution block.
const blocked = checkpointBlocked && !terminalQueue;
const autopilotBlocked = ["blocked", "provider_blocked", "needs_reconcile", "planning_required", "budget_exhausted", "ready_to_publish"].includes(autopilot?.state);
const blocked = autopilotBlocked || (!autopilotLock.alive && checkpointBlocked && !terminalQueue);
const state = blocked ? "BLOCKED" : active ? "RUNNING" : "IDLE";
const phase = blocked ? "blocked" : scheduler?.state === "running" ? "scheduler-task" : supervisor?.state === "running" ? "task-or-gate" : scheduler?.state ?? supervisor?.state ?? "unknown";
const phase = autopilotLock.alive || autopilotBlocked ? `python-${autopilot?.state ?? "starting"}` : blocked ? "blocked" : scheduler?.state === "running" ? "scheduler-task" : supervisor?.state === "running" ? "task-or-gate" : scheduler?.state ?? supervisor?.state ?? "unknown";
const observed = new Date().toISOString();
return {
schemaVersion: 1,
@@ -110,10 +114,11 @@ const collect = () => {
state,
phase,
message: state === "RUNNING"
? `RUNNING: ${queue.task ?? "unknown task"} is owned by the guardian/supervisor process; wait for its handoff.`
? `RUNNING: ${queue.task ?? "unknown task"} is owned by ${autopilotLock.alive ? "Python autopilot" : "guardian/supervisor"}; wait for its handoff.`
: state === "BLOCKED" ? "BLOCKED: inspect the scheduler/guardian/supervisor checkpoint reason before any write or retry." : "IDLE: no live scheduler, guardian or supervisor lock was found.",
queue,
scheduler: { pid: scheduler?.pid ?? schedulerLock.pid, alive: schedulerLock.alive, state: scheduler?.state ?? null, updatedAt: scheduler?.updatedAt ?? null, iteration: scheduler?.iteration ?? null, before: scheduler?.before ?? null, after: scheduler?.after ?? null, latestBatch: scheduler?.latestBatch ?? null },
autopilot: { pid: autopilot?.pid ?? autopilotLock.pid, alive: autopilotLock.alive, state: autopilot?.state ?? null, phase: autopilot?.currentProcess?.phase ?? null, activeJob: autopilot?.activeJob ?? null, reason: autopilot?.reason ?? null, tokensUsed: autopilot?.tokensUsed ?? 0, providerFailures: autopilot?.providerFailures ?? 0, nextRetryAt: autopilot?.nextRetryAt ?? null },
scheduler: { pid: schedulerLock.alive ? schedulerLock.pid : scheduler?.pid ?? schedulerLock.pid, alive: schedulerLock.alive, state: pythonOwnsScheduler ? "python-owned" : scheduler?.state ?? null, updatedAt: pythonOwnsScheduler ? autopilot?.updatedAt : scheduler?.updatedAt ?? null, iteration: pythonOwnsScheduler ? null : scheduler?.iteration ?? null, before: pythonOwnsScheduler ? null : scheduler?.before ?? null, after: pythonOwnsScheduler ? null : scheduler?.after ?? null, latestBatch: pythonOwnsScheduler ? null : scheduler?.latestBatch ?? null },
guardian: { pid: guardian?.pid ?? guardianLock.pid, alive: guardianLock.alive, state: guardian?.state ?? null, updatedAt: guardian?.updatedAt ?? null, heartbeatAt: guardian?.heartbeatAt ?? null, restartCount: guardian?.restartCount ?? null },
supervisor: { pid: supervisor?.pid ?? supervisorLock.pid, alive: supervisorLock.alive, state: supervisor?.state ?? null, updatedAt: supervisor?.updatedAt ?? null, iteration: supervisor?.iteration ?? null, before: supervisor?.before ?? null, after: supervisor?.after ?? null },
latestHandoff: handoff,
@@ -127,6 +132,7 @@ const format = (status) => [
`阶段: ${status.phase}`,
`当前任务: ${status.queue.task ?? "unknown"}`,
`parent manifest: ${status.queue.parentManifest ?? "unknown"}`,
`Python autopilot: pid=${status.autopilot.pid ?? "none"} alive=${status.autopilot.alive} state=${status.autopilot.state ?? "unknown"} job=${status.autopilot.activeJob ?? "none"} reason=${status.autopilot.reason ?? "none"}`,
`scheduler: pid=${status.scheduler.pid ?? "none"} alive=${status.scheduler.alive} state=${status.scheduler.state ?? "unknown"} iteration=${status.scheduler.iteration ?? "none"} batch=${status.scheduler.latestBatch?.batchId ?? "none"} completed=${status.scheduler.latestBatch?.completed ?? false}`,
`guardian: pid=${status.guardian.pid ?? "none"} alive=${status.guardian.alive} state=${status.guardian.state ?? "unknown"} heartbeat=${status.guardian.heartbeatAt ?? "none"}`,
`supervisor: pid=${status.supervisor.pid ?? "none"} alive=${status.supervisor.alive} state=${status.supervisor.state ?? "unknown"} iteration=${status.supervisor.iteration ?? "none"}`,

View File

@@ -168,7 +168,7 @@ try {
guardianExit = stopSignal === "SIGINT" ? 130 : 143;
break;
}
if (checkpoint?.state === "complete") {
if (checkpoint?.state === "complete" && result.code === 0) {
writeCheckpoint({ state: "complete", restartCount, current: after, supervisorExit: result.code });
break;
}

View File

@@ -31,9 +31,39 @@ if (!Number.isSafeInteger(pollMs) || pollMs < 0) throw new Error("--poll-ms must
if (!Number.isSafeInteger(pushRetries) || pushRetries < 0) throw new Error("--push-retries must be a non-negative integer");
if (pushEnabled && !commitEnabled) throw new Error("--push requires --commit");
// A preflight must neither claim ownership nor rotate queue/checkpoint files.
if (preflightOnly) {
try {
const pointer = currentTask();
if (!pointer) throw new Error("queue pointer is missing; completion is unproven");
parentManifest(pointer);
const check = spawnSync(process.execPath, [abs("tools/web/check-task-context.mjs")], { cwd: root, encoding: "utf8" });
if (check.status !== 0) throw new Error("task context validation failed");
const readiness = terminalTask(pointer.task)
? { ok: true, terminal: true, reason: "accepted_terminal_manifest" }
: sourceMappingReadiness(pointer);
process.stdout.write(`${JSON.stringify({ task: pointer.task, ...readiness })}\n`);
process.exit(readiness.ok ? 0 : 2);
}
catch (error) {
process.stderr.write(`scheduler-preflight-blocked: ${error.message}\n`);
process.exit(2);
}
}
fs.mkdirSync(abs(controlRoot), { recursive: true });
let lockHandle;
const acquireLock = () => {
const pythonOwnerPath = abs(`${controlRoot}/autopilot/owner.lock`);
if (fs.existsSync(pythonOwnerPath)) {
const owner = JSON.parse(fs.readFileSync(pythonOwnerPath, "utf8"));
let alive = false;
if (Number.isSafeInteger(owner.pid) && owner.pid > 0) {
try { process.kill(owner.pid, 0); alive = true; }
catch (error) { if (error.code !== "ESRCH") alive = true; }
}
if (alive) throw new Error("the Python autopilot already owns the project");
}
try {
return fs.openSync(abs(lockPath), "wx");
}
@@ -186,7 +216,13 @@ function terminalTask(task) {
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
const manifestPath = `tests/golden/${task}/manifest.json`;
const manifest = fs.existsSync(abs(manifestPath)) ? JSON.parse(read(manifestPath)) : null;
return index.activeTask === null && manifest?.task === task && manifest.status === "done" && manifest.nextTask === null;
return index.activeTask === null && manifest?.task === task && manifest.status === "done" && manifest.nextTask === null
&& manifest.acceptanceComplete === true && manifest.verificationDeferred !== true
&& Object.keys(manifest.artifacts ?? {}).length > 0
&& Object.values(manifest.artifacts).every((artifact) => typeof artifact.path === "string"
&& typeof artifact.sha256 === "string" && !path.isAbsolute(artifact.path)
&& !artifact.path.split("/").includes("..")
&& crypto.createHash("sha256").update(fs.readFileSync(abs(artifact.path))).digest("hex") === artifact.sha256);
}
catch {
return false;
@@ -319,7 +355,7 @@ try {
// guardian/supervisor chain.
try {
const index = JSON.parse(read("tests/golden/WBV2/task-index.json"));
if (index.activeTask === null && index.entries?.[before.task]) {
if (index.activeTask === null && index.entries?.[before.task] && terminalTask(before.task)) {
const completedBatchInfo = { ...batchInfo(before), completed: true };
latestBatch = completedBatchInfo;
const planned = preflightOnly ? { terminal: true } : planNextBatch(before);

View File

@@ -12,7 +12,7 @@ const arg = (name, fallback = null) => {
return index >= 0 ? process.argv[index + 1] : fallback;
};
if (process.argv.includes("--help")) {
process.stdout.write("usage: node tools/web/search-wbv2-batch-source.mjs [--task <task-id>] --pattern <text> [--runtime <path> ...] or --list\n");
process.stdout.write("usage: node tools/web/search-wbv2-batch-source.mjs [--task <task-id>] --pattern <text> [--unit <source-unit-id> ...] [--runtime <path> ...] [--max-hits <count>] or --list\n");
process.exit(0);
}
const task = arg("--task") ?? read("docs/EXECUTION_QUEUE.md").match(/\| 当前任务 \| `([^`]+)` \|/u)?.[1];
@@ -32,6 +32,21 @@ for (const artifact of Object.values(manifest?.artifacts ?? {})) {
for (const source of taskRecord?.module?.sourcePaths ?? []) {
if (source.startsWith("blender-5.2.0/source/blender/")) sourcePaths.add(source);
}
const selectedUnits = [];
for (let i = 0; i < process.argv.length; i += 1) {
if (process.argv[i] === "--unit") selectedUnits.push(process.argv[++i]);
}
if (selectedUnits.length > 16) throw new Error("source context is limited to 16 explicit unit IDs");
if (selectedUnits.length) {
const inventory = json("tests/golden/WBV2/dynamic-inventory.json");
for (const id of selectedUnits) {
const unit = inventory.units.find((item) => item.id === id);
if (!unit) throw new Error(`unknown source unit: ${id}`);
for (const location of unit.locations ?? []) {
if (location.path.startsWith("blender-5.2.0/source/blender/")) sourcePaths.add(location.path);
}
}
}
const runtimeAllowlist = new Set([
"tools/web/run-wbv2-module-task.mjs",
"tools/web/run-wbv2-continuous.mjs",
@@ -78,6 +93,17 @@ const runtimeAllowlist = new Set([
"blender-5.2.0/source/blender/web_engine/web_engine_main_state.h",
]);
const runtimePaths = [];
// Product workflows are bounded subtasks of the current authoritative task.
const backlogPath = abs("tests/golden/WBV2/web-product-backlog.json");
if (fs.existsSync(backlogPath)) {
const backlog = JSON.parse(fs.readFileSync(backlogPath, "utf8"));
if (backlog.queueTask === task) {
for (const job of backlog.tasks ?? []) {
for (const file of job.ownedPaths ?? []) runtimeAllowlist.add(file);
}
}
}
for (const file of ["tools/web/deployment-http-server.mjs", "my-tools/wbv2_autopilot.py", "my-tools/run_web_product_migration.py", "tools/web/run-wbv2-scheduler.mjs", "tools/web/run-wbv2-guardian.mjs", "tools/web/check-wbv2-module-execution.mjs"]) runtimeAllowlist.add(file);
for (let index = 0; index < process.argv.length; index += 1) {
if (process.argv[index] === "--runtime") {
const file = process.argv[index + 1];
@@ -101,4 +127,6 @@ if (result.error) throw result.error;
if (result.status !== 0 && result.status !== 1) throw new Error(`batch search failed with exit ${result.status}`);
const output = result.stdout ?? "";
const hits = output ? output.trimEnd().split("\n").filter(Boolean) : [];
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, task, scope: "batch", pattern, sourcePathCount: sourcePaths.size, runtimePathCount: runtimePaths.length, hitCount: hits.length, hits }, null, 2)}\n`);
const maxHits = Number(arg("--max-hits", "60"));
if (!Number.isSafeInteger(maxHits) || maxHits < 1 || maxHits > 200) throw new Error("--max-hits must be between 1 and 200");
process.stdout.write(`${JSON.stringify({ schemaVersion: 1, task, scope: "batch", pattern, selectedUnits, sourcePathCount: sourcePaths.size, runtimePathCount: runtimePaths.length, hitCount: hits.length, truncated: hits.length > maxHits, hits: hits.slice(0, maxHits) }, null, 2)}\n`);

View File

@@ -32,12 +32,15 @@ export function inputFiles() {
export function fingerprint() {
const hash = crypto.createHash("sha256");
for (const file of inputFiles()) {
const stat = fs.statSync(file);
// Read nanosecond metadata as bigint. Without the explicit option
// `mtimeNs` is undefined in Node, so same-sized edits can reuse stale
// bundles indefinitely.
const stat = fs.statSync(file, { bigint: true });
hash.update(path.relative(root, file));
hash.update("\0");
hash.update(String(stat.size));
hash.update(stat.size.toString());
hash.update("\0");
hash.update(String(stat.mtimeNs));
hash.update(stat.mtimeNs.toString());
hash.update("\n");
}
return hash.digest("hex");

View File

@@ -3,9 +3,9 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#202124" />
<meta name="theme-color" content="#181818" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Web Blender Modeler V1</title>
<title>Blender 5.2.0 LTS - Web</title>
<link rel="preload" as="fetch" type="application/wasm" crossorigin href="/vendor/blender/single/web_engine.wasm" />
<link rel="stylesheet" href="/src/app/app-shell.css" />
</head>

View File

@@ -82,7 +82,7 @@ import {
} from "../volume/nanovdb-viewport";
import { composePaintColorPatch, composePaintWeightPatch, createSculptMeshAttributes, createSculptStroke } from "../../../protocol/paint";
import type { SculptBrush, SculptStrokeSampleIR } from "../../../protocol/sculpt";
import { createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import { BLENDER_WORKSPACE_IDS, createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import { parseEditorWorkflow, type EditorWorkflowIR } from "../../../protocol/editor-workflow";
import { importExternalVFontIntoMain } from "../fonts/external-vfont-import";
import { createIMECompositionState, reduceIMEComposition, shouldBlockOperatorShortcuts, type IMECompositionEvent } from "../../../protocol/ime-composition";
@@ -138,7 +138,7 @@ function StorageBudgetPanel({ budget, onCleanup }: { budget: StorageBudgetResult
["媒体", budget.mediaBytes],
["VDB", budget.vdbBytes],
] as const : [];
return <section className="storage-budget-panel" data-testid="storage-budget-panel" data-project-id={budget?.projectId ?? ""} data-total-bytes={budget?.totalBytes ?? 0} aria-label="存储预算"><strong>存储预算</strong>{values.map(([label, bytes]) => <span key={label} data-category={label} data-bytes={bytes}>{label} {formatStorageBytes(bytes)}</span>)}<output>合计 {formatStorageBytes(budget?.totalBytes ?? 0)}</output><button type="button" data-testid="cleanup-project-assets" aria-label="清理项目孤儿资源" disabled={!budget?.projectId || budget.projectId === "untitled"} onClick={onCleanup}>清理孤儿资源</button></section>;
return <section className="storage-budget-panel" data-testid="storage-budget-panel" data-project-id={budget?.projectId ?? ""} data-total-bytes={budget?.totalBytes ?? 0} aria-label="存储预算"><strong>Storage</strong>{values.map(([label, bytes]) => <span key={label} data-category={label} data-bytes={bytes}>{label} {formatStorageBytes(bytes)}</span>)}<output>{formatStorageBytes(budget?.totalBytes ?? 0)}</output><button type="button" data-testid="cleanup-project-assets" aria-label="清理项目孤儿资源" title="清理项目孤儿资源" disabled={!budget?.projectId || budget.projectId === "untitled"} onClick={onCleanup}>⌫</button></section>;
}
interface OperatorPaletteCommand {
@@ -418,12 +418,44 @@ interface AreaProps {
children: React.ReactNode;
}
const EDITOR_ICONS: Record<EditorType, string> = {
"3D Viewport": "◇",
Outliner: "☷",
Properties: "⚙",
Timeline: "▥",
"Dope Sheet": "◆",
"Image Editor": "▧",
"Render Result": "▧",
"Shader Editor": "⬡",
Compositor: "⬡",
"Geometry Nodes": "⬡",
"File Browser": "▤",
Spreadsheet: "▦",
"Text Editor": "▥",
"Python Console": ">_",
Info: "☰",
};
const WORKSPACE_EDITOR_MENUS: Record<WorkspaceId, string> = {
Layout: "View Select Add Object",
Modeling: "View Select Add Mesh Vertex Edge Face UV",
Sculpting: "Sculpt Mask Face Sets View",
"UV Editing": "View Select Image UV",
"Texture Paint": "View Paint Mask Face Selection",
Shading: "View Select Add Node",
Animation: "View Select Marker Channel Key",
Rendering: "View Image",
Compositing: "View Select Add Node",
"Geometry Nodes": "View Select Add Node",
Scripting: "View Text Templates",
};
function Area({ className = "", editor, children }: AreaProps) {
return (
<section className={`area-frame ${className}`} data-editor={editor}>
<header className="editor-header">
<button className="editor-selector" type="button" aria-label={`切换 ${editor} 编辑器`}>
<span className="editor-icon" aria-hidden="true">{editor === "3D Viewport" ? "◇" : editor === "Outliner" ? "☷" : editor === "Properties" ? "⚙" : "▥"}</span>
<span className="editor-icon" aria-hidden="true">{EDITOR_ICONS[editor]}</span>
<span>{editor}</span>
</button>
<span className="header-spacer" />
@@ -434,6 +466,47 @@ function Area({ className = "", editor, children }: AreaProps) {
);
}
function WorkspaceEditorSurface({ editor, snapshot, engineStatus, onOpen, onSearch }: {
editor: Exclude<EditorType, "Outliner" | "Properties" | "Timeline" | "Dope Sheet">;
snapshot: SceneSnapshotIR | null;
engineStatus: string;
onOpen: () => void;
onSearch: () => void;
}) {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
const nodeCount = editor === "Geometry Nodes" ? snapshot?.geometryNodeGraphs?.length ?? 0 : snapshot?.materials.filter((material) => material.shaderGraphHash).length ?? 0;
if (editor === "Image Editor" || editor === "Render Result") {
return <div className={`workspace-editor-surface image-editor-surface${editor === "Render Result" ? " render-result-surface" : ""}`}>
<div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} {editor}</strong><span>View</span><span>Image</span>{editor === "Image Editor" ? <span>UV</span> : null}<button type="button" onClick={editor === "Render Result" ? onSearch : onOpen}>{editor === "Render Result" ? "Render" : "Open"}</button></div>
<div className="image-canvas"><div className="uv-island" aria-label="UV preview"><i /><i /><i /><i /></div><span>{editor === "Render Result" ? "Render Result" : activeNode?.name ?? "UVMap"}</span></div>
</div>;
}
if (editor === "Shader Editor" || editor === "Compositor" || editor === "Geometry Nodes") {
const labels = editor === "Shader Editor" ? ["Principled BSDF", "Material Output"] : editor === "Compositor" ? ["Render Layers", "Composite"] : ["Group Input", "Group Output"];
return <div className="workspace-editor-surface node-editor-surface">
<div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} {editor}</strong><span>View</span><span>Select</span><span>Add</span><span>Node</span><button type="button" onClick={onSearch}>Search</button></div>
<div className="node-canvas" data-node-editor={editor}><span className="node-link" aria-hidden="true" />{labels.map((label, index) => <button key={label} type="button" className={`node-card node-card-${index + 1}`} onClick={onSearch}><strong>{label}</strong><span>{index === 0 ? "Output" : "Surface"}</span></button>)}<output>{nodeCount} graphs</output></div>
</div>;
}
if (editor === "File Browser") {
return <div className="workspace-editor-surface file-browser-surface"><div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} Asset Browser</strong><button type="button" onClick={onOpen}>Import</button></div><div className="file-browser-body"><nav><span>Current File</span><span>Essentials</span><span>Unassigned</span></nav><div className="asset-grid"><button type="button" onClick={onOpen}><span>◇</span>Material</button><button type="button" onClick={onOpen}><span>◫</span>World</button></div></div></div>;
}
if (editor === "Spreadsheet") {
return <div className="workspace-editor-surface spreadsheet-surface"><div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} Spreadsheet</strong><span>Geometry</span><span>Original</span></div><table><thead><tr><th>Name</th><th>Type</th><th>Visible</th></tr></thead><tbody>{(snapshot?.nodes ?? []).slice(0, 24).map((node) => <tr key={node.id}><td>{node.name}</td><td>{node.type}</td><td>{node.visible ? "True" : "False"}</td></tr>)}</tbody></table>{!snapshot ? <span className="surface-empty">No geometry data</span> : null}</div>;
}
if (editor === "Text Editor") {
const source = snapshot?.texts?.[0]?.source ?? "# Blender Python\nimport bpy\n\n# Select a text data-block or create a new script.";
return <div className="workspace-editor-surface text-editor-surface"><div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} Text Editor</strong><span>View</span><span>Text</span><span>Templates</span><button type="button" onClick={onOpen}>Open</button><button type="button" onClick={onSearch}>Run</button></div><pre><span className="line-numbers">1{source.split("\n").slice(1).map((_, index) => `\n${index + 2}`)}</span><code>{source}</code></pre></div>;
}
if (editor === "Python Console") {
return <div className="workspace-editor-surface console-surface"><div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} Python Console</strong><span>View</span><span>Console</span></div><div className="console-output"><span>PYTHON INTERACTIVE CONSOLE 3.11</span><span>Blender 5.2.0 LTS</span><label><span>&gt;&gt;&gt;</span><input aria-label="Python Console command" value="" readOnly placeholder="Python execution is policy-gated" /></label></div></div>;
}
if (editor === "Info") {
return <div className="workspace-editor-surface info-surface"><div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} Info</strong><span>View</span></div><output>{engineStatus}</output><output>Scene: {snapshot?.scenes[0]?.name ?? "Scene"}</output></div>;
}
return <div className="workspace-editor-surface camera-preview-surface"><span>Camera Perspective</span><div className="camera-frame"><span>{activeNode?.name ?? "Cube"}</span></div></div>;
}
interface MeshEditSelection {
meshId: string | null;
mode: MeshElementMode;
@@ -744,10 +817,7 @@ function ViewportPlaceholder({ snapshot, geometryBuffers, nonMeshGeometryBuffers
<div className="viewport-placeholder" role="region" aria-label="Three.js 视口区域">
<canvas ref={canvasRef} className="viewport-canvas" aria-label="Three.js WebGL2 视口" />
<div className="axis-gizmo" aria-hidden="true"><span className="axis-x">X</span><span className="axis-y">Y</span><span className="axis-z">Z</span></div>
{viewportError || !snapshot || snapshot.nodes.length === 0 ? <div className="viewport-message">
<strong>3D Viewport</strong>
{viewportError ? <span>WebGL 不可用,已保留场景编辑界面:{viewportError}</span> : <span>Three.js WebGL2 适配器</span>}
</div> : null}
{viewportError ? <div className="viewport-message"><strong>3D Viewport</strong><span>WebGL 不可用,已保留场景编辑界面:{viewportError}</span></div> : null}
{editMode && activeTool === "marquee" && marqueeDrawing ? <div className="grease-pencil-marquee-surface" data-testid="grease-pencil-marquee-surface" data-drawing-id={marqueeDrawing.drawingId} onPointerDown={beginMarquee} onPointerMove={moveMarquee} onPointerUp={finishMarquee} onPointerCancel={finishMarquee}>{marqueeBox ? <span className="grease-pencil-marquee-box" style={{ left: `${marqueeBox.left * 100}%`, top: `${marqueeBox.top * 100}%`, width: `${(marqueeBox.right - marqueeBox.left) * 100}%`, height: `${(marqueeBox.bottom - marqueeBox.top) * 100}%` }} /> : null}</div> : null}
<div className="viewport-toolbar" aria-label="视口工具">
<button type="button" className={`tool-button${activeTool === "marquee" ? " active" : ""}`} aria-label="Grease Pencil 框选工具" title="框选" disabled={!editMode || !marqueeDrawing} onClick={() => setActiveTool("marquee")}>□</button>
@@ -1461,9 +1531,9 @@ function Properties({ client, snapshot, editorWorkflow, selectedFaceIndices, sel
return (
<div className="properties-content">
<div className="property-tabs" role="tablist" aria-label="属性标签">
<button className="property-tab active" type="button" role="tab" aria-selected="true">对象</button>
<button className="property-tab" type="button" role="tab" aria-selected="false">修改器</button>
<button className="property-tab" type="button" role="tab" aria-selected="false">材质</button>
<button className="property-tab active" type="button" role="tab" aria-selected="true" title="对象属性"><span aria-hidden="true">▣</span><span className="property-tab-label">对象</span></button>
<button className="property-tab" type="button" role="tab" aria-selected="false" title="修改器属性"><span aria-hidden="true">⌕</span><span className="property-tab-label">修改器</span></button>
<button className="property-tab" type="button" role="tab" aria-selected="false" title="材质属性"><span aria-hidden="true">●</span><span className="property-tab-label">材质</span></button>
</div>
<div className="property-section"><h3>Transform</h3><label>位置 <output>0.000, 0.000, 0.000</output></label><label>旋转 <output>0°, 0°, 0°</output></label><label>缩放 <output>1.000, 1.000, 1.000</output></label></div>
{activeNode ? <div className="property-section"><h3>Object & Hierarchy</h3><label>名称 <input aria-label="对象名称" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} /></label><div className="property-actions"><button type="button" onClick={() => renameValue && onCommand({ type: "renameId", id: activeNode.id, name: renameValue })}>重命名</button><button type="button" onClick={() => onCommand({ type: "applyObjectTransform", objectId: activeNode.id })}>应用变换</button><button type="button" onClick={() => onCommand({ type: "setObjectOrigin", objectId: activeNode.id, mode: "GEOMETRY" })}>原点到几何体</button></div><label>Collection <select aria-label="移动到 Collection" value="" onChange={(event) => event.target.value && onCommand({ type: "moveObjectToCollection", objectId: activeNode.id, collectionId: event.target.value })}><option value="">选择...</option>{snapshot?.collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label></div> : null}
@@ -1984,7 +2054,9 @@ export function App() {
const saveBlendRef = useRef<() => Promise<void>>(async () => undefined);
const workspace = uiState.context.workspaceId;
const saved = !dirtyState.dirty;
const workspaceLabel = useMemo(() => `${workspace} Workspace`, [workspace]);
const workspaceLabel = WORKSPACE_EDITOR_MENUS[workspace];
const preferredWorkspaceMode = workspace === "Sculpting" ? "Sculpt" : workspace === "Texture Paint" ? "Texture Paint" : "Edit";
const workspaceNodeEditor: "Shader Editor" | "Compositor" | "Geometry Nodes" | null = workspace === "Shading" ? "Shader Editor" : workspace === "Compositing" ? "Compositor" : workspace === "Geometry Nodes" ? "Geometry Nodes" : null;
const activeGreasePencilDrawing = useMemo(() => currentGreasePencilDrawing(snapshot), [snapshot]);
const recordDiagnostic = (area: AppDiagnosticArea, code: AppDiagnosticCode, error: unknown, context?: Record<string, AppDiagnosticContextValue>): string => {
const entry = createAppDiagnosticEntry({
@@ -3463,7 +3535,7 @@ export function App() {
...(snapshot?.activeObjectId ? [{ id: "selectObject", label: "Select Active Object", keywords: "browser selection outliner", execute: () => selectObject(snapshot.activeObjectId!, false) }] : []),
{ id: "focusViewport", label: "Focus 3D Viewport", keywords: "browser view zoom pan orbit", execute: () => dispatchUI({ type: "setMode", mode: "Object" }) },
{ id: "focusEditor", label: "Focus Current Editor", keywords: "browser editor area region", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode }) },
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
...BLENDER_WORKSPACE_IDS.filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
...(snapshot && uiState.context.mode === "Object" && isWorkbenchCommandAvailable("createPrimitive") ? ([
["CUBE", "Cube"], ["PLANE", "Plane"], ["UV_SPHERE", "UV Sphere"], ["ICO_SPHERE", "Ico Sphere"],
@@ -3513,26 +3585,33 @@ export function App() {
data-grease-pencil-selection-drawing-id={greasePencilSelection?.drawing.drawingId ?? ""}
data-diagnostic-count={diagnostics.length} data-last-diagnostic-code={diagnostics.at(-1)?.code}>
<header className="topbar">
<div className="brand"><span className="brand-mark" aria-hidden="true">◈</span><span>Web Blender Modeler V1</span></div>
<div className="brand" title="Blender 5.2.0 LTS Web"><span className="brand-mark" aria-hidden="true"><span className="brand-orbit" /></span><span className="brand-name">Blender</span></div>
<nav className="menu-bar" aria-label="主菜单">{(["文件", "编辑", "渲染", "窗口", "帮助"] as const).map((menu) => <span key={menu} className="menu-entry"><button ref={(element) => { menuTriggerRefs.current[menu] = element; }} type="button" aria-haspopup="menu" aria-expanded={uiState.openMenu === menu} onClick={() => dispatchUI({ type: "toggleMenu", menu })}>{menu}</button>{uiState.openMenu === menu ? <MenuPopover menu={menu} labels={menu === "文件" ? ["打开", "保存", "关闭"] : menu === "编辑" ? ["撤销", "重做"] : menu === "渲染" ? ["导出 GLB"] : ["关闭菜单"]} onClose={closeMenu} onSelect={(label) => { closeMenu(); if (label === "打开") fileInputRef.current?.click(); else if (label === "保存") void saveBlend(); else if (label === "关闭") closeProject(); else if (label === "撤销") void applyEditCommand({ type: "undo" }); else if (label === "重做") void applyEditCommand({ type: "redo" }); else if (label === "导出 GLB") void reportGLBExport(); }} /> : null}</span>)}</nav>
<nav className="workspace-tabs" aria-label="工作区">
{(["Layout", "Modeling", "Animation"] as WorkspaceId[]).map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" aria-current={item === workspace ? "page" : undefined} onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
{BLENDER_WORKSPACE_IDS.map((item) => <button key={item} className={item === workspace ? "workspace-tab active" : "workspace-tab"} type="button" aria-current={item === workspace ? "page" : undefined} onClick={() => dispatchUI({ type: "switchWorkspace", workspaceId: item })}>{item}</button>)}
<button className="workspace-add" type="button" aria-label="添加工作区" title="添加工作区">+</button>
</nav>
<div className="topbar-actions"><select aria-label="最近项目" data-testid="recent-projects" defaultValue="" onChange={(event) => { const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName); event.currentTarget.value = ""; }}><option value="">最近项目 ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" onClick={() => fileInputRef.current?.click()}>打开</button><button type="button" aria-label="关闭项目" disabled={!snapshot} onClick={closeProject}>关闭</button><button type="button" aria-label="恢复项目" onClick={() => void recoverCachedProject()}>恢复</button><button type="button" aria-label="保存项目" onClick={() => void saveBlend()}>保存</button><button type="button" aria-label="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}>诊断</button><button type="button" aria-label="撤销" onClick={() => void applyEditCommand({ type: "undo" })}>↶</button><button type="button" aria-label="重做" onClick={() => void applyEditCommand({ type: "redo" })}>↷</button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" aria-haspopup="dialog" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button>{workerFaultTestMode ? <button type="button" data-testid="inject-worker-crash" aria-label="注入 Worker 崩溃" onClick={injectWorkerCrash}>Fault</button> : null}</div>
<div className="topbar-actions"><span className="topbar-context" title="当前场景">Scene</span><span className="topbar-context" title="当前视图层">ViewLayer</span><select aria-label="最近项目" title="最近项目" data-testid="recent-projects" defaultValue="" onChange={(event) => { const projectId = event.target.value; const displayName = event.target.selectedOptions[0]?.textContent ?? projectId; if (!projectId) return; void recoverCachedProject(projectId, displayName); event.currentTarget.value = ""; }}><option value="">Recent ({recentProjects.length})</option>{recentProjects.map((project) => <option key={project.projectId} value={project.projectId}>{project.displayName}</option>)}</select><button type="button" aria-label="打开 .blend" title="打开 .blend" onClick={() => fileInputRef.current?.click()}>⌕</button><button type="button" aria-label="关闭项目" title="关闭项目" disabled={!snapshot} onClick={closeProject}>×</button><button type="button" aria-label="恢复项目" title="恢复项目" onClick={() => void recoverCachedProject()}>↺</button><button type="button" aria-label="保存项目" title="保存项目" onClick={() => void saveBlend()}>▣</button><button type="button" aria-label="导出 GLB" title="导出 GLB" onClick={() => void reportGLBExport()}>GLB</button><button type="button" aria-label="导出诊断报告" title="导出诊断报告" data-testid="export-diagnostics" disabled={diagnostics.length === 0} onClick={exportDiagnosticReport}>!</button><button type="button" aria-label="撤销" title="撤销" onClick={() => void applyEditCommand({ type: "undo" })}>↶</button><button type="button" aria-label="重做" title="重做" onClick={() => void applyEditCommand({ type: "redo" })}>↷</button><button ref={operatorSearchTriggerRef} type="button" aria-label="操作搜索" title="操作搜索 (F3)" aria-haspopup="dialog" onClick={() => dispatchUI({ type: "toggleOperatorSearch", open: true })}>F3</button>{workerFaultTestMode ? <button type="button" data-testid="inject-worker-crash" aria-label="注入 Worker 崩溃" onClick={injectWorkerCrash}>Fault</button> : null}</div>
<input ref={fileInputRef} className="file-input-hidden" type="file" accept={ioFormatUIAccept(IO_FORMAT_UI_REGISTRY)} aria-label="打开 .blend 文件" data-testid="blend-file-input" data-io-format-import-routes={IO_FORMAT_UI_REGISTRY.importRoutes.map((route) => route.format).join(",")} onChange={(event) => { const file = event.target.files?.[0]; if (file) { const gate = gateIOFormatFileSelection(file.name, IO_FORMAT_UI_REGISTRY); if (gate.status === "BLOCKED") setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); else if (file.name.toLowerCase().endsWith(".blend")) void openBlendFile(file); else { const route = IO_FORMAT_UI_REGISTRY.importRoutes.find((candidate) => candidate.execution === "LOCAL" && candidate.extensions.some((extension) => file.name.toLowerCase().endsWith(extension))); if (route) void importSceneFile(file, route.format); else setEngineStatus(recordDiagnostic("ENGINE", "IO_FORMAT_UNSUPPORTED", gate, { fileName: file.name })); } } event.target.value = ""; }} />
</header>
{workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING"}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null}
{recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span>最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。</span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}>移除引用</button></div>)}</div></div> : null}
<StorageBudgetPanel budget={storageBudget} onCleanup={() => void cleanupProjectAssets()} />
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0 && operation !== "RECALCULATE_NORMALS"} onClick={() => runMeshEdit(operation)}>{operation.replaceAll("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => <button key={primitive} type="button" aria-label={primitive === "CUBE" ? "添加立方体" : `添加 ${primitive.toLowerCase().replaceAll("_", " ")}`} onClick={() => void applyEditCommand(coreSceneCommands.createPrimitive(primitive))}>Add {primitive.replaceAll("_", " ")}</button>)}<button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
<div className="workspace-toolbar"><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? preferredWorkspaceMode : "Object" })}>{uiState.context.mode} Mode</button><span className="workspace-editor-menus">{workspaceLabel}</span>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0 && operation !== "RECALCULATE_NORMALS"} onClick={() => runMeshEdit(operation)}>{operation.replaceAll("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => <button key={primitive} type="button" aria-label={primitive === "CUBE" ? "添加立方体" : `添加 ${primitive.toLowerCase().replaceAll("_", " ")}`} onClick={() => void applyEditCommand(coreSceneCommands.createPrimitive(primitive))}>Add {primitive.replaceAll("_", " ")}</button>)}<button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
<div className="workspace-grid">
{workspace === "UV Editing" || workspace === "Texture Paint" || workspace === "Shading" || workspace === "Compositing" ? <Area className="image-editor-area" editor="Image Editor"><WorkspaceEditorSurface editor="Image Editor" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspace === "Rendering" ? <Area className="render-result-area" editor="Render Result"><WorkspaceEditorSurface editor="Render Result" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspaceNodeEditor ? <Area className="node-editor-area" editor={workspaceNodeEditor}><WorkspaceEditorSurface editor={workspaceNodeEditor} snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspace === "Shading" ? <Area className="file-browser-area" editor="File Browser"><WorkspaceEditorSurface editor="File Browser" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspace === "Animation" ? <Area className="camera-view-area" editor="3D Viewport"><WorkspaceEditorSurface editor="3D Viewport" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspace === "Geometry Nodes" ? <Area className="spreadsheet-area" editor="Spreadsheet"><WorkspaceEditorSurface editor="Spreadsheet" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspace === "Scripting" ? <><Area className="text-editor-area" editor="Text Editor"><WorkspaceEditorSurface editor="Text Editor" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area><Area className="console-area" editor="Python Console"><WorkspaceEditorSurface editor="Python Console" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area><Area className="info-area" editor="Info"><WorkspaceEditorSurface editor="Info" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area><Area className="secondary-outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area></> : null}
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
<Area className="properties-area" editor="Properties"><Properties client={webClientRef.current} snapshot={snapshot} editorWorkflow={editorWorkflow} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilSelection={greasePencilSelection} onGreasePencilCanvasPointSelect={(point, additive, baseSelectionRevision) => { commitGreasePencilSelection("CANVAS_2D", additive ? "TOGGLE" : "REPLACE", [point], baseSelectionRevision, point); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onImportVFont={(file) => void importVFont(file)} onRequestAsset={(assetId) => { const client = webClientRef.current; if (!client) return; void client.requestAsset(assetId).then((asset) => setEngineStatus(`Asset: ${asset.assetId} (${asset.status})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId }))); }} onAppendLibraryObject={async (source, request) => { const client = webClientRef.current; if (!client) return; try { const result = await client.appendLibraryObject(source, request); setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Library append: ${result.receipt.sourceDataBlockId}`); } catch (error) { setEngineStatus(recordDiagnostic("ENGINE", "COMMAND_FAILED", error)); } }} onAssetLibraryCommand={onAssetLibraryCommand} onQueryRenderCapability={(request) => { const client = webClientRef.current; if (!client) return; void client.queryRenderCapability(request).then((gate) => setEngineStatus(`Render: ${gate.status} (${gate.capability})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "GLB_EXPORT_BLOCKED", error))); }} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
<Area className="timeline-area" editor={workspace === "Animation" ? "Dope Sheet" : "Timeline"}><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <BlenderOperatorPalette client={webClientRef.current} commands={operatorCommands} onClose={closeOperatorSearch} onApplied={(result, route) => { setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; const routeLabel = (route as unknown as { identifier?: string; id?: string }).identifier ?? (route as unknown as { id?: string }).id ?? "unknown"; setEngineStatus(`Route: ${routeLabel}`); }} /> : null}
<footer className="status-bar"><span>Web Blender Modeler V1</span><span data-testid="scene-stats">Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span>{manifestStatus}</span><span>{wasmStatus}</span><span data-testid="engine-status">{engineStatus}</span><span>{storageStatus}</span></footer>
<footer className="status-bar"><span className="status-hint">LMB Select · MMB Rotate View · Wheel Zoom</span><span data-testid="scene-stats">Collection | Cube · Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span className="runtime-status">{manifestStatus}</span><span className="runtime-status" data-testid="wasm-status">{wasmStatus}</span><span className="runtime-status" data-testid="engine-status">{engineStatus}</span><span className="runtime-status">{storageStatus}</span><StorageBudgetPanel budget={storageBudget} onCleanup={() => void cleanupProjectAssets()} /><span className="blender-version">5.2.0 LTS</span></footer>
</main>
);
}

View File

@@ -1,102 +1,179 @@
:root {
color: #e5e7eb;
background: #18191c;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 13px;
color: #d9d9d9;
background: #181818;
font-family: "Noto Sans", Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 12px;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; }
button, input { font: inherit; }
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; background: #181818; }
button, input, select, textarea { font: inherit; letter-spacing: 0; }
button { color: inherit; border: 0; cursor: pointer; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible { outline: 2px solid #77b9f4; outline-offset: -2px; }
.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 38px 34px 30px minmax(0, 1fr) 24px; width: 100%; height: 100vh; min-height: 480px; background: #202124; }
.topbar, .workspace-toolbar, .status-bar { display: flex; align-items: center; gap: 8px; padding: 0 10px; background: #27282b; border-bottom: 1px solid #111214; }
.topbar { gap: 14px; }
.brand { display: flex; align-items: center; gap: 7px; min-width: 120px; font-weight: 700; color: #f4f4f5; }
.brand-mark { color: #e37a2c; font-size: 18px; }
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; gap: 2px; }.menu-entry { position: relative; display: inline-flex; }
.menu-bar button, .topbar-actions button, .workspace-tab, .workspace-toolbar button { padding: 5px 8px; background: transparent; border-radius: 3px; color: #bfc2c8; }
.menu-bar button:hover, .topbar-actions button:hover, .workspace-tab:hover, .workspace-toolbar button:hover { background: #3a3c41; color: #fff; }
.topbar-actions button:disabled { color: #6f737b; background: transparent; cursor: default; }
.workspace-tabs { align-self: stretch; gap: 0; }
.workspace-tab { min-width: 82px; border-bottom: 2px solid transparent; border-radius: 0; }
.workspace-tab.active { color: #fff; background: #313338; border-bottom-color: #e37a2c; }
.topbar-actions { margin-left: auto; }
.workspace-toolbar { min-width: 0; overflow-x: auto; color: #aeb2bb; border-bottom-color: #111214; white-space: nowrap; scrollbar-width: thin; }
.mode-chip { padding: 3px 8px; color: #dedfe2; background: #35373c; border-radius: 3px; }
.segmented { display: inline-flex; flex: none; border: 1px solid #4a4d54; border-radius: 3px; overflow: hidden; }.segmented button { border-radius: 0; border-right: 1px solid #4a4d54; }.segmented button:last-child { border-right: 0; }.segmented button.active { color: #fff; background: #a55325; }
.blender-app { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 28px 30px minmax(0, 1fr) 20px; width: 100%; height: 100vh; min-height: 480px; background: #202020; }
.topbar, .workspace-toolbar, .status-bar { display: flex; align-items: center; min-width: 0; background: #1d1d1d; border-bottom: 1px solid #0f0f0f; }
.topbar { gap: 2px; padding: 0 5px; overflow: hidden; box-shadow: inset 0 -1px #303030; }
.brand { display: flex; align-items: center; flex: none; gap: 5px; height: 100%; padding: 0 4px 0 2px; color: #f2f2f2; font-weight: 600; }
.brand-name { font-size: 11px; }
.brand-mark { position: relative; display: block; width: 21px; height: 14px; }
.brand-mark::before { position: absolute; left: 1px; top: 4px; width: 13px; height: 7px; background: #ea7600; border-radius: 60% 35% 60% 35%; transform: rotate(-12deg); content: ""; }
.brand-mark::after { position: absolute; left: 9px; top: 3px; width: 8px; height: 8px; border: 2px solid #ea7600; border-radius: 50%; background: #477eaf; content: ""; }
.brand-orbit { position: absolute; left: 0; top: 1px; width: 10px; height: 3px; border-top: 2px solid #ea7600; transform: rotate(17deg); }
.menu-bar, .workspace-tabs, .topbar-actions { display: flex; align-items: center; min-width: 0; gap: 1px; }.menu-entry { position: relative; display: inline-flex; flex: none; }
.menu-bar { flex: none; }
.menu-bar button, .topbar-actions button, .workspace-tab, .workspace-add, .workspace-toolbar button { min-height: 24px; padding: 3px 7px; color: #c9c9c9; background: transparent; border-radius: 2px; }
.menu-bar button:hover, .topbar-actions button:hover, .workspace-tab:hover, .workspace-add:hover, .workspace-toolbar button:hover { color: #fff; background: #3a3a3a; }
.topbar-actions button:disabled { color: #606060; background: transparent; cursor: default; }
.workspace-tabs { flex: 1 1 auto; align-self: stretch; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
.workspace-tabs::-webkit-scrollbar, .topbar::-webkit-scrollbar, .workspace-toolbar::-webkit-scrollbar { display: none; }
.workspace-tab { flex: none; min-width: auto; padding-inline: 6px; border: 0; border-radius: 0; font-size: 10px; }
.workspace-tab.active { color: #fff; background: #303030; box-shadow: inset 0 -2px #4c94c8; }
.workspace-add { flex: none; width: 24px; padding: 0; font-size: 16px; }
.topbar-actions { flex: none; margin-left: auto; }
.topbar-actions button { min-width: 24px; }
.topbar-actions select { width: 78px; height: 22px; color: #cfcfcf; background: #292929; border: 1px solid #444; border-radius: 2px; }
.topbar-context { padding: 2px 6px; color: #bdbdbd; border-left: 1px solid #383838; white-space: nowrap; }
.workspace-toolbar { gap: 2px; padding: 0 6px; overflow-x: auto; color: #bababa; background: #292929; white-space: nowrap; scrollbar-width: none; }
.workspace-toolbar::before { flex: none; color: #e67e22; font-size: 14px; content: "◇"; }
.workspace-editor-menus { flex: none; padding: 0 5px; color: #d0d0d0; }
.mode-chip { min-width: 86px; padding: 2px 8px; color: #e3e3e3; background: #343434 !important; border: 1px solid #484848; }
.segmented { display: inline-flex; flex: none; height: 22px; border: 1px solid #4b4b4b; border-radius: 2px; overflow: hidden; }.segmented button { min-height: 20px; border-radius: 0; border-right: 1px solid #4b4b4b; }.segmented button:last-child { border-right: 0; }.segmented button.active { color: #fff; background: #4775a3; }
.toolbar-spacer, .header-spacer, .status-spacer { flex: 1; }
.workspace-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; grid-template-rows: minmax(0, 1fr) 164px; min-height: 0; gap: 1px; background: #111214; }
.area-frame { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: #24262a; }
.editor-header { display: flex; align-items: center; min-height: 28px; padding: 0 6px; background: #303238; border-bottom: 1px solid #17181b; }
.workspace-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; grid-template-rows: minmax(180px, 1fr) minmax(220px, 2fr) 108px; min-height: 0; gap: 1px; background: #0f0f0f; }
.area-frame { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: #252525; }
.editor-header { display: flex; align-items: center; flex: none; min-height: 27px; padding: 0 4px; background: #292929; border-bottom: 1px solid #151515; box-shadow: inset 0 -1px #333; }
.editor-selector, .icon-button { display: inline-flex; align-items: center; gap: 6px; min-width: 24px; min-height: 24px; padding: 4px 6px; background: transparent; color: #e1e3e7; border-radius: 3px; }
.editor-selector:hover, .icon-button:hover { background: #41434a; }
.editor-icon { color: #e37a2c; font-size: 14px; }
.icon-button { color: #a9adb5; }
.editor-selector:hover, .icon-button:hover { background: #414141; }
.editor-icon { color: #e67e22; font-size: 14px; }
.icon-button { color: #aaa; }
.area-content { position: relative; flex: 1; min-height: 0; overflow: auto; }
.viewport-area { grid-column: 1; grid-row: 1; }
.viewport-area { grid-column: 1; grid-row: 1 / 3; }
.outliner-area { grid-column: 2; grid-row: 1; }
.properties-area { grid-column: 2; grid-row: 2; }
.timeline-area { grid-column: 1; grid-row: 2; }
.viewport-placeholder { position: relative; width: 100%; height: 100%; min-height: 260px; overflow: hidden; background: #25272b; }
.properties-area { grid-column: 2; grid-row: 2 / 4; }
.timeline-area { grid-column: 1; grid-row: 3; }
.viewport-area > .editor-header, .timeline-area > .editor-header { display: none; }
.viewport-placeholder { position: relative; width: 100%; height: 100%; min-height: 260px; overflow: hidden; background-color: #393939; background-image: linear-gradient(#414141 1px, transparent 1px), linear-gradient(90deg, #414141 1px, transparent 1px); background-size: 32px 32px; }
.viewport-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.grease-pencil-marquee-surface { position: absolute; inset: 0; z-index: 1; cursor: crosshair; touch-action: none; }
.grease-pencil-marquee-box { position: absolute; border: 1px solid #7fc4ff; background: #4c9ed126; box-shadow: 0 0 0 1px #17212aaa; pointer-events: none; }
.viewport-grid { position: absolute; inset: 0; opacity: .3; background-image: linear-gradient(#62656b 1px, transparent 1px), linear-gradient(90deg, #62656b 1px, transparent 1px); background-size: 32px 32px; transform: perspective(500px) rotateX(58deg) scale(1.7); transform-origin: 50% 100%; }
.viewport-placeholder::after { position: absolute; inset: 48% 0 0; border-top: 1px solid #777b83; content: ""; opacity: .5; pointer-events: none; }
.viewport-placeholder::after { position: absolute; inset: 50% 0 0; border-top: 1px solid #505050; content: ""; pointer-events: none; }
.viewport-message { position: absolute; top: 50%; left: 50%; z-index: 1; display: grid; gap: 5px; transform: translate(-50%, -50%); text-align: center; color: #c8cbd0; }
.viewport-message strong { color: #f3f4f6; font-size: 15px; }
.viewport-message span { color: #969ba4; }
.axis-gizmo { position: absolute; top: 14px; right: 14px; z-index: 2; width: 54px; height: 54px; border: 1px solid #545860; border-radius: 50%; color: #c2c5ca; font-size: 11px; }
.axis-gizmo span { position: absolute; padding: 2px; border-radius: 2px; background: #35373d; }.axis-x { right: -3px; top: 23px; color: #f06a6a; }.axis-y { left: 21px; top: -4px; color: #75d18d; }.axis-z { left: 3px; bottom: 4px; color: #6fa4f5; }
.viewport-toolbar { position: absolute; top: 14px; left: 12px; z-index: 2; display: grid; gap: 3px; padding: 4px; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.tool-button { width: 27px; height: 27px; background: transparent; color: #c7cad0; border-radius: 3px; }.tool-button:hover, .tool-button.active { color: #fff; background: #d26928; }
.axis-gizmo { position: absolute; top: 13px; right: 16px; z-index: 2; width: 54px; height: 54px; color: #c2c5ca; font-size: 11px; }
.axis-gizmo span { position: absolute; min-width: 18px; padding: 2px; text-align: center; border-radius: 50%; background: #353535; box-shadow: 0 0 0 1px #202020; }.axis-x { right: -3px; top: 23px; color: #ed6a6a; }.axis-y { left: 21px; top: -4px; color: #6fc47d; }.axis-z { left: 3px; bottom: 4px; color: #6e9ee8; }
.viewport-toolbar { position: absolute; top: 12px; left: 7px; z-index: 2; display: grid; gap: 2px; padding: 3px; background: #292929e8; border: 1px solid #4a4a4a; border-radius: 3px; box-shadow: 0 2px 8px #0005; }.tool-button { width: 28px; height: 28px; background: transparent; color: #ccc; border-radius: 2px; }.tool-button:hover, .tool-button.active { color: #fff; background: #4775a3; }
.tool-button:disabled { color: #666a72; cursor: default; }.tool-button:disabled:hover { background: transparent; }
.transform-gizmo { position: absolute; left: 50%; top: 50%; z-index: 2; width: 92px; height: 92px; transform: translate(-46px, -46px); pointer-events: none; }
.transform-gizmo.handle-local::before { position: absolute; left: 42px; top: 42px; width: 8px; height: 8px; border: 1px solid #fff; border-radius: 50%; background: #24262b; box-shadow: 0 0 0 2px #24262baa; content: ""; }
.gizmo-axis { position: absolute; width: 28px; height: 28px; padding: 0; border: 2px solid currentColor; border-radius: 50%; background: #24262bcc; font-weight: 700; pointer-events: auto; touch-action: none; }
.gizmo-axis.axis-x { left: 60px; top: 32px; color: #e35b55; }.gizmo-axis.axis-y { left: 32px; top: 4px; color: #65bb70; }.gizmo-axis.axis-z { left: 32px; top: 60px; color: #5d8ee8; }
.viewport-sidebar { position: absolute; top: 14px; right: 12px; z-index: 2; display: grid; gap: 7px; padding: 9px; color: #aeb3bc; background: #303238d9; border: 1px solid #45484f; border-radius: 4px; }.viewport-sidebar span { writing-mode: vertical-rl; }
.outliner-content, .properties-content { padding: 8px; color: #c8cbd0; }
.outliner-tools { display: flex; gap: 4px; margin-bottom: 8px; }.outliner-tools input { min-width: 0; flex: 1; padding: 5px 7px; color: #e4e6ea; background: #1d1f22; border: 1px solid #464950; border-radius: 3px; outline: none; }.outliner-tools button { width: 30px; background: #3a3c42; border-radius: 3px; }
.tree-row { display: flex; align-items: center; gap: 7px; min-height: 26px; padding: 2px 5px; border-radius: 3px; }.tree-row.child { padding-left: 24px; }.tree-row.selected { color: #fff; background: #a55325; }.tree-icon { color: #d7a04b; }.tree-icon.mesh { color: #72a7dc; }.tree-action { width: 24px; min-width: 24px; height: 24px; margin-left: auto; padding: 2px 4px; color: #8f949d; background: transparent; border: 0; border-radius: 3px; }.tree-action:hover { color: #fff; background: #41434a; }
.property-tabs { display: flex; gap: 2px; margin-bottom: 8px; border-bottom: 1px solid #42454b; }.property-tab { padding: 6px 8px; color: #aeb3bc; background: transparent; border-bottom: 2px solid transparent; }.property-tab.active { color: #fff; border-bottom-color: #e37a2c; }.property-section { padding: 8px 0; border-bottom: 1px solid #383a40; }.property-section h3 { margin: 0 0 8px; color: #eceef1; font-size: 12px; font-weight: 600; }.property-section label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 24px; color: #9fa4ad; }.property-section output { color: #e0e2e6; font-variant-numeric: tabular-nums; }.property-section select { min-width: 118px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="range"] { flex: 1; min-width: 78px; accent-color: #e37a2c; }.modifier-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; color: #7f858e; font-size: 11px; }.modifier-summary button { padding: 4px 9px; color: #fff; background: #b45f29; border: 1px solid #dc8243; border-radius: 2px; }.modifier-summary button:disabled { color: #777c84; background: #303237; border-color: #45484e; }.swatch { width: 32px; height: 14px; background: #a35d43; border: 1px solid #d18a68; border-radius: 2px; }
.viewport-sidebar { position: absolute; top: 72px; right: 0; z-index: 2; display: flex; gap: 1px; padding: 0; color: #c2c2c2; background: #292929e8; border: 1px solid #484848; border-right: 0; border-radius: 3px 0 0 3px; }.viewport-sidebar span { padding: 8px 5px; writing-mode: vertical-rl; }
.outliner-content { padding: 3px 2px; color: #d0d0d0; }
.properties-content { position: relative; display: flex; flex-direction: column; min-height: 100%; padding: 4px 7px 12px 40px; color: #d0d0d0; background: #303030; }
.outliner-tools { display: flex; gap: 3px; margin: 0 2px 4px; }.outliner-tools input { min-width: 0; height: 23px; flex: 1; padding: 3px 7px; color: #e4e4e4; background: #181818; border: 1px solid #454545; border-radius: 3px; outline: none; }.outliner-tools button { width: 27px; background: #383838; border-radius: 2px; }
.tree-row { display: flex; align-items: center; gap: 5px; min-height: 24px; padding: 1px 4px; border-radius: 0; }.tree-row:hover { background: #353535; }.tree-row.child { padding-left: 26px; }.tree-row.selected { color: #fff; background: #4775a3; }.tree-icon { color: #d59b44; }.tree-icon.mesh { color: #6999c8; }.tree-action { width: 24px; min-width: 24px; height: 24px; margin-left: auto; padding: 1px 3px; color: #a6a6a6; background: transparent; border: 0; border-radius: 2px; }.tree-action:hover { color: #fff; background: #4a4a4a; }
.property-tabs { position: absolute; left: 0; top: 0; bottom: 0; display: flex; flex-direction: column; gap: 1px; width: 34px; margin: 0; padding: 4px 3px; background: #242424; border-right: 1px solid #151515; }.property-tab { display: grid; place-items: center; width: 27px; height: 27px; padding: 0; color: #aaa; background: transparent; border-radius: 2px; }.property-tab:hover { color: #fff; background: #404040; }.property-tab.active { color: #fff; background: #4775a3; }.property-tab-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }.property-section { padding: 7px 3px 9px; border-bottom: 1px solid #454545; }.property-section h3 { margin: 0 0 7px; padding: 4px 6px; color: #eee; background: #383838; border-radius: 2px; font-size: 11px; font-weight: 600; }.property-section label { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 23px; color: #b5b5b5; }.property-section output { display: block; color: #ddd; font-variant-numeric: tabular-nums; }.property-section select { min-width: 118px; padding: 3px 5px; color: #e0e0e0; background: #252525; border: 1px solid #505050; border-radius: 2px; }.property-section input[type="range"] { flex: 1; min-width: 78px; accent-color: #4775a3; }.modifier-summary { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; color: #8e8e8e; font-size: 11px; }.modifier-summary button { padding: 4px 9px; color: #fff; background: #4775a3; border: 1px solid #6193c3; border-radius: 2px; }.modifier-summary button:disabled { color: #777; background: #303030; border-color: #454545; }.swatch { width: 32px; height: 14px; background: #a35d43; border: 1px solid #d18a68; border-radius: 2px; }
.properties-content > [data-testid="scene-context-workbench"], .properties-content > [data-testid="editor-workflow-workbench"], .properties-content > [data-testid="capability-family-workbench"] { order: 90; }
.properties-content > [data-testid="feature-route-coverage"], .properties-content > [data-testid="release-status-workbench"] { order: 100; }
.property-section > button { min-height: 24px; margin: 2px; padding: 3px 7px; color: #e2e2e2; background: #454545; border: 1px solid #575757; border-radius: 2px; }
.property-section > button:hover { background: #555; }
.workspace-editor-surface { width: 100%; height: 100%; min-width: 0; min-height: 0; color: #c8c8c8; background: #282828; overflow: hidden; }
.image-editor-area > .editor-header, .render-result-area > .editor-header, .node-editor-area > .editor-header, .file-browser-area > .editor-header, .spreadsheet-area > .editor-header, .text-editor-area > .editor-header, .console-area > .editor-header, .info-area > .editor-header { display: none; }
.surface-menubar { display: flex; align-items: center; gap: 12px; height: 26px; padding: 0 7px; color: #c8c8c8; background: #292929; border-bottom: 1px solid #181818; white-space: nowrap; }
.surface-menubar strong { color: #ddd; font-size: 11px; font-weight: 500; }
.surface-menubar button { min-height: 22px; margin-left: auto; padding: 2px 8px; color: #ddd; background: #424242; border: 1px solid #555; border-radius: 2px; }
.image-canvas { position: relative; display: grid; place-items: center; height: calc(100% - 26px); color: #888; background-color: #202020; background-image: linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px; }
.uv-island { position: absolute; width: min(52%, 260px); aspect-ratio: 1; border: 1px solid #e58b35; transform: rotate(8deg); }
.uv-island::before, .uv-island::after, .uv-island i { position: absolute; background: #e58b35; content: ""; }
.uv-island::before { left: 50%; top: 0; width: 1px; height: 100%; }.uv-island::after { left: 0; top: 50%; width: 100%; height: 1px; }
.uv-island i:nth-child(1), .uv-island i:nth-child(2) { top: 0; width: 1px; height: 141%; transform-origin: top; }.uv-island i:nth-child(1) { left: 0; transform: rotate(-45deg); }.uv-island i:nth-child(2) { right: 0; transform: rotate(45deg); }
.uv-island i:nth-child(3), .uv-island i:nth-child(4) { width: 6px; height: 6px; border-radius: 50%; }.uv-island i:nth-child(3) { left: -3px; top: -3px; }.uv-island i:nth-child(4) { right: -3px; bottom: -3px; }
.render-result-surface .image-canvas { background-color: #181818; background-image: linear-gradient(45deg, #202020 25%, transparent 25%), linear-gradient(-45deg, #202020 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #202020 75%), linear-gradient(-45deg, transparent 75%, #202020 75%); }
.render-result-surface .uv-island { display: none; }
.node-canvas { position: relative; height: calc(100% - 26px); background-color: #1f1f1f; background-image: linear-gradient(#292929 1px, transparent 1px), linear-gradient(90deg, #292929 1px, transparent 1px); background-size: 20px 20px; overflow: hidden; }
.node-canvas > output { position: absolute; right: 8px; bottom: 6px; color: #aaa; }
.node-card { position: absolute; z-index: 2; display: grid; align-content: start; gap: 10px; width: 150px; height: 88px; padding: 26px 10px 8px; text-align: left; color: #ddd; background: #353535; border: 1px solid #565656; border-radius: 5px; box-shadow: 0 4px 10px #0007; }
.node-card::before { position: absolute; inset: 0 0 auto; height: 20px; background: #4775a3; border-radius: 4px 4px 0 0; content: ""; }.node-card strong { z-index: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.node-card span { color: #d0d0d0; }.node-card-1 { left: 14%; top: 32%; }.node-card-2 { right: 14%; top: 46%; }.node-link { position: absolute; left: 32%; top: 48%; width: 40%; height: 2px; background: #d09b45; transform: rotate(7deg); transform-origin: left; }
.file-browser-body { display: grid; grid-template-columns: 118px minmax(0, 1fr); height: calc(100% - 26px); }.file-browser-body nav { display: grid; align-content: start; gap: 5px; padding: 8px; color: #aaa; background: #242424; border-right: 1px solid #151515; }.file-browser-body nav span { padding: 4px; }.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); align-content: start; gap: 7px; padding: 8px; }.asset-grid button { display: grid; justify-items: center; gap: 4px; min-height: 70px; padding: 8px; color: #ccc; background: transparent; border-radius: 2px; }.asset-grid button:hover { background: #3c3c3c; }.asset-grid button span { font-size: 25px; color: #cb873f; }
.spreadsheet-surface { overflow: auto; }.spreadsheet-surface table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }.spreadsheet-surface th, .spreadsheet-surface td { height: 24px; padding: 3px 7px; text-align: left; border-right: 1px solid #404040; border-bottom: 1px solid #383838; }.spreadsheet-surface th { position: sticky; top: 26px; background: #333; }.surface-empty { display: block; padding: 20px; color: #b0b0b0; text-align: center; }
.text-editor-surface pre { display: grid; grid-template-columns: 38px minmax(0, 1fr); height: calc(100% - 26px); margin: 0; padding: 8px 0; overflow: auto; color: #d6d6d6; background: #202020; font: 12px/1.7 ui-monospace, SFMono-Regular, Consolas, monospace; }.line-numbers { padding-right: 8px; color: #aaa; text-align: right; border-right: 1px solid #383838; }.text-editor-surface code { padding: 0 10px; color: #9ecbff; white-space: pre; }
.console-output { display: grid; align-content: start; gap: 6px; height: calc(100% - 26px); padding: 9px; color: #bbb; background: #181818; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }.console-output label { display: flex; align-items: center; gap: 7px; }.console-output input { min-width: 0; flex: 1; height: 25px; color: #ddd; background: #202020; border: 0; }
.info-surface output { display: block; padding: 6px 9px; overflow: hidden; color: #aaa; text-overflow: ellipsis; white-space: nowrap; border-bottom: 1px solid #373737; }
.camera-preview-surface { position: relative; display: grid; place-items: center; background-color: #383838; background-image: linear-gradient(#454545 1px, transparent 1px), linear-gradient(90deg, #454545 1px, transparent 1px); background-size: 26px 26px; }.camera-preview-surface > span { position: absolute; left: 8px; top: 7px; }.camera-frame { display: grid; place-items: center; width: 68%; aspect-ratio: 16 / 10; color: #aaa; border: 2px solid #777; box-shadow: 0 0 0 999px #0002; }
[data-workspace="Modeling"] .workspace-grid, [data-workspace="Sculpting"] .workspace-grid { grid-template-rows: 190px minmax(0, 1fr); }
[data-workspace="Modeling"] .viewport-area, [data-workspace="Sculpting"] .viewport-area { grid-row: 1 / 3; }
[data-workspace="Modeling"] .properties-area, [data-workspace="Sculpting"] .properties-area { grid-row: 2; }
[data-workspace="Modeling"] .timeline-area, [data-workspace="Sculpting"] .timeline-area { display: none; }
[data-workspace="UV Editing"] .workspace-grid, [data-workspace="Texture Paint"] .workspace-grid { grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) 320px; grid-template-rows: 190px minmax(0, 1fr); }
[data-workspace="UV Editing"] .image-editor-area, [data-workspace="Texture Paint"] .image-editor-area { grid-column: 1; grid-row: 1 / 3; }
[data-workspace="UV Editing"] .viewport-area, [data-workspace="Texture Paint"] .viewport-area { grid-column: 2; grid-row: 1 / 3; }
[data-workspace="UV Editing"] .outliner-area, [data-workspace="Texture Paint"] .outliner-area { grid-column: 3; grid-row: 1; }
[data-workspace="UV Editing"] .properties-area, [data-workspace="Texture Paint"] .properties-area { grid-column: 3; grid-row: 2; }
[data-workspace="UV Editing"] .timeline-area, [data-workspace="Texture Paint"] .timeline-area { display: none; }
[data-workspace="Shading"] .workspace-grid { grid-template-columns: 290px minmax(0, 1fr) 320px; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
[data-workspace="Shading"] .file-browser-area { grid-column: 1; grid-row: 1; } [data-workspace="Shading"] .viewport-area { grid-column: 2; grid-row: 1; } [data-workspace="Shading"] .image-editor-area { grid-column: 1; grid-row: 2; } [data-workspace="Shading"] .node-editor-area { grid-column: 2; grid-row: 2; } [data-workspace="Shading"] .outliner-area { grid-column: 3; grid-row: 1; } [data-workspace="Shading"] .properties-area { grid-column: 3; grid-row: 2; } [data-workspace="Shading"] .timeline-area { display: none; }
[data-workspace="Animation"] .workspace-grid { grid-template-columns: 36% minmax(0, 1fr) 320px; grid-template-rows: 190px minmax(0, 1fr) minmax(170px, .7fr); }
[data-workspace="Animation"] .camera-view-area { grid-column: 1; grid-row: 1 / 3; } [data-workspace="Animation"] .viewport-area { grid-column: 2; grid-row: 1 / 3; } [data-workspace="Animation"] .outliner-area { grid-column: 3; grid-row: 1; } [data-workspace="Animation"] .properties-area { grid-column: 3; grid-row: 2 / 4; } [data-workspace="Animation"] .timeline-area { grid-column: 1 / 3; grid-row: 3; }
[data-workspace="Rendering"] .workspace-grid { grid-template-columns: minmax(0, 1fr) 320px; grid-template-rows: minmax(0, 1fr) 108px; }
[data-workspace="Rendering"] .render-result-area { grid-column: 1; grid-row: 1; } [data-workspace="Rendering"] .timeline-area { grid-column: 1; grid-row: 2; } [data-workspace="Rendering"] .properties-area { grid-column: 2; grid-row: 1 / 3; } [data-workspace="Rendering"] .viewport-area, [data-workspace="Rendering"] .outliner-area { display: none; }
[data-workspace="Compositing"] .workspace-grid { grid-template-columns: minmax(0, 1fr) 320px; grid-template-rows: 44% minmax(0, 1fr) 54px; }
[data-workspace="Compositing"] .image-editor-area { grid-column: 1; grid-row: 1; } [data-workspace="Compositing"] .properties-area { grid-column: 2; grid-row: 1; } [data-workspace="Compositing"] .node-editor-area { grid-column: 1 / 3; grid-row: 2; } [data-workspace="Compositing"] .timeline-area { grid-column: 1 / 3; grid-row: 3; } [data-workspace="Compositing"] .viewport-area, [data-workspace="Compositing"] .outliner-area { display: none; }
[data-workspace="Geometry Nodes"] .workspace-grid { grid-template-columns: 42% minmax(0, 1fr) 320px; grid-template-rows: 42% minmax(0, 1fr) 74px; }
[data-workspace="Geometry Nodes"] .spreadsheet-area { grid-column: 1; grid-row: 1; } [data-workspace="Geometry Nodes"] .viewport-area { grid-column: 2; grid-row: 1; } [data-workspace="Geometry Nodes"] .outliner-area { grid-column: 3; grid-row: 1; } [data-workspace="Geometry Nodes"] .properties-area { grid-column: 3; grid-row: 2 / 4; } [data-workspace="Geometry Nodes"] .node-editor-area { grid-column: 1 / 3; grid-row: 2; } [data-workspace="Geometry Nodes"] .timeline-area { grid-column: 1 / 3; grid-row: 3; }
[data-workspace="Scripting"] .workspace-grid { grid-template-columns: 36% minmax(0, 1fr) 320px; grid-template-rows: 19% 43% 38%; }
[data-workspace="Scripting"] .viewport-area { grid-column: 1; grid-row: 1; } [data-workspace="Scripting"] .viewport-area > .editor-header { display: flex; } [data-workspace="Scripting"] .console-area { grid-column: 1; grid-row: 2; } [data-workspace="Scripting"] .info-area { grid-column: 1; grid-row: 3; } [data-workspace="Scripting"] .text-editor-area { grid-column: 2; grid-row: 1 / 4; } [data-workspace="Scripting"] .outliner-area { grid-column: 3; grid-row: 1; } [data-workspace="Scripting"] .secondary-outliner-area { grid-column: 3; grid-row: 2; } [data-workspace="Scripting"] .properties-area { grid-column: 3; grid-row: 3; } [data-workspace="Scripting"] .timeline-area { display: none; }
[data-workspace="Sculpting"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Texture Paint"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Shading"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Animation"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Rendering"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Compositing"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Geometry Nodes"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Scripting"] .workspace-toolbar > button:not(.mode-chip) { display: none; }
.modifier-row { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 26px); align-items: center; gap: 3px; min-height: 26px; }.modifier-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.property-section .modifier-row label { justify-content: center; gap: 2px; min-height: 20px; font-size: 10px; }
.property-section input[type="number"] { width: 72px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="text"], .property-section label > input:not([type]) { min-width: 0; width: 130px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="checkbox"] { accent-color: #e37a2c; }.property-actions { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }.property-actions button, .file-button { padding: 4px 7px; color: #e6e7e9; background: #393c42; border: 1px solid #4d5158; border-radius: 2px; }.property-actions button:disabled { opacity: .45; cursor: default; }.file-button { position: relative; cursor: pointer; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }.material-slots { display: grid; gap: 2px; max-height: 62px; overflow: auto; color: #b9bdc5; font-size: 11px; }.delimit-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 8px; margin: 6px 0; padding: 5px 0 3px; border: 0; border-top: 1px solid #383a40; }.delimit-options legend { padding: 0 5px 0 0; color: #7f858e; font-size: 10px; }.property-section .delimit-options label { justify-content: flex-start; min-width: 0; min-height: 20px; font-size: 10px; }
.grease-pencil-canvas-2d { display: block; width: 100%; max-width: 280px; aspect-ratio: 28 / 15; margin: 7px 0; border: 1px solid #4d5158; border-radius: 3px; background: #202328; cursor: crosshair; touch-action: none; }
.grease-pencil-canvas-2d:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
.timeline-content { display: grid; grid-template-rows: 34px 20px 16px minmax(30px, 1fr); height: 100%; padding: 6px 12px; }.timeline-controls { display: flex; align-items: center; gap: 4px; overflow-x: auto; }.timeline-controls button { min-width: 28px; height: 26px; padding: 0 6px; color: #c9ccd2; background: #35373d; border-radius: 3px; white-space: nowrap; }.timeline-controls button:hover { background: #4a4d54; }.frame-number { min-width: 45px; margin-left: 10px; padding: 5px 8px; text-align: center; color: #fff; background: #181a1d; border: 1px solid #4a4d54; border-radius: 3px; }.frame-slider { width: 100%; min-height: 24px; accent-color: #e37a2c; }.timeline-scale { display: flex; justify-content: space-between; color: #aeb3bc; font-size: 11px; }.dope-sheet { display: grid; grid-template-columns: 100px minmax(120px, 1fr) 90px; align-items: center; gap: 8px; border-top: 1px solid #3a3d43; }.channel-name { overflow: hidden; color: #b8bcc4; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 20px; background: #1e2024; border: 1px solid #373a40; }.key-dot { position: absolute; top: 5px; width: 9px; height: 9px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292b30; border: 1px solid #484c53; }
.dope-sheet input { min-width: 24px; min-height: 32px; margin-block: 4px; }.keying-set-tools > button, .timeline-markers > button { min-height: 24px; padding: 3px 7px; color: #f1f2f4; background: #41444a; border: 1px solid #656a73; border-radius: 2px; }
.status-bar { gap: 16px; min-width: 0; min-height: 24px; overflow: hidden; color: #8f949c; font-size: 11px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }
.storage-budget-panel { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0 10px; overflow-x: auto; color: #aeb2bb; background: #222427; border-bottom: 1px solid #111214; white-space: nowrap; scrollbar-width: thin; }.storage-budget-panel strong { color: #e4e6e9; font-size: 11px; }.storage-budget-panel span { color: #aeb2bb; font-variant-numeric: tabular-nums; }.storage-budget-panel output { margin-left: auto; color: #e37a2c; font-variant-numeric: tabular-nums; }.storage-budget-panel button { flex: none; padding: 3px 7px; color: #d9dce1; background: #35373d; border: 1px solid #4d5158; border-radius: 3px; }.storage-budget-panel button:disabled { opacity: .45; cursor: default; }
.timeline-content { display: grid; grid-template-rows: 27px 24px 15px minmax(28px, 1fr); height: auto; min-height: 100%; padding: 0; background: #292929; }.timeline-controls { display: flex; align-items: center; justify-content: center; gap: 1px; padding: 1px 5px; overflow-x: auto; border-bottom: 1px solid #151515; scrollbar-width: none; }.timeline-controls button { min-width: 25px; height: 24px; padding: 0 5px; color: #d0d0d0; background: transparent; border-radius: 2px; white-space: nowrap; }.timeline-controls button:hover { background: #484848; }.frame-number { min-width: 48px; height: 22px; margin-left: 8px; padding: 3px 7px; text-align: center; color: #fff; background: #464646; border: 1px solid #545454; border-radius: 2px; }.frame-slider { width: 100%; min-height: 24px; margin: 0; accent-color: #4775a3; }.timeline-scale { display: flex; justify-content: space-between; padding: 0 7px; color: #b6b6b6; background: #303030; font-size: 10px; }.dope-sheet { display: grid; grid-template-columns: 90px minmax(120px, 1fr) 78px; align-items: center; gap: 5px; padding: 2px 5px; border-top: 1px solid #444; }.channel-name { overflow: hidden; color: #c0c0c0; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 17px; background: #1f1f1f; border: 1px solid #3d3d3d; }.key-dot { position: absolute; top: 4px; width: 8px; height: 8px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #f0b24f; border: 1px solid #fff0bf; }.key-dot.active { background: #f07835; }.dope-sheet select { min-width: 0; color: #ddd; background: #292929; border: 1px solid #484848; }
.dope-sheet > button { min-height: 24px; color: #ededed; background: #454545; border: 1px solid #666; border-radius: 2px; }
.dope-sheet input { min-width: 80px; min-height: 24px; margin-block: 4px; }.keying-set-tools > button, .timeline-markers > button { min-height: 24px; padding: 3px 7px; color: #f1f2f4; background: #41444a; border: 1px solid #656a73; border-radius: 2px; }
.status-bar { gap: 10px; min-width: 0; min-height: 20px; padding: 0 6px; overflow: hidden; color: #aaa; background: #181818; font-size: 10px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }.status-hint { color: #888; }.runtime-status:not([data-testid="engine-status"]):not([data-testid="wasm-status"]) { display: none; }.blender-version { color: #7fa8cc; }
.storage-budget-panel { display: flex; align-items: center; flex: none; gap: 5px; min-width: 0; height: 100%; padding: 0 4px; color: #aaa; background: transparent; border: 0; white-space: nowrap; }.storage-budget-panel strong { color: #aaa; font-size: 10px; font-weight: 500; }.storage-budget-panel span { display: none; color: #aaa; font-variant-numeric: tabular-nums; }.storage-budget-panel output { color: #c9c9c9; font-variant-numeric: tabular-nums; }.storage-budget-panel button { flex: none; width: 19px; height: 18px; padding: 0; color: #ccc; background: #303030; border: 1px solid #444; border-radius: 2px; }.storage-budget-panel button:disabled { opacity: .45; cursor: default; }
.open-progress { display: inline-flex; align-items: center; gap: 5px; min-width: 0; max-width: 360px; }.open-progress progress { width: 72px; height: 8px; accent-color: #e37a2c; }.open-progress > span { overflow: hidden; text-overflow: ellipsis; }.open-progress button { width: 20px; height: 20px; padding: 0; color: #d6d8dc; background: #3a3c41; border-radius: 2px; }
.worker-fault-banner { position: fixed; top: 76px; left: 50%; z-index: 12; display: flex; align-items: center; gap: 10px; max-width: calc(100vw - 24px); padding: 8px 10px; transform: translateX(-50%); color: #f1f2f4; background: #3a2923; border: 1px solid #c6753b; border-radius: 4px; box-shadow: 0 8px 24px #0008; }.worker-fault-banner span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.worker-fault-banner button { flex: none; padding: 5px 9px; color: #fff; background: #a55325; border: 1px solid #d17a42; border-radius: 3px; }.worker-fault-banner button:disabled { opacity: .6; cursor: default; }
.recent-project-repair-banner { position: fixed; top: 76px; left: 50%; z-index: 11; display: grid; gap: 6px; width: min(560px, calc(100vw - 24px)); padding: 9px 11px; transform: translateX(-50%); color: #f1f2f4; background: #332d22; border: 1px solid #b98a42; border-radius: 4px; box-shadow: 0 8px 24px #0008; }.recent-project-repair-list { display: grid; gap: 4px; max-height: 160px; overflow: auto; }.recent-project-repair-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0; color: #d9d1bd; }.recent-project-repair-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.recent-project-repair-item button { flex: none; padding: 4px 8px; color: #fff; background: #765729; border: 1px solid #c49c58; border-radius: 3px; }.recent-project-repair-item button:hover { background: #92703a; }
.worker-fault-banner { position: fixed; top: 62px; left: 50%; z-index: 12; display: flex; align-items: center; gap: 10px; max-width: calc(100vw - 24px); padding: 8px 10px; transform: translateX(-50%); color: #f1f2f4; background: #3a2923; border: 1px solid #c6753b; border-radius: 3px; box-shadow: 0 8px 24px #0008; }.worker-fault-banner span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.worker-fault-banner button { flex: none; padding: 5px 9px; color: #fff; background: #8d4b28; border: 1px solid #c6753b; border-radius: 2px; }.worker-fault-banner button:disabled { opacity: .6; cursor: default; }
.recent-project-repair-banner { position: fixed; top: 62px; left: 50%; z-index: 11; display: grid; gap: 6px; width: min(560px, calc(100vw - 24px)); padding: 9px 11px; transform: translateX(-50%); color: #f1f2f4; background: #332d22; border: 1px solid #b98a42; border-radius: 3px; box-shadow: 0 8px 24px #0008; }.recent-project-repair-list { display: grid; gap: 4px; max-height: 160px; overflow: auto; }.recent-project-repair-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-width: 0; color: #d9d1bd; }.recent-project-repair-item > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.recent-project-repair-item button { flex: none; padding: 4px 8px; color: #fff; background: #765729; border: 1px solid #c49c58; border-radius: 2px; }.recent-project-repair-item button:hover { background: #92703a; }
.file-input-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.operator-search { position: fixed; top: 56px; left: 50%; z-index: 10; width: min(480px, calc(100vw - 24px)); padding: 8px; transform: translateX(-50%); background: #303238; border: 1px solid #545860; border-radius: 4px; box-shadow: 0 12px 30px #0008; }
.operator-search { position: fixed; top: 64px; left: 50%; z-index: 10; width: min(480px, calc(100vw - 24px)); padding: 7px; transform: translateX(-50%); background: #292929; border: 1px solid #5a5a5a; border-radius: 3px; box-shadow: 0 12px 30px #0008; }
.main-menu-popover { position: absolute; top: 100%; left: 0; z-index: 14; display: grid; min-width: 128px; padding: 4px; background: #303238; border: 1px solid #545860; border-radius: 3px; box-shadow: 0 8px 20px #0008; }.main-menu-popover button { padding: 6px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 2px; white-space: nowrap; }.main-menu-popover button:hover, .main-menu-popover button:focus-visible { color: #fff; background: #a55325; }
.operator-search input { width: 100%; padding: 8px 10px; color: #f4f5f6; background: #1d1f22; border: 1px solid #5a5e66; border-radius: 3px; outline: none; }
.operator-results { display: grid; gap: 2px; margin-top: 6px; }.operator-results button { padding: 8px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 3px; }.operator-results button:hover { color: #fff; background: #a55325; }
.outliner-tools input:focus-visible, .operator-search input:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
.file-button:has(input:focus-visible) { outline: 2px solid #f2a15b; outline-offset: 1px; }
@media (max-width: 900px) {
.topbar { min-width: 0; overflow-x: auto; scrollbar-width: none; }
.topbar::-webkit-scrollbar { display: none; }
.topbar > *, .topbar-actions button { flex: none; white-space: nowrap; }
.topbar-actions { margin-left: 0; }
.blender-app { grid-template-rows: 28px 30px minmax(0, 1fr) 20px; }
.topbar { min-width: 0; overflow: hidden; }
.topbar > *, .topbar-actions button { white-space: nowrap; }
.brand-name, .topbar-context, .topbar-actions button:nth-of-type(2), .topbar-actions button:nth-of-type(3), .topbar-actions button:nth-of-type(5), .topbar-actions button:nth-of-type(6), .topbar-actions button:nth-of-type(7), .topbar-actions button:nth-of-type(8) { display: none; }
.topbar-actions { flex: none; margin-left: 0; }
.topbar-actions select { width: 62px; }
.menu-bar { display: none; }
.workspace-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 150px; }
.outliner-area, .properties-area { display: none; }
.workspace-tabs .workspace-tab:nth-child(n+4) { display: none; }
.workspace-grid, [data-workspace] .workspace-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 108px; }
.outliner-area, .properties-area, .image-editor-area, .render-result-area, .node-editor-area, .file-browser-area, .camera-view-area, .spreadsheet-area, .text-editor-area, .console-area, .info-area, .secondary-outliner-area { display: none !important; }
[data-workspace] .viewport-area, [data-workspace] .timeline-area { display: flex; }
.viewport-area, .timeline-area { grid-column: 1; }
.viewport-area { grid-row: 1; }
.timeline-area { grid-row: 2; }
.workspace-tab { min-width: 64px; }
.status-bar span:nth-child(2), .status-bar span:nth-child(4) { display: none; }
.status-bar { overflow-x: auto; scrollbar-width: thin; }
.timeline-content { padding-inline: 8px; }
[data-workspace] .viewport-area { grid-row: 1; }
[data-workspace] .timeline-area { grid-row: 2; }
.workspace-tab { min-width: auto; padding-inline: 8px; }
.status-hint, .status-bar > [data-testid="scene-stats"], .storage-budget-panel strong, .storage-budget-panel output, .blender-version { display: none; }
.status-bar { gap: 5px; overflow: hidden; }
.runtime-status[data-testid="wasm-status"] { display: none; }
.runtime-status[data-testid="engine-status"] { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.timeline-controls { gap: 2px; }
.timeline-controls button { min-width: 24px; padding-inline: 4px; font-size: 11px; }
.frame-number { min-width: 38px; margin-left: 4px; padding-inline: 5px; }
}
@media (min-width: 901px) and (max-width: 1280px) {
.brand-name, .topbar-context { display: none; }
.workspace-grid { grid-template-columns: minmax(0, 1fr) 290px; }
.workspace-tab { padding-inline: 7px; }
}

View File

@@ -129,6 +129,19 @@ export async function probeSequencerCodec(
return probeMovie(request, source);
}
/** Probe a movie with the browser decoder first, then use the local ffmpeg.wasm
* Worker when Chromium cannot decode the container/codec. */
export async function probeSequencerMovieCodecWithFallback(
requestValue: unknown,
source: ArrayBuffer,
signal?: AbortSignal,
): Promise<SequencerCodecProbeResultIR> {
const browserResult = await probeSequencerCodec(requestValue, source);
if (browserResult.status === "READY") return browserResult;
const { probeMovieWithFFmpeg } = await import("./FFmpegMovieProbe");
return probeMovieWithFFmpeg(requestValue, source, signal);
}
export function createSequencerCodecProbeRequest(
stripType: SequencerCodecProbeRequestIR["stripType"],
mimeType: string,

View File

@@ -88,6 +88,21 @@ export async function generateInitialSequencerMovieProxyFrame(
throw new SequencerMediaCacheValidationError("SEQUENCER_CACHE_SOURCE_MISMATCH", "Movie proxy source bytes failed identity verification");
}
cancelled(signal);
if (capability.backend === "FFMPEG_WASM") {
try {
const { decodeMovieFrameWithFFmpeg } = await import("./FFmpegMovieProbe");
const frame = await decodeMovieFrameWithFFmpeg(source, sourceData, profile.width, profile.height, signal);
cancelled(signal);
return {
manifest: await createSequencerMediaCacheManifest(source, capability, profile, 0, frame.rgba),
data: frame.rgba,
};
}
catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw new SequencerMediaCacheValidationError("SEQUENCER_CANCELLED", "Movie proxy generation was cancelled");
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "ffmpeg.wasm movie proxy frame decode failed");
}
}
if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined") {
throw new SequencerMediaCacheValidationError("SEQUENCER_CODEC_UNSUPPORTED", "HTML media proxy generation is unavailable");
}

View File

@@ -1,6 +1,35 @@
export type WorkspaceId = "Layout" | "Modeling" | "Animation";
export type EditorType = "3D Viewport" | "Outliner" | "Properties" | "Timeline";
export type BlenderMode = "Object" | "Edit" | "Pose";
export const BLENDER_WORKSPACE_IDS = [
"Layout",
"Modeling",
"Sculpting",
"UV Editing",
"Texture Paint",
"Shading",
"Animation",
"Rendering",
"Compositing",
"Geometry Nodes",
"Scripting",
] as const;
export type WorkspaceId = typeof BLENDER_WORKSPACE_IDS[number];
export type EditorType =
| "3D Viewport"
| "Outliner"
| "Properties"
| "Timeline"
| "Dope Sheet"
| "Image Editor"
| "Render Result"
| "Shader Editor"
| "Compositor"
| "Geometry Nodes"
| "File Browser"
| "Spreadsheet"
| "Text Editor"
| "Python Console"
| "Info";
export type BlenderMode = "Object" | "Edit" | "Pose" | "Sculpt" | "Texture Paint";
export interface RegionIR {
id: string;
@@ -61,25 +90,88 @@ function regions(): RegionIR[] {
];
}
function layoutAreas(): AreaIR[] {
return [
{ id: "viewport", editor: "3D Viewport", regions: regions(), rect: { x: 0, y: 0, width: 0.8, height: 0.78 }, maximized: false },
{ id: "outliner", editor: "Outliner", regions: regions(), rect: { x: 0.8, y: 0, width: 0.2, height: 0.42 }, maximized: false },
{ id: "properties", editor: "Properties", regions: regions(), rect: { x: 0.8, y: 0.42, width: 0.2, height: 0.36 }, maximized: false },
{ id: "timeline", editor: "Timeline", regions: regions(), rect: { x: 0, y: 0.78, width: 1, height: 0.22 }, maximized: false },
function area(id: string, editor: EditorType, x: number, y: number, width: number, height: number): AreaIR {
return { id, editor, regions: regions(), rect: { x, y, width, height }, maximized: false };
}
function workspaceAreas(id: WorkspaceId): AreaIR[] {
const right = [
area("outliner", "Outliner", 0.822, 0.819, 0.176, 0.179),
area("properties", "Properties", 0.822, 0.022, 0.176, 0.794),
];
switch (id) {
case "Layout":
return [area("viewport", "3D Viewport", 0.001, 0.095, 0.819, 0.903), area("timeline", "Timeline", 0.001, 0.022, 0.819, 0.07), ...right];
case "Modeling":
case "Sculpting":
return [area("viewport", "3D Viewport", 0.001, 0.022, 0.819, 0.976), ...right];
case "UV Editing":
case "Texture Paint":
return [area("image-editor", "Image Editor", 0.001, 0.022, 0.409, 0.976), area("viewport", "3D Viewport", 0.412, 0.022, 0.408, 0.976), ...right];
case "Shading":
return [
area("file-browser", "File Browser", 0.001, 0.511, 0.151, 0.487),
area("viewport", "3D Viewport", 0.153, 0.511, 0.667, 0.487),
area("image-editor", "Image Editor", 0.001, 0.022, 0.151, 0.487),
area("shader-editor", "Shader Editor", 0.153, 0.022, 0.667, 0.487),
...right,
];
case "Animation":
return [
area("camera-view", "3D Viewport", 0.001, 0.435, 0.291, 0.563),
area("viewport", "3D Viewport", 0.294, 0.435, 0.526, 0.563),
area("dope-sheet", "Dope Sheet", 0.001, 0.022, 0.819, 0.41),
...right,
];
case "Rendering":
return [area("render-result", "Render Result", 0.001, 0.095, 0.819, 0.903), area("timeline", "Timeline", 0.001, 0.022, 0.819, 0.07), area("properties", "Properties", 0.822, 0.022, 0.176, 0.976)];
case "Compositing":
return [area("image-editor", "Image Editor", 0.001, 0.552, 0.819, 0.446), area("properties", "Properties", 0.822, 0.552, 0.176, 0.446), area("compositor", "Compositor", 0.001, 0.071, 0.997, 0.476), area("timeline", "Timeline", 0.001, 0.022, 0.997, 0.046)];
case "Geometry Nodes":
return [
area("spreadsheet", "Spreadsheet", 0.001, 0.552, 0.341, 0.446),
area("viewport", "3D Viewport", 0.343, 0.552, 0.477, 0.446),
area("geometry-nodes", "Geometry Nodes", 0.001, 0.095, 0.819, 0.452),
area("timeline", "Timeline", 0.001, 0.022, 0.819, 0.07),
...right,
];
case "Scripting":
return [
area("viewport", "3D Viewport", 0.001, 0.626, 0.291, 0.372),
area("console", "Python Console", 0.001, 0.185, 0.291, 0.438),
area("info", "Info", 0.001, 0.022, 0.291, 0.16),
area("text-editor", "Text Editor", 0.294, 0.022, 0.526, 0.976),
area("outliner", "Outliner", 0.822, 0.807, 0.176, 0.191),
area("secondary-outliner", "Outliner", 0.822, 0.598, 0.176, 0.206),
area("properties", "Properties", 0.822, 0.022, 0.176, 0.573),
];
}
}
function workspace(id: WorkspaceId): WorkspaceIR {
return { id, name: id, areas: layoutAreas(), activeAreaId: "viewport", revision: 0 };
const areas = workspaceAreas(id);
const preferred = ({
"UV Editing": "image-editor",
"Texture Paint": "image-editor",
Shading: "shader-editor",
Animation: "dope-sheet",
Rendering: "render-result",
Compositing: "compositor",
"Geometry Nodes": "geometry-nodes",
Scripting: "text-editor",
} as Partial<Record<WorkspaceId, string>>)[id];
return { id, name: id, areas, activeAreaId: preferred ?? "viewport", revision: 0 };
}
function defaultMode(id: WorkspaceId): BlenderMode {
if (id === "Modeling" || id === "UV Editing") return "Edit";
if (id === "Sculpting") return "Sculpt";
if (id === "Texture Paint") return "Texture Paint";
return "Object";
}
export function createDefaultWebWorkspaceState(): WebWorkspaceState {
const workspaces = {
Layout: workspace("Layout"),
Modeling: workspace("Modeling"),
Animation: workspace("Animation"),
} satisfies Record<WorkspaceId, WorkspaceIR>;
const workspaces = Object.fromEntries(BLENDER_WORKSPACE_IDS.map((id) => [id, workspace(id)])) as Record<WorkspaceId, WorkspaceIR>;
return {
workspaces,
context: {
@@ -110,6 +202,7 @@ export function reduceUICommand(state: WebWorkspaceState, command: UICommand): W
workspaceId: command.workspaceId,
activeAreaId: next.activeAreaId,
activeEditor: next.areas.find((area) => area.id === next.activeAreaId)?.editor ?? "3D Viewport",
mode: defaultMode(command.workspaceId),
revision: state.context.revision + 1,
},
};

View File

@@ -1,3 +1,4 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
const viewports = [
@@ -47,4 +48,96 @@ test.describe("M7-14 responsive layout", () => {
expect(report.rootScrollWidth, JSON.stringify(report)).toBeLessThanOrEqual(report.viewportWidth);
});
}
test("desktop mirrors Blender 5.2 Layout workspace regions", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
await expect(page.locator(".workspace-tab")).toHaveText([
"Layout", "Modeling", "Sculpting", "UV Editing", "Texture Paint", "Shading",
"Animation", "Rendering", "Compositing", "Geometry Nodes", "Scripting",
]);
const geometry = await page.evaluate(() => {
const rect = (selector: string) => {
const bounds = document.querySelector<HTMLElement>(selector)?.getBoundingClientRect();
if (!bounds) throw new Error(`Missing ${selector}`);
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, bottom: bounds.bottom };
};
const propertyTabs = document.querySelector<HTMLElement>(".property-tabs");
return {
viewport: rect(".viewport-area"),
outliner: rect(".outliner-area"),
properties: rect(".properties-area"),
timeline: rect(".timeline-area"),
status: rect(".status-bar"),
propertyTabsDirection: propertyTabs ? getComputedStyle(propertyTabs).flexDirection : "missing",
};
});
expect(geometry.viewport.width / 1440).toBeGreaterThan(0.75);
expect(geometry.outliner.x).toBeCloseTo(geometry.viewport.width + 1, 0);
expect(geometry.outliner.height).toBeLessThan(geometry.properties.height);
expect(geometry.properties.bottom).toBeCloseTo(geometry.status.y, 0);
expect(geometry.timeline.x).toBe(0);
expect(geometry.timeline.width).toBeCloseTo(geometry.viewport.width, 0);
expect(geometry.timeline.height).toBeGreaterThanOrEqual(96);
expect(geometry.timeline.height).toBeLessThanOrEqual(120);
expect(geometry.propertyTabsDirection).toBe("column");
});
test("desktop cycles every Blender 5.2 workspace with its source editor set", async ({ page }) => {
const expected = {
Layout: { mode: "Object Mode", menus: "View Select Add Object", editors: ["3D Viewport", "Outliner", "Properties", "Timeline"] },
Modeling: { mode: "Edit Mode", menus: "View Select Add Mesh Vertex Edge Face UV", editors: ["3D Viewport", "Outliner", "Properties"] },
Sculpting: { mode: "Sculpt Mode", menus: "Sculpt Mask Face Sets View", editors: ["3D Viewport", "Outliner", "Properties"] },
"UV Editing": { mode: "Edit Mode", menus: "View Select Image UV", editors: ["3D Viewport", "Image Editor", "Outliner", "Properties"] },
"Texture Paint": { mode: "Texture Paint Mode", menus: "View Paint Mask Face Selection", editors: ["3D Viewport", "Image Editor", "Outliner", "Properties"] },
Shading: { mode: "Object Mode", menus: "View Select Add Node", editors: ["3D Viewport", "File Browser", "Image Editor", "Outliner", "Properties", "Shader Editor"] },
Animation: { mode: "Object Mode", menus: "View Select Marker Channel Key", editors: ["3D Viewport", "3D Viewport", "Dope Sheet", "Outliner", "Properties"] },
Rendering: { mode: "Object Mode", menus: "View Image", editors: ["Properties", "Render Result", "Timeline"] },
Compositing: { mode: "Object Mode", menus: "View Select Add Node", editors: ["Compositor", "Image Editor", "Properties", "Timeline"] },
"Geometry Nodes": { mode: "Object Mode", menus: "View Select Add Node", editors: ["3D Viewport", "Geometry Nodes", "Outliner", "Properties", "Spreadsheet", "Timeline"] },
Scripting: { mode: "Object Mode", menus: "View Text Templates", editors: ["3D Viewport", "Info", "Outliner", "Outliner", "Properties", "Python Console", "Text Editor"] },
} as const;
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
for (const [workspace, contract] of Object.entries(expected)) {
await page.getByRole("button", { name: workspace, exact: true }).click();
await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", workspace);
await expect(page.locator(".mode-chip")).toHaveText(contract.mode);
await expect(page.locator(".workspace-editor-menus")).toHaveText(contract.menus);
const report = await page.evaluate(() => {
const visibleAreas = [...document.querySelectorAll<HTMLElement>(".workspace-grid > .area-frame")].filter((area) => {
const bounds = area.getBoundingClientRect();
return getComputedStyle(area).display !== "none" && bounds.width > 0 && bounds.height > 0;
});
const overlaps: string[] = [];
for (let leftIndex = 0; leftIndex < visibleAreas.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < visibleAreas.length; rightIndex += 1) {
const left = visibleAreas[leftIndex].getBoundingClientRect();
const right = visibleAreas[rightIndex].getBoundingClientRect();
if (Math.min(left.right, right.right) - Math.max(left.left, right.left) > 1 && Math.min(left.bottom, right.bottom) - Math.max(left.top, right.top) > 1) {
overlaps.push(`${visibleAreas[leftIndex].dataset.editor}/${visibleAreas[rightIndex].dataset.editor}`);
}
}
}
return {
editors: visibleAreas.map((area) => area.dataset.editor).sort(),
overlaps,
rootScrollWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
};
});
expect(report.editors, workspace).toEqual([...contract.editors].sort());
expect(report.overlaps, workspace).toEqual([]);
expect(report.rootScrollWidth, workspace).toBeLessThanOrEqual(report.viewportWidth);
if (await page.locator(".workspace-editor-surface").count() > 0) {
const accessibility = await new AxeBuilder({ page }).include(".workspace-editor-surface").withTags(["wcag2aa", "wcag21aa", "wcag22aa"]).analyze();
expect(accessibility.violations.filter((violation) => violation.impact === "critical" || violation.impact === "serious").map((violation) => violation.id), workspace).toEqual([]);
}
}
});
});

View File

@@ -13,7 +13,29 @@ const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
});
assert.deepEqual(transpiled.diagnostics, []);
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
const { createDefaultWebWorkspaceState, reduceUICommand } = await import(moduleUrl);
const { BLENDER_WORKSPACE_IDS, createDefaultWebWorkspaceState, reduceUICommand } = await import(moduleUrl);
test("Blender 5.2 workspaces retain their source editor area sets", () => {
const state = createDefaultWebWorkspaceState();
const expected = {
Layout: ["3D Viewport", "Outliner", "Properties", "Timeline"],
Modeling: ["3D Viewport", "Outliner", "Properties"],
Sculpting: ["3D Viewport", "Outliner", "Properties"],
"UV Editing": ["3D Viewport", "Image Editor", "Outliner", "Properties"],
"Texture Paint": ["3D Viewport", "Image Editor", "Outliner", "Properties"],
Shading: ["3D Viewport", "File Browser", "Image Editor", "Outliner", "Properties", "Shader Editor"],
Animation: ["3D Viewport", "3D Viewport", "Dope Sheet", "Outliner", "Properties"],
Rendering: ["Properties", "Render Result", "Timeline"],
Compositing: ["Compositor", "Image Editor", "Properties", "Timeline"],
"Geometry Nodes": ["3D Viewport", "Geometry Nodes", "Outliner", "Properties", "Spreadsheet", "Timeline"],
Scripting: ["3D Viewport", "Info", "Outliner", "Outliner", "Properties", "Python Console", "Text Editor"],
};
assert.deepEqual(Object.keys(state.workspaces), BLENDER_WORKSPACE_IDS);
for (const id of BLENDER_WORKSPACE_IDS) {
assert.deepEqual(state.workspaces[id].areas.map((area) => area.editor).sort(), expected[id].toSorted(), id);
assert.ok(state.workspaces[id].areas.every((area) => area.rect.width > 0 && area.rect.height > 0), id);
}
});
test("M7-13 menu and modal overlays are mutually exclusive and Escape-closeable", () => {
let state = createDefaultWebWorkspaceState();