Compare commits
8 Commits
72bf0f47ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
122142c26c | ||
|
|
edcf0fef9a | ||
|
|
a69e41457a | ||
|
|
39b188c574 | ||
|
|
ca2695b92f | ||
|
|
98cebd381a | ||
|
|
70721187c0 | ||
|
|
93c01c8f09 |
21
AGENTS.md
21
AGENTS.md
@@ -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 或历史计划。
|
||||
- 计时、索引、构建和验证是执行保障,不能代替业务功能完成;报告必须说明工作流、具体功能路由及源码依据。
|
||||
|
||||
205
docs/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md
Normal file
205
docs/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md
Normal 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 功能与界面完整对标”。
|
||||
44
docs/status/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md
Normal file
44
docs/status/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md
Normal 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 节的发布检查。
|
||||
@@ -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
168
my-tools/AUTOPILOT.md
Normal 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.
|
||||
56
my-tools/benchmark_wbv2_autopilot.py
Normal file
56
my-tools/benchmark_wbv2_autopilot.py
Normal 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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
375
my-tools/test_wbv2_autopilot.py
Normal file
375
my-tools/test_wbv2_autopilot.py
Normal 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()
|
||||
53
my-tools/view_wbv2_progress.py
Normal file
53
my-tools/view_wbv2_progress.py
Normal 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
946
my-tools/wbv2_autopilot.py
Normal 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())
|
||||
42
my-tools/wbv2_autopilot_policy.json
Normal file
42
my-tools/wbv2_autopilot_policy.json
Normal 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."
|
||||
]
|
||||
}
|
||||
106
my-tools/wbv2_local_planner.py
Normal file
106
my-tools/wbv2_local_planner.py
Normal 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
211
my-tools/wbv2_timing.py
Normal 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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
21
tests/golden/WBV2-P1424-001/autopilot-tests.log
Normal file
21
tests/golden/WBV2-P1424-001/autopilot-tests.log
Normal 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 |
272
tests/golden/WBV2-P1424-001/deployed-audit/chromium-report.json
Normal file
272
tests/golden/WBV2-P1424-001/deployed-audit/chromium-report.json
Normal 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"
|
||||
}
|
||||
172
tests/golden/WBV2-P1424-001/deployed-audit/completion-audit.json
Normal file
172
tests/golden/WBV2-P1424-001/deployed-audit/completion-audit.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
tests/golden/WBV2-P1424-001/deployed-audit/desktop-final.png
Normal file
BIN
tests/golden/WBV2-P1424-001/deployed-audit/desktop-final.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
BIN
tests/golden/WBV2-P1424-001/deployed-audit/desktop-open.png
Normal file
BIN
tests/golden/WBV2-P1424-001/deployed-audit/desktop-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
7378
tests/golden/WBV2-P1424-001/deployed-audit/evidence-integrity.json
Normal file
7378
tests/golden/WBV2-P1424-001/deployed-audit/evidence-integrity.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
tests/golden/WBV2-P1424-001/deployed-audit/f3-server-result.png
Normal file
BIN
tests/golden/WBV2-P1424-001/deployed-audit/f3-server-result.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 176 KiB |
BIN
tests/golden/WBV2-P1424-001/deployed-audit/roundtrip.blend
Normal file
BIN
tests/golden/WBV2-P1424-001/deployed-audit/roundtrip.blend
Normal file
Binary file not shown.
@@ -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
|
||||
|
||||
201
tests/golden/WBV2-P1424-001/performance-optimization-report.json
Normal file
201
tests/golden/WBV2-P1424-001/performance-optimization-report.json
Normal 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."
|
||||
]
|
||||
}
|
||||
@@ -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`,完整行为验收未完成,队列没有推进。
|
||||
73
tests/golden/WBV2-P1424-001/resume-report.json
Normal file
73
tests/golden/WBV2-P1424-001/resume-report.json
Normal 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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>>></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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user