diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md deleted file mode 100644 index 2ed1a39d6..000000000 --- a/CONTEXT-MAP.md +++ /dev/null @@ -1,12 +0,0 @@ -# Context Map - -## Contexts - -- [Agent Interaction](./CONTEXT.md) — defines structured requests and answers exchanged between the ECOS Agent and GUI -- [Chip Backend GUI](./docs/gui-backend/CONTEXT.md) — defines the product language for implementation workspaces, engineering analysis, artifacts and comparison -- [Resource Management](./docs/resource-management/CONTEXT.md) — defines installed design resources and their availability to ECOS Studio - -## Relationships - -- **Agent Interaction → Chip Backend GUI**: Agent interactions may collect bounded user input for a chip-backend action, but the interaction protocol does not own or execute Project, Workspace, Flow Step or Signoff operations. -- **Resource Management → Chip Backend GUI**: Resource Management supplies PDK Installations to backend Workspaces; Workspaces reference those installations but do not own them. diff --git a/PRODUCT.md b/PRODUCT.md deleted file mode 100644 index d3d17d128..000000000 --- a/PRODUCT.md +++ /dev/null @@ -1,33 +0,0 @@ -# Product - -## Register - -product - -## Users - -RTL and physical-design engineers, including first-time ECOS Studio users who need to run a design flow without first learning the application's Project and Workspace storage model. - -## Product Purpose - -ECOS Studio provides an integrated RTL-to-silicon workflow. Success means users can provide design inputs, understand and control the active flow parameters and stages, run the flow, and inspect trustworthy results with minimal setup overhead. - -## Brand Personality - -Professional, direct, and dependable. Product copy should use established EDA terms where they help the task, while hiding internal organization concepts until users need to manage them. - -## Anti-references - -Avoid onboarding that makes users name containers or choose output directories before they can experience the flow. Avoid decorative dashboard patterns, verbose explanations, and unfamiliar controls for standard choices. - -## Design Principles - -- Lead with the user's design task, not the application's storage model. -- Make the current state and next valid action immediately visible. -- Keep generated defaults inspectable and editable before execution. -- Preserve explicit confirmation at every operation that creates or mutates design artifacts. -- Reuse one workflow for first runs and expert runs after inputs are resolved. - -## Accessibility & Inclusion - -Support keyboard operation, visible focus, reduced motion, semantic status updates, and WCAG AA contrast for text and interactive controls. diff --git a/ecc b/ecc index f74d2bb8c..83fe68d15 160000 --- a/ecc +++ b/ecc @@ -1 +1 @@ -Subproject commit f74d2bb8c26f8498bc80afc02107d0bb42d7643b +Subproject commit 83fe68d15bc859143ce04dbbf4f4dd3bdad6c85a diff --git a/ecos/agent/PERMISSION_MODEL.md b/ecos/agent/PERMISSION_MODEL.md index 8f0dc7f95..5d828502b 100644 --- a/ecos/agent/PERMISSION_MODEL.md +++ b/ecos/agent/PERMISSION_MODEL.md @@ -155,45 +155,41 @@ Agent 的参数更新路径写完 `home/parameters.json` 后**直接上报成功 | `project.json`、workspace 目录结构 | 不可改动既有结构 | 同上 | | 已完成阶段产物 | 永不可写 | 同上,保证可追溯性 | | 新建 workspace / project | 可创建 | 走既有合同确认链路 | -| `home/parameters.json` 全局参数 | 可写 | typed patch + 值域校验 + 合同确认 | -| step config 参数 | 可写 | 同上,经 ECC `sync_config` | +| Workspace Parameter | 可写 | typed patch + 合同确认 + ECC `workspace.updateConfiguration` | +| Step Option | 可写 | Step identity + 合同确认 + ECC `workspace.updateStepConfiguration` | | flow 起止阶段 | 可写 | 同上 | | 隔离 rerun workspace 内部 | 可写 | 目标为新建目录,不覆盖 source | 关于「工程的建立」:**新建允许**(当前操作 1 与操作 5 的行为,保留),**改动既有结构不允许**——不重命名、不删除、不覆盖 `project.json`、不调整目录布局。 -## 5. 参数写入面统一设计 +## 5. 参数领域命令设计 -### 5.1 两个参数面 +### 5.1 单一写入所有者 -ECOS 存在两套参数存储: +ECOS Workspace 中仍可观察到两类派生配置: - `home/parameters.json`:全局设计参数,ICS55 扁平模板。字段如 `Clock`、`Frequency max [MHz]`、`Max fanout`、`Core.Utilitization`、`Core.Margin`、`Die.Size`、`Target density`、`Target overflow`、`Cell padding x`、`Routability opt flag`、`Bottom layer`、`Top layer`。 - `config/*.json`:每步工具配置,如 `dreamplace_ecc.json`、`cts_ecc.json`、`route_ecc.json`。 -ECC 提供双向同步: - -- `refresh_config`:parameters → step config(`data_api.refresh_workspace_config`) -- `sync_config`:step config → parameters,若 parameters 发生变化则再执行一次 refresh(`data_api.sync_workspace_config_to_parameters`) - -部分字段在两个面同时存在(`Target density`、`Target overflow`、`Cell padding x`、`Routability opt flag`)。 +这些路径只用于 Agent 读取当前值,不出现在跨进程合同中。Workspace Descriptor 是唯一权威配置;ECC 负责更新 Descriptor、生成派生配置、提升 Revision 并失效旧结果。Studio 和 Agent 不直接写这些文件。 ### 5.2 写入规则 -1. 补丁只描述逻辑 knob,不描述文件路径。 -2. 由**单一映射表**决定每个 knob 落在哪个面、哪个 key。该映射表必须是唯一事实来源,Python 与 TypeScript 侧共用同一份定义,不允许两侧各自硬编码。 -3. 落在 `home/parameters.json` 的改动,写入后**必须调用 `refreshConfig`**。 -4. 落在 `config/*.json` 的改动,写入后**必须调用 `syncConfig`**。 -5. 未知 knob 必须**报错**,不得静默跳过。 -6. 写入格式与 GUI 保持一致(4 空格缩进),避免无意义 diff。 -7. 合同展示的「旧值」必须从该 knob 的**实际写入面**读取。 +1. 提案只描述逻辑 knob,不描述文件路径。 +2. Agent 将提案解析为 canonical Workspace Parameters 和带 Step identity 的 Step Options。 +3. Studio 在用户确认后只调用对应 Product Command,不解释 JSON path。 +4. ECC 校验参数/选项、原子提交、生成派生配置、提升 Revision 并计算 stale 范围。 +5. 未知 knob、Workspace Parameter、Step identity 或 Step Option 必须报错,不得静默跳过。 +6. 合同展示的「旧值」来自只读采集面;执行载荷必须展示逻辑参数身份。 ### 5.3 knob 命名空间扩展 现有前缀 `place.` / `cts.` / `legalization.` / `route.` 保留。新增用于全局参数的前缀: -- `design.clock`、`design.frequency_max`、`design.max_fanout`、`design.top_module` -- `floorplan.utilitization`、`floorplan.margin`、`floorplan.die_width`、`floorplan.die_height`、`floorplan.aspect_ratio`、`floorplan.die_area_mode` +- `design.frequency_max` +- `floorplan.utilitization`、`floorplan.die_width`、`floorplan.die_height`、`floorplan.aspect_ratio` + +`design.clock`、`design.top_module` 和 Flow/PDK 变化属于结构性 Workspace Update,不作为普通参数建议。 命名需满足 `knob_id` 正则(至少一个点分隔)。 @@ -294,9 +290,7 @@ codex app-server -c mcp_servers={} -c tools.web_search= `codex_rpc.py` 的 `_readonly_activity` 仅匹配 `commandexecution` 类型的 item,web search 相关事件将完全静默,用户会看到 Agent 无响应而不知其在执行什么。 -需增加 `webSearch` 分支,在 Tool 卡中展示查询内容与访问域名。 - -依据:`PRODUCT.md` —— Make the current state and next valid action immediately visible。 +需增加 `webSearch` 分支,在 Tool 卡中展示查询内容与访问域名,让当前状态和下一步有效操作立刻可见。 ### 8.2 审计 @@ -351,10 +345,8 @@ codex app-server -c mcp_servers={} -c tools.web_search= 已解决: - ✅ 未知协议字段在 0.146.0 下被静默接受(实测 `__bogus_probe_field` 返回成功) -- ✅ 新增 `design.` / `floorplan.` knob 的写入面为 `home/parameters.json`,路径已在 - `knob_registry.py` 中登记 -- ✅ `refresh_config` 从 `parameters.json` 重新展开 step config,因此 step-config 类 - knob 必须先 `sync_config` 再 `refresh_config`——实现已按此顺序,并有测试锁定 +- ✅ `design.` / `floorplan.` knob 解析为 canonical Workspace Parameter,文件路径不进入合同 +- ✅ Step Option 解析为 Step identity + option object,并由 ECC 统一提交和刷新派生配置 仍待验证(需要真实 workspace 与真实 Codex 会话): diff --git a/ecos/agent/knowledge/cts/manifest.json b/ecos/agent/knowledge/cts/manifest.json index 7b94dcac3..ef6b8749e 100644 --- a/ecos/agent/knowledge/cts/manifest.json +++ b/ecos/agent/knowledge/cts/manifest.json @@ -1 +1 @@ -{"entity_count": 47, "files": {"catalog.json": "5339ada465d1966d4e0ff2e4d1ddb3ff4819da66f34ac61ce1cf9db92ed1f83e", "knowledge/algorithms.md": "a5af2adf9a4a1fe27232f320cb43034cb5db5af244be4c2b7c5f0ea62e0ef83a", "knowledge/artifacts.md": "fd4c3af58185fb0ad112f5492c3aff0de996bb35fe1c28f3893a37eaae79cab8", "knowledge/failures.md": "f7fd18e1ec479fe971e7d56204a3a3bc3409c208259ea0dc3a52acda94399de0", "knowledge/metrics.md": "8cf1a8070a0eb09f5f18fce7bec1e5bbbb306ec6eb2a4db624a740db24a985d0", "knowledge/parameters.md": "e860c02239e8f0adf76ff7f13d3947bb93bbe86e76ffb9020db6ed2bd625142f", "regression/cts_questions.jsonl": "13b1655b4c30e5b1e8ce95a90f68006f98bf51fb9fbb07d5aebae73a356c39c6", "sources.json": "dd8b2bd1e9cefdf2300dc47d9ad41f60084a56e1b55accc1be1bf0620886e55a"}, "schema_version": "ecos-step-manifest.v1"} +{"entity_count": 47, "files": {"catalog.json": "5339ada465d1966d4e0ff2e4d1ddb3ff4819da66f34ac61ce1cf9db92ed1f83e", "knowledge/algorithms.md": "a5af2adf9a4a1fe27232f320cb43034cb5db5af244be4c2b7c5f0ea62e0ef83a", "knowledge/artifacts.md": "fd4c3af58185fb0ad112f5492c3aff0de996bb35fe1c28f3893a37eaae79cab8", "knowledge/failures.md": "f7fd18e1ec479fe971e7d56204a3a3bc3409c208259ea0dc3a52acda94399de0", "knowledge/metrics.md": "8cf1a8070a0eb09f5f18fce7bec1e5bbbb306ec6eb2a4db624a740db24a985d0", "knowledge/parameters.md": "e860c02239e8f0adf76ff7f13d3947bb93bbe86e76ffb9020db6ed2bd625142f", "regression/cts_questions.jsonl": "13b1655b4c30e5b1e8ce95a90f68006f98bf51fb9fbb07d5aebae73a356c39c6", "sources.json": "61e9e8427ba3831c7b09eb69e25fc4353ba74b001439454a839b73ec7c82a4b8"}, "schema_version": "ecos-step-manifest.v1"} diff --git a/ecos/agent/knowledge/cts/sources.json b/ecos/agent/knowledge/cts/sources.json index 797de6e98..efb619366 100644 --- a/ecos/agent/knowledge/cts/sources.json +++ b/ecos/agent/knowledge/cts/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "1cf535bbed6a469037b399c059ecad5da2bdb82a72e7cc50f544db30d1e8c5b1"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "150f01725db5f4ccdf344cc191f321cc0cc682fe16b59789e50e12461ea6fb3a"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "dbd642254b2328453e3b5522a76fc6eca9fefe566f8029ceb4344c460d37b39b"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "86e1c74ae90de5a3b8ff180f67cb5b8bfac958af8d9dcfd549051452ee048a80"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "83ef3fc332ad86b041252f978651f4efde388724af93cc85193e7b9682a21cf1"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "3797f85c33322ab8f24f09db51a0d389619db810f5026e466f67f3672f7f005d"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "2b7c68a14266af92a60d93255c1057a5d1dbc742a6fc3e6ab8ed4513447903ec"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "0f3e6141f489984e1697587aa0653c434a504d5a593802a25d0cdd2ccd627029"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "cc3b2c6c97e95eed2a73e03ee94066602a064ef2ed37e4051cb9cb715f2a0c8d"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "181288f1d8208bb4e7f6c80589b5c3761a97149e6df65adb6c9f11449f35ea14"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "4c03f4ffcd539605be67350beb69723012df92a191e5bec031b30388dd959eb1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "87644517fee6b339a616aa01b96371e1a62c6a243a9b7f84e390ea1e0567679a"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "6d27833dd6d5937b9c644c032847a133086878f8345a3ba90b5b3a4f5b715664"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "1d52df523dbd0a2552a60ebfedfa8e9a71ec108ee88dec233a7cdbdd5cca6eac"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "e1a2a9c0adf18ba364c2225e9d4449893b52a29dbb0846668f38591f7aeab42b"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "de11c677f316104a44dcf3975f72cde137e21e42226b6e6e60a0a5820eac8581"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "d3fb50148cc715ca5305d708fc5d5c1805011364a06d8c9db83e1324b0fb2c61"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "baac52ceffbba8bc474444eb71a9c3c160426dcfd12848006eb94c1d2766711d"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "d751c3f7cdeae0e83799100260091b6cca5d96b25cb7eb6db8e1c5590eda2441"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "25313ed94a0707150193cd5c8e6d13be0e84633ecaf9cc4190d036952938ceb0"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "35d2875c036c31f3cd9a7453d6f9d722ae32f7f12ca9c20f782a5c7f206978b7"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "01e84d2dc2875691feba6dd80b197220e69bfe966e112033fcafe13a2478c033"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b9ae78e0548723dfda6b5439237c20e0076aea5b1dd4db9c8e2e11bc1a5da1a6"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "a1328b48e5008bf3dce236b1c3bbf8fd5d634d9efe556d703c6c95d3ccc0b7b5"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "f53b8bc10cf372a997ab6ff0df7fa530e9be477fd9dfc059f48d446219028aba"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "e79bcd01ae3b2ac16dc6cf24f6f13be141459cb6bc712dfbbdd9180517f549ce"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", "sha256": "1f37f4a5a509629cf1e57fdc42593b489dd9c7426220d5db351b888226acb0c4"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", "sha256": "fd967ff04a5b0af5f5acd0f86c987c92f6e32f440d931a2307ff3e504226a60a"}, {"id": "config.cts", "path": "ecc/chipcompiler/tools/ecc/configs/cts_ecc.json", "sha256": "a9a236aba16d7c76cfb10b60780d6761536140e201dd320144c8a5327e37efb2"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "733db918833ae7b1bd9dcdaebfb61a33d653089a7e93d181a44d9c5dccc070f3"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "349cff3e7756528dd5e86fda4d31f9f99e000194a989df153dc9b619cce658a9"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "7523ecc836515b6d0ceb4b4ef4b9762d198337bcde035756eb0c5b07763bc65e"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "425d97334c7ae27e1a2b889caf70fe2dbed2256bd9eb94b84aff5d7ba19d146b"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "ba0f45dd9c5f8a81e22604096e96f3a40495aae48e1914c40f59d6fe8fbaed29"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "857db8dee1a44e59c9fa0a6529cb3fa1572bcc3a8912471369b5be6d1aa8a312"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "1eb73e9eef0661ce18ba64ea6430676711d11390e51e0401fb8687cbdce183df"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "46e2a8af132a2fbdab82094b916e61266633c1d557f22a3f95bbec979174ed29"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "2b6cc5aeadb2ed1c574f31facd467c899cf3173c0d6ae3d80a227a3c35bd9661"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "bcfb7d43ae4f4837221b7a902078a56f0120cdc51c1ac70ca9222e5d434fe4fb"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "a59edef29fd9915b076adb28d37b9c4dfd1bcdb30be26970b28590cab7056db1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "27901646e566f6daa023aec9aa3bb0304e71a92725398c82416125e76ed3633c"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "2df0b006666a4503c0fab8a5b0a03ef2a1720788fd47a8e4a80e331cb93cd822"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "7ae73bb71dffa1a0b934e396afcdfa3a5304c853d1d5be465e5362198e2a5641"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "f2289589d023fd03a94b8bbdb7bfc95812057afb1c360bd8b8c3ac80de0fa203"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "fe9bb78f5a956cc7ab24a3da2c0212ce38a912dccdf7e568d918adb6d89c1a8f"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "31c97d537f0085ecc51f7c52931eaa4bebf735f6fba795b8ecada2e0a6eb3374"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "e561307cd0159be4f3731c38ccf452665b540928cad5d966295b51737fc841d4"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "23ea8070bbb31d219891cce3dc6f0c5353b4012c6d87ae9ba1e04e7a0edf0d40"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "42234167a797d93a82d7b36882530b982a94d0f41c3f811c82e03ba3ec5ac083"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "f73c527566e4c27d12a10c2bb7bba29e7f9e05f7c0845d1c5319960a642c1502"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "738a506706465576947164be40b82b8c57878cbb40236173f0174a04584fd295"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b6b791f25ad357addf94c75692005144ec84ee65ffa67bdff7bf507c44858b51"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "9f3f3bab73966493171a39fb99dd800166f7108722e45f3d9227a2ea31df4dde"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "ae50e3643a2c8179846b6e40404d2d82917a655d3b03e06e4c940005b556e64c"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "5911cfc0b6ac4d12899fd14616f9167f1f9c7fece3292a3aaaa785bdc4921872"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "config.cts", "path": "ecc/chipcompiler/tools/ecc/configs/cts_ecc.json", "sha256": "a9a236aba16d7c76cfb10b60780d6761536140e201dd320144c8a5327e37efb2"}]} diff --git a/ecos/agent/knowledge/drc/manifest.json b/ecos/agent/knowledge/drc/manifest.json index 9fd982181..281f9d332 100644 --- a/ecos/agent/knowledge/drc/manifest.json +++ b/ecos/agent/knowledge/drc/manifest.json @@ -1 +1 @@ -{"entity_count": 23, "files": {"catalog.json": "8b135a856e297965b81f5286a7d272946c38dbde25398eb1d563879a30566a03", "knowledge/algorithms.md": "0579fe78986de17ef767b1a0b3b45098f6a31b8ff125877fdf18e6f9bb50c779", "knowledge/artifacts.md": "a42430a131094fa708ba7e241ee88fb00d1de3c9d56bdff9a8430132eb18af42", "knowledge/failures.md": "50987deefba6f7892de098b34c1b01de134033f57c572660cf05dcbc0e66c4d5", "knowledge/metrics.md": "7f07346880ab2b36f5923b1498ae0587ab649cc08126aabd4ede06b0a4a97b88", "knowledge/parameters.md": "15aefe01ca2601264c1bf77a7dd5af01fa0087ef48c5a0c64bb83c8f1fff5246", "regression/drc_questions.jsonl": "44fdc91c9a1691a321e3c37b7a6bcc7751a7bf96535862ecad334beed6fa20ee", "sources.json": "4a86facf5e04bf7d34409a01c3cb3035e68157a8fc6f8701d0352d6f6c3dea8a"}, "schema_version": "ecos-step-manifest.v1"} +{"entity_count": 23, "files": {"catalog.json": "8b135a856e297965b81f5286a7d272946c38dbde25398eb1d563879a30566a03", "knowledge/algorithms.md": "0579fe78986de17ef767b1a0b3b45098f6a31b8ff125877fdf18e6f9bb50c779", "knowledge/artifacts.md": "a42430a131094fa708ba7e241ee88fb00d1de3c9d56bdff9a8430132eb18af42", "knowledge/failures.md": "50987deefba6f7892de098b34c1b01de134033f57c572660cf05dcbc0e66c4d5", "knowledge/metrics.md": "7f07346880ab2b36f5923b1498ae0587ab649cc08126aabd4ede06b0a4a97b88", "knowledge/parameters.md": "15aefe01ca2601264c1bf77a7dd5af01fa0087ef48c5a0c64bb83c8f1fff5246", "regression/drc_questions.jsonl": "44fdc91c9a1691a321e3c37b7a6bcc7751a7bf96535862ecad334beed6fa20ee", "sources.json": "f09ec9cd8f0fb42016fe84d89ebd10e3ff64f7dba97b242eef25f81542c735c7"}, "schema_version": "ecos-step-manifest.v1"} diff --git a/ecos/agent/knowledge/drc/sources.json b/ecos/agent/knowledge/drc/sources.json index 970e31960..502930066 100644 --- a/ecos/agent/knowledge/drc/sources.json +++ b/ecos/agent/knowledge/drc/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "1cf535bbed6a469037b399c059ecad5da2bdb82a72e7cc50f544db30d1e8c5b1"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "150f01725db5f4ccdf344cc191f321cc0cc682fe16b59789e50e12461ea6fb3a"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "dbd642254b2328453e3b5522a76fc6eca9fefe566f8029ceb4344c460d37b39b"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "86e1c74ae90de5a3b8ff180f67cb5b8bfac958af8d9dcfd549051452ee048a80"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "83ef3fc332ad86b041252f978651f4efde388724af93cc85193e7b9682a21cf1"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "3797f85c33322ab8f24f09db51a0d389619db810f5026e466f67f3672f7f005d"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "2b7c68a14266af92a60d93255c1057a5d1dbc742a6fc3e6ab8ed4513447903ec"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "0f3e6141f489984e1697587aa0653c434a504d5a593802a25d0cdd2ccd627029"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "cc3b2c6c97e95eed2a73e03ee94066602a064ef2ed37e4051cb9cb715f2a0c8d"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "181288f1d8208bb4e7f6c80589b5c3761a97149e6df65adb6c9f11449f35ea14"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "4c03f4ffcd539605be67350beb69723012df92a191e5bec031b30388dd959eb1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "87644517fee6b339a616aa01b96371e1a62c6a243a9b7f84e390ea1e0567679a"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "6d27833dd6d5937b9c644c032847a133086878f8345a3ba90b5b3a4f5b715664"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "1d52df523dbd0a2552a60ebfedfa8e9a71ec108ee88dec233a7cdbdd5cca6eac"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "e1a2a9c0adf18ba364c2225e9d4449893b52a29dbb0846668f38591f7aeab42b"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "de11c677f316104a44dcf3975f72cde137e21e42226b6e6e60a0a5820eac8581"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "d3fb50148cc715ca5305d708fc5d5c1805011364a06d8c9db83e1324b0fb2c61"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "baac52ceffbba8bc474444eb71a9c3c160426dcfd12848006eb94c1d2766711d"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "d751c3f7cdeae0e83799100260091b6cca5d96b25cb7eb6db8e1c5590eda2441"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "25313ed94a0707150193cd5c8e6d13be0e84633ecaf9cc4190d036952938ceb0"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "35d2875c036c31f3cd9a7453d6f9d722ae32f7f12ca9c20f782a5c7f206978b7"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "01e84d2dc2875691feba6dd80b197220e69bfe966e112033fcafe13a2478c033"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b9ae78e0548723dfda6b5439237c20e0076aea5b1dd4db9c8e2e11bc1a5da1a6"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "a1328b48e5008bf3dce236b1c3bbf8fd5d634d9efe556d703c6c95d3ccc0b7b5"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "f53b8bc10cf372a997ab6ff0df7fa530e9be477fd9dfc059f48d446219028aba"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "e79bcd01ae3b2ac16dc6cf24f6f13be141459cb6bc712dfbbdd9180517f549ce"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", "sha256": "1f37f4a5a509629cf1e57fdc42593b489dd9c7426220d5db351b888226acb0c4"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", "sha256": "fd967ff04a5b0af5f5acd0f86c987c92f6e32f440d931a2307ff3e504226a60a"}, {"id": "config.drc", "path": "ecc/chipcompiler/tools/ecc/configs/drc_ecc.json", "sha256": "8eb95bcbc154530931e15fc418c8b1fe991095671409552099ea1aa596999ede"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "733db918833ae7b1bd9dcdaebfb61a33d653089a7e93d181a44d9c5dccc070f3"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "349cff3e7756528dd5e86fda4d31f9f99e000194a989df153dc9b619cce658a9"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "7523ecc836515b6d0ceb4b4ef4b9762d198337bcde035756eb0c5b07763bc65e"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "425d97334c7ae27e1a2b889caf70fe2dbed2256bd9eb94b84aff5d7ba19d146b"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "ba0f45dd9c5f8a81e22604096e96f3a40495aae48e1914c40f59d6fe8fbaed29"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "857db8dee1a44e59c9fa0a6529cb3fa1572bcc3a8912471369b5be6d1aa8a312"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "1eb73e9eef0661ce18ba64ea6430676711d11390e51e0401fb8687cbdce183df"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "46e2a8af132a2fbdab82094b916e61266633c1d557f22a3f95bbec979174ed29"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "2b6cc5aeadb2ed1c574f31facd467c899cf3173c0d6ae3d80a227a3c35bd9661"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "bcfb7d43ae4f4837221b7a902078a56f0120cdc51c1ac70ca9222e5d434fe4fb"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "a59edef29fd9915b076adb28d37b9c4dfd1bcdb30be26970b28590cab7056db1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "27901646e566f6daa023aec9aa3bb0304e71a92725398c82416125e76ed3633c"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "2df0b006666a4503c0fab8a5b0a03ef2a1720788fd47a8e4a80e331cb93cd822"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "7ae73bb71dffa1a0b934e396afcdfa3a5304c853d1d5be465e5362198e2a5641"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "f2289589d023fd03a94b8bbdb7bfc95812057afb1c360bd8b8c3ac80de0fa203"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "fe9bb78f5a956cc7ab24a3da2c0212ce38a912dccdf7e568d918adb6d89c1a8f"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "31c97d537f0085ecc51f7c52931eaa4bebf735f6fba795b8ecada2e0a6eb3374"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "e561307cd0159be4f3731c38ccf452665b540928cad5d966295b51737fc841d4"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "23ea8070bbb31d219891cce3dc6f0c5353b4012c6d87ae9ba1e04e7a0edf0d40"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "42234167a797d93a82d7b36882530b982a94d0f41c3f811c82e03ba3ec5ac083"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "f73c527566e4c27d12a10c2bb7bba29e7f9e05f7c0845d1c5319960a642c1502"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "738a506706465576947164be40b82b8c57878cbb40236173f0174a04584fd295"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b6b791f25ad357addf94c75692005144ec84ee65ffa67bdff7bf507c44858b51"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "9f3f3bab73966493171a39fb99dd800166f7108722e45f3d9227a2ea31df4dde"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "ae50e3643a2c8179846b6e40404d2d82917a655d3b03e06e4c940005b556e64c"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "5911cfc0b6ac4d12899fd14616f9167f1f9c7fece3292a3aaaa785bdc4921872"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "config.drc", "path": "ecc/chipcompiler/tools/ecc/configs/drc_ecc.json", "sha256": "8eb95bcbc154530931e15fc418c8b1fe991095671409552099ea1aa596999ede"}]} diff --git a/ecos/agent/knowledge/filler/manifest.json b/ecos/agent/knowledge/filler/manifest.json index 5873f9740..9963cfb24 100644 --- a/ecos/agent/knowledge/filler/manifest.json +++ b/ecos/agent/knowledge/filler/manifest.json @@ -1 +1 @@ -{"entity_count": 24, "files": {"catalog.json": "e30b1930c5587308b58398634cef1c93601d8ee5c7b40330ab801b91375f7f7f", "knowledge/algorithms.md": "3117b2cd90af5a5fddb601c204df6e55f0524f01ff73e76a4b04413f7f923b92", "knowledge/artifacts.md": "caa978c0c554517a55cc6c313070bf28ecc7292b0cf1306812b30e19dec7fe6c", "knowledge/failures.md": "0f5591dd6e16f30a1c52ea5be10d45fb8622e624252b9ead11e374b21949b6b8", "knowledge/metrics.md": "6bd3ed280696d533bc7a4cfcb7325f22662180f5855894294776ccff3069f8e2", "knowledge/parameters.md": "60b19fa09e168fcb261f47cdb28a6b3ece02a93e46b9a196c1c7c0ae01ba1e3a", "regression/filler_questions.jsonl": "8c81962d544f6802eb47dffddb21c2506eaa1a6835d3f5e973f8b408a3c77079", "sources.json": "0a47f693e5847f5ef5ff37c21a3ef9842810b5a0aa2debe881229e7f934e2309"}, "schema_version": "ecos-step-manifest.v1"} +{"entity_count": 24, "files": {"catalog.json": "e30b1930c5587308b58398634cef1c93601d8ee5c7b40330ab801b91375f7f7f", "knowledge/algorithms.md": "3117b2cd90af5a5fddb601c204df6e55f0524f01ff73e76a4b04413f7f923b92", "knowledge/artifacts.md": "caa978c0c554517a55cc6c313070bf28ecc7292b0cf1306812b30e19dec7fe6c", "knowledge/failures.md": "0f5591dd6e16f30a1c52ea5be10d45fb8622e624252b9ead11e374b21949b6b8", "knowledge/metrics.md": "6bd3ed280696d533bc7a4cfcb7325f22662180f5855894294776ccff3069f8e2", "knowledge/parameters.md": "60b19fa09e168fcb261f47cdb28a6b3ece02a93e46b9a196c1c7c0ae01ba1e3a", "regression/filler_questions.jsonl": "8c81962d544f6802eb47dffddb21c2506eaa1a6835d3f5e973f8b408a3c77079", "sources.json": "47f9626a0035cd13927ecfcdc8a44706edc19cf62d77a2f6228d62fe2415b1dc"}, "schema_version": "ecos-step-manifest.v1"} diff --git a/ecos/agent/knowledge/filler/sources.json b/ecos/agent/knowledge/filler/sources.json index 77387b2a9..1993ba496 100644 --- a/ecos/agent/knowledge/filler/sources.json +++ b/ecos/agent/knowledge/filler/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "1cf535bbed6a469037b399c059ecad5da2bdb82a72e7cc50f544db30d1e8c5b1"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "150f01725db5f4ccdf344cc191f321cc0cc682fe16b59789e50e12461ea6fb3a"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "dbd642254b2328453e3b5522a76fc6eca9fefe566f8029ceb4344c460d37b39b"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "86e1c74ae90de5a3b8ff180f67cb5b8bfac958af8d9dcfd549051452ee048a80"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "83ef3fc332ad86b041252f978651f4efde388724af93cc85193e7b9682a21cf1"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "3797f85c33322ab8f24f09db51a0d389619db810f5026e466f67f3672f7f005d"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "2b7c68a14266af92a60d93255c1057a5d1dbc742a6fc3e6ab8ed4513447903ec"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "0f3e6141f489984e1697587aa0653c434a504d5a593802a25d0cdd2ccd627029"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "cc3b2c6c97e95eed2a73e03ee94066602a064ef2ed37e4051cb9cb715f2a0c8d"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "181288f1d8208bb4e7f6c80589b5c3761a97149e6df65adb6c9f11449f35ea14"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "4c03f4ffcd539605be67350beb69723012df92a191e5bec031b30388dd959eb1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "87644517fee6b339a616aa01b96371e1a62c6a243a9b7f84e390ea1e0567679a"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "6d27833dd6d5937b9c644c032847a133086878f8345a3ba90b5b3a4f5b715664"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "1d52df523dbd0a2552a60ebfedfa8e9a71ec108ee88dec233a7cdbdd5cca6eac"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "e1a2a9c0adf18ba364c2225e9d4449893b52a29dbb0846668f38591f7aeab42b"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "de11c677f316104a44dcf3975f72cde137e21e42226b6e6e60a0a5820eac8581"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "d3fb50148cc715ca5305d708fc5d5c1805011364a06d8c9db83e1324b0fb2c61"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "baac52ceffbba8bc474444eb71a9c3c160426dcfd12848006eb94c1d2766711d"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "d751c3f7cdeae0e83799100260091b6cca5d96b25cb7eb6db8e1c5590eda2441"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "25313ed94a0707150193cd5c8e6d13be0e84633ecaf9cc4190d036952938ceb0"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "35d2875c036c31f3cd9a7453d6f9d722ae32f7f12ca9c20f782a5c7f206978b7"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "01e84d2dc2875691feba6dd80b197220e69bfe966e112033fcafe13a2478c033"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b9ae78e0548723dfda6b5439237c20e0076aea5b1dd4db9c8e2e11bc1a5da1a6"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "a1328b48e5008bf3dce236b1c3bbf8fd5d634d9efe556d703c6c95d3ccc0b7b5"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "f53b8bc10cf372a997ab6ff0df7fa530e9be477fd9dfc059f48d446219028aba"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "e79bcd01ae3b2ac16dc6cf24f6f13be141459cb6bc712dfbbdd9180517f549ce"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", "sha256": "1f37f4a5a509629cf1e57fdc42593b489dd9c7426220d5db351b888226acb0c4"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", "sha256": "fd967ff04a5b0af5f5acd0f86c987c92f6e32f440d931a2307ff3e504226a60a"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "733db918833ae7b1bd9dcdaebfb61a33d653089a7e93d181a44d9c5dccc070f3"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "349cff3e7756528dd5e86fda4d31f9f99e000194a989df153dc9b619cce658a9"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "7523ecc836515b6d0ceb4b4ef4b9762d198337bcde035756eb0c5b07763bc65e"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "425d97334c7ae27e1a2b889caf70fe2dbed2256bd9eb94b84aff5d7ba19d146b"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "ba0f45dd9c5f8a81e22604096e96f3a40495aae48e1914c40f59d6fe8fbaed29"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "857db8dee1a44e59c9fa0a6529cb3fa1572bcc3a8912471369b5be6d1aa8a312"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "1eb73e9eef0661ce18ba64ea6430676711d11390e51e0401fb8687cbdce183df"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "46e2a8af132a2fbdab82094b916e61266633c1d557f22a3f95bbec979174ed29"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "2b6cc5aeadb2ed1c574f31facd467c899cf3173c0d6ae3d80a227a3c35bd9661"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "bcfb7d43ae4f4837221b7a902078a56f0120cdc51c1ac70ca9222e5d434fe4fb"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "a59edef29fd9915b076adb28d37b9c4dfd1bcdb30be26970b28590cab7056db1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "27901646e566f6daa023aec9aa3bb0304e71a92725398c82416125e76ed3633c"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "2df0b006666a4503c0fab8a5b0a03ef2a1720788fd47a8e4a80e331cb93cd822"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "7ae73bb71dffa1a0b934e396afcdfa3a5304c853d1d5be465e5362198e2a5641"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "f2289589d023fd03a94b8bbdb7bfc95812057afb1c360bd8b8c3ac80de0fa203"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "fe9bb78f5a956cc7ab24a3da2c0212ce38a912dccdf7e568d918adb6d89c1a8f"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "31c97d537f0085ecc51f7c52931eaa4bebf735f6fba795b8ecada2e0a6eb3374"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "e561307cd0159be4f3731c38ccf452665b540928cad5d966295b51737fc841d4"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "23ea8070bbb31d219891cce3dc6f0c5353b4012c6d87ae9ba1e04e7a0edf0d40"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "42234167a797d93a82d7b36882530b982a94d0f41c3f811c82e03ba3ec5ac083"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "f73c527566e4c27d12a10c2bb7bba29e7f9e05f7c0845d1c5319960a642c1502"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "738a506706465576947164be40b82b8c57878cbb40236173f0174a04584fd295"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b6b791f25ad357addf94c75692005144ec84ee65ffa67bdff7bf507c44858b51"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "9f3f3bab73966493171a39fb99dd800166f7108722e45f3d9227a2ea31df4dde"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "ae50e3643a2c8179846b6e40404d2d82917a655d3b03e06e4c940005b556e64c"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "5911cfc0b6ac4d12899fd14616f9167f1f9c7fece3292a3aaaa785bdc4921872"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}]} diff --git a/ecos/agent/knowledge/floorplan/manifest.json b/ecos/agent/knowledge/floorplan/manifest.json index c6a0a3351..cb7592164 100644 --- a/ecos/agent/knowledge/floorplan/manifest.json +++ b/ecos/agent/knowledge/floorplan/manifest.json @@ -1 +1 @@ -{"entity_count": 55, "files": {"catalog.json": "79c48f870999581958b8b520b7fa6bc115b8781f3a77d02c6bc9c60986325cd4", "knowledge/algorithms.md": "4a18bcb87f58e8852e20bf63fb91d9f1c4f3593254280d486ff1557bf2b436e6", "knowledge/artifacts.md": "26e00ab52f1f41c3e27c17906604ad0cc464ad7ff0e1bf67cb9d13242ba558a0", "knowledge/failures.md": "74fb23ac03ac7b2f251a122c433bac9473293f81589049f6f304bfa43dfb20cd", "knowledge/metrics.md": "5db98a1c84e89d53cf3326b011f755be335ca906ad032e048adecc85868b98c7", "knowledge/parameters.md": "0fb6cca0d9e7cf930ecd6656b267f8a7fa890df3b10a1f827fd67de83a8b64a2", "regression/floorplan_questions.jsonl": "0205b22b0478f625e263afa6c5aa04f83d6bce69b241098666819a0916673ca0", "sources.json": "40add890cad5aa2a0dc2add93ee3bfed801909e928b2b28de20a69de18b6e717"}, "schema_version": "ecos-step-manifest.v1"} +{"entity_count": 55, "files": {"catalog.json": "79c48f870999581958b8b520b7fa6bc115b8781f3a77d02c6bc9c60986325cd4", "knowledge/algorithms.md": "4a18bcb87f58e8852e20bf63fb91d9f1c4f3593254280d486ff1557bf2b436e6", "knowledge/artifacts.md": "26e00ab52f1f41c3e27c17906604ad0cc464ad7ff0e1bf67cb9d13242ba558a0", "knowledge/failures.md": "74fb23ac03ac7b2f251a122c433bac9473293f81589049f6f304bfa43dfb20cd", "knowledge/metrics.md": "5db98a1c84e89d53cf3326b011f755be335ca906ad032e048adecc85868b98c7", "knowledge/parameters.md": "0fb6cca0d9e7cf930ecd6656b267f8a7fa890df3b10a1f827fd67de83a8b64a2", "regression/floorplan_questions.jsonl": "0205b22b0478f625e263afa6c5aa04f83d6bce69b241098666819a0916673ca0", "sources.json": "15ee772451eba039b8da42338047c34be3ce6fc11515ffb7ea4017d397dc210a"}, "schema_version": "ecos-step-manifest.v1"} diff --git a/ecos/agent/knowledge/floorplan/sources.json b/ecos/agent/knowledge/floorplan/sources.json index 4ce28530b..be4afc359 100644 --- a/ecos/agent/knowledge/floorplan/sources.json +++ b/ecos/agent/knowledge/floorplan/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "1cf535bbed6a469037b399c059ecad5da2bdb82a72e7cc50f544db30d1e8c5b1"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "150f01725db5f4ccdf344cc191f321cc0cc682fe16b59789e50e12461ea6fb3a"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "dbd642254b2328453e3b5522a76fc6eca9fefe566f8029ceb4344c460d37b39b"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "86e1c74ae90de5a3b8ff180f67cb5b8bfac958af8d9dcfd549051452ee048a80"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "83ef3fc332ad86b041252f978651f4efde388724af93cc85193e7b9682a21cf1"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "3797f85c33322ab8f24f09db51a0d389619db810f5026e466f67f3672f7f005d"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "2b7c68a14266af92a60d93255c1057a5d1dbc742a6fc3e6ab8ed4513447903ec"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "0f3e6141f489984e1697587aa0653c434a504d5a593802a25d0cdd2ccd627029"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "cc3b2c6c97e95eed2a73e03ee94066602a064ef2ed37e4051cb9cb715f2a0c8d"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "181288f1d8208bb4e7f6c80589b5c3761a97149e6df65adb6c9f11449f35ea14"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "4c03f4ffcd539605be67350beb69723012df92a191e5bec031b30388dd959eb1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "87644517fee6b339a616aa01b96371e1a62c6a243a9b7f84e390ea1e0567679a"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "6d27833dd6d5937b9c644c032847a133086878f8345a3ba90b5b3a4f5b715664"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "1d52df523dbd0a2552a60ebfedfa8e9a71ec108ee88dec233a7cdbdd5cca6eac"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "e1a2a9c0adf18ba364c2225e9d4449893b52a29dbb0846668f38591f7aeab42b"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "de11c677f316104a44dcf3975f72cde137e21e42226b6e6e60a0a5820eac8581"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "d3fb50148cc715ca5305d708fc5d5c1805011364a06d8c9db83e1324b0fb2c61"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "baac52ceffbba8bc474444eb71a9c3c160426dcfd12848006eb94c1d2766711d"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "d751c3f7cdeae0e83799100260091b6cca5d96b25cb7eb6db8e1c5590eda2441"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "25313ed94a0707150193cd5c8e6d13be0e84633ecaf9cc4190d036952938ceb0"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "35d2875c036c31f3cd9a7453d6f9d722ae32f7f12ca9c20f782a5c7f206978b7"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "01e84d2dc2875691feba6dd80b197220e69bfe966e112033fcafe13a2478c033"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b9ae78e0548723dfda6b5439237c20e0076aea5b1dd4db9c8e2e11bc1a5da1a6"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "a1328b48e5008bf3dce236b1c3bbf8fd5d634d9efe556d703c6c95d3ccc0b7b5"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "f53b8bc10cf372a997ab6ff0df7fa530e9be477fd9dfc059f48d446219028aba"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "e79bcd01ae3b2ac16dc6cf24f6f13be141459cb6bc712dfbbdd9180517f549ce"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", "sha256": "1f37f4a5a509629cf1e57fdc42593b489dd9c7426220d5db351b888226acb0c4"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", "sha256": "fd967ff04a5b0af5f5acd0f86c987c92f6e32f440d931a2307ff3e504226a60a"}, {"id": "config.floorplan", "path": "ecc/chipcompiler/tools/ecc/configs/floorplan_ecc.json", "sha256": "2236944e5f3831d12ca0e1057b1313d0ccd03d0b48f34b8dd2ea4bfb3480c346"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "733db918833ae7b1bd9dcdaebfb61a33d653089a7e93d181a44d9c5dccc070f3"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "349cff3e7756528dd5e86fda4d31f9f99e000194a989df153dc9b619cce658a9"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "7523ecc836515b6d0ceb4b4ef4b9762d198337bcde035756eb0c5b07763bc65e"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "425d97334c7ae27e1a2b889caf70fe2dbed2256bd9eb94b84aff5d7ba19d146b"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "ba0f45dd9c5f8a81e22604096e96f3a40495aae48e1914c40f59d6fe8fbaed29"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "857db8dee1a44e59c9fa0a6529cb3fa1572bcc3a8912471369b5be6d1aa8a312"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "1eb73e9eef0661ce18ba64ea6430676711d11390e51e0401fb8687cbdce183df"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "46e2a8af132a2fbdab82094b916e61266633c1d557f22a3f95bbec979174ed29"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "2b6cc5aeadb2ed1c574f31facd467c899cf3173c0d6ae3d80a227a3c35bd9661"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "bcfb7d43ae4f4837221b7a902078a56f0120cdc51c1ac70ca9222e5d434fe4fb"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "a59edef29fd9915b076adb28d37b9c4dfd1bcdb30be26970b28590cab7056db1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "27901646e566f6daa023aec9aa3bb0304e71a92725398c82416125e76ed3633c"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "2df0b006666a4503c0fab8a5b0a03ef2a1720788fd47a8e4a80e331cb93cd822"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "7ae73bb71dffa1a0b934e396afcdfa3a5304c853d1d5be465e5362198e2a5641"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "f2289589d023fd03a94b8bbdb7bfc95812057afb1c360bd8b8c3ac80de0fa203"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "fe9bb78f5a956cc7ab24a3da2c0212ce38a912dccdf7e568d918adb6d89c1a8f"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "31c97d537f0085ecc51f7c52931eaa4bebf735f6fba795b8ecada2e0a6eb3374"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "e561307cd0159be4f3731c38ccf452665b540928cad5d966295b51737fc841d4"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "23ea8070bbb31d219891cce3dc6f0c5353b4012c6d87ae9ba1e04e7a0edf0d40"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "42234167a797d93a82d7b36882530b982a94d0f41c3f811c82e03ba3ec5ac083"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "f73c527566e4c27d12a10c2bb7bba29e7f9e05f7c0845d1c5319960a642c1502"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "738a506706465576947164be40b82b8c57878cbb40236173f0174a04584fd295"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b6b791f25ad357addf94c75692005144ec84ee65ffa67bdff7bf507c44858b51"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "9f3f3bab73966493171a39fb99dd800166f7108722e45f3d9227a2ea31df4dde"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "ae50e3643a2c8179846b6e40404d2d82917a655d3b03e06e4c940005b556e64c"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "5911cfc0b6ac4d12899fd14616f9167f1f9c7fece3292a3aaaa785bdc4921872"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "config.floorplan", "path": "ecc/chipcompiler/tools/ecc/configs/floorplan_ecc.json", "sha256": "2236944e5f3831d12ca0e1057b1313d0ccd03d0b48f34b8dd2ea4bfb3480c346"}]} diff --git a/ecos/agent/knowledge/harden/manifest.json b/ecos/agent/knowledge/harden/manifest.json index 706228945..b3e65cfe5 100644 --- a/ecos/agent/knowledge/harden/manifest.json +++ b/ecos/agent/knowledge/harden/manifest.json @@ -1 +1 @@ -{"entity_count": 17, "files": {"catalog.json": "f7dc32b0d69a649a6c1167f5a018478ff1ffa2da2efa4569dd55e7875da5bff1", "knowledge/algorithms.md": "d5bc26d806097960025e1f7d1249c85af92f74f7308a827c85e458222ebfef89", "knowledge/artifacts.md": "971f66df9273d5873d85047a65100cabd8a1d32047fccfaa8309bebd7f256198", "knowledge/failures.md": "12271b76779cfb403ed2a1c7763050d6ac09217a7ac5be53ddca7f3061f5253a", "knowledge/metrics.md": "9c037ffe96108032e11abf25f316cce8c67d50537ffa3a0278b182e3d1f267a6", "knowledge/parameters.md": "f280f7814d30bb4bda26b00d90e9720dd71b836762fd7a30750168ba3649cdbd", "regression/harden_questions.jsonl": "1581c2aeb77a7d20f3cd31ea2b082ccd5971fd85dfe1bff0fda520642ced6c64", "sources.json": "e38ddaf265a96a37ec713bd80827d4872a3186fe4a81bdcfdf64e90794bc04fb"}, "schema_version": "ecos-step-manifest.v1"} +{"entity_count": 17, "files": {"catalog.json": "f7dc32b0d69a649a6c1167f5a018478ff1ffa2da2efa4569dd55e7875da5bff1", "knowledge/algorithms.md": "d5bc26d806097960025e1f7d1249c85af92f74f7308a827c85e458222ebfef89", "knowledge/artifacts.md": "971f66df9273d5873d85047a65100cabd8a1d32047fccfaa8309bebd7f256198", "knowledge/failures.md": "12271b76779cfb403ed2a1c7763050d6ac09217a7ac5be53ddca7f3061f5253a", "knowledge/metrics.md": "9c037ffe96108032e11abf25f316cce8c67d50537ffa3a0278b182e3d1f267a6", "knowledge/parameters.md": "f280f7814d30bb4bda26b00d90e9720dd71b836762fd7a30750168ba3649cdbd", "regression/harden_questions.jsonl": "1581c2aeb77a7d20f3cd31ea2b082ccd5971fd85dfe1bff0fda520642ced6c64", "sources.json": "e335d0ef50c704672198982732092b7523904eec58aa3ab7cfcd3d9e47bd2104"}, "schema_version": "ecos-step-manifest.v1"} diff --git a/ecos/agent/knowledge/harden/sources.json b/ecos/agent/knowledge/harden/sources.json index 776f0b101..75fc49464 100644 --- a/ecos/agent/knowledge/harden/sources.json +++ b/ecos/agent/knowledge/harden/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "1cf535bbed6a469037b399c059ecad5da2bdb82a72e7cc50f544db30d1e8c5b1"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "150f01725db5f4ccdf344cc191f321cc0cc682fe16b59789e50e12461ea6fb3a"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "dbd642254b2328453e3b5522a76fc6eca9fefe566f8029ceb4344c460d37b39b"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "86e1c74ae90de5a3b8ff180f67cb5b8bfac958af8d9dcfd549051452ee048a80"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "83ef3fc332ad86b041252f978651f4efde388724af93cc85193e7b9682a21cf1"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "3797f85c33322ab8f24f09db51a0d389619db810f5026e466f67f3672f7f005d"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "2b7c68a14266af92a60d93255c1057a5d1dbc742a6fc3e6ab8ed4513447903ec"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "0f3e6141f489984e1697587aa0653c434a504d5a593802a25d0cdd2ccd627029"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "cc3b2c6c97e95eed2a73e03ee94066602a064ef2ed37e4051cb9cb715f2a0c8d"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "181288f1d8208bb4e7f6c80589b5c3761a97149e6df65adb6c9f11449f35ea14"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "4c03f4ffcd539605be67350beb69723012df92a191e5bec031b30388dd959eb1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "87644517fee6b339a616aa01b96371e1a62c6a243a9b7f84e390ea1e0567679a"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "6d27833dd6d5937b9c644c032847a133086878f8345a3ba90b5b3a4f5b715664"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "1d52df523dbd0a2552a60ebfedfa8e9a71ec108ee88dec233a7cdbdd5cca6eac"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "e1a2a9c0adf18ba364c2225e9d4449893b52a29dbb0846668f38591f7aeab42b"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "de11c677f316104a44dcf3975f72cde137e21e42226b6e6e60a0a5820eac8581"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "d3fb50148cc715ca5305d708fc5d5c1805011364a06d8c9db83e1324b0fb2c61"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "baac52ceffbba8bc474444eb71a9c3c160426dcfd12848006eb94c1d2766711d"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "d751c3f7cdeae0e83799100260091b6cca5d96b25cb7eb6db8e1c5590eda2441"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "25313ed94a0707150193cd5c8e6d13be0e84633ecaf9cc4190d036952938ceb0"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "35d2875c036c31f3cd9a7453d6f9d722ae32f7f12ca9c20f782a5c7f206978b7"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "01e84d2dc2875691feba6dd80b197220e69bfe966e112033fcafe13a2478c033"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b9ae78e0548723dfda6b5439237c20e0076aea5b1dd4db9c8e2e11bc1a5da1a6"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "a1328b48e5008bf3dce236b1c3bbf8fd5d634d9efe556d703c6c95d3ccc0b7b5"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "f53b8bc10cf372a997ab6ff0df7fa530e9be477fd9dfc059f48d446219028aba"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "e79bcd01ae3b2ac16dc6cf24f6f13be141459cb6bc712dfbbdd9180517f549ce"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", "sha256": "1f37f4a5a509629cf1e57fdc42593b489dd9c7426220d5db351b888226acb0c4"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", "sha256": "fd967ff04a5b0af5f5acd0f86c987c92f6e32f440d931a2307ff3e504226a60a"}, {"id": "config.harden", "path": "ecc/chipcompiler/tools/ecc/configs/sta_ecc.json", "sha256": "aae3e507ada13b396172bd58fa865f441ba4a276f00e1ccfa96aee4bfa9a4c6a"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "733db918833ae7b1bd9dcdaebfb61a33d653089a7e93d181a44d9c5dccc070f3"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "349cff3e7756528dd5e86fda4d31f9f99e000194a989df153dc9b619cce658a9"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "7523ecc836515b6d0ceb4b4ef4b9762d198337bcde035756eb0c5b07763bc65e"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "425d97334c7ae27e1a2b889caf70fe2dbed2256bd9eb94b84aff5d7ba19d146b"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "ba0f45dd9c5f8a81e22604096e96f3a40495aae48e1914c40f59d6fe8fbaed29"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "857db8dee1a44e59c9fa0a6529cb3fa1572bcc3a8912471369b5be6d1aa8a312"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "1eb73e9eef0661ce18ba64ea6430676711d11390e51e0401fb8687cbdce183df"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "46e2a8af132a2fbdab82094b916e61266633c1d557f22a3f95bbec979174ed29"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "2b6cc5aeadb2ed1c574f31facd467c899cf3173c0d6ae3d80a227a3c35bd9661"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "bcfb7d43ae4f4837221b7a902078a56f0120cdc51c1ac70ca9222e5d434fe4fb"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "a59edef29fd9915b076adb28d37b9c4dfd1bcdb30be26970b28590cab7056db1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "27901646e566f6daa023aec9aa3bb0304e71a92725398c82416125e76ed3633c"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "2df0b006666a4503c0fab8a5b0a03ef2a1720788fd47a8e4a80e331cb93cd822"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "7ae73bb71dffa1a0b934e396afcdfa3a5304c853d1d5be465e5362198e2a5641"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "f2289589d023fd03a94b8bbdb7bfc95812057afb1c360bd8b8c3ac80de0fa203"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "fe9bb78f5a956cc7ab24a3da2c0212ce38a912dccdf7e568d918adb6d89c1a8f"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "31c97d537f0085ecc51f7c52931eaa4bebf735f6fba795b8ecada2e0a6eb3374"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "e561307cd0159be4f3731c38ccf452665b540928cad5d966295b51737fc841d4"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "23ea8070bbb31d219891cce3dc6f0c5353b4012c6d87ae9ba1e04e7a0edf0d40"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "42234167a797d93a82d7b36882530b982a94d0f41c3f811c82e03ba3ec5ac083"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "f73c527566e4c27d12a10c2bb7bba29e7f9e05f7c0845d1c5319960a642c1502"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "738a506706465576947164be40b82b8c57878cbb40236173f0174a04584fd295"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b6b791f25ad357addf94c75692005144ec84ee65ffa67bdff7bf507c44858b51"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "9f3f3bab73966493171a39fb99dd800166f7108722e45f3d9227a2ea31df4dde"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "ae50e3643a2c8179846b6e40404d2d82917a655d3b03e06e4c940005b556e64c"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "5911cfc0b6ac4d12899fd14616f9167f1f9c7fece3292a3aaaa785bdc4921872"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "config.harden", "path": "ecc/chipcompiler/tools/ecc/configs/sta_ecc.json", "sha256": "aae3e507ada13b396172bd58fa865f441ba4a276f00e1ccfa96aee4bfa9a4c6a"}]} diff --git a/ecos/agent/knowledge/legalization/manifest.json b/ecos/agent/knowledge/legalization/manifest.json index 9fe39ec02..a49eae1ed 100644 --- a/ecos/agent/knowledge/legalization/manifest.json +++ b/ecos/agent/knowledge/legalization/manifest.json @@ -1 +1 @@ -{"entity_count": 114, "files": {"catalog.json": "28a106d0d90f016cd7deb750476bc0d108456d8c326c6140f6e19edfa6b655ad", "knowledge/algorithms.md": "4588b48958c604fac959663f3c2e95f392f43ebf9833f251ec1ef7b3621c4343", "knowledge/artifacts.md": "ce6b2d5bdd002df385d0b0eb31a27c792e50c540e221988a2560915bf87ca13f", "knowledge/failures.md": "46ce1b286c3d5613d57ddb607cefd378b705d1b650f07b3246ee32a480a290de", "knowledge/metrics.md": "7cf172acb79050ded3ef8e5be40e2ccd305c4e866e85128436ac06d673f75dba", "knowledge/parameters.md": "675a88116d72b32da2c322f3d21c1fd973168f1afa1770653ca065c4f0e754bb", "regression/legalization_questions.jsonl": "53acd93f21ce87db90c780f85667b4365da50061a925e86e27bf3af6c322f61f", "sources.json": "9be412b175e5190c61ae7f3933f4c9b92f967f00675f778d9a78edd8516753c7"}, "schema_version": "ecos-step-manifest.v1"} +{"entity_count": 114, "files": {"catalog.json": "28a106d0d90f016cd7deb750476bc0d108456d8c326c6140f6e19edfa6b655ad", "knowledge/algorithms.md": "4588b48958c604fac959663f3c2e95f392f43ebf9833f251ec1ef7b3621c4343", "knowledge/artifacts.md": "ce6b2d5bdd002df385d0b0eb31a27c792e50c540e221988a2560915bf87ca13f", "knowledge/failures.md": "46ce1b286c3d5613d57ddb607cefd378b705d1b650f07b3246ee32a480a290de", "knowledge/metrics.md": "7cf172acb79050ded3ef8e5be40e2ccd305c4e866e85128436ac06d673f75dba", "knowledge/parameters.md": "675a88116d72b32da2c322f3d21c1fd973168f1afa1770653ca065c4f0e754bb", "regression/legalization_questions.jsonl": "53acd93f21ce87db90c780f85667b4365da50061a925e86e27bf3af6c322f61f", "sources.json": "30dffe2d58abf003bdde8e9bd659b347b9d74fc6915c61f8808a36c9f71df7db"}, "schema_version": "ecos-step-manifest.v1"} diff --git a/ecos/agent/knowledge/legalization/sources.json b/ecos/agent/knowledge/legalization/sources.json index 1ab2a131a..2f6f901df 100644 --- a/ecos/agent/knowledge/legalization/sources.json +++ b/ecos/agent/knowledge/legalization/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "1cf535bbed6a469037b399c059ecad5da2bdb82a72e7cc50f544db30d1e8c5b1"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "150f01725db5f4ccdf344cc191f321cc0cc682fe16b59789e50e12461ea6fb3a"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "dbd642254b2328453e3b5522a76fc6eca9fefe566f8029ceb4344c460d37b39b"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "86e1c74ae90de5a3b8ff180f67cb5b8bfac958af8d9dcfd549051452ee048a80"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "83ef3fc332ad86b041252f978651f4efde388724af93cc85193e7b9682a21cf1"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "3797f85c33322ab8f24f09db51a0d389619db810f5026e466f67f3672f7f005d"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "2b7c68a14266af92a60d93255c1057a5d1dbc742a6fc3e6ab8ed4513447903ec"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "0f3e6141f489984e1697587aa0653c434a504d5a593802a25d0cdd2ccd627029"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "cc3b2c6c97e95eed2a73e03ee94066602a064ef2ed37e4051cb9cb715f2a0c8d"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "181288f1d8208bb4e7f6c80589b5c3761a97149e6df65adb6c9f11449f35ea14"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "4c03f4ffcd539605be67350beb69723012df92a191e5bec031b30388dd959eb1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "87644517fee6b339a616aa01b96371e1a62c6a243a9b7f84e390ea1e0567679a"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "6d27833dd6d5937b9c644c032847a133086878f8345a3ba90b5b3a4f5b715664"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "1d52df523dbd0a2552a60ebfedfa8e9a71ec108ee88dec233a7cdbdd5cca6eac"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "e1a2a9c0adf18ba364c2225e9d4449893b52a29dbb0846668f38591f7aeab42b"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "de11c677f316104a44dcf3975f72cde137e21e42226b6e6e60a0a5820eac8581"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "d3fb50148cc715ca5305d708fc5d5c1805011364a06d8c9db83e1324b0fb2c61"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "baac52ceffbba8bc474444eb71a9c3c160426dcfd12848006eb94c1d2766711d"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "d751c3f7cdeae0e83799100260091b6cca5d96b25cb7eb6db8e1c5590eda2441"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "25313ed94a0707150193cd5c8e6d13be0e84633ecaf9cc4190d036952938ceb0"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "35d2875c036c31f3cd9a7453d6f9d722ae32f7f12ca9c20f782a5c7f206978b7"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "01e84d2dc2875691feba6dd80b197220e69bfe966e112033fcafe13a2478c033"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b9ae78e0548723dfda6b5439237c20e0076aea5b1dd4db9c8e2e11bc1a5da1a6"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "a1328b48e5008bf3dce236b1c3bbf8fd5d634d9efe556d703c6c95d3ccc0b7b5"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "f53b8bc10cf372a997ab6ff0df7fa530e9be477fd9dfc059f48d446219028aba"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "e79bcd01ae3b2ac16dc6cf24f6f13be141459cb6bc712dfbbdd9180517f549ce"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", "sha256": "1f37f4a5a509629cf1e57fdc42593b489dd9c7426220d5db351b888226acb0c4"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", "sha256": "fd967ff04a5b0af5f5acd0f86c987c92f6e32f440d931a2307ff3e504226a60a"}, {"id": "config.legalization", "path": "ecc/chipcompiler/tools/ecc_dreamplace/configs/dreamplace_ecc.json", "sha256": "78831a0636e45d599d4aff44b24e2668804b4bea0270f0e7adcf770a557985db"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "733db918833ae7b1bd9dcdaebfb61a33d653089a7e93d181a44d9c5dccc070f3"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "349cff3e7756528dd5e86fda4d31f9f99e000194a989df153dc9b619cce658a9"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "7523ecc836515b6d0ceb4b4ef4b9762d198337bcde035756eb0c5b07763bc65e"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "425d97334c7ae27e1a2b889caf70fe2dbed2256bd9eb94b84aff5d7ba19d146b"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "ba0f45dd9c5f8a81e22604096e96f3a40495aae48e1914c40f59d6fe8fbaed29"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "857db8dee1a44e59c9fa0a6529cb3fa1572bcc3a8912471369b5be6d1aa8a312"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "1eb73e9eef0661ce18ba64ea6430676711d11390e51e0401fb8687cbdce183df"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "46e2a8af132a2fbdab82094b916e61266633c1d557f22a3f95bbec979174ed29"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "2b6cc5aeadb2ed1c574f31facd467c899cf3173c0d6ae3d80a227a3c35bd9661"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "bcfb7d43ae4f4837221b7a902078a56f0120cdc51c1ac70ca9222e5d434fe4fb"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "a59edef29fd9915b076adb28d37b9c4dfd1bcdb30be26970b28590cab7056db1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "27901646e566f6daa023aec9aa3bb0304e71a92725398c82416125e76ed3633c"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "2df0b006666a4503c0fab8a5b0a03ef2a1720788fd47a8e4a80e331cb93cd822"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "7ae73bb71dffa1a0b934e396afcdfa3a5304c853d1d5be465e5362198e2a5641"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "f2289589d023fd03a94b8bbdb7bfc95812057afb1c360bd8b8c3ac80de0fa203"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "fe9bb78f5a956cc7ab24a3da2c0212ce38a912dccdf7e568d918adb6d89c1a8f"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "31c97d537f0085ecc51f7c52931eaa4bebf735f6fba795b8ecada2e0a6eb3374"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "e561307cd0159be4f3731c38ccf452665b540928cad5d966295b51737fc841d4"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "23ea8070bbb31d219891cce3dc6f0c5353b4012c6d87ae9ba1e04e7a0edf0d40"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "42234167a797d93a82d7b36882530b982a94d0f41c3f811c82e03ba3ec5ac083"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "f73c527566e4c27d12a10c2bb7bba29e7f9e05f7c0845d1c5319960a642c1502"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "738a506706465576947164be40b82b8c57878cbb40236173f0174a04584fd295"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b6b791f25ad357addf94c75692005144ec84ee65ffa67bdff7bf507c44858b51"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "9f3f3bab73966493171a39fb99dd800166f7108722e45f3d9227a2ea31df4dde"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "ae50e3643a2c8179846b6e40404d2d82917a655d3b03e06e4c940005b556e64c"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "5911cfc0b6ac4d12899fd14616f9167f1f9c7fece3292a3aaaa785bdc4921872"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "config.legalization", "path": "ecc/chipcompiler/tools/ecc_dreamplace/configs/dreamplace_ecc.json", "sha256": "78831a0636e45d599d4aff44b24e2668804b4bea0270f0e7adcf770a557985db"}]} diff --git a/ecos/agent/knowledge/place/manifest.json b/ecos/agent/knowledge/place/manifest.json index a968d2457..694a72316 100644 --- a/ecos/agent/knowledge/place/manifest.json +++ b/ecos/agent/knowledge/place/manifest.json @@ -1 +1 @@ -{"entity_count": 145, "files": {"catalog.json": "3f2388493c34b11ca36b3d71c2df3e0df443b19b5a57931ea7bfd3ba36c1c153", "knowledge/algorithms.md": "adf6f264046640a99aad7fb4b701d8020007205cbfa6ae4ed57719ec6a420ccd", "knowledge/artifacts.md": "3c6e99e643db183bd88abd0e61257372a16c79d4924b27bdc7b6f397c3ad14fd", "knowledge/failures.md": "fd6e36cfb401df5e6e66f30b9d5f3073720d6840c3d2df2d4398efc1ec04872a", "knowledge/metrics.md": "e1c0d9ec830fdbf65ce94e5a7c72e292b1a637745209b6b8f84b671f6a7b6ac6", "knowledge/parameters.md": "7b4e833cd395ce081db37ce7d5539da0729ecc091da0b2498a9758d920705398", "regression/place_questions.jsonl": "97fd4a94f49eb052277a6b78ab26f338df25324cfbbf0795983003e6590a1ba2", "sources.json": "d650f5385883598686b66a4f6105c99e3a4fb139755719d120dfdafa2749738b"}, "schema_version": "ecos-place-manifest.v1"} +{"entity_count": 145, "files": {"catalog.json": "3f2388493c34b11ca36b3d71c2df3e0df443b19b5a57931ea7bfd3ba36c1c153", "knowledge/algorithms.md": "adf6f264046640a99aad7fb4b701d8020007205cbfa6ae4ed57719ec6a420ccd", "knowledge/artifacts.md": "3c6e99e643db183bd88abd0e61257372a16c79d4924b27bdc7b6f397c3ad14fd", "knowledge/failures.md": "fd6e36cfb401df5e6e66f30b9d5f3073720d6840c3d2df2d4398efc1ec04872a", "knowledge/metrics.md": "e1c0d9ec830fdbf65ce94e5a7c72e292b1a637745209b6b8f84b671f6a7b6ac6", "knowledge/parameters.md": "7b4e833cd395ce081db37ce7d5539da0729ecc091da0b2498a9758d920705398", "regression/place_questions.jsonl": "97fd4a94f49eb052277a6b78ab26f338df25324cfbbf0795983003e6590a1ba2", "sources.json": "02002bd16a09bdbbe51cca512b32e9565454d668dfdaf5f2a6392b0767204563"}, "schema_version": "ecos-place-manifest.v1"} diff --git a/ecos/agent/knowledge/place/sources.json b/ecos/agent/knowledge/place/sources.json index 098d69657..a9d8e1b23 100644 --- a/ecos/agent/knowledge/place/sources.json +++ b/ecos/agent/knowledge/place/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-place-sources.v1", "sources": [{"id": "dreamplace.config", "path": "ecc/chipcompiler/tools/ecc_dreamplace/configs/dreamplace_ecc.json", "sha256": "78831a0636e45d599d4aff44b24e2668804b4bea0270f0e7adcf770a557985db"}, {"id": "dreamplace.overrides", "path": "ecc/chipcompiler/tools/ecc_dreamplace/parameter_overrides.py", "sha256": "f0d9dd20b609556cf305f1366df7669bed2ef6334f95fcd12d19c2e76aae5eb0"}, {"id": "ecos.params", "path": "ecc/chipcompiler/cli/project/params.py", "sha256": "5acffa84a8eb67fbabcd92110ca4faafc203f038b6a77b3ac5215277136922c1"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.objective", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/PlaceObj.py", "sha256": "54f8f963db5d4afd32e6a8cd0d58ae62949083333e776c316af420e0d1185882"}, {"id": "ecc.congestion", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/evaluation/src/module/congestion/congestion_eval.cpp", "sha256": "8c7bdb75457d8b05d7523ebee7e70aac9d22ffa890dc1da0bbae71c2bb52b077"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.plot", "path": "ecc/chipcompiler/tools/ecc/plot.py", "sha256": "06d11fe40dfdff5e84b2c9d00910c4b6d29fd0ed349c0ea257f4c1f1e8726522"}, {"id": "ecc.feature_manager", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/feature_manager.cpp", "sha256": "1e0837051c616129497c9018f4f1989bc544bfaebead9cbdb22b45f97fab9b4c"}, {"id": "ecc.feature_summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser.cpp", "sha256": "8b9a2e9818c6a91c4a3aad66bfc777be1041a524435d67ce4e4c0a0ec0dc8c00"}, {"id": "ecc.geometry", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/geometry_builder/GeometrySnapshotWriter.cpp", "sha256": "5616b7a8bc7bc301b42d0fe593776da0b5f4b977e1ae5790a7993c5bc6d648c0"}, {"id": "ecc.service", "path": "ecc/chipcompiler/tools/ecc/service.py", "sha256": "6045c1eb6aa7a3e5e9f1cf8500149c978653d7d2d24b98f7af0848f0dbad917a"}, {"id": "ecc.feature_union", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_eval_union.cpp", "sha256": "cd35e65d496fa924778097024e05b85f7a4065f11fcb651c2bd7ea11451885b0"}, {"id": "ecc.feature_parser", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_eval.cpp", "sha256": "b4a2657f1d105e6ea738947d13b8041d8349a92e162354205e884028b9c14b0e"}, {"id": "ecc.density", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/evaluation/src/module/density/density_eval.cpp", "sha256": "19c220bd573ea2b36316f34d4734967f1423ff94ca6bb23ea44a90d02f9d489c"}, {"id": "ecc.wirelength", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/evaluation/src/module/wirelength/wirelength_eval.cpp", "sha256": "354838aad1ad8ab4327668f3dacd7302c16106f6476043de7ed8455971b60fab"}, {"id": "gui.place_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "gui.map_gallery", "path": "ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.ts", "sha256": "001a1ffd8c3836f7d1aa2c0d6b8fc670dc7820219d285a75043ffb433407508d"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "dreamplace.utility", "path": "ecc/chipcompiler/tools/ecc_dreamplace/utility.py", "sha256": "801d47727028e0ebdc48ea0d2b0da64b4e95d940dbf62da2f9bab86a4fb387d0"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-place-sources.v1", "sources": [{"id": "dreamplace.config", "path": "ecc/chipcompiler/tools/ecc_dreamplace/configs/dreamplace_ecc.json", "sha256": "78831a0636e45d599d4aff44b24e2668804b4bea0270f0e7adcf770a557985db"}, {"id": "dreamplace.overrides", "path": "ecc/chipcompiler/tools/ecc_dreamplace/parameter_overrides.py", "sha256": "f0d9dd20b609556cf305f1366df7669bed2ef6334f95fcd12d19c2e76aae5eb0"}, {"id": "ecos.params", "path": "ecc/chipcompiler/cli/project/params.py", "sha256": "68202fbfc2cd7d709ec9b7d8f5f21c23b408eca65609ff3f65dbd7825e9ec878"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.objective", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/PlaceObj.py", "sha256": "54f8f963db5d4afd32e6a8cd0d58ae62949083333e776c316af420e0d1185882"}, {"id": "ecc.congestion", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/evaluation/src/module/congestion/congestion_eval.cpp", "sha256": "8c7bdb75457d8b05d7523ebee7e70aac9d22ffa890dc1da0bbae71c2bb52b077"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.plot", "path": "ecc/chipcompiler/tools/ecc/plot.py", "sha256": "06d11fe40dfdff5e84b2c9d00910c4b6d29fd0ed349c0ea257f4c1f1e8726522"}, {"id": "ecc.feature_manager", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/feature_manager.cpp", "sha256": "1e0837051c616129497c9018f4f1989bc544bfaebead9cbdb22b45f97fab9b4c"}, {"id": "ecc.feature_summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser.cpp", "sha256": "8b9a2e9818c6a91c4a3aad66bfc777be1041a524435d67ce4e4c0a0ec0dc8c00"}, {"id": "ecc.geometry", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/geometry_builder/GeometrySnapshotWriter.cpp", "sha256": "5616b7a8bc7bc301b42d0fe593776da0b5f4b977e1ae5790a7993c5bc6d648c0"}, {"id": "ecc.service", "path": "ecc/chipcompiler/tools/ecc/service.py", "sha256": "6045c1eb6aa7a3e5e9f1cf8500149c978653d7d2d24b98f7af0848f0dbad917a"}, {"id": "ecc.feature_union", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_eval_union.cpp", "sha256": "cd35e65d496fa924778097024e05b85f7a4065f11fcb651c2bd7ea11451885b0"}, {"id": "ecc.feature_parser", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_eval.cpp", "sha256": "b4a2657f1d105e6ea738947d13b8041d8349a92e162354205e884028b9c14b0e"}, {"id": "ecc.density", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/evaluation/src/module/density/density_eval.cpp", "sha256": "19c220bd573ea2b36316f34d4734967f1423ff94ca6bb23ea44a90d02f9d489c"}, {"id": "ecc.wirelength", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/evaluation/src/module/wirelength/wirelength_eval.cpp", "sha256": "354838aad1ad8ab4327668f3dacd7302c16106f6476043de7ed8455971b60fab"}, {"id": "gui.place_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "gui.map_gallery", "path": "ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.ts", "sha256": "c8edba5be3ea318ba6a90c7184ef08b811d29a6f91dd35386125080706d693e6"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "dreamplace.utility", "path": "ecc/chipcompiler/tools/ecc_dreamplace/utility.py", "sha256": "801d47727028e0ebdc48ea0d2b0da64b4e95d940dbf62da2f9bab86a4fb387d0"}]} diff --git a/ecos/agent/knowledge/rcx/manifest.json b/ecos/agent/knowledge/rcx/manifest.json index 8eada4dc6..e93d6e816 100644 --- a/ecos/agent/knowledge/rcx/manifest.json +++ b/ecos/agent/knowledge/rcx/manifest.json @@ -1 +1 @@ -{"entity_count": 29, "files": {"catalog.json": "dade83dc7c5b219b7229bb76823e4a7c5643ef9e737827c41f34faa44393ca80", "knowledge/algorithms.md": "bd534a94848a16509cf4171f47e90a84de472c9691ef7a67c33b03617a49d2dd", "knowledge/artifacts.md": "a9958ca591e1a0d5151a500b09c6181d3470016a30cc1ddf0a521e5c1a300088", "knowledge/failures.md": "8632a6943f87b31cc423bd37a132e31bdaa0d3309362c34ec05bf73f38de5dac", "knowledge/metrics.md": "eb562915b6f095d582bb41822360c9ded32fc70f15937de894cc61c4dc2bff48", "knowledge/parameters.md": "a3904a7628505fec4e2e34b182b9bfbc143bcfb03ce0a56e34455b2bd40f6298", "regression/rcx_questions.jsonl": "f92635c70f07ba27a3231eb1e2e1cd6458b417bc3ddb33e2d349217c34c9bafc", "sources.json": "17737c3b0fcadf0f338e206113efcbce0b22e559d7912f1da6c85cc1f1713fa4"}, "schema_version": "ecos-step-manifest.v1"} +{"entity_count": 29, "files": {"catalog.json": "dade83dc7c5b219b7229bb76823e4a7c5643ef9e737827c41f34faa44393ca80", "knowledge/algorithms.md": "bd534a94848a16509cf4171f47e90a84de472c9691ef7a67c33b03617a49d2dd", "knowledge/artifacts.md": "a9958ca591e1a0d5151a500b09c6181d3470016a30cc1ddf0a521e5c1a300088", "knowledge/failures.md": "8632a6943f87b31cc423bd37a132e31bdaa0d3309362c34ec05bf73f38de5dac", "knowledge/metrics.md": "eb562915b6f095d582bb41822360c9ded32fc70f15937de894cc61c4dc2bff48", "knowledge/parameters.md": "a3904a7628505fec4e2e34b182b9bfbc143bcfb03ce0a56e34455b2bd40f6298", "regression/rcx_questions.jsonl": "f92635c70f07ba27a3231eb1e2e1cd6458b417bc3ddb33e2d349217c34c9bafc", "sources.json": "cc7d6a93bf9c589d2c8d396a482ab57ec039c429576c9860901f09066b398873"}, "schema_version": "ecos-step-manifest.v1"} diff --git a/ecos/agent/knowledge/rcx/sources.json b/ecos/agent/knowledge/rcx/sources.json index 53837a644..022ffbec7 100644 --- a/ecos/agent/knowledge/rcx/sources.json +++ b/ecos/agent/knowledge/rcx/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "1cf535bbed6a469037b399c059ecad5da2bdb82a72e7cc50f544db30d1e8c5b1"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "150f01725db5f4ccdf344cc191f321cc0cc682fe16b59789e50e12461ea6fb3a"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "dbd642254b2328453e3b5522a76fc6eca9fefe566f8029ceb4344c460d37b39b"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "86e1c74ae90de5a3b8ff180f67cb5b8bfac958af8d9dcfd549051452ee048a80"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "83ef3fc332ad86b041252f978651f4efde388724af93cc85193e7b9682a21cf1"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "3797f85c33322ab8f24f09db51a0d389619db810f5026e466f67f3672f7f005d"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "2b7c68a14266af92a60d93255c1057a5d1dbc742a6fc3e6ab8ed4513447903ec"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "0f3e6141f489984e1697587aa0653c434a504d5a593802a25d0cdd2ccd627029"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "cc3b2c6c97e95eed2a73e03ee94066602a064ef2ed37e4051cb9cb715f2a0c8d"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "181288f1d8208bb4e7f6c80589b5c3761a97149e6df65adb6c9f11449f35ea14"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "4c03f4ffcd539605be67350beb69723012df92a191e5bec031b30388dd959eb1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "87644517fee6b339a616aa01b96371e1a62c6a243a9b7f84e390ea1e0567679a"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "6d27833dd6d5937b9c644c032847a133086878f8345a3ba90b5b3a4f5b715664"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "1d52df523dbd0a2552a60ebfedfa8e9a71ec108ee88dec233a7cdbdd5cca6eac"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "e1a2a9c0adf18ba364c2225e9d4449893b52a29dbb0846668f38591f7aeab42b"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "de11c677f316104a44dcf3975f72cde137e21e42226b6e6e60a0a5820eac8581"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "d3fb50148cc715ca5305d708fc5d5c1805011364a06d8c9db83e1324b0fb2c61"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "baac52ceffbba8bc474444eb71a9c3c160426dcfd12848006eb94c1d2766711d"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "d751c3f7cdeae0e83799100260091b6cca5d96b25cb7eb6db8e1c5590eda2441"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "25313ed94a0707150193cd5c8e6d13be0e84633ecaf9cc4190d036952938ceb0"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "35d2875c036c31f3cd9a7453d6f9d722ae32f7f12ca9c20f782a5c7f206978b7"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "01e84d2dc2875691feba6dd80b197220e69bfe966e112033fcafe13a2478c033"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b9ae78e0548723dfda6b5439237c20e0076aea5b1dd4db9c8e2e11bc1a5da1a6"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "a1328b48e5008bf3dce236b1c3bbf8fd5d634d9efe556d703c6c95d3ccc0b7b5"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "f53b8bc10cf372a997ab6ff0df7fa530e9be477fd9dfc059f48d446219028aba"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "e79bcd01ae3b2ac16dc6cf24f6f13be141459cb6bc712dfbbdd9180517f549ce"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", "sha256": "1f37f4a5a509629cf1e57fdc42593b489dd9c7426220d5db351b888226acb0c4"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", "sha256": "fd967ff04a5b0af5f5acd0f86c987c92f6e32f440d931a2307ff3e504226a60a"}, {"id": "config.rcx", "path": "ecc/chipcompiler/tools/ecc/configs/rcx_ecc.json", "sha256": "314e0a15bbdabb98a461c5ab483fdfa0498e06128dbf51038f92898bef45af39"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "733db918833ae7b1bd9dcdaebfb61a33d653089a7e93d181a44d9c5dccc070f3"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "349cff3e7756528dd5e86fda4d31f9f99e000194a989df153dc9b619cce658a9"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "7523ecc836515b6d0ceb4b4ef4b9762d198337bcde035756eb0c5b07763bc65e"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "425d97334c7ae27e1a2b889caf70fe2dbed2256bd9eb94b84aff5d7ba19d146b"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "ba0f45dd9c5f8a81e22604096e96f3a40495aae48e1914c40f59d6fe8fbaed29"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "857db8dee1a44e59c9fa0a6529cb3fa1572bcc3a8912471369b5be6d1aa8a312"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "1eb73e9eef0661ce18ba64ea6430676711d11390e51e0401fb8687cbdce183df"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "46e2a8af132a2fbdab82094b916e61266633c1d557f22a3f95bbec979174ed29"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "2b6cc5aeadb2ed1c574f31facd467c899cf3173c0d6ae3d80a227a3c35bd9661"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "bcfb7d43ae4f4837221b7a902078a56f0120cdc51c1ac70ca9222e5d434fe4fb"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "a59edef29fd9915b076adb28d37b9c4dfd1bcdb30be26970b28590cab7056db1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "27901646e566f6daa023aec9aa3bb0304e71a92725398c82416125e76ed3633c"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "2df0b006666a4503c0fab8a5b0a03ef2a1720788fd47a8e4a80e331cb93cd822"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "7ae73bb71dffa1a0b934e396afcdfa3a5304c853d1d5be465e5362198e2a5641"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "f2289589d023fd03a94b8bbdb7bfc95812057afb1c360bd8b8c3ac80de0fa203"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "fe9bb78f5a956cc7ab24a3da2c0212ce38a912dccdf7e568d918adb6d89c1a8f"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "31c97d537f0085ecc51f7c52931eaa4bebf735f6fba795b8ecada2e0a6eb3374"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "e561307cd0159be4f3731c38ccf452665b540928cad5d966295b51737fc841d4"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "23ea8070bbb31d219891cce3dc6f0c5353b4012c6d87ae9ba1e04e7a0edf0d40"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "42234167a797d93a82d7b36882530b982a94d0f41c3f811c82e03ba3ec5ac083"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "f73c527566e4c27d12a10c2bb7bba29e7f9e05f7c0845d1c5319960a642c1502"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "738a506706465576947164be40b82b8c57878cbb40236173f0174a04584fd295"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b6b791f25ad357addf94c75692005144ec84ee65ffa67bdff7bf507c44858b51"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "9f3f3bab73966493171a39fb99dd800166f7108722e45f3d9227a2ea31df4dde"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "ae50e3643a2c8179846b6e40404d2d82917a655d3b03e06e4c940005b556e64c"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "5911cfc0b6ac4d12899fd14616f9167f1f9c7fece3292a3aaaa785bdc4921872"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "config.rcx", "path": "ecc/chipcompiler/tools/ecc/configs/rcx_ecc.json", "sha256": "314e0a15bbdabb98a461c5ab483fdfa0498e06128dbf51038f92898bef45af39"}]} diff --git a/ecos/agent/knowledge/route/manifest.json b/ecos/agent/knowledge/route/manifest.json index 7e88553dd..e46bee850 100644 --- a/ecos/agent/knowledge/route/manifest.json +++ b/ecos/agent/knowledge/route/manifest.json @@ -1 +1 @@ -{"entity_count": 36, "files": {"catalog.json": "d8072bfaa52774e1f8a6a901a283a5ad3110e95aab88c0a5f09e6da3c2345584", "knowledge/algorithms.md": "27cc26c60bf801b119f616c2db52b1b89adf979b0886f8b3f3ce45981768ce6d", "knowledge/artifacts.md": "d99a9dfa8870f8322bb0f12d125231ca095c71a47084d5f0e0fd4ae3c2f9a7a0", "knowledge/failures.md": "b08b7475bf3b96c66dd7037178079a989dc95167b3f543e674f15486ad5c5661", "knowledge/metrics.md": "640181bb6f9a4b04bbef2561814a57d38c37d7774b7120f2079563655bbf235f", "knowledge/parameters.md": "96fb6f5f26169e843a9d0f4273340a188bb3c6ee4854b8ca1240a23701358cab", "regression/route_questions.jsonl": "8ddadcc1cd1927a1d8a6fc984dc850896fa63638fb31c7efe6a8004139dcd0f8", "sources.json": "f9316bd1d8b256d75c2ededd382cf0aa0c306ee6539c85ec7b7a00a698ecaef7"}, "schema_version": "ecos-step-manifest.v1"} +{"entity_count": 36, "files": {"catalog.json": "d8072bfaa52774e1f8a6a901a283a5ad3110e95aab88c0a5f09e6da3c2345584", "knowledge/algorithms.md": "27cc26c60bf801b119f616c2db52b1b89adf979b0886f8b3f3ce45981768ce6d", "knowledge/artifacts.md": "d99a9dfa8870f8322bb0f12d125231ca095c71a47084d5f0e0fd4ae3c2f9a7a0", "knowledge/failures.md": "b08b7475bf3b96c66dd7037178079a989dc95167b3f543e674f15486ad5c5661", "knowledge/metrics.md": "640181bb6f9a4b04bbef2561814a57d38c37d7774b7120f2079563655bbf235f", "knowledge/parameters.md": "96fb6f5f26169e843a9d0f4273340a188bb3c6ee4854b8ca1240a23701358cab", "regression/route_questions.jsonl": "8ddadcc1cd1927a1d8a6fc984dc850896fa63638fb31c7efe6a8004139dcd0f8", "sources.json": "5bd9ea3ca70e8b85961bfbba4dc33dca23806963df6d5b0d089ad68587662345"}, "schema_version": "ecos-step-manifest.v1"} diff --git a/ecos/agent/knowledge/route/sources.json b/ecos/agent/knowledge/route/sources.json index c4163eb80..1554a2045 100644 --- a/ecos/agent/knowledge/route/sources.json +++ b/ecos/agent/knowledge/route/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "1cf535bbed6a469037b399c059ecad5da2bdb82a72e7cc50f544db30d1e8c5b1"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "150f01725db5f4ccdf344cc191f321cc0cc682fe16b59789e50e12461ea6fb3a"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "dbd642254b2328453e3b5522a76fc6eca9fefe566f8029ceb4344c460d37b39b"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "86e1c74ae90de5a3b8ff180f67cb5b8bfac958af8d9dcfd549051452ee048a80"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "83ef3fc332ad86b041252f978651f4efde388724af93cc85193e7b9682a21cf1"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "3797f85c33322ab8f24f09db51a0d389619db810f5026e466f67f3672f7f005d"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "2b7c68a14266af92a60d93255c1057a5d1dbc742a6fc3e6ab8ed4513447903ec"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "0f3e6141f489984e1697587aa0653c434a504d5a593802a25d0cdd2ccd627029"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "cc3b2c6c97e95eed2a73e03ee94066602a064ef2ed37e4051cb9cb715f2a0c8d"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "181288f1d8208bb4e7f6c80589b5c3761a97149e6df65adb6c9f11449f35ea14"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "4c03f4ffcd539605be67350beb69723012df92a191e5bec031b30388dd959eb1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "87644517fee6b339a616aa01b96371e1a62c6a243a9b7f84e390ea1e0567679a"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "6d27833dd6d5937b9c644c032847a133086878f8345a3ba90b5b3a4f5b715664"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "1d52df523dbd0a2552a60ebfedfa8e9a71ec108ee88dec233a7cdbdd5cca6eac"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "e1a2a9c0adf18ba364c2225e9d4449893b52a29dbb0846668f38591f7aeab42b"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "de11c677f316104a44dcf3975f72cde137e21e42226b6e6e60a0a5820eac8581"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "d3fb50148cc715ca5305d708fc5d5c1805011364a06d8c9db83e1324b0fb2c61"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "baac52ceffbba8bc474444eb71a9c3c160426dcfd12848006eb94c1d2766711d"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "d751c3f7cdeae0e83799100260091b6cca5d96b25cb7eb6db8e1c5590eda2441"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "25313ed94a0707150193cd5c8e6d13be0e84633ecaf9cc4190d036952938ceb0"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "35d2875c036c31f3cd9a7453d6f9d722ae32f7f12ca9c20f782a5c7f206978b7"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "01e84d2dc2875691feba6dd80b197220e69bfe966e112033fcafe13a2478c033"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b9ae78e0548723dfda6b5439237c20e0076aea5b1dd4db9c8e2e11bc1a5da1a6"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "a1328b48e5008bf3dce236b1c3bbf8fd5d634d9efe556d703c6c95d3ccc0b7b5"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "f53b8bc10cf372a997ab6ff0df7fa530e9be477fd9dfc059f48d446219028aba"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "e79bcd01ae3b2ac16dc6cf24f6f13be141459cb6bc712dfbbdd9180517f549ce"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", "sha256": "1f37f4a5a509629cf1e57fdc42593b489dd9c7426220d5db351b888226acb0c4"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", "sha256": "fd967ff04a5b0af5f5acd0f86c987c92f6e32f440d931a2307ff3e504226a60a"}, {"id": "config.route", "path": "ecc/chipcompiler/tools/ecc/configs/route_ecc.json", "sha256": "ade1311e61bc174a3a91348d7477af923b71fb52aea438c29f0617303ab69665"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "733db918833ae7b1bd9dcdaebfb61a33d653089a7e93d181a44d9c5dccc070f3"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "349cff3e7756528dd5e86fda4d31f9f99e000194a989df153dc9b619cce658a9"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "7523ecc836515b6d0ceb4b4ef4b9762d198337bcde035756eb0c5b07763bc65e"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "425d97334c7ae27e1a2b889caf70fe2dbed2256bd9eb94b84aff5d7ba19d146b"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "ba0f45dd9c5f8a81e22604096e96f3a40495aae48e1914c40f59d6fe8fbaed29"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "857db8dee1a44e59c9fa0a6529cb3fa1572bcc3a8912471369b5be6d1aa8a312"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "1eb73e9eef0661ce18ba64ea6430676711d11390e51e0401fb8687cbdce183df"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "46e2a8af132a2fbdab82094b916e61266633c1d557f22a3f95bbec979174ed29"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "2b6cc5aeadb2ed1c574f31facd467c899cf3173c0d6ae3d80a227a3c35bd9661"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "bcfb7d43ae4f4837221b7a902078a56f0120cdc51c1ac70ca9222e5d434fe4fb"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "a59edef29fd9915b076adb28d37b9c4dfd1bcdb30be26970b28590cab7056db1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "27901646e566f6daa023aec9aa3bb0304e71a92725398c82416125e76ed3633c"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "2df0b006666a4503c0fab8a5b0a03ef2a1720788fd47a8e4a80e331cb93cd822"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "7ae73bb71dffa1a0b934e396afcdfa3a5304c853d1d5be465e5362198e2a5641"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "f2289589d023fd03a94b8bbdb7bfc95812057afb1c360bd8b8c3ac80de0fa203"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "fe9bb78f5a956cc7ab24a3da2c0212ce38a912dccdf7e568d918adb6d89c1a8f"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "31c97d537f0085ecc51f7c52931eaa4bebf735f6fba795b8ecada2e0a6eb3374"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "e561307cd0159be4f3731c38ccf452665b540928cad5d966295b51737fc841d4"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "23ea8070bbb31d219891cce3dc6f0c5353b4012c6d87ae9ba1e04e7a0edf0d40"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "42234167a797d93a82d7b36882530b982a94d0f41c3f811c82e03ba3ec5ac083"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "f73c527566e4c27d12a10c2bb7bba29e7f9e05f7c0845d1c5319960a642c1502"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "738a506706465576947164be40b82b8c57878cbb40236173f0174a04584fd295"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b6b791f25ad357addf94c75692005144ec84ee65ffa67bdff7bf507c44858b51"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "9f3f3bab73966493171a39fb99dd800166f7108722e45f3d9227a2ea31df4dde"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "ae50e3643a2c8179846b6e40404d2d82917a655d3b03e06e4c940005b556e64c"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "5911cfc0b6ac4d12899fd14616f9167f1f9c7fece3292a3aaaa785bdc4921872"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "config.route", "path": "ecc/chipcompiler/tools/ecc/configs/route_ecc.json", "sha256": "ade1311e61bc174a3a91348d7477af923b71fb52aea438c29f0617303ab69665"}]} diff --git a/ecos/agent/knowledge/sta/manifest.json b/ecos/agent/knowledge/sta/manifest.json index cdf001ca4..d555f0b0a 100644 --- a/ecos/agent/knowledge/sta/manifest.json +++ b/ecos/agent/knowledge/sta/manifest.json @@ -1 +1 @@ -{"entity_count": 30, "files": {"catalog.json": "56b12f20e3054f008b6cde6643d05f193bfd245b522c6e24f3deb0812c21f57d", "knowledge/algorithms.md": "3693b469fb1534025557110a661efbe9568fb3273465ec06b59e19fdee18a796", "knowledge/artifacts.md": "3f2a84d586519e24ed660e4401a9d0262795511b2034092cdef39200fefbe390", "knowledge/failures.md": "07d24cb0f8438a1b9ca1c37da19667449099ac1a90078b5a8102620215cc3fca", "knowledge/metrics.md": "5d584bc61370fd276779d2f3ac3f8ecf94ab512bce95796c8554cbc14abe6144", "knowledge/parameters.md": "a397414daed9edac39473d00a7f495dea53e8f3a697f6bfd505fa6c990d4d593", "regression/sta_questions.jsonl": "9fa952a23dbae0f301a35cd62b4639dbdf03d924c4f4065d5b557694caad04b9", "sources.json": "d9c0e37f6b38c0cea2d0affaba4acfaa4227354d299eff0eb37bba0165c9b979"}, "schema_version": "ecos-step-manifest.v1"} +{"entity_count": 30, "files": {"catalog.json": "56b12f20e3054f008b6cde6643d05f193bfd245b522c6e24f3deb0812c21f57d", "knowledge/algorithms.md": "3693b469fb1534025557110a661efbe9568fb3273465ec06b59e19fdee18a796", "knowledge/artifacts.md": "3f2a84d586519e24ed660e4401a9d0262795511b2034092cdef39200fefbe390", "knowledge/failures.md": "07d24cb0f8438a1b9ca1c37da19667449099ac1a90078b5a8102620215cc3fca", "knowledge/metrics.md": "5d584bc61370fd276779d2f3ac3f8ecf94ab512bce95796c8554cbc14abe6144", "knowledge/parameters.md": "a397414daed9edac39473d00a7f495dea53e8f3a697f6bfd505fa6c990d4d593", "regression/sta_questions.jsonl": "9fa952a23dbae0f301a35cd62b4639dbdf03d924c4f4065d5b557694caad04b9", "sources.json": "5c1fa60081cfa3ff63cc3305b87647441c7add638b71b1fa01dd3880f0860770"}, "schema_version": "ecos-step-manifest.v1"} diff --git a/ecos/agent/knowledge/sta/sources.json b/ecos/agent/knowledge/sta/sources.json index a95a78bca..9e370744a 100644 --- a/ecos/agent/knowledge/sta/sources.json +++ b/ecos/agent/knowledge/sta/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "1cf535bbed6a469037b399c059ecad5da2bdb82a72e7cc50f544db30d1e8c5b1"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "150f01725db5f4ccdf344cc191f321cc0cc682fe16b59789e50e12461ea6fb3a"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "dbd642254b2328453e3b5522a76fc6eca9fefe566f8029ceb4344c460d37b39b"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "86e1c74ae90de5a3b8ff180f67cb5b8bfac958af8d9dcfd549051452ee048a80"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "83ef3fc332ad86b041252f978651f4efde388724af93cc85193e7b9682a21cf1"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "3797f85c33322ab8f24f09db51a0d389619db810f5026e466f67f3672f7f005d"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "2b7c68a14266af92a60d93255c1057a5d1dbc742a6fc3e6ab8ed4513447903ec"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "0f3e6141f489984e1697587aa0653c434a504d5a593802a25d0cdd2ccd627029"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "cc3b2c6c97e95eed2a73e03ee94066602a064ef2ed37e4051cb9cb715f2a0c8d"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "181288f1d8208bb4e7f6c80589b5c3761a97149e6df65adb6c9f11449f35ea14"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "4c03f4ffcd539605be67350beb69723012df92a191e5bec031b30388dd959eb1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "87644517fee6b339a616aa01b96371e1a62c6a243a9b7f84e390ea1e0567679a"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "6d27833dd6d5937b9c644c032847a133086878f8345a3ba90b5b3a4f5b715664"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "1d52df523dbd0a2552a60ebfedfa8e9a71ec108ee88dec233a7cdbdd5cca6eac"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "e1a2a9c0adf18ba364c2225e9d4449893b52a29dbb0846668f38591f7aeab42b"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "de11c677f316104a44dcf3975f72cde137e21e42226b6e6e60a0a5820eac8581"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "d3fb50148cc715ca5305d708fc5d5c1805011364a06d8c9db83e1324b0fb2c61"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "baac52ceffbba8bc474444eb71a9c3c160426dcfd12848006eb94c1d2766711d"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "d751c3f7cdeae0e83799100260091b6cca5d96b25cb7eb6db8e1c5590eda2441"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "25313ed94a0707150193cd5c8e6d13be0e84633ecaf9cc4190d036952938ceb0"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "35d2875c036c31f3cd9a7453d6f9d722ae32f7f12ca9c20f782a5c7f206978b7"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "01e84d2dc2875691feba6dd80b197220e69bfe966e112033fcafe13a2478c033"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b9ae78e0548723dfda6b5439237c20e0076aea5b1dd4db9c8e2e11bc1a5da1a6"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "a1328b48e5008bf3dce236b1c3bbf8fd5d634d9efe556d703c6c95d3ccc0b7b5"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "f53b8bc10cf372a997ab6ff0df7fa530e9be477fd9dfc059f48d446219028aba"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "e79bcd01ae3b2ac16dc6cf24f6f13be141459cb6bc712dfbbdd9180517f549ce"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", "sha256": "1f37f4a5a509629cf1e57fdc42593b489dd9c7426220d5db351b888226acb0c4"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", "sha256": "fd967ff04a5b0af5f5acd0f86c987c92f6e32f440d931a2307ff3e504226a60a"}, {"id": "config.sta", "path": "ecc/chipcompiler/tools/ecc/configs/sta_ecc.json", "sha256": "aae3e507ada13b396172bd58fa865f441ba4a276f00e1ccfa96aee4bfa9a4c6a"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "733db918833ae7b1bd9dcdaebfb61a33d653089a7e93d181a44d9c5dccc070f3"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "349cff3e7756528dd5e86fda4d31f9f99e000194a989df153dc9b619cce658a9"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "7523ecc836515b6d0ceb4b4ef4b9762d198337bcde035756eb0c5b07763bc65e"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "425d97334c7ae27e1a2b889caf70fe2dbed2256bd9eb94b84aff5d7ba19d146b"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "ba0f45dd9c5f8a81e22604096e96f3a40495aae48e1914c40f59d6fe8fbaed29"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "857db8dee1a44e59c9fa0a6529cb3fa1572bcc3a8912471369b5be6d1aa8a312"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "1eb73e9eef0661ce18ba64ea6430676711d11390e51e0401fb8687cbdce183df"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "46e2a8af132a2fbdab82094b916e61266633c1d557f22a3f95bbec979174ed29"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "2b6cc5aeadb2ed1c574f31facd467c899cf3173c0d6ae3d80a227a3c35bd9661"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "bcfb7d43ae4f4837221b7a902078a56f0120cdc51c1ac70ca9222e5d434fe4fb"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "a59edef29fd9915b076adb28d37b9c4dfd1bcdb30be26970b28590cab7056db1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "27901646e566f6daa023aec9aa3bb0304e71a92725398c82416125e76ed3633c"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "2df0b006666a4503c0fab8a5b0a03ef2a1720788fd47a8e4a80e331cb93cd822"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "7ae73bb71dffa1a0b934e396afcdfa3a5304c853d1d5be465e5362198e2a5641"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "f2289589d023fd03a94b8bbdb7bfc95812057afb1c360bd8b8c3ac80de0fa203"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "fe9bb78f5a956cc7ab24a3da2c0212ce38a912dccdf7e568d918adb6d89c1a8f"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "31c97d537f0085ecc51f7c52931eaa4bebf735f6fba795b8ecada2e0a6eb3374"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "e561307cd0159be4f3731c38ccf452665b540928cad5d966295b51737fc841d4"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "23ea8070bbb31d219891cce3dc6f0c5353b4012c6d87ae9ba1e04e7a0edf0d40"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "42234167a797d93a82d7b36882530b982a94d0f41c3f811c82e03ba3ec5ac083"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "f73c527566e4c27d12a10c2bb7bba29e7f9e05f7c0845d1c5319960a642c1502"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "738a506706465576947164be40b82b8c57878cbb40236173f0174a04584fd295"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b6b791f25ad357addf94c75692005144ec84ee65ffa67bdff7bf507c44858b51"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "9f3f3bab73966493171a39fb99dd800166f7108722e45f3d9227a2ea31df4dde"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "ae50e3643a2c8179846b6e40404d2d82917a655d3b03e06e4c940005b556e64c"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "5911cfc0b6ac4d12899fd14616f9167f1f9c7fece3292a3aaaa785bdc4921872"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "config.sta", "path": "ecc/chipcompiler/tools/ecc/configs/sta_ecc.json", "sha256": "aae3e507ada13b396172bd58fa865f441ba4a276f00e1ccfa96aee4bfa9a4c6a"}]} diff --git a/ecos/agent/knowledge/synthesis/manifest.json b/ecos/agent/knowledge/synthesis/manifest.json index 12ac544fb..49826c015 100644 --- a/ecos/agent/knowledge/synthesis/manifest.json +++ b/ecos/agent/knowledge/synthesis/manifest.json @@ -1 +1 @@ -{"entity_count": 20, "files": {"catalog.json": "272ff22aeb275c58d5cb49055e8e1ce4323a303739472c360fcafd204664f64b", "knowledge/algorithms.md": "13589257a315a4227095228388c92675d102e65687d0ea94983f31e39273d8e1", "knowledge/artifacts.md": "8e08525bf0643ee2fc5e02d2fc1afed2ea1ffd5c33fb82cb730967c9f8d5ba47", "knowledge/failures.md": "702dff923b18bfb4c3918a19570bbaebf24ca4efedda855c7b4046996f7ef1bf", "knowledge/metrics.md": "7c8d87ce54d68ea2b0e456a1db19441dd217b62b8e05e0ffb84e7511bf67421a", "knowledge/parameters.md": "3afa50c051d9a5d4fa4c14934390ebd9664e60b74921f16295834ead19242b70", "regression/synthesis_questions.jsonl": "7a2933bbda1507aba85374e51618e3fdb5540823cf706b1b1c7d8f863b08dda8", "sources.json": "0a47f693e5847f5ef5ff37c21a3ef9842810b5a0aa2debe881229e7f934e2309"}, "schema_version": "ecos-step-manifest.v1"} +{"entity_count": 20, "files": {"catalog.json": "272ff22aeb275c58d5cb49055e8e1ce4323a303739472c360fcafd204664f64b", "knowledge/algorithms.md": "13589257a315a4227095228388c92675d102e65687d0ea94983f31e39273d8e1", "knowledge/artifacts.md": "8e08525bf0643ee2fc5e02d2fc1afed2ea1ffd5c33fb82cb730967c9f8d5ba47", "knowledge/failures.md": "702dff923b18bfb4c3918a19570bbaebf24ca4efedda855c7b4046996f7ef1bf", "knowledge/metrics.md": "7c8d87ce54d68ea2b0e456a1db19441dd217b62b8e05e0ffb84e7511bf67421a", "knowledge/parameters.md": "3afa50c051d9a5d4fa4c14934390ebd9664e60b74921f16295834ead19242b70", "regression/synthesis_questions.jsonl": "7a2933bbda1507aba85374e51618e3fdb5540823cf706b1b1c7d8f863b08dda8", "sources.json": "47f9626a0035cd13927ecfcdc8a44706edc19cf62d77a2f6228d62fe2415b1dc"}, "schema_version": "ecos-step-manifest.v1"} diff --git a/ecos/agent/knowledge/synthesis/sources.json b/ecos/agent/knowledge/synthesis/sources.json index 77387b2a9..1993ba496 100644 --- a/ecos/agent/knowledge/synthesis/sources.json +++ b/ecos/agent/knowledge/synthesis/sources.json @@ -1 +1 @@ -{"repositories": {"ecc": "9e529e9ee65d5cbdc5a2209d59eed4030317f19d", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "f6ee10aa505853a535aa42b2848a0f2a0a865b9e", "ecos_studio": "ec3d2b87a2b50243ec32cabcfb10c022e7462020"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "b5856771e2007a131be34068b7a329fc40d5e88a5297a19cce4cd057af548af8"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "4d0e96d66629b7a05638413323b916e13b3096b9f6d731580859ab35a588f5a1"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "6cf8605c1cdd4acfbd6ee4b7467843d79e5c662c369fa97ca75849fcd71ef761"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "1cf535bbed6a469037b399c059ecad5da2bdb82a72e7cc50f544db30d1e8c5b1"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "94f9b7538ca0a15d0705edc76729cc0356bf6e935103855fb3f366c19ae8b1fd"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "150f01725db5f4ccdf344cc191f321cc0cc682fe16b59789e50e12461ea6fb3a"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "dbd642254b2328453e3b5522a76fc6eca9fefe566f8029ceb4344c460d37b39b"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "86e1c74ae90de5a3b8ff180f67cb5b8bfac958af8d9dcfd549051452ee048a80"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "83ef3fc332ad86b041252f978651f4efde388724af93cc85193e7b9682a21cf1"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "3797f85c33322ab8f24f09db51a0d389619db810f5026e466f67f3672f7f005d"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "2b7c68a14266af92a60d93255c1057a5d1dbc742a6fc3e6ab8ed4513447903ec"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "0f3e6141f489984e1697587aa0653c434a504d5a593802a25d0cdd2ccd627029"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "cc3b2c6c97e95eed2a73e03ee94066602a064ef2ed37e4051cb9cb715f2a0c8d"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "181288f1d8208bb4e7f6c80589b5c3761a97149e6df65adb6c9f11449f35ea14"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "4c03f4ffcd539605be67350beb69723012df92a191e5bec031b30388dd959eb1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "87644517fee6b339a616aa01b96371e1a62c6a243a9b7f84e390ea1e0567679a"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "6d27833dd6d5937b9c644c032847a133086878f8345a3ba90b5b3a4f5b715664"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "1d52df523dbd0a2552a60ebfedfa8e9a71ec108ee88dec233a7cdbdd5cca6eac"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "e1a2a9c0adf18ba364c2225e9d4449893b52a29dbb0846668f38591f7aeab42b"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "de11c677f316104a44dcf3975f72cde137e21e42226b6e6e60a0a5820eac8581"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "d3fb50148cc715ca5305d708fc5d5c1805011364a06d8c9db83e1324b0fb2c61"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "baac52ceffbba8bc474444eb71a9c3c160426dcfd12848006eb94c1d2766711d"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "d751c3f7cdeae0e83799100260091b6cca5d96b25cb7eb6db8e1c5590eda2441"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "25313ed94a0707150193cd5c8e6d13be0e84633ecaf9cc4190d036952938ceb0"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "35d2875c036c31f3cd9a7453d6f9d722ae32f7f12ca9c20f782a5c7f206978b7"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "01e84d2dc2875691feba6dd80b197220e69bfe966e112033fcafe13a2478c033"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b9ae78e0548723dfda6b5439237c20e0076aea5b1dd4db9c8e2e11bc1a5da1a6"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "a1328b48e5008bf3dce236b1c3bbf8fd5d634d9efe556d703c6c95d3ccc0b7b5"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "f53b8bc10cf372a997ab6ff0df7fa530e9be477fd9dfc059f48d446219028aba"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "e79bcd01ae3b2ac16dc6cf24f6f13be141459cb6bc712dfbbdd9180517f549ce"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", "sha256": "1f37f4a5a509629cf1e57fdc42593b489dd9c7426220d5db351b888226acb0c4"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", "sha256": "fd967ff04a5b0af5f5acd0f86c987c92f6e32f440d931a2307ff3e504226a60a"}]} +{"repositories": {"ecc": "0a4318bdd1ee2c6c0e91ca253899fd835bacc379", "ecc_dreamplace": "308dcd35da567d56301fcbb3a6b22018a2d4fec4", "ecc_tools": "2c11c6c7721f47eb251de5ef2d6f415e17d152be", "ecos_studio": "b03e31cfe0c1418b583b0eb3f6ae46733e11793f"}, "schema_version": "ecos-step-sources.v1", "sources": [{"id": "ecc.runner", "path": "ecc/chipcompiler/tools/ecc/runner.py", "sha256": "39c83615c25ad1627bb54a04d7b3f4bd4da1d8c7ca8c0a463a6baccf5e5f7295"}, {"id": "ecc.module", "path": "ecc/chipcompiler/tools/ecc/module.py", "sha256": "a27cffbad6932678d17db406de05def3f871216c4a059497a282bc02cf557759"}, {"id": "ecc.metrics", "path": "ecc/chipcompiler/tools/ecc/metrics.py", "sha256": "0f6729336eb027ed3847df1a2429f75671c387306b6fc0b6d53c3e5b5548a9d5"}, {"id": "ecc.builder", "path": "ecc/chipcompiler/tools/ecc/builder.py", "sha256": "eb7ba20a7f6fe7001bcd9f6f5e23a6ff215015b71e14ef5aa99b7f46458ed73b"}, {"id": "ecc.subflow", "path": "ecc/chipcompiler/tools/ecc/subflow.py", "sha256": "733db918833ae7b1bd9dcdaebfb61a33d653089a7e93d181a44d9c5dccc070f3"}, {"id": "ecc.flow", "path": "ecc/chipcompiler/rtl2gds/builder.py", "sha256": "e505e0157a3da0b5e9ca8509c649e4b138aa1e5ff322ce03ea8b4e3511da00b7"}, {"id": "gui.step_metrics", "path": "ecos/gui/apps/renderer/src/utils/projectManagement.ts", "sha256": "271792bc7f770d1321e0b2b9e1da53fba4346a9874079fb287c1eff9a3705db5"}, {"id": "yosys.runner", "path": "ecc/chipcompiler/tools/yosys/runner.py", "sha256": "349cff3e7756528dd5e86fda4d31f9f99e000194a989df153dc9b619cce658a9"}, {"id": "yosys.metrics", "path": "ecc/chipcompiler/tools/yosys/metrics.py", "sha256": "b46e5fb8ff1686a8c7d329c46a16ffbfe45ce7942d1a2d511301682e31fa6515"}, {"id": "dreamplace.runner", "path": "ecc/chipcompiler/tools/ecc_dreamplace/runner.py", "sha256": "159351fdc8e448d61d7519a589d07c5305fb192f16d87d9a904f6ebaa899770f"}, {"id": "dreamplace.module", "path": "ecc/chipcompiler/tools/ecc_dreamplace/module.py", "sha256": "82c240bdd35f8c5ec192b0e5313173173e43d46135467b34d3613bce52dccb26"}, {"id": "yosys.builder", "path": "ecc/chipcompiler/tools/yosys/builder.py", "sha256": "c11f29cc4b3f8cfeff95fc9409c6d33c050acf8fa5bc8764b559fcbe465ce32c"}, {"id": "yosys.script", "path": "ecc/chipcompiler/tools/yosys/scripts/yosys_synthesis.tcl", "sha256": "7ff326db0fc6848f519872471f9831af3a87b7c1673e812b3ebce4c331f1cae0"}, {"id": "yosys.tech", "path": "ecc/chipcompiler/tools/yosys/scripts/init_tech.tcl", "sha256": "536755095d017ae9233a82e45024387739b4cf5dfdb34625d26f3201274026ee"}, {"id": "ifp.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/interface/FPInterface.cpp", "sha256": "7523ecc836515b6d0ceb4b4ef4b9762d198337bcde035756eb0c5b07763bc65e"}, {"id": "ifp.die_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/die_builder/DieBuilder.cpp", "sha256": "425d97334c7ae27e1a2b889caf70fe2dbed2256bd9eb94b84aff5d7ba19d146b"}, {"id": "ifp.io_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/io_placer/IOPlacer.cpp", "sha256": "ba0f45dd9c5f8a81e22604096e96f3a40495aae48e1914c40f59d6fe8fbaed29"}, {"id": "ifp.macro_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/macro_placer/MacroPlacer.cpp", "sha256": "857db8dee1a44e59c9fa0a6529cb3fa1572bcc3a8912471369b5be6d1aa8a312"}, {"id": "ifp.pdn", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/pdn_generator/PDNGenerator.cpp", "sha256": "1eb73e9eef0661ce18ba64ea6430676711d11390e51e0401fb8687cbdce183df"}, {"id": "ifp.phy_placer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iFP/source/module/phy_placer/PhyPlacer.cpp", "sha256": "46e2a8af132a2fbdab82094b916e61266633c1d557f22a3f95bbec979174ed29"}, {"id": "izh.filler", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iZH/source/module/filler_inserter/FillerInserter.cpp", "sha256": "2b6cc5aeadb2ed1c574f31facd467c899cf3173c0d6ae3d80a227a3c35bd9661"}, {"id": "icts.api", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/interface/CTSAPI.cc", "sha256": "dbd64a9645c0a2f05edda85b8def3dc5e4d84f9e7ea37ee73f19eeec0ac72788"}, {"id": "icts.synthesis", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/Synthesis.cc", "sha256": "9dc4e1699452bcf5caacd6dce39366c61a45a955f63e9db98431c0869eb3e80a"}, {"id": "icts.topology", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/topology/Topology.cc", "sha256": "b0d485bb6c1309a037c7f3327575ed4644194e74f2faa9d6f7e476b56ed952c3"}, {"id": "icts.htree", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/synthesis/htree/HTree.cc", "sha256": "73ae6da63054194b1fb23664a98a9c5467728b4da3a9f657df06fb0966fa70fc"}, {"id": "icts.router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/routing/router/Router.cc", "sha256": "e1ceb27826031515279ba6facdeaf44bbd3d98b7759ba98f0791829757ff8257"}, {"id": "icts.optimization", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/optimization/Optimization.cc", "sha256": "0ec1d933c3cbc4f54ad43b6a225133f453f0703dfb24dab18bbb3005536754c4"}, {"id": "dreamplace.basic_place", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/BasicPlace.py", "sha256": "5907158132dcda2e5de5e872b5978ce9f30975474a44b04168b208817f7b0d9c"}, {"id": "dreamplace.placer", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/Placer.py", "sha256": "783b49fd04c2b14ed33ada395e168b5c235a59be1581a25e526926ba7b71cd09"}, {"id": "dreamplace.nonlinear", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/NonLinearPlace.py", "sha256": "8bbd2805698ec1722e0cac5021f6b008408d429557a1b246e1dc31e1003092cd"}, {"id": "dreamplace.macro_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/macro_legalize/src/macro_legalize.cpp", "sha256": "3bea044eb0c4d2f9e988f7629f5e48ffce788d8008b7f7e85144c67c43d4b51b"}, {"id": "dreamplace.greedy_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/greedy_legalize/src/greedy_legalize_cpu.cpp", "sha256": "ba1234b473355e700d57697468c72216fabee55a391d61026510cd44b12f8f57"}, {"id": "dreamplace.abacus_legalize", "path": "ecc/chipcompiler/thirdparty/ecc-dreamplace/dreamplace/ops/abacus_legalize/src/abacus_legalize.cpp", "sha256": "05e144ffb845f9ef110cf486d0c825445061bd29a8d9d82d1c2b1ee6fed56dd6"}, {"id": "irt.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/interface/RTInterface.cpp", "sha256": "bcfb7d43ae4f4837221b7a902078a56f0120cdc51c1ac70ca9222e5d434fe4fb"}, {"id": "irt.planar_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/planar_router/PlanarRouter.cpp", "sha256": "a59edef29fd9915b076adb28d37b9c4dfd1bcdb30be26970b28590cab7056db1"}, {"id": "irt.layer_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/layer_assigner/LayerAssigner.cpp", "sha256": "27901646e566f6daa023aec9aa3bb0304e71a92725398c82416125e76ed3633c"}, {"id": "irt.track_assigner", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/track_assigner/TrackAssigner.cpp", "sha256": "2df0b006666a4503c0fab8a5b0a03ef2a1720788fd47a8e4a80e331cb93cd822"}, {"id": "irt.detailed_router", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRT/source/module/detailed_router/DetailedRouter.cpp", "sha256": "7ae73bb71dffa1a0b934e396afcdfa3a5304c853d1d5be465e5362198e2a5641"}, {"id": "idrc.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/interface/DRCInterface.cpp", "sha256": "f2289589d023fd03a94b8bbdb7bfc95812057afb1c360bd8b8c3ac80de0fa203"}, {"id": "idrc.validator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/RuleValidator.cpp", "sha256": "39292b78a07c8a7b8e2f6834c9af230dca2d85c28a72adf2627bda77c1cfa00c"}, {"id": "idrc.metal_short", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MetalShort.cpp", "sha256": "67315855f3ea5412f35212b2ca612860935ce61fa67d4960a6a0d8d1eb4a35a0"}, {"id": "idrc.minimum_width", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/MinimumWidth.cpp", "sha256": "c125d619867f98660c7f39f2422bd5fe52fb75dd8985344ea215d6ea41fbf837"}, {"id": "idrc.cut_spacing", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iDRC/source/module/rule_validator/rv_design_rule/SameLayerCutSpacing.cpp", "sha256": "ad61e8a30d29235a09feb27d3bfda75d67d7f71749119e1211fb9d257615fe27"}, {"id": "ircx.topo", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/topo_builder/TopoBuilder.cpp", "sha256": "fe9bb78f5a956cc7ab24a3da2c0212ce38a912dccdf7e568d918adb6d89c1a8f"}, {"id": "ircx.env", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/env_builder/EnvBuilder.cpp", "sha256": "31c97d537f0085ecc51f7c52931eaa4bebf735f6fba795b8ecada2e0a6eb3374"}, {"id": "ircx.var_processor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/var_processor/VarProcessor.cpp", "sha256": "e561307cd0159be4f3731c38ccf452665b540928cad5d966295b51737fc841d4"}, {"id": "ircx.res_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/res_extractor/ResExtractor.cpp", "sha256": "23ea8070bbb31d219891cce3dc6f0c5353b4012c6d87ae9ba1e04e7a0edf0d40"}, {"id": "ircx.cap_extractor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/cap_extractor/CapExtractor.cpp", "sha256": "42234167a797d93a82d7b36882530b982a94d0f41c3f811c82e03ba3ec5ac083"}, {"id": "ircx.spef_writer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iRCX/source/module/spef_writer/SPEFWriter.cpp", "sha256": "f73c527566e4c27d12a10c2bb7bba29e7f9e05f7c0845d1c5319960a642c1502"}, {"id": "ista.interface", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/interface/STAInterface.cpp", "sha256": "738a506706465576947164be40b82b8c57878cbb40236173f0174a04584fd295"}, {"id": "ista.graph_builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/graph_builder/GraphBuilder.cpp", "sha256": "b6b791f25ad357addf94c75692005144ec84ee65ffa67bdff7bf507c44858b51"}, {"id": "ista.propagator", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_propagator/TimingPropagator.cpp", "sha256": "9f3f3bab73966493171a39fb99dd800166f7108722e45f3d9227a2ea31df4dde"}, {"id": "ista.analyzer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_analyzer/TimingAnalyzer.cpp", "sha256": "ae50e3643a2c8179846b6e40404d2d82917a655d3b03e06e4c940005b556e64c"}, {"id": "ista.characterizer", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iSTA/source/module/timing_characterizer/TimingCharacterizer.cpp", "sha256": "5911cfc0b6ac4d12899fd14616f9167f1f9c7fece3292a3aaaa785bdc4921872"}, {"id": "idb.python", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/interface/python/py_idb/py_db.cpp", "sha256": "3408c57f616d0fc6975d429efb4a6e0ad02d75b9567e03cfe1d7f041b04fa4df"}, {"id": "idb.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/database/manager/builder/builder.cpp", "sha256": "49b647b8c01ca30b2547f83d926d4c8ec2eade9272677b58733230d9771d966c"}, {"id": "ecc.sta_qor", "path": "ecc/chipcompiler/tools/ecc/sta_qor.py", "sha256": "aba81d97d1f3ef7ca7a63178ec07efc7275c27da8841c4f5df728911612d6f51"}, {"id": "ecc.feature.summary", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_summary.cpp", "sha256": "ad88d19a234c58a982637ab03cb0e4d2441eaf86e0f9cfa65ac49118b86508b2"}, {"id": "ecc.feature.tools", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/parser/feature_parser_tools.cpp", "sha256": "522a4b40e6bf70e176c32db81b97282a8bcf462b03471f000f395bd52d79a390"}, {"id": "ecc.feature.builder", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "sha256": "e8e27d77a150407ec24fa5e8f698eb588d3e1cbd7b8d8deba756eeac406009b0"}, {"id": "icts.qor", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "sha256": "a74c71ad1b0cf9a0efa49e320941fa802411593eccf8396bb63fab23ee241871"}, {"id": "icts.qor_metrics", "path": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", "sha256": "5472b2782f800cd873512ee884584ad85556d49ca870d741e60e26081a4eb822"}, {"id": "gui.qor_trend", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}, {"id": "gui.qor_data", "path": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", "sha256": "ab5e60a7961ff2b2325efd8a24e1e71fb8ad05afc3166e83202de8256749cd34"}]} diff --git a/ecos/agent/scripts/knowledge/metric_details.py b/ecos/agent/scripts/knowledge/metric_details.py index 0fde28186..204c27871 100644 --- a/ecos/agent/scripts/knowledge/metric_details.py +++ b/ecos/agent/scripts/knowledge/metric_details.py @@ -13,8 +13,8 @@ "ecc.feature.builder": "ecc/chipcompiler/thirdparty/ecc-tools/src/feature/builder/feature_builder.cpp", "icts.qor": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluation.cc", "icts.qor_metrics": "ecc/chipcompiler/thirdparty/ecc-tools/src/operation/iCTS/source/module/evaluation/qor/QOREvaluationMetrics.cc", - "gui.qor_trend": "ecos/gui/apps/renderer/src/utils/projectQorTrend.ts", - "gui.qor_data": "ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts", + "gui.qor_trend": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", + "gui.qor_data": "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts", } diff --git a/ecos/agent/src/ecos_agent/knob_registry.py b/ecos/agent/src/ecos_agent/knob_registry.py index 9b4501535..a2cb41b35 100644 --- a/ecos/agent/src/ecos_agent/knob_registry.py +++ b/ecos/agent/src/ecos_agent/knob_registry.py @@ -1,311 +1,66 @@ -"""Single source of truth for ECOS Agent tunable workspace parameters. - -Every tunable knob is declared exactly once here: which ECC step authorizes it, -which on-disk surface owns it, and how its value is constrained. Both the read -path (contract "current value") and the write path (GUI execution target) -resolve through this registry, so the two can never drift. - -Two surfaces exist in an ECOS workspace and ECC keeps them in sync: - -- ``parameters`` -- ``home/parameters.json``, the ICS55 flat template. It is the - authoritative source; ``refresh_config`` regenerates step configs from it. -- ``step_config`` -- ``config/*.json`` per-tool configuration, mirroring the ECC - candidate registry's ``config_key`` / ``json_path``. ``sync_config`` pushes a - change here back into ``parameters`` and then refreshes. - -A knob may exist on one surface or both. Reads use the ECC-canonical surface -(the step config, which the candidate rerun system applies to). Writes use the -authoritative surface, and the caller must always invoke the matching sync RPC -so the two converge -- skipping it is what makes the surfaces drift apart. -""" +"""Logical knobs the Agent may propose from ECC-provided current values.""" from __future__ import annotations import math from dataclasses import dataclass -from typing import Literal, Mapping +from typing import Mapping from ecos_agent.ecc_contracts import ECCParameterPatchItem, ECCStepName -KnobSurface = Literal["parameters", "step_config"] - -PARAMETERS_FILE = "home/parameters.json" -DREAMPLACE_FILE = "config/dreamplace_ecc.json" -CTS_FILE = "config/cts_ecc.json" -ROUTE_FILE = "config/route_ecc.json" - -WRITABLE_FILES = frozenset({PARAMETERS_FILE, DREAMPLACE_FILE, CTS_FILE, ROUTE_FILE}) - - -@dataclass(frozen=True) -class KnobTarget: - """Where a knob physically lives inside the workspace.""" - - surface: KnobSurface - file: str - json_path: tuple[str | int, ...] - - @dataclass(frozen=True) class KnobSpec: - """Authorization, location, and value contract for one tunable knob.""" + """Authorization and value contract for one logical knob.""" knob_id: str step: ECCStepName kind: str - config: KnobTarget | None = None - parameters: KnobTarget | None = None bounds: tuple[float, float] | None = None - # parameters.json stores some flags as 0/1 rather than JSON booleans. - store_boolean_as_int: bool = False - - @property - def read_target(self) -> KnobTarget: - """ECC-canonical surface: what the candidate rerun system applies to.""" - target = self.config or self.parameters - assert target is not None - return target - - @property - def write_target(self) -> KnobTarget: - """Authoritative surface; step configs are regenerated from parameters.""" - target = self.parameters or self.config - assert target is not None - return target - - -def _parameters(*json_path: str | int) -> KnobTarget: - return KnobTarget(surface="parameters", file=PARAMETERS_FILE, json_path=json_path) - - -def _dreamplace(key: str) -> KnobTarget: - return KnobTarget(surface="step_config", file=DREAMPLACE_FILE, json_path=(key,)) - - -def _cts(key: str) -> KnobTarget: - return KnobTarget(surface="step_config", file=CTS_FILE, json_path=(key,)) - - -def _route(key: str) -> KnobTarget: - return KnobTarget(surface="step_config", file=ROUTE_FILE, json_path=("RT", key)) _SPECS: tuple[KnobSpec, ...] = ( - # -- Synthesis: global design intent ------------------------------------- - KnobSpec("design.clock", ECCStepName.SYNTHESIS, "string", parameters=_parameters("Clock")), - KnobSpec( - "design.frequency_max", - ECCStepName.SYNTHESIS, - "positive_number", - parameters=_parameters("Frequency max [MHz]"), - ), - # -- Floorplan: die and core geometry ------------------------------------ - KnobSpec( - "floorplan.utilitization", - ECCStepName.FLOORPLAN, - "ranged", - parameters=_parameters("Core", "Utilitization"), - bounds=(0.01, 1.0), - ), - KnobSpec( - "floorplan.aspect_ratio", - ECCStepName.FLOORPLAN, - "positive_number", - parameters=_parameters("Core", "Aspect ratio"), - ), - KnobSpec( - "floorplan.margin_x", - ECCStepName.FLOORPLAN, - "number", - parameters=_parameters("Core", "Margin", 0), - ), - KnobSpec( - "floorplan.margin_y", - ECCStepName.FLOORPLAN, - "number", - parameters=_parameters("Core", "Margin", 1), - ), - KnobSpec( - "floorplan.die_width", - ECCStepName.FLOORPLAN, - "positive_number", - parameters=_parameters("Die", "Size", 0), - ), - KnobSpec( - "floorplan.die_height", - ECCStepName.FLOORPLAN, - "positive_number", - parameters=_parameters("Die", "Size", 1), - ), - KnobSpec( - "floorplan.global_right_padding", - ECCStepName.FLOORPLAN, - "zero_based_integer", - parameters=_parameters("Global right padding"), - ), - # -- Placement ----------------------------------------------------------- - KnobSpec( - "place.target_density", - ECCStepName.PLACEMENT, - "ranged", - config=_dreamplace("target_density"), - parameters=_parameters("Target density"), - bounds=(0.1, 0.95), - ), - KnobSpec( - "place.target_overflow", - ECCStepName.PLACEMENT, - "ranged", - config=_dreamplace("stop_overflow"), - parameters=_parameters("Target overflow"), - bounds=(0.0, 1.0), - ), - KnobSpec( - "place.cell_padding_x", - ECCStepName.PLACEMENT, - "zero_based_integer", - config=_dreamplace("cell_padding_x"), - parameters=_parameters("Cell padding x"), - ), - KnobSpec( - "place.routability_opt", - ECCStepName.PLACEMENT, - "boolean", - config=_dreamplace("routability_opt_flag"), - parameters=_parameters("Routability opt flag"), - store_boolean_as_int=True, - ), - KnobSpec( - "place.density_weight", ECCStepName.PLACEMENT, "number", config=_dreamplace("density_weight") - ), - KnobSpec( - "place.gp_noise_ratio", - ECCStepName.PLACEMENT, - "ranged", - config=_dreamplace("gp_noise_ratio"), - bounds=(0.0, 1.0), - ), - KnobSpec( - "place.num_threads", ECCStepName.PLACEMENT, "integer", config=_dreamplace("num_threads") - ), - # -- Clock tree synthesis ------------------------------------------------ - KnobSpec( - "cts.skew_bound", ECCStepName.CTS, "ranged", config=_cts("skew_bound"), bounds=(0.0, 1.0) - ), - KnobSpec("cts.max_buf_tran", ECCStepName.CTS, "number", config=_cts("max_buf_tran")), - KnobSpec("cts.root_input_slew", ECCStepName.CTS, "number", config=_cts("root_input_slew")), - KnobSpec("cts.max_sink_tran", ECCStepName.CTS, "number", config=_cts("max_sink_tran")), - KnobSpec("cts.max_cap", ECCStepName.CTS, "number", config=_cts("max_cap")), - KnobSpec("cts.wirelength_unit_um", ECCStepName.CTS, "number", config=_cts("wirelength_unit_um")), - KnobSpec( - "cts.wirelength_iterations", - ECCStepName.CTS, - "integer", - config=_cts("wirelength_iterations"), - ), - KnobSpec("cts.slew_steps", ECCStepName.CTS, "integer", config=_cts("slew_steps")), - KnobSpec("cts.cap_steps", ECCStepName.CTS, "integer", config=_cts("cap_steps")), - KnobSpec("cts.wire_width", ECCStepName.CTS, "number", config=_cts("wire_width")), - KnobSpec( - "cts.max_fanout", - ECCStepName.CTS, - "integer", - config=_cts("max_fanout"), - parameters=_parameters("Max fanout"), - ), - KnobSpec("cts.routing_layer", ECCStepName.CTS, "int_list", config=_cts("routing_layer")), - KnobSpec("cts.buffer_type", ECCStepName.CTS, "str_list", config=_cts("buffer_type")), - KnobSpec( - "cts.char_buf_redundancy_pct", - ECCStepName.CTS, - "number", - config=_cts("char_buf_redundancy_pct"), - ), - KnobSpec( - "cts.force_branch_buffer", ECCStepName.CTS, "boolean", config=_cts("force_branch_buffer") - ), - KnobSpec( - "cts.htree_depth_explore_window", - ECCStepName.CTS, - "integer", - config=_cts("htree_depth_explore_window"), - ), - KnobSpec( - "cts.htree_topology_tolerance", - ECCStepName.CTS, - "number", - config=_cts("htree_topology_tolerance"), - ), - KnobSpec( - "cts.enable_analytical_htree", - ECCStepName.CTS, - "boolean", - config=_cts("enable_analytical_htree"), - ), - KnobSpec( - "cts.enable_sink_clustering", - ECCStepName.CTS, - "boolean", - config=_cts("enable_sink_clustering"), - ), - # -- Legalization -------------------------------------------------------- - KnobSpec( - "legalization.cell_padding_x", - ECCStepName.LEGALIZATION, - "zero_based_integer", - config=_dreamplace("cell_padding_x"), - ), - KnobSpec( - "legalization.bndry_padding_x", - ECCStepName.LEGALIZATION, - "integer", - config=_dreamplace("bndry_padding_x"), - ), - KnobSpec( - "legalization.bndry_padding_y", - ECCStepName.LEGALIZATION, - "integer", - config=_dreamplace("bndry_padding_y"), - ), - KnobSpec( - "legalization.detailed_place_flag", - ECCStepName.LEGALIZATION, - "boolean", - config=_dreamplace("detailed_place_flag"), - ), - KnobSpec( - "legalization.num_threads", - ECCStepName.LEGALIZATION, - "integer", - config=_dreamplace("num_threads"), - ), - KnobSpec( - "legalization.deterministic", - ECCStepName.LEGALIZATION, - "boolean", - config=_dreamplace("deterministic_flag"), - ), - # -- Routing ------------------------------------------------------------- - KnobSpec( - "route.bottom_layer", - ECCStepName.ROUTING, - "string", - config=_route("-bottom_routing_layer"), - parameters=_parameters("Bottom layer"), - ), - KnobSpec( - "route.top_layer", - ECCStepName.ROUTING, - "string", - config=_route("-top_routing_layer"), - parameters=_parameters("Top layer"), - ), - KnobSpec( - "route.thread_number", ECCStepName.ROUTING, "integer", config=_route("-thread_number") - ), - KnobSpec( - "route.enable_timing", ECCStepName.ROUTING, "boolean", config=_route("-enable_timing") - ), + KnobSpec("design.frequency_max", ECCStepName.SYNTHESIS, "positive_number"), + KnobSpec("floorplan.utilitization", ECCStepName.FLOORPLAN, "ranged", (0.01, 1.0)), + KnobSpec("floorplan.aspect_ratio", ECCStepName.FLOORPLAN, "positive_number"), + KnobSpec("floorplan.die_width", ECCStepName.FLOORPLAN, "positive_number"), + KnobSpec("floorplan.die_height", ECCStepName.FLOORPLAN, "positive_number"), + KnobSpec("floorplan.global_right_padding", ECCStepName.FLOORPLAN, "zero_based_integer"), + KnobSpec("place.target_density", ECCStepName.PLACEMENT, "ranged", (0.1, 0.95)), + KnobSpec("place.target_overflow", ECCStepName.PLACEMENT, "ranged", (0.0, 1.0)), + KnobSpec("place.cell_padding_x", ECCStepName.PLACEMENT, "zero_based_integer"), + KnobSpec("place.routability_opt", ECCStepName.PLACEMENT, "boolean"), + KnobSpec("place.density_weight", ECCStepName.PLACEMENT, "number"), + KnobSpec("place.gp_noise_ratio", ECCStepName.PLACEMENT, "ranged", (0.0, 1.0)), + KnobSpec("place.num_threads", ECCStepName.PLACEMENT, "integer"), + KnobSpec("cts.skew_bound", ECCStepName.CTS, "ranged", (0.0, 1.0)), + KnobSpec("cts.max_buf_tran", ECCStepName.CTS, "number"), + KnobSpec("cts.root_input_slew", ECCStepName.CTS, "number"), + KnobSpec("cts.max_sink_tran", ECCStepName.CTS, "number"), + KnobSpec("cts.max_cap", ECCStepName.CTS, "number"), + KnobSpec("cts.wirelength_unit_um", ECCStepName.CTS, "number"), + KnobSpec("cts.wirelength_iterations", ECCStepName.CTS, "integer"), + KnobSpec("cts.slew_steps", ECCStepName.CTS, "integer"), + KnobSpec("cts.cap_steps", ECCStepName.CTS, "integer"), + KnobSpec("cts.wire_width", ECCStepName.CTS, "number"), + KnobSpec("cts.max_fanout", ECCStepName.CTS, "integer"), + KnobSpec("cts.routing_layer", ECCStepName.CTS, "int_list"), + KnobSpec("cts.buffer_type", ECCStepName.CTS, "str_list"), + KnobSpec("cts.char_buf_redundancy_pct", ECCStepName.CTS, "number"), + KnobSpec("cts.force_branch_buffer", ECCStepName.CTS, "boolean"), + KnobSpec("cts.htree_depth_explore_window", ECCStepName.CTS, "integer"), + KnobSpec("cts.htree_topology_tolerance", ECCStepName.CTS, "number"), + KnobSpec("cts.enable_analytical_htree", ECCStepName.CTS, "boolean"), + KnobSpec("cts.enable_sink_clustering", ECCStepName.CTS, "boolean"), + KnobSpec("legalization.cell_padding_x", ECCStepName.LEGALIZATION, "zero_based_integer"), + KnobSpec("legalization.bndry_padding_x", ECCStepName.LEGALIZATION, "integer"), + KnobSpec("legalization.bndry_padding_y", ECCStepName.LEGALIZATION, "integer"), + KnobSpec("legalization.detailed_place_flag", ECCStepName.LEGALIZATION, "boolean"), + KnobSpec("legalization.num_threads", ECCStepName.LEGALIZATION, "integer"), + KnobSpec("legalization.deterministic", ECCStepName.LEGALIZATION, "boolean"), + KnobSpec("route.bottom_layer", ECCStepName.ROUTING, "string"), + KnobSpec("route.top_layer", ECCStepName.ROUTING, "string"), + KnobSpec("route.thread_number", ECCStepName.ROUTING, "integer"), + KnobSpec("route.enable_timing", ECCStepName.ROUTING, "boolean"), ) @@ -329,30 +84,6 @@ def knob_spec(knob_id: str) -> KnobSpec: return spec -def storage_value(item: ECCParameterPatchItem) -> object: - """On-disk representation of a validated patch value.""" - spec = knob_spec(item.knob_id) - if spec.store_boolean_as_int: - return 1 if item.value is True else 0 - return item.value - - -def resolve_write(item: ECCParameterPatchItem) -> dict[str, object]: - """Execution instruction for the GUI: exactly where and what to write. - - Emitting the resolved target with the contract keeps the knob mapping in one - place; the GUI executes the instruction instead of holding its own table. - """ - target = knob_spec(item.knob_id).write_target - return { - "knob_id": item.knob_id, - "value": storage_value(item), - "surface": target.surface, - "file": target.file, - "json_path": list(target.json_path), - } - - def validate_value(item: ECCParameterPatchItem) -> None: """Reject values a knob cannot legally take. diff --git a/ecos/agent/src/ecos_agent/provider.py b/ecos/agent/src/ecos_agent/provider.py index 431421901..e288c5696 100644 --- a/ecos/agent/src/ecos_agent/provider.py +++ b/ecos/agent/src/ecos_agent/provider.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import uuid from dataclasses import dataclass, field from pathlib import Path @@ -32,7 +31,6 @@ number_prompt, numbered_choice, operation_choice, - operation_prompt, optional_file_choice, optional_file_prompt, pdk_prompt, @@ -68,7 +66,7 @@ derive_project_name, discover_ecos_pdk_paths, discover_design_file_candidates, - infer_design_defaults, + infer_clock_and_frequency_defaults, merge_workspace_inputs, merge_workspace_setup, normalize_identifier, @@ -80,7 +78,7 @@ workspace_search_roots, workspace_setup_contract, ) -from ecos_agent.knob_registry import resolve_write +from ecos_agent.knob_registry import KNOB_SPECS from ecos_agent.workspace_rerun import ( BOOLEAN_RERUN_KNOBS, GuiWorkspaceRerunContract, @@ -96,14 +94,11 @@ _deterministic_operation_choice, _extract_create_bootstrap, _flow_steps, - _gui_workspace_codex_provider, - _gui_workspace_request_context, _handle_workspace_rerun_result, _keyword_operation_choice, _number_default, _operation_choice, _optional_text, - _path_was_explicitly_provided, _prompt_for_phase, _propose_gui_chat_response, _propose_gui_workspace_path_discovery, @@ -123,10 +118,18 @@ PROVIDER_ID = "ecos_agent" -_WorkspaceSetupParser = Callable[[dict[str, Any]], GuiWorkspaceSetupProposal | dict[str, Any]] -_WorkspacePathRecommender = Callable[[dict[str, Any]], GuiWorkspaceSetupProposal | dict[str, Any]] -_RerunParameterParser = Callable[[dict[str, Any]], GuiWorkspaceRerunParameterProposal | dict[str, Any]] -_ChatResponseParser = Callable[[dict[str, Any]], GuiChatResponseProposal | dict[str, Any]] +_WorkspaceSetupParser = Callable[ + [dict[str, Any]], GuiWorkspaceSetupProposal | dict[str, Any] +] +_WorkspacePathRecommender = Callable[ + [dict[str, Any]], GuiWorkspaceSetupProposal | dict[str, Any] +] +_RerunParameterParser = Callable[ + [dict[str, Any]], GuiWorkspaceRerunParameterProposal | dict[str, Any] +] +_ChatResponseParser = Callable[ + [dict[str, Any]], GuiChatResponseProposal | dict[str, Any] +] _CHAT_GREETING_PREFIXES = ("hello", "hi", "hey", "你好", "您好", "嗨") _CHAT_QUESTION_PREFIXES = ( "what ", @@ -176,16 +179,6 @@ def _known_projects(value: object) -> list[tuple[str, str]]: def _design_id_for_workspace(workspace: str) -> str | None: root = Path(workspace) - parameters_path = root / "home" / "parameters.json" - if parameters_path.is_file(): - try: - payload = json.loads(parameters_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - payload = None - if isinstance(payload, dict): - design = payload.get("Design") - if isinstance(design, str) and design.strip(): - return design.strip() # Prefer known ECC output locations; avoid full-tree rglob on large workspaces. for pattern in ( "place_dreamplace/output/*_place.*", @@ -218,6 +211,7 @@ class _Session: language_locked: bool = False project_root: str | None = None known_projects: list[tuple[str, str]] = field(default_factory=list) + workspace_parameter_values: dict[str, object] = field(default_factory=dict) creating_project: bool = False design_id: str | None = None inherited_design_name: str | None = None @@ -227,7 +221,9 @@ class _Session: rerun_discovery: GuiWorkspaceRerunDiscovery | None = None rerun_parameter_patch: list[dict[str, Any]] = field(default_factory=list) workspace_rerun_contract: GuiWorkspaceRerunContract | None = None - workspace_setup: GuiWorkspaceSetupProposal = field(default_factory=recommended_workspace_setup) + workspace_setup: GuiWorkspaceSetupProposal = field( + default_factory=recommended_workspace_setup + ) workspace_inputs: WorkspaceInputs = field(default_factory=WorkspaceInputs) path_recommendations: dict[str, str] = field(default_factory=dict) workspace_setup_id: str | None = None @@ -255,9 +251,15 @@ def __init__( chat_response_parser: _ChatResponseParser | None = None, ) -> None: self.emit = emit - self.workspace_setup_parser = workspace_setup_parser or _propose_gui_workspace_setup - self.workspace_path_recommender = workspace_path_recommender or _propose_gui_workspace_path_discovery - self.rerun_parameter_parser = rerun_parameter_parser or _propose_gui_workspace_rerun_patch + self.workspace_setup_parser = ( + workspace_setup_parser or _propose_gui_workspace_setup + ) + self.workspace_path_recommender = ( + workspace_path_recommender or _propose_gui_workspace_path_discovery + ) + self.rerun_parameter_parser = ( + rerun_parameter_parser or _propose_gui_workspace_rerun_patch + ) self.knowledge = knowledge or load_default_step_knowledge() self.chat_response_parser = chat_response_parser or _propose_gui_chat_response self.sessions: dict[str, _Session] = {} @@ -282,8 +284,16 @@ def start_session(self, request: Mapping[str, Any]) -> dict[str, str]: if mode in {"home", "workspace"}: session.mode = mode session.known_projects = _known_projects(request.get("knownProjects")) + values = request.get("workspaceParameterValues") + session.workspace_parameter_values = ( + {str(key): value for key, value in values.items() if str(key) in KNOB_SPECS} + if isinstance(values, Mapping) + else {} + ) if directory: - session.inherited_design_name = _design_id_for_workspace(directory) + session.inherited_design_name = _optional_text( + request.get("workspaceDesignId") + ) or _design_id_for_workspace(directory) # Directory alone is only a rerun default; GUI must pass mode explicitly. session.phase = "operation" if session.mode == "workspace" else "home_ready" self._emit_status(session, "idle") @@ -336,7 +346,11 @@ def send_message(self, request: Mapping[str, Any]) -> dict[str, str]: session.running = False if not interrupted: self._emit_status(session, self._resting_status(session)) - return {"messageId": turn_id, "sessionId": session.session_id, "turnId": turn_id} + return { + "messageId": turn_id, + "sessionId": session.session_id, + "turnId": turn_id, + } def interrupt(self, request: Mapping[str, Any] | None = None) -> None: session = self._session(request or {}) @@ -359,7 +373,9 @@ def get_status(self, request: Mapping[str, Any] | None = None) -> dict[str, str] def set_mode(self, request: Mapping[str, Any]) -> dict[str, str]: return self.get_status(request) - def list_sessions(self, _request: Mapping[str, Any] | None = None) -> dict[str, list[dict[str, str]]]: + def list_sessions( + self, _request: Mapping[str, Any] | None = None + ) -> dict[str, list[dict[str, str]]]: return { "sessions": [ {"sessionId": session.session_id, "title": "ECOS Agent"} @@ -396,7 +412,6 @@ def _handle_input(self, session: _Session, message: str) -> None: "workspace_filelist": self._select_filelist, "workspace_sdc": self._select_sdc, "workspace_pdk": self._select_pdk, - "workspace_top": self._select_top_module, "workspace_clock": self._select_clock, "workspace_frequency": self._select_frequency, "workspace_max_fanout": self._select_max_fanout, @@ -415,7 +430,9 @@ def _handle_input(self, session: _Session, message: str) -> None: } handler = handlers.get(session.phase) if handler is None: - self._emit(session, "error", "The current ECOS Agent session is not actionable.") + self._emit( + session, "error", "The current ECOS Agent session is not actionable." + ) return if session.phase in {"home_ready", "operation"}: self._handle_idle_input(session, message) @@ -455,7 +472,9 @@ def _knowledge_answer(self, message: str) -> KnowledgeAnswer | None: def _select_home_ready(self, session: _Session, message: str, choice: str) -> None: if choice == "1": - self._begin_home_workspace_create(session, message if message.strip() != "1" else "") + self._begin_home_workspace_create( + session, message if message.strip() != "1" else "" + ) return def _select_operation(self, session: _Session, message: str, choice: str) -> None: @@ -484,8 +503,11 @@ def _select_operation(self, session: _Session, message: str, choice: str) -> Non self._begin_create_workspace_in_project(session) return elif choice == "1": - self._begin_home_workspace_create(session, message if message.strip() != "1" else "") + self._begin_home_workspace_create( + session, message if message.strip() != "1" else "" + ) return + def _resolve_operation_choice(self, session: _Session, message: str) -> str | None: resolve_mode = "home" if session.phase == "home_ready" else session.mode allowed_options = _allowed_operation_options( @@ -512,7 +534,9 @@ def _answer_with_codex( allow_operations: bool, knowledge_answer: KnowledgeAnswer | None, ) -> None: - allowed_options = self._chat_allowed_operations(session) if allow_operations else [] + allowed_options = ( + self._chat_allowed_operations(session) if allow_operations else [] + ) response = self._parse_chat_response( session, message, @@ -523,7 +547,10 @@ def _answer_with_codex( if response is None: if knowledge_answer is not None: self._emit( - session, "message", knowledge_answer.text, contract=knowledge_answer.contract + session, + "message", + knowledge_answer.text, + contract=knowledge_answer.contract, ) return if response.operation is None: @@ -544,7 +571,11 @@ def _answer_with_codex( return allowed_ids = {option["id"] for option in allowed_options} if response.operation not in allowed_ids: - self._emit(session, "error", "The interpreted operation is not available in the current session.") + self._emit( + session, + "error", + "The interpreted operation is not available in the current session.", + ) return if session.phase == "home_ready": self._select_home_ready(session, message, response.operation) @@ -579,7 +610,9 @@ def _parse_chat_response( "workspace": session.rerun_workspace_path or "", "project_root": session.project_root or "", "_progress_callback": lambda text: self._progress(session, text), - "_register_interrupt": lambda callback: self._register_interrupt(session, callback), + "_register_interrupt": lambda callback: self._register_interrupt( + session, callback + ), } if knowledge_answer is not None: context["retrieved_knowledge"] = { @@ -588,7 +621,9 @@ def _parse_chat_response( "text": knowledge_answer.text, } try: - response = GuiChatResponseProposal.model_validate(self.chat_response_parser(context)) + response = GuiChatResponseProposal.model_validate( + self.chat_response_parser(context) + ) self._check_interrupted(session) except (CodexProviderError, ValueError) as exc: self._check_interrupted(session) @@ -601,7 +636,9 @@ def _parse_chat_response( def _begin_home_workspace_create(self, session: _Session, message: str) -> None: self._reset_workspace_setup(session) session.creating_project = False - bootstrap = _extract_create_bootstrap(message) if message.strip() else CreateBootstrap() + bootstrap = ( + _extract_create_bootstrap(message) if message.strip() else CreateBootstrap() + ) mode_explicit = bootstrap.creating_project is not None if bootstrap.creating_project is True: session.creating_project = True @@ -612,13 +649,18 @@ def _begin_home_workspace_create(self, session: _Session, message: str) -> None: root = normalize_path( bootstrap.project_root, label="Project Root", require_directory=True ) - if not session.creating_project and not (Path(root) / "project.json").is_file(): + if ( + not session.creating_project + and not (Path(root) / "project.json").is_file() + ): raise ValueError("Existing Project Root must contain project.json") session.workspace_inputs.project_root = root session.workspace_inputs.project_name = derive_project_name(root) session.project_root = root pdk_paths = discover_ecos_pdk_paths(root) - session.path_recommendations = {"pdk": pdk_paths[0]} if pdk_paths else {} + session.path_recommendations = ( + {"pdk": pdk_paths[0]} if pdk_paths else {} + ) except ValueError: session.workspace_inputs.project_root = "" session.workspace_inputs.project_name = "" @@ -636,7 +678,9 @@ def _begin_home_workspace_create(self, session: _Session, message: str) -> None: try: self._update_workspace_setup( session, - design_name=normalize_identifier(bootstrap.design_name, label="Design Name"), + design_name=normalize_identifier( + bootstrap.design_name, label="Design Name" + ), ) except ValueError: pass @@ -665,7 +709,9 @@ def _enter_create_flow_phase( self._emit( session, "message", - project_root_prompt(session.language, creating=session.creating_project), + project_root_prompt( + session.language, creating=session.creating_project + ), ) else: session.phase = "workspace_project_mode" @@ -674,7 +720,9 @@ def _enter_create_flow_phase( return if not session.workspace_setup.workspace_name: session.phase = "workspace_name" - recommendation = recommended_workspace_name(session.workspace_inputs.project_root) + recommendation = recommended_workspace_name( + session.workspace_inputs.project_root + ) self._emit( session, "message", @@ -685,7 +733,9 @@ def _enter_create_flow_phase( if not session.workspace_setup.design_name: session.phase = "workspace_design" recommendation = ( - session.inherited_design_name or session.workspace_inputs.project_name or "" + session.inherited_design_name + or session.workspace_inputs.project_name + or "" ) self._emit( session, @@ -710,7 +760,9 @@ def _enter_create_flow_phase( def _begin_create_workspace_in_project(self, session: _Session) -> None: project_root = session.project_root if not project_root: - self._emit(session, "error", "No Project Root is bound to this Agent session.") + self._emit( + session, "error", "No Project Root is bound to this Agent session." + ) self._emit_phase_choice(session) return self._reset_workspace_setup(session) @@ -739,19 +791,31 @@ def _select_project_mode(self, session: _Session, message: str) -> None: choice = "1" elif any( key in text - for key in ("新建 project", "create project", "new project", "创建项目", "新建项目") + for key in ( + "新建 project", + "create project", + "new project", + "创建项目", + "新建项目", + ) ): choice = "2" if choice == "1": session.creating_project = False session.phase = "workspace_project_root" - self._emit(session, "message", project_root_prompt(session.language, creating=False)) + self._emit( + session, + "message", + project_root_prompt(session.language, creating=False), + ) self._emit_phase_choice(session) return if choice == "2": session.creating_project = True session.phase = "workspace_project_root" - self._emit(session, "message", project_root_prompt(session.language, creating=True)) + self._emit( + session, "message", project_root_prompt(session.language, creating=True) + ) self._emit_phase_choice(session) return self._emit(session, "message", unmatched_operation_prompt(session.language)) @@ -761,7 +825,9 @@ def _select_project_mode(self, session: _Session, message: str) -> None: def _begin_workspace_scoped_rerun(self, session: _Session) -> None: workspace = session.rerun_workspace_path if not workspace: - self._emit(session, "error", "No open workspace is bound to this Agent session.") + self._emit( + session, "error", "No open workspace is bound to this Agent session." + ) self._emit_phase_choice(session) return self._progress(session, "Preparing stage rerun…") @@ -786,7 +852,9 @@ def _begin_workspace_scoped_rerun(self, session: _Session) -> None: def _begin_workspace_continue(self, session: _Session) -> None: workspace = session.rerun_workspace_path if not workspace: - self._emit(session, "error", "No open workspace is bound to this Agent session.") + self._emit( + session, "error", "No open workspace is bound to this Agent session." + ) self._emit_phase_choice(session) return session.workspace_continue_id = uuid.uuid4().hex @@ -834,7 +902,9 @@ def _confirm_workspace_continue(self, session: _Session, message: str) -> None: }, ) - def _handle_workspace_continue_result(self, session: _Session, message: str) -> None: + def _handle_workspace_continue_result( + self, session: _Session, message: str + ) -> None: if not message.startswith("workspace_continue_result:"): self._emit(session, "error", "Continue-flow result is invalid.") return @@ -842,13 +912,17 @@ def _handle_workspace_continue_result(self, session: _Session, message: str) -> if '"status":"succeeded"' in message or '"status": "succeeded"' in message: self._emit(session, "message", "Flow continue finished.") else: - self._emit(session, "message", "Flow continue did not complete successfully.") + self._emit( + session, "message", "Flow continue did not complete successfully." + ) self._emit_phase_choice(session) def _begin_workspace_parameter_update(self, session: _Session) -> None: workspace = session.rerun_workspace_path if not workspace: - self._emit(session, "error", "No open workspace is bound to this Agent session.") + self._emit( + session, "error", "No open workspace is bound to this Agent session." + ) self._emit_phase_choice(session) return session.phase = "workspace_parameter_request" @@ -858,10 +932,14 @@ def _begin_workspace_parameter_update(self, session: _Session) -> None: workspace_parameter_request_prompt(session.language), ) - def _select_workspace_parameter_request(self, session: _Session, message: str) -> None: + def _select_workspace_parameter_request( + self, session: _Session, message: str + ) -> None: workspace = session.rerun_workspace_path if not workspace: - self._emit(session, "error", "No open workspace is bound to this Agent session.") + self._emit( + session, "error", "No open workspace is bound to this Agent session." + ) return if not message.strip(): self._emit( @@ -870,17 +948,27 @@ def _select_workspace_parameter_request(self, session: _Session, message: str) - workspace_parameter_request_prompt(session.language), ) return - design = _design_id_for_workspace(workspace) + design = session.inherited_design_name or _design_id_for_workspace(workspace) if design is None: - self._emit(session, "error", "Unable to infer the design name for parameter updates.") + self._emit( + session, + "error", + "Unable to infer the design name for parameter updates.", + ) session.phase = "operation" self._emit_phase_choice(session) return try: - source = Path(normalize_path(workspace, label="Workspace", require_directory=True)) - parameter_values = _tunable_workspace_parameters(source) + source = Path( + normalize_path(workspace, label="Workspace", require_directory=True) + ) + parameter_values = _tunable_workspace_parameters( + session.workspace_parameter_values + ) if not parameter_values: - raise ValueError("No tunable parameters are available in this workspace yet") + raise ValueError( + "No tunable parameters are available in this workspace yet" + ) allowed_knobs = [knob_id for knob_id, _ in parameter_values] current_values = {knob_id: value for knob_id, value in parameter_values} proposal = GuiWorkspaceRerunParameterProposal.model_validate( @@ -889,9 +977,13 @@ def _select_workspace_parameter_request(self, session: _Session, message: str) - "schema_version": "flow-agent.gui_workspace_rerun_parameter_context.v1", "natural_language_request": message, "allowed_knobs": allowed_knobs, - "boolean_knobs": sorted(set(allowed_knobs) & BOOLEAN_RERUN_KNOBS), + "boolean_knobs": sorted( + set(allowed_knobs) & BOOLEAN_RERUN_KNOBS + ), "workspace": str(source), - "_progress_callback": lambda text: self._progress(session, text), + "_progress_callback": lambda text: self._progress( + session, text + ), "_register_interrupt": lambda callback: self._register_interrupt( session, callback ), @@ -901,11 +993,12 @@ def _select_workspace_parameter_request(self, session: _Session, message: str) - self._check_interrupted(session) patch = [item.model_dump(mode="json") for item in proposal.parameter_patch] _validate_workspace_parameter_patch(patch, current_values) - writes = [resolve_write(item) for item in proposal.parameter_patch] except (CodexProviderError, ValueError) as exc: self._check_interrupted(session) self._raise_if_interrupted(exc) - self._emit(session, "error", f"Unable to validate the parameter change: {exc}") + self._emit( + session, "error", f"Unable to validate the parameter change: {exc}" + ) self._emit( session, "message", @@ -914,11 +1007,10 @@ def _select_workspace_parameter_request(self, session: _Session, message: str) - return update_id = uuid.uuid4().hex session.workspace_parameter_update = { - "schema_version": "flow-agent.workspace_parameter_update_contract.v2", + "schema_version": "flow-agent.workspace_parameter_update_contract.v3", "update_id": update_id, "workspace": workspace, "parameter_patch": patch, - "writes": writes, } session.phase = "workspace_parameter_confirmation" fields = [ @@ -944,11 +1036,16 @@ def _select_workspace_parameter_request(self, session: _Session, message: str) - "title": "Save workspace parameter changes", "presentation": "workspace_parameter_update", "fields": fields, + "parameter_patch": patch, + "update_id": update_id, + "workspace": workspace, }, ) self._emit_phase_choice(session) - def _confirm_workspace_parameter_update(self, session: _Session, message: str) -> None: + def _confirm_workspace_parameter_update( + self, session: _Session, message: str + ) -> None: choice = _operation_choice(message) if choice == "2" or message.strip().lower() in {"cancel", "n", "no"}: session.workspace_parameter_update = None @@ -972,12 +1069,23 @@ def _confirm_workspace_parameter_update(self, session: _Session, message: str) - workspace_parameter_update=contract, ) - def _handle_workspace_parameter_update_result(self, session: _Session, message: str) -> None: + def _handle_workspace_parameter_update_result( + self, session: _Session, message: str + ) -> None: if not message.startswith("workspace_parameter_update_result:"): self._emit(session, "error", "Parameter update result is invalid.") return + succeeded = ( + '"status":"succeeded"' in message or '"status": "succeeded"' in message + ) + if succeeded and session.workspace_parameter_update: + for item in session.workspace_parameter_update.get("parameter_patch", []): + if isinstance(item, dict) and isinstance(item.get("knob_id"), str): + session.workspace_parameter_values[item["knob_id"]] = item.get( + "value" + ) self._reset(session) - if '"status":"succeeded"' in message or '"status": "succeeded"' in message: + if succeeded: self._emit(session, "message", "Workspace parameters were saved.") else: self._emit(session, "message", "Workspace parameter update failed.") @@ -986,7 +1094,10 @@ def _handle_workspace_parameter_update_result(self, session: _Session, message: def _select_project_root(self, session: _Session, message: str) -> None: try: root = normalize_path(message, label="Project Root", require_directory=True) - if not session.creating_project and not (Path(root) / "project.json").is_file(): + if ( + not session.creating_project + and not (Path(root) / "project.json").is_file() + ): raise ValueError("Existing Project Root must contain project.json") session.workspace_inputs.project_root = root session.workspace_inputs.project_name = derive_project_name(root) @@ -1038,7 +1149,9 @@ def _select_workspace_name(self, session: _Session, message: str) -> None: try: self._update_workspace_setup( session, - design_name=normalize_identifier(bootstrap.design_name, label="Design Name"), + design_name=normalize_identifier( + bootstrap.design_name, label="Design Name" + ), ) except ValueError: pass @@ -1075,7 +1188,11 @@ def _select_flow_end(self, session: _Session, message: str) -> None: return self._update_workspace_setup(session, flow_start="Synthesis", flow_end=end_step) session.phase = "workspace_rtl" - self._emit(session, "message", rtl_prompt(session.language, _recommended_path(session, "rtl"))) + self._emit( + session, + "message", + rtl_prompt(session.language, _recommended_path(session, "rtl")), + ) self._emit_phase_choice(session) def _select_rtl(self, session: _Session, message: str) -> None: @@ -1088,7 +1205,9 @@ def _select_rtl(self, session: _Session, message: str) -> None: session, "RTL path", str(exc), - lambda language: rtl_prompt(language, _recommended_path(session, "rtl")), + lambda language: rtl_prompt( + language, _recommended_path(session, "rtl") + ), ) return self._apply_detected_defaults(session) @@ -1096,14 +1215,21 @@ def _select_rtl(self, session: _Session, message: str) -> None: self._emit( session, "message", - optional_file_prompt(session.language, "filelist", ".f", _recommended_path(session, "filelist")), + optional_file_prompt( + session.language, + "filelist", + ".f", + _recommended_path(session, "filelist"), + ), ) self._emit_phase_choice(session) def _select_filelist(self, session: _Session, message: str) -> None: try: session.workspace_inputs.filelist_path = optional_path( - resolve_emptyable_answer(message), label="Filelist path", suffixes=(".f",) + resolve_emptyable_answer(message), + label="Filelist path", + suffixes=(".f",), ) except ValueError as exc: self._repeat_invalid( @@ -1119,7 +1245,9 @@ def _select_filelist(self, session: _Session, message: str) -> None: self._emit( session, "message", - optional_file_prompt(session.language, "SDC", ".sdc", _recommended_path(session, "sdc")), + optional_file_prompt( + session.language, "SDC", ".sdc", _recommended_path(session, "sdc") + ), ) self._emit_phase_choice(session) @@ -1140,7 +1268,11 @@ def _select_sdc(self, session: _Session, message: str) -> None: return self._apply_detected_defaults(session) session.phase = "workspace_pdk" - self._emit(session, "message", pdk_prompt(session.language, _recommended_path(session, "pdk"))) + self._emit( + session, + "message", + pdk_prompt(session.language, _recommended_path(session, "pdk")), + ) self._emit_phase_choice(session) def _select_pdk(self, session: _Session, message: str) -> None: @@ -1148,28 +1280,30 @@ def _select_pdk(self, session: _Session, message: str) -> None: message = resolve_emptyable_answer(message) recommendation = session.path_recommendations.get("pdk") if not message and not recommendation: - raise ValueError("No local PDK recommendation was found; enter an existing PDK path") + raise ValueError( + "No local PDK recommendation was found; enter an existing PDK path" + ) session.workspace_inputs.pdk_root = normalize_path( - message or recommendation or "", label="PDK path", require_directory=True + message or recommendation or "", + label="PDK path", + require_directory=True, ) except ValueError as exc: self._repeat_invalid( session, "PDK path", str(exc), - lambda language: pdk_prompt(language, _recommended_path(session, "pdk")), + lambda language: pdk_prompt( + language, _recommended_path(session, "pdk") + ), ) return - session.phase = "workspace_top" - self._emit( - session, - "message", - default_value_prompt(session.language, "Top Module Name", session.workspace_setup.top_module), - ) - self._emit_phase_choice(session) + self._begin_clock_prompt(session) def _select_design_name(self, session: _Session, message: str) -> None: - recommendation = session.inherited_design_name or session.workspace_inputs.project_name or "" + recommendation = ( + session.inherited_design_name or session.workspace_inputs.project_name or "" + ) answer = resolve_emptyable_answer(message) or recommendation try: design = normalize_identifier(answer, label="Design Name") @@ -1187,23 +1321,16 @@ def _select_design_name(self, session: _Session, message: str) -> None: self._emit(session, "message", flow_end_prompt(session.language)) self._emit_phase_choice(session) - def _select_top_module(self, session: _Session, message: str) -> None: - message = resolve_emptyable_answer(message) - try: - top_module = ( - normalize_identifier(message, label="Top Module Name") - if message - else session.workspace_setup.top_module - ) - except ValueError as exc: - self._repeat_setup_default(session, "Top Module Name", str(exc)) - return - self._update_workspace_setup(session, top_module=top_module) + def _begin_clock_prompt(self, session: _Session) -> None: session.phase = "workspace_clock" self._emit( session, "message", - default_value_prompt(session.language, "Clock Signal Name", session.workspace_setup.clock_name), + default_value_prompt( + session.language, + "Clock Signal Name", + session.workspace_setup.clock_name, + ), ) self._emit_phase_choice(session) @@ -1234,7 +1361,9 @@ def _select_clock(self, session: _Session, message: str) -> None: self._emit_phase_choice(session) def _select_frequency(self, session: _Session, message: str) -> None: - value = self._number_or_repeat(session, message, "Frequency Max (MHz)", 1, 10_000) + value = self._number_or_repeat( + session, message, "Frequency Max (MHz)", 1, 10_000 + ) if value is None: return self._update_workspace_setup(session, frequency_mhz=value) @@ -1242,7 +1371,13 @@ def _select_frequency(self, session: _Session, message: str) -> None: self._emit( session, "message", - number_prompt(session.language, "Max Fanout", session.workspace_setup.max_fanout, 1, 1_000_000), + number_prompt( + session.language, + "Max Fanout", + session.workspace_setup.max_fanout, + 1, + 1_000_000, + ), ) self._emit_phase_choice(session) @@ -1255,12 +1390,20 @@ def _select_max_fanout(self, session: _Session, message: str) -> None: self._emit( session, "message", - number_prompt(session.language, "Die Area Utilization", session.workspace_setup.utilitization, 0.01, 1), + number_prompt( + session.language, + "Die Area Utilization", + session.workspace_setup.utilitization, + 0.01, + 1, + ), ) self._emit_phase_choice(session) def _select_utilization(self, session: _Session, message: str) -> None: - value = self._number_or_repeat(session, message, "Die Area Utilization", 0.01, 1) + value = self._number_or_repeat( + session, message, "Die Area Utilization", 0.01, 1 + ) if value is None: return self._update_workspace_setup(session, utilitization=value) @@ -1268,12 +1411,20 @@ def _select_utilization(self, session: _Session, message: str) -> None: self._emit( session, "message", - number_prompt(session.language, "Placement Target Density", session.workspace_setup.target_density, 0.01, 1), + number_prompt( + session.language, + "Placement Target Density", + session.workspace_setup.target_density, + 0.01, + 1, + ), ) self._emit_phase_choice(session) def _select_density(self, session: _Session, message: str) -> None: - value = self._number_or_repeat(session, message, "Placement Target Density", 0.01, 1) + value = self._number_or_repeat( + session, message, "Placement Target Density", 0.01, 1 + ) if value is None: return self._update_workspace_setup(session, target_density=value) @@ -1281,12 +1432,20 @@ def _select_density(self, session: _Session, message: str) -> None: self._emit( session, "message", - number_prompt(session.language, "Placement Target Overflow", session.workspace_setup.target_overflow, 0, 1), + number_prompt( + session.language, + "Placement Target Overflow", + session.workspace_setup.target_overflow, + 0, + 1, + ), ) self._emit_phase_choice(session) def _select_overflow(self, session: _Session, message: str) -> None: - value = self._number_or_repeat(session, message, "Placement Target Overflow", 0, 1) + value = self._number_or_repeat( + session, message, "Placement Target Overflow", 0, 1 + ) if value is None: return self._update_workspace_setup(session, target_overflow=value) @@ -1296,7 +1455,11 @@ def _select_rerun_design(self, session: _Session, message: str) -> None: try: design = normalize_identifier(message, label="Design Name") except ValueError as exc: - self._emit(session, "message", invalid_value(session.language, "Design Name", str(exc))) + self._emit( + session, + "message", + invalid_value(session.language, "Design Name", str(exc)), + ) self._emit(session, "message", rerun_design_prompt(session.language)) return session.design_id = design @@ -1330,7 +1493,9 @@ def _select_rerun_workspace(self, session: _Session, message: str) -> None: session, "message", invalid_value( - session.language, "Rerun workspace", "an existing workspace path is required" + session.language, + "Rerun workspace", + "an existing workspace path is required", ), ) self._emit( @@ -1342,9 +1507,14 @@ def _select_rerun_workspace(self, session: _Session, message: str) -> None: return try: source = Path( - normalize_path(workspace_path, label="Rerun workspace", require_directory=True) + normalize_path( + workspace_path, label="Rerun workspace", require_directory=True + ) + ) + resolver = GuiWorkspaceRerunResolver( + source.parent, + session.workspace_parameter_values, ) - resolver = GuiWorkspaceRerunResolver(source.parent) discovery = resolver.discover_workspace(source, design) except ValueError as exc: self._emit( @@ -1362,19 +1532,30 @@ def _select_rerun_workspace(self, session: _Session, message: str) -> None: session.rerun_resolver = resolver session.rerun_discovery = discovery session.phase = "rerun_stage" - self._emit(session, "message", rerun_stage_prompt(session.language, discovery.allowed_stages)) + self._emit( + session, + "message", + rerun_stage_prompt(session.language, discovery.allowed_stages), + ) self._emit_phase_choice(session) def _select_rerun_stage(self, session: _Session, message: str) -> None: resolver = _rerun_resolver(session) discovery = session.rerun_discovery - stage = None if discovery is None else numbered_choice(message, discovery.allowed_stages) + stage = ( + None + if discovery is None + else numbered_choice(message, discovery.allowed_stages) + ) if stage is None: self._emit(session, "message", invalid_choice(session.language)) self._emit( session, "message", - rerun_stage_prompt(session.language, () if discovery is None else discovery.allowed_stages), + rerun_stage_prompt( + session.language, + () if discovery is None else discovery.allowed_stages, + ), ) self._emit_phase_choice(session) return @@ -1387,9 +1568,7 @@ def _select_rerun_stage(self, session: _Session, message: str) -> None: self._emit( session, "message", - rerun_no_parameters_prompt( - session.language, catalog_end_step().value - ), + rerun_no_parameters_prompt(session.language, catalog_end_step().value), ) self._emit_phase_choice(session) return @@ -1414,11 +1593,11 @@ def _select_rerun_parameter(self, session: _Session, message: str) -> None: session.rerun_parameter_patch = [] else: try: - parameter_values = resolver.parameter_values( - discovery.source, stage - ) + parameter_values = resolver.parameter_values(discovery.source, stage) if not parameter_values: - raise ValueError("No config-backed parameters are available for this rerun stage") + raise ValueError( + "No config-backed parameters are available for this rerun stage" + ) allowed_knobs = [knob_id for knob_id, _ in parameter_values] proposal = GuiWorkspaceRerunParameterProposal.model_validate( self.rerun_parameter_parser( @@ -1427,21 +1606,32 @@ def _select_rerun_parameter(self, session: _Session, message: str) -> None: "natural_language_request": message, "target_step": stage, "allowed_knobs": allowed_knobs, - "boolean_knobs": sorted(set(allowed_knobs) & BOOLEAN_RERUN_KNOBS), + "boolean_knobs": sorted( + set(allowed_knobs) & BOOLEAN_RERUN_KNOBS + ), "workspace": str(discovery.source.workspace_path), - "_progress_callback": lambda text: self._progress(session, text), - "_register_interrupt": lambda callback: self._register_interrupt(session, callback), + "_progress_callback": lambda text: self._progress( + session, text + ), + "_register_interrupt": lambda callback: self._register_interrupt( + session, callback + ), } ) ) self._check_interrupted(session) resolver._validate_patch( - stage, [item.model_dump(mode="json") for item in proposal.parameter_patch] + stage, + [item.model_dump(mode="json") for item in proposal.parameter_patch], ) except (CodexProviderError, ValueError) as exc: self._check_interrupted(session) self._raise_if_interrupted(exc) - self._emit(session, "error", f"Unable to validate the rerun parameter change: {exc}") + self._emit( + session, + "error", + f"Unable to validate the rerun parameter change: {exc}", + ) self._emit( session, "message", @@ -1452,16 +1642,23 @@ def _select_rerun_parameter(self, session: _Session, message: str) -> None: ) self._emit_phase_choice(session) return - session.rerun_parameter_patch = [item.model_dump(mode="json") for item in proposal.parameter_patch] + session.rerun_parameter_patch = [ + item.model_dump(mode="json") for item in proposal.parameter_patch + ] if session.rerun_parameter_patch: effective_values = dict(parameter_values) effective_values.update( - {item["knob_id"]: item["value"] for item in session.rerun_parameter_patch} + { + item["knob_id"]: item["value"] + for item in session.rerun_parameter_patch + } ) self._emit( session, "message", - rerun_parameter_prompt(session.language, tuple(sorted(effective_values.items()))), + rerun_parameter_prompt( + session.language, tuple(sorted(effective_values.items())) + ), ) session.phase = "rerun_scope" self._emit( @@ -1474,7 +1671,11 @@ def _select_rerun_parameter(self, session: _Session, message: str) -> None: def _select_rerun_scope(self, session: _Session, message: str) -> None: resolver = _rerun_resolver(session) scope = numbered_choice(message, ("single_step", "full_flow")) - if scope is None or session.rerun_discovery is None or session.rerun_stage is None: + if ( + scope is None + or session.rerun_discovery is None + or session.rerun_stage is None + ): self._emit(session, "message", invalid_choice(session.language)) self._emit( session, @@ -1517,14 +1718,18 @@ def _show_workspace_contract(self, session: _Session) -> None: session.workspace_setup_id, ) except ValueError as exc: - self._emit(session, "message", invalid_value(session.language, "Workspace specification", str(exc))) - session.phase = "workspace_top" if "Top Module" in str(exc) else "workspace_project_root" self._emit( session, "message", - default_value_prompt(session.language, "Top Module Name", session.workspace_setup.top_module) - if session.phase == "workspace_top" - else project_root_prompt(session.language, creating=session.creating_project), + invalid_value(session.language, "Workspace specification", str(exc)), + ) + session.phase = "workspace_project_root" + self._emit( + session, + "message", + project_root_prompt( + session.language, creating=session.creating_project + ), ) self._emit_phase_choice(session) return @@ -1541,7 +1746,9 @@ def _show_workspace_contract(self, session: _Session) -> None: def _confirm_workspace_execution(self, session: _Session, message: str) -> None: _confirm_workspace_execution(self, session, message) - def _handle_workspace_creation_result(self, session: _Session, message: str) -> None: + def _handle_workspace_creation_result( + self, session: _Session, message: str + ) -> None: result = _workspace_creation_result(message) if result is None or result[0] != session.workspace_setup_id: self._emit(session, "error", "Workspace creation result is invalid.") @@ -1549,7 +1756,9 @@ def _handle_workspace_creation_result(self, session: _Session, message: str) -> _, status, error = result if status == "succeeded": session.mode = "workspace" - if session.workspace_contract and isinstance(session.workspace_contract, dict): + if session.workspace_contract and isinstance( + session.workspace_contract, dict + ): directory = session.workspace_contract.get("directory") if isinstance(directory, str) and directory.strip(): session.rerun_workspace_path = directory @@ -1612,7 +1821,9 @@ def _number_or_repeat( current = _number_default(session.workspace_setup, label) message = resolve_emptyable_answer(message) try: - return parse_number(message, label=label, lower=lower, upper=upper, default=current) + return parse_number( + message, label=label, lower=lower, upper=upper, default=current + ) except ValueError: pass try: @@ -1627,11 +1838,23 @@ def _number_or_repeat( "numeric_bounds": {"lower": lower, "upper": upper}, "default_value": current, "natural_language_choice": message, - "recommended_defaults": session.workspace_setup.model_dump(mode="json"), - "workspace_inputs": _workspace_inputs_payload(session.workspace_inputs), - "filesystem_roots": list(workspace_search_roots(session.workspace_inputs.project_root)), - "_progress_callback": lambda text: self._progress(session, text), - "_register_interrupt": lambda callback: self._register_interrupt(session, callback), + "recommended_defaults": session.workspace_setup.model_dump( + mode="json" + ), + "workspace_inputs": _workspace_inputs_payload( + session.workspace_inputs + ), + "filesystem_roots": list( + workspace_search_roots( + session.workspace_inputs.project_root + ) + ), + "_progress_callback": lambda text: self._progress( + session, text + ), + "_register_interrupt": lambda callback: self._register_interrupt( + session, callback + ), } ) ) @@ -1639,16 +1862,26 @@ def _number_or_repeat( value = getattr(proposal, field) if value is None: raise ValueError("Codex did not provide a value for this field") - return parse_number(str(value), label=label, lower=lower, upper=upper, default=current) + return parse_number( + str(value), label=label, lower=lower, upper=upper, default=current + ) except (CodexProviderError, ValueError) as exc: self._check_interrupted(session) self._raise_if_interrupted(exc) self._emit( session, "message", - invalid_value(session.language, label, "Unable to interpret a valid in-range value"), + invalid_value( + session.language, + label, + "Unable to interpret a valid in-range value", + ), + ) + self._emit( + session, + "message", + number_prompt(session.language, label, current, lower, upper), ) - self._emit(session, "message", number_prompt(session.language, label, current, lower, upper)) self._emit_phase_choice(session) return None @@ -1656,22 +1889,26 @@ def _repeat_setup_default(self, session: _Session, label: str, error: str) -> No self._emit(session, "message", invalid_value(session.language, label, error)) values = { "Design Name": session.workspace_setup.design_name, - "Top Module Name": session.workspace_setup.top_module, "Clock Signal Name": session.workspace_setup.clock_name, } - self._emit(session, "message", default_value_prompt(session.language, label, values[label])) + self._emit( + session, + "message", + default_value_prompt(session.language, label, values[label]), + ) self._emit_phase_choice(session) - def _repeat_invalid(self, session: _Session, label: str, error: str, prompt) -> None: + def _repeat_invalid( + self, session: _Session, label: str, error: str, prompt + ) -> None: self._emit(session, "message", invalid_value(session.language, label, error)) self._emit(session, "message", prompt(session.language)) self._emit_phase_choice(session) def _apply_detected_defaults(self, session: _Session) -> None: - defaults = infer_design_defaults( + defaults = infer_clock_and_frequency_defaults( session.workspace_inputs.rtl_path, session.workspace_inputs.sdc_path, - session.workspace_setup.design_name or "", ) self._update_workspace_setup(session, **defaults) @@ -1681,17 +1918,28 @@ def _corrected_workspace_state( setup = merge_workspace_setup(session.workspace_setup, proposal, "spec") inputs = merge_workspace_inputs(session.workspace_inputs, proposal) _validate_workspace_input_roots( - proposal, inputs, workspace_search_roots(session.workspace_inputs.project_root), message + proposal, + inputs, + workspace_search_roots(session.workspace_inputs.project_root), + message, ) if proposal.rtl_path is None and proposal.sdc_path is None: return setup, inputs - defaults = infer_design_defaults(inputs.rtl_path, inputs.sdc_path, setup.design_name or "") - updates = {key: value for key, value in defaults.items() if getattr(proposal, key) is None} - return GuiWorkspaceSetupProposal.model_validate({**setup.model_dump(mode="json"), **updates}), inputs + defaults = infer_clock_and_frequency_defaults(inputs.rtl_path, inputs.sdc_path) + updates = { + key: value + for key, value in defaults.items() + if getattr(proposal, key) is None + } + return GuiWorkspaceSetupProposal.model_validate( + {**setup.model_dump(mode="json"), **updates} + ), inputs def _discover_design_paths(self, session: _Session) -> None: roots = workspace_search_roots(session.workspace_inputs.project_root) - candidates = discover_design_file_candidates(session.workspace_setup.design_name or "", roots) + candidates = discover_design_file_candidates( + session.workspace_setup.design_name or "", roots + ) try: proposal = GuiWorkspaceSetupProposal.model_validate( self.workspace_path_recommender( @@ -1701,20 +1949,30 @@ def _discover_design_paths(self, session: _Session) -> None: "project_root": session.workspace_inputs.project_root, "filesystem_roots": list(roots), "discovered_candidates": candidates, - "_progress_callback": lambda text: self._progress(session, text), - "_register_interrupt": lambda callback: self._register_interrupt(session, callback), + "_progress_callback": lambda text: self._progress( + session, text + ), + "_register_interrupt": lambda callback: self._register_interrupt( + session, callback + ), } ) ) self._check_interrupted(session) - session.path_recommendations.update(_validated_path_recommendations(proposal, roots)) + session.path_recommendations.update( + _validated_path_recommendations(proposal, roots) + ) except (CodexProviderError, ValueError) as exc: self._check_interrupted(session) self._raise_if_interrupted(exc) session.path_recommendations = { - field: path for field, path in session.path_recommendations.items() if field == "pdk" + field: path + for field, path in session.path_recommendations.items() + if field == "pdk" } - self._emit(session, "error", f"Unable to discover local design files: {exc}") + self._emit( + session, "error", f"Unable to discover local design files: {exc}" + ) def _update_workspace_setup(self, session: _Session, **updates: Any) -> None: payload = session.workspace_setup.model_dump(mode="json") @@ -1810,8 +2068,12 @@ def _emit_phase_choice(self, session: _Session) -> None: prompt_id, tuple(session.known_projects), ) - elif session.phase == "workspace_name" and session.workspace_inputs.project_root: - recommendation = recommended_workspace_name(session.workspace_inputs.project_root) + elif ( + session.phase == "workspace_name" and session.workspace_inputs.project_root + ): + recommendation = recommended_workspace_name( + session.workspace_inputs.project_root + ) choice = default_value_choice( session.language, prompt_id, @@ -1822,7 +2084,9 @@ def _emit_phase_choice(self, session: _Session) -> None: session.inherited_design_name or session.workspace_inputs.project_name ): recommendation = ( - session.inherited_design_name or session.workspace_inputs.project_name or "" + session.inherited_design_name + or session.workspace_inputs.project_name + or "" ) choice = default_value_choice( session.language, @@ -1882,13 +2146,6 @@ def _emit_phase_choice(self, session: _Session) -> None: recommendation, field="PDK", ) - elif session.phase == "workspace_top": - choice = default_value_choice( - session.language, - prompt_id, - "Top Module Name", - session.workspace_setup.top_module, - ) elif session.phase == "workspace_clock": choice = default_value_choice( session.language, @@ -1932,13 +2189,17 @@ def _emit_phase_choice(self, session: _Session) -> None: session.workspace_setup.target_overflow, ) elif session.phase == "workspace_confirmation": - choice = confirmation_choice(session.language, prompt_id, allow_free_text=True) + choice = confirmation_choice( + session.language, prompt_id, allow_free_text=True + ) elif session.phase in { "confirmation", "workspace_continue_confirmation", "workspace_parameter_confirmation", }: - choice = confirmation_choice(session.language, prompt_id, allow_free_text=False) + choice = confirmation_choice( + session.language, prompt_id, allow_free_text=False + ) if choice is not None: self._emit(session, "choice", choice["title"], choice=choice) @@ -1953,7 +2214,9 @@ def _progress(self, session: _Session, text: str) -> None: ) @staticmethod - def _register_interrupt(session: _Session, callback: Callable[[], None] | None) -> None: + def _register_interrupt( + session: _Session, callback: Callable[[], None] | None + ) -> None: session.active_interrupt = callback if callback is not None and session.interrupt_requested: callback() @@ -1961,11 +2224,16 @@ def _register_interrupt(session: _Session, callback: Callable[[], None] | None) @staticmethod def _check_interrupted(session: _Session) -> None: if session.interrupt_requested: - raise CodexProviderError("Agent turn interrupted", failure_class="interrupted") + raise CodexProviderError( + "Agent turn interrupted", failure_class="interrupted" + ) @staticmethod def _raise_if_interrupted(error: Exception) -> None: - if isinstance(error, CodexProviderError) and error.failure_class == "interrupted": + if ( + isinstance(error, CodexProviderError) + and error.failure_class == "interrupted" + ): raise error @staticmethod @@ -1981,11 +2249,14 @@ def _resting_status(session: _Session) -> str: else "idle" ) if session.phase == "workspace_name": - return "awaiting_choice" if session.workspace_inputs.project_root else "idle" + return ( + "awaiting_choice" if session.workspace_inputs.project_root else "idle" + ) if session.phase == "workspace_design": return ( "awaiting_choice" - if session.inherited_design_name or session.workspace_inputs.project_name + if session.inherited_design_name + or session.workspace_inputs.project_name else "idle" ) if session.phase == "rerun_workspace": @@ -2006,7 +2277,6 @@ def _resting_status(session: _Session) -> str: "workspace_filelist", "workspace_sdc", "workspace_pdk", - "workspace_top", "workspace_clock", "workspace_frequency", "workspace_max_fanout", diff --git a/ecos/agent/src/ecos_agent/provider_support.py b/ecos/agent/src/ecos_agent/provider_support.py index f92d19668..76564052f 100644 --- a/ecos/agent/src/ecos_agent/provider_support.py +++ b/ecos/agent/src/ecos_agent/provider_support.py @@ -40,7 +40,6 @@ workspace_name_prompt, workspace_parameter_request_prompt, ) -from ecos_agent.ecc_contracts import ECCStepName from ecos_agent.knob_registry import KNOB_SPECS from ecos_agent.workspace_rerun import ( GuiWorkspaceRerunContract, @@ -67,7 +66,11 @@ _WORKSPACE_RERUN_RESULT_PREFIX = "workspace_rerun_result:" _PATH_FIELD_HINTS: tuple[tuple[tuple[str, ...], str, str], ...] = ( (("pdk", "工艺库", "工艺"), "pdk_root", "directory"), - (("project root", "project_root", "项目根", "project"), "project_root", "directory"), + ( + ("project root", "project_root", "项目根", "project"), + "project_root", + "directory", + ), (("rtl", "verilog", ".v", "网表"), "rtl_path", "rtl"), (("filelist", "文件列表", ".f"), "filelist_path", "filelist"), (("sdc", "约束"), "sdc_path", "sdc"), @@ -116,13 +119,21 @@ def _confirm_workspace_execution(provider: Any, session: Any, message: str) -> N "schema_version": "flow-agent.gui_workspace_setup_context.v2", "natural_language_choice": message, "stage": "spec", - "recommended_defaults": session.workspace_setup.model_dump(mode="json"), - "workspace_inputs": _workspace_inputs_payload(session.workspace_inputs), + "recommended_defaults": session.workspace_setup.model_dump( + mode="json" + ), + "workspace_inputs": _workspace_inputs_payload( + session.workspace_inputs + ), "filesystem_roots": list( - workspace_search_roots(session.workspace_inputs.project_root) + workspace_search_roots( + session.workspace_inputs.project_root + ) ), "explicit_paths": _explicit_path_tokens(message), - "_progress_callback": lambda text: provider._progress(session, text), + "_progress_callback": lambda text: provider._progress( + session, text + ), "_register_interrupt": lambda callback: provider._register_interrupt( session, callback ), @@ -142,8 +153,12 @@ def _confirm_workspace_execution(provider: Any, session: Any, message: str) -> N except (CodexProviderError, ValueError) as exc: provider._check_interrupted(session) provider._raise_if_interrupted(exc) - provider._emit(session, "error", f"Unable to correct the workspace specification: {exc}") - provider._emit(session, "message", workspace_confirmation_prompt(session.language)) + provider._emit( + session, "error", f"Unable to correct the workspace specification: {exc}" + ) + provider._emit( + session, "message", workspace_confirmation_prompt(session.language) + ) provider._emit_phase_choice(session) return session.workspace_setup = corrected_setup @@ -161,7 +176,11 @@ def _handle_workspace_rerun_result(provider: Any, session: Any, message: str) -> provider._emit(session, "error", "Workspace rerun contract is missing.") return if result[0] != contract.rerun_id: - provider._emit(session, "error", "Workspace rerun result does not match the pending contract.") + provider._emit( + session, + "error", + "Workspace rerun result does not match the pending contract.", + ) return _, status, error = result if status == "succeeded": @@ -181,21 +200,14 @@ def _rerun_resolver(session: Any) -> GuiWorkspaceRerunResolver: return session.rerun_resolver -def _tunable_workspace_parameters(workspace_path: Path) -> tuple[tuple[str, object], ...]: - """Every readable knob in the workspace. - - Rerun needs completed-stage evidence; changing a parameter does not, so this - spans all steps rather than only the ones that have already run. - """ - merged: dict[str, object] = {} - for step in ECCStepName: - try: - values = GuiWorkspaceRerunResolver.stage_parameter_values(workspace_path, step.value) - except ValueError: - continue - for knob_id, value in values: - merged.setdefault(knob_id, value) - return tuple(merged.items()) +def _tunable_workspace_parameters( + current_values: Mapping[str, object], +) -> tuple[tuple[str, object], ...]: + return tuple( + (knob_id, value) + for knob_id, value in current_values.items() + if knob_id in KNOB_SPECS + ) def _validate_workspace_parameter_patch( @@ -217,22 +229,32 @@ def _validate_workspace_parameter_patch( def _propose_gui_workspace_setup(context: dict[str, Any]) -> GuiWorkspaceSetupProposal: - progress_callback, register_interrupt, request_context = _gui_workspace_request_context(context) + progress_callback, register_interrupt, request_context = ( + _gui_workspace_request_context(context) + ) provider = _gui_workspace_codex_provider(request_context, progress_callback) register_interrupt(provider.interrupt) try: - return GuiWorkspaceSetupProposal.model_validate(provider.propose_gui_workspace_setup(request_context)) + return GuiWorkspaceSetupProposal.model_validate( + provider.propose_gui_workspace_setup(request_context) + ) finally: register_interrupt(None) provider.close() -def _propose_gui_workspace_path_discovery(context: dict[str, Any]) -> GuiWorkspaceSetupProposal: - progress_callback, register_interrupt, request_context = _gui_workspace_request_context(context) +def _propose_gui_workspace_path_discovery( + context: dict[str, Any], +) -> GuiWorkspaceSetupProposal: + progress_callback, register_interrupt, request_context = ( + _gui_workspace_request_context(context) + ) provider = _gui_workspace_codex_provider(request_context, progress_callback) register_interrupt(provider.interrupt) try: - return GuiWorkspaceSetupProposal.model_validate(provider.propose_gui_workspace_path_discovery(request_context)) + return GuiWorkspaceSetupProposal.model_validate( + provider.propose_gui_workspace_path_discovery(request_context) + ) finally: register_interrupt(None) provider.close() @@ -241,13 +263,19 @@ def _propose_gui_workspace_path_discovery(context: dict[str, Any]) -> GuiWorkspa def _propose_gui_workspace_rerun_patch( context: dict[str, Any], ) -> GuiWorkspaceRerunParameterProposal: - progress_callback, register_interrupt, request_context = _gui_workspace_request_context(context) + progress_callback, register_interrupt, request_context = ( + _gui_workspace_request_context(context) + ) workspace = request_context.get("workspace") if not isinstance(workspace, str) or not workspace: - raise CodexProviderError("GUI rerun workspace is missing", failure_class="missing_input") + raise CodexProviderError( + "GUI rerun workspace is missing", failure_class="missing_input" + ) source = Path(workspace).resolve() if not source.is_dir(): - raise CodexProviderError("GUI rerun workspace is unavailable", failure_class="missing_input") + raise CodexProviderError( + "GUI rerun workspace is unavailable", failure_class="missing_input" + ) provider = create_required_codex_provider( cwd=source, runtime_workspace_roots=(source,), @@ -265,7 +293,11 @@ def _propose_gui_workspace_rerun_patch( def _gui_workspace_request_context( context: Mapping[str, Any], -) -> tuple[Callable[[str], None] | None, Callable[[Callable[[], None] | None], None], dict[str, Any]]: +) -> tuple[ + Callable[[str], None] | None, + Callable[[Callable[[], None] | None], None], + dict[str, Any], +]: callback = context.get("_progress_callback") register_interrupt = context.get("_register_interrupt") return ( @@ -285,10 +317,14 @@ def _gui_workspace_codex_provider( else context.get("project_root") ) if not isinstance(project_root, str): - raise CodexProviderError("GUI workspace filesystem roots are missing", failure_class="missing_input") + raise CodexProviderError( + "GUI workspace filesystem roots are missing", failure_class="missing_input" + ) roots = workspace_search_roots(project_root) return create_required_codex_provider( - cwd=Path(roots[0]), runtime_workspace_roots=roots, progress_callback=progress_callback + cwd=Path(roots[0]), + runtime_workspace_roots=roots, + progress_callback=progress_callback, ) @@ -306,9 +342,15 @@ def _validated_path_recommendations( proposal: GuiWorkspaceSetupProposal, roots: tuple[str, ...] ) -> dict[str, str]: recommendations = { - "rtl": _validated_recommendation(proposal.rtl_path, "RTL path", (".v", ".sv"), roots), - "filelist": _validated_recommendation(proposal.filelist_path, "Filelist path", (".f",), roots), - "sdc": _validated_recommendation(proposal.sdc_path, "SDC path", (".sdc",), roots), + "rtl": _validated_recommendation( + proposal.rtl_path, "RTL path", (".v", ".sv"), roots + ), + "filelist": _validated_recommendation( + proposal.filelist_path, "Filelist path", (".f",), roots + ), + "sdc": _validated_recommendation( + proposal.sdc_path, "SDC path", (".sdc",), roots + ), } return {field: path for field, path in recommendations.items() if path is not None} @@ -321,12 +363,17 @@ def _validated_recommendation( path = normalize_path(value, label=label, suffixes=suffixes, require_file=True) resolved = Path(path) if not any(resolved.is_relative_to(Path(root)) for root in roots): - raise ValueError(f"{label} recommendation is outside the authorized filesystem roots") + raise ValueError( + f"{label} recommendation is outside the authorized filesystem roots" + ) return path def _validate_workspace_input_roots( - proposal: GuiWorkspaceSetupProposal, inputs: WorkspaceInputs, roots: tuple[str, ...], message: str + proposal: GuiWorkspaceSetupProposal, + inputs: WorkspaceInputs, + roots: tuple[str, ...], + message: str, ) -> None: path_updates = { "project_root": inputs.project_root, @@ -336,9 +383,11 @@ def _validate_workspace_input_roots( "pdk_root": inputs.pdk_root, } for field, path in path_updates.items(): - if getattr(proposal, field) is not None and not any( - Path(path).is_relative_to(Path(root)) for root in roots - ) and not _path_was_explicitly_provided(message, path): + if ( + getattr(proposal, field) is not None + and not any(Path(path).is_relative_to(Path(root)) for root in roots) + and not _path_was_explicitly_provided(message, path) + ): raise ValueError(f"{field} is outside the authorized filesystem roots") @@ -395,7 +444,8 @@ def _message_has_hint(text: str, hint: str) -> bool: return False if hint.isascii() and hint.isalpha() and len(hint) <= 4: return ( - re.search(rf"(? dict[str, str] continue if kind == "directory" and candidate.is_dir(): token_matches.append((field, resolved)) - elif kind == "rtl" and candidate.is_file() and resolved.lower().endswith((".v", ".sv")): + elif ( + kind == "rtl" + and candidate.is_file() + and resolved.lower().endswith((".v", ".sv")) + ): token_matches.append((field, resolved)) - elif kind == "filelist" and candidate.is_file() and resolved.lower().endswith(".f"): + elif ( + kind == "filelist" + and candidate.is_file() + and resolved.lower().endswith(".f") + ): token_matches.append((field, resolved)) - elif kind == "sdc" and candidate.is_file() and resolved.lower().endswith(".sdc"): + elif ( + kind == "sdc" + and candidate.is_file() + and resolved.lower().endswith(".sdc") + ): token_matches.append((field, resolved)) unique_for_token = list(dict.fromkeys(token_matches)) if len(unique_for_token) > 1: @@ -468,7 +530,9 @@ def _deterministic_path_field_updates(text: str, message: str) -> dict[str, str] return by_field -def _deterministic_number_field_updates(text: str, message: str) -> dict[str, float] | None: +def _deterministic_number_field_updates( + text: str, message: str +) -> dict[str, float] | None: """Return number updates, or None when a number is ambiguous across fields.""" numbers = [float(match.group(1)) for match in _NUMBER_TOKEN.finditer(message)] if not numbers: @@ -525,7 +589,9 @@ def _workspace_rerun_execution_contract( parameter_values: tuple[tuple[str, object], ...], ) -> dict[str, Any]: effective_values = dict(parameter_values) - effective_values.update({item.knob_id: item.value for item in contract.parameter_patch}) + effective_values.update( + {item.knob_id: item.value for item in contract.parameter_patch} + ) parameter_fields = [ {"label": knob_id, "value": str(value)} for knob_id, value in sorted(effective_values.items()) @@ -541,10 +607,7 @@ def _workspace_rerun_execution_contract( scope = ( "只重跑所选阶段,然后停止" if contract.execution_scope == "single_step" - else ( - f"从所选阶段重跑,并继续到标准流程终点" - f"({contract.end_step.value})" - ) + else (f"从所选阶段重跑,并继续到标准流程终点({contract.end_step.value})") ) fields = [ {"label": "Design", "value": contract.design_id}, @@ -582,6 +645,7 @@ def _workspace_rerun_execution_contract( "schema_version": "flow-agent.resolved_execution_contract.v1", "title": title, "fields": fields, + "workspace_rerun": contract.model_dump(mode="json"), } @@ -605,7 +669,9 @@ def _prompt_for_phase(session: Any) -> str: ), "rerun_stage": rerun_stage_prompt( session.language, - () if session.rerun_discovery is None else session.rerun_discovery.allowed_stages, + () + if session.rerun_discovery is None + else session.rerun_discovery.allowed_stages, ), "rerun_parameter": rerun_parameter_prompt( session.language, @@ -615,9 +681,7 @@ def _prompt_for_phase(session: Any) -> str: session.rerun_discovery.source, session.rerun_stage ), ), - "rerun_scope": rerun_scope_prompt( - session.language, catalog_end_step().value - ), + "rerun_scope": rerun_scope_prompt(session.language, catalog_end_step().value), "workspace_project_mode": project_mode_prompt(session.language), "workspace_project_root": project_root_prompt( session.language, creating=session.creating_project @@ -630,26 +694,65 @@ def _prompt_for_phase(session: Any) -> str: ), "workspace_design": design_name_prompt( session.language, - session.inherited_design_name or session.workspace_inputs.project_name or "", + session.inherited_design_name + or session.workspace_inputs.project_name + or "", ), "workspace_flow_end": flow_end_prompt(session.language), - "workspace_rtl": rtl_prompt(session.language, _recommended_path(session, "rtl")), + "workspace_rtl": rtl_prompt( + session.language, _recommended_path(session, "rtl") + ), "workspace_filelist": optional_file_prompt( session.language, "filelist", ".f", _recommended_path(session, "filelist") ), "workspace_sdc": optional_file_prompt( session.language, "SDC", ".sdc", _recommended_path(session, "sdc") ), - "workspace_pdk": pdk_prompt(session.language, _recommended_path(session, "pdk")), - "workspace_top": default_value_prompt(session.language, "Top Module Name", session.workspace_setup.top_module), - "workspace_clock": default_value_prompt(session.language, "Clock Signal Name", session.workspace_setup.clock_name), - "workspace_frequency": number_prompt(session.language, "Frequency Max (MHz)", session.workspace_setup.frequency_mhz, 1, 10_000), - "workspace_max_fanout": number_prompt(session.language, "Max Fanout", session.workspace_setup.max_fanout, 1, 1_000_000), - "workspace_utilization": number_prompt(session.language, "Die Area Utilization", session.workspace_setup.utilitization, 0.01, 1), - "workspace_density": number_prompt(session.language, "Placement Target Density", session.workspace_setup.target_density, 0.01, 1), - "workspace_overflow": number_prompt(session.language, "Placement Target Overflow", session.workspace_setup.target_overflow, 0, 1), + "workspace_pdk": pdk_prompt( + session.language, _recommended_path(session, "pdk") + ), + "workspace_clock": default_value_prompt( + session.language, "Clock Signal Name", session.workspace_setup.clock_name + ), + "workspace_frequency": number_prompt( + session.language, + "Frequency Max (MHz)", + session.workspace_setup.frequency_mhz, + 1, + 10_000, + ), + "workspace_max_fanout": number_prompt( + session.language, + "Max Fanout", + session.workspace_setup.max_fanout, + 1, + 1_000_000, + ), + "workspace_utilization": number_prompt( + session.language, + "Die Area Utilization", + session.workspace_setup.utilitization, + 0.01, + 1, + ), + "workspace_density": number_prompt( + session.language, + "Placement Target Density", + session.workspace_setup.target_density, + 0.01, + 1, + ), + "workspace_overflow": number_prompt( + session.language, + "Placement Target Overflow", + session.workspace_setup.target_overflow, + 0, + 1, + ), "workspace_confirmation": workspace_confirmation_prompt(session.language), - "workspace_parameter_request": workspace_parameter_request_prompt(session.language), + "workspace_parameter_request": workspace_parameter_request_prompt( + session.language + ), "confirmation": confirmation_menu(session.language), } return prompts.get(session.phase, operation_prompt(session.language)) @@ -669,7 +772,7 @@ def _number_default(proposal: GuiWorkspaceSetupProposal, label: str) -> float: return value -def _recommended_path(session: _Session, field: str) -> str: +def _recommended_path(session: Any, field: str) -> str: recommendation = session.path_recommendations.get(field, "") return display_path(recommendation) if recommendation else "" @@ -763,7 +866,13 @@ def _extract_create_bootstrap(message: str) -> CreateBootstrap: creating: bool | None = None if any( key in text - for key in ("新建 project", "create project", "new project", "创建项目", "新建项目") + for key in ( + "新建 project", + "create project", + "new project", + "创建项目", + "新建项目", + ) ): creating = True elif any( @@ -780,9 +889,13 @@ def _extract_create_bootstrap(message: str) -> CreateBootstrap: has_manifest = (candidate / "project.json").is_file() if creating is False and not has_manifest: continue - if creating is True or has_manifest or any( - _message_has_hint(text, hint) - for hint in ("project root", "project_root", "项目根", "project") + if ( + creating is True + or has_manifest + or any( + _message_has_hint(text, hint) + for hint in ("project root", "project_root", "项目根", "project") + ) ): project_root = str(candidate) if creating is None and has_manifest: @@ -841,9 +954,15 @@ def _allowed_operation_options( def _propose_gui_chat_response(context: dict[str, Any]) -> GuiChatResponseProposal: - progress_callback, register_interrupt, request_context = _gui_workspace_request_context(context) + progress_callback, register_interrupt, request_context = ( + _gui_workspace_request_context(context) + ) cwd_value = request_context.get("workspace") or request_context.get("project_root") - cwd = Path(cwd_value).expanduser().resolve() if isinstance(cwd_value, str) and cwd_value else Path.cwd() + cwd = ( + Path(cwd_value).expanduser().resolve() + if isinstance(cwd_value, str) and cwd_value + else Path.cwd() + ) if not cwd.is_dir(): cwd = Path.cwd() provider = create_required_codex_provider( @@ -899,7 +1018,11 @@ def _workspace_rerun_result(message: str) -> tuple[str, str, str] | None: return None if not isinstance(payload, dict) or set(payload) != {"rerun_id", "status", "error"}: return None - rerun_id, status, error = payload.get("rerun_id"), payload.get("status"), payload.get("error") + rerun_id, status, error = ( + payload.get("rerun_id"), + payload.get("status"), + payload.get("error"), + ) if ( not isinstance(rerun_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", rerun_id) diff --git a/ecos/agent/src/ecos_agent/workspace_rerun.py b/ecos/agent/src/ecos_agent/workspace_rerun.py index 58909e8a4..b86002a2f 100644 --- a/ecos/agent/src/ecos_agent/workspace_rerun.py +++ b/ecos/agent/src/ecos_agent/workspace_rerun.py @@ -14,9 +14,6 @@ from ecos_agent.hashing import file_sha256 from ecos_agent.knob_registry import ( BOOLEAN_KNOBS, - KnobTarget, - knob_spec, - resolve_write, validate_value, ) from ecos_agent.parameter_authorization import assert_authorized_parameter_patch @@ -71,7 +68,6 @@ class GuiWorkspaceRerunContract(BaseModel): source_stage_artifact: str source_stage_artifact_sha256: str parameter_patch: list[ECCParameterPatchItem] = Field(default_factory=list, max_length=16) - writes: list[dict[str, object]] = Field(default_factory=list, max_length=16) requires_gui_review: Literal[True] = True @field_validator("source_workspace", "target_workspace") @@ -132,8 +128,13 @@ def normalize_boolean_values(cls, value: object) -> object: class GuiWorkspaceRerunResolver: """Derive GUI rerun contracts from completed ECOS workspace evidence.""" - def __init__(self, workspace_root: Path) -> None: + def __init__( + self, + workspace_root: Path, + current_parameter_values: dict[str, object] | None = None, + ) -> None: self.workspace_root = workspace_root.resolve() + self.current_parameter_values = current_parameter_values or {} def discover(self, design_id: str) -> GuiWorkspaceRerunDiscovery: source = self._find_workspace(design_id) @@ -201,7 +202,6 @@ def freeze( source_stage_artifact=source.stage_artifact_ref[target_step], source_stage_artifact_sha256=source.stage_artifact_sha256[target_step], parameter_patch=[] if patch is None else patch.items, - writes=[] if patch is None else [resolve_write(item) for item in patch.items], ) @staticmethod @@ -221,12 +221,9 @@ def parameter_values( ) -> tuple[tuple[str, object], ...]: if target_step not in source.allowed_stages: raise ValueError("rerun stage is invalid") - return self.stage_parameter_values(source.workspace_path, target_step) + return self.stage_parameter_values(target_step) - @staticmethod - def stage_parameter_values( - workspace_path: Path, target_step: str - ) -> tuple[tuple[str, object], ...]: + def stage_parameter_values(self, target_step: str) -> tuple[tuple[str, object], ...]: """Readable knobs for a step, independent of whether that step has run. Rerun requires completed-stage evidence; changing a parameter does not. @@ -236,7 +233,7 @@ def stage_parameter_values( raise ValueError("rerun stage is invalid") values = [] for knob_id in _authorized_knobs_for_step(step): - value = _current_parameter_value(workspace_path, knob_id) + value = self.current_parameter_values.get(knob_id, _MISSING) if value is not _MISSING: values.append((knob_id, value)) return tuple(values) @@ -336,40 +333,5 @@ def _authorized_knobs_for_step(step: ECCStepName) -> tuple[str, ...]: return tuple(sorted(_AUTHORIZED_KNOBS.get(step, ()))) -def _current_parameter_value(workspace: Path, knob_id: str) -> object: - """Read a knob so contracts can show a real old value. - - Prefers the ECC-canonical step config, falling back to parameters.json: step - configs are only generated once a flow has run, but a knob is tunable before - that. - """ - spec = knob_spec(knob_id) - for target in (spec.read_target, spec.write_target): - value = _read_target_value(workspace, target) - if value is not _MISSING: - return value - return _MISSING - - -def _read_target_value(workspace: Path, target: KnobTarget) -> object: - config_path = workspace / target.file - try: - config_path.resolve().relative_to(workspace) - if config_path.is_symlink() or not config_path.is_file(): - return _MISSING - config = json.loads(config_path.read_text(encoding="utf-8")) - except (OSError, ValueError): - return _MISSING - current: object = config - for key in target.json_path: - if isinstance(key, int): - if not isinstance(current, list) or not 0 <= key < len(current): - return _MISSING - elif not isinstance(current, dict) or key not in current: - return _MISSING - current = current[key] - return current - - def _validate_value(item: ECCParameterPatchItem) -> None: validate_value(item) diff --git a/ecos/agent/src/ecos_agent/workspace_setup.py b/ecos/agent/src/ecos_agent/workspace_setup.py index 57c5b8ea1..fe132d219 100644 --- a/ecos/agent/src/ecos_agent/workspace_setup.py +++ b/ecos/agent/src/ecos_agent/workspace_setup.py @@ -15,7 +15,6 @@ ) _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") _WORKSPACE_ID = re.compile(r"^ws_(\d+)$") -_MODULE = re.compile(r"^\s*module\s+([A-Za-z_][A-Za-z0-9_$]*)\b", re.MULTILINE) _CLOCK = re.compile(r"\b(?:input|inout)\b[^;]*?\b([A-Za-z_][A-Za-z0-9_$]*(?:clk|clock)[A-Za-z0-9_$]*)\b", re.IGNORECASE) _SDC_PERIOD = re.compile(r"\bcreate_clock\b[^\n]*?\s-period\s+([0-9]+(?:\.[0-9]+)?)") _BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL) @@ -270,14 +269,11 @@ def display_path(path: str) -> str: return str(candidate) -def infer_design_defaults(rtl_path: str, sdc_path: str, design_name: str = "") -> dict[str, Any]: +def infer_clock_and_frequency_defaults(rtl_path: str, sdc_path: str) -> dict[str, Any]: source = _without_verilog_comments(_read_text(rtl_path)) - modules = _MODULE.findall(source) - top_module = design_name if design_name in modules else (modules[0] if modules else Path(rtl_path).stem) clock = _CLOCK.search(source) frequency_mhz = _frequency_from_sdc(sdc_path) return { - "top_module": top_module, "clock_name": clock.group(1) if clock else "clk", "frequency_mhz": frequency_mhz if frequency_mhz is not None else 50, } @@ -297,6 +293,7 @@ def workspace_setup_contract( if start_index > end_index: raise ValueError("workspace flow start must not follow its end") workspace_directory = _workspace_directory(inputs, proposal) + filelist = inputs.filelist_path or None return { "schema_version": "flow-agent.workspace_setup_contract.v2", "title": "Workspace 运行方案" if language == "zh" else "Workspace run plan", @@ -305,8 +302,8 @@ def workspace_setup_contract( "directory": workspace_directory, "pdk": "ics55", "pdk_root": inputs.pdk_root, - "rtl_list": [inputs.rtl_path], - "filelist": inputs.filelist_path or None, + "rtl_list": [] if filelist else [inputs.rtl_path], + "filelist": filelist, "sdc": inputs.sdc_path or None, "origin_def": "", "origin_verilog": "", @@ -361,11 +358,6 @@ def _validate_workspace_proposal( raise ValueError("Workspace Name must be provided") if not proposal.design_name: raise ValueError("Design Name must be provided") - if not proposal.top_module: - raise ValueError("Top Module Name must be provided") - modules = _MODULE.findall(_without_verilog_comments(_read_text(inputs.rtl_path))) - if proposal.top_module not in modules: - raise ValueError("Top Module Name must be declared by the RTL path") def _workspace_directory(inputs: WorkspaceInputs, proposal: GuiWorkspaceSetupProposal) -> str: diff --git a/ecos/agent/tests/test_knob_registry.py b/ecos/agent/tests/test_knob_registry.py index f9e872ca5..c3f6bbb82 100644 --- a/ecos/agent/tests/test_knob_registry.py +++ b/ecos/agent/tests/test_knob_registry.py @@ -6,10 +6,7 @@ from ecos_agent.ecc_contracts import ECCParameterPatchItem, ECCStepName from ecos_agent.knob_registry import ( KNOB_SPECS, - WRITABLE_FILES, knob_spec, - resolve_write, - storage_value, validate_value, ) from ecos_agent.provider import EcosAgentProvider @@ -56,50 +53,14 @@ def _workspace_without_completed_stages(root: Path) -> Path: return workspace -def test_every_knob_targets_a_writable_workspace_file() -> None: - for knob_id, spec in KNOB_SPECS.items(): - assert spec.write_target.file in WRITABLE_FILES, knob_id - assert spec.read_target.file in WRITABLE_FILES, knob_id - assert spec.write_target.json_path, knob_id - - def test_every_step_with_knobs_is_a_real_ecc_step() -> None: for knob_id, spec in KNOB_SPECS.items(): assert isinstance(spec.step, ECCStepName), knob_id -def test_parameters_surface_wins_over_the_derived_step_config() -> None: - # ECC regenerates step configs from parameters.json, so a knob present in - # both surfaces must be written to parameters.json. - spec = knob_spec("place.target_density") - assert spec.read_target.file == "config/dreamplace_ecc.json" - assert spec.write_target.file == "home/parameters.json" - assert spec.write_target.json_path == ("Target density",) - - -def test_step_config_only_knobs_write_to_their_tool_config() -> None: - spec = knob_spec("cts.skew_bound") - assert spec.write_target.surface == "step_config" - assert spec.write_target.file == "config/cts_ecc.json" - - -def test_resolve_write_emits_a_complete_execution_instruction() -> None: - write = resolve_write(ECCParameterPatchItem(knob_id="floorplan.die_width", value=250.0)) - assert write == { - "knob_id": "floorplan.die_width", - "value": 250.0, - "surface": "parameters", - "file": "home/parameters.json", - "json_path": ["Die", "Size", 0], - } - - -def test_boolean_parameters_flags_are_stored_as_integers() -> None: - item = ECCParameterPatchItem(knob_id="place.routability_opt", value=False) - assert storage_value(item) == 0 - assert storage_value(ECCParameterPatchItem(knob_id="place.routability_opt", value=True)) == 1 - # Step-config booleans keep their JSON boolean form. - assert storage_value(ECCParameterPatchItem(knob_id="route.enable_timing", value=True)) is True +def test_knob_registry_has_no_workspace_file_or_json_path_targets() -> None: + for spec in KNOB_SPECS.values(): + assert set(vars(spec)) == {"knob_id", "step", "kind", "bounds"} def test_unknown_knob_is_rejected_rather_than_ignored() -> None: @@ -113,7 +74,6 @@ def test_unknown_knob_is_rejected_rather_than_ignored() -> None: ("floorplan.utilitization", 1.5), ("floorplan.die_width", 0), ("design.frequency_max", -1), - ("design.clock", " "), ("floorplan.global_right_padding", -1), ], ) @@ -127,9 +87,7 @@ def test_out_of_range_values_are_rejected(knob_id: str, value: object) -> None: [ ("floorplan.utilitization", 0.7), ("floorplan.die_width", 250.0), - ("floorplan.margin_x", 0.0), ("design.frequency_max", 200), - ("design.clock", "clk"), ], ) def test_in_range_values_are_accepted(knob_id: str, value: object) -> None: @@ -137,15 +95,24 @@ def test_in_range_values_are_accepted(knob_id: str, value: object) -> None: def test_global_parameters_are_tunable_before_any_stage_completes(tmp_path: Path) -> None: - workspace = _workspace_without_completed_stages(tmp_path) - available = dict(_tunable_workspace_parameters(workspace)) + _workspace_without_completed_stages(tmp_path) + available = dict( + _tunable_workspace_parameters( + { + "design.frequency_max": 100, + "floorplan.utilitization": 0.4, + "floorplan.die_width": 100.0, + "place.target_density": 0.55, + } + ) + ) assert available["design.frequency_max"] == 100 assert available["floorplan.utilitization"] == 0.4 assert available["floorplan.die_width"] == 100.0 assert available["place.target_density"] == 0.55 -def test_parameter_update_contract_carries_resolved_writes(tmp_path: Path) -> None: +def test_parameter_update_contract_carries_domain_updates(tmp_path: Path) -> None: workspace = _workspace_without_completed_stages(tmp_path) events: list[dict[str, object]] = [] @@ -157,9 +124,13 @@ def parse_parameter(_context: dict[str, object]) -> dict[str, object]: } provider = EcosAgentProvider(emit=events.append, rerun_parameter_parser=parse_parameter) - session_id = provider.start_session({"directory": str(workspace), "mode": "workspace"})[ - "sessionId" - ] + session_id = provider.start_session( + { + "directory": str(workspace), + "mode": "workspace", + "workspaceParameterValues": {"floorplan.utilitization": 0.4}, + } + )["sessionId"] _send(provider, session_id, "1") _send(provider, session_id, "raise utilization to 0.7") @@ -167,13 +138,9 @@ def parse_parameter(_context: dict[str, object]) -> dict[str, object]: assert session.phase == "workspace_parameter_confirmation" contract = session.workspace_parameter_update assert contract is not None - assert contract["schema_version"] == "flow-agent.workspace_parameter_update_contract.v2" - assert contract["writes"] == [ - { - "knob_id": "floorplan.utilitization", - "value": 0.7, - "surface": "parameters", - "file": "home/parameters.json", - "json_path": ["Core", "Utilitization"], - } + assert contract["schema_version"] == "flow-agent.workspace_parameter_update_contract.v3" + assert contract["parameter_patch"] == [ + {"knob_id": "floorplan.utilitization", "value": 0.7} ] + assert "workspace_parameters" not in contract + assert "step_configurations" not in contract diff --git a/ecos/agent/tests/test_place_knowledge.py b/ecos/agent/tests/test_place_knowledge.py index 81adebd1a..ded6190c8 100644 --- a/ecos/agent/tests/test_place_knowledge.py +++ b/ecos/agent/tests/test_place_knowledge.py @@ -73,12 +73,11 @@ def test_algorithm_chunks_are_english_and_describe_place_stages() -> None: def test_metrics_cover_gui_place_values_and_maps_with_english_calculations() -> None: gui_metrics = ( - ECOS_ROOT / "ecos/gui/apps/renderer/src/utils/projectManagement.ts" + ECOS_ROOT / "ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts" ).read_text(encoding="utf-8") - place_metrics = re.search(r"Place: \[(?P.*?)\],", gui_metrics, re.DOTALL) - - assert place_metrics is not None - visible_numeric_metrics = set(re.findall(r"'(place_[^']+)'", place_metrics.group("metrics"))) + visible_numeric_metrics = set( + re.findall(r"metricName: '(place_[^']+)'", gui_metrics) + ) expected_map_entities = { "metric.place.map.cell_density", "metric.place.map.macro_density", diff --git a/ecos/agent/tests/test_provider.py b/ecos/agent/tests/test_provider.py index 7e81c9a02..ab83ba89a 100644 --- a/ecos/agent/tests/test_provider.py +++ b/ecos/agent/tests/test_provider.py @@ -2,12 +2,19 @@ import threading from pathlib import Path -from ecos_agent.codex_provider import CodexAppServerProposalProvider, CodexProviderError, _resolve_codex_bin +from ecos_agent.codex_provider import ( + CodexAppServerProposalProvider, + CodexProviderError, + _resolve_codex_bin, +) from ecos_agent.contracts import GuiWorkspaceSetupProposal from ecos_agent.ecc_contracts import ECCStepName from ecos_agent.messages import EMPTY_CHOICE_VALUE from ecos_agent.provider import EcosAgentProvider, PROVIDER_ID -from ecos_agent.workspace_rerun import GuiWorkspaceRerunResolver, GuiWorkspaceRerunSource +from ecos_agent.workspace_rerun import ( + GuiWorkspaceRerunResolver, + GuiWorkspaceRerunSource, +) from ecos_agent.workspace_setup import ( display_path, recommended_workspace_name, @@ -123,7 +130,10 @@ def test_codex_rerun_parameter_prompt_requires_boolean_and_multi_knob_interpreta prompts: list[str] = [] def capture_proposal( - _context: dict[str, object], system: str, _schema: dict[str, object], _model: object + _context: dict[str, object], + system: str, + _schema: dict[str, object], + _model: object, ) -> dict[str, object]: prompts.append(system) return { @@ -182,16 +192,21 @@ def parse_workspace_setup(context: dict[str, object]) -> GuiWorkspaceSetupPropos "", "", "", - "", "target overflow is 0.1", ): _send(provider, session_id, message) - setup = next(event["workspaceSetup"] for event in events if event["type"] == "workspace_setup") + setup = next( + event["workspaceSetup"] + for event in events + if event["type"] == "workspace_setup" + ) assert PROVIDER_ID == "ecos_agent" assert "execute" not in provider.__dict__ assert setup["schema_version"] == "flow-agent.workspace_setup_contract.v2" assert setup["directory"] == str(project_root / "ws_0001") + assert setup["filelist"] == str(filelist) + assert setup["rtl_list"] == [] assert setup["parameters"]["design"] == "gcd" assert setup["project_context"]["project_root"] == str(project_root) assert setup["parameters"]["target_overflow"] == 0.1 @@ -211,13 +226,17 @@ def parse_workspace_setup(context: dict[str, object]) -> GuiWorkspaceSetupPropos assert workspace_create["providerId"] == "ecos_agent" -def test_numeric_semantic_fallback_fails_closed_when_codex_times_out(tmp_path: Path) -> None: +def test_numeric_semantic_fallback_fails_closed_when_codex_times_out( + tmp_path: Path, +) -> None: events: list[dict[str, object]] = [] def mock_codex_timeout(_context: dict[str, object]) -> None: raise CodexProviderError("mock timeout", failure_class="timeout") - provider = EcosAgentProvider(emit=events.append, workspace_setup_parser=mock_codex_timeout) + provider = EcosAgentProvider( + emit=events.append, workspace_setup_parser=mock_codex_timeout + ) session_id = provider.start_session({})["sessionId"] session = provider.sessions[session_id] session.phase = "workspace_overflow" @@ -233,7 +252,9 @@ def mock_codex_timeout(_context: dict[str, object]) -> None: assert not any(event["type"] == "workspace_setup" for event in events) -def test_rerun_uses_the_open_gui_workspace_as_the_default_source(tmp_path: Path) -> None: +def test_rerun_uses_the_open_gui_workspace_as_the_default_source( + tmp_path: Path, +) -> None: workspace = tmp_path / "source-workspace" flow = workspace / "home" / "flow.json" flow.parent.mkdir(parents=True) @@ -248,7 +269,14 @@ def test_rerun_uses_the_open_gui_workspace_as_the_default_source(tmp_path: Path) provider = EcosAgentProvider(emit=events.append) session_id = provider.start_session( - {"directory": str(workspace), "mode": "workspace"} + { + "directory": str(workspace), + "mode": "workspace", + "workspaceParameterValues": { + "place.routability_opt": False, + "place.target_overflow": 0.1, + }, + } )["sessionId"] _send(provider, session_id, "2") @@ -262,7 +290,8 @@ def test_rerun_uses_the_open_gui_workspace_as_the_default_source(tmp_path: Path) (option["label"], option["value"]) for option in source_choice["options"] ] == [(str(workspace), "1")] assert any( - event["type"] == "tool" and "Preparing stage rerun" in str(event.get("text", "")) + event["type"] == "tool" + and "Preparing stage rerun" in str(event.get("text", "")) for event in events ) @@ -280,7 +309,9 @@ def test_home_mode_starts_with_primary_cta_not_operation_list() -> None: events: list[dict[str, object]] = [] provider = EcosAgentProvider( emit=events.append, - chat_response_parser=lambda _context: _chat_response(answer="Please describe your ECOS question."), + chat_response_parser=lambda _context: _chat_response( + answer="Please describe your ECOS question." + ), ) session_id = provider.start_session({"mode": "home"})["sessionId"] @@ -298,7 +329,9 @@ def test_home_mode_starts_with_primary_cta_not_operation_list() -> None: choice_count = len([event for event in events if event["type"] == "choice"]) _send(provider, session_id, "2") assert provider.sessions[session_id].phase == "home_ready" - assert _last_event(events, "message")["text"] == "Please describe your ECOS question." + assert ( + _last_event(events, "message")["text"] == "Please describe your ECOS question." + ) assert len([event for event in events if event["type"] == "choice"]) == choice_count @@ -316,13 +349,21 @@ def answer_chat(context: dict[str, object]) -> dict[str, object]: _send(provider, session_id, "你好") assert provider.sessions[session_id].phase == "home_ready" - assert _last_event(events, "message")["text"] == "你好,我可以回答 ECOS 物理设计流程相关问题。" - assert _last_event(events, "message")["contract"]["schema_version"] == "flow-agent.gui_chat_response.v1" + assert ( + _last_event(events, "message")["text"] + == "你好,我可以回答 ECOS 物理设计流程相关问题。" + ) + assert ( + _last_event(events, "message")["contract"]["schema_version"] + == "flow-agent.gui_chat_response.v1" + ) assert len([event for event in events if event["type"] == "choice"]) == choice_count assert not any(event["type"] == "error" for event in events) -def test_wizard_greeting_answers_without_losing_the_pending_input(tmp_path: Path) -> None: +def test_wizard_greeting_answers_without_losing_the_pending_input( + tmp_path: Path, +) -> None: events: list[dict[str, object]] = [] provider = EcosAgentProvider( emit=events.append, @@ -339,7 +380,10 @@ def test_wizard_greeting_answers_without_losing_the_pending_input(tmp_path: Path _send(provider, session_id, "hello") assert session.phase == "workspace_design" - assert _last_event(events, "message")["text"] == "I can help while waiting for workspace_design." + assert ( + _last_event(events, "message")["text"] + == "I can help while waiting for workspace_design." + ) assert len([event for event in events if event["type"] == "choice"]) == choice_count _send(provider, session_id, "gcd") @@ -347,7 +391,9 @@ def test_wizard_greeting_answers_without_losing_the_pending_input(tmp_path: Path assert session.phase == "workspace_flow_end" -def test_gui_chat_response_prompt_is_read_only_and_structured(tmp_path: Path, monkeypatch) -> None: +def test_gui_chat_response_prompt_is_read_only_and_structured( + tmp_path: Path, monkeypatch +) -> None: codex = tmp_path / "codex" codex.write_text("#!/usr/bin/env bash\n", encoding="utf-8") codex.chmod(0o755) @@ -373,7 +419,9 @@ def capture_turn(prompt: str, schema: dict[str, object]) -> str: ) assert response["answer"] == "Hello." - assert "Use retrieved_knowledge only as read-only factual context" in str(captured["prompt"]) + assert "Use retrieved_knowledge only as read-only factual context" in str( + captured["prompt"] + ) assert "Audited target-overflow knowledge." in str(captured["prompt"]) assert captured["schema"]["required"] == ["schema_version", "operation", "answer"] @@ -426,7 +474,11 @@ def test_workspace_mode_rerun_uses_bound_directory_without_design_prompt( provider = EcosAgentProvider(emit=events.append) session_id = provider.start_session( - {"directory": str(workspace), "mode": "workspace"} + { + "directory": str(workspace), + "mode": "workspace", + "workspaceParameterValues": {"place.target_density": 0.55}, + } )["sessionId"] _send(provider, session_id, "2") _send(provider, session_id, "1") @@ -472,7 +524,14 @@ def parse_rerun_parameter(context: dict[str, object]) -> dict[str, object]: ) session_id = provider.start_session( - {"directory": str(workspace), "mode": "workspace"} + { + "directory": str(workspace), + "mode": "workspace", + "workspaceParameterValues": { + "place.routability_opt": False, + "place.target_overflow": 0.1, + }, + } )["sessionId"] for message in ( "2", @@ -485,7 +544,8 @@ def parse_rerun_parameter(context: dict[str, object]) -> dict[str, object]: parameter_message = next( event for event in reversed(events) - if event["type"] == "message" and "Parameters available for this stage" in str(event["text"]) + if event["type"] == "message" + and "Parameters available for this stage" in str(event["text"]) ) assert "| place.routability_opt | false |" in str(parameter_message["text"]) assert "| place.target_overflow | 0.1 |" in str(parameter_message["text"]) @@ -520,27 +580,16 @@ def parse_rerun_parameter(context: dict[str, object]) -> dict[str, object]: {"knob_id": "place.routability_opt", "value": False}, {"knob_id": "place.target_overflow", "value": 0.1}, ] - assert rerun["writes"] == [ - { - "file": "home/parameters.json", - "json_path": ["Routability opt flag"], - "knob_id": "place.routability_opt", - "surface": "parameters", - "value": 0, - }, - { - "file": "home/parameters.json", - "json_path": ["Target overflow"], - "knob_id": "place.target_overflow", - "surface": "parameters", - "value": 0.1, - }, - ] + assert "workspace_parameters" not in rerun + assert "step_configurations" not in rerun _send( provider, session_id, - "workspace_rerun_result:" + json.dumps({"rerun_id": "gcd_rerun_place", "status": "succeeded", "error": ""}), + "workspace_rerun_result:" + + json.dumps( + {"rerun_id": "gcd_rerun_place", "status": "succeeded", "error": ""} + ), ) assert provider.sessions[session_id].phase == "operation" @@ -549,13 +598,19 @@ def parse_rerun_parameter(context: dict[str, object]) -> dict[str, object]: assert _last_event(events, "status")["status"] == "awaiting_choice" -def test_rerun_workspace_invalid_path_reemits_current_workspace_choice(tmp_path: Path) -> None: +def test_rerun_workspace_invalid_path_reemits_current_workspace_choice( + tmp_path: Path, +) -> None: workspace = tmp_path / "source-workspace" workspace.mkdir() events: list[dict[str, object]] = [] provider = EcosAgentProvider(emit=events.append) session_id = provider.start_session( - {"directory": str(workspace), "mode": "workspace"} + { + "directory": str(workspace), + "mode": "workspace", + "workspaceParameterValues": {"place.target_density": 0.55}, + } )["sessionId"] session = provider.sessions[session_id] session.phase = "rerun_workspace" @@ -579,7 +634,9 @@ def test_rerun_workspace_invalid_path_reemits_current_workspace_choice(tmp_path: ) -def test_workspace_contract_validation_failure_reemits_top_choice(tmp_path: Path) -> None: +def test_workspace_contract_skips_top_module_chat_prompt( + tmp_path: Path, +) -> None: rtl, _filelist, _sdc, pdk = _write_workspace_inputs(tmp_path) rtl.write_text("module other(input clk); endmodule\n", encoding="utf-8") events: list[dict[str, object]] = [] @@ -593,7 +650,7 @@ def test_workspace_contract_validation_failure_reemits_top_choice(tmp_path: Path session.workspace_setup = _proposal( workspace_name="ws_0001", design_name="gcd", - top_module="gcd", + top_module=None, clock_name="clk", frequency_mhz=100, max_fanout=32, @@ -608,20 +665,34 @@ def test_workspace_contract_validation_failure_reemits_top_choice(tmp_path: Path _send(provider, session_id, "0.1") - assert session.phase == "workspace_top" - choice = _last_event(events, "choice")["choice"] - assert choice["title"] == "Top Module Name" - assert [option["value"] for option in choice["options"]] == ["gcd"] - assert _last_event(events, "status")["status"] == "awaiting_choice" + assert session.phase == "workspace_confirmation" + setup = next( + event["workspaceSetup"] + for event in events + if event["type"] == "workspace_setup" + ) + assert setup["schema_version"] == "flow-agent.workspace_setup_contract.v2" + assert setup["parameters"]["top_module"] == "" + assert not any( + event.get("type") == "choice" + and str(event.get("choice", {}).get("title", "")) == "Top Module Name" + for event in events + ) -def test_workspace_parameter_request_uses_describe_change_prompt(tmp_path: Path) -> None: +def test_workspace_parameter_request_uses_describe_change_prompt( + tmp_path: Path, +) -> None: workspace = tmp_path / "gcd" workspace.mkdir() events: list[dict[str, object]] = [] provider = EcosAgentProvider(emit=events.append) session_id = provider.start_session( - {"directory": str(workspace), "mode": "workspace"} + { + "directory": str(workspace), + "mode": "workspace", + "workspaceParameterValues": {"place.target_density": 0.55}, + } )["sessionId"] _send(provider, session_id, "1") @@ -629,7 +700,8 @@ def test_workspace_parameter_request_uses_describe_change_prompt(tmp_path: Path) assert provider.sessions[session_id].phase == "workspace_parameter_request" assert any( event["type"] == "message" - and "Describe the parameter change to save in the current workspace" in str(event["text"]) + and "Describe the parameter change to save in the current workspace" + in str(event["text"]) for event in events ) assert not any( @@ -658,7 +730,9 @@ def test_workspace_continue_uses_compact_confirm_without_command_table( assert contract["contract"]["fields"] == [] assert "runAllFlow" not in str(contract["text"]) assert str(workspace) in str(contract["text"]) - assert "Continue the unfinished flow in the current workspace" in str(contract["text"]) + assert "Continue the unfinished flow in the current workspace" in str( + contract["text"] + ) def _workspace_with_place(tmp_path: Path) -> Path: @@ -693,7 +767,8 @@ def _workspace_with_place(tmp_path: Path) -> Path: def test_workspace_parameter_update_lists_concrete_knob_values(tmp_path: Path) -> None: - workspace = _workspace_with_place(tmp_path) + workspace = tmp_path / "empty-workspace" + workspace.mkdir() events: list[dict[str, object]] = [] parser_contexts: list[dict[str, object]] = [] @@ -710,7 +785,12 @@ def parse_parameter(context: dict[str, object]) -> dict[str, object]: rerun_parameter_parser=parse_parameter, ) session_id = provider.start_session( - {"directory": str(workspace), "mode": "workspace"} + { + "directory": str(workspace), + "mode": "workspace", + "workspaceDesignId": "gcd", + "workspaceParameterValues": {"place.target_density": 0.55}, + } )["sessionId"] _send(provider, session_id, "1") _send(provider, session_id, "lower target density") @@ -741,14 +821,19 @@ def empty_patch(_context: dict[str, object]) -> dict[str, object]: rerun_parameter_parser=empty_patch, ) session_id = provider.start_session( - {"directory": str(workspace), "mode": "workspace"} + { + "directory": str(workspace), + "mode": "workspace", + "workspaceParameterValues": {"place.target_density": 0.55}, + } )["sessionId"] _send(provider, session_id, "1") _send(provider, session_id, "lower target density") assert provider.sessions[session_id].phase == "workspace_parameter_request" assert any( - event["type"] == "error" and "no parameter changes were proposed" in str(event["text"]) + event["type"] == "error" + and "no parameter changes were proposed" in str(event["text"]) for event in events ) assert not any(event["type"] == "contract" for event in events) @@ -762,7 +847,9 @@ def test_invalid_choice_and_creation_failed_copy_point_to_cards() -> None: assert "confirm again" in workspace_creation_failed("en", "disk full").lower() -def test_rerun_allocates_a_numbered_target_when_the_default_exists(tmp_path: Path) -> None: +def test_rerun_allocates_a_numbered_target_when_the_default_exists( + tmp_path: Path, +) -> None: workspace = tmp_path / "gcd" workspace.mkdir() (tmp_path / "gcd_rerun_place").mkdir() @@ -776,7 +863,9 @@ def test_rerun_allocates_a_numbered_target_when_the_default_exists(tmp_path: Pat stage_artifact_sha256={"place": "1" * 64}, ) - contract = GuiWorkspaceRerunResolver(tmp_path).freeze(source, "place", [], "single_step") + contract = GuiWorkspaceRerunResolver(tmp_path).freeze( + source, "place", [], "single_step" + ) assert contract.target_workspace == str(tmp_path / "gcd_rerun_place_0001") assert contract.rerun_id == "gcd_rerun_place_0001" @@ -850,7 +939,9 @@ def test_rerun_discovers_timing_opt_stage_with_sanitized_target(tmp_path: Path) "timing_optimization_sizer/output/gcd_timing_optimization.def.gz" ) - contract = resolver.freeze(discovery.source, "Timing optimization", [], "single_step") + contract = resolver.freeze( + discovery.source, "Timing optimization", [], "single_step" + ) assert contract.rerun_id == "gcd_rerun_timing_optimization" assert contract.target_workspace == str(tmp_path / "gcd_rerun_timing_optimization") @@ -868,7 +959,9 @@ def test_rerun_fails_closed_when_mock_codex_times_out(tmp_path: Path) -> None: (output / "gcd_place.def.gz").write_bytes(b"def") config = workspace / "config" config.mkdir() - (config / "dreamplace_ecc.json").write_text('{"target_density": 0.2}', encoding="utf-8") + (config / "dreamplace_ecc.json").write_text( + '{"target_density": 0.2}', encoding="utf-8" + ) events: list[dict[str, object]] = [] def mock_codex_timeout(_context: dict[str, object]) -> None: @@ -879,7 +972,11 @@ def mock_codex_timeout(_context: dict[str, object]) -> None: rerun_parameter_parser=mock_codex_timeout, ) session_id = provider.start_session( - {"directory": str(workspace), "mode": "workspace"} + { + "directory": str(workspace), + "mode": "workspace", + "workspaceParameterValues": {"place.target_density": 0.2}, + } )["sessionId"] for message in ("2", "1", "1", "reduce density"): _send(provider, session_id, message) @@ -889,7 +986,9 @@ def mock_codex_timeout(_context: dict[str, object]) -> None: assert not any(event["type"] in {"contract", "workspace_rerun"} for event in events) -def test_optional_path_steps_emit_skip_and_recommendation_choices(tmp_path: Path) -> None: +def test_optional_path_steps_emit_skip_and_recommendation_choices( + tmp_path: Path, +) -> None: project_root = tmp_path / "projects" project_root.mkdir() rtl, filelist, sdc, pdk = _write_workspace_inputs(project_root) @@ -930,14 +1029,20 @@ def test_optional_path_steps_emit_skip_and_recommendation_choices(tmp_path: Path _send(provider, session_id, str(sdc)) assert session.phase == "workspace_pdk" pdk_choice = _last_event(events, "choice")["choice"] - assert [option["label"] for option in pdk_choice["options"]] == ["Use recommended path"] + assert [option["label"] for option in pdk_choice["options"]] == [ + "Use recommended path" + ] assert pdk_choice["options"][0]["value"] == display_path(str(pdk)) _send(provider, session_id, pdk_choice["options"][0]["value"]) - assert session.phase == "workspace_top" - top_choice = _last_event(events, "choice")["choice"] - assert top_choice["options"][0]["label"].startswith("Use default:") - assert top_choice["allowFreeText"] is True + assert session.phase == "workspace_clock" + clock_choice = _last_event(events, "choice")["choice"] + assert clock_choice["title"] == "Clock Signal Name" + assert not any( + event.get("type") == "choice" + and str(event.get("choice", {}).get("title", "")) == "Top Module Name" + for event in events + ) def test_workspace_confirmation_accepts_deterministic_frequency_and_workspace_name( @@ -986,7 +1091,9 @@ def reject_codex(_context: dict[str, object]) -> GuiWorkspaceSetupProposal: assert not any(event["type"] == "error" for event in events) -def test_workspace_confirmation_accepts_explicit_external_pdk_path(tmp_path: Path) -> None: +def test_workspace_confirmation_accepts_explicit_external_pdk_path( + tmp_path: Path, +) -> None: project_root = tmp_path / "gcd" project_root.mkdir() rtl, _filelist, _sdc, old_pdk = _write_workspace_inputs(project_root) @@ -1033,11 +1140,15 @@ def reject_codex(_context: dict[str, object]) -> GuiWorkspaceSetupProposal: assert session.phase == "workspace_confirmation" assert session.workspace_inputs.pdk_root == str(external_pdk.resolve()) assert not any(event["type"] == "error" for event in events) - assert any( - event["type"] == "workspace_setup" - and event.get("workspaceSetup", {}).get("pdk_root") == str(external_pdk.resolve()) - for event in events - ) or session.workspace_contract is not None + assert ( + any( + event["type"] == "workspace_setup" + and event.get("workspaceSetup", {}).get("pdk_root") + == str(external_pdk.resolve()) + for event in events + ) + or session.workspace_contract is not None + ) def test_operation_and_cancellation_choices_preserve_the_controlled_paths() -> None: @@ -1084,7 +1195,9 @@ def blocking_parser(context: dict[str, object]) -> GuiWorkspaceSetupProposal: assert release.wait(timeout=2) return _proposal(target_overflow=0.1) - provider = EcosAgentProvider(emit=events.append, workspace_setup_parser=blocking_parser) + provider = EcosAgentProvider( + emit=events.append, workspace_setup_parser=blocking_parser + ) session_id = provider.start_session({})["sessionId"] session = provider.sessions[session_id] session.phase = "workspace_overflow" @@ -1111,10 +1224,13 @@ def send_blocking_message() -> None: _send(provider, session_id, "0.1") - assert sum( - event["type"] == "status" and event.get("status") == "running" - for event in events - ) == 2 + assert ( + sum( + event["type"] == "status" and event.get("status") == "running" + for event in events + ) + == 2 + ) def test_start_session_binds_project_root_and_welcome_shows_both_contexts( @@ -1230,7 +1346,9 @@ def test_rtl_recommendation_emits_a_path_choice_without_embedding_the_path( rtl_choice = _last_event(events, "choice")["choice"] assert rtl_choice["title"] == "RTL path" assert rtl_choice["allowFreeText"] is True - assert [option["label"] for option in rtl_choice["options"]] == ["Use recommended path"] + assert [option["label"] for option in rtl_choice["options"]] == [ + "Use recommended path" + ] assert rtl_choice["options"][0]["value"] == display_path(str(rtl)) rtl_prompt_text = next( event["text"] @@ -1322,7 +1440,9 @@ def test_workspace_mode_option_four_prefills_project_root(tmp_path: Path) -> Non assert session.workspace_inputs.project_root == str(project) -def test_tool_streaming_reuses_one_message_id_for_all_turn_deltas(tmp_path: Path) -> None: +def test_tool_streaming_reuses_one_message_id_for_all_turn_deltas( + tmp_path: Path, +) -> None: events: list[dict[str, object]] = [] def streaming_parser(context: dict[str, object]) -> GuiWorkspaceSetupProposal: @@ -1332,7 +1452,9 @@ def streaming_parser(context: dict[str, object]) -> GuiWorkspaceSetupProposal: progress("Validating the structured proposal.") return _proposal(target_overflow=0.1) - provider = EcosAgentProvider(emit=events.append, workspace_setup_parser=streaming_parser) + provider = EcosAgentProvider( + emit=events.append, workspace_setup_parser=streaming_parser + ) session_id = provider.start_session({})["sessionId"] session = provider.sessions[session_id] session.phase = "workspace_overflow" @@ -1372,7 +1494,11 @@ def parse_operation(context: dict[str, object]) -> dict[str, object]: chat_response_parser=parse_operation, ) session_id = provider.start_session( - {"directory": str(workspace), "mode": "workspace"} + { + "directory": str(workspace), + "mode": "workspace", + "workspaceParameterValues": {"place.target_density": 0.55}, + } )["sessionId"] _send(provider, session_id, "lower target density") @@ -1384,7 +1510,9 @@ def parse_operation(context: dict[str, object]) -> dict[str, object]: ) -def test_operation_question_uses_place_knowledge_without_parameter_update(tmp_path: Path) -> None: +def test_operation_question_uses_place_knowledge_without_parameter_update( + tmp_path: Path, +) -> None: workspace = _workspace_with_place(tmp_path) events: list[dict[str, object]] = [] chat_contexts: list[dict[str, object]] = [] @@ -1392,9 +1520,13 @@ def test_operation_question_uses_place_knowledge_without_parameter_update(tmp_pa def unexpected_parameter_update(_context: dict[str, object]) -> dict[str, object]: raise AssertionError("a question must not enter the parameter-update parser") - def answer_with_retrieved_knowledge(context: dict[str, object]) -> dict[str, object]: + def answer_with_retrieved_knowledge( + context: dict[str, object], + ) -> dict[str, object]: chat_contexts.append(context) - return _chat_response(answer="The stop-overflow threshold ends global placement.") + return _chat_response( + answer="The stop-overflow threshold ends global placement." + ) provider = EcosAgentProvider( emit=events.append, @@ -1416,14 +1548,18 @@ def answer_with_retrieved_knowledge(context: dict[str, object]) -> dict[str, obj assert answer["contract"]["knowledge"]["entity_ids"] == retrieved["entity_ids"] -def test_operation_question_falls_back_to_audited_knowledge_when_codex_fails(tmp_path: Path) -> None: +def test_operation_question_falls_back_to_audited_knowledge_when_codex_fails( + tmp_path: Path, +) -> None: workspace = _workspace_with_place(tmp_path) events: list[dict[str, object]] = [] def unavailable_codex(_context: dict[str, object]) -> dict[str, object]: raise CodexProviderError("Codex timed out", failure_class="timeout") - provider = EcosAgentProvider(emit=events.append, chat_response_parser=unavailable_codex) + provider = EcosAgentProvider( + emit=events.append, chat_response_parser=unavailable_codex + ) session_id = provider.start_session( {"directory": str(workspace), "mode": "workspace"} )["sessionId"] @@ -1444,7 +1580,9 @@ def test_operation_question_codex_fallback_disallows_operations(tmp_path: Path) def answer_chat(context: dict[str, object]) -> dict[str, object]: contexts.append(context) - return _chat_response(answer="This workspace has no published answer for that question.") + return _chat_response( + answer="This workspace has no published answer for that question." + ) provider = EcosAgentProvider(emit=events.append, chat_response_parser=answer_chat) session_id = provider.start_session( @@ -1515,19 +1653,24 @@ def parse_operation(_context: dict[str, object]) -> dict[str, object]: assert provider.sessions[session_id].phase == "operation" assert any( - event["type"] == "error" and "Unable to answer the request" in str(event["text"]) + event["type"] == "error" + and "Unable to answer the request" in str(event["text"]) for event in events ) assert len([event for event in events if event["type"] == "choice"]) == 1 -def test_operation_codex_fallback_answers_unmatched_nl_without_error(tmp_path: Path) -> None: +def test_operation_codex_fallback_answers_unmatched_nl_without_error( + tmp_path: Path, +) -> None: workspace = tmp_path / "ws" workspace.mkdir() events: list[dict[str, object]] = [] def parse_operation(_context: dict[str, object]) -> dict[str, object]: - return _chat_response(answer="Hello. What would you like to know about this workspace?") + return _chat_response( + answer="Hello. What would you like to know about this workspace?" + ) provider = EcosAgentProvider( emit=events.append, @@ -1540,7 +1683,8 @@ def parse_operation(_context: dict[str, object]) -> dict[str, object]: assert provider.sessions[session_id].phase == "operation" assert any( - event["type"] == "message" and "What would you like to know" in str(event["text"]) + event["type"] == "message" + and "What would you like to know" in str(event["text"]) for event in events ) assert not any(event["type"] == "error" for event in events) diff --git a/ecos/agent/tests/test_step_knowledge.py b/ecos/agent/tests/test_step_knowledge.py index cd1dd1576..fd4221f86 100644 --- a/ecos/agent/tests/test_step_knowledge.py +++ b/ecos/agent/tests/test_step_knowledge.py @@ -162,7 +162,6 @@ def test_provider_answers_cts_question_without_changing_operation_state() -> Non provider.send_message({"sessionId": session_id, "message": "CTS stage execution"}) answer = next(event for event in reversed(events) if event["type"] == "message") - assert "clock-tree" in str(answer["text"]) assert answer["contract"]["knowledge"]["schema_version"] == "ecos-cts-answer.v1" assert provider.sessions[session_id].phase == "home_ready" diff --git a/ecos/gui/apps/desktop-electron/electron/main/createShutdownCoordinator.test.ts b/ecos/gui/apps/desktop-electron/electron/main/createShutdownCoordinator.test.ts new file mode 100644 index 000000000..b6834dbaf --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/main/createShutdownCoordinator.test.ts @@ -0,0 +1,123 @@ +import type { EccBackgroundOperationProjection } from '@ecos-studio/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const electron = vi.hoisted(() => ({ + appOn: vi.fn(), + appQuit: vi.fn(), + dialog: vi.fn(), + windows: [] as Array<{ + close: ReturnType + isDestroyed: ReturnType + webContents: { id: number; send: ReturnType } + }>, +})) + +vi.mock('electron', () => ({ + app: { on: electron.appOn, quit: electron.appQuit }, + BrowserWindow: { + getAllWindows: () => electron.windows, + getFocusedWindow: () => electron.windows[0], + }, + dialog: { showMessageBox: electron.dialog }, +})) + +import { createShutdownCoordinator } from './createShutdownCoordinator' + +function projection(): EccBackgroundOperationProjection { + return { creations: [], finalizations: [], generation: 0, operations: [], outcomes: [] } +} + +describe('createShutdownCoordinator', () => { + beforeEach(() => { + vi.clearAllMocks() + electron.windows = [ + { + close: vi.fn(), + isDestroyed: vi.fn(() => false), + webContents: { id: 7, send: vi.fn() }, + }, + ] + electron.dialog.mockResolvedValue({ response: 0 }) + }) + + it('intercepts before-quit, cleans renderers, and approves application close once', async () => { + const runtime = { + cancelOperation: vi.fn(), + forceShutdown: vi.fn(), + flushPendingState: vi.fn(), + onOperationProjectionInvalidated: vi.fn(() => () => undefined), + operationProjection: vi.fn(projection), + reconcileOperationProjection: vi.fn(async () => projection()), + waitForIdle: vi.fn(), + } + const journal = { + allEntries: vi.fn(async () => []), + markActiveUnfinished: vi.fn(), + onInvalidated: vi.fn(() => () => undefined), + } + const coordinator = createShutdownCoordinator(runtime, journal) + const beforeQuit = electron.appOn.mock.calls.find( + ([eventName]) => eventName === 'before-quit', + )?.[1] + const event = { preventDefault: vi.fn() } + + beforeQuit?.(event) + await vi.waitFor(() => expect(coordinator.status().state).toBe('cleaning-renderers')) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(electron.dialog).not.toHaveBeenCalled() + const attemptId = coordinator.status().attemptId! + await coordinator.completeRendererCleanup(attemptId, 7, true) + + expect(electron.windows[0]!.close).toHaveBeenCalledOnce() + expect(electron.appQuit).toHaveBeenCalledOnce() + }) + + it('keeps Force Quit confirmation separate from cancelling shutdown', async () => { + const active = projection() + active.operations.push({ + createdAt: 1, + currentStep: 'Place', + currentTool: 'openroad', + error: null, + kind: 'flow', + operationId: 'operation-1', + origin: 'gui', + rerun: false, + result: null, + state: 'running', + step: '', + updatedAt: 2, + workspaceDirectory: '/projects/demo/ws_1', + workspaceHandle: 'handle-1', + workspaceId: 'engineering-1', + }) + const runtime = { + cancelOperation: vi.fn(), + forceShutdown: vi.fn(), + flushPendingState: vi.fn(), + onOperationProjectionInvalidated: vi.fn(() => () => undefined), + operationProjection: vi.fn(() => active), + reconcileOperationProjection: vi.fn(async () => active), + waitForIdle: vi.fn(), + } + const coordinator = createShutdownCoordinator(runtime, { + allEntries: vi.fn(async () => []), + markActiveUnfinished: vi.fn(), + onInvalidated: vi.fn(() => () => undefined), + }) + + await coordinator.requestWindowClose(7) + await coordinator.reviewShutdownOptions() + + expect(electron.dialog).toHaveBeenLastCalledWith( + electron.windows[0], + expect.objectContaining({ + buttons: ['Keep Waiting', 'Force Quit'], + cancelId: 0, + defaultId: 0, + message: 'Force quit ECOS Studio?', + }), + ) + expect(runtime.forceShutdown).not.toHaveBeenCalled() + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/main/createShutdownCoordinator.ts b/ecos/gui/apps/desktop-electron/electron/main/createShutdownCoordinator.ts new file mode 100644 index 000000000..0dc42e5eb --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/main/createShutdownCoordinator.ts @@ -0,0 +1,129 @@ +import { app, BrowserWindow, dialog, type MessageBoxOptions } from 'electron' +import { + desktopApiEventChannels, + type EccBackgroundOperationProjection, + type EccBackgroundWorkspaceCreation, +} from '@ecos-studio/shared' +import { confirmWindowClose } from '../services/windowService' +import { ShutdownCoordinator } from './shutdownCoordinator' +import type { ShutdownScope } from './shutdownBlockers' + +interface RuntimeHost { + cancelOperation(request: { + operationId: string + workspaceHandle: string + }): Promise + forceShutdown(workspaceHandles?: readonly string[]): Promise + flushPendingState(): Promise + onOperationProjectionInvalidated(listener: () => void): () => void + operationProjection(): EccBackgroundOperationProjection + reconcileOperationProjection(): Promise + waitForIdle(workspaceHandles?: readonly string[]): Promise +} + +interface CreationJournal { + allEntries(): Promise + markActiveUnfinished(windowIds?: ReadonlySet): Promise + onInvalidated(listener: () => void): () => void +} + +export function createShutdownCoordinator( + runtime: RuntimeHost, + journal: CreationJournal, +): ShutdownCoordinator { + const showDialog = async (scope: ShutdownScope, options: MessageBoxOptions) => { + const parent = + scope.kind === 'window' + ? BrowserWindow.getAllWindows().find( + (window) => window.webContents.id === scope.windowId, + ) + : (BrowserWindow.getFocusedWindow() ?? undefined) + return parent + ? await dialog.showMessageBox(parent, options) + : await dialog.showMessageBox(options) + } + + let coordinator!: ShutdownCoordinator + coordinator = new ShutdownCoordinator({ + approve: (scope) => { + if (scope.kind === 'application') { + for (const window of BrowserWindow.getAllWindows()) { + confirmWindowClose(window) + } + app.quit() + return + } + const window = BrowserWindow.getAllWindows().find( + (candidate) => candidate.webContents.id === scope.windowId, + ) + if (window) confirmWindowClose(window) + }, + cancelOperation: (workspaceHandle, operationId) => + runtime.cancelOperation({ operationId, workspaceHandle }), + creationEntries: () => journal.allEntries(), + currentOperationProjection: () => runtime.operationProjection(), + forceTerminate: (workspaceHandles) => runtime.forceShutdown(workspaceHandles), + flushRuntimeState: () => runtime.flushPendingState(), + listWindowIds: () => + BrowserWindow.getAllWindows().map((window) => window.webContents.id), + markCreationsUnfinished: (windowIds) => journal.markActiveUnfinished(windowIds), + operationProjection: () => runtime.reconcileOperationProjection(), + promptForce: async (blockers) => { + const result = await showDialog(coordinator.scope(), { + buttons: ['Keep Waiting', 'Force Quit'], + cancelId: 0, + defaultId: 0, + detail: [ + ...blockers.details, + '', + 'Force quit may leave local Workspace details or Runtime logs unsynchronized. Unfinished creation will require recovery next time.', + ].join('\n'), + message: 'Force quit ECOS Studio?', + noLink: true, + title: 'Force quit ECOS Studio?', + type: 'warning', + }) + return result.response === 1 ? 'force' : 'keep-waiting' + }, + promptInitial: async (blockers) => { + const result = await showDialog(coordinator.scope(), { + buttons: ['Wait and Close Safely', 'Cancel'], + cancelId: 1, + defaultId: 0, + detail: [ + ...blockers.details, + '', + 'ECOS Studio can stay open until active Flows, final snapshots, and Workspace creation finish safely.', + ].join('\n'), + message: 'Work is still in progress', + noLink: true, + title: 'Work is still in progress', + type: 'warning', + }) + return result.response === 0 ? 'wait' : 'cancel' + }, + requestRendererCleanup: (attemptId, windowIds) => { + for (const window of BrowserWindow.getAllWindows()) { + if (windowIds.includes(window.webContents.id)) { + window.webContents.send(desktopApiEventChannels.shutdownCleanupRequested, { + attemptId, + }) + } + } + }, + waitForRuntimeIdle: (workspaceHandles) => runtime.waitForIdle(workspaceHandles), + }) + + runtime.onOperationProjectionInvalidated(() => { + void coordinator.notifyBlockersChanged() + }) + journal.onInvalidated(() => { + void coordinator.notifyBlockersChanged() + }) + app.on('before-quit', (event) => { + if (coordinator.isApplicationApproved()) return + event.preventDefault() + void coordinator.requestApplicationQuit() + }) + return coordinator +} diff --git a/ecos/gui/apps/desktop-electron/electron/main/index.ts b/ecos/gui/apps/desktop-electron/electron/main/index.ts index 10f2d40f3..bf1d9f58e 100644 --- a/ecos/gui/apps/desktop-electron/electron/main/index.ts +++ b/ecos/gui/apps/desktop-electron/electron/main/index.ts @@ -1,4 +1,8 @@ import { app, BrowserWindow, ipcMain, protocol } from 'electron' +import { + projectManifestForPresentation, + type EccProjectManifest, +} from '@ecos-studio/shared' import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { runAfterAppReady } from './appReady' @@ -6,11 +10,15 @@ import { applyHeadlessDisplayHint, parseCliInvocation, runCliCommand } from './c import { createMainWindow } from './createMainWindow' import { configureGpuMode } from './gpuMode' import { registerIpc } from './registerIpc' -import { installRuntimeQuitGuard } from './runtimeQuitGuard' +import type { ShutdownCoordinator } from './shutdownCoordinator' +import { createShutdownCoordinator } from './createShutdownCoordinator' import { handleSecondInstance } from '../services/appSecondInstance' import { createAgentRuntimeFromEnvironment } from '../services/agent/agentProviderRuntimeFactory' import { CodexDependencyService } from '../services/agent/codexDependencyService' import { AppInfoService } from '../services/appInfoService' +import { BackendWorkspaceService } from '../services/backendWorkspaceService' +import { ProjectComparisonFileWatcher } from '../services/projectComparisonFileWatcher' +import { BackendProjectComparisonService } from '../services/backendProjectComparisonService' import { prepareDesktopLogs } from '../services/desktopLogPaths' import { createEccRuntimeEnv, @@ -19,7 +27,6 @@ import { } from '../services/eccRpc/runtimeEnv' import type { EccRuntimeEnvOptions } from '../services/eccRpc/runtimeEnv' import { EccRpcRuntimeService } from '../services/eccRpc/runtimeService' -import { WorkspaceSnapshotLoader } from '../services/eccRpc/workspaceSnapshotLoader' import { resolveEccSidecarLogDirectory } from '../services/eccRpc/sidecarLogDirectory' import { EccRpcSidecarProcess } from '../services/eccRpc/sidecarProcess' import { @@ -39,7 +46,10 @@ import { import { ProjectScopeService } from '../services/projectScopeService' import { ProjectReadGrantStore } from '../services/projectReadGrantStore' import { ProjectManifestService } from '../services/projectManifestService' -import { ProjectManagementReadService } from '../services/projectManagementReadService' +import { + ProjectManagementReadService, + type ProjectWorkspaceConfiguration, +} from '../services/projectManagementReadService' import { ResourceManagerService } from '../services/resourceManagerService' import type { PdkInventoryService } from '../services/pdkInventoryService' import { SettingsStore } from '../services/settingsStore' @@ -51,6 +61,7 @@ import { import { bindWindowEvents } from '../services/windowService' import { WorkspaceResourceService } from '../services/workspaceResourceService' import { WorkspaceService } from '../services/workspaceService' +import { WorkspaceCreationJournal } from '../services/workspaceCreationJournal' import { workspaceWindowRegistry, type WorkspaceWindowLike, @@ -77,6 +88,8 @@ let workspaceReplacementRecovery: Promise | null = null let projectScopeService: ProjectScopeService | null = null let services: { appInfoService: AppInfoService + backendWorkspaceService: BackendWorkspaceService + backendProjectComparisonService: BackendProjectComparisonService cliInstallerService: CliInstallerService codexDependencyService: CodexDependencyService eccRuntimeService: EccRpcRuntimeService @@ -91,6 +104,8 @@ let services: { surferProtocolService: SurferProtocolService workspaceResourceService: WorkspaceResourceService workspaceService: WorkspaceService + workspaceCreationJournal: WorkspaceCreationJournal + shutdownCoordinator: ShutdownCoordinator } | null = null function readHostInfo(path: string): string { @@ -137,9 +152,6 @@ function getDesktopServices() { const projectReadGrantStore = new ProjectReadGrantStore({ filePath: join(app.getPath('userData'), 'project-read-grants.json'), }) - projectScopeService = new ProjectScopeService({ - readGrantProvider: projectReadGrantStore, - }) const eccRuntimeOptions = { appPath: app.getAppPath(), cwd: process.cwd(), @@ -158,22 +170,13 @@ function getDesktopServices() { electronLogger.info('[runtime] Using ECC executable %s', eccExecutable) } else { electronLogger.warn( - '[runtime] Packaged/dev ECC executable was not resolved; falling back to PATH lookup for ecc', + '[runtime] ECC executable is unavailable on this platform or installation', ) } const appInfoService = new AppInfoService({ appVersionProvider: () => app.getVersion(), env: runtimeEnv, }) - const runtimeMutationGuard = { - isWorkspaceRuntimeActive: async (directory: string) => - eccRuntimeService.isWorkspaceRuntimeActive(directory) || - frontendRpcRuntimeService.isWorkspaceRuntimeActive(directory), - } - const workspaceResourceService = new WorkspaceResourceService({ - projectScopeProvider: projectScopeService, - runtimeMutationGuard, - }) const resourceManagerService = new ResourceManagerService() const pdkInventoryService = resourceManagerService.getPdkInventoryService() const cliInstallerService = new CliInstallerService({ @@ -222,19 +225,26 @@ function getDesktopServices() { onEvent, onNotification, }), - lazyWorkspaceOpen: true, - snapshotLoader: (directory) => new WorkspaceSnapshotLoader().load(directory), + lazyWorkspaceOpen: false, }) - installRuntimeQuitGuard({ - app, - onShutdownError: (error) => { - electronLogger.error('[runtime] Failed to shut down ECC sidecars', error) - }, - runtime: eccRuntimeService, + projectScopeService = new ProjectScopeService({ + loadProjectManifest: async (projectRoot) => + projectManifestForPresentation( + await eccRuntimeService.callRuntime('project.manifest.load', { + projectRoot, + }), + projectRoot, + ), + readGrantProvider: projectReadGrantStore, + }) + const workspaceResourceService = new WorkspaceResourceService({ + projectScopeProvider: projectScopeService, }) const frontendRpcCore = new EccRpcRuntimeService({ + managementRpc: true, createSidecar: (directory, onEvent) => new EccRpcSidecarProcess({ + managementRpc: true, env: runtimeEnv, envProvider: runtimeEnvProvider, logDirectoryProvider: () => resolveEccSidecarLogDirectory(logSessionDirectory), @@ -260,13 +270,51 @@ function getDesktopServices() { const workspaceService = new WorkspaceService({ projectScopeProvider: projectScopeService, replacementJournalDirectory: join(app.getPath('userData'), 'workspace-replacements'), - runtimeMutationGuard, + runtimeMutationGuard: { + isWorkspaceRuntimeActive: async (directory) => + eccRuntimeService.isWorkspaceRuntimeActive(directory) || + frontendRpcRuntimeService.isWorkspaceRuntimeActive(directory), + }, }) const projectManifestService = new ProjectManifestService( projectScopeService, workspaceService, + eccRuntimeService, + ) + const creationProjectScope = projectScopeService + const workspaceCreationJournal = new WorkspaceCreationJournal({ + canonicalizePaths: (projectRoot, targetDirectory) => + creationProjectScope.canonicalizeProjectTarget(projectRoot, targetDirectory), + directory: join(app.getPath('userData'), 'workspace-creations'), + inspectWorkspaceIdentity: (path) => eccRuntimeService.inspectWorkspaceIdentity(path), + isWorkspace: (path) => workspaceService.isProjectDirectory(path), + projectManifestService, + settingsStore, + }) + const shutdownCoordinator = createShutdownCoordinator( + eccRuntimeService, + workspaceCreationJournal, + ) + const projectManagementReadService = new ProjectManagementReadService( + projectManifestService, + (directory, step) => + eccRuntimeService.readWorkspaceStepConfigurationForDirectory(directory, step), + (directory) => + eccRuntimeService.callRuntime( + 'workspace.configuration.read', + { directory }, + ), ) - const projectManagementReadService = new ProjectManagementReadService() + const backendProjectComparisonService = new BackendProjectComparisonService( + projectManagementReadService, + undefined, + () => eccRuntimeService.operationProjection().operations, + ) + const backendWorkspaceService = new BackendWorkspaceService({ + projectManagementReadService, + snapshotWatcherFactory: (callbacks) => new ProjectComparisonFileWatcher(callbacks), + workspaceRootProvider: projectScopeService, + }) const shellService = new ShellPtyService({ env: runtimeEnv, envProvider: runtimeEnvProvider, @@ -305,6 +353,8 @@ function getDesktopServices() { services = { appInfoService, + backendWorkspaceService, + backendProjectComparisonService, cliInstallerService, frontendRpcRuntimeService, chipViewerService, @@ -319,6 +369,8 @@ function getDesktopServices() { surferProtocolService, workspaceResourceService, workspaceService, + workspaceCreationJournal, + shutdownCoordinator, } return services @@ -327,10 +379,14 @@ function getDesktopServices() { async function ensureDesktopBridgeReady(): Promise { const desktopServices = getDesktopServices() if (!workspaceReplacementRecoveryComplete) { - workspaceReplacementRecovery ??= desktopServices.workspaceService - .recoverProjectDirectoryReplacements() + workspaceReplacementRecovery ??= desktopServices.workspaceCreationJournal + .initialize() + .then(() => desktopServices.workspaceService.recoverProjectDirectoryReplacements()) + .then(() => undefined) .catch((error) => { - electronLogger.error('[desktop] Failed to recover workspace replacements', error) + electronLogger.error('[desktop] Failed to recover workspace state', error) + workspaceReplacementRecovery = null + throw error }) await workspaceReplacementRecovery workspaceReplacementRecoveryComplete = true @@ -346,6 +402,8 @@ async function ensureDesktopBridgeReady(): Promise { registerIpc(undefined, { agentRuntimeService: agentRuntimeService ?? undefined, appInfoService: desktopServices.appInfoService, + backendWorkspaceService: desktopServices.backendWorkspaceService, + backendProjectComparisonService: desktopServices.backendProjectComparisonService, cliInstallerService: desktopServices.cliInstallerService, codexDependencyService: desktopServices.codexDependencyService, createWindow: async (options) => { @@ -366,6 +424,8 @@ async function ensureDesktopBridgeReady(): Promise { surferProtocolService: desktopServices.surferProtocolService, workspaceResourceService: desktopServices.workspaceResourceService, workspaceService: desktopServices.workspaceService, + workspaceCreationJournal: desktopServices.workspaceCreationJournal, + shutdownCoordinator: desktopServices.shutdownCoordinator, }) ipcRegistered = true } @@ -380,14 +440,38 @@ async function launchWindow( openWorkspacePath: options.openWorkspacePath, }) const windowId = mainWindow.webContents.id - bindWindowEvents(mainWindow) + bindWindowEvents(mainWindow, { + onCloseRequest: () => { + const coordinator = services?.shutdownCoordinator + if (!coordinator) return + const openWindowCount = BrowserWindow.getAllWindows().filter( + (window) => !window.isDestroyed(), + ).length + void (openWindowCount <= 1 + ? coordinator.requestApplicationQuit() + : coordinator.requestWindowClose(windowId)) + }, + }) mainWindow.on('closed', () => { + services?.shutdownCoordinator.windowClosed(windowId) workspaceWindowRegistry.unregisterByWindow(mainWindow as WorkspaceWindowLike) + services?.backendWorkspaceService.clearWindow(windowId) + services?.backendProjectComparisonService.disposeWindow(windowId) projectScopeService?.clearWindow(windowId) clearWindowMenuState(windowId) }) mainWindow.on('focus', () => { applyWindowMenuState(windowId) + void services?.backendWorkspaceService + .checkForUpdates(windowId) + .catch((error) => + electronLogger.warn('[backend-workspace] focus check failed', error), + ) + void services?.backendProjectComparisonService + .checkForUpdates(windowId) + .catch((error) => + electronLogger.warn('[project-comparison] focus check failed', error), + ) }) return mainWindow } diff --git a/ecos/gui/apps/desktop-electron/electron/main/registerBackgroundLifecycleIpc.ts b/ecos/gui/apps/desktop-electron/electron/main/registerBackgroundLifecycleIpc.ts new file mode 100644 index 000000000..427415a10 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/main/registerBackgroundLifecycleIpc.ts @@ -0,0 +1,152 @@ +import { BrowserWindow, type IpcMainInvokeEvent } from 'electron' +import { + desktopApiEventChannels, + desktopApiIpcChannels, + type DesktopShutdownStatus, + type EccBackgroundOperationProjection, + type EccBackgroundWorkspaceCreation, + type EccRuntimeOperationRequest, +} from '@ecos-studio/shared' +import { idleShutdownStatus } from './shutdownBlockers' + +type Handler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown +type Sender = IpcMainInvokeEvent['sender'] + +interface BackgroundRuntime { + onOperationProjectionInvalidated(listener: () => void): () => void + operationLog(request: EccRuntimeOperationRequest): Promise + operationProjection(): EccBackgroundOperationProjection + reconcileOperationProjection?(): Promise +} + +interface CreationJournal { + entriesForWindow(windowId: number): Promise + generation: number + onInvalidated(listener: () => void): () => void +} + +interface ShutdownBridge { + cancelShutdown(): void + completeRendererCleanup( + attemptId: string, + windowId: number, + ok: boolean, + issue?: string, + ): Promise + onStatusChanged(listener: (status: DesktopShutdownStatus) => void): () => void + reviewShutdownOptions(): Promise + statusForWindow(windowId: number): DesktopShutdownStatus +} + +export function registerBackgroundLifecycleIpc(options: { + creationJournal?: CreationJournal + handle(channel: string, handler: Handler): void + ownsWorkspaceHandle(sender: Sender, workspaceHandle: string): boolean + runtime: BackgroundRuntime + shutdown?: ShutdownBridge +}): void { + const subscribers = new Map void>() + const generation = (): number => + options.runtime.operationProjection().generation + + (options.creationJournal?.generation ?? 0) + const invalidate = (): void => { + for (const sender of subscribers.keys()) { + if (sender.isDestroyed()) continue + sender.send(desktopApiEventChannels.eccRuntimeOperationProjectionInvalidated, { + generation: generation(), + }) + } + } + options.runtime.onOperationProjectionInvalidated(invalidate) + options.creationJournal?.onInvalidated(invalidate) + + options.shutdown?.onStatusChanged(() => { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) continue + window.webContents.send( + desktopApiEventChannels.shutdownStatusChanged, + options.shutdown!.statusForWindow(window.webContents.id), + ) + } + }) + + options.handle(desktopApiIpcChannels.eccRuntimeOperationProjection, async (event) => { + subscribe(subscribers, event.sender) + const projection = options.runtime.reconcileOperationProjection + ? await options.runtime.reconcileOperationProjection() + : options.runtime.operationProjection() + const owns = (workspaceHandle: string) => + options.ownsWorkspaceHandle(event.sender, workspaceHandle) + return { + ...projection, + creations: options.creationJournal + ? await options.creationJournal.entriesForWindow(event.sender.id) + : [], + finalizations: projection.finalizations.filter((item) => + owns(item.workspaceHandle), + ), + generation: generation(), + operations: projection.operations.filter((item) => owns(item.workspaceHandle)), + outcomes: projection.outcomes.filter((item) => owns(item.workspaceHandle)), + } + }) + + options.handle(desktopApiIpcChannels.eccRuntimeOperationLog, async (event, value) => { + if ( + !isRecord(value) || + typeof value.workspaceHandle !== 'string' || + typeof value.operationId !== 'string' || + !options.ownsWorkspaceHandle(event.sender, value.workspaceHandle) + ) { + throw new Error('Operation log request does not own this Workspace handle.') + } + return await options.runtime.operationLog( + value as unknown as EccRuntimeOperationRequest, + ) + }) + + options.handle( + desktopApiIpcChannels.shutdownGetStatus, + async (event) => + options.shutdown?.statusForWindow(event.sender.id) ?? idleShutdownStatus(), + ) + options.handle(desktopApiIpcChannels.shutdownCancel, async (event) => { + if (options.shutdown?.statusForWindow(event.sender.id).attemptId) { + options.shutdown.cancelShutdown() + } + }) + options.handle(desktopApiIpcChannels.shutdownReviewOptions, async (event) => { + if (options.shutdown?.statusForWindow(event.sender.id).attemptId) { + await options.shutdown.reviewShutdownOptions() + } + }) + options.handle(desktopApiIpcChannels.shutdownCompleteCleanup, async (event, value) => { + if ( + !isRecord(value) || + typeof value.attemptId !== 'string' || + typeof value.ok !== 'boolean' || + (value.issue !== undefined && typeof value.issue !== 'string') + ) { + throw new Error('Shutdown cleanup result is invalid.') + } + await options.shutdown?.completeRendererCleanup( + value.attemptId, + event.sender.id, + value.ok, + value.issue, + ) + }) +} + +function subscribe(subscribers: Map void>, sender: Sender): void { + if (subscribers.has(sender)) return + const onDestroyed = (): void => { + subscribers.delete(sender) + } + subscribers.set(sender, onDestroyed) + sender.once('destroyed', onDestroyed) +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} diff --git a/ecos/gui/apps/desktop-electron/electron/main/registerIpc.test.ts b/ecos/gui/apps/desktop-electron/electron/main/registerIpc.test.ts index 80d7494d8..38bf77d4f 100644 --- a/ecos/gui/apps/desktop-electron/electron/main/registerIpc.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/main/registerIpc.test.ts @@ -4,6 +4,9 @@ import { desktopApiIpcChannels, desktopMenuEventIds, type EccRuntimeEvent, + type EccBackgroundOperationProjection, + type DesktopShutdownStatus, + type EccWorkspaceCreateRequest, } from '@ecos-studio/shared' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -108,24 +111,44 @@ function registerHandlers( mutate: vi.fn(), }, projectManagementReadService: { + discoverProject: vi.fn(), readManifest: vi.fn(), listProjectEntries: vi.fn(), - readWorkspaceTexts: vi.fn(), + readWorkspaceStepConfiguration: vi.fn(), + }, + backendWorkspaceService: { + clearWindow: vi.fn(), + getArtifact: vi.fn(), + getOverview: vi.fn(), + getStepDetail: vi.fn(), + invalidateWindow: vi.fn(), + onInvalidated: vi.fn(), + refreshOverview: vi.fn(), + }, + backendProjectComparisonService: { + closeProject: vi.fn(), + disposeWindow: vi.fn(), + getComparison: vi.fn(), + getExecutionSnapshot: vi.fn(), + getStepFindings: vi.fn(), + invalidateExecution: vi.fn(), + refreshComparison: vi.fn(), + selectProject: vi.fn(), + invalidateProject: vi.fn(), + invalidateWorkspace: vi.fn(), + onInvalidated: vi.fn(), + onExecutionInvalidated: vi.fn(), }, workspaceService: { approvePendingExternalReadRoots: vi.fn(), clearProjectRoot: vi.fn(), + getProjectRoot: vi.fn().mockResolvedValue('/work/demo'), isProjectDirectory: vi.fn(), listPendingExternalReadRoots: vi.fn(), readProjectBinaryFile: vi.fn(), readOptionalProjectTextFile: vi.fn(), - readWorkspaceParameters: vi.fn(), - hasWorkspaceConfigShadow: vi.fn(), - editWorkspaceParameters: vi.fn(), - applyWorkspaceParameterWrites: vi.fn(), readOptionalProjectTextFileChunk: vi.fn(), readOptionalProjectTextFileTail: vi.fn(), - readOptionalProjectTextFileUpdate: vi.fn(), readProjectTextFile: vi.fn(), readProjectTextFileTail: vi.fn(), registerProjectReadRoot: vi.fn(), @@ -136,17 +159,20 @@ function registerHandlers( requestProjectPathAccess: vi.fn(), scanPdkDirectory: vi.fn(), scanRtlDirectory: vi.fn(), + discoverHdlModules: vi.fn(), listDesignFiles: vi.fn(), addDesignFiles: vi.fn(), removeDesignFile: vi.fn(), prepareProjectDirectoryReplacement: vi.fn(), restoreProjectDirectoryReplacement: vi.fn(), finalizeProjectDirectoryReplacement: vi.fn(), + getProjectDirectoryReplacement: vi.fn(() => ({ + id: 'replacement-1', + targetPath: '/work/demo', + backupPath: '/work/.demo.backup', + projectRoot: '/work', + })), retainProjectDirectoryReplacement: vi.fn(), - subscribeProjectLogTail: vi.fn(), - unwatchProjectFile: vi.fn(), - unsubscribeProjectLogTail: vi.fn(), - watchProjectFile: vi.fn(), writeProjectTextFile: vi.fn(), }, workspaceResourceService: { @@ -154,7 +180,6 @@ function registerHandlers( readFlow: vi.fn(), readHome: vi.fn(), readParameters: vi.fn(), - writeParameters: vi.fn(), resolveStepInfo: vi.fn(), }, resourceManagerService: { @@ -191,31 +216,91 @@ function registerHandlers( }, createWindow: vi.fn(), eccRuntimeService: { - acknowledgeDetachedStepRendered: vi.fn(), - acknowledgeStepRendered: vi.fn(), cancelOperation: vi.fn(), cancelOperationLegacy: vi.fn(), closeWorkspace: vi.fn(), createWorkspace: vi.fn(), + describeWorkspaceSpec: vi.fn(), + engineeringSnapshot: vi.fn(), exportSignoff: vi.fn(), - inspectSignoff: vi.fn(), onEvent: vi.fn((_listener: (event: EccRuntimeEvent) => void) => () => undefined), + onOperationProjectionInvalidated: vi.fn( + (_listener: (generation: number) => void) => () => undefined, + ), + onWorkspaceReleased: vi.fn( + (_listener: (workspaceHandle: string) => void) => () => undefined, + ), + operationProjection: vi.fn( + (): EccBackgroundOperationProjection => ({ + creations: [], + finalizations: [], + generation: 0, + operations: [], + outcomes: [], + }), + ), + operationLog: vi.fn(), operationStatus: vi.fn(), waitForOperation: vi.fn(), openWorkspace: vi.fn(), refreshConfig: vi.fn(), + releaseWorkspace: vi.fn().mockResolvedValue({ ok: true }), + retryFinalSnapshot: vi.fn(), resetFlow: vi.fn(), - rpcHello: vi.fn(), - rpcPing: vi.fn(), - rpcShutdown: vi.fn(), runFlow: vi.fn(), runStep: vi.fn(), startFlowOperation: vi.fn(), startStepOperation: vi.fn(), - syncConfig: vi.fn(), + readWorkspaceStepConfiguration: vi.fn(), + readWorkspaceStepConfigurationForDirectory: vi.fn(), + updateWorkspaceConfiguration: vi.fn(), + updateWorkspaceStepConfiguration: vi.fn(), + updateWorkspace: vi.fn(), + validateWorkspaceSpec: vi.fn(), workspaceHome: vi.fn(), workspaceInfo: vi.fn(), workspaceSnapshot: vi.fn(), + workspaceSession: vi.fn(), + }, + workspaceCreationJournal: { + abandon: vi.fn(), + allowsRegistration: vi.fn().mockResolvedValue(false), + begin: vi.fn( + async (_ownerWindowId: number, request: EccWorkspaceCreateRequest) => ({ + creationId: 'creation-1', + targetDirectory: request.targetDirectory, + }), + ), + complete: vi.fn().mockResolvedValue(undefined), + continueInitialization: vi.fn(), + entriesForWindow: vi.fn().mockResolvedValue([]), + generation: 0, + markUnfinished: vi.fn().mockResolvedValue(undefined), + markWorkspaceCreated: vi.fn().mockResolvedValue(undefined), + onInvalidated: vi.fn(() => () => undefined), + registerWorkspace: vi.fn().mockResolvedValue(undefined), + }, + shutdownCoordinator: { + beginAcceptedWork: vi.fn(() => vi.fn()), + cancelShutdown: vi.fn(), + completeRendererCleanup: vi.fn(), + isMutationBlocked: vi.fn(() => false), + onStatusChanged: vi.fn(() => () => undefined), + reviewShutdownOptions: vi.fn(), + statusForWindow: vi.fn( + (): DesktopShutdownStatus => ({ + activeFlows: 0, + attemptId: null, + finalizations: 0, + forceEligible: false, + pendingCreations: 0, + scope: null, + snapshotFailures: 0, + state: 'idle', + }), + ), + trackWorkspaceHandle: vi.fn(), + untrackWorkspaceHandle: vi.fn(), }, frontendRpcRuntimeService: { cancelOperationLegacy: vi.fn(), @@ -231,7 +316,6 @@ function registerHandlers( rpcShutdown: vi.fn(), runFlow: vi.fn(), runStep: vi.fn(), - syncConfig: vi.fn(), validateConfig: vi.fn(), workspaceHome: vi.fn(), workspaceInfo: vi.fn(), @@ -263,6 +347,40 @@ function registerHandlers( } } +function openBackendWorkspace( + handlers: Map, + event: { sender: unknown }, + request: { directory: string }, +) { + return handlers.get(desktopApiIpcChannels.designRuntimeWorkspaceOpen)?.(event, { + ...request, + designTool: 'backend', + }) +} + +function closeBackendWorkspace( + handlers: Map, + event: { sender: unknown }, + request: { workspaceHandle: string }, +) { + return handlers.get(desktopApiIpcChannels.designRuntimeWorkspaceClose)?.(event, { + ...request, + designTool: 'backend', + }) +} + +function workspaceCreateRequest( + request: Partial, +): EccWorkspaceCreateRequest { + return { + commandId: 'workspace-create', + targetDirectory: '/tmp/workspace', + workspaceBindings: { inputs: {}, pdk: {} }, + workspaceSpec: { pdk: { familyId: 'ics55', mode: 'default' } }, + ...request, + } +} + function createWindowDouble(isMaximized = false) { return { close: vi.fn(), @@ -319,6 +437,199 @@ describe('registerIpc', () => { ) }) + it('rejects new backend mutations while the owning scope is draining', async () => { + const { handlers, services } = registerHandlers() + services.shutdownCoordinator.isMutationBlocked.mockReturnValue(true) + + await expect( + handlers.get(desktopApiIpcChannels.productCommandExecute)?.( + { sender: { id: 7 } }, + { + command: 'workspace.create', + payload: workspaceCreateRequest({ commandId: 'blocked-create' }), + }, + ), + ).resolves.toEqual({ + error: { + code: 'SHUTDOWN_IN_PROGRESS', + message: 'Shutdown is in progress.', + name: 'Error', + }, + ok: false, + }) + expect(services.eccRuntimeService.createWorkspace).not.toHaveBeenCalled() + + await expect( + handlers.get(desktopApiIpcChannels.productCommandExecute)?.( + { sender: { id: 7 } }, + { + command: 'workspace.continueCreation', + payload: { creationId: 'creation-1' }, + }, + ), + ).resolves.toMatchObject({ + error: { code: 'SHUTDOWN_IN_PROGRESS' }, + ok: false, + }) + expect( + services.workspaceCreationJournal.continueInitialization, + ).not.toHaveBeenCalled() + + await expect( + handlers.get(desktopApiIpcChannels.workspaceWriteProjectTextFile)?.( + { sender: { id: 7 } }, + '/projects/demo/home/parameters.json', + '{}', + ), + ).resolves.toMatchObject({ + error: { code: 'SHUTDOWN_IN_PROGRESS' }, + ok: false, + }) + expect(services.workspaceService.writeProjectTextFile).not.toHaveBeenCalled() + + await expect( + handlers.get(desktopApiIpcChannels.workspaceDiscardFailedWorkspaceCreate)?.( + { sender: { id: 7 } }, + '/projects/demo/ws_1', + ), + ).resolves.toMatchObject({ + error: { code: 'SHUTDOWN_IN_PROGRESS' }, + ok: false, + }) + expect(services.workspaceService.discardFailedWorkspaceCreate).not.toHaveBeenCalled() + }) + + it('tracks an accepted mutating command until its handler settles', async () => { + const { handlers, services } = registerHandlers() + const finish = vi.fn() + services.shutdownCoordinator.beginAcceptedWork.mockReturnValueOnce(finish) + + await handlers.get(desktopApiIpcChannels.workspaceWriteProjectTextFile)?.( + { sender: { id: 7 } }, + 'notes.txt', + 'ready', + ) + + expect(services.shutdownCoordinator.beginAcceptedWork).toHaveBeenCalledWith(7) + expect(finish).toHaveBeenCalledOnce() + }) + + it('allows only the exact active creation registration while draining', async () => { + const { handlers, services } = registerHandlers() + const event = { sender: { id: 7 } } + const request = { + mutation: { + input: { + projectRoot: '/projects/demo', + workspacePath: '/projects/demo/ws_1', + }, + type: 'register-workspace', + }, + projectRoot: '/projects/demo', + } + services.shutdownCoordinator.isMutationBlocked.mockReturnValue(true) + + await expect( + handlers.get(desktopApiIpcChannels.projectManifestMutate)?.(event, request), + ).resolves.toMatchObject({ + error: { code: 'SHUTDOWN_IN_PROGRESS' }, + ok: false, + }) + + services.workspaceCreationJournal.allowsRegistration.mockResolvedValueOnce(true) + await handlers.get(desktopApiIpcChannels.projectManifestMutate)?.(event, request) + expect(services.projectManifestService.mutate).toHaveBeenCalledWith(request) + expect(services.workspaceCreationJournal.allowsRegistration).toHaveBeenCalledWith( + 7, + '/projects/demo', + '/projects/demo/ws_1', + ) + }) + + it('matches Renderer cleanup acknowledgements to the sending window', async () => { + const { handlers, services } = registerHandlers() + + await handlers.get(desktopApiIpcChannels.shutdownCompleteCleanup)?.( + { sender: { id: 7 } }, + { attemptId: 'attempt-1', ok: true }, + ) + + expect(services.shutdownCoordinator.completeRendererCleanup).toHaveBeenCalledWith( + 'attempt-1', + 7, + true, + undefined, + ) + }) + + it('closes only the sending window Project Comparison context', async () => { + const { handlers, services } = registerHandlers() + + await handlers.get(desktopApiIpcChannels.backendProjectComparisonCloseProject)?.( + { sender: { id: 7 } }, + { projectComparisonContextId: 'context-1' }, + ) + + expect(services.backendProjectComparisonService.closeProject).toHaveBeenCalledWith( + 7, + 'context-1', + ) + }) + + it('queries Project execution state for only the sending window context', async () => { + const { handlers, services } = registerHandlers() + const result = { + data: { operations: [] }, + generation: 0, + ok: true, + projectComparisonContextId: 'context-1', + } + services.backendProjectComparisonService.getExecutionSnapshot.mockResolvedValue( + result, + ) + + await expect( + handlers.get(desktopApiIpcChannels.backendProjectComparisonGetExecutionSnapshot)?.( + { sender: { id: 7 } }, + { projectComparisonContextId: 'context-1' }, + ), + ).resolves.toEqual(result) + expect( + services.backendProjectComparisonService.getExecutionSnapshot, + ).toHaveBeenCalledWith(7, 'context-1') + }) + + it('queries Step Findings without accepting a Renderer path', async () => { + const { handlers, services } = registerHandlers() + const request = { + projectComparisonContextId: 'context-1', + projectWorkspaceId: 'ws_1', + step: 'Route', + } + const result = { ok: false, code: 'ARTIFACT_REFERENCE_MISSING' } + services.backendProjectComparisonService.getStepFindings.mockResolvedValue(result) + + await expect( + handlers.get(desktopApiIpcChannels.backendProjectComparisonGetStepFindings)?.( + { sender: { id: 7 } }, + request, + ), + ).resolves.toEqual(result) + expect(services.backendProjectComparisonService.getStepFindings).toHaveBeenCalledWith( + 7, + request, + ) + await expect( + handlers.get(desktopApiIpcChannels.backendProjectComparisonGetStepFindings)?.( + { sender: { id: 7 } }, + { ...request, projectWorkspaceId: undefined, workspacePath: '/work/ws_1' }, + ), + ).resolves.toMatchObject({ + ok: false, + error: { message: 'Backend project Findings query is invalid.' }, + }) + }) + it('requires native confirmation before approving external frontend roots', async () => { const { handlers, services } = registerHandlers() const event = { sender: { id: 7 } } @@ -402,6 +713,75 @@ describe('registerIpc', () => { expect(services.eccRuntimeService.openWorkspace).not.toHaveBeenCalled() }) + it('hydrates Workspace Agent knobs from ECC domain APIs', async () => { + const agentRuntimeService = { + interrupt: vi.fn(), + onEvent: vi.fn(() => () => undefined), + sendMessage: vi.fn(), + start: vi.fn(), + startSession: vi.fn(async (request) => ({ sessionId: request.sessionId })), + } as unknown as DesktopBridgeServices['agentRuntimeService'] + const { handlers, services } = registerHandlers(agentRuntimeService) + services.eccRuntimeService.workspaceSnapshot.mockResolvedValue({ + configuration: { + workspaceSpec: { + design: { name: 'gcd' }, + parameters: { + 'place.target_density': 0.4, + 'cts.skew_bound': '0.08', + }, + }, + }, + directory: '/runs/gcd', + engineeringSnapshot: { workspaceRevision: 4 }, + }) + const sender = { + id: 42, + isDestroyed: vi.fn(() => false), + once: vi.fn(), + } + + await handlers.get(desktopApiIpcChannels.agentStartSession)?.( + { sender }, + { + directory: '/runs/gcd', + mode: 'workspace', + providerId: 'ecos_agent', + sessionId: 'session-1', + workspaceId: 'workspace-1', + }, + ) + + expect(agentRuntimeService?.startSession).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceDesignId: 'gcd', + workspaceRevision: 4, + workspaceParameterValues: expect.objectContaining({ + 'place.target_density': 0.4, + 'cts.skew_bound': 0.08, + }), + }), + ) + expect( + services.eccRuntimeService.readWorkspaceStepConfiguration, + ).not.toHaveBeenCalled() + + services.eccRuntimeService.workspaceSnapshot.mockResolvedValue({ + engineeringSnapshot: { workspaceRevision: 5 }, + }) + await handlers.get(desktopApiIpcChannels.agentSendMessage)?.( + { sender }, + { + message: 'lower target density', + providerId: 'ecos_agent', + sessionId: 'session-1', + }, + ) + expect(agentRuntimeService?.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ workspaceRevision: 5 }), + ) + }) + it('binds rerun tokens to the agent window and its source workspace', async () => { let emitAgentEvent: ((event: Record) => void) | undefined const agentRuntimeService = { @@ -636,7 +1016,11 @@ describe('registerIpc', () => { directory: contract.target_workspace, workspaceHandle: 'target-gui-handle', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + services.eccRuntimeService.workspaceSnapshot.mockResolvedValue({ + engineeringSnapshot: { workspaceRevision: 1 }, + }) + await openBackendWorkspace( + handlers, { sender: owner }, { directory: contract.target_workspace }, ) @@ -651,6 +1035,7 @@ describe('registerIpc', () => { contract, services.eccRuntimeService, 'target-gui-handle', + 1, ) }) @@ -718,7 +1103,11 @@ describe('registerIpc', () => { directory: '/canonical/gcd_rerun_place', workspaceHandle: 'aliased-handle', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + services.eccRuntimeService.workspaceSnapshot.mockResolvedValue({ + engineeringSnapshot: { workspaceRevision: 1 }, + }) + await openBackendWorkspace( + handlers, { sender: owner }, { directory: contract.target_workspace }, ) @@ -733,6 +1122,7 @@ describe('registerIpc', () => { contract, services.eccRuntimeService, 'aliased-handle', + 1, ) }) @@ -809,6 +1199,18 @@ describe('registerIpc', () => { ...session, message: '', }) + + const confirmationToken = '00000000-0000-4000-8000-000000000001' + await handlers.get(desktopApiIpcChannels.agentSendMessage)?.(event, { + ...session, + confirmationToken, + message: '1', + }) + expect(agentRuntimeService?.sendMessage).toHaveBeenLastCalledWith({ + ...session, + confirmationToken, + message: '1', + }) expect(agentRuntimeService?.interrupt).toHaveBeenCalledWith(session) }) @@ -962,17 +1364,6 @@ describe('registerIpc', () => { ) }) - it('delegates ECC ping to the runtime service', async () => { - const { handlers, services } = registerHandlers() - const event = { sender: { id: 'web-contents' } } - services.eccRuntimeService.rpcPing.mockResolvedValue({ ok: true }) - - await expect( - handlers.get(desktopApiIpcChannels.eccRpcPing)?.(event), - ).resolves.toEqual({ ok: true }) - expect(services.eccRuntimeService.rpcPing).toHaveBeenCalledTimes(1) - }) - it('preserves and rejects an invalid existing Binding before workspace creation', async () => { const { handlers, services } = registerHandlers() const event = { sender: { id: 'web-contents' } } @@ -985,11 +1376,10 @@ describe('registerIpc', () => { services.pdkInventoryService.validateWorkspace.mockRejectedValue(error) await expect( - handlers.get(desktopApiIpcChannels.designRuntimeWorkspaceCreate)?.(event, { - designTool: 'backend', - payload: { - directory: '/tmp/workspace', - pdk: 'ics55', + handlers.get(desktopApiIpcChannels.productCommandExecute)?.(event, { + command: 'workspace.create', + payload: workspaceCreateRequest({ + commandId: 'workspace-create-invalid-binding', pdkInstallationId: 'pdk-installation:ics55', projectId: 'proj_demo', projectRoot: '/tmp/project', @@ -998,7 +1388,7 @@ describe('registerIpc', () => { version: null, manualConfig: null, }, - }, + }), }), ).resolves.toEqual({ error: { message: error.message, name: 'Error' }, @@ -1022,13 +1412,11 @@ describe('registerIpc', () => { const event = { sender: { id: 'web-contents' } } await expect( - handlers.get(desktopApiIpcChannels.designRuntimeWorkspaceCreate)?.(event, { - designTool: 'backend', - payload: { - directory: '/tmp/workspace', - pdk: 'vendor-pdk', - pdkRoot: '/tmp/vendor-pdk', - }, + handlers.get(desktopApiIpcChannels.productCommandExecute)?.(event, { + command: 'workspace.create', + payload: workspaceCreateRequest({ + commandId: 'workspace-create-missing-requirement', + }), }), ).resolves.toEqual({ error: { @@ -1043,22 +1431,9 @@ describe('registerIpc', () => { expect(services.eccRuntimeService.createWorkspace).not.toHaveBeenCalled() }) - it('uses the persisted Project Requirement for workspace creation', async () => { + it('uses the requested Project Requirement for workspace creation', async () => { const { handlers, services } = registerHandlers() const event = { sender: { id: 'web-contents' } } - const payload = { - directory: '/tmp/workspace', - pdk: 'ics55', - pdkInstallationId: 'pdk-installation:ics55', - projectId: 'proj_demo', - projectRoot: '/tmp/project', - pdkRequirement: { - familyId: 'ics55', - version: null, - manualConfig: null, - }, - } - const result = { directory: '/tmp/workspace', workspaceHandle: 'workspace-handle' } const persistedRequirement = { familyId: 'ics55', version: null, @@ -1068,21 +1443,25 @@ describe('registerIpc', () => { liberty: ['typ.lib'], }, } - services.projectManagementReadService.readManifest.mockResolvedValue( - JSON.stringify({ - schema_version: 1, - project_id: payload.projectId, - name: 'demo', - design_name: 'demo', - root_path: payload.projectRoot, - created_at: '2026-08-25T00:00:00.000Z', - updated_at: '2026-08-25T00:00:00.000Z', - base_design: { pdk_requirement: persistedRequirement, rtl_list: [] }, - objectives: { primary: 'timing', directions: {} }, - workspaces: [], - best_workspace: null, - }), - ) + const payload = workspaceCreateRequest({ + commandId: 'workspace-create-persisted-requirement', + pdkInstallationId: 'pdk-installation:ics55', + projectId: 'proj_demo', + projectRoot: '/tmp/project', + pdkRequirement: persistedRequirement, + workspaceSpec: { + pdk: { + familyId: 'ics55', + mode: 'manual', + files: [ + { fileId: 'tech', role: 'tech' }, + { fileId: 'lef-1', role: 'lef' }, + { fileId: 'liberty-1', role: 'liberty' }, + ], + }, + }, + }) + const result = { directory: '/tmp/workspace', workspaceHandle: 'workspace-handle' } services.pdkInventoryService.resolveBinding.mockResolvedValue(null) services.pdkInventoryService.bindInstallation.mockResolvedValue({ installationId: payload.pdkInstallationId, @@ -1102,11 +1481,11 @@ describe('registerIpc', () => { services.eccRuntimeService.createWorkspace.mockResolvedValue(result) await expect( - handlers.get(desktopApiIpcChannels.designRuntimeWorkspaceCreate)?.(event, { - designTool: 'backend', + handlers.get(desktopApiIpcChannels.productCommandExecute)?.(event, { + command: 'workspace.create', payload, }), - ).resolves.toEqual(result) + ).resolves.toEqual({ ...result, creationId: 'creation-1' }) expect(services.pdkInventoryService.bindInstallation).toHaveBeenCalledWith({ installationId: payload.pdkInstallationId, requirement: persistedRequirement, @@ -1123,11 +1502,47 @@ describe('registerIpc', () => { projectRoot: payload.projectRoot, requirement: persistedRequirement, }) - expect(services.eccRuntimeService.createWorkspace).toHaveBeenCalledWith({ - directory: payload.directory, - pdk: payload.pdk, - pdkRoot: '/canonical/pdk', - }) + expect(services.eccRuntimeService.createWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + commandId: payload.commandId, + targetDirectory: payload.targetDirectory, + workspaceBindings: { + inputs: {}, + pdk: { + files: { + tech: '/canonical/pdk/tech.lef', + 'lef-1': '/canonical/pdk/cells.lef', + 'liberty-1': '/canonical/pdk/typ.lib', + }, + root: '/canonical/pdk', + }, + }, + workspaceSpec: payload.workspaceSpec, + }), + ) + expect(services.workspaceCreationJournal.begin).toHaveBeenCalledWith( + 0, + expect.objectContaining({ + commandId: payload.commandId, + projectId: payload.projectId, + projectRoot: payload.projectRoot, + }), + ) + expect(services.workspaceCreationJournal.markWorkspaceCreated).toHaveBeenCalledWith( + 'creation-1', + result, + 0, + ) + expect(services.workspaceCreationJournal.registerWorkspace).toHaveBeenCalledWith( + 'creation-1', + 0, + ) + expect(services.workspaceCreationJournal.complete).not.toHaveBeenCalled() + expect( + services.workspaceCreationJournal.begin.mock.invocationCallOrder[0], + ).toBeLessThan( + services.eccRuntimeService.createWorkspace.mock.invocationCallOrder[0]!, + ) }) it('waits for a runtime operation through the main-process tracker', async () => { @@ -1215,14 +1630,13 @@ describe('registerIpc', () => { event, ) await handlers.get(desktopApiIpcChannels.windowClose)?.(event) - await handlers.get(desktopApiIpcChannels.windowConfirmClose)?.(event) - expect(fromWebContents).toHaveBeenCalledTimes(5) + expect(fromWebContents).toHaveBeenCalledTimes(4) expect(fromWebContents).toHaveBeenNthCalledWith(1, event.sender) expect(windowDouble.minimize).toHaveBeenCalledTimes(1) expect(windowDouble.setTitle).toHaveBeenCalledWith('ECOS Studio') expect(isMaximized).toBe(false) - expect(windowDouble.close).toHaveBeenCalledTimes(2) + expect(windowDouble.close).toHaveBeenCalledTimes(1) }) it('applies valid zoom factors and rejects values outside the supported range', async () => { @@ -1423,44 +1837,19 @@ describe('registerIpc', () => { truncated: true, sizeBytes: 4096, }) - services.workspaceService.readOptionalProjectTextFileUpdate.mockResolvedValue({ - content: 'next log', - fromOffsetBytes: 1024, - nextOffsetBytes: 1032, - sizeBytes: 1032, - reset: false, - truncated: false, - }) - services.workspaceService.subscribeProjectLogTail.mockImplementation( - async (_path, _options, listener) => { - listener({ - subscriptionId: 'project-log-tail-1', - path: '/tmp/project/Synthesis_yosys/log/Synthesis.log', - eventType: 'snapshot', - content: 'live log', - fromOffsetBytes: 0, - nextOffsetBytes: 8, - sizeBytes: 8, - reset: false, - truncated: false, - }) - return 'project-log-tail-1' - }, - ) services.workspaceService.readProjectBinaryFile.mockResolvedValue( Uint8Array.from([0x45, 0x43, 0x4f, 0x53]), ) services.workspaceService.registerProjectRoot.mockResolvedValue('/tmp/project') services.workspaceService.registerProjectReadRoot.mockResolvedValue('/tmp/project') - services.projectManagementReadService.readManifest.mockResolvedValue('{"name":"gcd"}') + services.projectManagementReadService.discoverProject.mockResolvedValue({ + name: 'gcd', + }) + services.projectManagementReadService.readManifest.mockResolvedValue({ name: 'gcd' }) services.projectManagementReadService.listProjectEntries.mockResolvedValue([ 'project.json', 'ws_0001', ]) - services.projectManagementReadService.readWorkspaceTexts.mockResolvedValue({ - texts: { 'home/flow.json': '{"steps":[]}' }, - unavailablePaths: [], - }) services.workspaceService.requestProjectPathAccess.mockResolvedValue( '/tmp/project/home.json', ) @@ -1533,28 +1922,24 @@ describe('registerIpc', () => { '/tmp/project', ), ).resolves.toBe('/tmp/project') + await expect( + handlers.get(desktopApiIpcChannels.projectManagementDiscoverProject)?.( + event, + '/tmp/project/ws_0001', + ), + ).resolves.toEqual({ name: 'gcd' }) await expect( handlers.get(desktopApiIpcChannels.projectManagementReadManifest)?.( event, '/tmp/project', ), - ).resolves.toBe('{"name":"gcd"}') + ).resolves.toEqual({ name: 'gcd' }) await expect( handlers.get(desktopApiIpcChannels.projectManagementListEntries)?.( event, '/tmp/project', ), ).resolves.toEqual(['project.json', 'ws_0001']) - await expect( - handlers.get(desktopApiIpcChannels.projectManagementReadWorkspaceTexts)?.(event, { - projectRoot: '/tmp/project', - workspacePath: '/tmp/project/ws_0001', - paths: ['home/flow.json'], - }), - ).resolves.toEqual({ - texts: { 'home/flow.json': '{"steps":[]}' }, - unavailablePaths: [], - }) await handlers.get(desktopApiIpcChannels.workspaceClearProjectRoot)?.(event) await expect( handlers.get(desktopApiIpcChannels.workspaceRequestProjectPathAccess)?.( @@ -1592,17 +1977,6 @@ describe('registerIpc', () => { truncated: true, sizeBytes: 4096, }) - await expect( - handlers.get(desktopApiIpcChannels.workspaceReadOptionalProjectTextFileUpdate)?.( - event, - '/tmp/project/Synthesis_yosys/log/Synthesis.log', - 1024, - 2048, - ), - ).resolves.toMatchObject({ - content: 'next log', - nextOffsetBytes: 1032, - }) await expect( handlers.get(desktopApiIpcChannels.workspaceReadOptionalProjectTextFileChunk)?.( event, @@ -1614,16 +1988,6 @@ describe('registerIpc', () => { content: 'complete log', eof: true, }) - await expect( - handlers.get(desktopApiIpcChannels.workspaceSubscribeProjectLogTail)?.( - event, - '/tmp/project/Synthesis_yosys/log/Synthesis.log', - { - maxInitialChars: 1024, - maxChunkChars: 1024, - }, - ), - ).resolves.toBe('project-log-tail-1') await expect( handlers.get(desktopApiIpcChannels.workspaceReadProjectBinaryFile)?.( event, @@ -1702,20 +2066,9 @@ describe('registerIpc', () => { expect( services.workspaceService.readOptionalProjectTextFileTail, ).toHaveBeenCalledWith('/tmp/project/Synthesis_yosys/log/Synthesis.log', 1024) - expect( - services.workspaceService.readOptionalProjectTextFileUpdate, - ).toHaveBeenCalledWith('/tmp/project/Synthesis_yosys/log/Synthesis.log', 1024, 2048) expect( services.workspaceService.readOptionalProjectTextFileChunk, ).toHaveBeenCalledWith('/tmp/project/Synthesis_yosys/log/Synthesis.log', 0, 262144) - expect(services.workspaceService.subscribeProjectLogTail).toHaveBeenCalledWith( - '/tmp/project/Synthesis_yosys/log/Synthesis.log', - { - maxInitialChars: 1024, - maxChunkChars: 1024, - }, - expect.any(Function), - ) expect(services.workspaceService.readProjectBinaryFile).toHaveBeenCalledWith( '/tmp/project/output/preview.bin', ) @@ -1931,26 +2284,119 @@ describe('registerIpc', () => { }) }) - it('runs ECC flow steps through the runtime service', async () => { + it('reads Backend Step Options from the owned ECC Workspace Session', async () => { const { handlers, services } = registerHandlers() - const event = { sender: { id: 'web-contents' } } - const result = { - state: 'Success', - step: 'place', - } - const request = { - rerun: false, - step: 'place', - workspaceHandle: 'workspace-handle-1', - } - services.eccRuntimeService.runStep.mockResolvedValue(result) - - await expect( - handlers.get(desktopApiIpcChannels.eccFlowRunStep)?.(event, request), - ).resolves.toEqual(result) - - expect(services.eccRuntimeService.runStep).toHaveBeenCalledWith(request) - }) + const event = { sender: { id: 42 } } + services.eccRuntimeService.openWorkspace.mockResolvedValue({ + directory: '/work/demo', + workspaceHandle: 'workspace-1', + }) + services.eccRuntimeService.readWorkspaceStepConfiguration.mockResolvedValue({ + options: { skew_bound: 0.08 }, + status: 'available', + step: 'CTS', + stepId: 'CTS', + workspaceId: 'workspace-1', + workspaceRevision: 1, + }) + await openBackendWorkspace(handlers, event, { directory: '/work/demo' }) + + await expect( + handlers.get(desktopApiIpcChannels.designRuntimeWorkspaceStepConfiguration)?.( + event, + { + designTool: 'backend', + step: 'CTS', + workspaceHandle: 'workspace-1', + }, + ), + ).resolves.toEqual({ + options: { skew_bound: 0.08 }, + status: 'available', + step: 'CTS', + stepId: 'CTS', + workspaceId: 'workspace-1', + workspaceRevision: 1, + }) + expect(services.workspaceResourceService.resolveStepInfo).not.toHaveBeenCalled() + }) + + it('delegates Backend Workspace queries to the scenario service', async () => { + const { handlers, services } = registerHandlers() + const event = { sender: { id: 41 } } + const result = { + generation: 0, + overview: { identity: { workspaceName: 'Workspace A' } }, + workspaceContextId: 'workspace-context-1', + } + services.backendWorkspaceService.getOverview.mockResolvedValue(result) + const detailRequest = { + stepId: 'Place', + workspaceContextId: 'workspace-context-1', + workspaceRevision: 9, + } + const detail = { detail: { status: 'unavailable', issues: [] } } + services.backendWorkspaceService.getStepDetail.mockResolvedValue(detail) + services.backendWorkspaceService.getArtifact.mockResolvedValue(detail) + services.backendWorkspaceService.refreshOverview.mockResolvedValue(result) + + await expect( + handlers.get(desktopApiIpcChannels.backendWorkspaceGetOverview)?.(event), + ).resolves.toEqual(result) + await expect( + handlers.get(desktopApiIpcChannels.backendWorkspaceGetStepDetail)?.( + event, + detailRequest, + ), + ).resolves.toEqual(detail) + await expect( + handlers.get(desktopApiIpcChannels.backendWorkspaceGetArtifact)?.(event, { + artifactId: 'layout-place', + workspaceContextId: 'workspace-context-1', + workspaceRevision: 9, + }), + ).resolves.toEqual(detail) + await expect( + handlers.get(desktopApiIpcChannels.backendWorkspaceRefreshOverview)?.(event), + ).resolves.toEqual(result) + + expect(services.backendWorkspaceService.getOverview).toHaveBeenCalledTimes(1) + expect(services.backendWorkspaceService.getStepDetail).toHaveBeenCalledWith( + detailRequest, + ) + expect(services.backendWorkspaceService.getArtifact).toHaveBeenCalledWith({ + artifactId: 'layout-place', + workspaceContextId: 'workspace-context-1', + workspaceRevision: 9, + }) + expect(services.backendWorkspaceService.refreshOverview).toHaveBeenCalledTimes(1) + }) + + it('forwards Backend Workspace invalidation only to its owning window', () => { + const send = vi.fn() + getAllWindows.mockReturnValue([ + { + isDestroyed: () => false, + webContents: { id: 41, send }, + } as unknown as MockBrowserWindow, + ]) + const { services } = registerHandlers() + const listener = services.backendWorkspaceService.onInvalidated.mock.calls[0]?.[0] + + listener?.({ + generation: 2, + windowId: 41, + workspaceContextId: 'workspace-context-1', + }) + + expect(send).toHaveBeenCalledWith( + desktopApiEventChannels.backendWorkspaceInvalidated, + { + generation: 2, + workspaceContextId: 'workspace-context-1', + }, + ) + }) it('exports ECC signoff through the runtime service', async () => { const { handlers, services } = registerHandlers() @@ -1960,27 +2406,374 @@ describe('registerIpc', () => { workspaceHandle: 'workspace-handle-1', } const result = { outputPath: request.outputPath } + services.eccRuntimeService.openWorkspace.mockResolvedValue({ + directory: '/work/demo', + workspaceHandle: request.workspaceHandle, + }) services.eccRuntimeService.exportSignoff.mockResolvedValue(result) + await openBackendWorkspace(handlers, event, { + directory: '/work/demo', + }) await expect( - handlers.get(desktopApiIpcChannels.eccWorkspaceExportSignoff)?.(event, request), + handlers.get(desktopApiIpcChannels.productCommandExecute)?.(event, { + command: 'workspace.exportSignoff', + payload: request, + }), ).resolves.toEqual(result) expect(services.eccRuntimeService.exportSignoff).toHaveBeenCalledWith(request) }) - it('inspects ECC signoff through the runtime service', async () => { + it('rejects Product Commands from a Renderer that does not own the Workspace', async () => { + const { handlers, services } = registerHandlers() + const owner = { sender: { id: 'owner' } } + services.eccRuntimeService.openWorkspace.mockResolvedValue({ + directory: '/work/demo', + workspaceHandle: 'workspace-handle-1', + }) + await openBackendWorkspace(handlers, owner, { + directory: '/work/demo', + }) + + await expect( + handlers.get(desktopApiIpcChannels.productCommandExecute)?.( + { sender: { id: 'other' } }, + { + command: 'workspace.run', + payload: { + expectedWorkspaceRevision: 1, + idempotencyKey: 'command-1', + workspaceHandle: 'workspace-handle-1', + }, + }, + ), + ).resolves.toEqual({ + error: { + message: 'Product Command does not own this Workspace handle', + name: 'Error', + }, + ok: false, + }) + expect(services.eccRuntimeService.startFlowOperation).not.toHaveBeenCalled() + }) + + it('reads the Engineering Snapshot through the runtime service', async () => { const { handlers, services } = registerHandlers() const event = { sender: { id: 'web-contents' } } - const request = { workspaceHandle: 'workspace-handle-1' } - const result = { groups: [], risks: [], status: 'ready' } - services.eccRuntimeService.inspectSignoff.mockResolvedValue(result) + const request = { + expectedWorkspaceRevision: 7, + workspaceHandle: 'workspace-handle-1', + } + const result = { workspaceId: 'workspace-1', workspaceRevision: 7 } + services.eccRuntimeService.engineeringSnapshot.mockResolvedValue(result) await expect( - handlers.get(desktopApiIpcChannels.eccWorkspaceInspectSignoff)?.(event, request), + handlers.get(desktopApiIpcChannels.eccRuntimeEngineeringSnapshot)?.(event, request), ).resolves.toEqual(result) - expect(services.eccRuntimeService.inspectSignoff).toHaveBeenCalledWith(request) + expect(services.eccRuntimeService.engineeringSnapshot).toHaveBeenCalledWith(request) + }) + + it('returns and invalidates only Operations owned by the sending window', async () => { + const { handlers, services } = registerHandlers() + const ownerSend = vi.fn() + const otherSend = vi.fn() + const ownerSender = Object.assign(new EventEmitter(), { + id: 11, + isDestroyed: vi.fn(() => false), + send: ownerSend, + }) + const otherSender = Object.assign(new EventEmitter(), { + id: 22, + isDestroyed: vi.fn(() => false), + send: otherSend, + }) + services.eccRuntimeService.openWorkspace + .mockResolvedValueOnce({ + directory: '/work/a', + workspaceHandle: 'workspace-handle-a', + }) + .mockResolvedValueOnce({ + directory: '/work/b', + workspaceHandle: 'workspace-handle-b', + }) + await openBackendWorkspace( + handlers, + { sender: ownerSender }, + { directory: '/work/a' }, + ) + await openBackendWorkspace( + handlers, + { sender: otherSender }, + { directory: '/work/b' }, + ) + services.eccRuntimeService.operationProjection.mockReturnValue({ + creations: [], + finalizations: [], + generation: 4, + operations: [ + { operationId: 'operation-a', workspaceHandle: 'workspace-handle-a' }, + { operationId: 'operation-b', workspaceHandle: 'workspace-handle-b' }, + ], + outcomes: [], + } as unknown as EccBackgroundOperationProjection) + + await expect( + handlers.get(desktopApiIpcChannels.eccRuntimeOperationProjection)?.({ + sender: ownerSender, + }), + ).resolves.toEqual({ + creations: [], + finalizations: [], + generation: 4, + operations: [{ operationId: 'operation-a', workspaceHandle: 'workspace-handle-a' }], + outcomes: [], + }) + await handlers.get(desktopApiIpcChannels.eccRuntimeOperationProjection)?.({ + sender: otherSender, + }) + + const invalidate = + services.eccRuntimeService.onOperationProjectionInvalidated.mock.calls[0]?.[0] + services.eccRuntimeService.operationProjection.mockReturnValue({ + creations: [], + finalizations: [], + generation: 5, + operations: [], + outcomes: [], + }) + invalidate?.(5) + expect(ownerSend).toHaveBeenCalledWith( + desktopApiEventChannels.eccRuntimeOperationProjectionInvalidated, + { generation: 5 }, + ) + expect(otherSend).toHaveBeenCalledWith( + desktopApiEventChannels.eccRuntimeOperationProjectionInvalidated, + { generation: 5 }, + ) + }) + + it('retains released terminal outcomes for the window that owned the handle', async () => { + const { handlers, services } = registerHandlers() + const ownerSender = Object.assign(new EventEmitter(), { + id: 11, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }) + services.eccRuntimeService.openWorkspace.mockResolvedValue({ + directory: '/work/a', + workspaceHandle: 'workspace-handle-a', + }) + await openBackendWorkspace( + handlers, + { sender: ownerSender }, + { directory: '/work/a' }, + ) + + const released = services.eccRuntimeService.onWorkspaceReleased.mock.calls[0]?.[0] + released?.('workspace-handle-a') + services.eccRuntimeService.operationProjection.mockReturnValue({ + creations: [], + finalizations: [], + generation: 5, + operations: [], + outcomes: [{ operationId: 'operation-a', workspaceHandle: 'workspace-handle-a' }], + } as unknown as EccBackgroundOperationProjection) + + await expect( + handlers.get(desktopApiIpcChannels.eccRuntimeOperationProjection)?.({ + sender: ownerSender, + }), + ).resolves.toMatchObject({ + outcomes: [{ operationId: 'operation-a', workspaceHandle: 'workspace-handle-a' }], + }) + await expect( + handlers.get(desktopApiIpcChannels.eccRuntimeOperationProjection)?.({ + sender: Object.assign(new EventEmitter(), { + id: 22, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }), + }), + ).resolves.toMatchObject({ outcomes: [] }) + }) + + it('reuses a tracked backend Workspace Session only for its owning window', async () => { + const { handlers, services } = registerHandlers() + const ownerSender = Object.assign(new EventEmitter(), { + id: 11, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }) + services.eccRuntimeService.openWorkspace.mockResolvedValue({ + directory: '/work/demo', + workspaceHandle: 'workspace-handle-1', + workspaceId: 'engineering-1', + workspaceRevision: 7, + }) + services.eccRuntimeService.workspaceSession.mockReturnValue({ + directory: '/work/demo', + reused: true, + workspaceHandle: 'workspace-handle-1', + workspaceId: 'engineering-1', + workspaceRevision: 7, + }) + + await openBackendWorkspace( + handlers, + { sender: ownerSender }, + { directory: '/work/demo' }, + ) + await expect( + openBackendWorkspace( + handlers, + { sender: ownerSender }, + { directory: '/work/demo/' }, + ), + ).resolves.toMatchObject({ + reused: true, + workspaceHandle: 'workspace-handle-1', + }) + + expect(services.eccRuntimeService.openWorkspace).toHaveBeenCalledOnce() + expect(services.eccRuntimeService.workspaceSession).toHaveBeenCalledWith( + 'workspace-handle-1', + ) + }) + + it('does not transfer a reused Runtime handle to another window', async () => { + const { handlers, services } = registerHandlers() + const ownerSender = Object.assign(new EventEmitter(), { + id: 11, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }) + const otherSender = Object.assign(new EventEmitter(), { + id: 22, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }) + services.eccRuntimeService.openWorkspace.mockResolvedValue({ + directory: '/work/demo', + workspaceHandle: 'workspace-handle-1', + }) + await openBackendWorkspace( + handlers, + { sender: ownerSender }, + { directory: '/work/demo' }, + ) + + await expect( + openBackendWorkspace( + handlers, + { sender: otherSender }, + { directory: '/work/demo' }, + ), + ).resolves.toMatchObject({ + error: { message: 'Workspace Runtime Session is owned by another window.' }, + ok: false, + }) + expect(services.eccRuntimeService.openWorkspace).toHaveBeenCalledOnce() + + services.eccRuntimeService.operationLog.mockResolvedValue({ + content: 'still owned', + truncated: false, + }) + await expect( + handlers.get(desktopApiIpcChannels.eccRuntimeOperationLog)?.( + { sender: ownerSender }, + { operationId: 'operation-1', workspaceHandle: 'workspace-handle-1' }, + ), + ).resolves.toMatchObject({ content: 'still owned' }) + }) + + it('claims a Workspace directory while an open is in flight', async () => { + const { handlers, services } = registerHandlers() + const ownerSender = Object.assign(new EventEmitter(), { + id: 11, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }) + const otherSender = Object.assign(new EventEmitter(), { + id: 22, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }) + let finishOpen: + | ((value: { directory: string; workspaceHandle: string }) => void) + | undefined + services.eccRuntimeService.openWorkspace.mockReturnValueOnce( + new Promise((resolve) => { + finishOpen = resolve + }), + ) + + const firstOpen = openBackendWorkspace( + handlers, + { sender: ownerSender }, + { directory: '/work/demo' }, + ) + await vi.waitFor(() => + expect(services.eccRuntimeService.openWorkspace).toHaveBeenCalledOnce(), + ) + + await expect( + openBackendWorkspace( + handlers, + { sender: otherSender }, + { directory: '/work/demo' }, + ), + ).resolves.toMatchObject({ + error: { message: 'Workspace Runtime Session is owned by another window.' }, + ok: false, + }) + + finishOpen?.({ directory: '/work/demo', workspaceHandle: 'workspace-handle-1' }) + await expect(firstOpen).resolves.toMatchObject({ + workspaceHandle: 'workspace-handle-1', + }) + expect(services.eccRuntimeService.openWorkspace).toHaveBeenCalledOnce() + }) + + it('loads bounded Operation logs only for the window that owns the handle', async () => { + const { handlers, services } = registerHandlers() + const owner = Object.assign(new EventEmitter(), { + id: 11, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }) + services.eccRuntimeService.openWorkspace.mockResolvedValue({ + directory: '/work/demo', + workspaceHandle: 'workspace-handle-1', + }) + services.eccRuntimeService.operationLog.mockResolvedValue({ + content: 'route completed', + truncated: false, + }) + await openBackendWorkspace(handlers, { sender: owner }, { directory: '/work/demo' }) + const request = { + operationId: 'operation-1', + workspaceHandle: 'workspace-handle-1', + } + + await expect( + handlers.get(desktopApiIpcChannels.eccRuntimeOperationLog)?.( + { sender: owner }, + request, + ), + ).resolves.toEqual({ content: 'route completed', truncated: false }) + await expect( + handlers.get(desktopApiIpcChannels.eccRuntimeOperationLog)?.( + { sender: { id: 22 } }, + request, + ), + ).resolves.toMatchObject({ + error: { + message: 'Operation log request does not own this Workspace handle.', + }, + ok: false, + }) }) it('routes directory-scoped runtime.ready only to the matching workspace window', async () => { @@ -2006,11 +2799,13 @@ describe('registerIpc', () => { directory: '/work/other', workspaceHandle: 'workspace-handle-2', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: ownerSender }, { directory: '/work/demo' }, ) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: otherSender }, { directory: '/work/other' }, ) @@ -2022,7 +2817,8 @@ describe('registerIpc', () => { workspaceDirectory: '/work/demo', }) - expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.eccEvent, { + expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.designRuntimeEvent, { + designTool: 'backend', type: 'runtime.ready', workspaceDirectory: '/work/demo', }) @@ -2053,11 +2849,13 @@ describe('registerIpc', () => { directory: '/work/other', workspaceHandle: 'workspace-handle-2', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: ownerSender }, { directory: '/work/demo' }, ) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: otherSender }, { directory: '/work/other' }, ) @@ -2072,7 +2870,10 @@ describe('registerIpc', () => { } listener?.(exited) - expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.eccEvent, exited) + expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.designRuntimeEvent, { + ...exited, + designTool: 'backend', + }) expect(otherSend).not.toHaveBeenCalled() }) @@ -2094,6 +2895,79 @@ describe('registerIpc', () => { expect(getAllWindows).not.toHaveBeenCalled() }) + it('invalidates every Project execution snapshot for Runtime operation changes', () => { + const { services } = registerHandlers() + const listener = services.eccRuntimeService.onEvent.mock.calls[0]?.[0] + + listener?.({ + event: { + eventId: 'event-1', + operationId: 'operation-1', + origin: 'gui', + payload: { state: 'queued', workspaceRevision: 1 }, + sequence: 1, + timestamp: 1, + type: 'operation.changed', + workspaceId: 'engineering-1', + }, + type: 'runtime.protocol', + }) + + expect( + services.backendProjectComparisonService.invalidateExecution, + ).toHaveBeenCalledOnce() + expect( + services.backendProjectComparisonService.invalidateWorkspace, + ).not.toHaveBeenCalled() + + listener?.({ + event: { + eventId: 'event-prepared', + operationId: 'operation-1', + origin: 'gui', + payload: { + sourceType: 'operation.rerun_prepared', + workspaceRevision: 2, + }, + sequence: 2, + timestamp: 2, + type: 'execution.progress', + workspaceId: 'engineering-1', + }, + type: 'runtime.protocol', + workspaceDirectory: '/work/demo', + }) + + expect( + services.backendProjectComparisonService.invalidateExecution, + ).toHaveBeenCalledTimes(2) + expect( + services.backendProjectComparisonService.invalidateWorkspace, + ).toHaveBeenCalledWith('/work/demo') + + listener?.({ + event: { + eventId: 'event-2', + operationId: 'operation-1', + origin: 'gui', + payload: { state: 'succeeded', workspaceRevision: 2 }, + sequence: 3, + timestamp: 3, + type: 'operation.changed', + workspaceId: 'engineering-1', + }, + type: 'runtime.protocol', + workspaceDirectory: '/work/demo', + }) + + expect( + services.backendProjectComparisonService.invalidateExecution, + ).toHaveBeenCalledTimes(3) + expect( + services.backendProjectComparisonService.invalidateWorkspace, + ).toHaveBeenCalledWith('/work/demo') + }) + it('matches directory-scoped events after normalizing trailing slashes', async () => { const { handlers, services } = registerHandlers() const ownerSend = vi.fn() @@ -2106,7 +2980,8 @@ describe('registerIpc', () => { directory: '/work/demo/', workspaceHandle: 'workspace-handle-1', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: ownerSender }, { directory: '/work/demo/' }, ) @@ -2117,7 +2992,8 @@ describe('registerIpc', () => { workspaceDirectory: '/work/demo', }) - expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.eccEvent, { + expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.designRuntimeEvent, { + designTool: 'backend', type: 'runtime.ready', workspaceDirectory: '/work/demo', }) @@ -2146,11 +3022,13 @@ describe('registerIpc', () => { directory: '/work/other', workspaceHandle: 'workspace-handle-2', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: ownerSender }, { directory: '/work/demo' }, ) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: otherSender }, { directory: '/work/other' }, ) @@ -2162,7 +3040,8 @@ describe('registerIpc', () => { workspaceDirectory: '/work/demo', }) - expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.eccEvent, { + expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.designRuntimeEvent, { + designTool: 'backend', text: 'yosys: warning', type: 'runtime.stderr', workspaceDirectory: '/work/demo', @@ -2188,12 +3067,14 @@ describe('registerIpc', () => { directory: '/work/demo', workspaceHandle: 'workspace-handle-1', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: ownerSender }, { directory: '/work/demo' }, ) - expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.eccEvent, { + expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.designRuntimeEvent, { + designTool: 'backend', type: 'runtime.ready', workspaceDirectory: '/work/demo', }) @@ -2222,11 +3103,13 @@ describe('registerIpc', () => { directory: '/work/other', workspaceHandle: 'workspace-handle-2', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: ownerSender }, { directory: '/work/demo' }, ) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: otherSender }, { directory: '/work/other' }, ) @@ -2241,7 +3124,7 @@ describe('registerIpc', () => { }) expect(ownerSend).toHaveBeenCalledWith( - desktopApiEventChannels.eccEvent, + desktopApiEventChannels.designRuntimeEvent, expect.objectContaining({ type: 'operation.started', workspaceHandle: 'workspace-handle-1', @@ -2251,6 +3134,48 @@ describe('registerIpc', () => { expect(getAllWindows).not.toHaveBeenCalled() }) + it('invalidates Backend Workspace before forwarding a committed step event', async () => { + const { handlers, services } = registerHandlers() + const send = vi.fn() + const sender = Object.assign(new EventEmitter(), { + id: 11, + isDestroyed: vi.fn(() => false), + send, + }) + services.eccRuntimeService.openWorkspace.mockResolvedValue({ + directory: '/work/demo', + workspaceHandle: 'workspace-handle-1', + }) + await openBackendWorkspace(handlers, { sender }, { directory: '/work/demo' }) + + const listener = services.eccRuntimeService.onEvent.mock.calls[0]?.[0] + listener?.({ + event: { + eventId: 'workspace-1:3', + operationId: 'operation-1', + origin: 'gui', + payload: { sourceType: 'step.completed', state: 'Success' }, + sequence: 3, + timestamp: 1, + type: 'workspace.committed', + workspaceId: 'workspace-1', + }, + type: 'runtime.protocol', + workspaceHandle: 'workspace-handle-1', + }) + + expect(services.backendWorkspaceService.invalidateWindow).toHaveBeenCalledWith(11) + expect( + services.backendProjectComparisonService.invalidateWorkspace, + ).toHaveBeenCalledOnce() + expect( + services.backendProjectComparisonService.invalidateWorkspace, + ).toHaveBeenCalledWith('/work/demo') + expect( + services.backendWorkspaceService.invalidateWindow.mock.invocationCallOrder[0], + ).toBeLessThan(send.mock.invocationCallOrder[0]!) + }) + it('streams frontend subflow progress to its subscribed workspace window', async () => { const { handlers, services } = registerHandlers() const ownerSend = vi.fn() @@ -2533,7 +3458,7 @@ describe('registerIpc', () => { expect(services.createWindow).toHaveBeenCalledWith({ initialRoute: '/' }) }) - it('detaches a renderer without closing its ECC workspace runtime', async () => { + it('marks a destroyed renderer Workspace lease for lifecycle-aware release', async () => { const { handlers, services } = registerHandlers() const sender = Object.assign(new EventEmitter(), { isDestroyed: vi.fn(() => false), @@ -2545,7 +3470,7 @@ describe('registerIpc', () => { }) await expect( - handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.(event, { + openBackendWorkspace(handlers, event, { directory: '/work/demo', }), ).resolves.toEqual({ @@ -2555,69 +3480,17 @@ describe('registerIpc', () => { expect(sender.listenerCount('destroyed')).toBe(1) sender.emit('destroyed') - const explicitClose = handlers.get(desktopApiIpcChannels.eccWorkspaceClose)?.(event, { + const explicitClose = closeBackendWorkspace(handlers, event, { workspaceHandle: 'workspace-handle-1', }) await explicitClose expect(services.eccRuntimeService.closeWorkspace).not.toHaveBeenCalled() - expect(sender.listenerCount('destroyed')).toBe(0) - }) - - it('acknowledges a committed GUI step from main after its renderer detaches', async () => { - const { handlers, services } = registerHandlers() - const sender = Object.assign(new EventEmitter(), { - isDestroyed: vi.fn(() => false), - send: vi.fn(), - }) - const event = { sender } - services.eccRuntimeService.openWorkspace.mockResolvedValue({ - directory: '/work/demo', - workspaceHandle: 'workspace-handle-1', - }) - services.eccRuntimeService.acknowledgeDetachedStepRendered.mockResolvedValue({ - accepted: true, - }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.(event, { - directory: '/work/demo', - }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceClose)?.(event, { + expect(services.eccRuntimeService.releaseWorkspace).toHaveBeenCalledWith({ workspaceHandle: 'workspace-handle-1', }) - - const listener = services.eccRuntimeService.onEvent.mock.calls[0]?.[0] - listener?.({ - type: 'runtime.protocol', - workspaceDirectory: '/work/demo', - workspaceHandle: 'workspace-handle-1', - event: { - eventId: 'workspace-1:3', - operationId: 'operation-1', - origin: 'gui', - payload: { - state: 'Success', - stepCommitId: 'operation-1:step:1', - workspaceRevision: 1, - }, - sequence: 3, - timestamp: 1, - type: 'step.completed', - workspaceId: 'workspace-1', - }, - }) - await Promise.resolve() - - expect( - services.eccRuntimeService.acknowledgeDetachedStepRendered, - ).toHaveBeenCalledWith({ - eventId: 'workspace-1:3', - operationId: 'operation-1', - stepCommitId: 'operation-1:step:1', - workspaceHandle: 'workspace-handle-1', - workspaceRevision: 1, - }) - expect(sender.send).not.toHaveBeenCalled() + expect(sender.listenerCount('destroyed')).toBe(0) }) it('tracks a workspace handle again after a successful explicit close', async () => { @@ -2631,17 +3504,17 @@ describe('registerIpc', () => { workspaceHandle: 'workspace-handle-1', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.(event, { + await openBackendWorkspace(handlers, event, { directory: '/work/demo', }) expect(sender.listenerCount('destroyed')).toBe(1) - await handlers.get(desktopApiIpcChannels.eccWorkspaceClose)?.(event, { + await closeBackendWorkspace(handlers, event, { workspaceHandle: 'workspace-handle-1', }) expect(sender.listenerCount('destroyed')).toBe(0) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.(event, { + await openBackendWorkspace(handlers, event, { directory: '/work/demo', }) @@ -2758,25 +3631,25 @@ describe('registerIpc', () => { expect(services.shellService.resize).toHaveBeenCalledWith('shell-1', 100, 28) }) - it('returns a missing project binary file as an IPC error without warning', async () => { + it('returns a missing project manifest directory as an IPC error without warning', async () => { const { handlers, services } = registerHandlers() const event = { sender: { id: 'web-contents' } } - const path = '/tmp/project/place_dreamplace/output/minirv_place.png' + const path = '/tmp/gone-project' const error = Object.assign( - new Error(`ENOENT: no such file or directory, open '${path}'`), + new Error(`ENOENT: no such file or directory, realpath '${path}'`), { code: 'ENOENT', path, }, ) - services.workspaceService.readProjectBinaryFile.mockRejectedValue(error) + services.projectManagementReadService.readManifest.mockRejectedValue(error) await expect( - handlers.get(desktopApiIpcChannels.workspaceReadProjectBinaryFile)?.(event, path), + handlers.get(desktopApiIpcChannels.projectManagementReadManifest)?.(event, path), ).resolves.toEqual({ error: { code: 'ENOENT', - message: `ENOENT: no such file or directory, open '${path}'`, + message: `ENOENT: no such file or directory, realpath '${path}'`, name: 'Error', }, ok: false, @@ -2785,132 +3658,54 @@ describe('registerIpc', () => { expect(electronLogger.warn).not.toHaveBeenCalled() }) - it('sends project file change notifications to the requesting renderer', async () => { + it('returns a non-directory project root as an IPC error without warning', async () => { const { handlers, services } = registerHandlers() - const sender = Object.assign(new EventEmitter(), { - isDestroyed: vi.fn(() => false), - send: vi.fn(), - }) - const event = { sender } - services.workspaceService.watchProjectFile.mockImplementation( - async (_path, listener) => { - listener({ - subscriptionId: 'project-file-watch-1', - path: '/tmp/project/home/flow.json', - eventType: 'change', - }) - return 'project-file-watch-1' - }, + const event = { sender: { id: 'web-contents' } } + const path = '/tmp/not-a-project' + const error = Object.assign( + new Error(`Project management path is not a directory: ${path}`), + { code: 'ENOTDIR', path }, ) + services.projectManagementReadService.readManifest.mockRejectedValue(error) await expect( - handlers.get(desktopApiIpcChannels.workspaceWatchProjectFile)?.( - event, - '/tmp/project/home/flow.json', - ), - ).resolves.toBe('project-file-watch-1') - - expect(sender.listenerCount('destroyed')).toBe(1) - - await handlers.get(desktopApiIpcChannels.workspaceUnwatchProjectFile)?.( - event, - 'project-file-watch-1', - ) - - expect(services.workspaceService.watchProjectFile).toHaveBeenCalledWith( - '/tmp/project/home/flow.json', - expect.any(Function), - ) - expect(sender.send).toHaveBeenCalledWith('workspace:file-changed', { - subscriptionId: 'project-file-watch-1', - path: '/tmp/project/home/flow.json', - eventType: 'change', - }) - expect(services.workspaceService.unwatchProjectFile).toHaveBeenCalledWith( - 'project-file-watch-1', - ) - expect(sender.listenerCount('destroyed')).toBe(0) - }) - - it('unwatches a project file when the requesting renderer is destroyed', async () => { - const { handlers, services } = registerHandlers() - const sender = Object.assign(new EventEmitter(), { - isDestroyed: vi.fn(() => false), - send: vi.fn(), - }) - const event = { sender } - services.workspaceService.watchProjectFile.mockResolvedValue('project-file-watch-1') - - await handlers.get(desktopApiIpcChannels.workspaceWatchProjectFile)?.( - event, - '/tmp/project/home/flow.json', - ) - - expect(sender.listenerCount('destroyed')).toBe(1) - - sender.emit('destroyed') - await vi.waitFor(() => { - expect(services.workspaceService.unwatchProjectFile).toHaveBeenCalledWith( - 'project-file-watch-1', - ) + handlers.get(desktopApiIpcChannels.projectManagementReadManifest)?.(event, path), + ).resolves.toEqual({ + error: { + code: 'ENOTDIR', + message: `Project management path is not a directory: ${path}`, + name: 'Error', + }, + ok: false, }) - await handlers.get(desktopApiIpcChannels.workspaceUnwatchProjectFile)?.( - event, - 'project-file-watch-1', - ) - - expect(services.workspaceService.unwatchProjectFile).toHaveBeenCalledTimes(1) - expect(sender.listenerCount('destroyed')).toBe(0) + expect(electronLogger.warn).not.toHaveBeenCalled() }) - it('unsubscribes live log tails when the renderer is destroyed or unsubscribes explicitly', async () => { + it('returns a missing project binary file as an IPC error without warning', async () => { const { handlers, services } = registerHandlers() - const sender = Object.assign(new EventEmitter(), { - isDestroyed: vi.fn(() => false), - send: vi.fn(), - }) - const event = { sender } - services.workspaceService.subscribeProjectLogTail.mockImplementation( - async (_path, _options, listener) => { - listener({ - subscriptionId: 'project-log-tail-1', - path: '/tmp/project/home/flow.log', - eventType: 'snapshot', - content: 'log chunk', - }) - return 'project-log-tail-1' + const event = { sender: { id: 'web-contents' } } + const path = '/tmp/project/place_dreamplace/output/minirv_place.png' + const error = Object.assign( + new Error(`ENOENT: no such file or directory, open '${path}'`), + { + code: 'ENOENT', + path, }, ) + services.workspaceService.readProjectBinaryFile.mockRejectedValue(error) await expect( - handlers.get(desktopApiIpcChannels.workspaceSubscribeProjectLogTail)?.( - event, - '/tmp/project/home/flow.log', - { - maxInitialChars: 256, - maxChunkChars: 256, - }, - ), - ).resolves.toBe('project-log-tail-1') - - expect(sender.listenerCount('destroyed')).toBe(1) - expect(sender.send).toHaveBeenCalledWith( - 'workspace:log-tail', - expect.objectContaining({ - subscriptionId: 'project-log-tail-1', - eventType: 'snapshot', - content: 'log chunk', - }), - ) + handlers.get(desktopApiIpcChannels.workspaceReadProjectBinaryFile)?.(event, path), + ).resolves.toEqual({ + error: { + code: 'ENOENT', + message: `ENOENT: no such file or directory, open '${path}'`, + name: 'Error', + }, + ok: false, + }) - await handlers.get(desktopApiIpcChannels.workspaceUnsubscribeProjectLogTail)?.( - event, - 'project-log-tail-1', - ) - expect(services.workspaceService.unsubscribeProjectLogTail).toHaveBeenCalledWith( - 'project-log-tail-1', - ) - expect(sender.listenerCount('destroyed')).toBe(0) + expect(electronLogger.warn).not.toHaveBeenCalled() }) }) diff --git a/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts b/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts index 64beea99c..6f7fc6182 100644 --- a/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts +++ b/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts @@ -7,16 +7,14 @@ import { type IpcMainInvokeEvent, } from 'electron' import { randomUUID } from 'node:crypto' -import { mkdir, stat, writeFile } from 'node:fs/promises' +import { mkdir, realpath, stat, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' import { desktopApiEventChannels, desktopApiIpcChannels, - type DesktopProjectFileChangedEvent, - type DesktopProjectLogTailEvent, type DesktopProjectDirectoryEntry, - type DesktopProjectManagementWorkspaceTextsRequest, - type DesktopProjectManagementWorkspaceTextsResult, + type DesktopProjectManagementWorkspaceStepConfigurationRequest, + type DesktopProjectManagementWorkspaceStepConfigurationResult, type DesignRuntimeCancelRequest, type DesignRuntimeFlowRunRequest, type DesignRuntimeFlowRunStepRequest, @@ -25,39 +23,45 @@ import { type DesignRuntimeWorkspaceHandleRequest, type DesignRuntimeWorkspaceInfoRequest, type DesignRuntimeWorkspaceOpenRequest, - type DesignRuntimeWorkspaceSyncConfigRequest, type DesignTool, type DesktopDirectoryDialogOptions, type EccFlowRunRequest, type EccFlowRunStepRequest, + type EccBackgroundOperationProjection, + type EccBackgroundWorkspaceCreation, type EccRuntimeEvent, type EccRuntimeOperation, type EccRuntimeOperationRequest, type EccRuntimeStartFlowRequest, type EccRuntimeStartStepRequest, - type EccRuntimeStepRenderedAckRequest, + type EccWorkspaceConfigurationUpdateRequest, type EccWorkspaceCreateRequest, type EccWorkspaceExportSignoffRequest, type EccWorkspaceHandleRequest, type EccWorkspaceInfoRequest, type EccWorkspaceOpenRequest, - type EccWorkspaceSyncConfigRequest, + type EccWorkspaceOpenResult, + type EccWorkspaceStepConfigurationReadResult, + type EccWorkspaceStepConfigurationUpdateRequest, + type EccWorkspaceSpecValidationRequest, + type EccWorkspaceUpdateRequest, type DesktopFileDialogOptions, type DesktopMenuEventId, type DesktopSaveFileDialogOptions, type DesktopRtlSourceDialogOptions, type PickedRtlSources, + type ProjectManifest, type ProjectManifestMutationRequest, type ProjectManifestMutationResult, + type WorkspaceCreationModelRequest, type DesktopProjectTextFileChunk, type DesktopProjectTextFileTail, - type DesktopProjectTextFileUpdate, type DesktopSettingsValue, + type DesktopShutdownStatus, type ChipViewerOpenRequest, type ChipViewerOpenResult, type DesktopAgentEvent, type DesktopAgentInterruptRequest, - type DesktopAgentWorkspaceParameterWrite, type DesktopAgentWorkspaceRerunContract, type DesktopAgentSendMessageRequest, type DesktopAgentStartRequest, @@ -67,12 +71,12 @@ import { type ResourceImportLocalRequest, type ResourceInstallRequest, type ResourceJob, + type MpcSpecReadResult, type PdkBinding, type PdkBindRequest, type PdkImportRequest, type PdkInstallationSnapshot, type PdkLocateRequest, - type PdkRequirement, type PdkResolveBindingRequest, type DesktopShellDataEvent, type DesktopShellExitEvent, @@ -80,18 +84,19 @@ import { type DesktopShellSessionOptions, type ScannedPdkDirectory, type ScannedRtlDirectory, + type HdlModuleDiscoveryRequest, + type HdlModuleDiscoveryResult, type VersionInfo, type WorkspaceDirectoryReplacement, type WorkspaceOpenOrFocusResult, type WorkspaceResourceIndex, type WorkspaceStepInfoRequest, type WorkspaceStepInfoResult, - parseProjectManifest, } from '@ecos-studio/shared' import type { AgentProviderRuntime } from '../services/agent/agentProviderContract' +import { readAgentWorkspaceParameterValues } from '../services/agent/agentWorkspaceParameterUpdates' import { closeWindow, - confirmWindowClose, isWindowMaximized, minimizeWindow, setWindowTitle, @@ -109,6 +114,13 @@ import { executeWorkspaceRerun, prepareWorkspaceRerun, } from '../services/eccRpc/workspaceRerun' +import { executeProductCommand } from '../services/productCommandService' +import { buildWorkspaceCreationModel } from '../services/workspaceCreationModel' +import { + prepareWorkspaceCreateBinding, + prepareWorkspaceOpenBinding, +} from '../services/workspacePdkBindings' +import { registerBackgroundLifecycleIpc } from './registerBackgroundLifecycleIpc' export type IpcMainLike = Pick @@ -127,6 +139,36 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } +function isShutdownBlockedProductCommand(value: unknown): boolean { + if (!isRecord(value) || typeof value.command !== 'string') return false + return [ + 'workspace.create', + 'workspace.run', + 'workspace.runStep', + 'workspace.update', + 'workspace.updateConfiguration', + 'workspace.updateStepConfiguration', + 'workspace.reset', + 'workspace.exportSignoff', + 'workspace.continueCreation', + 'workspace.abandonCreation', + ].includes(value.command) +} + +const acceptedWorkChannels = new Set([ + desktopApiIpcChannels.productCommandExecute, + desktopApiIpcChannels.projectManifestMutate, + desktopApiIpcChannels.workspaceExecuteFlowAgentRerun, + desktopApiIpcChannels.workspaceWriteProjectTextFile, + desktopApiIpcChannels.workspaceDiscardFailedWorkspaceCreate, + desktopApiIpcChannels.workspacePrepareProjectDirectoryReplacement, + desktopApiIpcChannels.workspaceRestoreProjectDirectoryReplacement, + desktopApiIpcChannels.workspaceFinalizeProjectDirectoryReplacement, + desktopApiIpcChannels.workspaceRetainProjectDirectoryReplacement, + desktopApiIpcChannels.workspaceAddDesignFiles, + desktopApiIpcChannels.workspaceRemoveDesignFile, +]) + export interface DesktopBridgeServices { agentRuntimeService?: AgentProviderRuntime & { syncEnvironmentOverrides?( @@ -171,11 +213,74 @@ export interface DesktopBridgeServices { ): Promise } projectManagementReadService?: { - readManifest(projectRoot: string): Promise + discoverProject(directory: string): Promise + readManifest(projectRoot: string): Promise listProjectEntries(projectRoot: string): Promise - readWorkspaceTexts( - request: DesktopProjectManagementWorkspaceTextsRequest, - ): Promise + readWorkspaceStepConfiguration( + request: DesktopProjectManagementWorkspaceStepConfigurationRequest, + ): Promise + } + backendWorkspaceService: { + clearWindow(windowId: number): void + getArtifact( + request: import('@ecos-studio/shared').BackendWorkspaceArtifactRequest, + ): Promise + getOverview(): Promise + getStepDetail( + request: import('@ecos-studio/shared').BackendWorkspaceStepDetailRequest, + ): Promise + invalidateWindow(windowId: number): void + onInvalidated( + listener: ( + event: import('../services/backendWorkspaceService').BackendWorkspaceInvalidation, + ) => void, + ): () => void + refreshOverview(): Promise< + import('@ecos-studio/shared').BackendWorkspaceOverviewResult + > + } + backendProjectComparisonService: { + closeProject(windowId: number, contextId: string): Promise + disposeWindow(windowId: number): void + getComparison( + windowId: number, + contextId: string, + ): Promise + getExecutionSnapshot( + windowId: number, + contextId: string, + ): Promise + getStepFindings( + windowId: number, + request: { + projectComparisonContextId: string + projectWorkspaceId: string + step: string + }, + ): Promise + refreshComparison( + windowId: number, + contextId: string, + ): Promise + selectProject( + windowId: number, + request: { projectRootLocator: string }, + ): Promise + invalidateProject(projectRoot: string): void + invalidateExecution(): void + invalidateWorkspace(workspaceRoot: string): void + onInvalidated( + listener: ( + windowId: number, + event: import('@ecos-studio/shared').BackendProjectComparisonInvalidatedEvent, + ) => void, + ): () => void + onExecutionInvalidated( + listener: ( + windowId: number, + event: import('@ecos-studio/shared').BackendProjectExecutionInvalidatedEvent, + ) => void, + ): () => void } workspaceService: { approvePendingExternalReadRoots?( @@ -183,52 +288,30 @@ export interface DesktopBridgeServices { expectedRoots: string[], ): Promise clearProjectRoot(): Promise + getProjectRoot(): Promise isProjectDirectory(path: string): Promise readProjectBinaryFile(path: string): Promise readOptionalProjectTextFile(path: string): Promise - readWorkspaceParameters( - workspacePath: string, - ): Promise | null> - hasWorkspaceConfigShadow(workspacePath: string): Promise - editWorkspaceParameters( - workspacePath: string, - edits: { json_path: (string | number)[]; value: unknown }[], - ): Promise<{ format: 'toml' | 'json'; path: string }> - applyWorkspaceParameterWrites( - workspacePath: string, - writes: DesktopAgentWorkspaceParameterWrite[], - ): Promise readProjectTextFile(path: string): Promise readProjectTextFileTail(path: string, maxChars: number): Promise readOptionalProjectTextFileTail( path: string, maxChars: number, ): Promise - readOptionalProjectTextFileUpdate( - path: string, - fromOffsetBytes: number, - maxChars: number, - ): Promise readOptionalProjectTextFileChunk( path: string, fromOffsetBytes: number, maxBytes: number, ): Promise listPendingExternalReadRoots?(): Promise - subscribeProjectLogTail( - path: string, - options: { - maxInitialChars?: number - maxChunkChars?: number - pollIntervalMs?: number - }, - listener: (event: DesktopProjectLogTailEvent) => void, - ): Promise registerProjectReadRoot(path: string): Promise registerProjectRoot(path: string): Promise requestProjectPathAccess(path: string): Promise scanPdkDirectory(path: string): Promise scanRtlDirectory(path: string): Promise + discoverHdlModules( + request: HdlModuleDiscoveryRequest, + ): Promise listDesignFiles(): Promise addDesignFiles( sourcePaths: string[], @@ -239,15 +322,12 @@ export interface DesktopBridgeServices { prepareProjectDirectoryReplacement( path: string, ): Promise + getProjectDirectoryReplacement( + replacementId: string, + ): WorkspaceDirectoryReplacement & { projectRoot: string } restoreProjectDirectoryReplacement(replacementId: string): Promise finalizeProjectDirectoryReplacement(replacementId: string): Promise retainProjectDirectoryReplacement(replacementId: string): Promise - unwatchProjectFile(subscriptionId: string): Promise - unsubscribeProjectLogTail(subscriptionId: string): Promise - watchProjectFile( - path: string, - listener: (event: DesktopProjectFileChangedEvent) => void, - ): Promise writeProjectTextFile(path: string, content: string): Promise listProjectDirectory(path: string): Promise pathExists(path: string): Promise @@ -266,16 +346,12 @@ export interface DesktopBridgeServices { readHome(): Promise | null> readFlow(): Promise | null> readParameters(): Promise | null> - writeParameters(request: { - parameters: Record - workspace: string - }): Promise<{ format: 'toml' | 'json'; path: string }> resolveStepInfo(request: WorkspaceStepInfoRequest): Promise } resourceManagerService: { listResources(): Promise getResource(resourceId: string): Promise - readMpcSpec(resourceId: string): Promise + readMpcSpec(resourceId: string): Promise installResource( resourceId: string, version?: string, @@ -330,43 +406,112 @@ export interface DesktopBridgeServices { workspaceHandle: string, payload: Record & { step: string }, ): Promise - syncConfig(workspaceHandle: string, configPath: string): Promise validateConfig(payload: Record): Promise> workspaceHome(workspaceHandle: string): Promise workspaceInfo(workspaceHandle: string, step: string, id: string): Promise } eccRuntimeService: { - acknowledgeDetachedStepRendered( - request: EccRuntimeStepRenderedAckRequest, - ): Promise - acknowledgeStepRendered(request: EccRuntimeStepRenderedAckRequest): Promise + callRuntime?( + method: string, + params?: Record, + options?: { timeoutMs?: number }, + ): Promise cancelOperation(request: EccRuntimeOperationRequest): Promise cancelOperationLegacy( operationId?: string, ): Promise<{ cancelled: boolean; operationId?: string }> closeWorkspace(request: EccWorkspaceHandleRequest): Promise createWorkspace(request: EccWorkspaceCreateRequest): Promise + describeWorkspaceSpec(): Promise exportSignoff(request: EccWorkspaceExportSignoffRequest): Promise - inspectSignoff(request: EccWorkspaceHandleRequest): Promise + engineeringSnapshot(request: EccWorkspaceHandleRequest): Promise onEvent(listener: (event: EccRuntimeEvent) => void): () => void + onOperationProjectionInvalidated(listener: (generation: number) => void): () => void + onWorkspaceReleased?(listener: (workspaceHandle: string) => void): () => void + operationProjection(): EccBackgroundOperationProjection + reconcileOperationProjection?(): Promise + operationLog(request: EccRuntimeOperationRequest): Promise operationStatus(request: EccRuntimeOperationRequest): Promise waitForOperation(request: EccRuntimeOperationRequest): Promise openWorkspace( request: EccWorkspaceOpenRequest, ): Promise<{ directory: string; workspaceHandle: string }> refreshConfig(request: EccWorkspaceHandleRequest): Promise + releaseWorkspace( + request: EccWorkspaceHandleRequest, + ): Promise<{ ok: boolean; retained?: boolean }> + retryFinalSnapshot(request: EccWorkspaceHandleRequest): Promise resetFlow(request: EccWorkspaceHandleRequest): Promise - rpcHello(): Promise - rpcPing(): Promise - rpcShutdown(): Promise runFlow(request: EccFlowRunRequest): Promise runStep(request: EccFlowRunStepRequest): Promise startFlowOperation(request: EccRuntimeStartFlowRequest): Promise startStepOperation(request: EccRuntimeStartStepRequest): Promise - syncConfig(request: EccWorkspaceSyncConfigRequest): Promise + updateWorkspaceConfiguration( + request: EccWorkspaceConfigurationUpdateRequest, + ): Promise<{ workspaceRevision: number }> + updateWorkspaceStepConfiguration( + request: EccWorkspaceStepConfigurationUpdateRequest, + ): Promise<{ workspaceRevision: number }> + readWorkspaceStepConfiguration( + request: import('@ecos-studio/shared').EccWorkspaceStepConfigurationReadRequest, + ): Promise + readWorkspaceStepConfigurationForDirectory( + directory: string, + step: string, + ): Promise + updateWorkspace(request: EccWorkspaceUpdateRequest): Promise + validateWorkspaceSpec(request: EccWorkspaceSpecValidationRequest): Promise workspaceHome(request: EccWorkspaceHandleRequest): Promise workspaceInfo(request: EccWorkspaceInfoRequest): Promise workspaceSnapshot(request: EccWorkspaceHandleRequest): Promise + workspaceSession(workspaceHandle: string): Promise + } + workspaceCreationJournal?: { + abandon(creationId: string, ownerWindowId: number): Promise<{ abandoned: boolean }> + begin( + ownerWindowId: number, + request: EccWorkspaceCreateRequest, + ): Promise<{ creationId: string; targetDirectory: string }> + complete(creationId: string, ownerWindowId: number): Promise + continueInitialization( + creationId: string, + ownerWindowId: number, + ): Promise<{ recovered: boolean; issue?: string }> + allowsRegistration( + windowId: number, + projectRoot: string, + targetDirectory: string, + ): Promise + entriesForWindow(windowId: number): Promise + generation: number + markUnfinished( + creationId: string, + issue: string, + ownerWindowId: number, + ): Promise + markWorkspaceCreated( + creationId: string, + result: { workspaceId?: string; workspaceRevision?: number }, + ownerWindowId: number, + ): Promise + onInvalidated(listener: (generation: number) => void): () => void + registerWorkspace(creationId: string, ownerWindowId: number): Promise + } + shutdownCoordinator?: { + beginAcceptedWork?(windowId: number): () => void + cancelShutdown(): void + completeRendererCleanup( + attemptId: string, + windowId: number, + ok: boolean, + issue?: string, + ): Promise + isMutationBlocked(windowId: number): boolean + onStatusChanged(listener: (status: DesktopShutdownStatus) => void): () => void + reviewShutdownOptions(): Promise + statusForWindow(windowId: number): DesktopShutdownStatus + trackWorkspaceHandle(windowId: number, workspaceHandle: string): void + untrackWorkspaceHandle(workspaceHandle: string): void } shellService: { createSession( @@ -439,6 +584,19 @@ function serializeError(error: unknown): { } } +function shouldSilenceIpcError(channel: string, error: unknown): boolean { + if ( + channel === desktopApiIpcChannels.workspaceReadProjectBinaryFile && + isNodeErrorWithCode(error, 'ENOENT') + ) { + return true + } + return ( + channel === desktopApiIpcChannels.projectManagementReadManifest && + (isNodeErrorWithCode(error, 'ENOENT') || isNodeErrorWithCode(error, 'ENOTDIR')) + ) +} + function summarizeIpcError(channel: string, args: unknown[], error: unknown): string { if (channel === desktopApiIpcChannels.workspaceReadProjectBinaryFile) { return summarizeProjectBinaryReadError(String(args[0] ?? ''), error) @@ -454,12 +612,7 @@ function wrapIpcHandler(channel: string, handler: IpcHandler): IpcHandler { try { return await handler(event, ...args) } catch (error) { - if ( - !( - channel === desktopApiIpcChannels.workspaceReadProjectBinaryFile && - isNodeErrorWithCode(error, 'ENOENT') - ) - ) { + if (!shouldSilenceIpcError(channel, error)) { electronLogger.warn(summarizeIpcError(channel, args, error), error) } return { @@ -486,76 +639,6 @@ function requireDesignTool(value: unknown): DesignTool { throw new Error(`Unsupported design runtime: ${String(value)}`) } -async function resolveWorkspacePdkContext( - services: DesktopBridgeServices, - requestedProjectId: string, - projectRoot: string, - requested: PdkRequirement, -): Promise<{ projectId: string; requirement: PdkRequirement }> { - if (!projectRoot || !services.projectManagementReadService) { - return { projectId: requestedProjectId, requirement: requested } - } - const manifestText = - await services.projectManagementReadService.readManifest(projectRoot) - if (!manifestText) return { projectId: requestedProjectId, requirement: requested } - const manifest = parseProjectManifest(manifestText) - return { - projectId: manifest.project_id, - requirement: manifest.base_design.pdk_requirement ?? requested, - } -} - -async function prepareEccWorkspaceCreateRequest( - services: DesktopBridgeServices, - request: EccWorkspaceCreateRequest, -): Promise { - if (!request.pdkRequirement) { - throw new Error('PDK Requirement is required for backend workspace creation') - } - - const projectRoot = request.projectRoot ?? '' - const context = await resolveWorkspacePdkContext( - services, - request.projectId ?? '', - projectRoot, - request.pdkRequirement, - ) - const { projectId, requirement } = context - const binding = await services.pdkInventoryService.resolveBinding({ - projectId, - projectRoot, - requirement, - }) - if (!binding) { - if (!request.pdkInstallationId) { - throw new Error('Project PDK Requirement is unbound') - } - await services.pdkInventoryService.bindInstallation({ - installationId: request.pdkInstallationId, - requirement, - projectId, - projectRoot, - }) - } - const installation = await services.pdkInventoryService.validateWorkspace({ - projectId, - projectRoot, - requirement, - }) - const { - pdkInstallationId: _pdkInstallationId, - pdkRequirement: _pdkRequirement, - projectId: _projectId, - projectRoot: _projectRoot, - ...runtimeRequest - } = request - return { - ...runtimeRequest, - pdk: requirement.familyId, - pdkRoot: installation.root, - } -} - function readWorkspaceDirectoryFromEvent(event: EccRuntimeEvent): string | undefined { if (!('workspaceDirectory' in event)) return undefined const directory = event.workspaceDirectory @@ -714,23 +797,49 @@ export function registerIpc( services: DesktopBridgeServices, ): void { const handle = (channel: string, handler: IpcHandler): void => { - target.handle(channel, wrapIpcHandler(channel, handler)) + const trackedHandler: IpcHandler = async (event, ...args) => { + const finish = acceptedWorkChannels.has(channel) + ? services.shutdownCoordinator?.beginAcceptedWork?.(event.sender.id) + : undefined + try { + return await handler(event, ...args) + } finally { + finish?.() + } + } + target.handle(channel, wrapIpcHandler(channel, trackedHandler)) } - const projectFileWatchSubscriptions = new Map< - string, - { - sender: IpcMainInvokeEvent['sender'] - onDestroyed: () => void - } - >() - const projectLogTailSubscriptions = new Map< - string, - { - sender: IpcMainInvokeEvent['sender'] - onDestroyed: () => void - } - >() + services.backendWorkspaceService.onInvalidated((event) => { + const targetWindow = BrowserWindow.getAllWindows().find( + (window) => window.webContents.id === event.windowId, + ) + if (!targetWindow || targetWindow.isDestroyed()) return + targetWindow.webContents.send(desktopApiEventChannels.backendWorkspaceInvalidated, { + generation: event.generation, + workspaceContextId: event.workspaceContextId, + }) + }) + services.backendProjectComparisonService.onInvalidated((windowId, event) => { + const targetWindow = BrowserWindow.getAllWindows().find( + (window) => window.webContents.id === windowId, + ) + if (!targetWindow || targetWindow.isDestroyed()) return + targetWindow.webContents.send( + desktopApiEventChannels.backendProjectComparisonInvalidated, + event, + ) + }) + services.backendProjectComparisonService.onExecutionInvalidated((windowId, event) => { + const targetWindow = BrowserWindow.getAllWindows().find( + (window) => window.webContents.id === windowId, + ) + if (!targetWindow || targetWindow.isDestroyed()) return + targetWindow.webContents.send( + desktopApiEventChannels.backendProjectExecutionInvalidated, + event, + ) + }) const shellSessions = new Map< string, { @@ -749,11 +858,13 @@ export function registerIpc( } >() const workspaceHandleClosePromises = new Map>() + const backendWorkspaceOpenClaims = new Map() const agentSessionSubscriptions = new Map< string, { sender: IpcMainInvokeEvent['sender'] onDestroyed: () => void + workspaceId?: string } >() const pendingWorkspaceReruns = new Map< @@ -772,18 +883,33 @@ export function registerIpc( >() /** Last runtime.ready per tool and directory, replayed when a handle subscribes. */ const lastReadyByDirectory = new Map() + const releasedWorkspaceHandleOwnerIds = new Map() const readyKey = (designTool: DesignTool, directory: string): string => `${designTool}:${directory}` - - const sendEccEventToSender = ( - sender: IpcMainInvokeEvent['sender'], - payload: EccRuntimeEvent, - ): void => { - if (typeof sender.isDestroyed === 'function' && sender.isDestroyed()) { - return + registerBackgroundLifecycleIpc({ + creationJournal: services.workspaceCreationJournal, + handle, + ownsWorkspaceHandle: (sender, workspaceHandle) => + workspaceHandleSubscriptions.get(workspaceHandle)?.sender === sender || + releasedWorkspaceHandleOwnerIds.get(workspaceHandle) === sender.id, + runtime: services.eccRuntimeService, + shutdown: services.shutdownCoordinator, + }) + services.eccRuntimeService.onWorkspaceReleased?.((workspaceHandle) => { + services.shutdownCoordinator?.untrackWorkspaceHandle(workspaceHandle) + const subscription = workspaceHandleSubscriptions.get(workspaceHandle) + if (!subscription) return + releasedWorkspaceHandleOwnerIds.set(workspaceHandle, subscription.sender.id) + while (releasedWorkspaceHandleOwnerIds.size > 64) { + releasedWorkspaceHandleOwnerIds.delete( + releasedWorkspaceHandleOwnerIds.keys().next().value!, + ) } - sender.send(desktopApiEventChannels.eccEvent, payload) - } + workspaceHandleSubscriptions.delete(workspaceHandle) + if (typeof subscription.sender.off === 'function') { + subscription.sender.off('destroyed', subscription.onDestroyed) + } + }) const sendDesignRuntimeEventToSender = ( sender: IpcMainInvokeEvent['sender'], @@ -794,6 +920,57 @@ export function registerIpc( sender.send(desktopApiEventChannels.designRuntimeEvent, { ...payload, designTool }) } + const invalidateBackendWorkspaceForSender = ( + sender: IpcMainInvokeEvent['sender'], + ): void => { + if (typeof sender.id === 'number') { + services.backendWorkspaceService.invalidateWindow(sender.id) + } + } + + const requireBackendMutationAllowed = (event: IpcMainInvokeEvent): void => { + if (!services.shutdownCoordinator?.isMutationBlocked(event.sender.id)) return + throw Object.assign(new Error('Shutdown is in progress.'), { + code: 'SHUTDOWN_IN_PROGRESS', + }) + } + + const requireCreationCleanupAllowed = async ( + event: IpcMainInvokeEvent, + projectRoot: string, + targetDirectory: string, + ): Promise => { + if (!services.shutdownCoordinator?.isMutationBlocked(event.sender.id)) return + const allowed = + (await services.workspaceCreationJournal?.allowsRegistration( + event.sender.id, + projectRoot, + targetDirectory, + )) ?? false + if (!allowed) requireBackendMutationAllowed(event) + } + + const runtimeEventCommitsWorkspaceFacts = (payload: EccRuntimeEvent): boolean => { + if ( + payload.type === 'operation.completed' || + payload.type === 'operation.failed' || + payload.type === 'operation.cancelled' + ) { + return true + } + if (payload.type !== 'runtime.protocol') return false + if (payload.event.type === 'workspace.committed') return true + if (payload.event.type === 'operation.changed') { + return ['succeeded', 'failed', 'cancelled', 'interrupted'].includes( + String(payload.event.payload.state), + ) + } + return ( + payload.event.type === 'execution.progress' && + payload.event.payload.sourceType === 'operation.rerun_prepared' + ) + } + const agentSessionKey = (providerId: string, sessionId: string): string => `${providerId}:${sessionId}` @@ -820,7 +997,11 @@ export function registerIpc( const onDestroyed = (): void => { agentSessionSubscriptions.delete(key) } - agentSessionSubscriptions.set(key, { sender, onDestroyed }) + agentSessionSubscriptions.set(key, { + sender, + onDestroyed, + ...(request.workspaceId ? { workspaceId: request.workspaceId } : {}), + }) if (typeof sender.once === 'function') sender.once('destroyed', onDestroyed) if (typeof sender.isDestroyed === 'function' && sender.isDestroyed()) onDestroyed() } @@ -828,7 +1009,7 @@ export function registerIpc( const requireAgentSessionOwner = ( sender: IpcMainInvokeEvent['sender'], request: DesktopAgentInterruptRequest | DesktopAgentSendMessageRequest, - ): void => { + ) => { const providerId = readAgentProviderId(request) const subscription = agentSessionSubscriptions.get( agentSessionKey(providerId, request.sessionId), @@ -836,6 +1017,7 @@ export function registerIpc( if (!subscription || subscription.sender !== sender) { throw new Error('Unknown agent session for this window.') } + return subscription } const deliverDirectoryScopedEvent = ( @@ -860,6 +1042,10 @@ export function registerIpc( lastReadyByDirectory.delete(readyKey(designTool, normalizedDirectory)) } + if (designTool === 'backend' && runtimeEventCommitsWorkspaceFacts(payload)) { + services.backendProjectComparisonService.invalidateWorkspace(normalizedDirectory) + } + const deliveredSenders = new Set() for (const subscription of workspaceHandleSubscriptions.values()) { if (subscription.designTool !== designTool) continue @@ -870,47 +1056,39 @@ export function registerIpc( ...payload, workspaceDirectory: normalizedDirectory, } - if (designTool === 'backend') - sendEccEventToSender(subscription.sender, scopedPayload) + if (designTool === 'backend' && runtimeEventCommitsWorkspaceFacts(payload)) { + invalidateBackendWorkspaceForSender(subscription.sender) + } sendDesignRuntimeEventToSender(subscription.sender, designTool, scopedPayload) } return deliveredSenders.size } - const isSuccessfulDetachedStepCommit = ( - payload: EccRuntimeEvent, - ): payload is Extract => { - if (payload.type !== 'runtime.protocol') return false - if (payload.event.type !== 'step.completed') return false - return String(payload.event.payload.state).toLowerCase() === 'success' - } - - const acknowledgeDetachedStepCommit = (payload: EccRuntimeEvent): void => { - if (!isSuccessfulDetachedStepCommit(payload) || !payload.workspaceHandle) return - const stepCommitId = payload.event.payload.stepCommitId - const workspaceRevision = payload.event.payload.workspaceRevision - void services.eccRuntimeService - .acknowledgeDetachedStepRendered({ - eventId: payload.event.eventId, - operationId: payload.event.operationId, - workspaceHandle: payload.workspaceHandle, - ...(typeof stepCommitId === 'string' ? { stepCommitId } : {}), - ...(typeof workspaceRevision === 'number' ? { workspaceRevision } : {}), - }) - .catch((error: unknown) => { - console.warn('Failed to persist a detached GUI step commit:', error) - }) - } - const deliverRuntimeEvent = ( designTool: DesignTool, payload: EccRuntimeEvent, ): void => { + if ( + designTool === 'backend' && + payload.type === 'runtime.protocol' && + (payload.event.type === 'operation.changed' || + (payload.event.type === 'execution.progress' && + payload.event.payload.sourceType === 'operation.rerun_prepared')) + ) { + services.backendProjectComparisonService.invalidateExecution() + } const workspaceHandle = readWorkspaceHandleFromEvent(payload) if (workspaceHandle) { const subscription = workspaceHandleSubscriptions.get(workspaceHandle) if (subscription && subscription.designTool === designTool) { - if (designTool === 'backend') sendEccEventToSender(subscription.sender, payload) + if (designTool === 'backend' && runtimeEventCommitsWorkspaceFacts(payload)) { + invalidateBackendWorkspaceForSender(subscription.sender) + const directory = + readWorkspaceDirectoryFromEvent(payload) ?? + subscription.directories.values().next().value + if (directory) + services.backendProjectComparisonService.invalidateWorkspace(directory) + } sendDesignRuntimeEventToSender(subscription.sender, designTool, payload) return } @@ -919,18 +1097,14 @@ export function registerIpc( // attached the GUI handle, or carry a stale handle after a workspace // reopen. The explicit directory is still scoped to the owning window, // so use it as a routing fallback instead of dropping the progress event. - const delivered = deliverDirectoryScopedEvent(designTool, payload) - if (delivered === 0 && designTool === 'backend') { - acknowledgeDetachedStepCommit(payload) - } + deliverDirectoryScopedEvent(designTool, payload) return } // Frontend legacy RPC progress events are directory-scoped even though // they do not carry the shared runtime protocol's workspaceHandle. if (!readWorkspaceDirectoryFromEvent(payload)) return const delivered = deliverDirectoryScopedEvent(designTool, payload) - if (delivered === 0 && designTool === 'backend') - acknowledgeDetachedStepCommit(payload) + void delivered } services.eccRuntimeService.onEvent((payload) => deliverRuntimeEvent('backend', payload)) @@ -959,34 +1133,6 @@ export function registerIpc( }) }) - const unwatchProjectFile = async (subscriptionId: string): Promise => { - const subscription = projectFileWatchSubscriptions.get(subscriptionId) - - if (!subscription) { - return - } - - projectFileWatchSubscriptions.delete(subscriptionId) - if (typeof subscription.sender.off === 'function') { - subscription.sender.off('destroyed', subscription.onDestroyed) - } - await services.workspaceService.unwatchProjectFile(subscriptionId) - } - - const unsubscribeProjectLogTail = async (subscriptionId: string): Promise => { - const subscription = projectLogTailSubscriptions.get(subscriptionId) - - if (!subscription) { - return - } - - projectLogTailSubscriptions.delete(subscriptionId) - if (typeof subscription.sender.off === 'function') { - subscription.sender.off('destroyed', subscription.onDestroyed) - } - await services.workspaceService.unsubscribeProjectLogTail(subscriptionId) - } - const killShellSession = async (sessionId: string): Promise => { const session = shellSessions.get(sessionId) @@ -1017,9 +1163,12 @@ export function registerIpc( } } - // A renderer/page only owns a subscription lease. Releasing that lease - // must not close a running ECC operation or its sidecar. - const closePromise = Promise.resolve({ ok: true }) + // Backend release is lifecycle-aware: active work is retained, then an + // unreferenced Session closes only after terminal snapshot finalization. + const closePromise = + subscription?.designTool === 'backend' + ? services.eccRuntimeService.releaseWorkspace({ workspaceHandle }) + : Promise.resolve({ ok: true }) const trackedClosePromise = closePromise.finally(() => { workspaceHandleClosePromises.delete(workspaceHandle) }) @@ -1043,12 +1192,9 @@ export function registerIpc( } const previous = workspaceHandleSubscriptions.get(workspaceHandle) - if ( - previous && - previous.sender !== sender && - typeof previous.sender.off === 'function' - ) { - previous.sender.off('destroyed', previous.onDestroyed) + releasedWorkspaceHandleOwnerIds.delete(workspaceHandle) + if (previous && previous.sender !== sender) { + throw new Error('Workspace Runtime Session is owned by another window.') } const onDestroyed = (): void => { @@ -1062,6 +1208,9 @@ export function registerIpc( sender, onDestroyed: previous?.sender === sender ? previous.onDestroyed : onDestroyed, }) + if (typeof sender.id === 'number') { + services.shutdownCoordinator?.trackWorkspaceHandle(sender.id, workspaceHandle) + } if (previous?.sender !== sender && typeof sender.once === 'function') { sender.once('destroyed', onDestroyed) } @@ -1077,7 +1226,6 @@ export function registerIpc( readyKey(designTool, normalizedDirectory), ) if (pendingReady) { - if (designTool === 'backend') sendEccEventToSender(sender, pendingReady) sendDesignRuntimeEventToSender(sender, designTool, pendingReady) } } @@ -1110,6 +1258,18 @@ export function registerIpc( return null } + const workspaceOwnedByAnotherSender = ( + sender: IpcMainInvokeEvent['sender'], + directory: string, + ): boolean => { + const normalizedDirectory = normalizeWorkspacePath(directory) + return [...workspaceHandleSubscriptions.values()].some( + (subscription) => + subscription.sender !== sender && + subscription.directories.has(normalizedDirectory), + ) + } + handle(desktopApiIpcChannels.appGetVersions, async () => { return await services.appInfoService.getVersions() }) @@ -1126,10 +1286,6 @@ export function registerIpc( closeWindow(getEventWindow(event)) }) - handle(desktopApiIpcChannels.windowConfirmClose, (event) => { - confirmWindowClose(getEventWindow(event)) - }) - handle(desktopApiIpcChannels.windowSetTitle, (event, title) => { setWindowTitle(getEventWindow(event), title as string) }) @@ -1219,6 +1375,7 @@ export function registerIpc( }) handle(desktopApiIpcChannels.workspaceExecuteFlowAgentRerun, async (event, request) => { + requireBackendMutationAllowed(event) const token = readWorkspaceRerunToken(request) const pending = pendingWorkspaceRerunExecutions.get(token) if (!pending || pending.sender !== event.sender) { @@ -1258,10 +1415,20 @@ export function registerIpc( workspaceHandle = openedHandle } pendingWorkspaceRerunExecutions.delete(token) + const runtimeSnapshot = await services.eccRuntimeService.workspaceSnapshot({ + workspaceHandle, + }) + const workspaceRevision = + isRecord(runtimeSnapshot) && + isRecord(runtimeSnapshot.engineeringSnapshot) && + typeof runtimeSnapshot.engineeringSnapshot.workspaceRevision === 'number' + ? runtimeSnapshot.engineeringSnapshot.workspaceRevision + : undefined await executeWorkspaceRerun( pending.contract, services.eccRuntimeService, workspaceHandle, + workspaceRevision, ) }) @@ -1322,7 +1489,7 @@ export function registerIpc( await services.settingsStore.delete(key as string) }) - handle(desktopApiIpcChannels.projectManifestMutate, async (_event, request) => { + handle(desktopApiIpcChannels.projectManifestMutate, async (event, request) => { if (!isRecord(request)) throw new Error('Project manifest mutation request must be an object') if (typeof request.projectRoot !== 'string') { @@ -1331,11 +1498,145 @@ export function registerIpc( if (!isRecord(request.mutation) || typeof request.mutation.type !== 'string') { throw new Error('Project manifest mutation must include a type') } - return await services.projectManifestService.mutate( + let acceptedCreationRegistration = false + const mutationInput = isRecord(request.mutation.input) ? request.mutation.input : {} + const mutationBlocked = + services.shutdownCoordinator?.isMutationBlocked(event.sender.id) ?? false + if (mutationBlocked && request.mutation.type === 'register-workspace') { + acceptedCreationRegistration = + (await services.workspaceCreationJournal?.allowsRegistration( + event.sender.id, + request.projectRoot, + String(mutationInput.workspacePath ?? ''), + )) ?? false + } else if (mutationBlocked && request.mutation.type === 'record-replacement-backup') { + const replacement = services.workspaceService.getProjectDirectoryReplacement( + String(mutationInput.replacementId ?? ''), + ) + acceptedCreationRegistration = + (await services.workspaceCreationJournal?.allowsRegistration( + event.sender.id, + request.projectRoot, + replacement.targetPath, + )) ?? false + } + if (mutationBlocked && !acceptedCreationRegistration) { + requireBackendMutationAllowed(event) + } + const result = await services.projectManifestService.mutate( request as unknown as ProjectManifestMutationRequest, ) + invalidateBackendWorkspaceForSender(event.sender) + services.backendProjectComparisonService.invalidateProject(request.projectRoot) + return result }) + handle( + desktopApiIpcChannels.backendProjectComparisonSelectProject, + async (event, request) => { + if (!isRecord(request) || typeof request.projectRootLocator !== 'string') { + throw new Error('Backend project comparison selection is invalid.') + } + return await services.backendProjectComparisonService.selectProject( + event.sender.id, + { + projectRootLocator: request.projectRootLocator, + }, + ) + }, + ) + + handle( + desktopApiIpcChannels.backendProjectComparisonCloseProject, + async (event, request) => { + if (!isRecord(request) || typeof request.projectComparisonContextId !== 'string') { + throw new Error('Backend project comparison close request is invalid.') + } + await services.backendProjectComparisonService.closeProject( + event.sender.id, + request.projectComparisonContextId, + ) + }, + ) + + handle( + desktopApiIpcChannels.backendProjectComparisonGetComparison, + async (event, request) => { + if (!isRecord(request) || typeof request.projectComparisonContextId !== 'string') { + throw new Error('Backend project comparison query is invalid.') + } + return await services.backendProjectComparisonService.getComparison( + event.sender.id, + request.projectComparisonContextId, + ) + }, + ) + + handle( + desktopApiIpcChannels.backendProjectComparisonGetExecutionSnapshot, + async (event, request) => { + if (!isRecord(request) || typeof request.projectComparisonContextId !== 'string') { + throw new Error('Backend project execution query is invalid.') + } + return await services.backendProjectComparisonService.getExecutionSnapshot( + event.sender.id, + request.projectComparisonContextId, + ) + }, + ) + + handle( + desktopApiIpcChannels.backendProjectComparisonGetStepFindings, + async (event, request) => { + if ( + !isRecord(request) || + typeof request.projectComparisonContextId !== 'string' || + typeof request.projectWorkspaceId !== 'string' || + typeof request.step !== 'string' + ) { + throw new Error('Backend project Findings query is invalid.') + } + return await services.backendProjectComparisonService.getStepFindings( + event.sender.id, + { + projectComparisonContextId: request.projectComparisonContextId, + projectWorkspaceId: request.projectWorkspaceId, + step: request.step, + }, + ) + }, + ) + + handle( + desktopApiIpcChannels.backendProjectComparisonRefreshComparison, + async (event, request) => { + if (!isRecord(request) || typeof request.projectComparisonContextId !== 'string') { + throw new Error('Backend project comparison refresh is invalid.') + } + return await services.backendProjectComparisonService.refreshComparison( + event.sender.id, + request.projectComparisonContextId, + ) + }, + ) + + handle( + desktopApiIpcChannels.projectManagementDiscoverProject, + async (_event, directory) => { + if (!services.projectManagementReadService) { + throw new Error('Project management reads are unavailable.') + } + if (typeof directory !== 'string') { + throw new Error('Project management directory must be a string.') + } + const authorizedDirectory = + await services.workspaceService.requestProjectPathAccess(directory) + return await services.projectManagementReadService.discoverProject( + authorizedDirectory, + ) + }, + ) + handle( desktopApiIpcChannels.projectManagementReadManifest, async (_event, projectRoot) => { @@ -1363,7 +1664,7 @@ export function registerIpc( ) handle( - desktopApiIpcChannels.projectManagementReadWorkspaceTexts, + desktopApiIpcChannels.projectManagementReadWorkspaceStepConfiguration, async (_event, request) => { if (!services.projectManagementReadService) { throw new Error('Project management reads are unavailable.') @@ -1372,13 +1673,12 @@ export function registerIpc( !isRecord(request) || typeof request.projectRoot !== 'string' || typeof request.workspacePath !== 'string' || - !Array.isArray(request.paths) || - !request.paths.every((path) => typeof path === 'string') + typeof request.step !== 'string' ) { - throw new Error('Project management workspace read request is invalid.') + throw new Error('Project management Step Configuration request is invalid.') } - return await services.projectManagementReadService.readWorkspaceTexts( - request as unknown as DesktopProjectManagementWorkspaceTextsRequest, + return await services.projectManagementReadService.readWorkspaceStepConfiguration( + request as unknown as DesktopProjectManagementWorkspaceStepConfigurationRequest, ) }, ) @@ -1407,6 +1707,9 @@ export function registerIpc( const projectRoot = await services.workspaceService.registerProjectRoot( path as string, ) + if (typeof _event.sender.id === 'number') { + services.backendWorkspaceService.clearWindow(_event.sender.id) + } const pendingRoots = (await services.workspaceService.listPendingExternalReadRoots?.()) ?? [] if (pendingRoots.length === 0) return projectRoot @@ -1453,20 +1756,10 @@ export function registerIpc( handle(desktopApiIpcChannels.workspaceClearProjectRoot, async (event) => { const sender = event.sender - for (const [ - subscriptionId, - subscription, - ] of projectFileWatchSubscriptions.entries()) { - if (subscription.sender === sender) { - await unwatchProjectFile(subscriptionId) - } - } - for (const [subscriptionId, subscription] of projectLogTailSubscriptions.entries()) { - if (subscription.sender === sender) { - await unsubscribeProjectLogTail(subscriptionId) - } - } await services.workspaceService.clearProjectRoot() + if (typeof sender.id === 'number') { + services.backendWorkspaceService.clearWindow(sender.id) + } }) handle( @@ -1499,50 +1792,6 @@ export function registerIpc( }, ) - handle( - desktopApiIpcChannels.workspaceReadWorkspaceParameters, - async (_event, workspacePath) => { - return await services.workspaceService.readWorkspaceParameters( - workspacePath as string, - ) - }, - ) - - handle( - desktopApiIpcChannels.workspaceHasConfigShadow, - async (_event, workspacePath) => { - return await services.workspaceService.hasWorkspaceConfigShadow( - workspacePath as string, - ) - }, - ) - - handle( - desktopApiIpcChannels.workspaceEditWorkspaceParameters, - async (_event, workspacePath, edits) => { - return await services.workspaceService.editWorkspaceParameters( - workspacePath as string, - edits as { json_path: (string | number)[]; value: unknown }[], - ) - }, - ) - - handle( - desktopApiIpcChannels.workspaceApplyWorkspaceParameterWrites, - async (_event, workspacePath, writes) => { - if (typeof workspacePath !== 'string') { - throw new Error('Workspace path must be a string') - } - if (!Array.isArray(writes)) { - throw new Error('Workspace parameter writes must be an array') - } - await services.workspaceService.applyWorkspaceParameterWrites( - workspacePath, - writes as DesktopAgentWorkspaceParameterWrite[], - ) - }, - ) - handle( desktopApiIpcChannels.workspaceReadProjectTextFileTail, async (_event, path, maxChars) => { @@ -1563,17 +1812,6 @@ export function registerIpc( }, ) - handle( - desktopApiIpcChannels.workspaceReadOptionalProjectTextFileUpdate, - async (_event, path, fromOffsetBytes, maxChars) => { - return await services.workspaceService.readOptionalProjectTextFileUpdate( - path as string, - fromOffsetBytes as number, - maxChars as number, - ) - }, - ) - handle( desktopApiIpcChannels.workspaceReadOptionalProjectTextFileChunk, async (_event, path, fromOffsetBytes, maxBytes) => { @@ -1585,59 +1823,20 @@ export function registerIpc( }, ) - handle( - desktopApiIpcChannels.workspaceSubscribeProjectLogTail, - async (event, path, options) => { - const sender = event.sender - const isSenderDestroyed = (): boolean => - typeof sender.isDestroyed === 'function' ? sender.isDestroyed() : false - let subscriptionId: string | null = null - const onDestroyed = (): void => { - if (!subscriptionId) return - void unsubscribeProjectLogTail(subscriptionId) - } - - subscriptionId = await services.workspaceService.subscribeProjectLogTail( - path as string, - options as { - maxInitialChars?: number - maxChunkChars?: number - pollIntervalMs?: number - }, - (payload) => { - if (isSenderDestroyed()) return - if (typeof sender.send === 'function') { - sender.send(desktopApiEventChannels.workspaceLogTail, payload) - } - }, - ) - projectLogTailSubscriptions.set(subscriptionId, { - sender, - onDestroyed, - }) - if (typeof sender.once === 'function') { - sender.once('destroyed', onDestroyed) - } - - if (isSenderDestroyed()) { - onDestroyed() - } - - return subscriptionId - }, - ) - handle(desktopApiIpcChannels.workspaceReadProjectBinaryFile, async (_event, path) => { return await services.workspaceService.readProjectBinaryFile(path as string) }) handle( desktopApiIpcChannels.workspaceWriteProjectTextFile, - async (_event, path, content) => { + async (event, path, content) => { + requireBackendMutationAllowed(event) await services.workspaceService.writeProjectTextFile( path as string, content as string, ) + invalidateBackendWorkspaceForSender(event.sender) + services.backendProjectComparisonService.invalidateWorkspace(path as string) }, ) @@ -1654,7 +1853,8 @@ export function registerIpc( handle( desktopApiIpcChannels.workspaceDiscardFailedWorkspaceCreate, - async (_event, path) => { + async (event, path) => { + requireBackendMutationAllowed(event) if (typeof path !== 'string') { throw new Error('Workspace path must be a string') } @@ -1664,7 +1864,8 @@ export function registerIpc( handle( desktopApiIpcChannels.workspacePrepareProjectDirectoryReplacement, - async (_event, path) => { + async (event, path) => { + requireBackendMutationAllowed(event) return await services.workspaceService.prepareProjectDirectoryReplacement( path as string, ) @@ -1673,31 +1874,58 @@ export function registerIpc( handle( desktopApiIpcChannels.workspaceRestoreProjectDirectoryReplacement, - async (_event, replacementId) => { + async (event, replacementId) => { if (typeof replacementId !== 'string') { throw new Error('Workspace replacement id must be a string') } + const replacement = + services.workspaceService.getProjectDirectoryReplacement(replacementId) + await requireCreationCleanupAllowed( + event, + replacement.projectRoot, + replacement.targetPath, + ) await services.workspaceService.restoreProjectDirectoryReplacement(replacementId) + invalidateBackendWorkspaceForSender(event.sender) + services.backendProjectComparisonService.invalidateWorkspace(replacement.targetPath) }, ) handle( desktopApiIpcChannels.workspaceFinalizeProjectDirectoryReplacement, - async (_event, replacementId) => { + async (event, replacementId) => { if (typeof replacementId !== 'string') { throw new Error('Workspace replacement id must be a string') } + const replacement = + services.workspaceService.getProjectDirectoryReplacement(replacementId) + await requireCreationCleanupAllowed( + event, + replacement.projectRoot, + replacement.targetPath, + ) await services.workspaceService.finalizeProjectDirectoryReplacement(replacementId) + invalidateBackendWorkspaceForSender(event.sender) + services.backendProjectComparisonService.invalidateWorkspace(replacement.targetPath) }, ) handle( desktopApiIpcChannels.workspaceRetainProjectDirectoryReplacement, - async (_event, replacementId) => { + async (event, replacementId) => { if (typeof replacementId !== 'string') { throw new Error('Workspace replacement id must be a string') } + const replacement = + services.workspaceService.getProjectDirectoryReplacement(replacementId) + await requireCreationCleanupAllowed( + event, + replacement.projectRoot, + replacement.targetPath, + ) await services.workspaceService.retainProjectDirectoryReplacement(replacementId) + invalidateBackendWorkspaceForSender(event.sender) + services.backendProjectComparisonService.invalidateWorkspace(replacement.targetPath) }, ) @@ -1709,64 +1937,36 @@ export function registerIpc( return await services.workspaceService.scanRtlDirectory(path as string) }) + handle(desktopApiIpcChannels.workspaceDiscoverHdlModules, async (_event, request) => { + return await services.workspaceService.discoverHdlModules( + (request ?? {}) as HdlModuleDiscoveryRequest, + ) + }) + handle(desktopApiIpcChannels.workspaceListDesignFiles, async () => { return await services.workspaceService.listDesignFiles() }) - handle(desktopApiIpcChannels.workspaceAddDesignFiles, async (_event, sourcePaths) => { - return await services.workspaceService.addDesignFiles(sourcePaths as string[]) + handle(desktopApiIpcChannels.workspaceAddDesignFiles, async (event, sourcePaths) => { + requireBackendMutationAllowed(event) + const result = await services.workspaceService.addDesignFiles(sourcePaths as string[]) + const workspaceRoot = await services.workspaceService.getProjectRoot() + invalidateBackendWorkspaceForSender(event.sender) + services.backendProjectComparisonService.invalidateWorkspace(workspaceRoot) + return result }) handle( desktopApiIpcChannels.workspaceRemoveDesignFile, - async (_event, filelistEntry) => { - return await services.workspaceService.removeDesignFile(filelistEntry as string) - }, - ) - - handle(desktopApiIpcChannels.workspaceWatchProjectFile, async (event, path) => { - const sender = event.sender - let subscriptionId: string | null = null - const onDestroyed = (): void => { - if (!subscriptionId) return - void unwatchProjectFile(subscriptionId) - } - - subscriptionId = await services.workspaceService.watchProjectFile( - path as string, - (payload) => { - if (event.sender.isDestroyed()) return - if (typeof event.sender.send === 'function') { - event.sender.send(desktopApiEventChannels.workspaceFileChanged, payload) - } - }, - ) - projectFileWatchSubscriptions.set(subscriptionId, { - sender, - onDestroyed, - }) - if (typeof sender.once === 'function') { - sender.once('destroyed', onDestroyed) - } - - if (sender.isDestroyed()) { - onDestroyed() - } - - return subscriptionId - }) - - handle( - desktopApiIpcChannels.workspaceUnwatchProjectFile, - async (_event, subscriptionId) => { - await unwatchProjectFile(subscriptionId as string) - }, - ) - - handle( - desktopApiIpcChannels.workspaceUnsubscribeProjectLogTail, - async (_event, subscriptionId) => { - await unsubscribeProjectLogTail(subscriptionId as string) + async (event, filelistEntry) => { + requireBackendMutationAllowed(event) + const result = await services.workspaceService.removeDesignFile( + filelistEntry as string, + ) + const workspaceRoot = await services.workspaceService.getProjectRoot() + invalidateBackendWorkspaceForSender(event.sender) + services.backendProjectComparisonService.invalidateWorkspace(workspaceRoot) + return result }, ) @@ -1782,6 +1982,26 @@ export function registerIpc( return await services.workspaceResourceService.getIndex() }) + handle(desktopApiIpcChannels.backendWorkspaceGetOverview, async () => { + return await services.backendWorkspaceService.getOverview() + }) + + handle(desktopApiIpcChannels.backendWorkspaceGetArtifact, async (_event, request) => { + return await services.backendWorkspaceService.getArtifact( + request as import('@ecos-studio/shared').BackendWorkspaceArtifactRequest, + ) + }) + + handle(desktopApiIpcChannels.backendWorkspaceGetStepDetail, async (_event, request) => { + return await services.backendWorkspaceService.getStepDetail( + request as import('@ecos-studio/shared').BackendWorkspaceStepDetailRequest, + ) + }) + + handle(desktopApiIpcChannels.backendWorkspaceRefreshOverview, async () => { + return await services.backendWorkspaceService.refreshOverview() + }) + handle(desktopApiIpcChannels.workspaceResourcesReadHome, async () => { return await services.workspaceResourceService.readHome() }) @@ -1794,26 +2014,6 @@ export function registerIpc( return await services.workspaceResourceService.readParameters() }) - handle( - desktopApiIpcChannels.workspaceResourcesWriteParameters, - async (_event, request) => { - if ( - !isRecord(request) || - !isRecord(request.parameters) || - typeof request.workspace !== 'string' || - request.workspace.trim() === '' - ) { - throw new Error( - 'Workspace parameters write requires a parameters object and workspace path', - ) - } - return await services.workspaceResourceService.writeParameters({ - parameters: request.parameters, - workspace: request.workspace, - }) - }, - ) - handle( desktopApiIpcChannels.workspaceResourcesResolveStepInfo, async (_event, request) => { @@ -1936,38 +2136,119 @@ export function registerIpc( handle(desktopApiIpcChannels.designRuntimeCancel, async (_event, request) => { const runtimeRequest = request as DesignRuntimeCancelRequest - return requireDesignTool(runtimeRequest.designTool) === 'frontend' - ? await services.frontendRpcRuntimeService.cancelOperationLegacy( - runtimeRequest.operationId, - ) - : await services.eccRuntimeService.cancelOperationLegacy(runtimeRequest.operationId) + if (requireDesignTool(runtimeRequest.designTool) !== 'frontend') { + throw new Error('Backend operation cancellation requires a Product Command') + } + return await services.frontendRpcRuntimeService.cancelOperationLegacy( + runtimeRequest.operationId, + ) }) handle(desktopApiIpcChannels.designRuntimeRpcHello, async (_event, request) => { - const designTool = requireDesignTool( - (request as DesignRuntimeTargetRequest).designTool, + if ( + requireDesignTool((request as DesignRuntimeTargetRequest).designTool) !== 'frontend' + ) { + throw new Error('Backend runtime negotiation is not supported') + } + return await services.frontendRpcRuntimeService.rpcHello() + }) + + handle(desktopApiIpcChannels.workspaceCreationModelGet, async (_event, request) => { + const [discovery, pdkInstallations] = await Promise.all([ + services.eccRuntimeService.describeWorkspaceSpec(), + services.pdkInventoryService.listInstallations(), + ]) + return buildWorkspaceCreationModel( + discovery as Record, + pdkInstallations, + (request as WorkspaceCreationModelRequest | undefined) ?? {}, ) - return designTool === 'frontend' - ? await services.frontendRpcRuntimeService.rpcHello() - : await services.eccRuntimeService.rpcHello() + }) + + handle(desktopApiIpcChannels.productCommandExecute, async (event, request) => { + if ( + services.shutdownCoordinator?.isMutationBlocked(event.sender.id) && + isShutdownBlockedProductCommand(request) + ) { + throw Object.assign(new Error('Shutdown is in progress.'), { + code: 'SHUTDOWN_IN_PROGRESS', + }) + } + const ownerWindowId = typeof event.sender.id === 'number' ? event.sender.id : 0 + return await executeProductCommand(request, { + beginCreate: services.workspaceCreationJournal + ? (createRequest) => + services.workspaceCreationJournal!.begin(ownerWindowId, createRequest) + : undefined, + abandonCreate: services.workspaceCreationJournal + ? (creationId) => + services.workspaceCreationJournal!.abandon(creationId, ownerWindowId) + : undefined, + completeCreate: services.workspaceCreationJournal + ? (creationId) => + services.workspaceCreationJournal!.complete(creationId, ownerWindowId) + : undefined, + continueCreate: services.workspaceCreationJournal + ? (creationId) => + services.workspaceCreationJournal!.continueInitialization( + creationId, + ownerWindowId, + ) + : undefined, + failCreate: services.workspaceCreationJournal + ? (creationId, error) => + services.workspaceCreationJournal!.markUnfinished( + creationId, + error instanceof Error ? error.message : String(error), + ownerWindowId, + ) + : undefined, + markCreateWorkspaceCreated: services.workspaceCreationJournal + ? (creationId, result) => + services.workspaceCreationJournal!.markWorkspaceCreated( + creationId, + result, + ownerWindowId, + ) + : undefined, + registerCreateWorkspace: services.workspaceCreationJournal + ? (creationId) => + services.workspaceCreationJournal!.registerWorkspace( + creationId, + ownerWindowId, + ) + : undefined, + ownsWorkspaceHandle: (workspaceHandle) => + workspaceHandleSubscriptions.get(workspaceHandle)?.sender === event.sender, + prepareCreate: async (createRequest) => + await prepareWorkspaceCreateBinding(services, createRequest), + runtime: services.eccRuntimeService, + trackCreateResult: (result) => { + const workspaceHandle = workspaceHandleFromResult(result) + const directory = workspaceDirectoryFromResult(result) + if (workspaceHandle && directory) { + trackWorkspaceHandle(event.sender, workspaceHandle, directory) + } + }, + }) }) handle(desktopApiIpcChannels.designRuntimeRpcPing, async (_event, request) => { - const designTool = requireDesignTool( - (request as DesignRuntimeTargetRequest).designTool, - ) - return designTool === 'frontend' - ? await services.frontendRpcRuntimeService.rpcPing() - : await services.eccRuntimeService.rpcPing() + if ( + requireDesignTool((request as DesignRuntimeTargetRequest).designTool) !== 'frontend' + ) { + throw new Error('Backend runtime ping is not supported') + } + return await services.frontendRpcRuntimeService.rpcPing() }) handle(desktopApiIpcChannels.designRuntimeRpcShutdown, async (_event, request) => { - const designTool = requireDesignTool( - (request as DesignRuntimeTargetRequest).designTool, - ) - return designTool === 'frontend' - ? await services.frontendRpcRuntimeService.rpcShutdown() - : await services.eccRuntimeService.rpcShutdown() + if ( + requireDesignTool((request as DesignRuntimeTargetRequest).designTool) !== 'frontend' + ) { + throw new Error('Backend runtime shutdown RPC is not supported') + } + return await services.frontendRpcRuntimeService.rpcShutdown() }) handle( @@ -1985,17 +2266,12 @@ export function registerIpc( handle(desktopApiIpcChannels.designRuntimeWorkspaceCreate, async (event, request) => { const runtimeRequest = request as DesignRuntimeWorkspaceCreateRequest const designTool = requireDesignTool(runtimeRequest.designTool) - const backendRequest = - designTool === 'backend' - ? (runtimeRequest.payload as unknown as EccWorkspaceCreateRequest) - : null - const eccBackendRequest = backendRequest - ? await prepareEccWorkspaceCreateRequest(services, backendRequest) - : null - const result = - designTool === 'frontend' - ? await services.frontendRpcRuntimeService.createWorkspace(runtimeRequest.payload) - : await services.eccRuntimeService.createWorkspace(eccBackendRequest!) + if (designTool !== 'frontend') { + throw new Error('Backend workspace creation requires a Product Command') + } + const result = await services.frontendRpcRuntimeService.createWorkspace( + runtimeRequest.payload, + ) const workspaceHandle = workspaceHandleFromResult(result) if (workspaceHandle) { trackWorkspaceHandle( @@ -2014,52 +2290,83 @@ export function registerIpc( handle(desktopApiIpcChannels.designRuntimeWorkspaceOpen, async (event, request) => { const runtimeRequest = request as DesignRuntimeWorkspaceOpenRequest const designTool = requireDesignTool(runtimeRequest.designTool) - const result = - designTool === 'frontend' - ? await services.frontendRpcRuntimeService.openWorkspace(runtimeRequest.directory) - : await services.eccRuntimeService.openWorkspace({ - directory: runtimeRequest.directory, - }) - const workspaceHandle = workspaceHandleFromResult(result) - if (workspaceHandle) { - trackWorkspaceHandle( - event.sender, - workspaceHandle, - runtimeRequest.directory, - designTool, - ) - const directory = workspaceDirectoryFromResult(result) - if (directory) - trackWorkspaceHandle(event.sender, workspaceHandle, directory, designTool) + const openDirectory = + designTool === 'backend' + ? await realpath(runtimeRequest.directory).catch(() => + normalizeWorkspacePath(runtimeRequest.directory), + ) + : runtimeRequest.directory + if ( + designTool === 'backend' && + (workspaceOwnedByAnotherSender(event.sender, openDirectory) || + backendWorkspaceOpenClaims.has(openDirectory)) + ) { + throw new Error('Workspace Runtime Session is owned by another window.') + } + if (designTool === 'backend') { + backendWorkspaceOpenClaims.set(openDirectory, event.sender) + } + try { + const existingHandle = + designTool === 'backend' + ? workspaceHandleForSender(event.sender, openDirectory) + : null + const result = + designTool === 'frontend' + ? await services.frontendRpcRuntimeService.openWorkspace(openDirectory) + : existingHandle + ? await services.eccRuntimeService.workspaceSession(existingHandle) + : await services.eccRuntimeService.openWorkspace( + await prepareWorkspaceOpenBinding(services, openDirectory), + ) + const workspaceHandle = workspaceHandleFromResult(result) + if (workspaceHandle) { + trackWorkspaceHandle(event.sender, workspaceHandle, openDirectory, designTool) + const directory = workspaceDirectoryFromResult(result) + if (directory) + trackWorkspaceHandle(event.sender, workspaceHandle, directory, designTool) + } + return result + } finally { + if (backendWorkspaceOpenClaims.get(openDirectory) === event.sender) { + backendWorkspaceOpenClaims.delete(openDirectory) + } } - return result }) - handle(desktopApiIpcChannels.designRuntimeWorkspaceClose, async (_event, request) => { + handle(desktopApiIpcChannels.designRuntimeWorkspaceClose, async (event, request) => { const runtimeRequest = request as DesignRuntimeWorkspaceHandleRequest const subscription = workspaceHandleSubscriptions.get(runtimeRequest.workspaceHandle) + if (!subscription || subscription.sender !== event.sender) return { ok: true } const designTool = requireDesignTool( runtimeRequest.designTool ?? subscription?.designTool, ) const existingClose = workspaceHandleClosePromises.get(runtimeRequest.workspaceHandle) if (existingClose) return await existingClose - if (subscription) { - workspaceHandleSubscriptions.delete(runtimeRequest.workspaceHandle) - if (typeof subscription.sender.off === 'function') { - subscription.sender.off('destroyed', subscription.onDestroyed) - } - } - - const closePromise = Promise.resolve().then(() => - designTool === 'frontend' - ? services.frontendRpcRuntimeService.closeWorkspace( + const closePromise = Promise.resolve() + .then(() => + designTool === 'frontend' + ? services.frontendRpcRuntimeService.closeWorkspace( + runtimeRequest.workspaceHandle, + ) + : services.eccRuntimeService.releaseWorkspace({ + workspaceHandle: runtimeRequest.workspaceHandle, + }), + ) + .then((result) => { + const retained = isRecord(result) && result.retained === true + if (!retained) { + workspaceHandleSubscriptions.delete(runtimeRequest.workspaceHandle) + services.shutdownCoordinator?.untrackWorkspaceHandle( runtimeRequest.workspaceHandle, ) - : services.eccRuntimeService.closeWorkspace({ - workspaceHandle: runtimeRequest.workspaceHandle, - }), - ) + if (typeof subscription.sender.off === 'function') { + subscription.sender.off('destroyed', subscription.onDestroyed) + } + } + return result + }) const trackedClosePromise = closePromise.finally(() => { workspaceHandleClosePromises.delete(runtimeRequest.workspaceHandle) }) @@ -2092,29 +2399,44 @@ export function registerIpc( }) handle( - desktopApiIpcChannels.designRuntimeWorkspaceRefreshConfig, - async (_event, request) => { - const runtimeRequest = request as DesignRuntimeWorkspaceHandleRequest - return requireDesignTool(runtimeRequest.designTool) === 'frontend' - ? await services.frontendRpcRuntimeService.refreshConfig( - runtimeRequest.workspaceHandle, - ) - : await services.eccRuntimeService.refreshConfig(runtimeRequest) + desktopApiIpcChannels.designRuntimeWorkspaceStepConfiguration, + async (event, request) => { + const runtimeRequest = request as DesignRuntimeWorkspaceHandleRequest & { + step: string + } + if (requireDesignTool(runtimeRequest.designTool) !== 'backend') { + return { + status: 'unavailable', + step: runtimeRequest.step, + reason: 'step_configuration_not_supported', + } satisfies EccWorkspaceStepConfigurationReadResult + } + const subscription = workspaceHandleSubscriptions.get( + runtimeRequest.workspaceHandle, + ) + if ( + !subscription || + subscription.sender !== event.sender || + subscription.designTool !== 'backend' + ) { + throw new Error('Backend Step Configuration requires an owned Workspace Session.') + } + return await services.eccRuntimeService.readWorkspaceStepConfiguration({ + step: runtimeRequest.step, + workspaceHandle: runtimeRequest.workspaceHandle, + }) }, ) handle( - desktopApiIpcChannels.designRuntimeWorkspaceSyncConfig, + desktopApiIpcChannels.designRuntimeWorkspaceRefreshConfig, async (_event, request) => { - const runtimeRequest = request as DesignRuntimeWorkspaceSyncConfigRequest + const runtimeRequest = request as DesignRuntimeWorkspaceHandleRequest return requireDesignTool(runtimeRequest.designTool) === 'frontend' - ? await services.frontendRpcRuntimeService.syncConfig( + ? await services.frontendRpcRuntimeService.refreshConfig( runtimeRequest.workspaceHandle, - runtimeRequest.configPath, - ) - : await services.eccRuntimeService.syncConfig( - runtimeRequest as unknown as EccWorkspaceSyncConfigRequest, ) + : await services.eccRuntimeService.refreshConfig(runtimeRequest) }, ) @@ -2122,181 +2444,53 @@ export function registerIpc( desktopApiIpcChannels.designRuntimeWorkspaceResetFlow, async (_event, request) => { const runtimeRequest = request as DesignRuntimeWorkspaceHandleRequest - return requireDesignTool(runtimeRequest.designTool) === 'frontend' - ? await services.frontendRpcRuntimeService.resetFlow( - runtimeRequest.workspaceHandle, - ) - : await services.eccRuntimeService.resetFlow(runtimeRequest) + if (requireDesignTool(runtimeRequest.designTool) !== 'frontend') { + throw new Error('Backend flow reset requires a Product Command') + } + return await services.frontendRpcRuntimeService.resetFlow( + runtimeRequest.workspaceHandle, + ) }, ) handle(desktopApiIpcChannels.designRuntimeFlowRun, async (_event, request) => { const runtimeRequest = request as DesignRuntimeFlowRunRequest - return requireDesignTool(runtimeRequest.designTool) === 'frontend' - ? await services.frontendRpcRuntimeService.runFlow( - runtimeRequest.workspaceHandle, - Boolean(runtimeRequest.rerun), - ) - : await services.eccRuntimeService.runFlow( - runtimeRequest as unknown as EccFlowRunRequest, - ) + if (requireDesignTool(runtimeRequest.designTool) !== 'frontend') { + throw new Error('Backend flow execution requires a Product Command') + } + return await services.frontendRpcRuntimeService.runFlow( + runtimeRequest.workspaceHandle, + Boolean(runtimeRequest.rerun), + ) }) handle(desktopApiIpcChannels.designRuntimeFlowRunStep, async (_event, request) => { const runtimeRequest = request as DesignRuntimeFlowRunStepRequest - return requireDesignTool(runtimeRequest.designTool) === 'frontend' - ? await services.frontendRpcRuntimeService.runStep(runtimeRequest.workspaceHandle, { - ...runtimeRequest.options, - rerun: Boolean(runtimeRequest.rerun), - step: runtimeRequest.step, - }) - : await services.eccRuntimeService.runStep( - runtimeRequest as unknown as EccFlowRunStepRequest, - ) - }) - - handle(desktopApiIpcChannels.eccRpcHello, async () => { - return await services.eccRuntimeService.rpcHello() - }) - - handle(desktopApiIpcChannels.eccRpcPing, async () => { - return await services.eccRuntimeService.rpcPing() - }) - - handle(desktopApiIpcChannels.eccRpcShutdown, async () => { - return await services.eccRuntimeService.rpcShutdown() - }) - - handle(desktopApiIpcChannels.eccWorkspaceCreate, async (event, request) => { - const createRequest = request as EccWorkspaceCreateRequest - const validatedRequest = await prepareEccWorkspaceCreateRequest( - services, - createRequest, - ) - const result = await services.eccRuntimeService.createWorkspace(validatedRequest) - const workspaceHandle = workspaceHandleFromResult(result) - const directory = workspaceDirectoryFromResult(result) - if (workspaceHandle) { - if (typeof createRequest.directory === 'string') { - trackWorkspaceHandle(event.sender, workspaceHandle, createRequest.directory) - } - if (directory) { - trackWorkspaceHandle(event.sender, workspaceHandle, directory) - } - } - return result - }) - - handle(desktopApiIpcChannels.eccWorkspaceOpen, async (event, request) => { - const openRequest = request as EccWorkspaceOpenRequest - const result = await services.eccRuntimeService.openWorkspace(openRequest) - const workspaceHandle = workspaceHandleFromResult(result) - const directory = workspaceDirectoryFromResult(result) - if (workspaceHandle) { - if (typeof openRequest.directory === 'string') { - trackWorkspaceHandle(event.sender, workspaceHandle, openRequest.directory) - } - if (directory) { - trackWorkspaceHandle(event.sender, workspaceHandle, directory) - } + if (requireDesignTool(runtimeRequest.designTool) !== 'frontend') { + throw new Error('Backend flow execution requires a Product Command') } - return result - }) - - handle(desktopApiIpcChannels.eccWorkspaceClose, async (_event, request) => { - const closeRequest = request as EccWorkspaceHandleRequest - return await detachTrackedWorkspaceHandle(closeRequest.workspaceHandle) - }) - - handle(desktopApiIpcChannels.eccWorkspaceHome, async (_event, request) => { - return await services.eccRuntimeService.workspaceHome( - request as EccWorkspaceHandleRequest, - ) - }) - - handle(desktopApiIpcChannels.eccWorkspaceInfo, async (_event, request) => { - return await services.eccRuntimeService.workspaceInfo( - request as EccWorkspaceInfoRequest, - ) - }) - - handle(desktopApiIpcChannels.eccWorkspaceRefreshConfig, async (_event, request) => { - return await services.eccRuntimeService.refreshConfig( - request as EccWorkspaceHandleRequest, - ) - }) - - handle(desktopApiIpcChannels.eccWorkspaceSyncConfig, async (_event, request) => { - return await services.eccRuntimeService.syncConfig( - request as EccWorkspaceSyncConfigRequest, - ) - }) - - handle(desktopApiIpcChannels.eccWorkspaceResetFlow, async (_event, request) => { - return await services.eccRuntimeService.resetFlow( - request as EccWorkspaceHandleRequest, - ) - }) - - handle(desktopApiIpcChannels.eccWorkspaceExportSignoff, async (_event, request) => { - return await services.eccRuntimeService.exportSignoff( - request as EccWorkspaceExportSignoffRequest, + return await services.frontendRpcRuntimeService.runStep( + runtimeRequest.workspaceHandle, + { + ...runtimeRequest.options, + rerun: Boolean(runtimeRequest.rerun), + step: runtimeRequest.step, + }, ) }) - handle(desktopApiIpcChannels.eccWorkspaceInspectSignoff, async (_event, request) => { - return await services.eccRuntimeService.inspectSignoff( + handle(desktopApiIpcChannels.eccRuntimeEngineeringSnapshot, async (_event, request) => { + return await services.eccRuntimeService.engineeringSnapshot( request as EccWorkspaceHandleRequest, ) }) - handle(desktopApiIpcChannels.eccFlowRun, async (_event, request) => { - return await services.eccRuntimeService.runFlow(request as EccFlowRunRequest) - }) - - handle(desktopApiIpcChannels.eccFlowRunStep, async (_event, request) => { - return await services.eccRuntimeService.runStep(request as EccFlowRunStepRequest) - }) - - handle(desktopApiIpcChannels.eccRuntimeStartFlow, async (_event, request) => { - return await services.eccRuntimeService.startFlowOperation( - request as EccRuntimeStartFlowRequest, - ) - }) - - handle(desktopApiIpcChannels.eccRuntimeStartStep, async (_event, request) => { - return await services.eccRuntimeService.startStepOperation( - request as EccRuntimeStartStepRequest, - ) - }) - - handle(desktopApiIpcChannels.eccRuntimeOperationStatus, async (_event, request) => { - return await services.eccRuntimeService.operationStatus( - request as EccRuntimeOperationRequest, - ) - }) - handle(desktopApiIpcChannels.eccRuntimeWaitForOperation, async (_event, request) => { return await services.eccRuntimeService.waitForOperation( request as EccRuntimeOperationRequest, ) }) - handle(desktopApiIpcChannels.eccRuntimeOperationCancel, async (_event, request) => { - return await services.eccRuntimeService.cancelOperation( - request as EccRuntimeOperationRequest, - ) - }) - - handle( - desktopApiIpcChannels.eccRuntimeAcknowledgeStepRendered, - async (_event, request) => { - return await services.eccRuntimeService.acknowledgeStepRendered( - request as EccRuntimeStepRenderedAckRequest, - ) - }, - ) - handle(desktopApiIpcChannels.eccRuntimeSnapshot, async (_event, request) => { return await services.eccRuntimeService.workspaceSnapshot( request as EccWorkspaceHandleRequest, @@ -2380,13 +2574,63 @@ export function registerIpc( if (!agentRequest.directory && windowDirectory) { agentRequest.directory = windowDirectory } + if (agentRequest.workspaceId && agentRequest.directory) { + const snapshot = await services.eccRuntimeService.workspaceSnapshot({ + workspaceHandle: agentRequest.workspaceId, + }) + if ( + !isRecord(snapshot) || + typeof snapshot.directory !== 'string' || + normalizeWorkspacePath(snapshot.directory) !== + normalizeWorkspacePath(agentRequest.directory) + ) { + throw new Error('Agent Workspace context does not match its ECC session.') + } + const configuration = isRecord(snapshot.configuration) + ? snapshot.configuration + : null + const workspaceSpec = isRecord(configuration?.workspaceSpec) + ? configuration.workspaceSpec + : null + if (!workspaceSpec) throw new Error('ECC Workspace configuration is unavailable.') + const engineeringSnapshot = isRecord(snapshot.engineeringSnapshot) + ? snapshot.engineeringSnapshot + : null + const workspaceRevision = engineeringSnapshot?.workspaceRevision + if (!Number.isInteger(workspaceRevision) || Number(workspaceRevision) < 1) { + throw new Error('ECC Workspace Revision is unavailable.') + } + agentRequest.workspaceRevision = Number(workspaceRevision) + agentRequest.workspaceParameterValues = readAgentWorkspaceParameterValues( + workspaceSpec, + {}, + ) + const design = isRecord(workspaceSpec.design) ? workspaceSpec.design : null + if (typeof design?.name === 'string' && design.name) { + agentRequest.workspaceDesignId = design.name + } + } trackAgentSession(event.sender, agentRequest) return await requireAgentRuntime(services).startSession(agentRequest) }) handle(desktopApiIpcChannels.agentSendMessage, async (event, request) => { const agentRequest = readAgentSendMessageRequest(request) - requireAgentSessionOwner(event.sender, agentRequest) + const subscription = requireAgentSessionOwner(event.sender, agentRequest) + if (!agentRequest.confirmationToken && subscription.workspaceId) { + const snapshot = await services.eccRuntimeService.workspaceSnapshot({ + workspaceHandle: subscription.workspaceId, + }) + const engineeringSnapshot = + isRecord(snapshot) && isRecord(snapshot.engineeringSnapshot) + ? snapshot.engineeringSnapshot + : null + const workspaceRevision = engineeringSnapshot?.workspaceRevision + if (!Number.isInteger(workspaceRevision) || Number(workspaceRevision) < 1) { + throw new Error('ECC Workspace Revision is unavailable.') + } + agentRequest.workspaceRevision = Number(workspaceRevision) + } return await requireAgentRuntime(services).sendMessage(agentRequest) }) @@ -2525,6 +2769,11 @@ function readAgentStartSessionRequest(value: unknown): DesktopAgentStartSessionR ? record.directory.trim() : undefined const knownProjects = readAgentKnownProjects(record.knownProjects) + const workspaceId = + typeof record.workspaceId === 'string' && + /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(record.workspaceId) + ? record.workspaceId.trim() + : undefined return { providerId: readAgentProviderId(record), sessionId: readAgentSessionId(record.sessionId), @@ -2532,6 +2781,7 @@ function readAgentStartSessionRequest(value: unknown): DesktopAgentStartSessionR ...(directory ? { directory } : {}), ...(projectRoot ? { projectRoot } : {}), ...(knownProjects ? { knownProjects } : {}), + ...(workspaceId ? { workspaceId } : {}), } } @@ -2561,7 +2811,16 @@ function readAgentSendMessageRequest(value: unknown): DesktopAgentSendMessageReq if (typeof message !== 'string' || message.length > 4096) { throw new Error('Agent message must be a string of at most 4096 characters.') } + const confirmationToken = + typeof record.confirmationToken === 'string' && + /^[a-f0-9-]{36}$/.test(record.confirmationToken) + ? record.confirmationToken + : undefined + if (record.confirmationToken !== undefined && !confirmationToken) { + throw new Error('Agent execution confirmation token is invalid.') + } return { + ...(confirmationToken ? { confirmationToken } : {}), message, providerId: readAgentProviderId(record), sessionId: readAgentSessionId(record.sessionId), diff --git a/ecos/gui/apps/desktop-electron/electron/main/runtimeQuitGuard.test.ts b/ecos/gui/apps/desktop-electron/electron/main/runtimeQuitGuard.test.ts deleted file mode 100644 index c0f204157..000000000 --- a/ecos/gui/apps/desktop-electron/electron/main/runtimeQuitGuard.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { EccRpcShutdownResult, EccRuntimeEvent } from '@ecos-studio/shared' -import { describe, expect, it, vi } from 'vitest' - -import { installRuntimeQuitGuard } from './runtimeQuitGuard' - -class FakeApp { - private beforeQuit: ((event: { preventDefault(): void }) => void) | null = null - readonly quit = vi.fn() - - on( - event: 'before-quit', - listener: (closeEvent: { preventDefault(): void }) => void, - ): void { - if (event === 'before-quit') this.beforeQuit = listener - } - - requestQuit(): { prevented: boolean } { - let prevented = false - this.beforeQuit?.({ - preventDefault: () => { - prevented = true - }, - }) - return { prevented } - } -} - -class FakeRuntime { - hasPending = true - readonly shutdown = vi.fn<() => Promise>() - private listener: ((event: EccRuntimeEvent) => void) | null = null - - hasPendingRuntimeWork(): boolean { - return this.hasPending - } - - onEvent(listener: (event: EccRuntimeEvent) => void): () => void { - this.listener = listener - return () => { - this.listener = null - } - } - - rpcShutdown(): Promise { - return this.shutdown() - } - - emit(event: EccRuntimeEvent): void { - this.listener?.(event) - } -} - -async function flushPromises(): Promise { - await Promise.resolve() - await Promise.resolve() -} - -describe('installRuntimeQuitGuard', () => { - it('retries pending quit at a step ACK boundary but not for streaming logs', async () => { - const app = new FakeApp() - const runtime = new FakeRuntime() - runtime.shutdown.mockResolvedValueOnce({ - deferred: true, - ok: false, - shutdownBarrier: { - operationId: 'operation-1', - safeToStop: false, - state: 'running', - step: 'Synthesis', - workspaceId: 'workspace-1', - }, - }) - runtime.shutdown.mockResolvedValueOnce({ ok: true }) - installRuntimeQuitGuard({ app, onShutdownError: vi.fn(), runtime }) - - expect(app.requestQuit()).toEqual({ prevented: true }) - await flushPromises() - expect(runtime.shutdown).toHaveBeenCalledTimes(1) - - runtime.emit({ - event: { - eventId: 'workspace-1:1', - operationId: 'operation-1', - origin: 'gui', - payload: { chunk: 'live output' }, - sequence: 1, - timestamp: 1, - type: 'step.log', - workspaceId: 'workspace-1', - }, - type: 'runtime.protocol', - }) - await flushPromises() - expect(runtime.shutdown).toHaveBeenCalledTimes(1) - - runtime.emit({ - event: { - eventId: 'workspace-1:2', - operationId: 'operation-1', - origin: 'gui', - payload: { state: 'Success' }, - sequence: 2, - timestamp: 2, - type: 'step.completed', - workspaceId: 'workspace-1', - }, - type: 'runtime.protocol', - }) - await flushPromises() - - expect(runtime.shutdown).toHaveBeenCalledTimes(2) - expect(app.quit).toHaveBeenCalledOnce() - }) -}) diff --git a/ecos/gui/apps/desktop-electron/electron/main/runtimeQuitGuard.ts b/ecos/gui/apps/desktop-electron/electron/main/runtimeQuitGuard.ts deleted file mode 100644 index 85989d018..000000000 --- a/ecos/gui/apps/desktop-electron/electron/main/runtimeQuitGuard.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { EccRpcShutdownResult, EccRuntimeEvent } from '@ecos-studio/shared' - -export interface RuntimeQuitGuardApp { - on(event: 'before-quit', listener: (event: { preventDefault(): void }) => void): unknown - quit(): void -} - -export interface RuntimeQuitGuardRuntime { - hasPendingRuntimeWork(): boolean - onEvent(listener: (event: EccRuntimeEvent) => void): () => void - rpcShutdown(): Promise -} - -export interface RuntimeQuitGuardOptions { - app: RuntimeQuitGuardApp - onShutdownError(error: unknown): void - runtime: RuntimeQuitGuardRuntime -} - -const safeBoundaryEventTypes = new Set([ - 'step.completed', - 'operation.cancelled', - 'operation.completed', - 'operation.failed', -]) - -/** - * The renderer is already detached when its window closes. Retry a pending - * application quit only at a protocol boundary that can change the ECC - * shutdown decision, never for high-frequency log notifications. - */ -export function installRuntimeQuitGuard(options: RuntimeQuitGuardOptions): void { - let quitApproved = false - let quitPending = false - let shutdownInFlight = false - - const requestShutdown = (): void => { - if (!quitPending || shutdownInFlight) return - shutdownInFlight = true - void options.runtime - .rpcShutdown() - .then((result) => { - shutdownInFlight = false - if (result.deferred) return - quitApproved = true - options.app.quit() - }) - .catch((error) => { - shutdownInFlight = false - options.onShutdownError(error) - }) - } - - options.runtime.onEvent((event) => { - if (!quitPending || !shouldRetryShutdown(event, options.runtime)) return - requestShutdown() - }) - - options.app.on('before-quit', (event) => { - if (quitApproved) return - event.preventDefault() - quitPending = true - requestShutdown() - }) -} - -function shouldRetryShutdown( - event: EccRuntimeEvent, - runtime: RuntimeQuitGuardRuntime, -): boolean { - if (!runtime.hasPendingRuntimeWork()) return true - if (event.type === 'runtime.idle' || event.type === 'runtime.exited') return true - return event.type === 'runtime.protocol' && safeBoundaryEventTypes.has(event.event.type) -} diff --git a/ecos/gui/apps/desktop-electron/electron/main/shutdownAcceptedWork.ts b/ecos/gui/apps/desktop-electron/electron/main/shutdownAcceptedWork.ts new file mode 100644 index 000000000..b64b19a80 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/main/shutdownAcceptedWork.ts @@ -0,0 +1,47 @@ +import type { ShutdownScope } from './shutdownBlockers' + +interface Waiter { + resolve(): void + scope: ShutdownScope +} + +export class ShutdownAcceptedWork { + readonly counts = new Map() + private readonly waiters = new Set() + + begin(windowId: number, onChange: () => void): () => void { + this.counts.set(windowId, (this.counts.get(windowId) ?? 0) + 1) + onChange() + let ended = false + return () => { + if (ended) return + ended = true + const remaining = (this.counts.get(windowId) ?? 1) - 1 + if (remaining > 0) this.counts.set(windowId, remaining) + else this.counts.delete(windowId) + this.resolveWaiters() + onChange() + } + } + + wait(scope: ShutdownScope): Promise { + if (!this.count(scope)) return Promise.resolve() + return new Promise((resolve) => this.waiters.add({ resolve, scope })) + } + + private count(scope: ShutdownScope): number { + return [...this.counts].reduce( + (total, [windowId, count]) => + total + (scope.kind === 'application' || scope.windowId === windowId ? count : 0), + 0, + ) + } + + private resolveWaiters(): void { + for (const waiter of this.waiters) { + if (this.count(waiter.scope)) continue + this.waiters.delete(waiter) + waiter.resolve() + } + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/main/shutdownBlockers.ts b/ecos/gui/apps/desktop-electron/electron/main/shutdownBlockers.ts new file mode 100644 index 000000000..dc59c69e2 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/main/shutdownBlockers.ts @@ -0,0 +1,140 @@ +import type { + DesktopShutdownStatus, + EccBackgroundOperationProjection, + EccBackgroundWorkspaceCreation, +} from '@ecos-studio/shared' + +export interface ShutdownScope { + kind: 'window' | 'application' + windowId?: number +} + +export interface ShutdownBlockerSummary { + activeFlows: number + details: string[] + finalizations: number + pendingCommands: number + pendingCreations: number + snapshotFailures: number +} + +export function boundedShutdownIssue(value: string): string { + return value.replace(/\s+/g, ' ').trim().slice(0, 500) +} + +export function idleShutdownStatus(): DesktopShutdownStatus { + return { + activeFlows: 0, + attemptId: null, + finalizations: 0, + forceEligible: false, + pendingCommands: 0, + pendingCreations: 0, + scope: null, + snapshotFailures: 0, + state: 'idle', + } +} + +export function emptyShutdownBlockers(): ShutdownBlockerSummary { + return { + activeFlows: 0, + details: [], + finalizations: 0, + pendingCommands: 0, + pendingCreations: 0, + snapshotFailures: 0, + } +} + +export function buildShutdownBlockers(options: { + acceptedWorkByWindow: ReadonlyMap + creations: EccBackgroundWorkspaceCreation[] + handleOwners: ReadonlyMap + projection: EccBackgroundOperationProjection + scope: ShutdownScope +}): ShutdownBlockerSummary { + const { projection, scope } = options + const inScope = (workspaceHandle: string) => + scope.kind === 'application' || + options.handleOwners.get(workspaceHandle) === scope.windowId + const operations = projection.operations.filter((operation) => + inScope(operation.workspaceHandle), + ) + const finalizations = projection.finalizations.filter((item) => + inScope(item.workspaceHandle), + ) + const pendingCreations = options.creations.filter( + (item) => + item.status === 'active' && + (scope.kind === 'application' || item.ownerWindowId === scope.windowId), + ) + const pendingCommands = [...options.acceptedWorkByWindow].reduce( + (total, [windowId, count]) => + total + (scope.kind === 'application' || scope.windowId === windowId ? count : 0), + 0, + ) + return { + activeFlows: operations.length, + details: boundedDetails([ + ...operations.map( + (operation) => + `${workspaceName(operation.workspaceDirectory)}: ${operation.currentStep || operation.state}`, + ), + ...finalizations.map( + (item) => `${workspaceName(item.workspaceDirectory)}: ${item.state}`, + ), + ...pendingCreations.map( + (item) => `${workspaceName(item.targetDirectory ?? '')}: creating Workspace`, + ), + ...(pendingCommands + ? [`${pendingCommands} accepted backend command(s) finishing`] + : []), + ]), + finalizations: finalizations.filter((item) => item.state === 'finalizing').length, + pendingCommands, + pendingCreations: pendingCreations.length, + snapshotFailures: finalizations.filter((item) => item.state === 'snapshot-failed') + .length, + } +} + +export function hasShutdownBlockers(blockers: ShutdownBlockerSummary): boolean { + return ( + blockers.activeFlows + + blockers.finalizations + + blockers.pendingCommands + + blockers.pendingCreations + + blockers.snapshotFailures > + 0 + ) +} + +export function workspaceHandlesInShutdownScope( + handleOwners: ReadonlyMap, + scope: ShutdownScope, +): string[] | undefined { + return scope.kind === 'application' + ? undefined + : [...handleOwners] + .filter(([, windowId]) => windowId === scope.windowId) + .map(([workspaceHandle]) => workspaceHandle) +} + +function boundedDetails(details: string[]): string[] { + const visible = details.slice(0, 8) + return details.length > visible.length + ? [...visible, `${details.length - visible.length} more item(s)`] + : visible +} + +function workspaceName(path: string): string { + return ( + path + .replace(/[\\/]+$/g, '') + .split(/[\\/]/) + .pop() || + path || + 'Workspace' + ) +} diff --git a/ecos/gui/apps/desktop-electron/electron/main/shutdownCoordinator.test.ts b/ecos/gui/apps/desktop-electron/electron/main/shutdownCoordinator.test.ts new file mode 100644 index 000000000..a2ad928be --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/main/shutdownCoordinator.test.ts @@ -0,0 +1,331 @@ +import type { EccBackgroundOperationProjection } from '@ecos-studio/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ShutdownCoordinator } from './shutdownCoordinator' + +function projection( + overrides: Partial = {}, +): EccBackgroundOperationProjection { + return { + creations: [], + finalizations: [], + generation: 0, + operations: [], + outcomes: [], + ...overrides, + } +} + +function operationProjection(): EccBackgroundOperationProjection { + return projection({ + operations: [ + { + createdAt: 1, + currentStep: 'Route', + currentTool: 'openroad', + error: null, + kind: 'flow', + operationId: 'operation-1', + origin: 'gui', + rerun: false, + result: null, + state: 'running', + step: '', + updatedAt: 2, + workspaceDirectory: '/projects/demo/ws_1', + workspaceHandle: 'handle-1', + workspaceId: 'engineering-1', + }, + ], + }) +} + +function setup(currentProjection = projection()) { + let snapshot = currentProjection + const approve = vi.fn() + const requestRendererCleanup = vi.fn() + const markCreationsUnfinished = vi.fn().mockResolvedValue(undefined) + const forceTerminate = vi.fn().mockResolvedValue(undefined) + const flushRuntimeState = vi.fn().mockResolvedValue(undefined) + const cancelOperation = vi.fn().mockResolvedValue(undefined) + const promptInitial = vi.fn().mockResolvedValue('wait' as const) + const promptForce = vi.fn().mockResolvedValue('keep-waiting' as const) + const waitForRuntimeIdle = vi.fn().mockResolvedValue(undefined) + const coordinator = new ShutdownCoordinator({ + approve, + cancelOperation, + forceTerminate, + flushRuntimeState, + listWindowIds: () => [7], + markCreationsUnfinished, + operationProjection: () => snapshot, + promptForce, + promptInitial, + requestRendererCleanup, + waitForRuntimeIdle, + }) + coordinator.trackWorkspaceHandle(7, 'handle-1') + return { + approve, + cancelOperation, + coordinator, + forceTerminate, + flushRuntimeState, + markCreationsUnfinished, + promptForce, + promptInitial, + requestRendererCleanup, + waitForRuntimeIdle, + setProjection(value: EccBackgroundOperationProjection) { + snapshot = value + }, + } +} + +describe('ShutdownCoordinator', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + it('requests Renderer cleanup and approves an idle window exactly once', async () => { + const state = setup() + + await state.coordinator.requestWindowClose(7) + expect(state.promptInitial).not.toHaveBeenCalled() + expect(state.requestRendererCleanup).toHaveBeenCalledWith(expect.any(String), [7]) + const attemptId = state.coordinator.status().attemptId! + await state.coordinator.completeRendererCleanup(attemptId, 7, true) + + expect(state.approve).toHaveBeenCalledOnce() + await state.coordinator.completeRendererCleanup(attemptId, 7, true) + expect(state.approve).toHaveBeenCalledOnce() + }) + + it('exposes Force quit without interrupting stalled Renderer cleanup', async () => { + const state = setup() + + await state.coordinator.requestWindowClose(7) + expect(state.promptForce).not.toHaveBeenCalled() + expect(state.coordinator.status()).toMatchObject({ + forceEligible: true, + state: 'cleaning-renderers', + }) + await state.coordinator.reviewShutdownOptions() + expect(state.promptForce).toHaveBeenCalledOnce() + }) + + it('releases an approved window attempt so another window can close later', async () => { + const state = setup() + + await state.coordinator.requestWindowClose(7) + const firstAttempt = state.coordinator.status().attemptId! + await state.coordinator.completeRendererCleanup(firstAttempt, 7, true) + state.coordinator.windowClosed(7) + await state.coordinator.requestWindowClose(8) + + expect(state.coordinator.status().attemptId).not.toBe(firstAttempt) + expect(state.requestRendererCleanup).toHaveBeenLastCalledWith(expect.any(String), [8]) + }) + + it('exposes Force quit without interrupting safe drain', async () => { + const state = setup(operationProjection()) + + await state.coordinator.requestWindowClose(7) + expect(state.coordinator.status()).toMatchObject({ + forceEligible: true, + state: 'draining', + }) + expect(state.promptInitial).toHaveBeenCalledWith( + expect.objectContaining({ activeFlows: 1 }), + ) + expect(state.promptForce).not.toHaveBeenCalled() + + await state.coordinator.reviewShutdownOptions() + expect(state.promptForce).toHaveBeenCalledOnce() + }) + + it('waits for an already accepted backend command before Renderer cleanup', async () => { + const state = setup() + const finish = state.coordinator.beginAcceptedWork(7) + + await state.coordinator.requestWindowClose(7) + expect(state.coordinator.status()).toMatchObject({ + pendingCommands: 1, + state: 'draining', + }) + expect(state.requestRendererCleanup).not.toHaveBeenCalled() + + finish() + await vi.waitFor(() => + expect(state.coordinator.status().state).toBe('cleaning-renderers'), + ) + expect(state.requestRendererCleanup).toHaveBeenCalledOnce() + }) + + it('persists unfinished creation evidence before cancelling or terminating', async () => { + const state = setup(operationProjection()) + state.promptForce.mockResolvedValueOnce('force') + await state.coordinator.requestWindowClose(7) + + const forcing = state.coordinator.reviewShutdownOptions() + await vi.runAllTimersAsync() + await forcing + + expect(state.markCreationsUnfinished).toHaveBeenCalledOnce() + expect(state.markCreationsUnfinished.mock.invocationCallOrder[0]).toBeLessThan( + state.cancelOperation.mock.invocationCallOrder[0]!, + ) + expect(state.cancelOperation).toHaveBeenCalledWith('handle-1', 'operation-1') + expect(state.forceTerminate).toHaveBeenCalledOnce() + expect(state.forceTerminate).toHaveBeenCalledWith(['handle-1']) + expect(state.flushRuntimeState).toHaveBeenCalledOnce() + expect(state.waitForRuntimeIdle).toHaveBeenCalledWith(['handle-1']) + expect(state.approve).toHaveBeenCalledOnce() + }) + + it('does not send cancellation to an Operation with deferred interruption', async () => { + const current = operationProjection() + current.operations[0]!.interruptibility = 'deferred' + const state = setup(current) + state.promptForce.mockResolvedValueOnce('force') + await state.coordinator.requestWindowClose(7) + + const forcing = state.coordinator.reviewShutdownOptions() + await vi.runAllTimersAsync() + await forcing + + expect(state.cancelOperation).not.toHaveBeenCalled() + expect(state.forceTerminate).toHaveBeenCalledWith(['handle-1']) + }) + + it('waits within the Force deadline for main-process work already accepted', async () => { + const state = setup(operationProjection()) + const finishAcceptedWork = state.coordinator.beginAcceptedWork(7) + state.promptForce.mockResolvedValueOnce('force') + await state.coordinator.requestWindowClose(7) + + const forcing = state.coordinator.reviewShutdownOptions() + await vi.advanceTimersByTimeAsync(0) + const attemptId = state.coordinator.status().attemptId! + await state.coordinator.completeRendererCleanup(attemptId, 7, true) + expect(state.forceTerminate).not.toHaveBeenCalled() + expect(state.flushRuntimeState).not.toHaveBeenCalled() + + finishAcceptedWork() + await forcing + expect(state.forceTerminate).toHaveBeenCalledOnce() + expect(state.flushRuntimeState).toHaveBeenCalledOnce() + }) + + it('flushes accepted state without waiting for a stuck Runtime to become idle', async () => { + const state = setup(operationProjection()) + state.waitForRuntimeIdle.mockReturnValueOnce(new Promise(() => undefined)) + state.promptForce.mockResolvedValueOnce('force') + await state.coordinator.requestWindowClose(7) + + const forcing = state.coordinator.reviewShutdownOptions() + await vi.advanceTimersByTimeAsync(0) + const attemptId = state.coordinator.status().attemptId! + await state.coordinator.completeRendererCleanup(attemptId, 7, true) + await vi.advanceTimersByTimeAsync(0) + expect(state.flushRuntimeState).toHaveBeenCalledOnce() + expect(state.forceTerminate).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(3_000) + await forcing + expect(state.forceTerminate).toHaveBeenCalledWith(['handle-1']) + }) + + it('accepts matching Renderer cleanup during the bounded Force quit drain', async () => { + const state = setup(operationProjection()) + state.promptForce.mockResolvedValueOnce('force') + await state.coordinator.requestWindowClose(7) + + const forcing = state.coordinator.reviewShutdownOptions() + await vi.advanceTimersByTimeAsync(0) + const attemptId = state.coordinator.status().attemptId! + await state.coordinator.completeRendererCleanup(attemptId, 999, true) + expect(state.forceTerminate).not.toHaveBeenCalled() + await state.coordinator.completeRendererCleanup(attemptId, 7, true) + await vi.runAllTimersAsync() + await forcing + + expect(state.forceTerminate).toHaveBeenCalledOnce() + }) + + it('stops Force quit when required creation recovery evidence cannot be persisted', async () => { + const state = setup(operationProjection()) + state.promptForce.mockResolvedValueOnce('force') + state.markCreationsUnfinished.mockRejectedValueOnce(new Error('journal unavailable')) + await state.coordinator.requestWindowClose(7) + + await state.coordinator.reviewShutdownOptions() + + expect(state.coordinator.status()).toMatchObject({ + issue: 'journal unavailable', + state: 'error', + }) + expect(state.cancelOperation).not.toHaveBeenCalled() + expect(state.forceTerminate).not.toHaveBeenCalled() + expect(state.approve).not.toHaveBeenCalled() + }) + + it('cancels safe shutdown without changing the running Operation', async () => { + const state = setup(operationProjection()) + state.promptInitial.mockResolvedValueOnce('cancel') + + await state.coordinator.requestWindowClose(7) + + expect(state.coordinator.status().state).toBe('idle') + expect(state.cancelOperation).not.toHaveBeenCalled() + expect(state.approve).not.toHaveBeenCalled() + }) + + it('isolates a window close and escalates a concurrent app quit to one attempt', async () => { + const first = operationProjection().operations[0]! + const state = setup( + projection({ + operations: [ + first, + { + ...first, + operationId: 'operation-2', + workspaceDirectory: '/projects/demo/ws_2', + workspaceHandle: 'handle-2', + workspaceId: 'engineering-2', + }, + ], + }), + ) + state.coordinator.trackWorkspaceHandle(8, 'handle-2') + + await state.coordinator.requestWindowClose(7) + const attemptId = state.coordinator.status().attemptId + expect(state.coordinator.status()).toMatchObject({ + activeFlows: 1, + scope: 'window', + }) + + await state.coordinator.requestApplicationQuit() + expect(state.coordinator.status()).toMatchObject({ + activeFlows: 2, + attemptId, + scope: 'application', + }) + expect(state.promptInitial).toHaveBeenCalledOnce() + }) + + it('collapses close and quit requests racing the first blocker query', async () => { + const state = setup(operationProjection()) + + await Promise.all([ + state.coordinator.requestWindowClose(7), + state.coordinator.requestApplicationQuit(), + ]) + + expect(state.promptInitial).toHaveBeenCalledOnce() + expect(state.coordinator.status()).toMatchObject({ + scope: 'application', + state: 'draining', + }) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/main/shutdownCoordinator.ts b/ecos/gui/apps/desktop-electron/electron/main/shutdownCoordinator.ts new file mode 100644 index 000000000..877f1b53e --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/main/shutdownCoordinator.ts @@ -0,0 +1,460 @@ +import { randomUUID } from 'node:crypto' +import type { + DesktopShutdownStatus, + EccBackgroundOperation, + EccBackgroundOperationProjection, + EccBackgroundWorkspaceCreation, +} from '@ecos-studio/shared' +import { + boundedShutdownIssue, + buildShutdownBlockers, + emptyShutdownBlockers, + hasShutdownBlockers, + idleShutdownStatus, + workspaceHandlesInShutdownScope, + type ShutdownBlockerSummary, + type ShutdownScope, +} from './shutdownBlockers' +import { ShutdownAcceptedWork } from './shutdownAcceptedWork' + +interface ShutdownCoordinatorOptions { + approve(scope: ShutdownScope): void | Promise + cancelOperation(workspaceHandle: string, operationId: string): Promise + creationEntries?(): Promise + currentOperationProjection?(): EccBackgroundOperationProjection + forceTerminate(workspaceHandles?: readonly string[]): Promise + flushRuntimeState?(): Promise + listWindowIds(): number[] + markCreationsUnfinished(windowIds?: ReadonlySet): Promise + operationProjection(): + | EccBackgroundOperationProjection + | Promise + promptForce(blockers: ShutdownBlockerSummary): Promise<'keep-waiting' | 'force'> + promptInitial(blockers: ShutdownBlockerSummary): Promise<'wait' | 'cancel'> + requestRendererCleanup(attemptId: string, windowIds: number[]): void + waitForRuntimeIdle?(workspaceHandles?: readonly string[]): Promise + setTimeout?: typeof setTimeout + clearTimeout?: typeof clearTimeout +} + +interface Attempt { + id: string + forceEligible: boolean + initialPromptOpen: boolean + pendingCleanup: Set + scope: ShutdownScope + state: DesktopShutdownStatus['state'] + blockers: ShutdownBlockerSummary + issue?: string + forceCleanupResolve?: () => void +} + +export class ShutdownCoordinator { + private attempt: Attempt | null = null + private readonly acceptedWork = new ShutdownAcceptedWork() + private readonly handleOwners = new Map() + private readonly listeners = new Set<(status: DesktopShutdownStatus) => void>() + private forcePromptOpen = false + private applicationApproved = false + private readonly approvedWindows = new Set() + + constructor(private readonly options: ShutdownCoordinatorOptions) {} + + trackWorkspaceHandle(windowId: number, workspaceHandle: string): void { + this.handleOwners.set(workspaceHandle, windowId) + } + + untrackWorkspaceHandle(workspaceHandle: string): void { + this.handleOwners.delete(workspaceHandle) + } + + beginAcceptedWork(windowId: number): () => void { + return this.acceptedWork.begin(windowId, () => { + void this.notifyBlockersChanged() + }) + } + + windowClosed(windowId: number): void { + this.approvedWindows.delete(windowId) + if ( + this.attempt?.scope.kind === 'window' && + this.attempt.scope.windowId === windowId + ) { + this.attempt = null + this.emit() + } + } + + isApplicationApproved(): boolean { + return this.applicationApproved + } + + isWindowApproved(windowId: number): boolean { + return this.approvedWindows.has(windowId) + } + + isMutationBlocked(windowId: number): boolean { + if ( + !this.attempt || + !['draining', 'force-eligible', 'cleaning-renderers', 'forcing', 'error'].includes( + this.attempt.state, + ) + ) + return false + return ( + this.attempt.scope.kind === 'application' || + this.attempt.scope.windowId === windowId + ) + } + + onStatusChanged(listener: (status: DesktopShutdownStatus) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + status(): DesktopShutdownStatus { + const attempt = this.attempt + const blockers = attempt?.blockers ?? emptyShutdownBlockers() + return { + activeFlows: blockers.activeFlows, + attemptId: attempt?.id ?? null, + finalizations: blockers.finalizations, + forceEligible: attempt?.forceEligible ?? false, + ...(attempt?.issue ? { issue: attempt.issue } : {}), + pendingCommands: blockers.pendingCommands, + pendingCreations: blockers.pendingCreations, + scope: attempt?.scope.kind ?? null, + snapshotFailures: blockers.snapshotFailures, + state: attempt?.state ?? 'idle', + } + } + + statusForWindow(windowId: number): DesktopShutdownStatus { + if ( + !this.attempt || + this.attempt.scope.kind === 'application' || + this.attempt.scope.windowId === windowId + ) { + return this.status() + } + return idleShutdownStatus() + } + + scope(): ShutdownScope { + return this.attempt?.scope ?? { kind: 'application' } + } + + requestWindowClose(windowId: number): Promise { + return this.request({ kind: 'window', windowId }) + } + + requestApplicationQuit(): Promise { + return this.request({ kind: 'application' }) + } + + async notifyBlockersChanged(): Promise { + const attempt = this.attempt + if (!attempt || !['draining', 'force-eligible', 'error'].includes(attempt.state)) + return + const blockers = await this.inspect(attempt.scope) + if (this.attempt !== attempt) return + attempt.blockers = blockers + this.emit() + if (!hasShutdownBlockers(blockers)) { + await this.beginRendererCleanup() + } + } + + async completeRendererCleanup( + attemptId: string, + windowId: number, + ok: boolean, + issue?: string, + ): Promise { + const attempt = this.attempt + if (!attempt || attempt.id !== attemptId) return + if (attempt.state === 'forcing') { + if (!attempt.pendingCleanup.delete(windowId)) return + if (!attempt.pendingCleanup.size) attempt.forceCleanupResolve?.() + return + } + if (attempt.state !== 'cleaning-renderers') return + if (!attempt.pendingCleanup.has(windowId)) return + if (!ok) { + attempt.state = 'error' + attempt.issue = boundedShutdownIssue(issue || 'Renderer cleanup failed.') + this.emit() + return + } + attempt.pendingCleanup.delete(windowId) + if (attempt.pendingCleanup.size > 0) return + const blockers = await this.inspect(attempt.scope) + if (this.attempt !== attempt || attempt.state !== 'cleaning-renderers') return + attempt.blockers = blockers + if (hasShutdownBlockers(attempt.blockers)) { + this.enterDraining() + return + } + await this.approve() + } + + cancelShutdown(): void { + if (this.attempt?.state === 'forcing' || this.attempt?.state === 'approved') return + this.attempt = null + this.forcePromptOpen = false + this.emit() + } + + async reviewShutdownOptions(): Promise { + const attempt = this.attempt + if (!attempt) return + if (attempt.state === 'error') { + const blockers = await this.inspect(attempt.scope) + if (this.attempt !== attempt || attempt.state !== 'error') return + attempt.blockers = blockers + if (!hasShutdownBlockers(blockers)) { + await this.beginRendererCleanup() + } else { + attempt.state = attempt.forceEligible ? 'force-eligible' : 'draining' + attempt.issue = undefined + this.emit() + } + } + if (attempt.forceEligible) await this.showForcePrompt() + } + + private async request(scope: ShutdownScope): Promise { + let attempt = this.attempt + if (attempt) { + if (scope.kind === 'application' && attempt.scope.kind === 'window') { + attempt.scope = scope + attempt.blockers = await this.inspect(scope) + if (this.attempt !== attempt) return + if (attempt.state === 'cleaning-renderers') { + const windowIds = this.options.listWindowIds() + attempt.pendingCleanup = new Set(windowIds) + this.options.requestRendererCleanup(attempt.id, windowIds) + } + this.emit() + } + if (attempt.state === 'error') { + await this.reviewShutdownOptions() + return + } + if (attempt.forceEligible) { + await this.showForcePrompt() + return + } + if (attempt.state !== 'idle') return + } else { + attempt = { + blockers: emptyShutdownBlockers(), + forceEligible: false, + id: randomUUID(), + initialPromptOpen: false, + pendingCleanup: new Set(), + scope, + state: 'idle', + } + this.attempt = attempt + } + await this.resolveInitialDecision(attempt) + } + + private async resolveInitialDecision(attempt: Attempt): Promise { + const inspectedScope = attempt.scope + const blockers = await this.inspect(inspectedScope) + if (this.attempt !== attempt || attempt.state !== 'idle') return + if (attempt.scope !== inspectedScope) { + await this.resolveInitialDecision(attempt) + return + } + attempt.blockers = blockers + if (!hasShutdownBlockers(blockers)) { + await this.beginRendererCleanup() + return + } + if (attempt.initialPromptOpen) return + attempt.initialPromptOpen = true + try { + if ((await this.options.promptInitial(blockers)) === 'cancel') { + if (this.attempt === attempt) this.cancelShutdown() + return + } + } finally { + attempt.initialPromptOpen = false + } + if (this.attempt !== attempt || attempt.state !== 'idle') return + this.enterDraining() + } + + private enterDraining(): void { + if (!this.attempt) return + this.attempt.forceEligible = true + this.attempt.state = 'draining' + this.attempt.issue = undefined + this.emit() + } + + private async showForcePrompt(): Promise { + const attempt = this.attempt + if (!attempt?.forceEligible || this.forcePromptOpen) return + this.forcePromptOpen = true + try { + const result = await this.options.promptForce(attempt.blockers) + if ( + this.attempt !== attempt || + !attempt.forceEligible || + attempt.state === 'approved' + ) + return + if (result === 'force') await this.forceQuit() + } finally { + this.forcePromptOpen = false + } + } + + private async forceQuit(): Promise { + const attempt = this.attempt + if (!attempt) return + attempt.state = 'forcing' + attempt.blockers = await this.inspect(attempt.scope) + this.emit() + try { + await this.options.markCreationsUnfinished(this.windowScope(attempt.scope)) + } catch (error) { + attempt.state = 'error' + attempt.issue = boundedShutdownIssue( + error instanceof Error ? error.message : String(error), + ) + this.emit() + return + } + + const projection = + this.options.currentOperationProjection?.() ?? + (await this.options.operationProjection()) + const operations = this.operationsInScope( + projection.operations, + attempt.scope, + ).filter( + (operation) => + operation.interruptibility !== 'deferred' && + operation.interruptibility !== 'forbidden', + ) + const windowIds = + attempt.scope.kind === 'application' + ? this.options.listWindowIds() + : attempt.scope.windowId === undefined + ? [] + : [attempt.scope.windowId] + attempt.pendingCleanup = new Set(windowIds) + const rendererCleanup = new Promise((resolve) => { + attempt.forceCleanupResolve = resolve + if (!windowIds.length) resolve() + }) + this.options.requestRendererCleanup(attempt.id, windowIds) + const workspaceHandles = workspaceHandlesInShutdownScope( + this.handleOwners, + attempt.scope, + ) + const backendQuiescence = Promise.allSettled( + operations.map((operation) => + this.options.cancelOperation(operation.workspaceHandle, operation.operationId), + ), + ).then(() => + Promise.all([ + this.acceptedWork + .wait(attempt.scope) + .then(() => this.options.flushRuntimeState?.()), + this.options.waitForRuntimeIdle?.(workspaceHandles), + ]), + ) + const bestEffort = Promise.all([rendererCleanup, backendQuiescence]) + const schedule = this.options.setTimeout ?? setTimeout + let deadline: ReturnType | null = null + try { + await Promise.race([ + bestEffort, + new Promise((resolve) => { + deadline = schedule(resolve, 3_000) + }), + ]) + } finally { + if (deadline) (this.options.clearTimeout ?? clearTimeout)(deadline) + } + await this.options.forceTerminate(workspaceHandles) + await this.approve() + } + + private async beginRendererCleanup(): Promise { + const attempt = this.attempt + if (!attempt || attempt.state === 'cleaning-renderers') return + const windowIds = + attempt.scope.kind === 'application' + ? this.options.listWindowIds() + : attempt.scope.windowId === undefined + ? [] + : [attempt.scope.windowId] + attempt.forceEligible = true + attempt.state = 'cleaning-renderers' + attempt.pendingCleanup = new Set(windowIds) + this.emit() + if (!windowIds.length) { + await this.approve() + return + } + this.options.requestRendererCleanup(attempt.id, windowIds) + } + + private async approve(): Promise { + const attempt = this.attempt + if (!attempt || attempt.state === 'approved') return + attempt.forceEligible = false + attempt.state = 'approved' + if (attempt.scope.kind === 'application') this.applicationApproved = true + else if (attempt.scope.windowId !== undefined) + this.approvedWindows.add(attempt.scope.windowId) + this.emit() + await this.options.approve(attempt.scope) + } + + private async inspect(scope: ShutdownScope): Promise { + const projection = await this.options.operationProjection() + const creations = (await this.options.creationEntries?.()) ?? projection.creations + return buildShutdownBlockers({ + acceptedWorkByWindow: this.acceptedWork.counts, + creations, + handleOwners: this.handleOwners, + projection, + scope, + }) + } + + private operationsInScope( + operations: EccBackgroundOperation[], + scope: ShutdownScope, + ): EccBackgroundOperation[] { + return operations.filter((operation) => + this.inScope(operation.workspaceHandle, scope), + ) + } + + private inScope(workspaceHandle: string, scope: ShutdownScope): boolean { + return ( + scope.kind === 'application' || + this.handleOwners.get(workspaceHandle) === scope.windowId + ) + } + + private windowScope(scope: ShutdownScope): ReadonlySet | undefined { + return scope.kind === 'application' || scope.windowId === undefined + ? undefined + : new Set([scope.windowId]) + } + + private emit(): void { + const status = this.status() + for (const listener of this.listeners) listener(status) + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/preload/index.test.ts b/ecos/gui/apps/desktop-electron/electron/preload/index.test.ts index 4d7a9f4bd..1d9b5415a 100644 --- a/ecos/gui/apps/desktop-electron/electron/preload/index.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/preload/index.test.ts @@ -33,20 +33,42 @@ async function loadDesktopBridge() { app: { getVersions(): Promise } + shutdown: { + cancel(): Promise + completeCleanup(request: unknown): Promise + getStatus(): Promise + onCleanupRequested(listener: (event: unknown) => void): () => void + onStatusChanged(listener: (event: unknown) => void): () => void + reviewOptions(): Promise + } + backendWorkspace: { + getArtifact(request: unknown): Promise + getOverview(): Promise + getStepDetail(request: unknown): Promise + refreshOverview(): Promise + onInvalidated(listener: (event: unknown) => void): () => void + } + backendProjectComparison: { + closeProject(request: unknown): Promise + selectProject(request: unknown): Promise + getComparison(request: unknown): Promise + getExecutionSnapshot(request: unknown): Promise + getStepFindings(request: unknown): Promise + refreshComparison(request: unknown): Promise + onInvalidated(listener: (event: unknown) => void): () => void + onExecutionInvalidated(listener: (event: unknown) => void): () => void + } ecc: { events: { onEvent(listener: (event: unknown) => void): () => void } - flow: { - runStep(request: unknown): Promise - } runtime: { + engineeringSnapshot(request: unknown): Promise + operationProjection(): Promise + operationLog(request: unknown): Promise + onOperationProjectionInvalidated(listener: (event: unknown) => void): () => void waitForOperation(request: unknown): Promise } - workspace: { - exportSignoff(request: unknown): Promise - inspectSignoff(request: unknown): Promise - } } agent: { interrupt(request: unknown): Promise @@ -99,13 +121,13 @@ describe('preload desktop bridge contract', () => { app: expect.objectContaining({ getVersions: expect.any(Function), }), - ecc: expect.objectContaining({ - events: expect.objectContaining({ - onEvent: expect.any(Function), - }), - flow: expect.objectContaining({ - runStep: expect.any(Function), - }), + backendProjectComparison: expect.objectContaining({ + closeProject: expect.any(Function), + selectProject: expect.any(Function), + getComparison: expect.any(Function), + getExecutionSnapshot: expect.any(Function), + getStepFindings: expect.any(Function), + refreshComparison: expect.any(Function), }), workspace: expect.objectContaining({ readProjectTextFile: expect.any(Function), @@ -114,6 +136,66 @@ describe('preload desktop bridge contract', () => { ) }) + it('routes Project Comparison close through its typed IPC channel', async () => { + const bridge = await loadDesktopBridge() + const request = { projectComparisonContextId: 'context-1' } + ipcRenderer.invoke.mockResolvedValueOnce(undefined) + + await expect( + bridge.backendProjectComparison.closeProject(request), + ).resolves.toBeUndefined() + expect(ipcRenderer.invoke).toHaveBeenCalledWith( + desktopApiIpcChannels.backendProjectComparisonCloseProject, + request, + ) + }) + + it('routes Project execution queries and invalidation through typed channels', async () => { + const bridge = await loadDesktopBridge() + const request = { projectComparisonContextId: 'context-1' } + const result = { data: { operations: [] }, generation: 0, ok: true } + ipcRenderer.invoke.mockResolvedValueOnce(result) + + await expect( + bridge.backendProjectComparison.getExecutionSnapshot(request), + ).resolves.toEqual(result) + const listener = vi.fn() + const unsubscribe = bridge.backendProjectComparison.onExecutionInvalidated(listener) + const eventListener = ipcRenderer.on.mock.calls.at(-1)?.[1] + eventListener?.({}, { generation: 1, projectComparisonContextId: 'context-1' }) + unsubscribe() + + expect(ipcRenderer.invoke).toHaveBeenCalledWith( + desktopApiIpcChannels.backendProjectComparisonGetExecutionSnapshot, + request, + ) + expect(ipcRenderer.on).toHaveBeenCalledWith( + desktopApiEventChannels.backendProjectExecutionInvalidated, + eventListener, + ) + expect(listener).toHaveBeenCalledWith({ + generation: 1, + projectComparisonContextId: 'context-1', + }) + }) + + it('routes the path-free Step Findings query through its typed channel', async () => { + const bridge = await loadDesktopBridge() + const request = { + projectComparisonContextId: 'context-1', + projectWorkspaceId: 'ws_1', + step: 'Route', + } + ipcRenderer.invoke.mockResolvedValueOnce({ ok: false, code: 'FINDINGS_READ_FAILED' }) + + await bridge.backendProjectComparison.getStepFindings(request) + + expect(ipcRenderer.invoke).toHaveBeenCalledWith( + desktopApiIpcChannels.backendProjectComparisonGetStepFindings, + request, + ) + }) + it('routes bridge calls through shared IPC channel constants', async () => { const bridge = await loadDesktopBridge() ipcRenderer.invoke.mockResolvedValueOnce({ gui: '0.1.0-test' }) @@ -190,25 +272,72 @@ describe('preload desktop bridge contract', () => { ) }) - it('routes ECC flow calls through the shared IPC channel constant', async () => { + it('routes Backend Workspace queries and invalidation through typed channels', async () => { const bridge = await loadDesktopBridge() - ipcRenderer.invoke.mockResolvedValueOnce({ - state: 'Success', - step: 'place', - }) - const request = { - rerun: false, - step: 'place', - workspaceHandle: 'workspace-handle-1', + const overview = { + generation: 0, + overview: { identity: { workspaceName: 'Workspace A' } }, + workspaceContextId: 'workspace-context-1', + } + ipcRenderer.invoke.mockResolvedValue(overview) + const detailRequest = { + stepId: 'Place', + workspaceContextId: 'workspace-context-1', + workspaceRevision: 9, } - await expect(bridge.ecc.flow.runStep(request)).resolves.toMatchObject({ - state: 'Success', - step: 'place', + await expect(bridge.backendWorkspace.getOverview()).resolves.toEqual(overview) + await expect(bridge.backendWorkspace.getStepDetail(detailRequest)).resolves.toEqual( + overview, + ) + await expect( + bridge.backendWorkspace.getArtifact({ + artifactId: 'layout-place', + workspaceContextId: 'workspace-context-1', + workspaceRevision: 9, + }), + ).resolves.toEqual(overview) + await expect(bridge.backendWorkspace.refreshOverview()).resolves.toEqual(overview) + + const listener = vi.fn() + const unsubscribe = bridge.backendWorkspace.onInvalidated(listener) + const eventListener = ipcRenderer.on.mock.calls.at(-1)?.[1] + eventListener?.({}, { generation: 1, workspaceContextId: 'workspace-context-1' }) + unsubscribe() + + expect(ipcRenderer.invoke).toHaveBeenNthCalledWith( + 1, + desktopApiIpcChannels.backendWorkspaceGetOverview, + ) + expect(ipcRenderer.invoke).toHaveBeenNthCalledWith( + 2, + desktopApiIpcChannels.backendWorkspaceGetStepDetail, + detailRequest, + ) + expect(ipcRenderer.invoke).toHaveBeenNthCalledWith( + 3, + desktopApiIpcChannels.backendWorkspaceGetArtifact, + { + artifactId: 'layout-place', + workspaceContextId: 'workspace-context-1', + workspaceRevision: 9, + }, + ) + expect(ipcRenderer.invoke).toHaveBeenNthCalledWith( + 4, + desktopApiIpcChannels.backendWorkspaceRefreshOverview, + ) + expect(ipcRenderer.on).toHaveBeenCalledWith( + desktopApiEventChannels.backendWorkspaceInvalidated, + eventListener, + ) + expect(listener).toHaveBeenCalledWith({ + generation: 1, + workspaceContextId: 'workspace-context-1', }) - expect(ipcRenderer.invoke).toHaveBeenCalledWith( - desktopApiIpcChannels.eccFlowRunStep, - request, + expect(ipcRenderer.removeListener).toHaveBeenCalledWith( + desktopApiEventChannels.backendWorkspaceInvalidated, + eventListener, ) }) @@ -244,6 +373,69 @@ describe('preload desktop bridge contract', () => { ) }) + it('exposes the authoritative background Operation projection', async () => { + const bridge = await loadDesktopBridge() + const listener = vi.fn() + ipcRenderer.invoke.mockResolvedValueOnce({ generation: 3, operations: [] }) + + await expect(bridge.ecc.runtime.operationProjection()).resolves.toEqual({ + generation: 3, + operations: [], + }) + expect(ipcRenderer.invoke).toHaveBeenCalledWith( + desktopApiIpcChannels.eccRuntimeOperationProjection, + ) + + const logRequest = { operationId: 'operation-1', workspaceHandle: 'handle-1' } + ipcRenderer.invoke.mockResolvedValueOnce({ content: 'log', truncated: false }) + await expect(bridge.ecc.runtime.operationLog(logRequest)).resolves.toEqual({ + content: 'log', + truncated: false, + }) + expect(ipcRenderer.invoke).toHaveBeenCalledWith( + desktopApiIpcChannels.eccRuntimeOperationLog, + logRequest, + ) + + const unsubscribe = bridge.ecc.runtime.onOperationProjectionInvalidated(listener) + const eventListener = ipcRenderer.on.mock.calls.find( + ([channel]) => + channel === desktopApiEventChannels.eccRuntimeOperationProjectionInvalidated, + )?.[1] + eventListener?.({}, { generation: 4 }) + expect(listener).toHaveBeenCalledWith({ generation: 4 }) + unsubscribe() + expect(ipcRenderer.removeListener).toHaveBeenCalledWith( + desktopApiEventChannels.eccRuntimeOperationProjectionInvalidated, + eventListener, + ) + }) + + it('routes shutdown status and Renderer cleanup through typed channels', async () => { + const bridge = await loadDesktopBridge() + const cleanupListener = vi.fn() + ipcRenderer.invoke.mockResolvedValueOnce({ state: 'draining' }) + + await expect(bridge.shutdown.getStatus()).resolves.toEqual({ state: 'draining' }) + expect(ipcRenderer.invoke).toHaveBeenCalledWith( + desktopApiIpcChannels.shutdownGetStatus, + ) + + bridge.shutdown.onCleanupRequested(cleanupListener) + const eventListener = ipcRenderer.on.mock.calls.find( + ([channel]) => channel === desktopApiEventChannels.shutdownCleanupRequested, + )?.[1] + eventListener?.({}, { attemptId: 'attempt-1' }) + expect(cleanupListener).toHaveBeenCalledWith({ attemptId: 'attempt-1' }) + + ipcRenderer.invoke.mockResolvedValueOnce(undefined) + await bridge.shutdown.completeCleanup({ attemptId: 'attempt-1', ok: true }) + expect(ipcRenderer.invoke).toHaveBeenCalledWith( + desktopApiIpcChannels.shutdownCompleteCleanup, + { attemptId: 'attempt-1', ok: true }, + ) + }) + it('routes agent requests and events through shared IPC channels', async () => { const bridge = await loadDesktopBridge() const session = { @@ -357,34 +549,15 @@ describe('preload desktop bridge contract', () => { }) }) - it('routes ECC signoff export through the shared IPC channel constant', async () => { - const bridge = await loadDesktopBridge() - const request = { - outputPath: '/exports/custom package.tar.gz', - workspaceHandle: 'workspace-handle-1', - } - ipcRenderer.invoke.mockResolvedValueOnce({ - outputPath: request.outputPath, - }) - - await expect(bridge.ecc.workspace.exportSignoff(request)).resolves.toEqual({ - outputPath: request.outputPath, - }) - expect(ipcRenderer.invoke).toHaveBeenCalledWith( - desktopApiIpcChannels.eccWorkspaceExportSignoff, - request, - ) - }) - - it('routes ECC signoff inspection through the shared IPC channel constant', async () => { + it('routes Engineering Snapshot reads through the shared IPC channel constant', async () => { const bridge = await loadDesktopBridge() const request = { workspaceHandle: 'workspace-handle-1' } - const result = { groups: [], risks: [], status: 'ready' } + const result = { workspaceId: 'workspace-1', workspaceRevision: 7 } ipcRenderer.invoke.mockResolvedValueOnce(result) - await expect(bridge.ecc.workspace.inspectSignoff(request)).resolves.toEqual(result) + await expect(bridge.ecc.runtime.engineeringSnapshot(request)).resolves.toEqual(result) expect(ipcRenderer.invoke).toHaveBeenCalledWith( - desktopApiIpcChannels.eccWorkspaceInspectSignoff, + desktopApiIpcChannels.eccRuntimeEngineeringSnapshot, request, ) }) @@ -417,26 +590,6 @@ describe('preload desktop bridge contract', () => { ) }) - it('subscribes and unsubscribes with shared event channel constants', async () => { - const bridge = await loadDesktopBridge() - const listener = vi.fn() - - const unsubscribe = bridge.ecc.events.onEvent(listener) - const eventListener = ipcRenderer.on.mock.calls[0]?.[1] - eventListener?.({}, { type: 'runtime.ready' }) - unsubscribe() - - expect(ipcRenderer.on).toHaveBeenCalledWith( - desktopApiEventChannels.eccEvent, - expect.any(Function), - ) - expect(listener).toHaveBeenCalledWith({ type: 'runtime.ready' }) - expect(ipcRenderer.removeListener).toHaveBeenCalledWith( - desktopApiEventChannels.eccEvent, - eventListener, - ) - }) - it('only exposes the smoke-test bridge when smoke mode is enabled', async () => { await loadDesktopBridge() diff --git a/ecos/gui/apps/desktop-electron/electron/preload/index.ts b/ecos/gui/apps/desktop-electron/electron/preload/index.ts index 00497df2e..22be07b84 100644 --- a/ecos/gui/apps/desktop-electron/electron/preload/index.ts +++ b/ecos/gui/apps/desktop-electron/electron/preload/index.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' import { desktopApiEventChannels, desktopApiIpcChannels, @@ -7,13 +7,10 @@ import type { DesktopApi, DesignRuntimeEvent, DesktopDirectoryDialogOptions, - EccRuntimeEvent, DesktopFileDialogOptions, DesktopRtlSourceDialogOptions, ChipViewerOpenRequest, DesktopMenuEventId, - DesktopProjectFileChangedEvent, - DesktopProjectLogTailEvent, ProjectManifestMutationRequest, ResourceJob, ResourceImportLocalRequest, @@ -80,20 +77,23 @@ const desktopApi: DesktopApi = { app: { getVersions: () => invokeDesktop(desktopApiIpcChannels.appGetVersions), }, + productCommands: { + execute: (request) => + invokeDesktop(desktopApiIpcChannels.productCommandExecute, request), + }, + workspaceCreationModel: { + get: (request) => + invokeDesktop(desktopApiIpcChannels.workspaceCreationModelGet, request), + }, window: { minimize: () => invokeDesktop(desktopApiIpcChannels.windowMinimize), toggleMaximize: () => invokeDesktop(desktopApiIpcChannels.windowToggleMaximize), close: () => invokeDesktop(desktopApiIpcChannels.windowClose), - confirmClose: () => invokeDesktop(desktopApiIpcChannels.windowConfirmClose), setTitle: (title) => invokeDesktop(desktopApiIpcChannels.windowSetTitle, title), isMaximized: () => invokeDesktop(desktopApiIpcChannels.windowIsMaximized), setZoomFactor: (factor) => invokeDesktop(desktopApiIpcChannels.windowSetZoomFactor, factor), create: (options) => invokeDesktop(desktopApiIpcChannels.windowCreate, options), - onCloseRequested: (listener) => - subscribeToDesktopEvent(desktopApiEventChannels.windowCloseRequested, () => { - listener() - }), onResized: (listener) => subscribeToDesktopEvent(desktopApiEventChannels.windowResized, () => { listener() @@ -106,6 +106,24 @@ const desktopApi: DesktopApi = { }, ), }, + shutdown: { + cancel: () => invokeDesktop(desktopApiIpcChannels.shutdownCancel), + completeCleanup: (request) => + invokeDesktop(desktopApiIpcChannels.shutdownCompleteCleanup, request), + getStatus: () => invokeDesktop(desktopApiIpcChannels.shutdownGetStatus), + reviewOptions: () => invokeDesktop(desktopApiIpcChannels.shutdownReviewOptions), + onCleanupRequested: (listener) => + subscribeToDesktopEvent( + desktopApiEventChannels.shutdownCleanupRequested, + (_event, payload: unknown) => listener(payload as { attemptId: string }), + ), + onStatusChanged: (listener) => + subscribeToDesktopEvent( + desktopApiEventChannels.shutdownStatusChanged, + (_event, payload: unknown) => + listener(payload as import('@ecos-studio/shared').DesktopShutdownStatus), + ), + }, menu: { onAction: (listener) => subscribeToDesktopEvent( @@ -130,13 +148,71 @@ const desktopApi: DesktopApi = { mutate: (request: ProjectManifestMutationRequest) => invokeDesktop(desktopApiIpcChannels.projectManifestMutate, request), }, + backendWorkspace: { + getArtifact: (request) => + invokeDesktop(desktopApiIpcChannels.backendWorkspaceGetArtifact, request), + getOverview: () => invokeDesktop(desktopApiIpcChannels.backendWorkspaceGetOverview), + getStepDetail: (request) => + invokeDesktop(desktopApiIpcChannels.backendWorkspaceGetStepDetail, request), + refreshOverview: () => + invokeDesktop(desktopApiIpcChannels.backendWorkspaceRefreshOverview), + onInvalidated: (listener) => + subscribeToDesktopEvent( + desktopApiEventChannels.backendWorkspaceInvalidated, + (_event, payload: unknown) => { + listener(payload as Parameters[0]) + }, + ), + }, + backendProjectComparison: { + closeProject: (request) => + invokeDesktop(desktopApiIpcChannels.backendProjectComparisonCloseProject, request), + selectProject: (request) => + invokeDesktop(desktopApiIpcChannels.backendProjectComparisonSelectProject, request), + getComparison: (request) => + invokeDesktop(desktopApiIpcChannels.backendProjectComparisonGetComparison, request), + getExecutionSnapshot: (request) => + invokeDesktop( + desktopApiIpcChannels.backendProjectComparisonGetExecutionSnapshot, + request, + ), + getStepFindings: (request) => + invokeDesktop( + desktopApiIpcChannels.backendProjectComparisonGetStepFindings, + request, + ), + refreshComparison: (request) => + invokeDesktop( + desktopApiIpcChannels.backendProjectComparisonRefreshComparison, + request, + ), + onInvalidated: (listener) => + subscribeToDesktopEvent( + desktopApiEventChannels.backendProjectComparisonInvalidated, + (_event, payload: unknown) => { + listener(payload as Parameters[0]) + }, + ), + onExecutionInvalidated: (listener) => + subscribeToDesktopEvent( + desktopApiEventChannels.backendProjectExecutionInvalidated, + (_event, payload: unknown) => { + listener(payload as Parameters[0]) + }, + ), + }, projectManagement: { + discoverProject: (directory) => + invokeDesktop(desktopApiIpcChannels.projectManagementDiscoverProject, directory), readManifest: (projectRoot) => invokeDesktop(desktopApiIpcChannels.projectManagementReadManifest, projectRoot), listProjectEntries: (projectRoot) => invokeDesktop(desktopApiIpcChannels.projectManagementListEntries, projectRoot), - readWorkspaceTexts: (request) => - invokeDesktop(desktopApiIpcChannels.projectManagementReadWorkspaceTexts, request), + readWorkspaceStepConfiguration: (request) => + invokeDesktop( + desktopApiIpcChannels.projectManagementReadWorkspaceStepConfiguration, + request, + ), }, dialog: { pickDirectory: (options?: DesktopDirectoryDialogOptions) => @@ -176,25 +252,6 @@ const desktopApi: DesktopApi = { invokeDesktop(desktopApiIpcChannels.workspaceReadProjectTextFile, path), readOptionalProjectTextFile: (path) => invokeDesktop(desktopApiIpcChannels.workspaceReadOptionalProjectTextFile, path), - readWorkspaceParameters: (workspacePath) => - invokeDesktop( - desktopApiIpcChannels.workspaceReadWorkspaceParameters, - workspacePath, - ), - hasWorkspaceConfigShadow: (workspacePath: string) => - invokeDesktop(desktopApiIpcChannels.workspaceHasConfigShadow, workspacePath), - editWorkspaceParameters: (workspacePath, edits) => - invokeDesktop( - desktopApiIpcChannels.workspaceEditWorkspaceParameters, - workspacePath, - edits, - ), - applyWorkspaceParameterWrites: (workspacePath, writes) => - invokeDesktop( - desktopApiIpcChannels.workspaceApplyWorkspaceParameterWrites, - workspacePath, - writes, - ), readProjectTextFileTail: (path, maxChars) => invokeDesktop( desktopApiIpcChannels.workspaceReadProjectTextFileTail, @@ -207,13 +264,6 @@ const desktopApi: DesktopApi = { path, maxChars, ), - readOptionalProjectTextFileUpdate: (path, fromOffsetBytes, maxChars) => - invokeDesktop( - desktopApiIpcChannels.workspaceReadOptionalProjectTextFileUpdate, - path, - fromOffsetBytes, - maxChars, - ), readOptionalProjectTextFileChunk: (path, fromOffsetBytes, maxBytes) => invokeDesktop( desktopApiIpcChannels.workspaceReadOptionalProjectTextFileChunk, @@ -221,32 +271,6 @@ const desktopApi: DesktopApi = { fromOffsetBytes, maxBytes, ), - subscribeProjectLogTail: async (path, options, listener) => { - const subscriptionId = (await ipcRenderer.invoke( - desktopApiIpcChannels.workspaceSubscribeProjectLogTail, - path, - options, - )) as string - const eventListener = ( - _event: IpcRendererEvent, - payload: DesktopProjectLogTailEvent, - ) => { - if (payload.subscriptionId !== subscriptionId) return - listener(payload) - } - ipcRenderer.on(desktopApiEventChannels.workspaceLogTail, eventListener) - - return () => { - ipcRenderer.removeListener( - desktopApiEventChannels.workspaceLogTail, - eventListener, - ) - void invokeDesktop( - desktopApiIpcChannels.workspaceUnsubscribeProjectLogTail, - subscriptionId, - ) - } - }, readProjectBinaryFile: (path) => invokeDesktop(desktopApiIpcChannels.workspaceReadProjectBinaryFile, path), writeProjectTextFile: (path, content) => @@ -280,36 +304,13 @@ const desktopApi: DesktopApi = { invokeDesktop(desktopApiIpcChannels.workspaceScanPdkDirectory, path), scanRtlDirectory: (path) => invokeDesktop(desktopApiIpcChannels.workspaceScanRtlDirectory, path), + discoverHdlModules: (request) => + invokeDesktop(desktopApiIpcChannels.workspaceDiscoverHdlModules, request), listDesignFiles: () => invokeDesktop(desktopApiIpcChannels.workspaceListDesignFiles), addDesignFiles: (sourcePaths) => invokeDesktop(desktopApiIpcChannels.workspaceAddDesignFiles, sourcePaths), removeDesignFile: (filelistEntry) => invokeDesktop(desktopApiIpcChannels.workspaceRemoveDesignFile, filelistEntry), - watchProjectFile: async (path, listener) => { - const subscriptionId = (await ipcRenderer.invoke( - desktopApiIpcChannels.workspaceWatchProjectFile, - path, - )) as string - const eventListener = ( - _event: IpcRendererEvent, - payload: DesktopProjectFileChangedEvent, - ) => { - if (payload.subscriptionId !== subscriptionId) return - listener(payload) - } - ipcRenderer.on(desktopApiEventChannels.workspaceFileChanged, eventListener) - - return () => { - ipcRenderer.removeListener( - desktopApiEventChannels.workspaceFileChanged, - eventListener, - ) - void invokeDesktop( - desktopApiIpcChannels.workspaceUnwatchProjectFile, - subscriptionId, - ) - } - }, }, chipViewer: { open: (request: ChipViewerOpenRequest) => @@ -323,10 +324,6 @@ const desktopApi: DesktopApi = { readFlow: () => invokeDesktop(desktopApiIpcChannels.workspaceResourcesReadFlow), readParameters: () => invokeDesktop(desktopApiIpcChannels.workspaceResourcesReadParameters), - writeParameters: (request: { - parameters: Record - workspace: string - }) => invokeDesktop(desktopApiIpcChannels.workspaceResourcesWriteParameters, request), resolveStepInfo: (request: WorkspaceStepInfoRequest) => invokeDesktop(desktopApiIpcChannels.workspaceResourcesResolveStepInfo, request), }, @@ -411,69 +408,39 @@ const desktopApi: DesktopApi = { invokeDesktop(desktopApiIpcChannels.designRuntimeWorkspaceHome, request), info: (request) => invokeDesktop(desktopApiIpcChannels.designRuntimeWorkspaceInfo, request), + stepConfiguration: (request) => + invokeDesktop( + desktopApiIpcChannels.designRuntimeWorkspaceStepConfiguration, + request, + ), open: (request) => invokeDesktop(desktopApiIpcChannels.designRuntimeWorkspaceOpen, request), refreshConfig: (request) => invokeDesktop(desktopApiIpcChannels.designRuntimeWorkspaceRefreshConfig, request), resetFlow: (request) => invokeDesktop(desktopApiIpcChannels.designRuntimeWorkspaceResetFlow, request), - syncConfig: (request) => - invokeDesktop(desktopApiIpcChannels.designRuntimeWorkspaceSyncConfig, request), }, }, ecc: { - events: { - onEvent: (listener) => + runtime: { + engineeringSnapshot: (request) => + invokeDesktop(desktopApiIpcChannels.eccRuntimeEngineeringSnapshot, request), + operationProjection: () => + invokeDesktop(desktopApiIpcChannels.eccRuntimeOperationProjection), + operationLog: (request) => + invokeDesktop(desktopApiIpcChannels.eccRuntimeOperationLog, request), + onOperationProjectionInvalidated: (listener) => subscribeToDesktopEvent( - desktopApiEventChannels.eccEvent, + desktopApiEventChannels.eccRuntimeOperationProjectionInvalidated, (_event, payload: unknown) => { - listener(payload as EccRuntimeEvent) + listener(payload as { generation: number }) }, ), - }, - flow: { - run: (request) => invokeDesktop(desktopApiIpcChannels.eccFlowRun, request), - runStep: (request) => invokeDesktop(desktopApiIpcChannels.eccFlowRunStep, request), - }, - rpc: { - hello: () => invokeDesktop(desktopApiIpcChannels.eccRpcHello), - ping: () => invokeDesktop(desktopApiIpcChannels.eccRpcPing), - shutdown: () => invokeDesktop(desktopApiIpcChannels.eccRpcShutdown), - }, - runtime: { - acknowledgeStepRendered: (request) => - invokeDesktop(desktopApiIpcChannels.eccRuntimeAcknowledgeStepRendered, request), - cancel: (request) => - invokeDesktop(desktopApiIpcChannels.eccRuntimeOperationCancel, request), snapshot: (request) => invokeDesktop(desktopApiIpcChannels.eccRuntimeSnapshot, request), - startFlow: (request) => - invokeDesktop(desktopApiIpcChannels.eccRuntimeStartFlow, request), - startStep: (request) => - invokeDesktop(desktopApiIpcChannels.eccRuntimeStartStep, request), - status: (request) => - invokeDesktop(desktopApiIpcChannels.eccRuntimeOperationStatus, request), waitForOperation: (request) => invokeDesktop(desktopApiIpcChannels.eccRuntimeWaitForOperation, request), }, - workspace: { - close: (request) => invokeDesktop(desktopApiIpcChannels.eccWorkspaceClose, request), - create: (request) => - invokeDesktop(desktopApiIpcChannels.eccWorkspaceCreate, request), - exportSignoff: (request) => - invokeDesktop(desktopApiIpcChannels.eccWorkspaceExportSignoff, request), - inspectSignoff: (request) => - invokeDesktop(desktopApiIpcChannels.eccWorkspaceInspectSignoff, request), - home: (request) => invokeDesktop(desktopApiIpcChannels.eccWorkspaceHome, request), - info: (request) => invokeDesktop(desktopApiIpcChannels.eccWorkspaceInfo, request), - open: (request) => invokeDesktop(desktopApiIpcChannels.eccWorkspaceOpen, request), - refreshConfig: (request) => - invokeDesktop(desktopApiIpcChannels.eccWorkspaceRefreshConfig, request), - resetFlow: (request) => - invokeDesktop(desktopApiIpcChannels.eccWorkspaceResetFlow, request), - syncConfig: (request) => - invokeDesktop(desktopApiIpcChannels.eccWorkspaceSyncConfig, request), - }, }, agent: { interrupt: (request) => invokeDesktop(desktopApiIpcChannels.agentInterrupt, request), diff --git a/ecos/gui/apps/desktop-electron/electron/services/agent/agentProviderProcessRuntime.test.ts b/ecos/gui/apps/desktop-electron/electron/services/agent/agentProviderProcessRuntime.test.ts index c9ae8bcf0..08bdd4d3f 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/agent/agentProviderProcessRuntime.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/agent/agentProviderProcessRuntime.test.ts @@ -295,7 +295,7 @@ describe('AgentProviderProcessRuntime', () => { expect(listener).not.toHaveBeenCalled() }) - it('forwards execution contracts with every resolved parameter field', () => { + it('rebuilds rerun confirmation fields from the frozen execution payload', () => { const harness = createSpawnHarness() const runtime = new AgentProviderProcessRuntime({ manifest: { @@ -316,14 +316,27 @@ describe('AgentProviderProcessRuntime', () => { `${JSON.stringify({ event: { contract: { - fields: Array.from({ length: 25 }, (_, index) => ({ - label: `parameter_${index}`, - value: String(index), - })), + fields: [{ label: 'untrusted', value: 'untrusted' }], presentation: 'workspace_rerun', schema_version: 'flow-agent.resolved_execution_contract.v1', title: 'Workspace rerun plan', + workspace_rerun: { + design_id: 'gcd', + end_step: 'place', + execution_scope: 'single_step', + parameter_patch: [{ knob_id: 'place.target_density', value: 0.55 }], + requires_gui_review: true, + rerun_id: 'gcd_rerun_place', + schema_version: 'flow-agent.workspace_rerun_contract.v1', + source_stage_artifact: 'place_dreamplace/output/gcd_place.def.gz', + source_flow_json_sha256: `sha256:${'a'.repeat(64)}`, + source_stage_artifact_sha256: `sha256:${'b'.repeat(64)}`, + source_workspace: '/runs/gcd', + target_step: 'place', + target_workspace: '/runs/gcd_rerun_place', + }, }, + sessionId: 'session-1', type: 'contract', }, type: 'event', @@ -333,7 +346,10 @@ describe('AgentProviderProcessRuntime', () => { expect(listener).toHaveBeenCalledWith( expect.objectContaining({ contract: expect.objectContaining({ - fields: expect.arrayContaining([{ label: 'parameter_24', value: '24' }]), + confirmation_token: expect.any(String), + fields: expect.arrayContaining([ + { label: 'place.target_density', value: '0.55' }, + ]), presentation: 'workspace_rerun', }), type: 'contract', @@ -427,7 +443,7 @@ describe('AgentProviderProcessRuntime', () => { ) }) - it('forwards validated workspace rerun contracts from provider stdout', () => { + it('forwards a validated workspace rerun only after the user confirms it', () => { const harness = createSpawnHarness() const runtime = new AgentProviderProcessRuntime({ manifest: { @@ -442,36 +458,54 @@ describe('AgentProviderProcessRuntime', () => { const listener = vi.fn() runtime.onEvent(listener) - void runtime.getStatus({ providerId: 'local' }) + void runtime.startSession({ providerId: 'local', sessionId: 'session-1' }) + const workspaceRerun = { + design_id: 'gcd', + end_step: 'place', + execution_scope: 'single_step', + parameter_patch: [{ knob_id: 'place.target_density', value: 0.55 }], + requires_gui_review: true, + rerun_id: 'gcd_rerun_place', + schema_version: 'flow-agent.workspace_rerun_contract.v1', + source_stage_artifact: 'place_dreamplace/output/gcd_place.def.gz', + source_flow_json_sha256: `sha256:${'a'.repeat(64)}`, + source_stage_artifact_sha256: `sha256:${'b'.repeat(64)}`, + source_workspace: '/runs/gcd', + target_step: 'place', + target_workspace: '/runs/gcd_rerun_place', + } harness.children[0].stdout.emit( 'data', `${JSON.stringify({ event: { - type: 'workspace_rerun', - workspaceRerun: { - design_id: 'gcd', - end_step: 'place', - execution_scope: 'single_step', - parameter_patch: [{ knob_id: 'place.target_density', value: 0.55 }], - writes: [ - { - file: 'home/parameters.json', - json_path: ['Target density'], - knob_id: 'place.target_density', - surface: 'parameters', - value: 0.55, - }, - ], - requires_gui_review: true, - rerun_id: 'gcd_rerun_place', - schema_version: 'flow-agent.workspace_rerun_contract.v1', - source_stage_artifact: 'place_dreamplace/output/gcd_place.def.gz', - source_flow_json_sha256: `sha256:${'a'.repeat(64)}`, - source_stage_artifact_sha256: `sha256:${'b'.repeat(64)}`, - source_workspace: '/runs/gcd', - target_step: 'place', - target_workspace: '/runs/gcd_rerun_place', + contract: { + fields: [{ label: 'ignored', value: 'ignored' }], + presentation: 'workspace_rerun', + schema_version: 'flow-agent.resolved_execution_contract.v1', + title: 'Workspace rerun plan', + workspace_rerun: workspaceRerun, }, + sessionId: 'session-1', + type: 'contract', + }, + type: 'event', + })}\n`, + ) + const confirmationToken = listener.mock.calls[0][0].contract.confirmation_token + listener.mockClear() + void runtime.sendMessage({ + confirmationToken, + message: '1', + providerId: 'local', + sessionId: 'session-1', + }) + harness.children[0].stdout.emit( + 'data', + `${JSON.stringify({ + event: { + sessionId: 'session-1', + type: 'workspace_rerun', + workspaceRerun, }, type: 'event', })}\n`, @@ -479,38 +513,39 @@ describe('AgentProviderProcessRuntime', () => { expect(listener).toHaveBeenCalledWith({ providerId: 'local', + sessionId: 'session-1', type: 'workspace_rerun', workspaceRerun: expect.objectContaining({ rerun_id: 'gcd_rerun_place', end_step: 'place', source_flow_json_sha256: 'a'.repeat(64), source_stage_artifact_sha256: 'b'.repeat(64), - writes: [ - expect.objectContaining({ - file: 'home/parameters.json', - knob_id: 'place.target_density', - }), - ], + workspace_parameters: { 'place.target_density': 0.55 }, + step_configurations: [], }), }) }) - const parameterUpdateEvent = (writes: unknown): string => + const parameterUpdateEvent = (overrides: Record = {}): string => `${JSON.stringify({ event: { + sessionId: 'session-1', type: 'workspace_parameter_update', workspaceParameterUpdate: { parameter_patch: [{ knob_id: 'floorplan.utilitization', value: 0.7 }], - schema_version: 'flow-agent.workspace_parameter_update_contract.v2', + schema_version: 'flow-agent.workspace_parameter_update_contract.v3', update_id: 'update_1', workspace: '/runs/gcd', - writes, + ...overrides, }, }, type: 'event', })}\n` - const emitParameterUpdate = (writes: unknown) => { + const emitParameterUpdate = ( + overrides: Record = {}, + action: 'confirm' | 'cancel' | 'none' = 'confirm', + ) => { const harness = createSpawnHarness() const runtime = new AgentProviderProcessRuntime({ manifest: { @@ -524,103 +559,125 @@ describe('AgentProviderProcessRuntime', () => { }) const listener = vi.fn() runtime.onEvent(listener) - void runtime.getStatus({ providerId: 'local' }) - harness.children[0].stdout.emit('data', parameterUpdateEvent(writes)) + void runtime.startSession({ + providerId: 'local', + sessionId: 'session-1', + workspaceRevision: 4, + }) + const patch = (overrides.confirmation_patch as unknown[]) ?? + (overrides.parameter_patch as unknown[]) ?? [ + { knob_id: 'floorplan.utilitization', value: 0.7 }, + ] + harness.children[0].stdout.emit( + 'data', + `${JSON.stringify({ + event: { + contract: { + fields: [{ label: 'ignored', value: 'ignored' }], + parameter_patch: patch, + presentation: 'workspace_parameter_update', + schema_version: 'flow-agent.resolved_execution_contract.v1', + title: 'Confirm parameter update', + update_id: 'update_1', + workspace: '/runs/gcd', + }, + sessionId: 'session-1', + type: 'contract', + }, + type: 'event', + })}\n`, + ) + const confirmationToken = listener.mock.calls[0]?.[0].contract?.confirmation_token + if (confirmationToken && action !== 'none') { + void runtime.sendMessage({ + ...(action === 'confirm' ? { confirmationToken } : {}), + message: action === 'confirm' ? '1' : '2', + providerId: 'local', + sessionId: 'session-1', + }) + } + listener.mockClear() + harness.children[0].stdout.emit('data', parameterUpdateEvent(overrides)) return listener } - it('forwards resolved parameter write targets from provider stdout', () => { - const listener = emitParameterUpdate([ - { - file: 'home/parameters.json', - json_path: ['Core', 'Utilitization'], - knob_id: 'floorplan.utilitization', - surface: 'parameters', - value: 0.7, - }, - ]) + it('derives canonical domain updates from the confirmed logical patch', () => { + const listener = emitParameterUpdate({ + parameter_patch: [ + { knob_id: 'floorplan.utilitization', value: 0.7 }, + { knob_id: 'cts.skew_bound', value: 0.08 }, + ], + }) expect(listener).toHaveBeenCalledWith( expect.objectContaining({ type: 'workspace_parameter_update', workspaceParameterUpdate: expect.objectContaining({ - schema_version: 'flow-agent.workspace_parameter_update_contract.v2', - writes: [ - { - file: 'home/parameters.json', - json_path: ['Core', 'Utilitization'], - knob_id: 'floorplan.utilitization', - surface: 'parameters', - value: 0.7, - }, - ], + schema_version: 'flow-agent.workspace_parameter_update_contract.v3', + workspace_parameters: { + 'floorplan.core_util': 0.7, + 'cts.skew_bound': '0.08', + }, + step_configurations: [], + workspace_revision: 4, }), }), ) }) - it.each([ - ['a file outside the parameter allowlist', 'home/../../etc/passwd'], - ['an arbitrary project source file', 'rtl/gcd.v'], - ['a flow definition', 'home/flow.json'], - ])('drops parameter updates that target %s', (_label, file) => { - const listener = emitParameterUpdate([ - { - file, - json_path: ['Core', 'Utilitization'], - knob_id: 'floorplan.utilitization', - surface: 'parameters', - value: 0.7, - }, - ]) + it('drops parameter updates that were not confirmed or were cancelled', () => { + expect(emitParameterUpdate({}, 'none')).not.toHaveBeenCalled() + expect(emitParameterUpdate({}, 'cancel')).not.toHaveBeenCalled() + }) - expect(listener).not.toHaveBeenCalled() + it('drops obsolete provider-supplied domain updates', () => { + expect( + emitParameterUpdate({ workspace_parameters: { core_utilization: 0.8 } }), + ).not.toHaveBeenCalled() + expect( + emitParameterUpdate({ + workspace_parameters: { core_utilization: 0.7 }, + step_configurations: [{ step_id: 'CTS', options: { skew_bound: 0.08 } }], + }), + ).not.toHaveBeenCalled() }) - it('drops parameter updates whose writes do not cover every patch entry', () => { - expect(emitParameterUpdate([])).not.toHaveBeenCalled() - expect(emitParameterUpdate(undefined)).not.toHaveBeenCalled() + it('drops an update whose patch differs from the confirmed card', () => { + expect( + emitParameterUpdate({ + confirmation_patch: [{ knob_id: 'floorplan.utilitization', value: 0.6 }], + parameter_patch: [{ knob_id: 'floorplan.utilitization', value: 0.7 }], + }), + ).not.toHaveBeenCalled() }) - it('drops parameter updates whose write value does not match the advertised patch', () => { + it('drops legacy file-write contracts', () => { expect( - emitParameterUpdate([ - { - file: 'home/params.toml', - json_path: ['pdk_root'], - knob_id: 'floorplan.utilitization', - surface: 'parameters', - value: '/tmp/other', - }, - ]), + emitParameterUpdate({ + schema_version: 'flow-agent.workspace_parameter_update_contract.v2', + writes: [{ file: 'home/parameters.json', json_path: ['Core'] }], + }), ).not.toHaveBeenCalled() }) - it('drops parameter updates whose json_path would pollute Object.prototype', () => { + it('drops invalid Workspace Parameter identities', () => { expect( - emitParameterUpdate([ - { - file: 'config/dreamplace_ecc.json', - json_path: ['__proto__', 'toString'], - knob_id: 'floorplan.utilitization', - surface: 'step_config', - value: 0.7, - }, - ]), + emitParameterUpdate({ + parameter_patch: [{ knob_id: 'Core.Utilitization', value: 0.7 }], + }), ).not.toHaveBeenCalled() }) - it('drops parameter updates that pair the parameters surface with a step-config file', () => { + it('drops unknown Step identities and unsafe Step Options', () => { expect( - emitParameterUpdate([ - { - file: 'config/dreamplace_ecc.json', - json_path: ['utilitization'], - knob_id: 'floorplan.utilitization', - surface: 'parameters', - value: 0.7, - }, - ]), + emitParameterUpdate({ + parameter_patch: [{ knob_id: 'fixfanout.enabled', value: true }], + }), + ).not.toHaveBeenCalled() + expect( + emitParameterUpdate({ + parameter_patch: [{ knob_id: 'cts.buffer_type', value: ['BUF; rm'] }], + }), ).not.toHaveBeenCalled() }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/agent/agentProviderProcessRuntime.ts b/ecos/gui/apps/desktop-electron/electron/services/agent/agentProviderProcessRuntime.ts index b72e6fe22..279eb7a32 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/agent/agentProviderProcessRuntime.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/agent/agentProviderProcessRuntime.ts @@ -1,5 +1,6 @@ import { spawn as spawnChild } from 'node:child_process' import { randomUUID } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' import type { DesktopAgentEventType, DesktopAgentEvent, @@ -7,7 +8,6 @@ import type { DesktopAgentExecutionContract, DesktopAgentWorkspaceContinueContract, DesktopAgentWorkspaceParameterUpdateContract, - DesktopAgentWorkspaceParameterWrite, DesktopAgentWorkspaceRerunContract, DesktopAgentWorkspaceSetupContract, DesktopAgentListSessionsRequest, @@ -23,14 +23,10 @@ import type { DesktopAgentStartSessionResponse, DesktopAgentStatus, } from '@ecos-studio/shared' -import { - desktopAgentParameterWriteFiles, - hasSafeJsonPath, - parameterWritesMatchPatch, -} from '@ecos-studio/shared' import type { AgentProviderRuntime } from './agentProviderContract' import type { ResolvedAgentProviderManifest } from './agentProviderPlugin' import { RuntimeEventFanout } from '../runtime/runtimeEvents' +import { deriveAgentWorkspaceParameterUpdates } from './agentWorkspaceParameterUpdates' type SpawnLike = typeof spawnChild type AgentProviderMethod = @@ -79,6 +75,21 @@ export class AgentProviderProcessRuntime implements AgentProviderRuntime { private readonly eventFanout = new RuntimeEventFanout() private readonly manifest: ResolvedAgentProviderManifest private readonly pendingRequests = new Map() + private readonly pendingExecutionConfirmations = new Map< + string, + { + approved: boolean + parameter?: { + patch: DesktopAgentWorkspaceRerunContract['parameter_patch'] + updateId: string + workspace: string + workspaceRevision: number + } + rerun?: DesktopAgentWorkspaceRerunContract + token: string + } + >() + private readonly workspaceRevisions = new Map() private readonly spawnImpl: SpawnLike private child: ReturnType | null = null private stderrTail = '' @@ -123,19 +134,51 @@ export class AgentProviderProcessRuntime implements AgentProviderRuntime { async startSession( request: DesktopAgentStartSessionRequest, ): Promise { + if (request.workspaceRevision !== undefined) { + this.workspaceRevisions.set(request.sessionId ?? '', request.workspaceRevision) + } + const { workspaceRevision: _workspaceRevision, ...providerRequest } = request return (await this.sendRequest( 'startSession', - request, + providerRequest, )) as DesktopAgentStartSessionResponse } async sendMessage( request: DesktopAgentSendMessageRequest, ): Promise { - return (await this.sendRequest( - 'sendMessage', - request, - )) as DesktopAgentSendMessageResponse + const pending = this.pendingExecutionConfirmations.get(request.sessionId) + if (request.confirmationToken) { + if ( + !pending || + pending.token !== request.confirmationToken || + request.message.trim() !== '1' + ) { + throw new Error('Agent execution confirmation is invalid or expired.') + } + pending.approved = true + } else { + this.pendingExecutionConfirmations.delete(request.sessionId) + if (request.workspaceRevision !== undefined) { + this.workspaceRevisions.set(request.sessionId, request.workspaceRevision) + } + } + const { + confirmationToken: _confirmationToken, + workspaceRevision: _workspaceRevision, + ...providerRequest + } = request + try { + return (await this.sendRequest( + 'sendMessage', + providerRequest, + )) as DesktopAgentSendMessageResponse + } catch (error) { + if (request.confirmationToken) { + this.pendingExecutionConfirmations.delete(request.sessionId) + } + throw error + } } async interrupt(request?: DesktopAgentProviderRequest): Promise { @@ -306,10 +349,11 @@ export class AgentProviderProcessRuntime implements AgentProviderRuntime { private handleProtocolRecord(record: Record): void { if (record.type === 'event') { const event = readDesktopAgentEvent(record.event) - if (event) { + const accepted = event ? this.acceptConfirmedWorkspaceAction(event) : null + if (accepted) { this.eventFanout.emit({ - ...event, - providerId: event.providerId ?? this.manifest.providerId, + ...accepted, + providerId: accepted.providerId ?? this.manifest.providerId, } as DesktopAgentEvent) } return @@ -328,6 +372,80 @@ export class AgentProviderProcessRuntime implements AgentProviderRuntime { pending.resolve(response.result) } + private acceptConfirmedWorkspaceAction( + event: DesktopAgentEvent, + ): DesktopAgentEvent | null { + const sessionId = event.sessionId + if (!sessionId) { + return event.type === 'workspace_parameter_update' || + event.type === 'workspace_rerun' + ? null + : event + } + if (event.type === 'contract' && event.contract) { + const contract = event.contract + const token = randomUUID() + if (contract.presentation === 'workspace_parameter_update') { + const { parameter_patch: patch, update_id: updateId, workspace } = contract + const workspaceRevision = this.workspaceRevisions.get(sessionId) + if (!patch || !updateId || !workspace || workspaceRevision === undefined) + return null + this.pendingExecutionConfirmations.set(sessionId, { + approved: false, + parameter: { patch, updateId, workspace, workspaceRevision }, + token, + }) + return { + ...event, + contract: { + ...contract, + confirmation_token: token, + workspace_revision: workspaceRevision, + }, + } + } + if (contract.presentation === 'workspace_rerun' && contract.workspace_rerun) { + this.pendingExecutionConfirmations.set(sessionId, { + approved: false, + rerun: contract.workspace_rerun, + token, + }) + return { + ...event, + contract: { ...contract, confirmation_token: token }, + } + } + if (contract.presentation === 'workspace_rerun') return null + } + if (event.type !== 'workspace_parameter_update' && event.type !== 'workspace_rerun') { + return event + } + const pending = this.pendingExecutionConfirmations.get(sessionId) + this.pendingExecutionConfirmations.delete(sessionId) + if (!pending?.approved) return null + if (event.type === 'workspace_rerun') { + return event.workspaceRerun && + isDeepStrictEqual(pending.rerun, event.workspaceRerun) + ? event + : null + } + const update = event.workspaceParameterUpdate + const expected = pending.parameter + return update && + expected && + expected.updateId === update.update_id && + expected.workspace === update.workspace && + isDeepStrictEqual(expected.patch, update.parameter_patch) + ? { + ...event, + workspaceParameterUpdate: { + ...update, + workspace_revision: expected.workspaceRevision, + }, + } + : null + } + private rejectPending(error: Error): void { for (const pending of this.pendingRequests.values()) { pending.reject(error) @@ -341,6 +459,8 @@ export class AgentProviderProcessRuntime implements AgentProviderRuntime { this.child = null this.stderrTail = '' this.stdoutBuffer = '' + this.pendingExecutionConfirmations.clear() + this.workspaceRevisions.clear() } private disposeChildForEnvReload(): void { @@ -349,6 +469,8 @@ export class AgentProviderProcessRuntime implements AgentProviderRuntime { this.child = null this.stderrTail = '' this.stdoutBuffer = '' + this.pendingExecutionConfirmations.clear() + this.workspaceRevisions.clear() this.rejectPending(new Error('Agent provider restarted to apply Codex CLI path')) try { child.kill() @@ -521,11 +643,7 @@ function readWorkspaceRerunContract( const endStep = record.end_step const executionScope = record.execution_scope const patch = readWorkspaceRerunPatch(record.parameter_patch) - const writes = - record.writes === undefined || - (Array.isArray(record.writes) && record.writes.length === 0) - ? [] - : readWorkspaceParameterWrites(record.writes) + const derivedUpdates = patch ? deriveAgentWorkspaceParameterUpdates(patch) : null const sourceStageArtifact = readWorkspaceRerunArtifactReference( record.source_stage_artifact, ) @@ -547,8 +665,9 @@ function readWorkspaceRerunContract( workspaceSetupFlowSteps.indexOf(endStep) < workspaceSetupFlowSteps.indexOf(targetStep) || !patch || - !writes || - !parameterWritesMatchPatch(patch, writes) || + !derivedUpdates || + 'workspace_parameters' in record || + 'step_configurations' in record || !sourceStageArtifact || !sourceFlowJsonSha256 || !sourceStageArtifactSha256 @@ -560,7 +679,8 @@ function readWorkspaceRerunContract( end_step: endStep, execution_scope: executionScope, parameter_patch: patch, - writes, + step_configurations: derivedUpdates.step_configurations, + workspace_parameters: derivedUpdates.workspace_parameters, requires_gui_review: true, rerun_id: rerunId, schema_version: 'flow-agent.workspace_rerun_contract.v1', @@ -882,68 +1002,28 @@ function readWorkspaceParameterUpdateContract( const workspace = readEventText(record.workspace) const updateId = readOptionalIdentifier(record.update_id) const patch = readWorkspaceRerunPatch(record.parameter_patch) - const writes = readWorkspaceParameterWrites(record.writes) + const derivedUpdates = patch ? deriveAgentWorkspaceParameterUpdates(patch) : null if ( - record.schema_version !== 'flow-agent.workspace_parameter_update_contract.v2' || + record.schema_version !== 'flow-agent.workspace_parameter_update_contract.v3' || !workspace || !updateId || !patch || - !writes || - !parameterWritesMatchPatch(patch, writes) + !derivedUpdates || + 'workspace_parameters' in record || + 'step_configurations' in record ) { return null } return { parameter_patch: patch, - schema_version: 'flow-agent.workspace_parameter_update_contract.v2', + schema_version: 'flow-agent.workspace_parameter_update_contract.v3', + step_configurations: derivedUpdates.step_configurations, update_id: updateId, workspace, - writes, + workspace_parameters: derivedUpdates.workspace_parameters, } } -/** - * Confines Agent-proposed writes to known parameter files. The Agent resolves - * the target, but the main process decides which targets are legal at all, so a - * malformed or hostile proposal cannot reach arbitrary project files. - */ -function readWorkspaceParameterWrites( - value: unknown, -): DesktopAgentWorkspaceParameterWrite[] | null { - if (!Array.isArray(value) || value.length === 0 || value.length > 16) return null - const writes = value.map((item) => { - const record = readRecord(item) - const knobId = record.knob_id - const file = record.file - const surface = record.surface - const jsonPath = record.json_path - if ( - typeof knobId !== 'string' || - !/^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/.test(knobId) || - typeof file !== 'string' || - !(desktopAgentParameterWriteFiles as readonly string[]).includes(file) || - (surface !== 'parameters' && surface !== 'step_config') || - !isWorkspaceRerunParameterValue(record.value) || - !Array.isArray(jsonPath) || - !hasSafeJsonPath(jsonPath as (string | number)[]) - ) { - return null - } - return { - file: file as DesktopAgentWorkspaceParameterWrite['file'], - json_path: jsonPath as (string | number)[], - knob_id: knobId, - surface, - value: record.value, - } - }) - if (writes.some((item) => item === null)) return null - const normalized = writes as DesktopAgentWorkspaceParameterWrite[] - return new Set(normalized.map((item) => item.knob_id)).size === normalized.length - ? normalized - : null -} - function readExecutionContract(value: unknown): DesktopAgentExecutionContract | null { const record = readRecord(value) const presentation = @@ -954,28 +1034,80 @@ function readExecutionContract(value: unknown): DesktopAgentExecutionContract | record.presentation === 'workspace_parameter_update' ? record.presentation : null + const parameterPatch = + presentation === 'workspace_parameter_update' + ? readWorkspaceRerunPatch(record.parameter_patch) + : null + const updateId = + presentation === 'workspace_parameter_update' + ? readOptionalIdentifier(record.update_id) + : null + const workspace = + presentation === 'workspace_parameter_update' + ? readWorkspaceRerunPath(record.workspace) + : null + const workspaceRerun = + presentation === 'workspace_rerun' + ? readWorkspaceRerunContract(record.workspace_rerun) + : null if ( record.schema_version !== 'flow-agent.resolved_execution_contract.v1' || !readEventText(record.title) || - !Array.isArray(record.fields) || - record.fields.length === 0 || - record.fields.length > 32 || - presentation === null + presentation === null || + (presentation === 'workspace_parameter_update' + ? !parameterPatch || + !deriveAgentWorkspaceParameterUpdates(parameterPatch) || + !updateId || + !workspace + : presentation === 'workspace_rerun' + ? !workspaceRerun + : !Array.isArray(record.fields) || + record.fields.length === 0 || + record.fields.length > 32) ) { return null } - const fields = record.fields.map((value) => { - const field = readRecord(value) - const label = readEventText(field.label) - const fieldValue = readEventText(field.value) - return label && fieldValue ? { label, value: fieldValue } : null - }) + const fields = parameterPatch + ? [ + { label: 'Workspace', value: workspace! }, + ...parameterPatch.map((item) => ({ + label: item.knob_id, + value: Array.isArray(item.value) + ? JSON.stringify(item.value) + : String(item.value), + })), + ] + : workspaceRerun + ? [ + { label: 'Design', value: workspaceRerun.design_id }, + { label: 'Source workspace', value: workspaceRerun.source_workspace }, + { label: 'Target workspace', value: workspaceRerun.target_workspace }, + { label: 'Start stage', value: workspaceRerun.target_step }, + { label: 'End stage', value: workspaceRerun.end_step }, + { label: 'Execution scope', value: workspaceRerun.execution_scope }, + ...workspaceRerun.parameter_patch.map((item) => ({ + label: item.knob_id, + value: Array.isArray(item.value) + ? JSON.stringify(item.value) + : String(item.value), + })), + ] + : (record.fields as unknown[]).map((value) => { + const field = readRecord(value) + const label = readEventText(field.label) + const fieldValue = readEventText(field.value) + return label && fieldValue ? { label, value: fieldValue } : null + }) if (fields.some((field) => field === null)) return null return { fields: fields as DesktopAgentExecutionContract['fields'], ...(presentation ? { presentation } : {}), + ...(parameterPatch + ? { parameter_patch: parameterPatch, update_id: updateId!, workspace: workspace! } + : {}), + ...(workspaceRerun ? { workspace_rerun: workspaceRerun } : {}), schema_version: 'flow-agent.resolved_execution_contract.v1', title: readEventText(record.title) as string, } diff --git a/ecos/gui/apps/desktop-electron/electron/services/agent/agentWorkspaceParameterUpdates.test.ts b/ecos/gui/apps/desktop-electron/electron/services/agent/agentWorkspaceParameterUpdates.test.ts new file mode 100644 index 000000000..0a1e239b7 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/agent/agentWorkspaceParameterUpdates.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + deriveAgentWorkspaceParameterUpdates, + readAgentWorkspaceParameterValues, +} from './agentWorkspaceParameterUpdates' + +describe('Agent Workspace parameter mapping', () => { + it('reads logical values only from ECC canonical domain projections', () => { + const values = readAgentWorkspaceParameterValues( + { + parameters: { + 'design.frequency_mhz': 200, + 'place.target_density': 0.4, + 'place.routability_opt': 0, + 'cts.skew_bound': 0.08, + 'route.RT.-thread_number': 8, + }, + }, + {}, + ) + + expect(values).toMatchObject({ + 'design.frequency_max': 200, + 'place.target_density': 0.4, + 'place.routability_opt': false, + 'cts.skew_bound': 0.08, + 'route.thread_number': 8, + }) + }) + + it('derives the exact ECC commands from a validated logical patch', () => { + expect( + deriveAgentWorkspaceParameterUpdates([ + { knob_id: 'place.routability_opt', value: false }, + { knob_id: 'cts.skew_bound', value: 0.08 }, + { knob_id: 'route.thread_number', value: 8 }, + ]), + ).toEqual({ + workspace_parameters: { + 'place.routability_opt': 0, + 'cts.skew_bound': '0.08', + 'route.RT.-thread_number': '8', + }, + step_configurations: [], + }) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/agent/agentWorkspaceParameterUpdates.ts b/ecos/gui/apps/desktop-electron/electron/services/agent/agentWorkspaceParameterUpdates.ts new file mode 100644 index 000000000..f9fe91450 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/agent/agentWorkspaceParameterUpdates.ts @@ -0,0 +1,176 @@ +import type { DesktopAgentWorkspaceRerunParameterPatch } from '@ecos-studio/shared' + +type ParameterValue = DesktopAgentWorkspaceRerunParameterPatch['value'] +type ValueKind = + | 'boolean' + | 'integer' + | 'number' + | 'positive' + | 'string' + | 'int-list' + | 'str-list' + +type Knob = { + kind: ValueKind + parameter: string + range?: readonly [number, number] + transform?: (value: ParameterValue) => ParameterValue +} + +const workspace = ( + id: string, + kind: ValueKind, + range?: readonly [number, number], +): Knob => ({ + kind, + parameter: id, + range, +}) +const numericString = ( + id: string, + kind: 'integer' | 'number', + range?: readonly [number, number], +): Knob => ({ + ...workspace(id, kind, range), + transform: (value) => String(value), +}) + +const knobs: Record = { + 'design.frequency_max': workspace('design.frequency_mhz', 'positive'), + 'floorplan.utilitization': workspace('floorplan.core_util', 'number', [0.01, 1]), + 'floorplan.aspect_ratio': workspace('floorplan.aspect_ratio', 'positive'), + 'floorplan.die_width': workspace( + 'floorplan.die_builder.die_size.width_micron', + 'positive', + ), + 'floorplan.die_height': workspace( + 'floorplan.die_builder.die_size.height_micron', + 'positive', + ), + 'floorplan.global_right_padding': workspace('place.global_right_padding', 'integer', [ + 0, + Infinity, + ]), + 'place.target_density': workspace('place.target_density', 'number', [0.1, 0.95]), + 'place.target_overflow': workspace('place.target_overflow', 'number', [0, 1]), + 'place.cell_padding_x': workspace('place.cell_padding_x', 'integer', [0, Infinity]), + 'place.routability_opt': { + ...workspace('place.routability_opt', 'boolean'), + transform: (value) => (value ? 1 : 0), + }, + 'cts.max_fanout': workspace('cts.max_fanout', 'integer'), + 'route.bottom_layer': workspace('route.bottom_layer', 'string'), + 'route.top_layer': workspace('route.top_layer', 'string'), + 'place.density_weight': workspace('place.density_weight', 'number'), + 'place.gp_noise_ratio': workspace('place.gp_noise_ratio', 'number', [0, 1]), + 'place.num_threads': workspace('place.num_threads', 'integer'), + 'cts.skew_bound': numericString('cts.skew_bound', 'number', [0, 1]), + 'cts.max_buf_tran': numericString('cts.max_buf_tran', 'number'), + 'cts.root_input_slew': numericString('cts.root_input_slew', 'number'), + 'cts.max_sink_tran': numericString('cts.max_sink_tran', 'number'), + 'cts.max_cap': numericString('cts.max_cap', 'number'), + 'cts.wirelength_iterations': numericString('cts.wirelength_iterations', 'integer'), + 'cts.slew_steps': numericString('cts.slew_steps', 'integer'), + 'cts.cap_steps': numericString('cts.cap_steps', 'integer'), + 'cts.routing_layer': workspace('cts.routing_layer', 'int-list'), + 'cts.buffer_type': workspace('cts.buffer_type', 'str-list'), + 'route.thread_number': numericString('route.RT.-thread_number', 'integer'), + 'route.enable_timing': { + ...workspace('route.RT.-enable_timing', 'boolean'), + transform: (value) => (value ? '1' : '0'), + }, +} + +function validString(value: unknown): value is string { + return ( + typeof value === 'string' && + value.trim().length > 0 && + value.length <= 256 && + ![...value].some((character) => character.charCodeAt(0) < 32) && + !/[`]|\.\.|[;&|]|\$\(/.test(value) + ) +} + +function validValue(value: ParameterValue, knob: Knob): boolean { + if (knob.kind === 'boolean') return typeof value === 'boolean' + if (knob.kind === 'string') return validString(value) + if (knob.kind === 'int-list' || knob.kind === 'str-list') { + if (!Array.isArray(value) || value.length === 0 || value.length > 64) return false + if (new Set(value).size !== value.length) return false + return knob.kind === 'int-list' + ? value.every((item) => Number.isInteger(item) && Number(item) >= 1) + : value.every(validString) + } + if (typeof value !== 'number' || !Number.isFinite(value)) return false + if ( + knob.kind === 'integer' && + (!Number.isInteger(value) || value < (knob.range?.[0] ?? 1)) + ) + return false + if (knob.kind === 'positive' && value <= 0) return false + if (knob.kind === 'number' && !knob.range && value < 0) return false + return !knob.range || (value >= knob.range[0] && value <= knob.range[1]) +} + +export function readAgentWorkspaceParameterValues( + workspaceSpec: Record, + _stepConfigurations: Record>, +): Record { + const parameters = + workspaceSpec.parameters && + typeof workspaceSpec.parameters === 'object' && + !Array.isArray(workspaceSpec.parameters) + ? (workspaceSpec.parameters as Record) + : {} + const result: Record = {} + for (const [knobId, knob] of Object.entries(knobs)) { + const value = parameters[knob.parameter] + let normalized = value + if ( + knob.kind === 'boolean' && + (value === 0 || value === 1 || value === '0' || value === '1') + ) { + normalized = value === 1 || value === '1' + } else if ( + (knob.kind === 'integer' || knob.kind === 'number' || knob.kind === 'positive') && + typeof value === 'string' && + Number.isFinite(Number(value)) + ) { + normalized = Number(value) + } + if ( + typeof normalized === 'boolean' || + typeof normalized === 'string' || + (typeof normalized === 'number' && Number.isFinite(normalized)) || + (Array.isArray(normalized) && + normalized.length > 0 && + normalized.every( + (item) => + typeof item === 'string' || + (typeof item === 'number' && Number.isFinite(item)), + )) + ) { + result[knobId] = normalized + } + } + return result +} + +export function deriveAgentWorkspaceParameterUpdates( + patch: DesktopAgentWorkspaceRerunParameterPatch[], +): { + workspace_parameters: Record + step_configurations: [] +} | null { + const workspaceParameters: Record = {} + for (const item of patch) { + const knob = knobs[item.knob_id] + if (!knob || !validValue(item.value, knob)) return null + const value = knob.transform?.(item.value) ?? item.value + workspaceParameters[knob.parameter] = Array.isArray(value) ? [...value] : value + } + return { + workspace_parameters: workspaceParameters, + step_configurations: [], + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/agent/codexDependencyService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/agent/codexDependencyService.test.ts index 46a835429..3a7b9010c 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/agent/codexDependencyService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/agent/codexDependencyService.test.ts @@ -51,6 +51,7 @@ describe('CodexDependencyService', () => { arch: 'x64', settingsStore: new MemorySettingsStore(), spawn: vi.fn() as never, + homedir: () => root, }) await expect(service.getStatus()).resolves.toMatchObject({ @@ -111,6 +112,49 @@ describe('CodexDependencyService', () => { }) }) + it('finds Codex in well-known user install directories before PATH', async () => { + const root = await createRoot() + const binDir = join(root, '.nvm', 'versions', 'node', 'v22.1.0', 'bin') + const codexBin = join(binDir, 'codex') + await mkdir(binDir, { recursive: true }) + await writeFile(codexBin, '#!/bin/sh\necho codex-cli 1.0.0\n') + await chmod(codexBin, 0o755) + + const spawn = vi.fn((command: string, args: string[]) => { + const child = new FakeChild() + queueMicrotask(() => { + if (args[0] === '--version') { + child.stdout.emit('data', 'codex-cli 1.0.0\n') + child.emit('close', 0) + return + } + if (command === codexBin && args[0] === 'login' && args[1] === 'status') { + child.stdout.emit('data', 'Logged in\n') + child.emit('close', 0) + return + } + child.emit('close', 1) + }) + return child as never + }) + + const service = new CodexDependencyService({ + env: { PATH: join(root, 'empty-bin') }, + installRoot: join(root, 'managed'), + platform: 'linux', + arch: 'x64', + settingsStore: new MemorySettingsStore(), + spawn: spawn as never, + homedir: () => root, + }) + + await expect(service.getStatus()).resolves.toMatchObject({ + state: 'ready', + binPath: codexBin, + authState: 'authenticated', + }) + }) + it('rejects install on non-linux platforms', async () => { const root = await createRoot() const service = new CodexDependencyService({ @@ -119,6 +163,7 @@ describe('CodexDependencyService', () => { platform: 'darwin', arch: 'arm64', settingsStore: new MemorySettingsStore(), + homedir: () => root, }) await expect(service.getStatus()).resolves.toMatchObject({ diff --git a/ecos/gui/apps/desktop-electron/electron/services/agent/codexDependencyService.ts b/ecos/gui/apps/desktop-electron/electron/services/agent/codexDependencyService.ts index fbb45b991..8f992e086 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/agent/codexDependencyService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/agent/codexDependencyService.ts @@ -334,12 +334,59 @@ export class CodexDependencyService { const managedValidated = await this.validateExecutable(managed) if (managedValidated) return managedValidated + const wellKnown = await this.findCodexInDirectories(await this.wellKnownCodexDirs()) + if (wellKnown) return wellKnown + return await this.whichCodex() } + private async wellKnownCodexDirs(): Promise { + const home = this.resolveHomedir() + const directories = [ + join(home, '.npm-global', 'bin'), + join(home, '.volta', 'bin'), + join(home, '.bun', 'bin'), + join(home, '.local', 'share', 'pnpm'), + join(home, '.local', 'bin'), + ] + + if (this.platform !== 'win32') { + directories.push('/usr/local/bin', '/opt/homebrew/bin') + } + + const nvmRoot = join(home, '.nvm', 'versions', 'node') + try { + const entries = await readdir(nvmRoot, { withFileTypes: true }) + directories.splice( + 1, + 0, + ...entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .sort((left, right) => + right.name.localeCompare(left.name, undefined, { numeric: true }), + ) + .map((entry) => join(nvmRoot, entry.name, 'bin')), + ) + } catch { + // NVM is optional and its directory may not exist. + } + + return directories + } + + private async findCodexInDirectories( + directories: readonly string[], + ): Promise { + for (const directory of directories) { + const validated = await this.validateExecutable(join(directory, 'codex')) + if (validated) return validated + } + return null + } + private async whichCodex(): Promise { const pathValue = this.env.PATH ?? '' - for (const entry of pathValue.split(':')) { + for (const entry of pathValue.split(delimiter)) { if (!entry) continue const candidate = join(entry, 'codex') const validated = await this.validateExecutable(candidate) diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparison.fixture.test.ts b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparison.fixture.test.ts new file mode 100644 index 000000000..dc6c9fd70 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparison.fixture.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + projectManagementWorkspaceStepAnalysisSpecs, + projectManifestFlowSteps, + validateEngineeringSnapshot, +} from '@ecos-studio/shared' +import { representativeProjectComparisonFixture } from './backendProjectComparison.fixture' + +describe('representativeProjectComparisonFixture', () => { + it('contains every committed Dashboard and Compare fact in Engineering Snapshots', () => { + const fixture = representativeProjectComparisonFixture() + + for (const snapshot of Object.values(fixture.engineeringSnapshots)) { + const validated = validateEngineeringSnapshot(snapshot) + expect(validated.ok && validated.sections).toMatchObject({ + artifacts: { status: 'ready' }, + flow: { status: 'ready' }, + qor: { status: 'ready' }, + signoff: { status: 'ready' }, + }) + expect(snapshot.flow).toEqual({ + steps: projectManifestFlowSteps.map((name) => ({ name, state: 'Success' })), + }) + expect(snapshot.metrics).toHaveLength(209) + expect(snapshot.analysis.steps).toHaveLength(12) + expect(snapshot.analysis.steps.map((step) => step.stepId)).toEqual( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => spec.step), + ) + expect(snapshot.metrics[0]).toMatchObject({ + analysis_group: expect.any(String), + category: expect.any(String), + confidence: expect.stringMatching(/^(high|medium|low)$/), + direction: expect.any(String), + project_role: expect.any(String), + rating: { gate: expect.any(Boolean), score: expect.any(Boolean) }, + scope: expect.any(String), + source: { kind: 'feature', path: expect.any(String) }, + step_role: expect.any(String), + }) + expect(snapshot.artifacts).toHaveLength(37) + expect(snapshot.signoffAssessment.status).toBe('ready') + expect(snapshot.qorAssessment).toMatchObject({ + score: { gate: 'pass', threshold: 60, value: expect.any(Number) }, + }) + } + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparison.fixture.ts b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparison.fixture.ts new file mode 100644 index 000000000..ea0c1af5c --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparison.fixture.ts @@ -0,0 +1,365 @@ +import { + projectManagementStaTimingIssuesPath, + projectManagementWorkspaceStepAnalysisSpecs, + projectManifestFlowSteps, + type EccEngineeringMetric, + type EccPersistedEngineeringSnapshot, + type ProjectManifest, + type ProjectManifestFlowStep, +} from '@ecos-studio/shared' +import { createHash } from 'node:crypto' + +const metricIds: Partial> = { + Synth: ['synthesis_cell_area', 'runtime_seconds', 'peak_memory_mb'], + Floor: ['die_area', 'core_utilization'], + CTS: ['cts_buffer_count', 'cts_buffer_area'], + Route: ['route_wirelength', 'route_via_count'], + DRC: ['drc_count'], + LVS: ['lvs_count'], + STA: [ + 'sta_setup_wns', + 'sta_setup_tns', + 'sta_hold_wns', + 'sta_hold_tns', + 'sta_frequency_mhz', + ], +} + +export interface RepresentativeProjectComparisonFixture { + manifest: ProjectManifest + engineeringSnapshots: Record +} + +export function representativeProjectComparisonFixture( + root = '/projects/gcd', +): RepresentativeProjectComparisonFixture { + const workspaceIds = ['ws_0001', 'ws_0002'] as const + const manifest: ProjectManifest = { + schema_version: 1, + project_id: 'proj_gcd', + name: 'gcd', + design_name: 'gcd', + description: 'Representative Project Comparison fixture', + root_path: root, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + base_design: { top_module: 'gcd', pdk: 'sky130A' }, + objectives: { primary: 'timing', directions: {} }, + workspaces: workspaceIds.map((workspaceId, index) => ({ + workspace_id: workspaceId, + name: index === 0 ? 'baseline' : 'candidate', + workspace_path: `${root}/${workspaceId}`, + source_workspace_id: index === 0 ? null : workspaceIds[0], + branch_from: + index === 0 + ? null + : { source_workspace_id: workspaceIds[0], source_step: 'Route' }, + start_step: 'Synth', + end_step: 'Harden', + // Deliberately stale: committed Flow state comes from Engineering Snapshot. + status: 'not_started', + created_at: `2026-01-0${index + 1}T00:00:00Z`, + updated_at: `2026-01-0${index + 1}T00:00:00Z`, + parameter_patch: {}, + metrics_summary: {}, + step_metrics: {}, + })), + mpc: null, + best_workspace: null, + qor_baseline: { workspace_id: workspaceIds[0], reason: 'reference' }, + } + + return { + manifest, + engineeringSnapshots: Object.fromEntries( + workspaceIds.map((workspaceId, index) => [ + workspaceId, + engineeringSnapshot(`engineering-gcd-${index + 1}`, index === 0 ? 72 : 84), + ]), + ), + } +} + +function engineeringSnapshot( + workspaceId: string, + score: number, +): EccPersistedEngineeringSnapshot { + const texts = analysisTexts(score > 80 ? 1 : 0) + const artifacts: EccPersistedEngineeringSnapshot['artifacts'] = [] + const analysisFile = (stepId: string, kind: string, reference: string) => { + const text = texts[reference]! + const artifactId = `artifact-${createHash('sha256') + .update(`${workspaceId}\0${reference}`) + .digest('hex') + .slice(0, 32)}` + artifacts.push({ + artifactId, + availability: 'available', + kind, + name: reference.split('/').at(-1)!, + reference, + sha256: createHash('sha256').update(text).digest('hex'), + sizeBytes: Buffer.byteLength(text), + stepId, + }) + return { artifactId, status: 'available' as const, data: JSON.parse(text) } + } + const analysis = { + steps: projectManagementWorkspaceStepAnalysisSpecs.map((spec, order) => ({ + stepId: spec.step, + toolId: spec.metricsPath.split('/')[0]!.split('_').at(-1)!, + order, + flowState: 'Success', + metrics: analysisFile(spec.step, 'qor_metrics', spec.metricsPath), + summary: analysisFile(spec.step, 'qor_summary', spec.summaryPath), + hotspots: analysisFile(spec.step, 'qor_hotspots', spec.hotspotsPath), + timingIssues: + spec.step === 'STA' + ? analysisFile( + spec.step, + 'sta_timing_issues', + projectManagementStaTimingIssuesPath, + ) + : null, + })), + } + const metrics = projectManifestFlowSteps.flatMap((step, stepIndex) => + stepMetrics(step, stepIndex, score > 80), + ) + return { + analysis, + artifacts, + checklist: {}, + flow: { + steps: projectManifestFlowSteps.map((name) => ({ name, state: 'Success' })), + }, + metrics, + parameters: {}, + qorAssessment: { + status: 'ready', + metrics, + score: { gate: 'pass', threshold: 60, value: score }, + steps: projectManifestFlowSteps.map((stepId, order) => { + const summaryMetricCount = metricCount(order) + return { name: stepId, order, status: 'pass', stepId, summaryMetricCount } + }), + }, + qorSnapshotExtension: { + schemaVersion: 1, + scoringEngine: 'qor-v3', + status: 'available', + score, + scalarStatus: score >= 60 ? 'GREEN' : 'RED', + profile: 'balanced', + qphys: { + timing: { value: score, state: score >= 60 ? 'PASS' : 'FAIL', featureIds: [] }, + }, + feasibility: { status: 'PASS', gates: [] }, + evidence: { + index: 100, + state: 'HIGH', + integrity: 1, + coverage: 1, + consistency: 1, + }, + diagnoses: [], + inflation: { + iPlace: null, + iRoute: null, + iTotal: null, + congestionSeverity: null, + compatibilityStatus: 'UNAVAILABLE', + }, + power: { totalUw: null, budgetUw: null, sourceKind: null, corner: null }, + artifactIds: [], + }, + schemaVersion: 1, + signoffAssessment: { groups: [], risks: [], status: 'ready' }, + workspaceId, + workspaceRevision: 14, + } +} + +function analysisTexts(candidate: number): Record { + const texts: Record = {} + for (const [stepIndex, spec] of projectManagementWorkspaceStepAnalysisSpecs.entries()) { + texts[spec.metricsPath] = JSON.stringify({ + schema_version: 3, + step: spec.step, + metrics: stepMetrics(spec.step, stepIndex, candidate === 1), + details: stepDetails(spec.step), + context: { + timing_constraints: { + sdc_sha256: 'a'.repeat(64), + source: featureSource(spec.step, '/context/timing_constraints'), + }, + }, + integrity: { + status: 'pass', + invalid_metric_source_ids: [], + invalid_detail_ids: [], + }, + }) + texts[spec.summaryPath] = JSON.stringify({ + schema_version: 4, + analysis_status: 'complete', + quality_status: 'pass', + gates: + spec.step === 'RCX' || spec.step === 'STA' + ? [{ id: `${spec.step.toLowerCase()}_ready`, state: 'pass', metrics: [] }] + : [], + missing_metrics: [], + }) + texts[spec.hotspotsPath] = JSON.stringify({ + schema_version: 3, + hotspots: + candidate === 1 && spec.step === 'Route' + ? [ + { + kind: 'congestion', + severity: 'warning', + metric_id: 'route_congestion', + display_name: 'Route congestion', + value: 0.82, + description: 'Congestion is concentrated near the macro channel.', + source: featureSource(spec.step, '/hotspots/0'), + }, + ] + : [], + }) + } + texts[projectManagementStaTimingIssuesPath] = JSON.stringify({ + schema_version: 1, + near_fail_slack_ns: 0.05, + missing_corners: [], + issues: [ + { + issue_id: 'setup-main', + severity: 'critical', + analysis_type: 'setup', + corner: 'typical', + path_group: 'reg2reg', + check_type: 'setup', + slack_ns: candidate === 1 ? -0.05 : -0.2, + launch_clock_network_delay_ns: 0.1, + capture_clock_network_delay_ns: 0.12, + clock_network_delay_delta_ns: 0.02, + }, + ], + artifact_paths: [ + { + corner: 'typical', + report_dir: 'sta_ecc/reports/typical', + feature_dir: 'sta_ecc/features/typical', + qor_summary_file: 'sta_ecc/analysis/qor_summary.json', + timing_paths_file: 'sta_ecc/analysis/sta_timing_paths.json', + }, + ], + }) + return texts +} + +function stepMetrics( + step: ProjectManifestFlowStep, + stepIndex: number, + candidate: boolean, +): EccEngineeringMetric[] { + const preferred = metricIds[step] ?? [] + return Array.from({ length: metricCount(stepIndex) }, (_, index) => { + const id = preferred[index] ?? `fixture_${step.toLowerCase()}_${index}` + const higherIsBetter = id.includes('wns') || id.includes('frequency') + const value = metricValue(id, stepIndex, index, candidate) + return { + id, + display_name: id.replaceAll('_', ' '), + value, + unit: id.includes('wns') || id.includes('tns') ? 'ns' : undefined, + category: id.startsWith('sta_') + ? 'timing' + : id.includes('area') || id.includes('utilization') + ? 'area_cost' + : 'routability_physical', + direction: higherIsBetter ? 'higher_is_better' : 'lower_is_better', + scope: 'design', + corner: step === 'STA' ? 'typical' : null, + corner_context: + step === 'STA' + ? { + configured_role: 'setup', + process_corner: 'tt', + voltage_v: 1.8, + temperature_c: 25, + rc_corner: 'typical', + label: 'TT 1.8V 25C', + } + : null, + analysis_group: step.toLowerCase(), + rating: { gate: index === 0, score: index < 3, trend: true }, + project_role: index < 3 ? 'final' : 'trend', + step_role: index === 0 ? 'primary' : index === 1 ? 'secondary' : 'detail', + confidence: index % 3 === 0 ? 'high' : index % 3 === 1 ? 'medium' : 'low', + source: featureSource(step, `/metrics/${index}`), + } + }) +} + +function metricCount(stepIndex: number): number { + return stepIndex === projectManifestFlowSteps.length - 1 ? 13 : 14 +} + +function metricValue( + id: string, + stepIndex: number, + metricIndex: number, + candidate: boolean, +): number { + if (id === 'drc_count' || id === 'lvs_count') return 0 + if (id === 'sta_setup_wns') return candidate ? -0.05 : -0.2 + if (id === 'sta_setup_tns') return candidate ? -0.5 : -1.5 + if (id === 'sta_hold_wns') return candidate ? 0.03 : -0.02 + if (id === 'sta_hold_tns') return candidate ? 0 : -0.1 + if (id === 'sta_frequency_mhz') return candidate ? 150 : 125 + return 1000 + stepIndex * 20 + metricIndex - (candidate ? 10 : 0) +} + +function stepDetails(step: ProjectManifestFlowStep) { + if (step === 'RCX') { + return [ + { + id: 'rcx-corners', + presentation: 'rcx_spef_corner_table', + summary: { rc_corners: [{ rc_corner: 'typical' }] }, + feature_source: featureSource(step, '/details/0'), + }, + ] + } + if (step === 'STA') { + return [ + { + id: 'sta-path-groups', + presentation: 'path_group_table', + summary: { + records: [ + { + path_group: 'reg2reg', + corner_context: { + configured_role: 'setup', + process_corner: 'tt', + voltage_v: 1.8, + temperature_c: 25, + rc_corner: 'typical', + label: 'TT 1.8V 25C', + }, + }, + ], + }, + feature_source: featureSource(step, '/details/0'), + }, + ] + } + return [] +} + +function featureSource(step: ProjectManifestFlowStep, selector: string) { + return { kind: 'feature', path: `feature/${step}.step.json`, selector } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.test.ts new file mode 100644 index 000000000..d052a9d37 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.test.ts @@ -0,0 +1,1141 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + projectManifestFlowSteps, + validateEngineeringSnapshot, + type EccEngineeringSnapshot, + type EccEngineeringMetric, + type EccPersistedEngineeringSnapshot, + type EccRuntimeOperation, + type ProjectManifest, +} from '@ecos-studio/shared' +import type { ProjectEngineeringSnapshotReadResult } from './projectManagementReadService' +import { electronLogger } from './logger' +import { representativeProjectComparisonFixture } from './backendProjectComparison.fixture' +import { BackendProjectComparisonService } from './backendProjectComparisonService' +import type { ProjectComparisonFileWatcherCallbacks } from './projectComparisonFileWatcher' + +class FakeProjectComparisonWatcher { + close = vi.fn(async () => undefined) + reconcile = vi.fn( + async (_projectRoot: string, _workspaceRoots: readonly string[]) => undefined, + ) + startProject = vi.fn(async (_projectRoot: string) => undefined) + + constructor(readonly callbacks: ProjectComparisonFileWatcherCallbacks) {} +} + +function watcherHarness(startError?: Error) { + let watcher: FakeProjectComparisonWatcher | null = null + return { + create: (callbacks: ProjectComparisonFileWatcherCallbacks) => { + watcher = new FakeProjectComparisonWatcher(callbacks) + if (startError) watcher.startProject.mockRejectedValueOnce(startError) + return watcher as never + }, + get current(): FakeProjectComparisonWatcher { + if (!watcher) throw new Error('watcher not created') + return watcher + }, + } +} + +function manifest(root = '/projects/demo'): ProjectManifest { + return { + schema_version: 1, + project_id: 'project-1', + name: 'demo', + design_name: 'gcd', + description: '', + root_path: root, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + base_design: {}, + objectives: { primary: 'timing', directions: {} }, + workspaces: [ + { + workspace_id: 'ws_1', + name: 'baseline', + workspace_path: `${root}/ws_1`, + source_workspace_id: null, + branch_from: null, + start_step: 'Synth', + end_step: 'Harden', + status: 'success', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + parameter_patch: {}, + metrics_summary: {}, + step_metrics: {}, + }, + { + workspace_id: 'ws_2', + name: 'candidate', + workspace_path: `${root}/ws_2`, + source_workspace_id: 'ws_1', + branch_from: { source_workspace_id: 'ws_1', source_step: 'Route' }, + start_step: 'Route', + end_step: 'Harden', + status: 'success', + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + parameter_patch: {}, + metrics_summary: {}, + step_metrics: {}, + }, + ], + mpc: null, + best_workspace: null, + qor_baseline: { workspace_id: 'ws_1', reason: 'reference' }, + } +} + +function engineeringSnapshot( + workspacePath: string, + step = 'Route', +): EccEngineeringSnapshot { + const value = workspacePath.endsWith('ws_1') ? 120 : 100 + const metric = { + analysis_group: 'route', + category: 'routability_physical' as const, + confidence: 'high' as const, + corner: null, + corner_context: null, + direction: 'lower_is_better' as const, + display_name: 'Wire length', + id: 'wire_length', + project_role: 'final' as const, + rating: { gate: false, score: true, trend: true }, + scope: 'route', + source: { kind: 'feature', path: 'feature/Route.step.json', selector: '/wire' }, + step_role: 'primary' as const, + value, + } + return { + analysis: { steps: [] }, + artifacts: [], + checklist: {}, + flow: { steps: [{ name: step, state: 'Success' }] }, + metrics: [metric], + parameters: {}, + qorAssessment: { + status: 'ready', + metrics: [metric], + score: { gate: 'pass', threshold: 60, value: 80 - value / 10 }, + steps: [ + { + name: 'Route', + order: 6, + status: 'pass', + stepId: 'Route', + summaryMetricCount: 1, + }, + ], + }, + schemaVersion: 1, + signoffAssessment: { groups: [], risks: [], status: 'ready' }, + workspaceId: workspacePath, + workspaceRevision: 1, + } +} + +function snapshotResult( + snapshot: EccEngineeringSnapshot, +): ProjectEngineeringSnapshotReadResult { + const validated = validateEngineeringSnapshot(snapshot) + if (!validated.ok) throw new Error(validated.issue.code) + return { ...validated, readBytes: Buffer.byteLength(JSON.stringify(snapshot)) } +} + +function appendSnapshotStepMetric( + snapshot: EccPersistedEngineeringSnapshot, + stepId: string, + metric: EccEngineeringMetric, +): void { + const metricsFile = snapshot.analysis.steps.find( + (step) => step.stepId === stepId, + )?.metrics + const metrics = metricsFile?.data?.metrics + if (!Array.isArray(metrics)) throw new Error(`missing ${stepId} Snapshot metrics`) + metrics.push(metric) +} + +function serviceFixture() { + const watchers = watcherHarness() + const project = manifest() + const readManifest = vi.fn().mockResolvedValue(project) + const readEngineeringSnapshot = vi + .fn() + .mockImplementation(async ({ workspacePath }) => + snapshotResult(engineeringSnapshot(workspacePath)), + ) + return { + project, + readEngineeringSnapshot, + readManifest, + service: new BackendProjectComparisonService( + { + readEngineeringSnapshot, + readManifest, + resolveProjectRoot: async (path) => path, + }, + watchers.create, + ), + watchers, + } +} + +function activeOperation( + overrides: Partial = {}, +): EccRuntimeOperation { + return { + cancelRequested: false, + createdAt: 1, + currentStep: 'Route', + currentTool: 'openroad', + error: null, + kind: 'step', + operationId: 'operation-1', + origin: 'gui', + rerun: false, + result: null, + state: 'running', + step: 'Route', + updatedAt: 2, + workspaceId: '/projects/demo/ws_1', + workspaceRevision: 1, + ...overrides, + } +} + +describe('BackendProjectComparisonService', () => { + afterEach(() => vi.restoreAllMocks()) + + it('builds a lightweight revision-matched execution overlay', async () => { + const fixture = serviceFixture() + const activeOperations = vi.fn(() => [ + activeOperation(), + activeOperation({ operationId: 'stale', workspaceRevision: 0 }), + activeOperation({ operationId: 'other', workspaceId: 'engineering-other' }), + ]) + const service = new BackendProjectComparisonService( + { + readEngineeringSnapshot: fixture.readEngineeringSnapshot, + readManifest: fixture.readManifest, + resolveProjectRoot: async (path) => path, + }, + fixture.watchers.create, + activeOperations, + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + await service.getComparison(11, selected.projectComparisonContextId) + const readsBeforeExecution = fixture.readEngineeringSnapshot.mock.calls.length + + await expect( + service.getExecutionSnapshot(11, selected.projectComparisonContextId), + ).resolves.toEqual({ + ok: true, + projectComparisonContextId: selected.projectComparisonContextId, + generation: 0, + data: { + operations: [ + expect.objectContaining({ + engineeringWorkspaceId: '/projects/demo/ws_1', + operationId: 'operation-1', + projectWorkspaceId: 'ws_1', + step: null, + workspaceRevision: 1, + }), + ], + }, + }) + expect(fixture.readEngineeringSnapshot).toHaveBeenCalledTimes(readsBeforeExecution) + expect(activeOperations).toHaveBeenCalledOnce() + }) + + it('invalidates execution snapshots for every selected window independently', async () => { + const fixture = serviceFixture() + const service = new BackendProjectComparisonService( + { + readEngineeringSnapshot: fixture.readEngineeringSnapshot, + readManifest: fixture.readManifest, + resolveProjectRoot: async (path) => path, + }, + fixture.watchers.create, + () => [], + ) + const first = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + const second = await service.selectProject(12, { + projectRootLocator: '/projects/demo', + }) + if (!first.ok || !second.ok) throw new Error('selection failed') + const listener = vi.fn() + service.onExecutionInvalidated(listener) + + service.invalidateExecution() + + expect(listener.mock.calls).toEqual([ + [ + 11, + { generation: 1, projectComparisonContextId: first.projectComparisonContextId }, + ], + [ + 12, + { generation: 1, projectComparisonContextId: second.projectComparisonContextId }, + ], + ]) + }) + + it('freezes representative Project Comparison behavior and deterministic read costs', async () => { + const fixture = representativeProjectComparisonFixture() + const readManifest = vi.fn().mockResolvedValue(fixture.manifest) + const readEngineeringSnapshot = vi + .fn() + .mockImplementation(async ({ workspacePath }) => { + const workspaceId = workspacePath.split('/').at(-1)! + return snapshotResult(fixture.engineeringSnapshots[workspaceId]!) + }) + const debug = vi.spyOn(electronLogger, 'debug').mockImplementation(() => undefined) + const watchers = watcherHarness() + const service = new BackendProjectComparisonService( + { + readEngineeringSnapshot, + readManifest, + resolveProjectRoot: async (path) => path, + }, + watchers.create, + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/gcd', + }) + if (!selected.ok) throw new Error('selection failed') + + const [first, coalesced] = await Promise.all([ + service.getComparison(11, selected.projectComparisonContextId), + service.getComparison(11, selected.projectComparisonContextId), + ]) + + expect(coalesced).toEqual(first) + if ( + !first.ok || + !('data' in first.data.workspaceSnapshots) || + !('data' in first.data.trend) || + !('data' in first.data.stepComparisons) + ) { + throw new Error('representative comparison was unavailable') + } + expect(first.data.identity).toEqual({ + projectId: 'proj_gcd', + projectName: 'gcd', + designName: 'gcd', + baselineWorkspaceId: 'ws_0001', + }) + const successfulFlow = Object.fromEntries( + projectManifestFlowSteps.map((step) => [step, 'success']), + ) + expect(first.data.workspaceSnapshots.data.flowStates).toEqual({ + ws_0001: successfulFlow, + ws_0002: successfulFlow, + }) + expect( + first.data.trend.data.workspaces.map((workspace) => ({ + id: workspace.workspaceId, + score: workspace.overallScore, + status: workspace.status, + metrics: workspace.comparisonRecords?.length, + signoff: workspace.signoffReadiness.status, + })), + ).toEqual([ + { id: 'ws_0001', score: 72, status: 'Green', metrics: 168, signoff: 'pass' }, + { id: 'ws_0002', score: 84, status: 'Green', metrics: 168, signoff: 'pass' }, + ]) + expect(first.data.trend.data.workspaces[1]?.qorSnapshotExtension).toMatchObject({ + scoringEngine: 'qor-v3', + score: 84, + }) + expect(first.ok && first.data.recommendation).toMatchObject({ + status: 'ready', + data: { workspaceId: 'ws_0002', score: 84 }, + }) + expect(first.ok && first.data.risks).toMatchObject({ + data: { + items: [expect.objectContaining({ workspaceId: 'ws_0002', step: 'Route' })], + }, + }) + expect(first.ok && first.data.timingTriage).toMatchObject({ + data: { + items: [ + expect.objectContaining({ + workspaceId: 'ws_0002', + baselineWorkspaceId: 'ws_0001', + issueId: 'setup-main', + state: 'improved', + }), + ], + }, + }) + expect(first.data.stepComparisons.data.steps).toHaveLength( + projectManifestFlowSteps.length, + ) + expect(first.data.workspaceSnapshots.data.items[1]?.steps.Route).toMatchObject({ + flowStatus: 'success', + metrics: expect.any(Array), + hotspots: [expect.objectContaining({ metric: 'route_congestion' })], + }) + expect(readEngineeringSnapshot).toHaveBeenCalledTimes(2) + expect(debug).toHaveBeenCalledWith( + '[backend-project-comparison] query metrics', + expect.objectContaining({ + coalescedRequests: 1, + fileCount: 2, + ipcPayloadBytes: expect.any(Number), + readBytes: expect.any(Number), + workspaceCount: 2, + }), + ) + expect(fixture.engineeringSnapshots.ws_0001.workspaceId).not.toBe('ws_0001') + }) + + it('preserves the complete Snapshot metric contract in Step Compare', async () => { + const fixture = representativeProjectComparisonFixture() + for (const [index, workspaceId] of ['ws_0001', 'ws_0002'].entries()) { + const metric = { + analysis_group: 'route_latency', + category: 'runtime' as const, + confidence: 'medium' as const, + corner: 'slow', + corner_context: { + configured_role: 'route', + label: 'SS 1.62V 125C', + process_corner: 'ss', + rc_corner: 'rcmax', + temperature_c: 125, + voltage_v: 1.62, + }, + direction: 'lower_is_better' as const, + display_name: 'Route Snapshot Latency', + id: 'snapshot_only_latency', + project_role: 'trend' as const, + rating: { gate: false, score: false, trend: true }, + scope: 'design', + source: { + kind: 'feature', + path: 'feature/Route.step.json', + selector: '/metrics/snapshot_only_latency', + }, + step_role: 'primary' as const, + unit: 'ms', + value: index === 0 ? 100 : 80, + } + appendSnapshotStepMetric( + fixture.engineeringSnapshots[workspaceId]!, + 'Route', + metric, + ) + appendSnapshotStepMetric(fixture.engineeringSnapshots[workspaceId]!, 'Route', { + ...metric, + display_name: 'Internal Route Counter', + id: 'snapshot_hidden_counter', + step_role: 'hidden', + }) + } + const candidateRoute = fixture.engineeringSnapshots.ws_0002!.analysis.steps.find( + (step) => step.stepId === 'Route', + ) + if (!candidateRoute) throw new Error('missing Route analysis') + candidateRoute.hotspots = { + artifactId: candidateRoute.hotspots.artifactId, + data: null, + reasonCode: 'ANALYSIS_FILE_INVALID', + status: 'invalid', + } + const service = new BackendProjectComparisonService( + { + readEngineeringSnapshot: async ({ workspacePath }) => + snapshotResult(fixture.engineeringSnapshots[workspacePath.split('/').at(-1)!]!), + readManifest: async () => fixture.manifest, + resolveProjectRoot: async (path) => path, + }, + watcherHarness().create, + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/gcd', + }) + if (!selected.ok) throw new Error('selection failed') + + const result = await service.getComparison(11, selected.projectComparisonContextId) + if (!result.ok || !('data' in result.data.stepComparisons)) { + throw new Error('Step Compare unavailable') + } + const route = result.data.stepComparisons.data.steps.find( + (step) => step.stepId === 'Route', + ) + const records = route?.workspaces.map((workspace) => + workspace.metrics.filter((metric) => metric.metricName.startsWith('snapshot_')), + ) + + expect(records).toEqual([ + [ + { + analysisGroup: 'route_latency', + baselineComparison: { + absoluteDelta: 0, + baselineValue: 100, + relativeDeltaPct: 0, + verdict: 'baseline', + }, + confidence: 'medium', + corner: 'slow', + cornerContext: { + configuredRole: 'route', + label: 'SS 1.62V 125C', + processCorner: 'ss', + rcCorner: 'rcmax', + temperatureC: 125, + voltageV: 1.62, + }, + dimension: 'runtime', + displayName: 'Route Snapshot Latency', + leads: false, + metricName: 'snapshot_only_latency', + polarity: 'lower_is_better', + projectRole: 'trend', + rating: { gate: false, score: false, trend: true }, + scope: 'design', + sourceFile: 'feature/Route.step.json', + step: 'Route', + stepRole: 'primary', + unit: 'ms', + value: 100, + verdict: 'pass', + workspaceId: 'ws_0001', + }, + ], + [ + expect.objectContaining({ + baselineComparison: { + absoluteDelta: -20, + baselineValue: 100, + relativeDeltaPct: -20, + verdict: 'improvement', + }, + leads: true, + metricName: 'snapshot_only_latency', + value: 80, + workspaceId: 'ws_0002', + }), + ], + ]) + }) + + it('keeps an invalid-QoR Workspace column but excludes it from metrics and ranking', async () => { + const { readEngineeringSnapshot, service } = serviceFixture() + readEngineeringSnapshot.mockImplementation(async ({ workspacePath }) => { + const snapshot = engineeringSnapshot(workspacePath) + if (workspacePath.endsWith('ws_2')) { + snapshot.metrics = [ + { + ...snapshot.metrics[0]!, + step_role: 'unknown', + } as unknown as EccEngineeringMetric, + ] + } + return snapshotResult(snapshot) + }) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + + const result = await service.getComparison(11, selected.projectComparisonContextId) + if ( + !result.ok || + !('data' in result.data.stepComparisons) || + !('data' in result.data.trend) || + !('data' in result.data.workspaceSnapshots) + ) { + throw new Error('comparison unavailable') + } + const route = result.data.stepComparisons.data.steps.find( + (step) => step.stepId === 'Route', + ) + + expect(route?.workspaces).toEqual([ + expect.objectContaining({ workspaceId: 'ws_1' }), + { workspaceId: 'ws_2', status: 'unavailable', metrics: [] }, + ]) + expect( + result.data.trend.data.workspaces.map((workspace) => workspace.workspaceId), + ).toEqual(['ws_1']) + expect(result.data.workspaceSnapshots.data.flowStates).toMatchObject({ + ws_1: { Route: 'success' }, + ws_2: { Route: 'success' }, + }) + }) + + it('does not project QoR v3 facts from a snapshot with stale predecessors', async () => { + const fixture = representativeProjectComparisonFixture() + fixture.engineeringSnapshots.ws_0002!.stalePredecessor = { + workspaceRevision: 13, + invalidatedStepIds: ['Route'], + } + const watchers = watcherHarness() + const service = new BackendProjectComparisonService( + { + readManifest: vi.fn().mockResolvedValue(fixture.manifest), + readEngineeringSnapshot: vi.fn().mockImplementation(async ({ workspacePath }) => { + const workspaceId = workspacePath.split('/').at(-1)! + return snapshotResult(fixture.engineeringSnapshots[workspaceId]!) + }), + resolveProjectRoot: async (path) => path, + }, + watchers.create, + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/gcd', + }) + if (!selected.ok) throw new Error('selection failed') + + const result = await service.getComparison(11, selected.projectComparisonContextId) + + expect(result.ok).toBe(true) + if (!result.ok || result.data.trend.status !== 'ready') return + expect(result.data.trend.data.workspaces[1]).not.toHaveProperty( + 'qorSnapshotExtension', + ) + }) + + it('marks an invalid QoR v3 extension unavailable without report fallback', async () => { + const fixture = representativeProjectComparisonFixture() + fixture.engineeringSnapshots.ws_0002!.qorSnapshotExtension!.profile = + 'invalid' as never + const watchers = watcherHarness() + const service = new BackendProjectComparisonService( + { + readManifest: vi.fn().mockResolvedValue(fixture.manifest), + readEngineeringSnapshot: vi.fn().mockImplementation(async ({ workspacePath }) => { + const workspaceId = workspacePath.split('/').at(-1)! + return snapshotResult(fixture.engineeringSnapshots[workspaceId]!) + }), + resolveProjectRoot: async (path) => path, + }, + watchers.create, + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/gcd', + }) + if (!selected.ok) throw new Error('selection failed') + + const result = await service.getComparison(11, selected.projectComparisonContextId) + + expect(result.ok).toBe(true) + if (!result.ok || result.data.trend.status !== 'ready') return + const candidate = result.data.trend.data.workspaces.find( + (workspace) => workspace.workspaceId === 'ws_0002', + ) + expect(candidate).toBeDefined() + expect(candidate).not.toHaveProperty('qorSnapshotExtension') + }) + + it('captures the no-HMR initial-load failure when a lifecycle event invalidates the query', async () => { + const { service } = serviceFixture() + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + + const query = service.getComparison(11, selected.projectComparisonContextId) + service.invalidateWorkspace('/projects/demo/ws_1') + + await expect(query).resolves.toEqual({ ok: false, code: 'unknown-context' }) + }) + + it('selects an opaque context and coalesces comparison reads in one generation', async () => { + const { readEngineeringSnapshot, readManifest, service, watchers } = serviceFixture() + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + expect(selected.ok).toBe(true) + if (!selected.ok) return + expect(selected.projectComparisonContextId).not.toContain('/projects/demo') + + const [left, right] = await Promise.all([ + service.getComparison(11, selected.projectComparisonContextId), + service.getComparison(11, selected.projectComparisonContextId), + ]) + expect(left).toEqual(right) + expect(left.ok && left.data.identity).toMatchObject({ + projectId: 'project-1', + baselineWorkspaceId: 'ws_1', + }) + expect( + left.ok && + (left.data.stepComparisons.status === 'ready' || + left.data.stepComparisons.status === 'partial') && + left.data.stepComparisons.data.steps, + ).toEqual(expect.arrayContaining([expect.objectContaining({ stepId: 'Route' })])) + expect(JSON.stringify(left)).not.toContain('/projects/demo/ws_') + expect(readEngineeringSnapshot).toHaveBeenCalledTimes(2) + expect(watchers.current.startProject.mock.invocationCallOrder[0]).toBeLessThan( + readManifest.mock.invocationCallOrder[0]!, + ) + expect(watchers.current.reconcile.mock.invocationCallOrder[0]).toBeLessThan( + readEngineeringSnapshot.mock.invocationCallOrder[0]!, + ) + + await expect( + service.getComparison(11, selected.projectComparisonContextId), + ).resolves.toEqual(left) + expect(readEngineeringSnapshot).toHaveBeenCalledTimes(2) + }) + + it('rereads only the changed Snapshot and ignores an unchanged revision', async () => { + const { readEngineeringSnapshot, service, watchers } = serviceFixture() + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + await service.getComparison(11, selected.projectComparisonContextId) + const invalidated = vi.fn() + service.onInvalidated(invalidated) + readEngineeringSnapshot.mockImplementation(async ({ workspacePath }) => { + const snapshot = engineeringSnapshot(workspacePath) + if (workspacePath.endsWith('ws_1')) snapshot.workspaceRevision = 2 + return snapshotResult(snapshot) + }) + + watchers.current.callbacks.onSnapshotChanged('/projects/demo/ws_1') + await vi.waitFor(() => expect(invalidated).toHaveBeenCalledOnce()) + await service.getComparison(11, selected.projectComparisonContextId) + expect(readEngineeringSnapshot).toHaveBeenCalledTimes(3) + + invalidated.mockClear() + watchers.current.callbacks.onSnapshotChanged('/projects/demo/ws_1') + await vi.waitFor(() => expect(readEngineeringSnapshot).toHaveBeenCalledTimes(4)) + expect(invalidated).not.toHaveBeenCalled() + + service.invalidateWorkspace('/projects/demo/ws_2') + expect(invalidated).toHaveBeenCalledOnce() + await service.getComparison(11, selected.projectComparisonContextId) + expect(readEngineeringSnapshot).toHaveBeenCalledTimes(5) + }) + + it('preserves verified data and reports watcher failure without retry polling', async () => { + const { service, watchers } = serviceFixture() + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + await service.getComparison(11, selected.projectComparisonContextId) + const invalidated = vi.fn() + service.onInvalidated(invalidated) + + watchers.current.callbacks.onError(new Error('watch failed')) + expect(invalidated).toHaveBeenCalledOnce() + const result = await service.getComparison(11, selected.projectComparisonContextId) + + expect(result.ok && result.data.refresh).toEqual({ + automatic: 'unavailable', + issue: { code: 'PROJECT_COMPARISON_AUTO_REFRESH_UNAVAILABLE' }, + }) + expect(watchers.current.startProject).toHaveBeenCalledOnce() + }) + + it('checks revisions on focus without invalidating unchanged data', async () => { + const { readEngineeringSnapshot, service } = serviceFixture() + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + await service.getComparison(11, selected.projectComparisonContextId) + const invalidated = vi.fn() + service.onInvalidated(invalidated) + + await service.checkForUpdates(11) + + expect(readEngineeringSnapshot).toHaveBeenCalledTimes(4) + expect(invalidated).not.toHaveBeenCalled() + }) + + it('reconciles Workspace watchers after a Manifest change and closes them on dispose', async () => { + const { project, readManifest, service, watchers } = serviceFixture() + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + await service.getComparison(11, selected.projectComparisonContextId) + readManifest.mockResolvedValue({ + ...project, + updated_at: '2026-01-03T00:00:00Z', + workspaces: project.workspaces.slice(0, 1), + }) + const invalidated = vi.fn() + service.onInvalidated(invalidated) + + watchers.current.callbacks.onManifestChanged() + await vi.waitFor(() => expect(invalidated).toHaveBeenCalledOnce()) + await service.getComparison(11, selected.projectComparisonContextId) + expect(watchers.current.reconcile).toHaveBeenLastCalledWith('/projects/demo', [ + '/projects/demo/ws_1', + ]) + + service.disposeWindow(11) + expect(watchers.current.close).toHaveBeenCalledOnce() + }) + + it('continues initial loading when watcher startup fails and uses a fresh watcher on reopen', async () => { + const readers = serviceFixture() + const failingWatchers = watcherHarness(new Error('watch unavailable')) + const service = new BackendProjectComparisonService( + { + readEngineeringSnapshot: readers.readEngineeringSnapshot, + readManifest: readers.readManifest, + resolveProjectRoot: async (path) => path, + }, + failingWatchers.create, + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + + const result = await service.getComparison(11, selected.projectComparisonContextId) + expect(result.ok && result.data.refresh.automatic).toBe('unavailable') + const failedWatcher = failingWatchers.current + + await service.selectProject(11, { projectRootLocator: '/projects/demo' }) + expect(failedWatcher.close).toHaveBeenCalledOnce() + expect(failingWatchers.current).not.toBe(failedWatcher) + }) + + it('releases a Project context only when the window and context identity match', async () => { + const { service, watchers } = serviceFixture() + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + + await service.closeProject(22, selected.projectComparisonContextId) + expect(watchers.current.close).not.toHaveBeenCalled() + await service.closeProject(11, selected.projectComparisonContextId) + + expect(watchers.current.close).toHaveBeenCalledOnce() + await expect( + service.getComparison(11, selected.projectComparisonContextId), + ).resolves.toEqual({ ok: false, code: 'unknown-context' }) + }) + + it('keeps readable workspaces when one Engineering Snapshot is invalid', async () => { + const { service, readEngineeringSnapshot } = serviceFixture() + readEngineeringSnapshot.mockResolvedValueOnce({ + ok: false, + readBytes: 0, + issue: { code: 'ENGINEERING_SNAPSHOT_INVALID' }, + }) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + + const result = await service.getComparison(11, selected.projectComparisonContextId) + expect(result.ok && result.data.workspaceSnapshots.status).toBe('partial') + expect( + result.ok && + result.data.workspaceSnapshots.status === 'partial' && + result.data.workspaceSnapshots.data.items, + ).toHaveLength(1) + expect(result.ok && result.data.workspaceSnapshots.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'ENGINEERING_SNAPSHOT_INVALID' }), + ]), + ) + }) + + it('rejects a changed Engineering Workspace identity at the same Project locator', async () => { + const { service, readEngineeringSnapshot } = serviceFixture() + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + await service.getComparison(11, selected.projectComparisonContextId) + readEngineeringSnapshot.mockImplementation(async ({ workspacePath }) => { + const snapshot = engineeringSnapshot(workspacePath) + if (workspacePath.endsWith('ws_1')) snapshot.workspaceId = 'replacement-id' + return snapshotResult(snapshot) + }) + + const result = await service.refreshComparison( + 11, + selected.projectComparisonContextId, + ) + + expect(result.ok && result.data.workspaceSnapshots).toMatchObject({ + status: 'partial', + issues: expect.arrayContaining([ + expect.objectContaining({ code: 'ENGINEERING_WORKSPACE_ID_MISMATCH' }), + ]), + }) + }) + + it('rejects a manifest that redirects the selected Project root', async () => { + const { service, readManifest } = serviceFixture() + readManifest.mockResolvedValue(manifest('/projects/other')) + + await expect( + service.selectProject(11, { projectRootLocator: '/projects/demo' }), + ).resolves.toMatchObject({ ok: false, code: 'invalid-project' }) + }) + + it('preserves an unknown Flow Step as an opaque comparison identity', async () => { + const { readEngineeringSnapshot, service } = serviceFixture() + readEngineeringSnapshot.mockImplementation(async ({ workspacePath }) => + snapshotResult(engineeringSnapshot(workspacePath, 'CustomSignoff')), + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + + const result = await service.getComparison(11, selected.projectComparisonContextId) + expect( + result.ok && + (result.data.stepComparisons.status === 'ready' || + result.data.stepComparisons.status === 'partial') && + result.data.stepComparisons.data.steps, + ).toEqual( + expect.arrayContaining([expect.objectContaining({ stepId: 'CustomSignoff' })]), + ) + }) + + it('reports the configured baseline when it is unavailable', async () => { + const { service, project, readManifest } = serviceFixture() + readManifest.mockResolvedValue({ + ...project, + qor_baseline: { workspace_id: 'ws_missing', reason: 'configured' }, + }) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/demo', + }) + if (!selected.ok) throw new Error('selection failed') + + const result = await service.getComparison(11, selected.projectComparisonContextId) + expect(result.ok && result.data.trend).toMatchObject({ + status: 'partial', + issues: [{ code: 'PROJECT_BASELINE_UNAVAILABLE', detail: 'ws_missing' }], + }) + }) + + it('invalidates only contexts that depend on the committed workspace', async () => { + const first = serviceFixture() + const secondProject = manifest('/projects/other') + first.readManifest + .mockResolvedValueOnce(first.project) + .mockResolvedValueOnce(secondProject) + const events: Array<{ windowId: number; generation: number }> = [] + first.service.onInvalidated((windowId, event) => { + events.push({ windowId, generation: event.generation }) + }) + await first.service.selectProject(11, { projectRootLocator: '/projects/demo' }) + await first.service.selectProject(22, { projectRootLocator: '/projects/other' }) + + first.service.invalidateWorkspace('/projects/demo/ws_2') + expect(events).toEqual([{ windowId: 11, generation: 1 }]) + }) + + it('lazy-loads only declared Step Findings and keeps same-revision verified cache', async () => { + const fixture = representativeProjectComparisonFixture() + const readVerifiedArtifacts = vi.fn().mockResolvedValue({ ok: true, texts: {} }) + const service = new BackendProjectComparisonService( + { + readEngineeringSnapshot: async ({ workspacePath }) => + snapshotResult(fixture.engineeringSnapshots[workspacePath.split('/').at(-1)!]!), + readManifest: async () => fixture.manifest, + readVerifiedArtifacts, + resolveProjectRoot: async (path) => path, + }, + watcherHarness().create, + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/gcd', + }) + if (!selected.ok) throw new Error('selection failed') + await service.getComparison(11, selected.projectComparisonContextId) + + const current = await service.getStepFindings(11, { + projectComparisonContextId: selected.projectComparisonContextId, + projectWorkspaceId: 'ws_0002', + step: 'Route', + }) + + expect(current).toMatchObject({ + ok: true, + freshness: 'current', + data: { + engineeringWorkspaceId: fixture.engineeringSnapshots.ws_0002!.workspaceId, + projectWorkspaceId: 'ws_0002', + step: 'Route', + workspaceRevision: 14, + details: { + metrics: expect.arrayContaining([ + expect.objectContaining({ + baselineComparison: expect.objectContaining({ verdict: 'improvement' }), + }), + ]), + }, + }, + }) + expect( + readVerifiedArtifacts.mock.calls[0]?.[0].artifacts.map( + (artifact: { reference: string }) => artifact.reference, + ), + ).toEqual([ + 'route_ecc/analysis/qor_metrics.json', + 'route_ecc/analysis/qor_summary.json', + 'route_ecc/analysis/qor_hotspots.json', + ]) + + readVerifiedArtifacts.mockResolvedValueOnce({ + ok: false, + code: 'ARTIFACT_REVISION_MISMATCH', + reference: 'route_ecc/analysis/qor_metrics.json', + }) + await expect( + service.getStepFindings(11, { + projectComparisonContextId: selected.projectComparisonContextId, + projectWorkspaceId: 'ws_0002', + step: 'Route', + }), + ).resolves.toMatchObject({ + ok: true, + freshness: 'last-committed', + issue: { code: 'ARTIFACT_REVISION_MISMATCH' }, + }) + + fixture.engineeringSnapshots.ws_0002!.workspaceRevision = 15 + await service.refreshComparison(11, selected.projectComparisonContextId) + readVerifiedArtifacts.mockResolvedValueOnce({ + ok: false, + code: 'ARTIFACT_REVISION_MISMATCH', + reference: 'route_ecc/analysis/qor_metrics.json', + }) + await expect( + service.getStepFindings(11, { + projectComparisonContextId: selected.projectComparisonContextId, + projectWorkspaceId: 'ws_0002', + step: 'Route', + }), + ).resolves.toEqual({ + ok: false, + code: 'ARTIFACT_REVISION_MISMATCH', + detail: 'route_ecc/analysis/qor_metrics.json', + }) + }) + + it('rejects undeclared Findings and a Snapshot revision changed during the read', async () => { + const fixture = representativeProjectComparisonFixture() + let finishRead!: (value: { ok: true; texts: {} }) => void + const readVerifiedArtifacts = vi.fn( + (_request: { artifacts: unknown[] }) => + new Promise<{ ok: true; texts: {} }>((resolveRead) => { + finishRead = resolveRead + }), + ) + const service = new BackendProjectComparisonService( + { + readEngineeringSnapshot: async ({ workspacePath }) => + snapshotResult(fixture.engineeringSnapshots[workspacePath.split('/').at(-1)!]!), + readManifest: async () => fixture.manifest, + readVerifiedArtifacts, + resolveProjectRoot: async (path) => path, + }, + watcherHarness().create, + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/gcd', + }) + if (!selected.ok) throw new Error('selection failed') + await service.getComparison(11, selected.projectComparisonContextId) + + await expect( + service.getStepFindings(11, { + projectComparisonContextId: selected.projectComparisonContextId, + projectWorkspaceId: 'other', + step: 'Route', + }), + ).resolves.toEqual({ ok: false, code: 'FINDINGS_WORKSPACE_UNAVAILABLE' }) + await expect( + service.getStepFindings(11, { + projectComparisonContextId: selected.projectComparisonContextId, + projectWorkspaceId: 'ws_0001', + step: 'Future', + }), + ).resolves.toEqual({ ok: false, code: 'FINDINGS_STEP_UNAVAILABLE' }) + + const pending = service.getStepFindings(11, { + projectComparisonContextId: selected.projectComparisonContextId, + projectWorkspaceId: 'ws_0001', + step: 'STA', + }) + service.invalidateWorkspace('/projects/gcd/ws_0001') + finishRead({ ok: true, texts: {} }) + + await expect(pending).resolves.toEqual({ + ok: false, + code: 'FINDINGS_SNAPSHOT_REVISION_CHANGED', + }) + expect(readVerifiedArtifacts.mock.calls.at(-1)?.[0].artifacts).toHaveLength(4) + }) + + it('rejects each missing Step Findings declaration without reading another detail', async () => { + for (const [stepId, field] of [ + ['Route', 'metrics'], + ['Route', 'summary'], + ['Route', 'hotspots'], + ['STA', 'timingIssues'], + ] as const) { + const fixture = representativeProjectComparisonFixture() + const analysisStep = fixture.engineeringSnapshots.ws_0001!.analysis.steps.find( + (step) => step.stepId === stepId, + )! + const declared = analysisStep[field] + if (!declared) throw new Error(`missing ${stepId} ${field}`) + analysisStep[field] = { + artifactId: declared.artifactId, + data: null, + reasonCode: 'ANALYSIS_FILE_MISSING', + status: 'missing', + } + const readVerifiedArtifacts = vi.fn() + const service = new BackendProjectComparisonService( + { + readEngineeringSnapshot: async ({ workspacePath }) => + snapshotResult( + fixture.engineeringSnapshots[workspacePath.split('/').at(-1)!]!, + ), + readManifest: async () => fixture.manifest, + readVerifiedArtifacts, + resolveProjectRoot: async (path) => path, + }, + watcherHarness().create, + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/gcd', + }) + if (!selected.ok) throw new Error('selection failed') + await service.getComparison(11, selected.projectComparisonContextId) + + await expect( + service.getStepFindings(11, { + projectComparisonContextId: selected.projectComparisonContextId, + projectWorkspaceId: 'ws_0001', + step: stepId, + }), + ).resolves.toEqual({ ok: false, code: 'ARTIFACT_REFERENCE_MISSING' }) + expect(readVerifiedArtifacts).not.toHaveBeenCalled() + } + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.ts b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.ts new file mode 100644 index 000000000..67f495acc --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.ts @@ -0,0 +1,725 @@ +import { randomUUID } from 'node:crypto' +import { realpath } from 'node:fs/promises' +import { resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { + type BackendProjectComparison, + type BackendProjectComparisonInvalidatedEvent, + type BackendProjectComparisonQueryResult, + type BackendProjectComparisonSelectResult, + type BackendProjectExecutionSnapshotResult, + type BackendProjectStepFindingsResult, + type EccRuntimeOperation, + type ProjectManifest, + type ReadIssue, +} from '@ecos-studio/shared' +import { projectQorInputForWorkspace, workspaceFlowStates } from './workspaceQorAnalysis' +import { electronLogger } from './logger' +import { isPathWithinRoot } from './pathScope' +import { mapWithConcurrency } from './boundedConcurrency' +import type { + ProjectEngineeringSnapshotReadResult, + VerifiedProjectArtifactsReadResult, +} from './projectManagementReadService' +import { + ProjectComparisonFileWatcher, + type ProjectComparisonFileWatcherCallbacks, +} from './projectComparisonFileWatcher' +import { + ProjectExecutionOverlay, + type CommittedProjectWorkspace, +} from './projectExecutionOverlay' +import { ProjectStepFindingsService } from './projectStepFindingsService' +import { projectComparisonEvidence } from './projectComparisonEvidence' +import { + buildProjectComparisonSnapshots, + buildProjectComparisonSteps, + buildProjectComparisonTrend, + comparisonSection, + selectRecommendation, + type ProjectComparisonInput, + workspaceIssue, +} from './projectComparisonProjection' + +interface ProjectComparisonReader { + resolveProjectRoot?(projectRoot: string): Promise + readManifest(projectRoot: string): Promise + readEngineeringSnapshot(request: { + projectRoot: string + workspacePath: string + }): Promise + readVerifiedArtifacts?(request: { + projectRoot: string + workspacePath: string + artifacts: Array<{ reference: string; sha256: string; sizeBytes: number }> + }): Promise +} + +interface ProjectComparisonContext { + id: string + windowId: number + projectRoot: string + generation: number + dependencies: Set + cache: BackendProjectComparisonQueryResult | null + inFlight: Promise | null + coalescedRequests: number + engineeringWorkspaceIds: Map + manifestFingerprint: string + snapshotCache: Map + watcher: ProjectComparisonFileWatcher + watcherIssue: ReadIssue | null +} + +interface WorkspaceComparisonEntry { + engineeringWorkspaceId?: string + executionWorkspace?: CommittedProjectWorkspace + identityKey?: string + input?: ProjectComparisonInput + issues: ReadIssue[] +} + +type ProjectComparisonFileWatcherFactory = ( + callbacks: ProjectComparisonFileWatcherCallbacks, +) => ProjectComparisonFileWatcher + +type InvalidationListener = ( + windowId: number, + event: BackendProjectComparisonInvalidatedEvent, +) => void + +export class BackendProjectComparisonService { + private readonly contextsByWindow = new Map() + private readonly listeners = new Set() + private readonly execution: ProjectExecutionOverlay + private readonly findings: ProjectStepFindingsService + + constructor( + private readonly reader: ProjectComparisonReader, + private readonly createWatcher: ProjectComparisonFileWatcherFactory = (callbacks) => + new ProjectComparisonFileWatcher(callbacks), + activeOperations: () => EccRuntimeOperation[] = () => [], + ) { + this.execution = new ProjectExecutionOverlay(activeOperations) + this.findings = new ProjectStepFindingsService({ + readVerifiedArtifacts: (request) => + this.reader.readVerifiedArtifacts?.(request) ?? + Promise.resolve({ ok: false, code: 'FINDINGS_READ_FAILED', reference: '' }), + }) + } + + async selectProject( + windowId: number, + request: { projectRootLocator: string }, + ): Promise { + const previous = this.contextsByWindow.get(windowId) + if (previous) { + this.contextsByWindow.delete(windowId) + this.execution.unregister(previous.id) + this.findings.unregister(previous.id) + await previous.watcher.close() + } + let watcher: ProjectComparisonFileWatcher | null = null + try { + const projectRoot = await (this.reader.resolveProjectRoot ?? realpath)( + request.projectRootLocator, + ) + let context: ProjectComparisonContext | null = null + let manifestChangedBeforeRead = false + let watcherIssue: ReadIssue | null = null + watcher = this.createWatcher({ + onError: () => { + watcherIssue = autoRefreshIssue() + if (context) this.markWatcherUnavailable(context) + }, + onManifestChanged: () => { + if (context) void this.handleManifestChanged(context) + else manifestChangedBeforeRead = true + }, + onSnapshotChanged: (workspaceRoot) => { + if (context) void this.handleSnapshotChanged(context, workspaceRoot) + }, + }) + try { + await watcher.startProject(projectRoot) + } catch { + watcherIssue = autoRefreshIssue() + } + let manifest: ProjectManifest + do { + manifestChangedBeforeRead = false + manifest = await this.readManifest(projectRoot) + } while (manifestChangedBeforeRead) + context = { + id: randomUUID(), + windowId, + projectRoot, + generation: 0, + dependencies: new Set( + manifest.workspaces.map((workspace) => resolve(workspace.workspace_path)), + ), + cache: null, + inFlight: null, + coalescedRequests: 0, + engineeringWorkspaceIds: new Map(), + manifestFingerprint: manifestFingerprint(manifest), + snapshotCache: new Map(), + watcher, + watcherIssue, + } + try { + await watcher.reconcile( + projectRoot, + manifest.workspaces.map((workspace) => workspace.workspace_path), + ) + } catch { + context.watcherIssue = autoRefreshIssue() + } + this.contextsByWindow.set(windowId, context) + this.execution.register(windowId, context.id) + this.findings.register(windowId, context.id, projectRoot) + return { + ok: true, + projectComparisonContextId: context.id, + generation: context.generation, + } + } catch (error) { + await watcher?.close() + return failure('invalid-project', error) + } + } + + getComparison( + windowId: number, + projectComparisonContextId: string, + ): Promise { + const context = this.context(windowId, projectComparisonContextId) + if (!context) return Promise.resolve({ ok: false, code: 'unknown-context' }) + if (context.cache) return Promise.resolve(context.cache) + if (context.inFlight) { + context.coalescedRequests += 1 + return context.inFlight + } + + const generation = context.generation + const query = this.buildComparison(context, generation).then((result) => { + if (this.isCurrent(context, generation)) context.cache = result + return result + }) + const inFlight = query.finally(() => { + if (context.inFlight === inFlight) context.inFlight = null + }) + context.inFlight = inFlight + return inFlight + } + + refreshComparison( + windowId: number, + projectComparisonContextId: string, + ): Promise { + const context = this.context(windowId, projectComparisonContextId) + if (!context) return Promise.resolve({ ok: false, code: 'unknown-context' }) + context.snapshotCache.clear() + context.generation += 1 + this.findings.invalidate(context.id, context.generation) + context.cache = null + context.inFlight = null + return this.getComparison(windowId, projectComparisonContextId) + } + + getExecutionSnapshot( + windowId: number, + projectComparisonContextId: string, + ): Promise { + return Promise.resolve(this.execution.get(windowId, projectComparisonContextId)) + } + + getStepFindings( + windowId: number, + request: { + projectComparisonContextId: string + projectWorkspaceId: string + step: string + }, + ): Promise { + return this.findings.get(windowId, request) + } + + invalidateExecution(): void { + this.execution.invalidate() + } + + onExecutionInvalidated( + listener: Parameters[0], + ): () => void { + return this.execution.onInvalidated(listener) + } + + invalidateWorkspace(workspaceRoot: string): void { + const dependency = resolve(workspaceRoot) + for (const context of this.contextsByWindow.values()) { + if ( + ![...context.dependencies].some( + (root) => dependency === root || dependency.startsWith(`${root}/`), + ) + ) + continue + for (const root of context.dependencies) { + if (dependency === root || dependency.startsWith(`${root}/`)) { + context.snapshotCache.delete(root) + } + } + this.invalidateContext(context) + } + } + + invalidateProject(projectRoot: string): void { + const root = resolve(projectRoot) + for (const context of this.contextsByWindow.values()) { + if (context.projectRoot !== root) continue + this.invalidateContext(context) + } + } + + private invalidateContext(context: ProjectComparisonContext): void { + context.generation += 1 + this.findings.invalidate(context.id, context.generation) + context.cache = null + context.inFlight = null + const event = { + projectComparisonContextId: context.id, + generation: context.generation, + } + for (const listener of this.listeners) listener(context.windowId, event) + } + + onInvalidated(listener: InvalidationListener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + async closeProject( + windowId: number, + projectComparisonContextId: string, + ): Promise { + const context = this.context(windowId, projectComparisonContextId) + if (!context) return + this.contextsByWindow.delete(windowId) + this.execution.unregister(context.id) + this.findings.unregister(context.id) + await context.watcher.close() + } + + disposeWindow(windowId: number): void { + const context = this.contextsByWindow.get(windowId) + this.contextsByWindow.delete(windowId) + if (context) { + this.execution.unregister(context.id) + this.findings.unregister(context.id) + void context.watcher.close() + } + } + + async checkForUpdates(windowId: number): Promise { + const context = this.contextsByWindow.get(windowId) + if (!context || (await this.handleManifestChanged(context))) return + await Promise.all( + [...context.dependencies].map((workspaceRoot) => + this.handleSnapshotChanged(context, workspaceRoot), + ), + ) + } + + private context(windowId: number, id: string): ProjectComparisonContext | null { + const context = this.contextsByWindow.get(windowId) + return context?.id === id ? context : null + } + + private isCurrent(context: ProjectComparisonContext, generation: number): boolean { + return ( + this.contextsByWindow.get(context.windowId) === context && + context.generation === generation + ) + } + + private markWatcherUnavailable(context: ProjectComparisonContext): void { + if (context.watcherIssue) return + context.watcherIssue = autoRefreshIssue() + if (context.cache) this.invalidateContext(context) + } + + private async handleManifestChanged( + context: ProjectComparisonContext, + ): Promise { + if (this.contextsByWindow.get(context.windowId) !== context) return false + try { + const manifest = await this.readManifest(context.projectRoot) + if (this.contextsByWindow.get(context.windowId) !== context) return false + const fingerprint = manifestFingerprint(manifest) + if (fingerprint === context.manifestFingerprint) return false + context.manifestFingerprint = fingerprint + this.invalidateContext(context) + return true + } catch { + if (this.contextsByWindow.get(context.windowId) === context) { + this.invalidateContext(context) + } + return true + } + } + + private async handleSnapshotChanged( + context: ProjectComparisonContext, + workspaceRoot: string, + ): Promise { + if (this.contextsByWindow.get(context.windowId) !== context) return + const key = resolve(workspaceRoot) + const previous = context.snapshotCache.get(key) + const next = await this.reader.readEngineeringSnapshot({ + projectRoot: context.projectRoot, + workspacePath: key, + }) + if (this.contextsByWindow.get(context.windowId) !== context) return + context.snapshotCache.set(key, next) + if (!previous || snapshotRevision(previous) === snapshotRevision(next)) return + this.invalidateContext(context) + } + + private async readManifest(projectRoot: string): Promise { + const manifest = await this.reader.readManifest(projectRoot) + if (!manifest) throw new Error('Project manifest does not exist.') + const manifestRoot = await (this.reader.resolveProjectRoot ?? realpath)( + manifest.root_path, + ) + if (manifestRoot !== projectRoot) { + throw new Error('Project manifest root_path does not match the selected project.') + } + for (const workspace of manifest.workspaces) { + const workspacePath = resolve(workspace.workspace_path) + if ( + workspacePath === projectRoot || + !isPathWithinRoot(workspacePath, projectRoot) + ) { + throw new Error('Project manifest contains an invalid workspace path.') + } + } + return manifest + } + + private async buildComparison( + context: ProjectComparisonContext, + generation: number, + ): Promise { + const startedAt = performance.now() + const eventLoopDelay = eventLoopDelayMs() + try { + const manifest = await this.readManifest(context.projectRoot) + context.manifestFingerprint = manifestFingerprint(manifest) + try { + await context.watcher.reconcile( + context.projectRoot, + manifest.workspaces.map((workspace) => workspace.workspace_path), + ) + } catch { + this.markWatcherUnavailable(context) + } + let readBytes = 0 + let readFileCount = 0 + let unavailableFileCount = 0 + const readStartedAt = performance.now() + const entries: WorkspaceComparisonEntry[] = await mapWithConcurrency( + manifest.workspaces, + 2, + async (workspace) => { + try { + const snapshotKey = resolve(workspace.workspace_path) + let snapshotResult = context.snapshotCache.get(snapshotKey) + if (!snapshotResult) { + snapshotResult = await this.reader.readEngineeringSnapshot({ + projectRoot: context.projectRoot, + workspacePath: workspace.workspace_path, + }) + context.snapshotCache.set(snapshotKey, snapshotResult) + readFileCount += 1 + readBytes += snapshotResult.readBytes + } + if (!snapshotResult.ok) { + unavailableFileCount += 1 + return { + issues: [workspaceIssue(workspace.workspace_id, snapshotResult.issue)], + } + } + const identityKey = engineeringIdentityKey( + workspace.workspace_id, + workspace.workspace_path, + ) + const expectedEngineeringWorkspaceId = + context.engineeringWorkspaceIds.get(identityKey) + if ( + expectedEngineeringWorkspaceId && + expectedEngineeringWorkspaceId !== snapshotResult.snapshot.workspaceId + ) { + unavailableFileCount += 1 + return { + issues: [ + workspaceIssue(workspace.workspace_id, { + code: 'ENGINEERING_WORKSPACE_ID_MISMATCH', + }), + ], + } + } + const sectionIssues = Object.values(snapshotResult.sections).flatMap( + (section) => section.issues, + ) + unavailableFileCount += sectionIssues.length + const envelope = snapshotResult.snapshot + const flow = + snapshotResult.sections.flow.status === 'ready' + ? snapshotResult.sections.flow.data + : undefined + const stepStatuses = workspaceFlowStates(flow) + const entry = { + ...(flow + ? { + executionWorkspace: { + engineeringWorkspaceId: envelope.workspaceId, + projectWorkspaceId: workspace.workspace_id, + stepStatuses, + workspaceRevision: envelope.workspaceRevision, + } satisfies CommittedProjectWorkspace, + } + : {}), + engineeringWorkspaceId: envelope.workspaceId, + identityKey, + issues: sectionIssues.map((issue) => + workspaceIssue(workspace.workspace_id, issue), + ), + } + if (snapshotResult.sections.qor.status !== 'ready') return entry + const qor = snapshotResult.sections.qor.data + const qorSnapshotExtension = + !snapshotResult.snapshot.stalePredecessor && + snapshotResult.sections.qorSnapshotExtension.status === 'ready' + ? snapshotResult.sections.qorSnapshotExtension.data + : undefined + const engineeringFacts = { + analysis: qor.analysis, + metrics: qor.metrics, + qorAssessment: qor.qorAssessment, + ...(qorSnapshotExtension ? { qorSnapshotExtension } : {}), + ...(flow ? { flow } : {}), + ...(snapshotResult.sections.signoff.status === 'ready' + ? { signoffAssessment: snapshotResult.sections.signoff.data } + : {}), + } + const input = projectQorInputForWorkspace( + manifest, + workspace.workspace_id, + engineeringFacts, + ) + return input + ? { + ...entry, + input, + } + : { + ...entry, + issues: [...entry.issues, workspaceIssue(workspace.workspace_id)], + } + } catch (error) { + unavailableFileCount += 1 + return { issues: [workspaceIssue(workspace.workspace_id, error)] } + } + }, + ) + if (!this.isCurrent(context, generation)) { + return { ok: false, code: 'unknown-context' } + } + + this.execution.setCommittedWorkspaces( + context.id, + entries.flatMap((entry) => + entry.executionWorkspace ? [entry.executionWorkspace] : [], + ), + ) + + context.dependencies = new Set( + manifest.workspaces.map((workspace) => resolve(workspace.workspace_path)), + ) + for (const key of context.snapshotCache.keys()) { + if (!context.dependencies.has(key)) context.snapshotCache.delete(key) + } + const currentIdentityKeys = new Set( + manifest.workspaces.map((workspace) => + engineeringIdentityKey(workspace.workspace_id, workspace.workspace_path), + ), + ) + for (const key of context.engineeringWorkspaceIds.keys()) { + if (!currentIdentityKeys.has(key)) context.engineeringWorkspaceIds.delete(key) + } + for (const entry of entries) { + if (entry.identityKey && entry.engineeringWorkspaceId) { + context.engineeringWorkspaceIds.set( + entry.identityKey, + entry.engineeringWorkspaceId, + ) + } + } + const inputs = entries.flatMap((entry) => (entry.input ? [entry.input] : [])) + const issues = entries.flatMap((entry) => entry.issues) + const baselineWorkspaceId = manifest.qor_baseline?.workspace_id + if ( + baselineWorkspaceId && + !inputs.some((input) => input.workspaceId === baselineWorkspaceId) + ) { + issues.push({ + code: 'PROJECT_BASELINE_UNAVAILABLE', + detail: baselineWorkspaceId, + }) + } + const readMs = performance.now() - readStartedAt + const analysisStartedAt = performance.now() + const trend = buildProjectComparisonTrend( + inputs, + manifest.qor_baseline?.workspace_id ?? null, + ) + const snapshots = buildProjectComparisonSnapshots(inputs) + const flowStates = Object.fromEntries( + entries.flatMap((entry) => + entry.executionWorkspace + ? [ + [ + entry.executionWorkspace.projectWorkspaceId, + entry.executionWorkspace.stepStatuses, + ], + ] + : [], + ), + ) + const stepComparisons = buildProjectComparisonSteps( + manifest, + inputs, + trend, + flowStates, + ) + const analysisByWorkspace = new Map( + snapshots.map((snapshot) => [snapshot.workspaceId, snapshot]), + ) + this.findings.commit( + context.id, + generation, + manifest.workspaces.flatMap((workspace) => { + const snapshot = context.snapshotCache.get(resolve(workspace.workspace_path)) + const analysis = analysisByWorkspace.get(workspace.workspace_id) + if (!snapshot?.ok || !analysis) return [] + const evidence = projectComparisonEvidence( + snapshot, + manifest, + workspace, + analysis, + stepComparisons, + ) + return evidence ? [evidence] : [] + }), + ) + const recommendation = selectRecommendation(trend.workspaces) + const data: BackendProjectComparison = { + identity: { + projectId: manifest.project_id, + projectName: manifest.name, + designName: manifest.design_name, + ...(manifest.qor_baseline?.workspace_id + ? { baselineWorkspaceId: manifest.qor_baseline.workspace_id } + : {}), + }, + refresh: context.watcherIssue + ? { automatic: 'unavailable', issue: context.watcherIssue } + : { automatic: 'available' }, + trend: comparisonSection(trend, issues), + workspaceSnapshots: comparisonSection({ items: snapshots, flowStates }, issues), + stepComparisons: comparisonSection({ steps: stepComparisons }, issues), + recommendation: recommendation + ? comparisonSection(recommendation, issues) + : { + status: 'unavailable', + issues: [...issues, { code: 'NO_ELIGIBLE_WORKSPACE' }], + }, + risks: comparisonSection({ items: trend.risks }, issues), + timingTriage: comparisonSection({ items: trend.timingClosure.triage }, issues), + } + const result: BackendProjectComparisonQueryResult = { + ok: true, + projectComparisonContextId: context.id, + generation, + data, + } + electronLogger.debug('[backend-project-comparison] query metrics', { + analysisMs: roundMs(performance.now() - analysisStartedAt), + coalescedRequests: context.coalescedRequests, + eventLoopDelayMs: roundMs(await eventLoopDelay), + fileCount: readFileCount, + ipcPayloadBytes: Buffer.byteLength(JSON.stringify(result)), + readBytes, + readMs: roundMs(readMs), + totalMs: roundMs(performance.now() - startedAt), + unavailableFileCount, + workspaceCount: manifest.workspaces.length, + }) + context.coalescedRequests = 0 + return result + } catch (error) { + return queryFailure('read-failed', error) + } + } +} + +function snapshotRevision(result: ProjectEngineeringSnapshotReadResult): string { + return result.ok + ? `${result.snapshot.workspaceId}:${result.snapshot.workspaceRevision}` + : `${result.issue.code}:${result.issue.actualSizeBytes ?? ''}:${result.issue.allowedSizeBytes ?? ''}` +} + +function manifestFingerprint(manifest: ProjectManifest): string { + return JSON.stringify(manifest) +} + +function autoRefreshIssue(): ReadIssue { + return { code: 'PROJECT_COMPARISON_AUTO_REFRESH_UNAVAILABLE' } +} + +function engineeringIdentityKey(workspaceId: string, workspacePath: string): string { + return `${workspaceId}\0${resolve(workspacePath)}` +} + +function eventLoopDelayMs(): Promise { + const startedAt = performance.now() + return new Promise((resolveDelay) => { + setImmediate(() => resolveDelay(performance.now() - startedAt)) + }) +} + +function roundMs(value: number): number { + return Number(value.toFixed(2)) +} + +function failure( + code: 'invalid-project' | 'read-failed', + error: unknown, +): BackendProjectComparisonSelectResult { + return { + ok: false, + code, + detail: error instanceof Error ? error.message : String(error), + } +} + +function queryFailure( + code: 'read-failed', + error: unknown, +): BackendProjectComparisonQueryResult { + return { + ok: false, + code, + detail: error instanceof Error ? error.message : String(error), + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceArtifact.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceArtifact.ts new file mode 100644 index 000000000..cd064189b --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceArtifact.ts @@ -0,0 +1,246 @@ +import { dirname } from 'node:path' +import { performance } from 'node:perf_hooks' +import type { + BackendWorkspaceArtifactContent, + ReadSection, + WorkspaceTimingPathsDetail, + WorkspaceTimingSummaryDetail, +} from '@ecos-studio/shared' +import { electronLogger } from './logger' +import type { + ProjectEngineeringSnapshotReadResult, + VerifiedProjectArtifactReadResult, +} from './projectManagementReadService' + +type ValidSnapshot = NonNullable + +export type WorkspaceArtifactReader = (request: { + projectRoot: string + workspacePath: string + artifact: { reference: string; sha256: string; sizeBytes: number } +}) => Promise + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function stringValue(value: Record, key: string): string { + return typeof value[key] === 'string' ? value[key] : '' +} + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function artifactJson(bytes: Uint8Array): Record | null { + try { + return record(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))) + } catch { + return null + } +} + +function timingCorner(reference: string): string { + const parts = reference.replace(/\\/g, '/').split('/') + const feature = parts.indexOf('feature') + return feature >= 0 ? parts.slice(feature + 1, -1).join('/') : '' +} + +function timingPaths(bytes: Uint8Array): WorkspaceTimingPathsDetail | null { + const source = artifactJson(bytes) + const corner = source ? stringValue(source, 'corner') : '' + const pathLimit = finiteNumber(source?.path_limit) + if ( + !source || + source.schema_version !== 1 || + !corner || + pathLimit === null || + !Number.isSafeInteger(pathLimit) || + pathLimit < 0 || + !Array.isArray(source.paths) || + source.paths.length > 256 + ) { + return null + } + const paths: WorkspaceTimingPathsDetail['paths'] = [] + for (const value of source.paths) { + const path = record(value) + const pathId = path ? stringValue(path, 'path_id') : '' + const analysisType = path ? stringValue(path, 'analysis_type') : '' + const pathGroup = path ? stringValue(path, 'path_group') : '' + const startPoint = path ? stringValue(path, 'start_point') : '' + const endPoint = path ? stringValue(path, 'end_point') : '' + const slackNs = finiteNumber(path?.slack_ns) + if ( + !pathId || + (analysisType !== 'setup' && analysisType !== 'hold') || + !pathGroup || + !startPoint || + !endPoint || + slackNs === null || + !Array.isArray(path?.stages) || + path.stages.length > 512 + ) { + return null + } + const stages = path.stages.flatMap((value) => { + const stage = record(value) + if (!stage || typeof stage.pin !== 'string' || typeof stage.cell !== 'string') { + return [] + } + return [ + { + pin: stringValue(stage, 'pin'), + cell: stringValue(stage, 'cell'), + arrivalNs: finiteNumber(stage.arrival_ns), + delayNs: finiteNumber(stage.incremental_delay_ns ?? stage.delay_ns), + }, + ] + }) + if (stages.length !== path.stages.length) return null + paths.push({ + pathId, + analysisType, + pathGroup, + startPoint, + endPoint, + slackNs, + stages, + }) + } + return { corner, pathLimit, paths } +} + +function timingSummary( + bytes: Uint8Array, + reference: string, +): WorkspaceTimingSummaryDetail | null { + const source = artifactJson(bytes) + const summary = record(source?.summary) + const setup = record(summary?.setup) + const hold = record(summary?.hold) + const corner = source ? stringValue(source, 'corner') || timingCorner(reference) : '' + if ( + !source || + (source.schema_version !== undefined && source.schema_version !== 1) || + !summary || + !setup || + !hold || + !corner + ) { + return null + } + const setupWns = finiteNumber(setup.wns) + const holdWns = finiteNumber(hold.wns) + return { + corner, + meetsTiming: + setupWns === null || holdWns === null ? null : setupWns >= 0 && holdWns >= 0, + setup: { + wns: setupWns, + tns: finiteNumber(setup.tns), + violationCount: finiteNumber(setup.nvp), + frequencyMhz: finiteNumber(setup.frequency_mhz), + }, + hold: { + wns: holdWns, + tns: finiteNumber(hold.tns), + violationCount: finiteNumber(hold.nvp), + }, + } +} + +export async function readWorkspaceArtifact( + snapshot: ValidSnapshot, + workspaceRoot: string, + artifactId: string, + reader: WorkspaceArtifactReader | undefined, +): Promise> { + const unavailable = (code: string): ReadSection => ({ + status: 'unavailable', + issues: [{ code }], + }) + const artifacts = snapshot.sections.artifacts + if (artifacts.status !== 'ready') return unavailable('ENGINEERING_ARTIFACT_INVALID') + const artifact = artifacts.data.find((candidate) => candidate.artifactId === artifactId) + if ( + !artifact || + artifact.availability !== 'available' || + ![ + 'layout_image', + 'congestion_image', + 'timing_paths', + 'timing_summary', + 'report_text', + ].includes(artifact.kind) || + artifact.sizeBytes === undefined || + !artifact.sha256 || + !reader + ) { + return unavailable('ARTIFACT_REFERENCE_MISSING') + } + const startedAt = performance.now() + const read = await reader({ + artifact: { + reference: artifact.reference, + sha256: artifact.sha256, + sizeBytes: artifact.sizeBytes, + }, + projectRoot: dirname(workspaceRoot), + workspacePath: workspaceRoot, + }) + electronLogger.debug('[backend-workspace] artifact query metrics', { + artifactBytes: read.ok ? read.bytes.byteLength : 0, + artifactReadCount: 1, + totalMs: Number((performance.now() - startedAt).toFixed(2)), + }) + if (!read.ok) { + const code = + read.code === 'FINDINGS_ARTIFACT_TOO_LARGE' + ? 'ARTIFACT_TOO_LARGE' + : read.code === 'FINDINGS_READ_FAILED' + ? 'ARTIFACT_READ_FAILED' + : read.code + return unavailable(code) + } + const parsedTimingPaths = + artifact.kind === 'timing_paths' ? timingPaths(read.bytes) : undefined + const parsedTimingSummary = + artifact.kind === 'timing_summary' + ? timingSummary(read.bytes, artifact.reference) + : undefined + if ( + (artifact.kind === 'timing_paths' && !parsedTimingPaths) || + (artifact.kind === 'timing_summary' && !parsedTimingSummary) + ) { + return unavailable('ARTIFACT_INVALID_JSON') + } + let text: string | undefined + if (artifact.kind === 'report_text') { + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(read.bytes) + } catch { + return unavailable('ARTIFACT_TEXT_INVALID') + } + } + return { + status: 'ready', + data: { + artifactId: artifact.artifactId, + bytes: read.bytes, + kind: artifact.kind, + mimeType: artifact.name.toLowerCase().endsWith('.png') + ? 'image/png' + : artifact.kind === 'report_text' + ? 'text/plain' + : 'application/json', + name: artifact.name, + ...(text === undefined ? {} : { text }), + ...(parsedTimingPaths ? { timingPaths: parsedTimingPaths } : {}), + ...(parsedTimingSummary ? { timingSummary: parsedTimingSummary } : {}), + }, + issues: [], + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceDetail.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceDetail.ts new file mode 100644 index 000000000..f3b21c688 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceDetail.ts @@ -0,0 +1,361 @@ +import type { + EccEngineeringAnalysisArtifactRef, + EccEngineeringMetric, + ReadSection, + WorkspaceArtifactDescriptor, + WorkspaceChecklistSummary, + WorkspaceDatabaseFacts, + WorkspaceFlowSummary, + WorkspaceFlowInsightsSummary, + WorkspaceLvsInsights, + WorkspaceRcxInsights, + WorkspaceStepDetail, +} from '@ecos-studio/shared' +import type { ProjectEngineeringSnapshotReadResult } from './projectManagementReadService' +import { checklistSection, flowSection } from './backendWorkspaceOverviewProjection' + +type ValidSnapshot = NonNullable + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function sameStep(left: string, right: string): boolean { + return left.trim().toLowerCase() === right.trim().toLowerCase() +} + +function stringValue(value: Record, key: string): string { + return typeof value[key] === 'string' ? value[key] : '' +} + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function analysisDetail( + data: Record | null, + id: string, +): Record | null { + const details = Array.isArray(data?.details) ? data.details : [] + for (const value of details) { + const detail = record(value) + if (detail?.id === id) return record(detail.summary) + } + return null +} + +function databaseFacts( + data: Record | null, +): WorkspaceDatabaseFacts | null { + const summary = analysisDetail(data, 'database_facts') + if (!summary) return null + const layout = record(summary.layout) ?? {} + const statistics = record(summary.statistics) ?? {} + const total = record(summary.instance_total) ?? {} + const rows = (value: unknown, limit: number): Record[] => + Array.isArray(value) + ? value.slice(0, limit).flatMap((candidate) => { + const row = record(candidate) + return row ? [row] : [] + }) + : [] + return { + layout: { + dieArea: finiteNumber(layout.die_area), + dieUsage: finiteNumber(layout.die_usage), + dieWidth: finiteNumber(layout.die_width), + dieHeight: finiteNumber(layout.die_height), + coreArea: finiteNumber(layout.core_area), + coreUsage: finiteNumber(layout.core_usage), + coreWidth: finiteNumber(layout.core_width), + coreHeight: finiteNumber(layout.core_height), + dbu: finiteNumber(layout.dbu), + }, + statistics: { + ioPins: finiteNumber(statistics.io_pins), + instances: finiteNumber(statistics.instances), + nets: finiteNumber(statistics.nets), + pdn: finiteNumber(statistics.pdn), + }, + instanceClasses: rows(summary.instance_classes, 32).flatMap((row) => { + const kind = stringValue(row, 'kind') + return kind + ? [ + { + kind, + count: finiteNumber(row.count), + area: finiteNumber(row.area), + pinCount: finiteNumber(row.pin_count), + }, + ] + : [] + }), + instanceTotal: { + count: finiteNumber(total.count), + area: finiteNumber(total.area), + pinCount: finiteNumber(total.pin_count), + }, + pinDistribution: rows(summary.pin_distribution, 64).flatMap((row) => { + const pinCount = finiteNumber(row.pin_count) + return pinCount !== null && Number.isInteger(pinCount) && pinCount >= 0 + ? [ + { + pinCount, + instanceCount: finiteNumber(row.instance_count), + netCount: finiteNumber(row.net_count), + }, + ] + : [] + }), + cutLayers: rows(summary.cut_layers, 64).flatMap((row) => { + const layer = stringValue(row, 'layer') + return layer ? [{ layer, viaCount: finiteNumber(row.via_count) }] : [] + }), + routingLayers: rows(summary.routing_layers, 64).flatMap((row) => { + const layer = stringValue(row, 'layer') + return layer ? [{ layer, wireLength: finiteNumber(row.wire_length) }] : [] + }), + wireLength: finiteNumber(summary.wire_length), + viaCount: finiteNumber(summary.via_count), + } +} + +function lvsInsights(data: Record | null): WorkspaceLvsInsights | null { + const summary = analysisDetail(data, 'lvs_connectivity_summary') + if (!summary) return null + const rows = (key: string): Record[] => + Array.isArray(summary[key]) + ? summary[key].flatMap((value) => { + const row = record(value) + return row ? [row] : [] + }) + : [] + return { + entities: rows('entities').flatMap((row, index) => { + const entity = stringValue(row, 'entity') + return entity + ? [ + { + id: `lvs-entity-${index}`, + entity, + netlist: finiteNumber(row.netlist), + def: finiteNumber(row.def), + difference: finiteNumber(row.difference), + }, + ] + : [] + }), + connections: rows('connectivity').flatMap((row, index) => { + const connectivity = stringValue(row, 'connectivity') + return connectivity + ? [ + { + id: `lvs-connectivity-${index}`, + connectivity, + open: finiteNumber(row.open), + short: finiteNumber(row.short), + connected: finiteNumber(row.connected), + total: finiteNumber(row.total), + }, + ] + : [] + }), + violations: rows('violations').flatMap((row, index) => { + const type = stringValue(row, 'type') + return type + ? [ + { + id: `lvs-violation-${index}`, + type, + net: stringValue(row, 'net'), + instance: stringValue(row, 'instance'), + terminals: stringValue(row, 'terminals'), + components: stringValue(row, 'components'), + }, + ] + : [] + }), + } +} + +function displayNumber(value: number): string { + return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3))) +} + +function rcxInsights(data: Record | null): WorkspaceRcxInsights | null { + const summary = analysisDetail(data, 'rcx_electrical_corner_metrics') + if (!summary) return null + const metrics = (source: Record, prefix: string) => + Object.entries(source).flatMap(([key, value]) => { + const number = finiteNumber(value) + return number === null + ? [] + : [ + { + id: `${prefix}-${key}`, + label: key.replace(/_/g, ' '), + value: displayNumber(number), + }, + ] + }) + const rows = (value: unknown): Record[] => + Array.isArray(value) + ? value.flatMap((candidate) => { + const row = record(candidate) + return row ? [row] : [] + }) + : [] + const coverage = record(summary.coverage) + return { + electricalMetrics: metrics(summary, 'rcx-electrical'), + electricalCorners: rows(summary.electrical_corners).map((corner) => ({ + corner: stringValue(corner, 'corner'), + netCount: finiteNumber(corner.net_count), + groundCapacitanceFf: finiteNumber(corner.ground_capacitance_ff), + couplingCapacitanceFf: finiteNumber(corner.coupling_capacitance_ff), + totalCapacitanceFf: finiteNumber(corner.total_capacitance_ff), + totalResistanceOhm: finiteNumber(corner.total_resistance_ohm), + })), + signoffMetrics: coverage ? metrics(coverage, 'rcx-coverage') : [], + signoffCorners: rows(summary.rc_corners).map((corner) => ({ + corner: stringValue(corner, 'label') || stringValue(corner, 'rc_corner'), + availability: stringValue(corner, 'availability'), + totalCapacitanceFf: finiteNumber(corner.total_capacitance_ff), + couplingCapacitanceFf: finiteNumber(corner.coupling_capacitance_ff), + totalResistanceOhm: finiteNumber(corner.total_resistance_ohm), + })), + } +} + +export function artifactDescriptor( + artifact: EccEngineeringAnalysisArtifactRef, + sourceRevision?: number, +): WorkspaceArtifactDescriptor { + return { + artifactId: artifact.artifactId, + availability: artifact.availability, + kind: artifact.kind, + name: artifact.name, + ...(sourceRevision === undefined ? {} : { sourceRevision }), + ...(artifact.sizeBytes === undefined ? {} : { sizeBytes: artifact.sizeBytes }), + ...(artifact.stepId ? { stepId: artifact.stepId } : {}), + } +} + +function analysisMetrics(data: Record | null): EccEngineeringMetric[] { + return Array.isArray(data?.metrics) ? (data.metrics as EccEngineeringMetric[]) : [] +} + +function analysisHotspots( + data: Record | null, +): Array> { + return Array.isArray(data?.hotspots) + ? data.hotspots.flatMap((value) => { + const hotspot = record(value) + return hotspot ? [hotspot] : [] + }) + : [] +} + +export function workspaceStepDetail( + snapshot: ValidSnapshot, + stepId: string, + flow: ReadSection, + checklist: ReadSection, + insights: WorkspaceFlowInsightsSummary | null, + staleSnapshot?: ValidSnapshot, +): ReadSection { + const analysis = snapshot.sections.qor + if (flow.status !== 'ready' && flow.status !== 'partial') { + return { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_STEP_DETAIL_UNAVAILABLE' }], + } + } + const analysisStep = + analysis.status === 'ready' + ? analysis.data.analysis.steps.find((step) => sameStep(step.stepId, stepId)) + : undefined + const flowStep = flow.data.steps.find((step) => sameStep(step.stepId, stepId)) + if (!flowStep) { + return { status: 'unavailable', issues: [{ code: 'WORKSPACE_STEP_NOT_FOUND' }] } + } + const artifacts = snapshot.sections.artifacts + const invalidated = snapshot.snapshot.stalePredecessor?.invalidatedStepIds ?? [] + let staleEvidence: WorkspaceStepDetail['staleEvidence'] + const staleAnalysis = staleSnapshot?.sections.qor + const hasStaleAnalysis = + staleAnalysis?.status === 'ready' && + staleAnalysis.data.analysis.steps.some((step) => sameStep(step.stepId, stepId)) + if ( + staleSnapshot && + hasStaleAnalysis && + invalidated.some((candidate) => sameStep(candidate, stepId)) + ) { + const staleFlow = flowSection(staleSnapshot) + const staleDetail = workspaceStepDetail( + staleSnapshot, + stepId, + staleFlow, + checklistSection(staleSnapshot, staleFlow), + null, + ) + if (staleDetail.status === 'ready' || staleDetail.status === 'partial') { + staleEvidence = { + ...staleDetail.data, + workspaceRevision: staleSnapshot.snapshot.workspaceRevision, + } + } + } + if (analysis.status !== 'ready' && !staleEvidence) { + return { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_STEP_DETAIL_UNAVAILABLE' }], + } + } + return { + status: 'ready', + data: { + analysis: { + metrics: analysisMetrics(analysisStep?.metrics.data ?? null), + summary: analysisStep?.summary.data ?? null, + hotspots: analysisHotspots(analysisStep?.hotspots.data ?? null), + lec: analysisStep?.lecResult?.data ?? null, + drc: + analysisStep?.stepId.trim().toLowerCase() === 'drc' && insights + ? insights.drc + : { totalCount: null, hotspots: [], reportedCount: 0, truncated: false }, + sta: + analysisStep?.stepId.trim().toLowerCase() === 'sta' + ? (insights?.sta ?? null) + : null, + congestion: (insights?.congestion ?? []).filter((statistic) => + sameStep(statistic.stepId, analysisStep?.stepId ?? stepId), + ), + database: databaseFacts(analysisStep?.metrics.data ?? null), + lvs: lvsInsights(analysisStep?.metrics.data ?? null), + rcx: rcxInsights(analysisStep?.metrics.data ?? null), + }, + artifacts: + artifacts.status === 'ready' + ? artifacts.data + .filter((artifact) => sameStep(artifact.stepId ?? '', stepId)) + .map(artifactDescriptor) + : [], + checklist: + checklist.status === 'ready' || checklist.status === 'partial' + ? { + findings: checklist.data.findings.filter((finding) => + sameStep(finding.step, stepId), + ), + } + : { findings: [] }, + step: flowStep, + subflow: analysisStep?.subflow ?? { status: 'missing', steps: [] }, + ...(staleEvidence ? { staleEvidence } : {}), + }, + issues: [], + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceFlowInsights.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceFlowInsights.ts new file mode 100644 index 000000000..012329cf2 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceFlowInsights.ts @@ -0,0 +1,436 @@ +import type { + ReadSection, + WorkspaceCongestionStatistic, + WorkspaceDrcHotspot, + WorkspaceFlowInsightsSummary, + WorkspaceFlowSummary, + WorkspaceQorSummary, + WorkspaceStaInsights, + WorkspaceStaTimingIssue, +} from '@ecos-studio/shared' +import type { ProjectEngineeringSnapshotReadResult } from './projectManagementReadService' + +const TREND_METRIC_IDS = new Set([ + 'instance_count', + 'instance_area', + 'net_count', + 'io_pin_count', + 'die_area', + 'core_area', + 'core_utilization', + 'macro_count', + 'macro_area', + 'std_cell_count', + 'std_cell_area', + 'clock_count', + 'clock_area', + 'io_pad_count', + 'io_pad_area', + 'route_wirelength', + 'route_via_count', +]) + +const STEP_ALIASES: Record = { + synthesis: 'Synth', + synth: 'Synth', + floorplan: 'Floor', + floor: 'Floor', + prefloorplan: 'Floor', + macroplacement: 'Floor', + postfloorplan: 'Floor', + lec: 'LEC', + legalization: 'Legal', + legal: 'Legal', + 'timing optimization': 'Timing Opt', + timingoptimization: 'Timing Opt', + postroutelec: 'Post-route LEC', +} + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +function stringValue(value: Record | null, key: string): string { + const candidate = value?.[key] + return typeof candidate === 'string' ? candidate : '' +} + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function canonicalStepId(value: string): string { + const trimmed = value.trim() + return STEP_ALIASES[trimmed.toLowerCase()] ?? trimmed +} + +function metricValue( + qor: WorkspaceQorSummary, + stepId: string, + metricId: string, + corner?: string, +): number | null { + return ( + qor.metrics.find( + (metric) => + metric.id === metricId && + metric.stepId.toLowerCase() === canonicalStepId(stepId).toLowerCase() && + (corner === undefined || metric.corner === corner), + )?.value ?? null + ) +} + +function trendVerdict( + delta: number | null, + polarity: WorkspaceQorSummary['metrics'][number]['polarity'], +): 'improvement' | 'regression' | 'unchanged' | 'not-comparable' { + if (delta === null || polarity === 'trend_only' || polarity === 'target_range') { + return 'not-comparable' + } + if (delta === 0) return 'unchanged' + return (polarity === 'lower_is_better' ? delta < 0 : delta > 0) + ? 'improvement' + : 'regression' +} + +function remainder(total: number | null, values: Array): number | null { + if (total === null || values.some((value) => value === null)) return null + return Math.max(0, total - values.reduce((sum, value) => sum + (value ?? 0), 0)) +} + +function drcBreakdown( + snapshot: Extract, +): { hotspots: WorkspaceDrcHotspot[]; reportedCount: number; truncated: boolean } { + const analysis = snapshot.sections.qor + const empty = { hotspots: [], reportedCount: 0, truncated: false } + if (analysis.status !== 'ready' && analysis.status !== 'partial') return empty + const step = analysis.data.analysis.steps.find( + (candidate) => canonicalStepId(candidate.stepId).toLowerCase() === 'drc', + ) + const details = record(step?.metrics.data)?.details + const detail = Array.isArray(details) + ? details.map(record).find((value) => value?.id === 'drc_rule_layer_summary') + : null + const summary = record(detail?.summary) + const detailedValues = summary?.top_violations + const fallbackValues = record(step?.hotspots.data)?.hotspots + const values = Array.isArray(detailedValues) + ? detailedValues + : Array.isArray(fallbackValues) + ? fallbackValues + : [] + const hotspots = values.flatMap((value) => { + const hotspot = record(value) + if (!hotspot || (hotspot.kind !== undefined && hotspot.kind !== 'drc_rule_layer')) { + return [] + } + const metricId = stringValue(hotspot, 'metric_id') + const parts = metricId.split(':') + const rule = stringValue(hotspot, 'rule') || parts[1] || '' + const layer = stringValue(hotspot, 'layer') || parts[2] || '' + const amount = finiteNumber(hotspot.value) + if (!metricId || !rule || !layer || amount === null || amount < 0) return [] + return [ + { + metricId, + rule, + layer, + displayName: stringValue(hotspot, 'display_name') || `${rule} · ${layer}`, + value: amount, + unit: stringValue(hotspot, 'unit') || 'count', + }, + ] + }) + return { + hotspots, + reportedCount: finiteNumber(summary?.reported_count) ?? hotspots.length, + truncated: + typeof summary?.truncated === 'boolean' ? summary.truncated : hotspots.length > 0, + } +} + +function timingIssues( + snapshot: Extract, +): WorkspaceStaTimingIssue[] { + const analysis = snapshot.sections.qor + if (analysis.status !== 'ready' && analysis.status !== 'partial') return [] + const step = analysis.data.analysis.steps.find( + (candidate) => canonicalStepId(candidate.stepId).toLowerCase() === 'sta', + ) + const values = record(step?.timingIssues?.data)?.issues + if (!Array.isArray(values)) return [] + return values.flatMap((value) => { + const issue = record(value) + const issueId = stringValue(issue, 'issue_id') + const corner = stringValue(issue, 'corner') + const analysisType = stringValue(issue, 'analysis_type') + const slackNs = finiteNumber(issue?.slack_ns) + if ( + !issueId || + !corner || + (analysisType !== 'setup' && analysisType !== 'hold') || + slackNs === null + ) { + return [] + } + const stages = Array.isArray(issue?.dominant_stages) ? issue.dominant_stages : [] + return [ + { + issueId, + corner, + analysisType, + slackNs, + startPoint: stringValue(issue, 'start_point'), + endPoint: stringValue(issue, 'end_point'), + pathGroup: stringValue(issue, 'path_group'), + stages: stages.flatMap((value) => { + const stage = record(value) + return stage + ? [ + { + pin: stringValue(stage, 'pin'), + cell: stringValue(stage, 'cell'), + arrivalNs: finiteNumber(stage.arrival_ns), + delayNs: finiteNumber(stage.incremental_delay_ns), + }, + ] + : [] + }), + }, + ] + }) +} + +function missingTimingCorners( + snapshot: Extract, +): string[] { + const analysis = snapshot.sections.qor + if (analysis.status !== 'ready' && analysis.status !== 'partial') return [] + const step = analysis.data.analysis.steps.find( + (candidate) => canonicalStepId(candidate.stepId).toLowerCase() === 'sta', + ) + const missing = record(step?.timingIssues?.data)?.missing_corners + return Array.isArray(missing) + ? missing.filter((corner): corner is string => typeof corner === 'string' && !!corner) + : [] +} + +function congestionStatistics( + snapshot: Extract, +): WorkspaceCongestionStatistic[] { + const analysis = snapshot.sections.qor + if (analysis.status !== 'ready' && analysis.status !== 'partial') return [] + return analysis.data.analysis.steps.flatMap((step) => { + const details = record(step.metrics.data)?.details + if (!Array.isArray(details)) return [] + return details.flatMap((value) => { + const detail = record(value) + if (detail?.id !== 'place_map_metrics') return [] + const maps = record(detail.summary)?.maps + if (!Array.isArray(maps)) return [] + return maps.flatMap((value) => { + const map = record(value) + const metric = stringValue(map, 'metric').toLowerCase() + const direction = stringValue(map, 'direction').toLowerCase() + const max = finiteNumber(map?.max) + const total = finiteNumber(map?.total) + const hotspotCount = finiteNumber(map?.nonzero_count) + if ( + max === null || + total === null || + hotspotCount === null || + !['', 'horizontal', 'vertical', 'union'].includes(direction) + ) { + return [] + } + const mapKind = metric.includes('lut') + ? 'lut_rudy' + : metric.includes('rudy') + ? 'rudy' + : metric.includes('density') + ? 'density' + : 'egr' + return [ + { + stepId: step.stepId, + mapKind, + direction: direction as WorkspaceCongestionStatistic['direction'], + max, + total, + hotspotCount, + }, + ] + }) + }) + }) +} + +function staInsights( + qor: WorkspaceQorSummary, + issues: WorkspaceStaTimingIssue[], + missingCorners: string[], +): WorkspaceStaInsights | null { + const staMetrics = qor.metrics.filter( + (metric) => metric.stepId === 'STA' && Boolean(metric.corner), + ) + const missing = new Set(missingCorners) + const corners = [ + ...new Set([ + ...staMetrics.flatMap((metric) => metric.corner ?? []), + ...missingCorners, + ]), + ] + if (!corners.length && !issues.length) return null + const summaries = corners.map((corner) => { + const context = record( + staMetrics.find((metric) => metric.corner === corner)?.cornerContext, + ) + return { + corner, + role: stringValue(context, 'configured_role'), + process: stringValue(context, 'process_corner'), + voltageV: finiteNumber(context?.voltage_v), + temperatureC: finiteNumber(context?.temperature_c), + rcCorner: stringValue(context, 'rc_corner'), + availability: missing.has(corner) ? 'missing' : 'available', + setupWns: metricValue(qor, 'STA', 'sta_setup_wns', corner), + setupTns: metricValue(qor, 'STA', 'sta_setup_tns', corner), + setupViolationCount: metricValue(qor, 'STA', 'sta_setup_violation_count', corner), + frequencyMhz: metricValue(qor, 'STA', 'sta_frequency_mhz', corner), + holdWns: metricValue(qor, 'STA', 'sta_hold_wns', corner), + holdTns: metricValue(qor, 'STA', 'sta_hold_tns', corner), + holdViolationCount: metricValue(qor, 'STA', 'sta_hold_violation_count', corner), + } + }) + const setup = summaries.flatMap((summary) => + summary.setupWns === null ? [] : [{ corner: summary.corner, wns: summary.setupWns }], + ) + const hold = summaries.flatMap((summary) => + summary.holdWns === null ? [] : [{ corner: summary.corner, wns: summary.holdWns }], + ) + const worstSetup = setup.sort((left, right) => left.wns - right.wns)[0] ?? null + const worstHold = hold.sort((left, right) => left.wns - right.wns)[0] ?? null + const incomplete = + missing.size > 0 || (metricValue(qor, 'STA', 'sta_missing_corner_count') ?? 0) > 0 + return { + corners: summaries, + criticalPaths: issues, + worstSetup, + worstHold, + frequencyMhz: + summaries.find((summary) => summary.frequencyMhz !== null)?.frequencyMhz ?? null, + setupViolationCount: + !incomplete && summaries.every((summary) => summary.setupViolationCount !== null) + ? summaries.reduce( + (total, summary) => total + (summary.setupViolationCount ?? 0), + 0, + ) + : null, + holdViolationCount: + !incomplete && summaries.every((summary) => summary.holdViolationCount !== null) + ? summaries.reduce( + (total, summary) => total + (summary.holdViolationCount ?? 0), + 0, + ) + : null, + allCornersMet: + !incomplete && worstSetup && worstHold + ? worstSetup.wns >= 0 && worstHold.wns >= 0 + : null, + } +} + +export function flowInsightsSection( + snapshot: ProjectEngineeringSnapshotReadResult | null, + flow: ReadSection, + qor: ReadSection, +): ReadSection { + if ( + !snapshot?.ok || + (flow.status !== 'ready' && flow.status !== 'partial') || + (qor.status !== 'ready' && qor.status !== 'partial') + ) { + return { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_FLOW_INSIGHTS_UNAVAILABLE' }], + } + } + const definitions = new Map() + for (const metric of qor.data.metrics) { + if (TREND_METRIC_IDS.has(metric.id) && !definitions.has(metric.id)) { + definitions.set(metric.id, metric) + } + } + const trends = [...definitions.values()].map((definition) => { + let previous: number | null = null + return { + id: definition.id, + name: definition.name, + unit: definition.unit ?? '', + polarity: definition.polarity, + points: flow.data.steps.map((step) => { + const value = metricValue(qor.data, step.name, definition.id) + const delta = value === null || previous === null ? null : value - previous + if (value !== null) previous = value + return { + stepId: step.stepId, + value, + delta, + verdict: trendVerdict(delta, definition.polarity), + } + }), + } + }) + const issues = timingIssues(snapshot) + const drc = drcBreakdown(snapshot) + return { + status: 'ready', + data: { + trends, + composition: flow.data.steps.map((step) => { + const totalCount = metricValue(qor.data, step.name, 'instance_count') + const totalArea = metricValue(qor.data, step.name, 'instance_area') + const stdCellCount = metricValue(qor.data, step.name, 'std_cell_count') + const stdCellArea = metricValue(qor.data, step.name, 'std_cell_area') + const clockCount = metricValue(qor.data, step.name, 'clock_count') + const clockArea = metricValue(qor.data, step.name, 'clock_area') + const macroCount = metricValue(qor.data, step.name, 'macro_count') + const macroArea = metricValue(qor.data, step.name, 'macro_area') + const ioPadCount = metricValue(qor.data, step.name, 'io_pad_count') + const ioPadArea = metricValue(qor.data, step.name, 'io_pad_area') + return { + stepId: step.stepId, + stdCellCount, + stdCellArea, + clockCount, + clockArea, + macroCount, + macroArea, + ioPadCount, + ioPadArea, + fillerCount: remainder(totalCount, [ + stdCellCount, + clockCount, + macroCount, + ioPadCount, + ]), + fillerArea: remainder(totalArea, [ + stdCellArea, + clockArea, + macroArea, + ioPadArea, + ]), + } + }), + congestion: congestionStatistics(snapshot), + drc: { + totalCount: metricValue(qor.data, 'DRC', 'drc_count'), + ...drc, + }, + sta: staInsights(qor.data, issues, missingTimingCorners(snapshot)), + }, + issues: [], + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceOverviewProjection.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceOverviewProjection.ts new file mode 100644 index 000000000..3f5615c65 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceOverviewProjection.ts @@ -0,0 +1,325 @@ +import { relative, resolve } from 'node:path' +import { + parseRuntimeSeconds, + type ChecklistFinding, + type FlowStepState, + type EngineeringSnapshotValidationResult, + type ProjectManifest, + type ReadSection, + type WorkspaceChecklistSummary, + type WorkspaceConfigurationSummary, + type WorkspaceFlowSummary, + type WorkspaceOverviewIdentity, +} from '@ecos-studio/shared' + +function stringValue(record: Record | null, key: string): string { + const value = record?.[key] + return typeof value === 'string' ? value : '' +} + +function firstStringValue( + record: Record | null, + keys: readonly string[], +): string { + for (const key of keys) { + const value = stringValue(record, key) + if (value) return value + } + return '' +} + +function firstFiniteNumber( + record: Record | null, + keys: readonly string[], +): number | null { + for (const key of keys) { + const value = finiteNumber(record?.[key]) + if (value !== null) return value + } + return null +} + +function finiteNumber(value: unknown): number | null { + if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) { + return null + } + const number = typeof value === 'number' ? value : Number(value) + return Number.isFinite(number) ? number : null +} + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +export function pathsEqual(left: string, right: string): boolean { + return relative(resolve(left), resolve(right)) === '' +} + +export function configurationSection( + snapshot: EngineeringSnapshotValidationResult | null, +): ReadSection { + if (!snapshot?.ok) { + return { + status: 'unavailable', + issues: [snapshot?.issue ?? { code: 'WORKSPACE_CONFIGURATION_UNAVAILABLE' }], + } + } + const parameters = snapshot.snapshot.parameters + const die = record(parameters.die) ?? record(parameters.Die) + const canonicalDie = record(parameters.die_area) + const core = record(parameters.core) ?? record(parameters.Core) + const mpc = record(parameters.mpc) ?? record(parameters.MPC) + const template = record(mpc?.template) ?? record(mpc?.core_template) + const ports = Array.isArray(template?.ports) + ? template.ports.flatMap((value) => { + const port = record(value) + const name = stringValue(port, 'name').trim() + return name + ? [ + { + name, + direction: stringValue(port, 'direction').trim() || '--', + dataType: stringValue(port, 'data_type').trim() || '--', + width: finiteNumber(port?.width), + info: stringValue(port, 'info').trim(), + }, + ] + : [] + }) + : [] + return { + status: 'ready', + data: { + pdk: firstStringValue(parameters, ['pdk', 'PDK']), + design: firstStringValue(parameters, ['design', 'Design']), + topModule: firstStringValue(parameters, ['top_module', 'Top module']), + dieArea: + firstFiniteNumber(die, ['area', 'Area']) ?? + firstFiniteNumber(canonicalDie, ['area', 'Area']) ?? + finiteNumber(parameters.die_area), + coreUtilization: + firstFiniteNumber(canonicalDie, ['utilization', 'utilitization']) ?? + firstFiniteNumber(core, ['utilization', 'utilitization', 'Utilitization']) ?? + finiteNumber(parameters.core_utilization), + maxFanout: firstFiniteNumber(parameters, ['max_fanout', 'Max fanout']), + clock: firstStringValue(parameters, ['clock', 'Clock']), + frequencyMaxMhz: firstFiniteNumber(parameters, [ + 'frequency_max', + 'Frequency max [MHz]', + ]), + mpcDisplayName: stringValue(mpc, 'display_name').trim() || null, + mpcConstraints: template + ? { + minimumArea: finiteNumber(template.minimum_area), + maximumArea: finiteNumber(template.maximum_area), + maximumCellCount: finiteNumber(template.maximum_cell_num), + ports, + } + : null, + }, + issues: [], + } +} + +function normalizeFlowState(value: string): FlowStepState { + switch (value.trim().toLowerCase()) { + case 'success': + case 'succeeded': + case 'completed': + case 'complete': + return 'succeeded' + case 'warning': + return 'warning' + case 'ongoing': + case 'running': + return 'running' + case 'incomplete': + case 'invalid': + case 'failed': + case 'failure': + case 'error': + return 'failed' + case 'pending': + case 'unstart': + case 'unstarted': + case 'not_started': + case 'not-started': + case 'not started': + return 'not-started' + case 'skipped': + return 'skipped' + case 'cancelled': + case 'canceled': + return 'cancelled' + default: + return value.trim() ? 'unknown' : 'not-started' + } +} + +export function flowSection( + snapshot: EngineeringSnapshotValidationResult | null, +): ReadSection { + if (!snapshot?.ok) { + return { + status: 'unavailable', + issues: [snapshot?.issue ?? { code: 'WORKSPACE_FLOW_UNAVAILABLE' }], + } + } + const flow = snapshot.sections.flow + if (flow.status !== 'ready' && flow.status !== 'partial') { + return { status: flow.status, issues: flow.issues } + } + const steps = record(flow.data)?.steps + if (!Array.isArray(steps)) { + return { status: 'error', issues: [{ code: 'WORKSPACE_FLOW_INVALID' }] } + } + return { + status: 'ready', + data: { + steps: steps.flatMap((value, order) => { + const step = record(value) + if (!step || typeof step.name !== 'string') return [] + if (step.name.toLowerCase().replace(/[\s_-]/g, '') === 'fixfanout') return [] + const runtimeSeconds = parseRuntimeSeconds(String(step.runtime ?? '')) + const peakMemoryMb = finiteNumber( + step['peak memory (mb)'] ?? record(step.info)?.['peak memory (mb)'], + ) + return [ + { + stepId: step.name, + order, + name: step.name, + state: normalizeFlowState(String(step.state ?? '')), + ...(typeof step.tool === 'string' && step.tool ? { toolId: step.tool } : {}), + ...(runtimeSeconds === null ? {} : { runtimeSeconds }), + ...(peakMemoryMb === null ? {} : { peakMemoryMb }), + }, + ] + }), + }, + issues: [], + } +} + +function checklistFinding(value: unknown): ChecklistFinding | null { + const item = record(value) + if (!item) return null + const requiredStrings = [ + 'id', + 'step', + 'category', + 'owner', + 'policy', + 'state', + 'title', + 'summary', + ] as const + if (requiredStrings.some((key) => typeof item[key] !== 'string')) return null + if (typeof item.blocked !== 'boolean') return null + const source = record(item.source) + if (!source || !Array.isArray(item.evidence)) return null + return { + id: item.id as string, + step: item.step as string, + category: item.category as string, + owner: item.owner as string, + policy: item.policy as string, + state: item.state as string, + blocked: item.blocked, + title: item.title as string, + summary: item.summary as string, + source, + evidence: item.evidence.filter( + (entry): entry is Record => record(entry) !== null, + ), + } +} + +function reconcileFinding( + finding: ChecklistFinding, + successfulSteps: ReadonlySet, +): ChecklistFinding { + if ( + finding.category !== 'flow' || + finding.state !== 'failed' || + !successfulSteps.has(finding.step.trim().toLowerCase()) + ) { + return finding + } + return { + ...finding, + blocked: false, + state: 'pass', + evidence: [ + ...finding.evidence, + { + kind: 'flow-checklist-reconciliation', + previousState: 'failed', + committedFlowState: 'Success', + }, + ], + } +} + +export function checklistSection( + snapshot: EngineeringSnapshotValidationResult | null, + flow: ReadSection, +): ReadSection { + if (!snapshot?.ok) { + return { + status: 'unavailable', + issues: [snapshot?.issue ?? { code: 'WORKSPACE_CHECKLIST_UNAVAILABLE' }], + } + } + const root = record(snapshot.snapshot.checklist) + if (!root || !Array.isArray(root.checklist)) { + return { status: 'error', issues: [{ code: 'WORKSPACE_CHECKLIST_INVALID' }] } + } + const findings = root.checklist.map(checklistFinding) + const invalidCount = findings.filter((finding) => finding === null).length + const successfulSteps = new Set( + (flow.status === 'ready' || flow.status === 'partial' ? flow.data.steps : []) + .filter((step) => step.state === 'succeeded') + .map((step) => step.name.trim().toLowerCase()), + ) + const data = { + findings: findings + .filter((finding): finding is ChecklistFinding => finding !== null) + .map((finding) => reconcileFinding(finding, successfulSteps)), + } + return invalidCount + ? { + status: 'partial', + data, + issues: [ + { + code: 'WORKSPACE_CHECKLIST_ITEM_INVALID', + detail: `${invalidCount} invalid checklist item(s)`, + }, + ], + } + : { status: 'ready', data, issues: [] } +} + +export function identityFromManifest( + workspaceRoot: string, + manifest: ProjectManifest | null, +): WorkspaceOverviewIdentity { + const workspace = manifest?.workspaces.find((candidate) => + pathsEqual(candidate.workspace_path, workspaceRoot), + ) + return { + ...(manifest ? { projectId: manifest.project_id, projectName: manifest.name } : {}), + ...(workspace + ? { workspaceId: workspace.workspace_id, workspaceName: workspace.name } + : { + workspaceName: + workspaceRoot.split(/[\\/]/).filter(Boolean).pop() ?? 'Workspace', + }), + ...(manifest?.qor_baseline?.workspace_id + ? { baselineWorkspaceId: manifest.qor_baseline.workspace_id } + : {}), + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceResultProjection.test.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceResultProjection.test.ts new file mode 100644 index 000000000..cc1405838 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceResultProjection.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { projectWorkspaceResults } from './backendWorkspaceResultProjection' +import type { ProjectEngineeringSnapshotReadResult } from './projectManagementReadService' + +function section(data: T) { + return { status: 'ready' as const, data, issues: [] } +} + +function readResult( + revision: number, + stalePredecessor?: { workspaceRevision: number; invalidatedStepIds: string[] }, +) { + const qor = { + analysis: { steps: [] }, + metrics: [], + qorAssessment: { + status: 'ready', + metrics: [], + score: { gate: 'incomplete', threshold: 60, value: null }, + steps: + revision === 1 + ? [ + { + stepId: 'Route', + name: 'Route', + order: 0, + status: 'pass', + summaryMetricCount: 0, + }, + ] + : [], + }, + } + return { + ok: true, + readBytes: 1, + snapshot: { + checklist: {}, + parameters: {}, + schemaVersion: 3, + workspaceId: 'workspace-1', + workspaceRevision: revision, + ...(stalePredecessor ? { stalePredecessor } : {}), + }, + sections: { + artifacts: section([]), + flow: section({ steps: [] }), + qor: section(qor), + qorSnapshotExtension: section({ status: 'available' }), + signoff: section({ groups: [], risks: [], status: 'ready' }), + }, + } as unknown as ProjectEngineeringSnapshotReadResult +} + +describe('projectWorkspaceResults', () => { + it('marks the QoR v3 extension unavailable when the display mixes stale facts', () => { + const current = readResult(2, { workspaceRevision: 1, invalidatedStepIds: ['Route'] }) + const result = projectWorkspaceResults({ + ...current, + staleSnapshot: readResult(1), + } as Extract) + + expect(result.freshness.status).toBe('stale') + expect(result.snapshot.sections.qorSnapshotExtension).toEqual({ + status: 'unavailable', + issues: [{ code: 'ENGINEERING_QOR_SNAPSHOT_STALE' }], + }) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceResultProjection.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceResultProjection.ts new file mode 100644 index 000000000..0f72f937b --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceResultProjection.ts @@ -0,0 +1,239 @@ +import { + parseProjectManifestFlowStep, + type EccEngineeringAnalysisArtifactRef, + type EccEngineeringMetric, + type WorkspaceResultFreshness, +} from '@ecos-studio/shared' +import type { ProjectEngineeringSnapshotReadResult } from './projectManagementReadService' + +type ValidSnapshot = Extract +type SnapshotData = NonNullable + +export interface WorkspaceResultProjection { + freshness: WorkspaceResultFreshness + snapshot: ValidSnapshot + staleArtifactIds: ReadonlySet +} + +interface AssessmentStep { + metrics: EccEngineeringMetric[] + order: number + raw: Record + stepId: string +} + +function canonicalStepIdentity(stepId: string): string { + return (parseProjectManifestFlowStep(stepId) ?? stepId).trim().toLowerCase() +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function readyData(section: { status: string; data?: T }): T | null { + return section.status === 'ready' || section.status === 'partial' + ? (section.data ?? null) + : null +} + +function staleQorSnapshotExtension(): ValidSnapshot['sections']['qorSnapshotExtension'] { + return { + status: 'unavailable', + issues: [{ code: 'ENGINEERING_QOR_SNAPSHOT_STALE' }], + } +} + +function assessmentSteps(assessment: Record): AssessmentStep[] | null { + const steps = assessment.steps + const metrics = assessment.metrics + if (!Array.isArray(steps) || !Array.isArray(metrics)) return null + const result: AssessmentStep[] = [] + let offset = 0 + for (const value of steps) { + const step = record(value) + const stepId = typeof step?.stepId === 'string' ? step.stepId : '' + const count = step?.summaryMetricCount + const order = step?.order + if (!step || !stepId || !Number.isInteger(count) || !Number.isInteger(order)) + return null + const nextOffset = offset + (count as number) + if (nextOffset > metrics.length) return null + result.push({ + metrics: metrics.slice(offset, nextOffset) as EccEngineeringMetric[], + order: order as number, + raw: step, + stepId, + }) + offset = nextOffset + } + return offset === metrics.length ? result : null +} + +function mergeQor( + current: SnapshotData, + stale: SnapshotData, + staleSteps: ReadonlySet, +): ValidSnapshot['sections']['qor'] { + const currentQor = readyData(current.sections.qor) + const staleQor = readyData(stale.sections.qor) + if (!currentQor) return staleQor ? stale.sections.qor : current.sections.qor + if (!staleQor) return current.sections.qor + + const currentAssessmentSteps = assessmentSteps(currentQor.qorAssessment) + const staleAssessmentSteps = assessmentSteps(staleQor.qorAssessment) + if (!currentAssessmentSteps) return stale.sections.qor + if (!staleAssessmentSteps) return current.sections.qor + + const selectedSteps = [ + ...currentAssessmentSteps, + ...staleAssessmentSteps.filter((step) => + staleSteps.has(canonicalStepIdentity(step.stepId)), + ), + ].sort((left, right) => left.order - right.order) + const metrics = selectedSteps.flatMap((step) => step.metrics) + const analysisSteps = [ + ...currentQor.analysis.steps, + ...staleQor.analysis.steps.filter((step) => + staleSteps.has(canonicalStepIdentity(step.stepId)), + ), + ].sort((left, right) => left.order - right.order) + + return { + status: 'ready', + data: { + analysis: { steps: analysisSteps }, + metrics, + qorAssessment: { + ...staleQor.qorAssessment, + metrics, + steps: selectedSteps.map((step) => step.raw), + }, + }, + issues: [], + } +} + +function mergeChecklist( + current: SnapshotData, + stale: SnapshotData, + staleSteps: ReadonlySet, +): Record { + const currentChecklist = record(current.snapshot.checklist) + const staleChecklist = record(stale.snapshot.checklist) + const currentFindings = currentChecklist?.checklist + const staleFindings = staleChecklist?.checklist + if (!Array.isArray(currentFindings) || !Array.isArray(staleFindings)) { + return current.snapshot.checklist + } + const fallbackFindings = staleFindings.filter((value) => { + const finding = record(value) + return ( + typeof finding?.step === 'string' && + staleSteps.has(canonicalStepIdentity(finding.step)) + ) + }) + return { ...currentChecklist, checklist: [...currentFindings, ...fallbackFindings] } +} + +function mergeArtifacts( + current: SnapshotData, + stale: SnapshotData, + staleSteps: ReadonlySet, +): { + section: ValidSnapshot['sections']['artifacts'] + staleArtifactIds: ReadonlySet +} { + const currentArtifacts = readyData(current.sections.artifacts) + const staleArtifacts = readyData(stale.sections.artifacts) + if (!staleArtifacts) { + return { section: current.sections.artifacts, staleArtifactIds: new Set() } + } + const fallback = staleArtifacts.filter( + (artifact) => + typeof artifact.stepId === 'string' && + staleSteps.has(canonicalStepIdentity(artifact.stepId)), + ) as EccEngineeringAnalysisArtifactRef[] + if (!currentArtifacts) { + return { + section: { status: 'ready', data: fallback, issues: [] }, + staleArtifactIds: new Set(fallback.map((artifact) => artifact.artifactId)), + } + } + return { + section: { status: 'ready', data: [...currentArtifacts, ...fallback], issues: [] }, + staleArtifactIds: new Set(fallback.map((artifact) => artifact.artifactId)), + } +} + +export function projectWorkspaceResults( + current: ValidSnapshot, +): WorkspaceResultProjection { + const predecessor = current.snapshot.stalePredecessor + const stale = current.staleSnapshot + if (!predecessor || !stale) { + return { + freshness: { + status: 'current', + currentRevision: current.snapshot.workspaceRevision, + currentStepIds: [], + staleStepIds: [], + }, + snapshot: current, + staleArtifactIds: new Set(), + } + } + + const currentQor = readyData(current.sections.qor) + const currentAssessmentSteps = currentQor + ? assessmentSteps(currentQor.qorAssessment) + : null + const currentResultSteps = new Set( + (currentAssessmentSteps ?? []).map((step) => canonicalStepIdentity(step.stepId)), + ) + const currentStepIds = [ + ...new Set((currentAssessmentSteps ?? []).map((step) => step.stepId)), + ] + const staleStepIds = predecessor.invalidatedStepIds.filter( + (stepId) => !currentResultSteps.has(canonicalStepIdentity(stepId)), + ) + const staleSteps = new Set(staleStepIds.map(canonicalStepIdentity)) + if (staleSteps.size === 0) { + return { + freshness: { + status: 'current', + currentRevision: current.snapshot.workspaceRevision, + currentStepIds, + staleStepIds: [], + }, + snapshot: current, + staleArtifactIds: new Set(), + } + } + + const artifacts = mergeArtifacts(current, stale, staleSteps) + return { + freshness: { + status: currentStepIds.length ? 'mixed' : 'stale', + currentRevision: current.snapshot.workspaceRevision, + staleRevision: stale.snapshot.workspaceRevision, + currentStepIds, + staleStepIds, + }, + snapshot: { + ...current, + snapshot: { + ...current.snapshot, + checklist: mergeChecklist(current, stale, staleSteps), + }, + sections: { + ...current.sections, + artifacts: artifacts.section, + qor: mergeQor(current, stale, staleSteps), + qorSnapshotExtension: staleQorSnapshotExtension(), + }, + }, + staleArtifactIds: artifacts.staleArtifactIds, + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.integration.test.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.integration.test.ts new file mode 100644 index 000000000..030c5721c --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.integration.test.ts @@ -0,0 +1,274 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + projectManifestForPresentation, + type EccEngineeringMetric, + type EccProjectManifest, + type EccPersistedEngineeringSnapshot, +} from '@ecos-studio/shared' +import { afterEach, describe, expect, it } from 'vitest' +import { BackendWorkspaceService } from './backendWorkspaceService' +import { ProjectManagementReadService } from './projectManagementReadService' +import { runWithWindowScope } from './windowScopeContext' + +const temporaryDirectories: string[] = [] + +function metric(id: string, value: number): EccEngineeringMetric { + return { + id, + display_name: id === 'core_area' ? 'Core Area' : 'Instance Count', + value, + unit: id === 'core_area' ? 'um^2' : 'count', + category: 'area_cost', + direction: 'trend_only', + scope: 'workspace', + corner: null, + analysis_group: 'physical_scale', + rating: { gate: false, score: false, trend: true }, + project_role: 'trend', + step_role: 'primary', + confidence: 'high', + source: { kind: 'feature', path: 'feature/Place.step.json', selector: '/count' }, + } +} + +function snapshot(workspaceId: string, revision: number, value: number) { + const metrics = [metric('instance_count', value), metric('core_area', 6400)] + return { + analysis: { + steps: [ + { + stepId: 'Place', + toolId: 'ecc', + order: 1, + flowState: 'Success', + metrics: { + artifactId: 'place-metrics', + status: 'available', + data: { + schema_version: 3, + metrics, + details: [ + { + id: 'database_facts', + summary: { + layout: { core_area: 6400 }, + statistics: { instances: value }, + instance_classes: [], + instance_total: { count: value, area: null, pin_count: null }, + pin_distribution: [], + cut_layers: [], + routing_layers: [], + wire_length: null, + via_count: null, + }, + }, + ], + }, + }, + summary: { + artifactId: 'place-summary', + status: 'missing', + reasonCode: 'ANALYSIS_FILE_MISSING', + data: null, + }, + hotspots: { + artifactId: 'place-hotspots', + status: 'missing', + reasonCode: 'ANALYSIS_FILE_MISSING', + data: null, + }, + timingIssues: null, + subflow: { + status: 'available', + steps: [{ name: 'placement', state: 'Success' }], + }, + }, + ], + }, + artifacts: [], + checklist: { checklist: [] }, + flow: { + steps: [ + { name: 'Synthesis', tool: 'yosys', state: 'Success', runtime: '0:0:1' }, + { name: 'Place', tool: 'ecc', state: 'Success' }, + ], + }, + metrics, + parameters: { + PDK: 'ics55', + Design: 'gcd', + 'Top module': 'gcd', + 'Max fanout': 32, + }, + qorAssessment: { + status: 'ready', + metrics, + score: { value: 70, threshold: 60, gate: 'pass' }, + steps: [ + { + stepId: 'Place', + name: 'Place', + order: 1, + status: 'pass', + summaryMetricCount: 2, + }, + ], + }, + schemaVersion: 2, + signoffAssessment: { status: 'ready', groups: [], risks: [] }, + workspaceId, + workspaceRevision: revision, + } satisfies EccPersistedEngineeringSnapshot +} + +describe('BackendWorkspaceService persisted integration', () => { + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true })), + ) + }) + + it('loads current and baseline committed facts through the bounded reader', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'ecos-backend-workspace-')) + temporaryDirectories.push(projectRoot) + const currentRoot = join(projectRoot, 'ws_current') + const baselineRoot = join(projectRoot, 'ws_baseline') + await Promise.all( + [currentRoot, baselineRoot].map((root) => + mkdir(join(root, 'home'), { recursive: true }), + ), + ) + const now = '2026-09-03T00:00:00.000Z' + const manifest: EccProjectManifest = { + schema_version: 1, + project_id: 'proj_gcd', + name: 'gcd', + design_name: 'gcd', + description: '', + root_path: projectRoot, + created_at: now, + updated_at: now, + base_design: { pdk: 'ics55', top_module: 'gcd_top', parameters: {} }, + objectives: { primary: 'timing', directions: {} }, + workspaces: [ + { + workspace_id: 'ws_current', + name: 'ws_current', + workspace_path: 'ws_current', + source_workspace_id: null, + branch_from: null, + start_step: 'Synth', + end_step: 'Harden', + status: 'success', + created_at: now, + updated_at: now, + parameter_patch: {}, + metrics_summary: {}, + step_metrics: {}, + }, + { + workspace_id: 'ws_baseline', + name: 'ws_baseline', + workspace_path: 'ws_baseline', + source_workspace_id: null, + branch_from: null, + start_step: 'Synth', + end_step: 'Harden', + status: 'success', + created_at: now, + updated_at: now, + parameter_patch: {}, + metrics_summary: {}, + step_metrics: {}, + }, + ], + mpc: null, + best_workspace: null, + qor_baseline: { workspace_id: 'ws_baseline', reason: 'selected' }, + } + await Promise.all([ + writeFile(join(projectRoot, 'project.json'), JSON.stringify(manifest)), + writeFile( + join(currentRoot, 'home', 'engineering-snapshot.json'), + JSON.stringify(snapshot('engineering-current', 7, 450)), + ), + writeFile( + join(baselineRoot, 'home', 'engineering-snapshot.json'), + JSON.stringify(snapshot('engineering-baseline', 3, 400)), + ), + ]) + const service = new BackendWorkspaceService({ + projectManagementReadService: new ProjectManagementReadService({ + discover: async () => null, + load: async (root) => projectManifestForPresentation(manifest, root), + }), + workspaceRootProvider: { getProjectRoot: async () => currentRoot }, + }) + + const result = await runWithWindowScope(101, () => service.getOverview()) + + expect(result.overview).toMatchObject({ + identity: { + projectName: 'gcd', + workspaceId: 'ws_current', + baselineWorkspaceId: 'ws_baseline', + }, + configuration: { status: 'ready', data: { pdk: 'ics55', maxFanout: 32 } }, + flow: { + status: 'ready', + data: { steps: [{ state: 'succeeded' }, { state: 'succeeded' }] }, + }, + checklist: { status: 'ready' }, + qor: { status: 'ready' }, + baselineComparison: { status: 'ready' }, + flowInsights: { status: 'ready' }, + revision: { + status: 'ready', + data: { workspaceId: 'engineering-current', workspaceRevision: 7 }, + }, + }) + const { qor, keyMetrics, flowInsights } = result.overview + if ( + qor.status !== 'ready' || + keyMetrics.status !== 'ready' || + flowInsights?.status !== 'ready' + ) { + throw new Error('expected committed Overview sections') + } + expect(qor.data.metrics).toContainEqual( + expect.objectContaining({ id: 'instance_count', value: 450 }), + ) + expect(keyMetrics.data.items).toContainEqual({ + id: 'core-area', + label: 'Core Area', + unit: 'um2', + value: 6400, + }) + expect(flowInsights.data.trends).toContainEqual( + expect.objectContaining({ + id: 'instance_count', + points: expect.arrayContaining([expect.objectContaining({ value: 450 })]), + }), + ) + + const detail = await runWithWindowScope(101, () => + service.getStepDetail({ + stepId: 'Place', + workspaceContextId: result.workspaceContextId, + workspaceRevision: 7, + }), + ) + expect(detail).toMatchObject({ + workspaceRevision: 7, + detail: { + status: 'ready', + data: { + analysis: { database: { layout: { coreArea: 6400 } } }, + subflow: { status: 'available', steps: [{ name: 'placement' }] }, + }, + }, + }) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.test.ts new file mode 100644 index 000000000..6d4c58e07 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.test.ts @@ -0,0 +1,1972 @@ +import { + projectManifestForPresentation, + validateEngineeringSnapshot, + type EccEngineeringMetric, + type EccEngineeringSnapshot, + type WorkspaceResourceIndex, +} from '@ecos-studio/shared' +import { describe, expect, it, vi } from 'vitest' +import { BackendWorkspaceService } from './backendWorkspaceService' +import { electronLogger } from './logger' +import type { + ProjectComparisonFileWatcher, + ProjectComparisonFileWatcherCallbacks, +} from './projectComparisonFileWatcher' +import type { ProjectEngineeringSnapshotReadResult } from './projectManagementReadService' +import { runWithWindowScope } from './windowScopeContext' + +function resourceIndex(): WorkspaceResourceIndex { + const file = (kind: 'checklist' | 'flow' | 'home' | 'parameters') => ({ + exists: true, + kind, + path: `/project/ws-a/home/${kind}.json`, + }) + return { + design: 'gcd', + flow: { steps: [] }, + home: { + checklistJson: file('checklist'), + flowJson: file('flow'), + homeJson: file('home'), + parametersJson: file('parameters'), + }, + homeData: {}, + messages: [], + parameters: { + clock: 'clk', + design: 'gcd', + die: { area: 14400 }, + frequency_max: 200, + max_fanout: 24, + pdk: 'ics55', + top_module: 'gcd_top', + }, + pdk: 'ics55', + root: '/project/ws-a', + status: 'available', + topModule: 'gcd_top', + } +} + +function engineeringSnapshot(index = resourceIndex()): EccEngineeringSnapshot { + return { + analysis: { steps: [] }, + artifacts: [], + checklist: { checklist: [] }, + flow: { + steps: index.flow.steps.map((step) => ({ + info: step.info, + name: step.name, + 'peak memory (mb)': + step.peakMemoryMb ?? Number(step.info['peak memory (mb)'] ?? 0), + runtime: step.runtime, + state: step.state, + tool: step.tool, + })), + }, + metrics: [], + parameters: index.parameters ?? {}, + qorAssessment: { + status: 'ready', + metrics: [], + score: { gate: 'pass', threshold: 60, value: 73.5 }, + steps: [], + }, + schemaVersion: 1, + signoffAssessment: { groups: [], risks: [], status: 'ready' }, + workspaceId: 'ecc-workspace-a', + workspaceRevision: 1, + } +} + +function engineeringMetric( + id: string, + value: number, + options: { corner?: string; direction?: EccEngineeringMetric['direction'] } = {}, +): EccEngineeringMetric { + return { + id, + display_name: id, + value, + unit: 'count', + category: 'routability_physical', + direction: options.direction ?? 'trend_only', + scope: 'workspace', + corner: options.corner ?? null, + ...(options.corner + ? { + corner_context: { + configured_role: 'setup', + process_corner: 'tt', + voltage_v: 1.8, + temperature_c: 25, + rc_corner: 'typical', + }, + } + : {}), + analysis_group: 'test', + rating: { gate: false, score: false, trend: true }, + project_role: 'trend', + step_role: 'primary', + confidence: 'high', + source: {}, + } +} + +function persistedSnapshotResult( + snapshot: EccEngineeringSnapshot, +): ProjectEngineeringSnapshotReadResult { + const result = validateEngineeringSnapshot(snapshot) + if (!result.ok) throw new Error(result.issue.code) + return { ...result, readBytes: 1 } +} + +function persistedReadService( + snapshot: EccEngineeringSnapshot, + manifest = manifestForWorkspace(), +) { + return { + readEngineeringSnapshot: vi.fn().mockResolvedValue(persistedSnapshotResult(snapshot)), + readManifest: vi.fn().mockResolvedValue(manifest), + } +} + +function workspaceRootProvider() { + return { getProjectRoot: vi.fn().mockResolvedValue('/project/ws-a') } +} + +function manifestForWorkspace() { + return manifestForWorkspaces(['ws-a'], 'ws-a') +} + +function manifestWithBaseline() { + return manifestForWorkspaces(['ws-a', 'ws-base'], 'ws-base') +} + +function manifestForWorkspaces(workspaceIds: string[], baselineId: string) { + const now = '2026-08-30T00:00:00.000Z' + return { + ...projectManifestForPresentation( + { + schema_version: 1, + project_id: 'proj_demo_project', + name: 'demo-project', + design_name: 'gcd', + description: '', + root_path: '/project', + created_at: now, + updated_at: now, + base_design: { pdk: 'ics55', top_module: 'gcd_top', parameters: {} }, + objectives: { primary: 'timing', directions: {} }, + workspaces: workspaceIds.map((workspaceId) => ({ + workspace_id: workspaceId, + name: workspaceId, + workspace_path: workspaceId, + source_workspace_id: null, + branch_from: null, + start_step: 'Synth', + end_step: 'Harden', + status: 'not_started' as const, + created_at: now, + updated_at: now, + parameter_patch: {}, + metrics_summary: {}, + step_metrics: {}, + })), + mpc: null, + best_workspace: null, + qor_baseline: { workspace_id: baselineId, reason: 'selected' }, + }, + '/project', + ), + base_design: { pdk: 'ics55', top_module: 'gcd_top', parameters: {} }, + } +} + +describe('BackendWorkspaceService', () => { + it('invalidates the previous Context when its window scope is cleared', async () => { + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(engineeringSnapshot()), + workspaceRootProvider: workspaceRootProvider(), + }) + const invalidated = vi.fn() + service.onInvalidated(invalidated) + const initial = await runWithWindowScope(41, () => service.getOverview()) + + service.clearWindow(41) + + expect(invalidated).toHaveBeenCalledWith({ + generation: initial.generation + 1, + windowId: 41, + workspaceContextId: initial.workspaceContextId, + }) + }) + + it('returns window-scoped identity and configuration from committed facts', async () => { + const readManifest = vi.fn().mockResolvedValue({ + base_design: {}, + best_workspace: null, + created_at: '2026-08-30T00:00:00.000Z', + description: '', + design_name: 'gcd', + mpc: null, + name: 'demo-project', + objectives: { directions: {}, primary: 'timing' }, + project_id: 'project-demo', + qor_baseline: { reason: 'selected', workspace_id: 'ws-base' }, + root_path: '/project', + schema_version: 1, + updated_at: '2026-08-30T00:00:00.000Z', + workspaces: [ + { + branch_from: null, + created_at: '2026-08-30T00:00:00.000Z', + end_step: 'Harden', + metrics_summary: {}, + name: 'Workspace A', + parameter_patch: {}, + source_workspace_id: null, + start_step: 'Synth', + status: 'not_started', + step_metrics: {}, + updated_at: '2026-08-30T00:00:00.000Z', + workspace_id: 'ws-a', + workspace_path: '/project/ws-a', + }, + ], + }) + const snapshot = engineeringSnapshot() + snapshot.parameters.die_area = { utilitization: 0.41 } + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot: vi + .fn() + .mockResolvedValue(persistedSnapshotResult(snapshot)), + readManifest, + }, + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(41, () => service.getOverview()) + + expect(result).toMatchObject({ + generation: 0, + overview: { + configuration: { + data: { + clock: 'clk', + coreUtilization: 0.41, + design: 'gcd', + dieArea: 14400, + frequencyMaxMhz: 200, + maxFanout: 24, + pdk: 'ics55', + topModule: 'gcd_top', + }, + issues: [], + status: 'ready', + }, + identity: { + baselineWorkspaceId: 'ws-base', + projectId: 'project-demo', + projectName: 'demo-project', + workspaceId: 'ws-a', + workspaceName: 'Workspace A', + }, + }, + }) + expect(result.workspaceContextId).toEqual(expect.any(String)) + expect(result.overview.qor.status).toBe('ready') + expect(readManifest).toHaveBeenCalledWith('/project') + }) + + it('returns ordered committed Flow facts without Renderer parsing', async () => { + const index = resourceIndex() + index.flow.steps = [ + { + directory: '/project/ws-a/place_ecc', + info: { 'peak memory (mb)': 812.5 }, + name: 'Place', + resources: { + analysis: {}, + checklist: {}, + config: {}, + data: {}, + feature: {}, + log: {}, + output: {}, + report: {}, + script: {}, + subflow: {}, + }, + runtime: '00:01:02.5', + state: 'completed', + tool: 'ecc', + }, + ] + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(engineeringSnapshot(index)), + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(42, () => service.getOverview()) + + expect(result.overview.flow).toEqual({ + data: { + steps: [ + { + name: 'Place', + order: 0, + peakMemoryMb: 812.5, + runtimeSeconds: 62.5, + state: 'succeeded', + stepId: 'Place', + toolId: 'ecc', + }, + ], + }, + issues: [], + status: 'ready', + }) + }) + + it('keeps ECC Unstart steps queued instead of marking them invalid', async () => { + const index = resourceIndex() + index.flow.steps = [ + { + directory: '/project/ws-a/Synthesis_yosys', + info: {}, + name: 'Synthesis', + resources: { + analysis: {}, + checklist: {}, + config: {}, + data: {}, + feature: {}, + log: {}, + output: {}, + report: {}, + script: {}, + subflow: {}, + }, + runtime: '', + state: 'Unstart', + tool: 'yosys', + }, + ] + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(engineeringSnapshot(index)), + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(42, () => service.getOverview()) + + expect(result.overview.flow).toMatchObject({ + data: { steps: [{ stepId: 'Synthesis', state: 'not-started' }] }, + }) + }) + + it('keeps absent numeric facts unavailable instead of manufacturing zeroes', async () => { + const snapshot = engineeringSnapshot() + snapshot.parameters = { Die: { Area: null }, 'Max fanout': null } + snapshot.flow = { + steps: [ + { + name: 'Place', + tool: 'ecc', + state: 'Unstart', + 'peak memory (mb)': null, + }, + ], + } + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(snapshot), + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(42, () => service.getOverview()) + + expect(result.overview.configuration).toMatchObject({ + status: 'ready', + data: { dieArea: null, maxFanout: null }, + }) + expect(result.overview.flow).toMatchObject({ + status: 'ready', + data: { steps: [{ state: 'not-started' }] }, + }) + if (result.overview.flow.status === 'ready') { + expect(result.overview.flow.data.steps[0]).not.toHaveProperty('peakMemoryMb') + } + }) + + it('reports a damaged Flow section instead of a ready empty flow', async () => { + const index = resourceIndex() + index.flow.steps = [] + const snapshot = engineeringSnapshot(index) + snapshot.flow = {} + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(snapshot), + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(42, () => service.getOverview()) + + expect(result.overview.flow).toMatchObject({ + status: 'unavailable', + issues: [{ code: 'ENGINEERING_FLOW_INVALID' }], + }) + }) + + it('reconciles only stale Flow checklist failures against committed success', async () => { + const index = resourceIndex() + index.flow.steps = [ + { + directory: '/project/ws-a/place_ecc', + info: {}, + name: 'Place', + resources: { + analysis: {}, + checklist: {}, + config: {}, + data: {}, + feature: {}, + log: {}, + output: {}, + report: {}, + script: {}, + subflow: {}, + }, + runtime: '', + state: 'Success', + tool: 'ecc', + }, + ] + const finding = (id: string, category: string) => ({ + blocked: true, + category, + evidence: [], + id, + owner: 'checklist', + policy: 'block', + source: {}, + state: 'failed', + step: 'Place', + summary: 'stale result', + title: id, + }) + const snapshot = engineeringSnapshot(index) + snapshot.checklist = { + checklist: [finding('flow-ready', 'flow'), finding('layout', 'artifact')], + } + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(snapshot), + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(43, () => service.getOverview()) + + expect(result.overview.checklist).toMatchObject({ + data: { + findings: [ + { + blocked: false, + category: 'flow', + evidence: [ + { + committedFlowState: 'Success', + kind: 'flow-checklist-reconciliation', + previousState: 'failed', + }, + ], + id: 'flow-ready', + state: 'pass', + }, + { blocked: true, category: 'artifact', id: 'layout', state: 'failed' }, + ], + }, + issues: [], + status: 'ready', + }) + }) + + it('coalesces one Query per generation and refreshes with a new generation', async () => { + let resolveRoot!: (value: string) => void + const getProjectRoot = vi + .fn() + .mockReturnValueOnce( + new Promise((resolve) => { + resolveRoot = resolve + }), + ) + .mockResolvedValue('/project/ws-a') + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService( + engineeringSnapshot(), + manifestWithBaseline(), + ), + workspaceRootProvider: { getProjectRoot }, + }) + + const first = runWithWindowScope(44, () => service.getOverview()) + const second = runWithWindowScope(44, () => service.getOverview()) + expect(getProjectRoot).toHaveBeenCalledTimes(1) + + resolveRoot('/project/ws-a') + const [firstResult, secondResult] = await Promise.all([first, second]) + expect(secondResult).toEqual(firstResult) + await runWithWindowScope(44, () => service.getOverview()) + expect(getProjectRoot).toHaveBeenCalledTimes(1) + + const refreshed = await runWithWindowScope(44, () => service.refreshOverview()) + expect(refreshed.generation).toBe(1) + expect(getProjectRoot).toHaveBeenCalledTimes(2) + }) + + it('records a bounded Snapshot-only Overview query', async () => { + const readService = persistedReadService( + engineeringSnapshot(), + manifestWithBaseline(), + ) + const readVerifiedArtifact = vi.fn() + const debug = vi.spyOn(electronLogger, 'debug') + const service = new BackendWorkspaceService({ + projectManagementReadService: { ...readService, readVerifiedArtifact }, + workspaceRootProvider: workspaceRootProvider(), + }) + + await runWithWindowScope(61, () => + Promise.all([service.getOverview(), service.getOverview()]), + ) + + expect(readService.readManifest).toHaveBeenCalledOnce() + expect(readService.readEngineeringSnapshot).toHaveBeenCalledTimes(2) + expect(readVerifiedArtifact).not.toHaveBeenCalled() + expect(debug).toHaveBeenCalledWith( + '[backend-workspace] query metrics', + expect.objectContaining({ + baselineSnapshotReads: 1, + coalescedRequests: 1, + snapshotFileCount: 1, + snapshotBytes: 1, + eventLoopDelayMs: expect.any(Number), + }), + ) + debug.mockRestore() + }) + + it('invalidates before notifying subscribers with the next generation', async () => { + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(engineeringSnapshot()), + workspaceRootProvider: workspaceRootProvider(), + }) + const initial = await runWithWindowScope(45, () => service.getOverview()) + const listener = vi.fn() + service.onInvalidated(listener) + + service.invalidateWindow(45) + + expect(listener).toHaveBeenCalledWith({ + generation: 1, + windowId: 45, + workspaceContextId: initial.workspaceContextId, + }) + const refreshed = await runWithWindowScope(45, () => service.getOverview()) + expect(refreshed.generation).toBe(1) + }) + + it('refreshes committed Dashboard facts while the runtime operation is active', async () => { + const index = resourceIndex() + const first = engineeringSnapshot(index) + first.flow = { + steps: [ + { name: 'Synthesis', tool: 'yosys', state: 'Success' }, + { name: 'Floorplan', tool: 'ecc', state: 'Success' }, + { name: 'Place', tool: 'dreamplace', state: 'Unstart' }, + ], + } + const second = structuredClone(first) + second.workspaceRevision = 2 + second.flow = { + steps: [ + { name: 'Synthesis', tool: 'yosys', state: 'Success' }, + { name: 'Floorplan', tool: 'ecc', state: 'Success' }, + { name: 'Place', tool: 'dreamplace', state: 'Success' }, + ], + } + ;(second.qorAssessment.score as { value: number }).value = 80 + const readEngineeringSnapshot = vi + .fn() + .mockResolvedValueOnce(persistedSnapshotResult(first)) + .mockResolvedValueOnce(persistedSnapshotResult(second)) + const projectManagementReadService = { + readEngineeringSnapshot, + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + } + const service = new BackendWorkspaceService({ + projectManagementReadService, + workspaceRootProvider: workspaceRootProvider(), + }) + + const initial = await runWithWindowScope(46, () => service.getOverview()) + const refreshed = await runWithWindowScope(46, () => service.refreshOverview()) + + expect(initial.overview.flow).toMatchObject({ + status: 'ready', + data: { + steps: [ + { stepId: 'Synthesis', state: 'succeeded' }, + { stepId: 'Floorplan', state: 'succeeded' }, + { stepId: 'Place', state: 'not-started' }, + ], + }, + }) + expect(refreshed.overview.flow).toMatchObject({ + status: 'ready', + data: { steps: [expect.anything(), expect.anything(), { state: 'succeeded' }] }, + }) + expect(initial.overview.qor).toMatchObject({ data: { score: { value: 73.5 } } }) + expect(refreshed.overview.qor).toMatchObject({ data: { score: { value: 80 } } }) + }) + + it('does not publish a transiently incomplete snapshot over committed facts', async () => { + const first = engineeringSnapshot() + first.flow = { + steps: [{ name: 'Synthesis', state: 'Success', tool: 'yosys' }], + } + const transient = structuredClone(first) + transient.workspaceRevision = 2 + transient.flow = { steps: [] } + const readEngineeringSnapshot = vi + .fn() + .mockResolvedValueOnce(persistedSnapshotResult(first)) + .mockResolvedValueOnce(persistedSnapshotResult(transient)) + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot, + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + + const initial = await runWithWindowScope(49, () => service.getOverview()) + await expect(runWithWindowScope(49, () => service.refreshOverview())).rejects.toThrow( + 'ENGINEERING_SNAPSHOT_SECTION_INVALID', + ) + const detail = await runWithWindowScope(49, () => + service.getStepDetail({ + stepId: 'missing', + workspaceContextId: initial.workspaceContextId, + workspaceRevision: 1, + }), + ) + + expect(initial.overview.flow.status).toBe('ready') + expect(detail.workspaceRevision).toBe(1) + }) + + it('loads current and selected baseline facts through the persisted reader only', async () => { + const index = resourceIndex() + const current = engineeringSnapshot(index) + current.workspaceId = 'engineering-current' + current.workspaceRevision = 7 + const baseline = structuredClone(current) + baseline.workspaceId = 'engineering-baseline' + baseline.workspaceRevision = 3 + ;(baseline.qorAssessment.score as { value: number }).value = 61 + const readEngineeringSnapshot = vi.fn(async ({ workspacePath }) => + persistedSnapshotResult(workspacePath === '/project/ws-base' ? baseline : current), + ) + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot, + readManifest: vi.fn().mockResolvedValue(manifestWithBaseline()), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(47, () => service.getOverview()) + + expect(readEngineeringSnapshot).toHaveBeenCalledTimes(2) + expect(readEngineeringSnapshot).toHaveBeenCalledWith({ + projectRoot: '/project', + workspacePath: '/project/ws-base', + }) + expect(result.overview.identity.baselineWorkspaceId).toBe('ws-base') + expect(result.overview.baselineComparison.status).toBe('ready') + }) + + it('builds Overview from the authorized workspace root without a resource index', async () => { + const snapshot = engineeringSnapshot() + const readEngineeringSnapshot = vi + .fn() + .mockResolvedValue(persistedSnapshotResult(snapshot)) + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot, + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + }, + workspaceRootProvider: { + getProjectRoot: vi.fn().mockResolvedValue('/project/ws-a'), + }, + }) + + const result = await runWithWindowScope(48, () => service.getOverview()) + + expect(result.overview.identity).toMatchObject({ + projectName: 'demo-project', + workspaceId: 'ws-a', + }) + expect(result.overview.configuration.status).toBe('ready') + expect(readEngineeringSnapshot).toHaveBeenCalledOnce() + }) + + it('projects aliased trends, DRC detail, and STA paths from one revision', async () => { + const snapshot = engineeringSnapshot() + snapshot.schemaVersion = 2 + snapshot.workspaceRevision = 8 + snapshot.flow = { + steps: [ + { name: 'Synthesis', tool: 'yosys', state: 'Success' }, + { name: 'DRC', tool: 'ecc', state: 'Success' }, + { name: 'STA', tool: 'ecc', state: 'Success' }, + ], + } + const synthesisMetrics = [ + engineeringMetric('instance_count', 450), + engineeringMetric('instance_area', 1000), + engineeringMetric('std_cell_count', 300), + engineeringMetric('std_cell_area', 600), + engineeringMetric('clock_count', 10), + engineeringMetric('clock_area', 20), + engineeringMetric('macro_count', 2), + engineeringMetric('macro_area', 200), + engineeringMetric('io_pad_count', 5), + engineeringMetric('io_pad_area', 10), + ] + const drcMetrics = [ + engineeringMetric('drc_count', 12, { direction: 'lower_is_better' }), + ] + const staMetrics = [ + engineeringMetric('sta_setup_wns', -0.2, { corner: 'TT' }), + engineeringMetric('sta_setup_tns', -1.2, { corner: 'TT' }), + engineeringMetric('sta_setup_violation_count', 3, { corner: 'TT' }), + engineeringMetric('sta_frequency_mhz', 750, { corner: 'TT' }), + engineeringMetric('sta_hold_wns', 0.1, { corner: 'TT' }), + engineeringMetric('sta_hold_tns', 0, { corner: 'TT' }), + engineeringMetric('sta_hold_violation_count', 0, { corner: 'TT' }), + ] + const metrics = [...synthesisMetrics, ...drcMetrics, ...staMetrics] + snapshot.metrics = metrics + snapshot.qorAssessment = { + status: 'ready', + score: { gate: 'blocked', threshold: 60, value: 70 }, + metrics, + steps: [ + { + stepId: 'Synthesis', + name: 'Synthesis', + order: 0, + status: 'pass', + summaryMetricCount: synthesisMetrics.length, + }, + { + stepId: 'DRC', + name: 'DRC', + order: 1, + status: 'blocked', + summaryMetricCount: drcMetrics.length, + }, + { + stepId: 'STA', + name: 'STA', + order: 2, + status: 'blocked', + summaryMetricCount: staMetrics.length, + }, + ], + } + const missing = (artifactId: string) => ({ + artifactId, + data: null, + reasonCode: 'ANALYSIS_FILE_MISSING', + status: 'missing' as const, + }) + snapshot.analysis.steps = [ + { + stepId: 'Synthesis', + toolId: 'yosys', + order: 0, + flowState: 'Success', + metrics: { + artifactId: 'synth-metrics', + status: 'available', + data: { + schema_version: 3, + metrics: synthesisMetrics, + details: [ + { + id: 'place_map_metrics', + summary: { + maps: [ + { + metric: 'egr', + direction: 'union', + max: 3, + total: 6, + nonzero_count: 3, + }, + ], + }, + }, + ], + }, + }, + summary: missing('synth-summary'), + hotspots: missing('synth-hotspots'), + timingIssues: null, + subflow: { status: 'missing', steps: [] }, + }, + { + stepId: 'DRC', + toolId: 'ecc', + order: 1, + flowState: 'Success', + metrics: missing('drc-metrics'), + summary: missing('drc-summary'), + hotspots: { + artifactId: 'drc-hotspots', + status: 'available', + data: { + schema_version: 3, + hotspots: [ + { + kind: 'drc_rule_layer', + metric_id: 'drc:MinimumSpacing:M3', + rule: 'MinimumSpacing', + layer: 'M3', + display_name: 'Minimum Spacing · M3', + value: 12, + unit: 'count', + }, + ], + }, + }, + timingIssues: null, + subflow: { status: 'missing', steps: [] }, + }, + { + stepId: 'STA', + toolId: 'ecc', + order: 2, + flowState: 'Success', + metrics: missing('sta-metrics'), + summary: missing('sta-summary'), + hotspots: missing('sta-hotspots'), + timingIssues: { + artifactId: 'sta-timing', + status: 'available', + data: { + schema_version: 1, + near_fail_slack_ns: 0.05, + missing_corners: ['SS'], + artifact_paths: [], + issues: [ + { + issue_id: 'setup-main', + corner: 'TT', + analysis_type: 'setup', + slack_ns: -0.2, + start_point: 'launch', + end_point: 'capture', + path_group: 'core', + dominant_stages: [ + { + pin: 'u_buf:Y', + cell: 'BUFX3', + arrival_ns: 1.2, + incremental_delay_ns: 0.12, + }, + ], + }, + ], + }, + }, + subflow: { status: 'missing', steps: [] }, + }, + ] + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(snapshot), + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(56, () => service.getOverview()) + + expect(result.overview.flowInsights).toMatchObject({ + status: 'ready', + data: { + trends: expect.arrayContaining([ + expect.objectContaining({ + id: 'instance_count', + points: expect.arrayContaining([ + expect.objectContaining({ stepId: 'Synthesis', value: 450 }), + ]), + }), + ]), + composition: expect.arrayContaining([ + expect.objectContaining({ + stepId: 'Synthesis', + stdCellCount: 300, + stdCellArea: 600, + clockCount: 10, + clockArea: 20, + macroCount: 2, + macroArea: 200, + ioPadCount: 5, + ioPadArea: 10, + fillerCount: 133, + fillerArea: 170, + }), + ]), + drc: { + hotspots: [{ rule: 'MinimumSpacing', layer: 'M3', value: 12 }], + }, + congestion: [ + { + stepId: 'Synthesis', + mapKind: 'egr', + direction: 'union', + max: 3, + total: 6, + hotspotCount: 3, + }, + ], + sta: { + allCornersMet: null, + setupViolationCount: null, + holdViolationCount: null, + corners: expect.arrayContaining([ + expect.objectContaining({ corner: 'SS', availability: 'missing' }), + ]), + worstSetup: { corner: 'TT', wns: -0.2 }, + criticalPaths: [{ issueId: 'setup-main', stages: [{ pin: 'u_buf:Y' }] }], + }, + }, + }) + }) + + it('returns revision-bound committed Step detail without exposing artifact paths', async () => { + const snapshot = engineeringSnapshot() + snapshot.schemaVersion = 2 + snapshot.workspaceId = 'engineering-a' + snapshot.workspaceRevision = 9 + snapshot.flow = { + steps: [{ name: 'Place', tool: 'ecc', state: 'Success', runtime: '0:0:2' }], + } + snapshot.analysis.steps = [ + { + flowState: 'Success', + hotspots: { + artifactId: 'hotspots', + data: null, + reasonCode: 'ANALYSIS_FILE_MISSING', + status: 'missing', + }, + metrics: { + artifactId: 'metrics', + data: null, + reasonCode: 'ANALYSIS_FILE_MISSING', + status: 'missing', + }, + order: 0, + stepId: 'Place', + subflow: { + status: 'available', + steps: [{ name: 'run placement', state: 'Success', runtime: '0:0:2' }], + }, + summary: { + artifactId: 'summary', + data: null, + reasonCode: 'ANALYSIS_FILE_MISSING', + status: 'missing', + }, + timingIssues: null, + toolId: 'ecc', + }, + ] + snapshot.artifacts = [ + { + artifactId: 'layout-place', + availability: 'missing', + kind: 'layout_image', + name: 'gcd_Place.png', + reference: 'Place_ecc/output/gcd_Place.png', + stepId: 'Place', + }, + ] as never + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(snapshot), + workspaceRootProvider: workspaceRootProvider(), + }) + const overview = await runWithWindowScope(49, () => service.getOverview()) + + const detail = await runWithWindowScope(49, () => + service.getStepDetail({ + stepId: 'Place', + workspaceContextId: overview.workspaceContextId, + workspaceRevision: 9, + }), + ) + + expect(detail).toMatchObject({ + detail: { + status: 'ready', + data: { + artifacts: [ + { + artifactId: 'layout-place', + availability: 'missing', + kind: 'layout_image', + }, + ], + step: { stepId: 'Place', state: 'succeeded' }, + subflow: { status: 'available', steps: [{ name: 'run placement' }] }, + }, + }, + workspaceRevision: 9, + }) + expect(JSON.stringify(detail)).not.toContain('Place_ecc/') + + await expect( + runWithWindowScope(49, () => + service.getStepDetail({ + stepId: 'Place', + workspaceContextId: overview.workspaceContextId, + workspaceRevision: 8, + }), + ), + ).resolves.toMatchObject({ + detail: { + status: 'unavailable', + issues: [{ code: 'ENGINEERING_SNAPSHOT_REVISION_MISMATCH' }], + }, + }) + }) + + it('attaches stale Step evidence to an invalidated current Revision', async () => { + const stale = engineeringSnapshot() + stale.schemaVersion = 2 + stale.workspaceRevision = 1 + stale.flow = { + steps: [{ name: 'Place', tool: 'ecc', state: 'Success', runtime: '0:0:2' }], + } + stale.analysis.steps = [ + { + flowState: 'Success', + hotspots: { + artifactId: 'hotspots', + data: null, + reasonCode: 'ANALYSIS_FILE_MISSING', + status: 'missing', + }, + metrics: { + artifactId: 'metrics', + data: { + schema_version: 3, + metrics: [engineeringMetric('place_hpwl', 1234)], + }, + status: 'available', + }, + order: 0, + stepId: 'Place', + subflow: { status: 'available', steps: [] }, + summary: { + artifactId: 'summary', + data: null, + reasonCode: 'ANALYSIS_FILE_MISSING', + status: 'missing', + }, + timingIssues: null, + toolId: 'ecc', + }, + ] + const current = structuredClone(stale) + current.workspaceRevision = 2 + current.stalePredecessor = { + workspaceRevision: 1, + invalidatedStepIds: ['Place'], + } + current.flow = { + steps: [{ name: 'Place', tool: 'ecc', state: 'Unstart', runtime: '0:0:2' }], + } + current.analysis.steps = [] + const readResult = persistedSnapshotResult(current) + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot: vi.fn().mockResolvedValue({ + ...readResult, + staleSnapshot: persistedSnapshotResult(stale), + }), + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + const overview = await runWithWindowScope(51, () => service.getOverview()) + + const detail = await runWithWindowScope(51, () => + service.getStepDetail({ + stepId: 'Place', + workspaceContextId: overview.workspaceContextId, + workspaceRevision: 2, + }), + ) + + expect(detail).toMatchObject({ + detail: { + status: 'ready', + data: { + step: { state: 'not-started' }, + staleEvidence: { + workspaceRevision: 1, + analysis: { metrics: [{ id: 'place_hpwl', value: 1234 }] }, + }, + }, + }, + }) + }) + + it('keeps the previous Dashboard result visible while the current Revision is unstarted', async () => { + const stale = engineeringSnapshot() + stale.workspaceRevision = 1 + const metric = engineeringMetric('instance_count', 298) + stale.flow = { + steps: [{ name: 'Synthesis', tool: 'yosys', state: 'Success' }], + } + stale.artifacts = [ + { + artifactId: 'layout-synthesis', + availability: 'available', + kind: 'layout_image', + name: 'gcd_Synthesis.png', + reference: 'Synthesis_yosys/output/gcd_Synthesis.png', + sha256: '0'.repeat(64), + sizeBytes: 3, + stepId: 'Synthesis', + }, + ] as never + stale.metrics = [metric] + stale.qorAssessment = { + status: 'ready', + score: { gate: 'pass', threshold: 60, value: 73.5 }, + metrics: [metric], + steps: [ + { + stepId: 'Synthesis', + name: 'Synthesis', + order: 0, + status: 'pass', + summaryMetricCount: 1, + }, + ], + } + const current = structuredClone(stale) + current.workspaceRevision = 2 + current.stalePredecessor = { + workspaceRevision: 1, + invalidatedStepIds: ['Synthesis'], + } + current.flow = { + steps: [{ name: 'Synthesis', tool: 'yosys', state: 'Unstart' }], + } + current.artifacts = [] + current.metrics = [] + current.qorAssessment = { + status: 'ready', + score: { gate: 'incomplete', threshold: 60, value: null }, + metrics: [], + steps: [], + } + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot: vi.fn().mockResolvedValue({ + ...persistedSnapshotResult(current), + staleSnapshot: persistedSnapshotResult(stale), + }), + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(53, () => service.getOverview()) + + expect(result.overview.revision).toMatchObject({ + data: { + workspaceRevision: 2, + stalePredecessor: { workspaceRevision: 1, invalidatedStepIds: ['Synthesis'] }, + }, + }) + expect(result.overview.resultFreshness).toEqual({ + status: 'stale', + currentRevision: 2, + staleRevision: 1, + currentStepIds: [], + staleStepIds: ['Synthesis'], + }) + expect(result.overview.qor).toMatchObject({ + data: { + score: { value: 73.5 }, + metrics: [{ id: 'instance_count', value: 298 }], + }, + }) + expect(result.overview.keyMetrics).toMatchObject({ + data: { + items: expect.arrayContaining([ + expect.objectContaining({ id: 'instances', value: 298 }), + ]), + }, + }) + expect(result.overview.artifacts).toMatchObject({ + data: { + items: [ + expect.objectContaining({ artifactId: 'layout-synthesis', sourceRevision: 1 }), + ], + }, + }) + }) + + it('replaces stale Dashboard results after each current-revision Step commit', async () => { + const stale = engineeringSnapshot() + stale.workspaceRevision = 1 + const staleSynthesisMetric = engineeringMetric('instance_count', 298) + const staleSynthesisUtilization = engineeringMetric('core_utilization', 0.4) + const stalePlaceMetric = engineeringMetric('instance_count', 320) + const stalePlaceUtilization = engineeringMetric('core_utilization', 0.4) + stale.flow = { + steps: [ + { name: 'Synthesis', tool: 'yosys', state: 'Success' }, + { name: 'Place', tool: 'dreamplace', state: 'Success' }, + ], + } + stale.artifacts = [ + { + artifactId: 'layout-synthesis-old', + availability: 'available', + kind: 'layout_image', + name: 'gcd_Synthesis.png', + reference: 'Synthesis_yosys/output/gcd_Synthesis.png', + sha256: '0'.repeat(64), + sizeBytes: 3, + stepId: 'Synthesis', + }, + { + artifactId: 'layout-place-old', + availability: 'available', + kind: 'layout_image', + name: 'gcd_Place.png', + reference: 'Place_dreamplace/output/gcd_Place.png', + sha256: '1'.repeat(64), + sizeBytes: 3, + stepId: 'Place', + }, + ] as never + stale.metrics = [ + staleSynthesisMetric, + staleSynthesisUtilization, + stalePlaceMetric, + stalePlaceUtilization, + ] + stale.qorAssessment = { + status: 'ready', + score: { gate: 'pass', threshold: 60, value: 73.5 }, + metrics: [ + staleSynthesisMetric, + staleSynthesisUtilization, + stalePlaceMetric, + stalePlaceUtilization, + ], + steps: [ + { + stepId: 'Synthesis', + name: 'Synthesis', + order: 0, + status: 'pass', + summaryMetricCount: 2, + }, + { + stepId: 'Place', + name: 'Place', + order: 1, + status: 'pass', + summaryMetricCount: 2, + }, + ], + } + + const current = structuredClone(stale) + const currentSynthesisMetric = engineeringMetric('instance_count', 311) + const currentSynthesisUtilization = engineeringMetric('core_utilization', 0.58) + current.workspaceRevision = 4 + current.stalePredecessor = { + workspaceRevision: 1, + invalidatedStepIds: ['Synthesis', 'Place'], + } + current.flow = { + steps: [ + { name: 'Synthesis', tool: 'yosys', state: 'Success' }, + { name: 'Place', tool: 'dreamplace', state: 'Ongoing' }, + ], + } + current.artifacts = [ + { + artifactId: 'layout-synthesis-current', + availability: 'available', + kind: 'layout_image', + name: 'gcd_Synthesis.png', + reference: 'Synthesis_yosys/output/gcd_Synthesis.png', + sha256: '2'.repeat(64), + sizeBytes: 3, + stepId: 'Synthesis', + }, + ] as never + current.metrics = [currentSynthesisMetric, currentSynthesisUtilization] + current.qorAssessment = { + status: 'ready', + score: { gate: 'incomplete', threshold: 60, value: null }, + metrics: [currentSynthesisMetric, currentSynthesisUtilization], + steps: [ + { + stepId: 'Synthesis', + name: 'Synthesis', + order: 0, + status: 'pass', + summaryMetricCount: 2, + }, + ], + } + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot: vi.fn().mockResolvedValue({ + ...persistedSnapshotResult(current), + staleSnapshot: persistedSnapshotResult(stale), + }), + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(55, () => service.getOverview()) + + expect(result.overview.resultFreshness).toEqual({ + status: 'mixed', + currentRevision: 4, + staleRevision: 1, + currentStepIds: ['Synthesis'], + staleStepIds: ['Place'], + }) + expect(result.overview.qor).toMatchObject({ + data: { + metrics: [ + { id: 'instance_count', stepId: 'Synth', value: 311 }, + { id: 'core_utilization', stepId: 'Synth', value: 0.58 }, + { id: 'instance_count', stepId: 'Place', value: 320 }, + { id: 'core_utilization', stepId: 'Place', value: 0.4 }, + ], + }, + }) + expect(result.overview.keyMetrics).toMatchObject({ + data: { + items: expect.arrayContaining([ + expect.objectContaining({ id: 'core-utilization', value: 0.58 }), + expect.objectContaining({ id: 'instances', value: 311 }), + ]), + }, + }) + expect(result.overview.flowInsights).toMatchObject({ + data: { + trends: expect.arrayContaining([ + expect.objectContaining({ + id: 'instance_count', + points: expect.arrayContaining([ + expect.objectContaining({ stepId: 'Synthesis', value: 311 }), + expect.objectContaining({ stepId: 'Place', value: 320 }), + ]), + }), + ]), + }, + }) + expect(result.overview.artifacts).toMatchObject({ + data: { + items: [ + expect.objectContaining({ + artifactId: 'layout-synthesis-current', + }), + expect.objectContaining({ + artifactId: 'layout-place-old', + sourceRevision: 1, + }), + ], + }, + }) + const artifacts = result.overview.artifacts + expect( + artifacts?.status === 'ready' ? artifacts.data.items[0] : null, + ).not.toHaveProperty('sourceRevision') + }) + + it('matches invalidated flow aliases when current QoR covers the rerun', async () => { + const stale = engineeringSnapshot() + stale.workspaceRevision = 1 + const metric = engineeringMetric('instance_count', 298) + stale.flow = { steps: [{ name: 'Floorplan', tool: 'ecc', state: 'Success' }] } + stale.metrics = [metric] + stale.qorAssessment = { + status: 'ready', + score: { gate: 'pass', threshold: 60, value: 73.5 }, + metrics: [metric], + steps: [ + { + stepId: 'Floorplan', + name: 'Floorplan', + order: 0, + status: 'pass', + summaryMetricCount: 1, + }, + ], + } + const current = structuredClone(stale) + current.workspaceRevision = 2 + current.stalePredecessor = { + workspaceRevision: 1, + invalidatedStepIds: ['Floorplan'], + } + ;(current.qorAssessment.score as { value: number }).value = 80 + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot: vi.fn().mockResolvedValue({ + ...persistedSnapshotResult(current), + staleSnapshot: persistedSnapshotResult(stale), + }), + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(54, () => service.getOverview()) + + expect(result.overview.qor).toMatchObject({ data: { score: { value: 80 } } }) + expect(result.overview.resultFreshness).toMatchObject({ + status: 'current', + currentRevision: 2, + staleStepIds: [], + }) + }) + + it('returns an empty Step detail when neither current nor stale results exist', async () => { + const stale = engineeringSnapshot() + stale.schemaVersion = 2 + stale.workspaceRevision = 1 + stale.flow = { + steps: [{ name: 'Floorplan', tool: 'ecc', state: 'Unstart' }], + } + const current = structuredClone(stale) + current.workspaceRevision = 2 + current.stalePredecessor = { + workspaceRevision: 1, + invalidatedStepIds: ['Floorplan'], + } + const readResult = persistedSnapshotResult(current) + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot: vi.fn().mockResolvedValue({ + ...readResult, + staleSnapshot: persistedSnapshotResult(stale), + }), + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + const overview = await runWithWindowScope(52, () => service.getOverview()) + + const detail = await runWithWindowScope(52, () => + service.getStepDetail({ + stepId: 'Floorplan', + workspaceContextId: overview.workspaceContextId, + workspaceRevision: 2, + }), + ) + + expect(detail.detail).toMatchObject({ + status: 'ready', + data: { + analysis: { metrics: [], summary: null }, + step: { state: 'not-started', stepId: 'Floorplan' }, + subflow: { status: 'missing', steps: [] }, + }, + }) + expect(detail.detail).not.toHaveProperty('data.staleEvidence') + }) + + it('returns bounded LVS detail from the committed analysis projection', async () => { + const snapshot = engineeringSnapshot() + snapshot.schemaVersion = 2 + snapshot.flow = { steps: [{ name: 'LVS', tool: 'ecc', state: 'Success' }] } + const lvsMetric = engineeringMetric('lvs_count', 1, { + direction: 'lower_is_better', + }) + snapshot.metrics = [lvsMetric] + snapshot.qorAssessment = { + status: 'ready', + score: { gate: 'blocked', threshold: 60, value: 60 }, + metrics: [lvsMetric], + steps: [ + { + stepId: 'LVS', + name: 'LVS', + order: 0, + status: 'blocked', + summaryMetricCount: 1, + }, + ], + } + snapshot.analysis.steps = [ + { + stepId: 'LVS', + toolId: 'ecc', + order: 0, + flowState: 'Success', + metrics: { + artifactId: 'lvs-metrics', + status: 'available', + data: { + schema_version: 3, + metrics: [lvsMetric], + details: [ + { + id: 'lvs_connectivity_summary', + summary: { + entities: [{ entity: 'nets', netlist: 10, def: 9, difference: 1 }], + connectivity: [], + violations: [ + { + type: 'open', + net: 'n1', + instance: '', + terminals: 'A, B', + components: '', + }, + ], + }, + }, + ], + }, + }, + summary: { + artifactId: 'lvs-summary', + status: 'missing', + reasonCode: 'ANALYSIS_FILE_MISSING', + data: null, + }, + hotspots: { + artifactId: 'lvs-hotspots', + status: 'missing', + reasonCode: 'ANALYSIS_FILE_MISSING', + data: null, + }, + timingIssues: null, + subflow: { status: 'missing', steps: [] }, + }, + ] + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(snapshot), + workspaceRootProvider: workspaceRootProvider(), + }) + const overview = await runWithWindowScope(57, () => service.getOverview()) + + const result = await runWithWindowScope(57, () => + service.getStepDetail({ + stepId: 'LVS', + workspaceContextId: overview.workspaceContextId, + workspaceRevision: 1, + }), + ) + + expect(result.detail).toMatchObject({ + status: 'ready', + data: { + analysis: { + lvs: { + entities: [{ entity: 'nets', difference: 1 }], + violations: [{ type: 'open', net: 'n1', terminals: 'A, B' }], + }, + }, + }, + }) + }) + + it('reads a declared layout Artifact by identity and Snapshot revision', async () => { + const snapshot = engineeringSnapshot() + snapshot.artifacts = [ + { + artifactId: 'layout-place', + availability: 'available', + kind: 'layout_image', + name: 'gcd_Place.png', + reference: 'Place_ecc/output/gcd_Place.png', + sha256: 'a'.repeat(64), + sizeBytes: 3, + stepId: 'Place', + }, + ] as never + const expectedBytes = new Uint8Array([1, 2, 3]) + const projectManagementReadService = { + ...persistedReadService(snapshot), + expectedBytes, + readVerifiedArtifact: vi.fn(function (this: { expectedBytes?: Uint8Array }) { + if (!this.expectedBytes) throw new Error('reader lost its receiver') + return Promise.resolve({ ok: true as const, bytes: this.expectedBytes }) + }), + } + const service = new BackendWorkspaceService({ + projectManagementReadService, + workspaceRootProvider: workspaceRootProvider(), + }) + const overview = await runWithWindowScope(50, () => service.getOverview()) + + const result = await runWithWindowScope(50, () => + service.getArtifact({ + artifactId: 'layout-place', + workspaceContextId: overview.workspaceContextId, + workspaceRevision: 1, + }), + ) + + expect(projectManagementReadService.readVerifiedArtifact).toHaveBeenCalledWith({ + artifact: { + reference: 'Place_ecc/output/gcd_Place.png', + sha256: 'a'.repeat(64), + sizeBytes: 3, + }, + projectRoot: '/project', + workspacePath: '/project/ws-a', + }) + expect(result).toMatchObject({ + artifact: { + status: 'ready', + data: { + artifactId: 'layout-place', + bytes: new Uint8Array([1, 2, 3]), + mimeType: 'image/png', + }, + }, + workspaceRevision: 1, + }) + expect(JSON.stringify(result)).not.toContain('Place_ecc/') + }) + + it('parses timing Artifacts only for the current Snapshot revision', async () => { + const snapshot = engineeringSnapshot() + snapshot.workspaceRevision = 2 + const validBytes = new TextEncoder().encode( + JSON.stringify({ + schema_version: 1, + corner: 'MAX_125/RCworst', + path_limit: 10, + paths: [ + { + path_id: 'setup-1', + analysis_type: 'setup', + path_group: 'clk', + start_point: 'u0/Q', + end_point: 'u1/D', + slack_ns: -0.1, + stages: [], + }, + ], + }), + ) + snapshot.artifacts = [ + { + artifactId: 'timing-paths-sta', + availability: 'available', + kind: 'timing_paths', + name: 'timing_paths.json', + reference: 'STA_ecc/feature/MAX_125/RCworst/timing_paths.json', + sha256: 'b'.repeat(64), + sizeBytes: validBytes.byteLength, + stepId: 'STA', + }, + ] as never + const readVerifiedArtifact = vi + .fn() + .mockResolvedValueOnce({ ok: true as const, bytes: validBytes }) + .mockResolvedValueOnce({ + ok: true as const, + bytes: new TextEncoder().encode('{'), + }) + const service = new BackendWorkspaceService({ + projectManagementReadService: { + ...persistedReadService(snapshot), + readVerifiedArtifact, + }, + workspaceRootProvider: workspaceRootProvider(), + }) + const overview = await runWithWindowScope(58, () => service.getOverview()) + const request = { + artifactId: 'timing-paths-sta', + workspaceContextId: overview.workspaceContextId, + workspaceRevision: 2, + } + + const result = await runWithWindowScope(58, () => service.getArtifact(request)) + expect(result.artifact).toMatchObject({ + status: 'ready', + data: { + timingPaths: { + corner: 'MAX_125/RCworst', + pathLimit: 10, + paths: [{ pathId: 'setup-1', slackNs: -0.1 }], + }, + }, + }) + + await expect( + runWithWindowScope(58, () => + service.getArtifact({ ...request, workspaceRevision: 1 }), + ), + ).resolves.toMatchObject({ + artifact: { + status: 'unavailable', + issues: [{ code: 'ENGINEERING_SNAPSHOT_REVISION_MISMATCH' }], + }, + }) + expect(readVerifiedArtifact).toHaveBeenCalledTimes(1) + + await expect( + runWithWindowScope(58, () => service.getArtifact(request)), + ).resolves.toMatchObject({ + artifact: { + status: 'unavailable', + issues: [{ code: 'ARTIFACT_INVALID_JSON' }], + }, + }) + }) + + it('rejects a failed refresh while retaining the last verified revision', async () => { + const snapshot = engineeringSnapshot() + snapshot.workspaceRevision = 6 + const readEngineeringSnapshot = vi + .fn() + .mockResolvedValueOnce(persistedSnapshotResult(snapshot)) + .mockResolvedValue({ + ok: false, + readBytes: 0, + issue: { code: 'ENGINEERING_SNAPSHOT_MISSING' }, + }) + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot, + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + const initial = await runWithWindowScope(51, () => service.getOverview()) + + await expect(runWithWindowScope(51, () => service.refreshOverview())).rejects.toThrow( + 'ENGINEERING_SNAPSHOT_MISSING', + ) + const detail = await runWithWindowScope(51, () => + service.getStepDetail({ + stepId: 'missing', + workspaceContextId: initial.workspaceContextId, + workspaceRevision: 6, + }), + ) + expect(detail.workspaceRevision).toBe(6) + }) + + it('retains the last verified revision when the Project Manifest is transiently invalid', async () => { + const snapshot = engineeringSnapshot() + snapshot.workspaceRevision = 6 + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot: vi + .fn() + .mockResolvedValue(persistedSnapshotResult(snapshot)), + readManifest: vi + .fn() + .mockResolvedValueOnce(manifestForWorkspace()) + .mockResolvedValueOnce(null), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + const initial = await runWithWindowScope(59, () => service.getOverview()) + + await expect(runWithWindowScope(59, () => service.refreshOverview())).rejects.toThrow( + 'PROJECT_MANIFEST_READ_FAILED', + ) + const detail = await runWithWindowScope(59, () => + service.getStepDetail({ + stepId: 'missing', + workspaceContextId: initial.workspaceContextId, + workspaceRevision: 6, + }), + ) + expect(detail.workspaceRevision).toBe(6) + }) + + it('does not let an invalidated query replace the newer committed snapshot', async () => { + const first = engineeringSnapshot() + const second = structuredClone(first) + second.workspaceRevision = 2 + let resolveFirst!: (value: ProjectEngineeringSnapshotReadResult) => void + const readEngineeringSnapshot = vi + .fn() + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve + }), + ) + .mockResolvedValueOnce(persistedSnapshotResult(second)) + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot, + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + + const staleQuery = runWithWindowScope(52, () => service.getOverview()) + const fresh = await runWithWindowScope(52, () => service.refreshOverview()) + resolveFirst(persistedSnapshotResult(first)) + await staleQuery + const detail = await runWithWindowScope(52, () => + service.getStepDetail({ + stepId: 'missing', + workspaceContextId: fresh.workspaceContextId, + workspaceRevision: 2, + }), + ) + + expect(fresh.overview.revision).toMatchObject({ + status: 'ready', + data: { workspaceRevision: 2 }, + }) + expect(detail.workspaceRevision).toBe(2) + }) + + it.each([ + ['ENGINEERING_SNAPSHOT_REVISION_REGRESSION', { workspaceRevision: 1 }], + ['ENGINEERING_WORKSPACE_ID_MISMATCH', { workspaceId: 'replacement' }], + ])('rejects %s while retaining the last verified snapshot', async (code, patch) => { + const initial = engineeringSnapshot() + initial.workspaceRevision = 2 + const invalid = { ...structuredClone(initial), ...patch } + const readEngineeringSnapshot = vi + .fn() + .mockResolvedValueOnce(persistedSnapshotResult(initial)) + .mockResolvedValueOnce(persistedSnapshotResult(invalid)) + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot, + readManifest: vi.fn().mockResolvedValue(manifestForWorkspace()), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + const overview = await runWithWindowScope(53, () => service.getOverview()) + + await expect(runWithWindowScope(53, () => service.refreshOverview())).rejects.toThrow( + code, + ) + const detail = await runWithWindowScope(53, () => + service.getStepDetail({ + stepId: 'missing', + workspaceContextId: overview.workspaceContextId, + workspaceRevision: 2, + }), + ) + expect(detail.workspaceRevision).toBe(2) + }) + + it('does not read a baseline workspace declared outside the active project', async () => { + const manifest = manifestWithBaseline() + const baseline = manifest.workspaces.find( + (workspace) => workspace.workspace_id === 'ws-base', + )! + baseline.workspace_path = '/outside/ws-base' + const readEngineeringSnapshot = vi + .fn() + .mockResolvedValue(persistedSnapshotResult(engineeringSnapshot())) + const service = new BackendWorkspaceService({ + projectManagementReadService: { + readEngineeringSnapshot, + readManifest: vi.fn().mockResolvedValue(manifest), + }, + workspaceRootProvider: workspaceRootProvider(), + }) + + const result = await runWithWindowScope(54, () => service.getOverview()) + + expect(readEngineeringSnapshot).toHaveBeenCalledOnce() + expect(result.overview.baselineComparison).toMatchObject({ + status: 'unavailable', + }) + }) + + it('invalidates the committed projection when its snapshot watcher fires', async () => { + let callbacks!: ProjectComparisonFileWatcherCallbacks + const watcher = { + close: vi.fn().mockResolvedValue(undefined), + reconcile: vi.fn().mockResolvedValue(undefined), + startProject: vi.fn().mockResolvedValue(undefined), + } as unknown as ProjectComparisonFileWatcher + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService( + engineeringSnapshot(), + manifestWithBaseline(), + ), + snapshotWatcherFactory: (next) => { + callbacks = next + return watcher + }, + workspaceRootProvider: workspaceRootProvider(), + }) + const initial = await runWithWindowScope(55, () => service.getOverview()) + const listener = vi.fn() + service.onInvalidated(listener) + + expect(watcher.startProject).toHaveBeenCalledWith('/project') + expect(watcher.reconcile).toHaveBeenCalledWith('/project', [ + '/project/ws-a', + '/project/ws-base', + ]) + callbacks.onSnapshotChanged('/project/ws-base') + + expect(listener).toHaveBeenCalledWith({ + generation: 1, + windowId: 55, + workspaceContextId: initial.workspaceContextId, + }) + callbacks.onManifestChanged() + expect(listener).toHaveBeenLastCalledWith({ + generation: 2, + windowId: 55, + workspaceContextId: initial.workspaceContextId, + }) + }) + + it('closes a partially started Snapshot watcher', async () => { + const watcher = { + close: vi.fn().mockResolvedValue(undefined), + reconcile: vi.fn().mockRejectedValue(new Error('reconcile failed')), + startProject: vi.fn().mockResolvedValue(undefined), + } as unknown as ProjectComparisonFileWatcher + const service = new BackendWorkspaceService({ + projectManagementReadService: persistedReadService(engineeringSnapshot()), + snapshotWatcherFactory: () => watcher, + workspaceRootProvider: workspaceRootProvider(), + }) + + await runWithWindowScope(60, () => service.getOverview()) + + await vi.waitFor(() => expect(watcher.close).toHaveBeenCalledOnce()) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.ts new file mode 100644 index 000000000..91dc26377 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.ts @@ -0,0 +1,730 @@ +import { randomUUID } from 'node:crypto' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { + type BackendWorkspaceOverviewResult, + type BackendWorkspaceArtifactRequest, + type BackendWorkspaceArtifactResult, + type BackendWorkspaceStepDetailRequest, + type BackendWorkspaceStepDetailResult, + type EngineeringSnapshotValidationResult, + type ProjectManifest, + type ReadIssue, + type ReadSection, + type WorkspaceDashboardMetric, + type WorkspaceFlowInsightsSummary, + type WorkspaceOverviewCore, + type WorkspaceArtifactDescriptor, + type WorkspaceBaselineComparison, + type WorkspaceQorSummary, +} from '@ecos-studio/shared' +import { requireWindowScopeId } from './windowScopeContext' +import { + analyzeWorkspaceQor, + type WorkspaceEngineeringFacts, +} from './workspaceQorAnalysis' +import { electronLogger } from './logger' +import { workspaceDashboardMetrics } from './workspaceDashboardAnalysis' +import type { ProjectEngineeringSnapshotReadResult } from './projectManagementReadService' +import { artifactDescriptor, workspaceStepDetail } from './backendWorkspaceDetail' +import { + readWorkspaceArtifact, + type WorkspaceArtifactReader, +} from './backendWorkspaceArtifact' +import type { + ProjectComparisonFileWatcher, + ProjectComparisonFileWatcherCallbacks, +} from './projectComparisonFileWatcher' +import { + checklistSection, + configurationSection, + flowSection, + identityFromManifest, + pathsEqual, +} from './backendWorkspaceOverviewProjection' +import { flowInsightsSection } from './backendWorkspaceFlowInsights' +import { isPathWithinRoot } from './pathScope' +import { projectWorkspaceResults } from './backendWorkspaceResultProjection' + +interface BackendWorkspaceServiceOptions { + workspaceRootProvider: { + getProjectRoot(): Promise + } + projectManagementReadService: { + readManifest(projectRoot: string): Promise + readEngineeringSnapshot(request: { + projectRoot: string + workspacePath: string + }): Promise + readVerifiedArtifact?: WorkspaceArtifactReader + } + snapshotWatcherFactory?: ( + callbacks: ProjectComparisonFileWatcherCallbacks, + ) => ProjectComparisonFileWatcher +} + +interface WorkspaceContext { + id: string + generation: number + cache?: BackendWorkspaceOverviewResult + inFlight?: Promise + coalescedRequests: number + snapshot?: ProjectEngineeringSnapshotReadResult + workspaceRoot?: string + watchKey?: string + watchedRoots?: string[] + watcher?: ProjectComparisonFileWatcher + flowInsights?: ReadSection + windowId: number +} + +interface BuiltWorkspaceOverview { + flowInsights: ReadSection + result: BackendWorkspaceOverviewResult + snapshot: ProjectEngineeringSnapshotReadResult | null + workspaceRoot: string + watchedRoots: string[] +} + +export interface BackendWorkspaceInvalidation { + windowId: number + workspaceContextId: string + generation: number +} + +const NOT_MIGRATED_ISSUE: ReadIssue = { code: 'BACKEND_SECTION_NOT_MIGRATED' } +const SNAPSHOT_SECTIONS = ['artifacts', 'flow', 'qor', 'signoff'] as const +type ValidEngineeringSnapshot = Extract + +function unavailable(): ReadSection { + return { status: 'unavailable', issues: [NOT_MIGRATED_ISSUE] } +} + +function snapshotSectionItemCount( + snapshot: ValidEngineeringSnapshot, + section: (typeof SNAPSHOT_SECTIONS)[number], +): number { + if (section === 'flow') { + const value = snapshot.sections.flow + if (value.status !== 'ready' && value.status !== 'partial') return 0 + const steps = value.data.steps + return Array.isArray(steps) ? steps.length : 0 + } + if (section === 'qor') { + const value = snapshot.sections.qor + return value.status === 'ready' || value.status === 'partial' + ? value.data.metrics.length + : 0 + } + if (section === 'artifacts') { + const value = snapshot.sections.artifacts + return value.status === 'ready' || value.status === 'partial' ? value.data.length : 0 + } + const value = snapshot.sections.signoff + return value.status === 'ready' || value.status === 'partial' + ? value.data.groups.length + value.data.risks.length + : 0 +} + +function snapshotSectionRegressed( + previous: ValidEngineeringSnapshot, + current: ValidEngineeringSnapshot & { + staleSnapshot?: ValidEngineeringSnapshot + }, +): boolean { + return SNAPSHOT_SECTIONS.some((section) => { + const previousStatus = previous.sections[section].status + const currentStatus = current.sections[section].status + const previousAvailable = previousStatus === 'ready' || previousStatus === 'partial' + const currentAvailable = + currentStatus === 'ready' || + currentStatus === 'partial' || + current.staleSnapshot?.sections[section].status === 'ready' || + current.staleSnapshot?.sections[section].status === 'partial' + const currentHasData = snapshotSectionItemCount(current, section) > 0 + const previousHasData = snapshotSectionItemCount(previous, section) > 0 + const staleHasData = current.staleSnapshot + ? snapshotSectionItemCount(current.staleSnapshot, section) > 0 + : false + return ( + previousAvailable && + (!currentAvailable || (previousHasData && !currentHasData && !staleHasData)) + ) + }) +} + +export class BackendWorkspaceService { + private readonly contexts = new Map() + private readonly invalidationListeners = new Set< + (event: BackendWorkspaceInvalidation) => void + >() + + constructor(private readonly options: BackendWorkspaceServiceOptions) {} + + async getOverview(): Promise { + const windowId = requireWindowScopeId() + const context = this.contextForWindow(windowId) + if (context.cache) return context.cache + if (context.inFlight) { + context.coalescedRequests += 1 + return await context.inFlight + } + + const generation = context.generation + let query: Promise + query = this.buildOverview(context, generation) + .then(({ flowInsights, result, snapshot, workspaceRoot, watchedRoots }) => { + const current = this.contexts.get(windowId) + if (current === context && current.generation === generation) { + current.cache = result + current.flowInsights = flowInsights + if (snapshot?.ok || !current.snapshot) current.snapshot = snapshot ?? undefined + current.workspaceRoot = workspaceRoot + this.observeWorkspace(current, workspaceRoot, watchedRoots) + current.coalescedRequests = 0 + } + return result + }) + .finally(() => { + if (context.inFlight === query) context.inFlight = undefined + }) + context.inFlight = query + return await query + } + + async refreshOverview(): Promise { + const windowId = requireWindowScopeId() + this.invalidateWindow(windowId, false) + return await this.getOverview() + } + + async checkForUpdates(windowId: number): Promise { + const context = this.contexts.get(windowId) + if (!context?.workspaceRoot || !context.snapshot?.ok) return + const latest = await this.readEngineeringSnapshot(context.workspaceRoot) + if ( + latest?.ok && + (latest.snapshot.workspaceId !== context.snapshot.snapshot.workspaceId || + latest.snapshot.workspaceRevision !== context.snapshot.snapshot.workspaceRevision) + ) { + this.invalidateWindow(windowId) + } + } + + async getStepDetail( + request: BackendWorkspaceStepDetailRequest, + ): Promise { + const context = this.contextForWindow(requireWindowScopeId()) + if ( + !request || + typeof request.stepId !== 'string' || + !request.stepId.trim() || + typeof request.workspaceContextId !== 'string' || + !Number.isSafeInteger(request.workspaceRevision) || + request.workspaceRevision < 1 + ) { + return this.unavailableStepDetail(context, 'BACKEND_WORKSPACE_REQUEST_INVALID') + } + if (request.workspaceContextId !== context.id) { + return this.unavailableStepDetail(context, 'BACKEND_WORKSPACE_CONTEXT_MISMATCH') + } + if (!context.snapshot) await this.getOverview() + const snapshot = context.snapshot + if (!snapshot?.ok) { + return this.unavailableStepDetail( + context, + snapshot?.issue.code ?? 'ENGINEERING_SNAPSHOT_READ_FAILED', + ) + } + if (snapshot.snapshot.workspaceRevision !== request.workspaceRevision) { + return this.unavailableStepDetail( + context, + 'ENGINEERING_SNAPSHOT_REVISION_MISMATCH', + snapshot, + ) + } + const flow = flowSection(snapshot) + const checklist = checklistSection(snapshot, flow) + const insights = context.flowInsights + return { + detail: workspaceStepDetail( + snapshot, + request.stepId, + flow, + checklist, + insights?.status === 'ready' || insights?.status === 'partial' + ? insights.data + : null, + snapshot.staleSnapshot, + ), + generation: context.generation, + workspaceContextId: context.id, + workspaceId: snapshot.snapshot.workspaceId, + workspaceRevision: snapshot.snapshot.workspaceRevision, + } + } + + async getArtifact( + request: BackendWorkspaceArtifactRequest, + ): Promise { + const context = this.contextForWindow(requireWindowScopeId()) + const unavailable = (code: string): BackendWorkspaceArtifactResult => ({ + artifact: { status: 'unavailable', issues: [{ code }] }, + generation: context.generation, + workspaceContextId: context.id, + ...(context.snapshot?.ok + ? { + workspaceId: context.snapshot.snapshot.workspaceId, + workspaceRevision: context.snapshot.snapshot.workspaceRevision, + } + : {}), + }) + if ( + !request || + typeof request.artifactId !== 'string' || + !request.artifactId || + typeof request.workspaceContextId !== 'string' || + !Number.isSafeInteger(request.workspaceRevision) || + request.workspaceRevision < 1 + ) { + return unavailable('BACKEND_WORKSPACE_REQUEST_INVALID') + } + if (request.workspaceContextId !== context.id) { + return unavailable('BACKEND_WORKSPACE_CONTEXT_MISMATCH') + } + const currentSnapshot = context.snapshot + if (!currentSnapshot?.ok || !context.workspaceRoot) { + return unavailable('ENGINEERING_SNAPSHOT_READ_FAILED') + } + const snapshot = + currentSnapshot.snapshot.workspaceRevision === request.workspaceRevision + ? currentSnapshot + : currentSnapshot.staleSnapshot?.snapshot.workspaceRevision === + request.workspaceRevision + ? currentSnapshot.staleSnapshot + : null + if (!snapshot) { + return unavailable('ENGINEERING_SNAPSHOT_REVISION_MISMATCH') + } + return { + artifact: await readWorkspaceArtifact( + snapshot, + context.workspaceRoot, + request.artifactId, + this.options.projectManagementReadService.readVerifiedArtifact + ? (artifactRequest) => + this.options.projectManagementReadService.readVerifiedArtifact!( + artifactRequest, + ) + : undefined, + ), + generation: context.generation, + workspaceContextId: context.id, + workspaceId: snapshot.snapshot.workspaceId, + workspaceRevision: snapshot.snapshot.workspaceRevision, + } + } + + invalidateWindow(windowId: number, notify = true): void { + const context = this.contexts.get(windowId) + if (!context) return + context.generation += 1 + context.cache = undefined + context.inFlight = undefined + if (notify) { + const event = { + generation: context.generation, + windowId, + workspaceContextId: context.id, + } + for (const listener of this.invalidationListeners) listener(event) + } + } + + onInvalidated(listener: (event: BackendWorkspaceInvalidation) => void): () => void { + this.invalidationListeners.add(listener) + return () => this.invalidationListeners.delete(listener) + } + + clearWindow(windowId: number): void { + const context = this.contexts.get(windowId) + this.contexts.delete(windowId) + void context?.watcher?.close() + if (!context) return + const event = { + generation: context.generation + 1, + windowId, + workspaceContextId: context.id, + } + for (const listener of this.invalidationListeners) listener(event) + } + + private contextForWindow(windowId: number): WorkspaceContext { + const existing = this.contexts.get(windowId) + if (existing) return existing + const context = { + id: randomUUID(), + generation: 0, + coalescedRequests: 0, + windowId, + } + this.contexts.set(windowId, context) + return context + } + + private async buildOverview( + context: WorkspaceContext, + generation: number, + ): Promise { + const startedAt = performance.now() + const eventLoopDelay = eventLoopDelayMs() + const readStartedAt = performance.now() + const previousSnapshot = context.snapshot + const previousWorkspaceRoot = context.workspaceRoot + const workspaceRoot = await this.options.workspaceRootProvider.getProjectRoot() + const [manifest, snapshot] = await Promise.all([ + this.readManifest(workspaceRoot), + this.readEngineeringSnapshot(workspaceRoot), + ]) + if (!manifest && previousSnapshot?.ok && previousWorkspaceRoot) { + throw new Error('PROJECT_MANIFEST_READ_FAILED') + } + const projectRoot = dirname(workspaceRoot) + const baseline = manifest?.workspaces.find( + (workspace) => workspace.workspace_id === manifest.qor_baseline?.workspace_id, + ) + const baselineRoot = baseline ? resolve(baseline.workspace_path) : null + const watchedRoots = [ + workspaceRoot, + ...(baselineRoot && + baselineRoot !== projectRoot && + isPathWithinRoot(baselineRoot, projectRoot) && + !pathsEqual(baselineRoot, workspaceRoot) + ? [baselineRoot] + : []), + ] + if (!snapshot?.ok && previousSnapshot?.ok) { + throw new Error(snapshot?.issue.code ?? 'ENGINEERING_SNAPSHOT_READ_FAILED') + } + if ( + snapshot?.ok && + previousSnapshot?.ok && + previousWorkspaceRoot && + pathsEqual(workspaceRoot, previousWorkspaceRoot) + ) { + if (snapshot.snapshot.workspaceId !== previousSnapshot.snapshot.workspaceId) { + throw new Error('ENGINEERING_WORKSPACE_ID_MISMATCH') + } + if ( + snapshot.snapshot.workspaceRevision < previousSnapshot.snapshot.workspaceRevision + ) { + throw new Error('ENGINEERING_SNAPSHOT_REVISION_REGRESSION') + } + if (snapshotSectionRegressed(previousSnapshot, snapshot)) { + throw new Error('ENGINEERING_SNAPSHOT_SECTION_INVALID') + } + } + const resultProjection = snapshot?.ok ? projectWorkspaceResults(snapshot) : null + const displaySnapshot = resultProjection?.snapshot ?? snapshot + const flow = flowSection(snapshot) + const checklist = checklistSection(displaySnapshot, flow) + const qor = await this.readQor(workspaceRoot, manifest, displaySnapshot) + const flowInsights = flowInsightsSection(snapshot, flow, qor.qor) + const displayFlowInsights = flowInsightsSection(displaySnapshot, flow, qor.qor) + const displayArtifacts = artifactSection( + displaySnapshot, + resultProjection?.staleArtifactIds, + resultProjection?.freshness.staleRevision, + ) + const keyMetrics = this.readKeyMetrics( + qor.qor, + resultProjection?.freshness.currentStepIds, + ) + const readMs = performance.now() - readStartedAt + const normalizeStartedAt = performance.now() + const overview: WorkspaceOverviewCore = { + revision: snapshot?.ok + ? { + status: 'ready', + data: { + workspaceId: snapshot.snapshot.workspaceId, + workspaceRevision: snapshot.snapshot.workspaceRevision, + ...(snapshot.snapshot.stalePredecessor + ? { stalePredecessor: snapshot.snapshot.stalePredecessor } + : {}), + }, + issues: [], + } + : { + status: 'unavailable', + issues: [snapshot?.issue ?? { code: 'ENGINEERING_SNAPSHOT_READ_FAILED' }], + }, + ...(resultProjection ? { resultFreshness: resultProjection.freshness } : {}), + artifacts: displayArtifacts, + identity: identityFromManifest(workspaceRoot, manifest), + configuration: configurationSection(snapshot), + flow, + flowInsights: displayFlowInsights, + checklist, + qor: qor.qor, + keyMetrics, + baselineComparison: qor.baselineComparison, + } + const result = { + workspaceContextId: context.id, + generation, + overview, + } + electronLogger.debug('[backend-workspace] query metrics', { + baselineSnapshotReads: + manifest?.qor_baseline?.workspace_id && + manifest.qor_baseline.workspace_id !== overview.identity.workspaceId + ? 1 + : 0, + coalescedRequests: context.coalescedRequests, + eventLoopDelayMs: roundMs(await eventLoopDelay), + snapshotFileCount: snapshot ? 1 : 0, + snapshotBytes: snapshot?.readBytes ?? 0, + ipcPayloadBytes: Buffer.byteLength(JSON.stringify(result)), + normalizeMs: roundMs(performance.now() - normalizeStartedAt), + readMs: roundMs(readMs), + totalMs: roundMs(performance.now() - startedAt), + }) + return { flowInsights, result, snapshot, workspaceRoot, watchedRoots } + } + + private async readManifest(workspaceRoot: string): Promise { + try { + return await this.options.projectManagementReadService.readManifest( + dirname(workspaceRoot), + ) + } catch { + return null + } + } + + private async readEngineeringSnapshot( + workspaceRoot: string, + ): Promise { + try { + return await this.options.projectManagementReadService.readEngineeringSnapshot({ + projectRoot: dirname(workspaceRoot), + workspacePath: workspaceRoot, + }) + } catch { + return null + } + } + + private async readQor( + workspaceRoot: string, + manifest: ProjectManifest | null, + snapshot: ProjectEngineeringSnapshotReadResult | null, + ): Promise<{ + qor: ReadSection + baselineComparison: ReadSection + }> { + const currentWorkspace = manifest?.workspaces.find((workspace) => + pathsEqual(workspace.workspace_path, workspaceRoot), + ) + if (!manifest || !currentWorkspace) { + return { qor: unavailable(), baselineComparison: unavailable() } + } + + const baselineWorkspaceId = manifest.qor_baseline?.workspace_id + const requestedIds = [ + currentWorkspace.workspace_id, + ...(baselineWorkspaceId && baselineWorkspaceId !== currentWorkspace.workspace_id + ? [baselineWorkspaceId] + : []), + ] + const snapshotsByWorkspaceId: Record = {} + const failedIds = new Set() + await Promise.all( + requestedIds.map(async (workspaceId) => { + const workspace = manifest.workspaces.find( + (candidate) => candidate.workspace_id === workspaceId, + ) + if (!workspace) { + failedIds.add(workspaceId) + return + } + const projectRoot = dirname(workspaceRoot) + const candidate = resolve(workspace.workspace_path) + if (candidate === projectRoot || !isPathWithinRoot(candidate, projectRoot)) { + failedIds.add(workspaceId) + return + } + if (workspaceId === currentWorkspace.workspace_id && snapshot) { + snapshotsByWorkspaceId[workspaceId] = engineeringFacts(snapshot) + if (!snapshotsByWorkspaceId[workspaceId]) failedIds.add(workspaceId) + return + } + try { + snapshotsByWorkspaceId[workspaceId] = engineeringFacts( + await this.readEngineeringSnapshot(workspace.workspace_path), + ) + if (!snapshotsByWorkspaceId[workspaceId]) failedIds.add(workspaceId) + } catch { + snapshotsByWorkspaceId[workspaceId] = null + failedIds.add(workspaceId) + } + }), + ) + if (failedIds.has(currentWorkspace.workspace_id)) { + return { + qor: { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_QOR_UNAVAILABLE' }], + }, + baselineComparison: { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_BASELINE_UNAVAILABLE' }], + }, + } + } + + const result = analyzeWorkspaceQor( + manifest, + currentWorkspace.workspace_id, + snapshotsByWorkspaceId, + ) + if (baselineWorkspaceId && failedIds.has(baselineWorkspaceId)) { + result.baselineComparison = { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_BASELINE_UNAVAILABLE' }], + } + } + return result + } + + private readKeyMetrics( + qor: ReadSection, + currentStepIds: readonly string[] = [], + ): ReadSection<{ items: WorkspaceDashboardMetric[] }> { + const metrics = + qor.status === 'ready' || qor.status === 'partial' ? qor.data.metrics : [] + return { + status: 'ready', + data: { + items: workspaceDashboardMetrics(metrics, currentStepIds), + }, + issues: [], + } + } + + private observeWorkspace( + context: WorkspaceContext, + workspaceRoot: string, + watchedRoots: string[], + ): void { + const createWatcher = this.options.snapshotWatcherFactory + const watchKey = watchedRoots + .map((root) => resolve(root)) + .sort() + .join('\0') + if (!createWatcher || context.watchKey === watchKey) return + void context.watcher?.close() + const watcher = createWatcher({ + onError: (error) => + electronLogger.warn('[backend-workspace] snapshot watcher failed', error), + onManifestChanged: () => this.invalidateWindow(context.windowId), + onSnapshotChanged: (changedRoot) => { + if (context.watchedRoots?.some((root) => pathsEqual(changedRoot, root))) { + this.invalidateWindow(context.windowId) + } + }, + }) + context.watcher = watcher + context.watchKey = watchKey + context.watchedRoots = watchedRoots + void Promise.all([ + watcher.startProject(dirname(workspaceRoot)), + watcher.reconcile(dirname(workspaceRoot), watchedRoots), + ]).catch((error) => { + if (context.watcher === watcher) { + context.watcher = undefined + context.watchKey = undefined + context.watchedRoots = undefined + } + void watcher.close() + electronLogger.warn('[backend-workspace] snapshot watcher start failed', error) + }) + } + + private unavailableStepDetail( + context: WorkspaceContext, + code: string, + snapshot?: ProjectEngineeringSnapshotReadResult, + ): BackendWorkspaceStepDetailResult { + return { + detail: { status: 'unavailable', issues: [{ code }] }, + generation: context.generation, + workspaceContextId: context.id, + ...(snapshot?.ok + ? { + workspaceId: snapshot.snapshot.workspaceId, + workspaceRevision: snapshot.snapshot.workspaceRevision, + } + : {}), + } + } +} + +function engineeringFacts( + result: ProjectEngineeringSnapshotReadResult | null, +): WorkspaceEngineeringFacts | null { + if (!result?.ok || result.sections.qor.status !== 'ready') return null + const flow = result.sections.flow + const signoff = result.sections.signoff + const qorSnapshotExtension = result.sections.qorSnapshotExtension + return { + ...result.sections.qor.data, + ...(flow.status === 'ready' ? { flow: flow.data } : {}), + ...(signoff.status === 'ready' ? { signoffAssessment: signoff.data } : {}), + ...(!result.snapshot.stalePredecessor && qorSnapshotExtension.status === 'ready' + ? { qorSnapshotExtension: qorSnapshotExtension.data } + : {}), + } +} + +function artifactSection( + snapshot: ProjectEngineeringSnapshotReadResult | null, + staleArtifactIds: ReadonlySet = new Set(), + staleRevision?: number, +): ReadSection<{ items: WorkspaceArtifactDescriptor[] }> { + if (snapshot?.ok && snapshot.sections.artifacts.status === 'ready') { + return { + status: 'ready', + data: { + items: snapshot.sections.artifacts.data.map((artifact) => + artifactDescriptor( + artifact, + staleArtifactIds.has(artifact.artifactId) ? staleRevision : undefined, + ), + ), + }, + issues: [], + } + } + return { + status: 'unavailable', + issues: + snapshot?.ok && snapshot.sections.artifacts.status !== 'ready' + ? snapshot.sections.artifacts.issues + : snapshot && !snapshot.ok + ? [snapshot.issue] + : [{ code: 'ENGINEERING_ARTIFACT_INVALID' }], + } +} + +function eventLoopDelayMs(): Promise { + const startedAt = performance.now() + return new Promise((resolveDelay) => { + setImmediate(() => resolveDelay(performance.now() - startedAt)) + }) +} + +function roundMs(value: number): number { + return Number(value.toFixed(2)) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/boundedConcurrency.ts b/ecos/gui/apps/desktop-electron/electron/services/boundedConcurrency.ts new file mode 100644 index 000000000..facbbb05a --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/boundedConcurrency.ts @@ -0,0 +1,20 @@ +export async function mapWithConcurrency( + values: readonly T[], + concurrency: number, + mapper: (value: T) => Promise, +): Promise { + const results: R[] = [] + let nextIndex = 0 + await Promise.all( + Array.from( + { length: Math.min(Math.max(concurrency, 1), values.length) }, + async () => { + while (nextIndex < values.length) { + const index = nextIndex++ + results[index] = await mapper(values[index]!) + } + }, + ), + ) + return results +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/chipViewerService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/chipViewerService.test.ts index 201fc9c10..78f750403 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/chipViewerService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/chipViewerService.test.ts @@ -210,6 +210,7 @@ function createService(options: { openWorkspace: vi.fn(async () => ({ directory: PROJECT_ROOT, workspaceHandle: 'workspace-handle-1', + workspaceRevision: 1, })), } const service = new ChipViewerService({ @@ -342,6 +343,7 @@ describe('ChipViewerService', () => { openWorkspace: vi.fn(async () => ({ directory: PROJECT_ROOT, workspaceHandle: 'workspace-handle-1', + workspaceRevision: 1, })), }, }) @@ -395,6 +397,7 @@ describe('ChipViewerService', () => { openWorkspace: vi.fn(async () => ({ directory: PROJECT_ROOT, workspaceHandle: 'workspace-handle-1', + workspaceRevision: 1, })), }, }) @@ -942,6 +945,7 @@ describe('ChipViewerService', () => { expect(layoutEditRuntime.layoutEditSave).toHaveBeenCalledWith({ editSessionId: 'layout-edit-1', expectedRevision: 0, + expectedWorkspaceRevision: 1, workspaceHandle: 'workspace-handle-1', }) expect(ensureDirectory).toHaveBeenCalledWith(join(STEP_DIRECTORY, 'output')) @@ -1055,6 +1059,7 @@ describe('ChipViewerService', () => { openWorkspace: vi.fn(async () => ({ directory: PROJECT_ROOT, workspaceHandle: 'workspace-handle-1', + workspaceRevision: 1, })), }, }) @@ -1120,6 +1125,7 @@ describe('ChipViewerService', () => { openWorkspace: vi.fn(async () => ({ directory: PROJECT_ROOT, workspaceHandle: 'workspace-handle-1', + workspaceRevision: 1, })), }, }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/chipViewerService.ts b/ecos/gui/apps/desktop-electron/electron/services/chipViewerService.ts index 282815979..d8fdd9b6d 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/chipViewerService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/chipViewerService.ts @@ -132,6 +132,7 @@ interface LayoutEditContext { revision: number step: string workspaceHandle: string + workspaceRevision: number } interface NativeGeometryEditCommand { @@ -930,6 +931,9 @@ export class ChipViewerService { if (!editSession.geometryManifestPath) { throw new Error('ECC layout edit session did not return a geometry manifest') } + if (!Number.isInteger(workspace.workspaceRevision)) { + throw new Error('ECC layout edit session did not return a Workspace revision') + } return { bridgeId: `bridge-${this.nextEditBridgeId++}`, dirty: editSession.dirty, @@ -938,6 +942,7 @@ export class ChipViewerService { revision: editSession.revision, step, workspaceHandle: workspace.workspaceHandle, + workspaceRevision: workspace.workspaceRevision!, } } @@ -1132,6 +1137,7 @@ export class ChipViewerService { editSessionId: layoutEdit.editSessionId, expectedRevision: layoutEdit.revision, workspaceHandle: layoutEdit.workspaceHandle, + expectedWorkspaceRevision: layoutEdit.workspaceRevision, }) if (!saved.saved || saved.dirty) { throw new Error('ECC did not confirm that dirty layout edits were published') @@ -1143,6 +1149,9 @@ export class ChipViewerService { }) await this.verifyPublishedLayoutArtifacts(saved) layoutEdit.revision = saved.revision + if (typeof saved.workspaceRevision === 'number') { + layoutEdit.workspaceRevision = saved.workspaceRevision + } geometryManifestPath = saved.artifacts.geometryManifestPath layoutEdit.geometryManifestPath = geometryManifestPath message = 'layout edit saved; verified DEF, IDB, GDS, and geometry manifest' diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/engineeringSnapshotReader.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/engineeringSnapshotReader.ts new file mode 100644 index 000000000..68844f97a --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/engineeringSnapshotReader.ts @@ -0,0 +1,55 @@ +import { existsSync } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { EccPersistedEngineeringSnapshot } from '@ecos-studio/shared' +import { + ENGINEERING_SNAPSHOT_MAX_BYTES, + validateEngineeringSnapshot, +} from '@ecos-studio/shared' + +export async function readPersistedEngineeringSnapshot( + directory: string, + expectedWorkspaceId?: string, +): Promise { + const path = join(directory, 'home', 'engineering-snapshot.json') + let content: string + try { + if ((await stat(path)).size > ENGINEERING_SNAPSHOT_MAX_BYTES) { + throw new Error('ENGINEERING_SNAPSHOT_TOO_LARGE') + } + content = await readFile(path, 'utf8') + } catch (error) { + throw new Error( + error instanceof Error + ? `ENGINEERING_SNAPSHOT_READ_FAILED: ${error.message}` + : 'ENGINEERING_SNAPSHOT_READ_FAILED', + ) + } + + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch { + throw new Error('ENGINEERING_SNAPSHOT_INVALID') + } + const validated = validateEngineeringSnapshot(parsed, expectedWorkspaceId) + if (!validated.ok) throw new Error(validated.issue.code) + const { artifacts, flow, qor, signoff } = validated.sections + if (artifacts.status !== 'ready') throw new Error(artifacts.issues[0]?.code) + if (flow.status !== 'ready') throw new Error(flow.issues[0]?.code) + if (qor.status !== 'ready') throw new Error(qor.issues[0]?.code) + if (signoff.status !== 'ready') throw new Error(signoff.issues[0]?.code) + return { + ...validated.snapshot, + analysis: qor.data.analysis, + artifacts: artifacts.data, + flow: flow.data, + metrics: qor.data.metrics, + qorAssessment: qor.data.qorAssessment, + signoffAssessment: signoff.data, + } +} + +export function hasPersistedWorkspace(directory: string): boolean { + return existsSync(directory) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeClient.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeClient.ts index 6e4ca3247..fa340992b 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeClient.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeClient.ts @@ -1,3 +1,5 @@ +import type { EccRuntimeInterruptibility } from '@ecos-studio/shared' + export interface EccRpcRuntimeClient { call( method: string, @@ -6,7 +8,24 @@ export interface EccRpcRuntimeClient { ): Promise } +export interface RuntimeShutdownBarrier { + cancelRequested?: boolean + interruptibility?: EccRuntimeInterruptibility + operationId: string + safeToStop?: boolean + state: string + step: string + workspaceId: string +} + +export interface RuntimeShutdownResult { + ok: boolean + deferred?: boolean + shutdownBarrier?: RuntimeShutdownBarrier +} + export interface EccRpcRuntimeSidecar { + forceShutdown?(): Promise logFile: string | null relocateLogFileFrom?(workspaceDirectory: string | null): void shutdown(): Promise diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeEnv.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeEnv.test.ts index a688bbef9..f208133d6 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeEnv.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeEnv.test.ts @@ -428,6 +428,28 @@ describe('createEccRuntimeEnv', () => { expect(executable).toBe(packagedEcc) }) + it('does not create or resolve a development ECC executable on Windows', () => { + const fixture = createRepoFixture() + writeFileSync(join(fixture.repoRoot, 'ecc', 'pyproject.toml'), '') + mkdirSync(join(fixture.repoRoot, 'ecos', 'scripts'), { recursive: true }) + writeFileSync( + join(fixture.repoRoot, 'ecos', 'scripts', 'ecc-wrapper.sh'), + '#!/usr/bin/env bash\n', + ) + + expect( + resolveEccExecutable({ + appPath: fixture.appPath, + cwd: fixture.appPath, + env: { PATH: 'C:\\Windows\\System32' }, + isPackaged: false, + platform: 'win32', + userDataPath: fixture.userDataPath, + }), + ).toBeNull() + expect(existsSync(join(fixture.userDataPath, 'runtime-bin', 'ecc.cmd'))).toBe(false) + }) + it('resolves the development ECC shim by absolute path', () => { const fixture = createRepoFixture() writeFileSync( diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeEnv.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeEnv.ts index 8cf1c1c5b..2029bb98b 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeEnv.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeEnv.ts @@ -229,10 +229,10 @@ export function resolveEccExecutable(options: EccRuntimeEnvOptions): string | nu return bundleHomeBin ? join(bundleHomeBin, executableName) : null } + const repoRoot = findRepoRootFromAppPath(options.appPath) + if (!repoRoot) return null const developmentBinDir = resolveDevelopmentEccBinDir(options) - if (!developmentBinDir) { - return null - } + if (!developmentBinDir) return null const candidate = join(developmentBinDir, executableName) return existsSync(candidate) ? candidate : null diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeOperationProjection.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeOperationProjection.ts new file mode 100644 index 000000000..ac9cfb225 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeOperationProjection.ts @@ -0,0 +1,158 @@ +import type { + EccBackgroundFinalization, + EccBackgroundOperation, + EccBackgroundOperationOutcome, + EccBackgroundOperationProjection, +} from '@ecos-studio/shared' +import type { EccWorkspaceRuntime } from './workspaceRuntime' + +interface RuntimeOperationProjectionOptions { + handleEntries(): Iterable<[string, string]> + runtimeForDirectory(directory: string): EccWorkspaceRuntime | undefined + runtimes(): EccWorkspaceRuntime[] +} + +export class RuntimeOperationProjection { + private generation = 0 + private readonly listeners = new Set<(generation: number) => void>() + private readonly releasedOutcomes: EccBackgroundOperationOutcome[] = [] + private signature = '{"finalizations":[],"operations":[],"outcomes":[]}' + + constructor(private readonly options: RuntimeOperationProjectionOptions) {} + + snapshot(): EccBackgroundOperationProjection { + return { + creations: [], + finalizations: this.finalizations(), + generation: this.generation, + operations: this.operations(), + outcomes: this.outcomes(), + } + } + + onInvalidated(listener: (generation: number) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + rememberReleased( + runtime: EccWorkspaceRuntime, + workspaceHandle: string, + workspaceDirectory: string, + ): void { + for (const operation of runtime.recentOperationOutcomes()) { + const outcome = projectOperation(operation, workspaceDirectory, workspaceHandle) + const key = outcomeKey(outcome) + const existing = this.releasedOutcomes.findIndex( + (candidate) => outcomeKey(candidate) === key, + ) + if (existing >= 0) this.releasedOutcomes.splice(existing, 1) + this.releasedOutcomes.push(outcome) + } + if (this.releasedOutcomes.length > 64) { + this.releasedOutcomes.splice(0, this.releasedOutcomes.length - 64) + } + } + + refresh(): void { + const snapshot = this.snapshot() + const signature = JSON.stringify({ + finalizations: snapshot.finalizations, + operations: snapshot.operations, + outcomes: snapshot.outcomes, + }) + if (signature === this.signature) return + this.signature = signature + this.generation += 1 + for (const listener of this.listeners) listener(this.generation) + } + + private operations(): EccBackgroundOperation[] { + return this.contexts().flatMap(({ runtime, workspaceDirectory, workspaceHandle }) => + runtime.activeOperations().map((operation) => ({ + ...projectOperation(operation, workspaceDirectory, workspaceHandle), + })), + ) + } + + private finalizations(): EccBackgroundFinalization[] { + return this.contexts().flatMap(({ runtime, workspaceDirectory, workspaceHandle }) => { + const finalization = runtime.finalization() + return finalization + ? [ + { + ...finalization, + ...(finalization.issue ? { issue: bounded(finalization.issue, 500) } : {}), + workspaceDirectory, + workspaceHandle, + }, + ] + : [] + }) + } + + private outcomes(): EccBackgroundOperationOutcome[] { + const outcomes = new Map() + for (const outcome of this.releasedOutcomes) + outcomes.set(outcomeKey(outcome), outcome) + for (const { runtime, workspaceDirectory, workspaceHandle } of this.contexts()) { + for (const operation of runtime.recentOperationOutcomes()) { + const outcome = projectOperation(operation, workspaceDirectory, workspaceHandle) + outcomes.set(outcomeKey(outcome), outcome) + } + } + return [...outcomes.values()].slice(-64) + } + + private contexts(): Array<{ + runtime: EccWorkspaceRuntime + workspaceDirectory: string + workspaceHandle: string + }> { + const entries = [...this.options.handleEntries()] + return this.options.runtimes().flatMap((runtime) => { + const context = entries.find( + ([, directory]) => this.options.runtimeForDirectory(directory) === runtime, + ) + return context + ? [ + { + runtime, + workspaceDirectory: context[1], + workspaceHandle: context[0], + }, + ] + : [] + }) + } +} + +function outcomeKey(outcome: EccBackgroundOperationOutcome): string { + return `${outcome.runtimeInstanceId ?? ''}\0${outcome.workspaceId}\0${outcome.operationId}\0${outcome.state}` +} + +function projectOperation( + operation: ReturnType[number], + workspaceDirectory: string, + workspaceHandle: string, +): EccBackgroundOperation { + return { + ...operation, + currentStep: bounded(operation.currentStep, 256), + currentTool: bounded(operation.currentTool, 256), + error: operation.error + ? { + code: bounded(operation.error.code, 128), + message: bounded(operation.error.message, 500), + } + : null, + result: null, + step: bounded(operation.step, 256), + workspaceDirectory, + workspaceHandle, + } +} + +function bounded(value: string, limit: number): string { + return value.replace(/\s+/g, ' ').trim().slice(0, limit) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeOperationTracker.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeOperationTracker.test.ts new file mode 100644 index 000000000..6b881febc --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeOperationTracker.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import type { EccRuntimeProtocolPayload } from '@ecos-studio/shared' +import { RuntimeOperationTracker } from './runtimeOperationTracker' + +function operationEvent( + state: 'queued' | 'running' | 'succeeded', + overrides: Partial = {}, +): EccRuntimeProtocolPayload { + return { + eventId: `operation-1:${state}`, + kind: 'step', + operationId: 'operation-1', + origin: 'gui', + payload: { + cancelRequested: false, + state, + step: 'Route', + workspaceRevision: 7, + }, + sequence: 1, + timestamp: 10, + type: 'operation.changed', + workspaceId: 'engineering-ws-1', + ...overrides, + } +} + +describe('RuntimeOperationTracker active operations', () => { + it('retains queued, running, and cancellation-requested operation facts', () => { + const tracker = new RuntimeOperationTracker() + + tracker.track(operationEvent('queued')) + expect(tracker.activeOperations()).toEqual([ + expect.objectContaining({ + operationId: 'operation-1', + state: 'queued', + workspaceId: 'engineering-ws-1', + workspaceRevision: 7, + }), + ]) + + tracker.track(operationEvent('running', { sequence: 2 })) + tracker.track(operationEvent('queued')) + tracker.track( + operationEvent('running', { + payload: { + cancelRequested: true, + state: 'running', + step: 'Route', + workspaceRevision: 7, + }, + sequence: 3, + }), + ) + expect(tracker.activeOperations()).toEqual([ + expect.objectContaining({ + cancelRequested: true, + state: 'running', + workspaceRevision: 7, + }), + ]) + + tracker.track(operationEvent('succeeded', { sequence: 4 })) + tracker.track(operationEvent('succeeded', { sequence: 4 })) + expect(tracker.activeOperations()).toEqual([]) + }) + + it('advances an active rerun to its committed preparation revision', () => { + const tracker = new RuntimeOperationTracker() + tracker.track(operationEvent('running', { sequence: 2 })) + + tracker.track({ + ...operationEvent('running'), + payload: { + sourceType: 'operation.rerun_prepared', + workspaceRevision: 8, + }, + sequence: 3, + type: 'execution.progress', + workspaceRevision: 8, + }) + + expect(tracker.activeOperations()).toEqual([ + expect.objectContaining({ + operationId: 'operation-1', + state: 'running', + workspaceRevision: 8, + }), + ]) + + const reordered = new RuntimeOperationTracker() + reordered.track({ + ...operationEvent('running'), + payload: { + sourceType: 'operation.rerun_prepared', + workspaceRevision: 8, + }, + sequence: 3, + type: 'execution.progress', + }) + reordered.track(operationEvent('queued')) + expect(reordered.activeOperations()).toHaveLength(1) + }) + + it('keeps an active flow revision-matched as steps commit', () => { + const tracker = new RuntimeOperationTracker() + tracker.track(operationEvent('running', { sequence: 2 })) + + tracker.track({ + ...operationEvent('running'), + payload: { + sourceType: 'step.completed', + step: 'Route', + tool: 'openroad', + workspaceRevision: 8, + }, + sequence: 3, + type: 'workspace.committed', + workspaceRevision: 8, + }) + tracker.track({ + ...operationEvent('running'), + payload: { + sourceType: 'step.started', + step: 'STA', + tool: 'opensta', + }, + sequence: 4, + type: 'execution.progress', + }) + + expect(tracker.activeOperations()).toEqual([ + expect.objectContaining({ + currentStep: 'STA', + currentTool: 'opensta', + operationId: 'operation-1', + state: 'running', + workspaceRevision: 8, + }), + ]) + }) + + it('reconciles a missed terminal notification from operation.status', async () => { + const tracker = new RuntimeOperationTracker() + tracker.track(operationEvent('running')) + const completed = tracker.waitFor('operation-1') + + expect( + tracker.reconcile({ + ...tracker.activeOperations()[0]!, + state: 'succeeded', + updatedAt: 20, + }), + ).toBe(true) + + await expect(completed).resolves.toMatchObject({ state: 'succeeded' }) + expect(tracker.activeOperations()).toEqual([]) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeOperationTracker.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeOperationTracker.ts index b6caa8dd7..203d394b9 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeOperationTracker.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeOperationTracker.ts @@ -5,11 +5,8 @@ interface OperationWaiter { resolve(operation: EccRuntimeOperation): void } -const terminalEventTypes = new Set([ - 'operation.completed', - 'operation.failed', - 'operation.cancelled', -]) +const terminalStates = new Set(['succeeded', 'failed', 'cancelled', 'interrupted']) +const activeStates = new Set(['queued', 'running']) /** * Keeps the notification-derived operation state separate from RPC session @@ -17,39 +14,102 @@ const terminalEventTypes = new Set([ * both idempotent. */ export class RuntimeOperationTracker { - private readonly activeOperationIds = new Set() + private readonly active = new Map() + private readonly latestSequences = new Map() private readonly terminalOperations = new Map() private readonly waiters = new Map() hasActiveOperations(): boolean { - return this.activeOperationIds.size > 0 + return this.active.size > 0 } firstActiveOperationId(): string | null { - return this.activeOperationIds.values().next().value ?? null + return this.active.keys().next().value ?? null + } + + activeOperations(): EccRuntimeOperation[] { + return [...this.active.values()] + } + + recentOutcomes(limit = 64): EccRuntimeOperation[] { + return [...this.terminalOperations.values()].slice(-limit) } hasTerminalOperation(operationId: string): boolean { return this.terminalOperations.has(operationId) } + knowsOperation(operationId: string): boolean { + return this.active.has(operationId) || this.terminalOperations.has(operationId) + } + track(protocolEvent: EccRuntimeProtocolPayload): boolean { - if (!terminalEventTypes.has(protocolEvent.type)) { + const updatesActiveOperation = + protocolEvent.type === 'execution.progress' || + protocolEvent.type === 'workspace.committed' + if (protocolEvent.type !== 'operation.changed' && !updatesActiveOperation) { + return false + } + const latestSequence = this.latestSequences.get(protocolEvent.operationId) + if (latestSequence !== undefined && protocolEvent.sequence <= latestSequence) { + return false + } + if (updatesActiveOperation) { + const active = this.active.get(protocolEvent.operationId) + const workspaceRevision = + protocolEvent.payload.workspaceRevision ?? protocolEvent.workspaceRevision + if (!active) return false + this.latestSequences.set(protocolEvent.operationId, protocolEvent.sequence) + this.active.set(protocolEvent.operationId, { + ...active, + currentStep: + stringPayloadValue(protocolEvent.payload, 'step') || active.currentStep, + currentTool: + stringPayloadValue(protocolEvent.payload, 'tool') || active.currentTool, + updatedAt: protocolEvent.timestamp, + ...(typeof workspaceRevision === 'number' ? { workspaceRevision } : {}), + }) + return false + } + this.latestSequences.set(protocolEvent.operationId, protocolEvent.sequence) + const state = stringPayloadValue(protocolEvent.payload, 'state') + if (activeStates.has(state)) { if (this.terminalOperations.has(protocolEvent.operationId)) return false - this.activeOperationIds.add(protocolEvent.operationId) + this.active.set( + protocolEvent.operationId, + operationFrom(protocolEvent, this.active.get(protocolEvent.operationId)), + ) return false } + if (!terminalStates.has(state)) return false - this.activeOperationIds.delete(protocolEvent.operationId) - const operation = terminalOperationFrom(protocolEvent) + const previous = this.active.get(protocolEvent.operationId) + this.active.delete(protocolEvent.operationId) + const operation = operationFrom(protocolEvent, previous) this.terminalOperations.set(operation.operationId, operation) - if (this.terminalOperations.size > 512) { - this.terminalOperations.delete(this.terminalOperations.keys().next().value!) - } + this.trimOutcomes() this.resolveWaiters(operation.operationId, operation) return true } + reconcile(operation: EccRuntimeOperation): boolean { + if (terminalStates.has(operation.state)) { + const alreadyTerminal = this.terminalOperations.has(operation.operationId) + this.active.delete(operation.operationId) + this.terminalOperations.set(operation.operationId, operation) + this.trimOutcomes() + this.resolveWaiters(operation.operationId, operation) + return !alreadyTerminal + } + if ( + activeStates.has(operation.state) && + !this.terminalOperations.has(operation.operationId) + ) { + this.active.set(operation.operationId, operation) + } + return false + } + waitFor(operationId: string): Promise { const completed = this.terminalOperations.get(operationId) if (completed) return Promise.resolve(completed) @@ -70,12 +130,13 @@ export class RuntimeOperationTracker { for (const waiter of waiters) waiter.reject(reason) } this.waiters.clear() - this.activeOperationIds.clear() + this.active.clear() } reset(reason: Error): void { this.rejectAll(reason) this.terminalOperations.clear() + this.latestSequences.clear() } private resolveWaiters(operationId: string, operation: EccRuntimeOperation): void { @@ -84,6 +145,14 @@ export class RuntimeOperationTracker { this.waiters.delete(operationId) for (const waiter of waiters) waiter.resolve(operation) } + + private trimOutcomes(): void { + while (this.terminalOperations.size > 512) { + const oldestOperationId = this.terminalOperations.keys().next().value! + this.terminalOperations.delete(oldestOperationId) + this.latestSequences.delete(oldestOperationId) + } + } } export function isRuntimeProtocolPayload( @@ -103,39 +172,73 @@ export function isRuntimeProtocolPayload( ) } -function terminalOperationFrom( +function operationFrom( protocolEvent: EccRuntimeProtocolPayload, + previous?: EccRuntimeOperation, ): EccRuntimeOperation { const payload = protocolEvent.payload const error = isRuntimeErrorPayload(payload.error) ? payload.error - : protocolEvent.type === 'operation.cancelled' + : payload.state === 'cancelled' ? { code: 'cancelled', message: 'ECC operation cancelled.' } : null return { - awaitingEventId: null, - cancelRequested: protocolEvent.type === 'operation.cancelled', - createdAt: protocolEvent.timestamp, - currentStep: stringPayloadValue(payload, 'step'), - currentTool: stringPayloadValue(payload, 'tool'), + cancelRequested: payload.state === 'cancelled' || Boolean(payload.cancelRequested), + createdAt: + numberPayloadValue(payload, 'createdAt') ?? + previous?.createdAt ?? + protocolEvent.timestamp, + currentStep: stringPayloadValue(payload, 'step') || previous?.currentStep || '', + currentTool: stringPayloadValue(payload, 'tool') || previous?.currentTool || '', error, kind: protocolEvent.kind ?? 'step', operationId: protocolEvent.operationId, origin: protocolEvent.origin, rerun: Boolean(protocolEvent.rerun), + ...(isInterruptibility(payload.interruptibility) + ? { interruptibility: payload.interruptibility } + : previous?.interruptibility + ? { interruptibility: previous.interruptibility } + : {}), + ...(protocolEvent.runSessionId ? { runSessionId: protocolEvent.runSessionId } : {}), + ...(protocolEvent.runtimeInstanceId + ? { runtimeInstanceId: protocolEvent.runtimeInstanceId } + : {}), result: recordPayloadValue(payload, 'result'), - state: - protocolEvent.type === 'operation.completed' - ? 'succeeded' - : protocolEvent.type === 'operation.cancelled' - ? 'cancelled' - : 'failed', + state: stringPayloadValue(payload, 'state') as EccRuntimeOperation['state'], step: stringPayloadValue(payload, 'step'), + ...(typeof payload.safeToStop === 'boolean' + ? { safeToStop: payload.safeToStop } + : {}), + ...(typeof payload.shutdownBarrier === 'boolean' + ? { shutdownBarrier: payload.shutdownBarrier } + : {}), updatedAt: protocolEvent.timestamp, + ...(typeof (payload.workspaceRevision ?? protocolEvent.workspaceRevision) === 'number' + ? { + workspaceRevision: (payload.workspaceRevision ?? + protocolEvent.workspaceRevision) as number, + } + : {}), workspaceId: protocolEvent.workspaceId, } } +function numberPayloadValue( + payload: Record, + key: string, +): number | undefined { + return typeof payload[key] === 'number' && Number.isFinite(payload[key]) + ? payload[key] + : undefined +} + +function isInterruptibility( + value: unknown, +): value is NonNullable { + return value === 'safe' || value === 'deferred' || value === 'forbidden' +} + function stringPayloadValue(payload: Record, key: string): string { return typeof payload[key] === 'string' ? payload[key] : '' } diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeService.test.ts index 3b52c7fc2..5ee3c314d 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeService.test.ts @@ -1,12 +1,11 @@ import type { EccRuntimeEvent } from '@ecos-studio/shared' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { EccRpcRuntimeService, type EccRpcRuntimeClient, type EccRpcRuntimeSidecar, } from './runtimeService' -import { EccRpcShutdownDeferredError } from './sidecarProcess' import { WorkspaceSessionNotFoundError } from './workspaceSessions' import type { JsonRpcNotificationPayload } from './jsonRpcClient' @@ -32,6 +31,27 @@ function waitForQueuedOperation(): Promise { }) } +function engineeringSnapshot(workspaceId: string, workspaceRevision = 1) { + return { + analysis: { steps: [] }, + artifacts: [], + checklist: {}, + flow: { steps: [] }, + metrics: [], + parameters: {}, + qorAssessment: { + metrics: [], + score: { gate: 'unavailable', threshold: 60, value: null }, + status: 'unavailable', + steps: [], + }, + schemaVersion: 1, + signoffAssessment: { groups: [], risks: [], status: 'ready' }, + workspaceId, + workspaceRevision, + } +} + class FakeRpcClient implements EccRpcRuntimeClient { readonly calls: RpcCall[] = [] responses: Array> = [] @@ -210,10 +230,10 @@ describe('EccRpcRuntimeService pool', () => { it('routes generic frontend RPC calls through the control runtime', async () => { const pool = createPool() - await pool.service.rpcHello() + const request = pool.service.callRuntime('frontend.catalog') pool.clientFor(null).responses.push({ cores: ['ysyx_22050550'] }) - await expect(pool.service.callRuntime('frontend.catalog')).resolves.toEqual({ + await expect(request).resolves.toEqual({ cores: ['ysyx_22050550'], }) expect(pool.clientFor(null).calls.at(-1)).toEqual({ @@ -223,10 +243,75 @@ describe('EccRpcRuntimeService pool', () => { }) }) + it('reads a baseline Step Configuration without opening a workspace session', async () => { + const pool = createPool() + const request = pool.service.readWorkspaceStepConfigurationForDirectory( + '/work/baseline', + 'CTS', + ) + pool.clientFor(null).responses.push({ + parameters: [ + { + applies: 'cts', + default: 0.08, + description: 'CTS skew bound', + param: 'cts.skew_bound', + type: 'float', + value: 0.08, + }, + ], + status: 'available', + step: 'CTS', + stepId: 'CTS', + workspaceId: 'workspace-baseline', + workspaceRevision: 2, + }) + + await expect(request).resolves.toMatchObject({ + status: 'available', + workspaceRevision: 2, + }) + expect( + pool.clientFor(null).calls.filter((call) => call.method === 'workspace.open'), + ).toEqual([]) + expect(pool.clientFor(null).calls.at(-1)).toEqual({ + method: 'workspace.step_configuration.read', + params: { directory: '/work/baseline', step: 'CTS' }, + }) + }) + + it('maps a normal unavailable Step Configuration to a product missing result', async () => { + const pool = createPool() + const request = pool.service.readWorkspaceStepConfigurationForDirectory( + '/work/baseline', + 'Synthesis', + ) + pool.clientFor(null).responses.push({ + reason: 'step_configuration_unavailable', + status: 'unavailable', + step: 'Synthesis', + workspaceId: 'workspace-baseline', + workspaceRevision: 2, + }) + + await expect(request).resolves.toEqual({ + reason: 'step_configuration_unavailable', + status: 'missing', + step: 'Synthesis', + workspaceId: 'workspace-baseline', + workspaceRevision: 2, + }) + }) + it('releases the one-shot workspace creation sidecar after the session is registered', async () => { const pool = createPool() - const workspace = await pool.service.createWorkspace({ directory: '/work/new' }) + const workspace = await pool.service.createWorkspace({ + commandId: 'workspace-create-new', + targetDirectory: '/work/new', + workspaceBindings: {}, + workspaceSpec: {}, + }) expect(workspace.directory).toBe('/work/new') expect(pool.sidecarFor('/work/new').shutdownCount).toBe(1) @@ -347,6 +432,86 @@ describe('EccRpcRuntimeService pool', () => { ).rejects.toThrow(WorkspaceSessionNotFoundError) }) + it('returns an existing Workspace Session without reopening ECC', async () => { + const pool = createPool() + const opened = await pool.service.openWorkspace({ directory: '/work/demo' }) + + await expect(pool.service.workspaceSession(opened.workspaceHandle)).resolves.toEqual({ + ...opened, + reused: true, + }) + expect( + pool + .clientFor('/work/demo') + .calls.filter((call) => call.method === 'workspace.open'), + ).toHaveLength(1) + }) + + it('resolves the Engineering Snapshot for an active Workspace directory', async () => { + const pool = createPool() + const workspace = await pool.service.openWorkspace({ directory: '/work/demo' }) + pool.clientFor('/work/demo').responses.push(engineeringSnapshot('id-/work/demo')) + + await expect( + pool.service.engineeringSnapshotForDirectory('/work/demo/'), + ).resolves.toMatchObject({ + artifacts: [], + workspaceId: 'id-/work/demo', + workspaceRevision: 1, + }) + expect(pool.clientFor('/work/demo').calls.at(-1)).toEqual({ + method: 'workspace.engineering_snapshot', + params: { workspaceId: 'id-/work/demo' }, + }) + expect(workspace.workspaceHandle).toEqual(expect.any(String)) + }) + + it('rejects an Engineering Snapshot whose revision no longer matches the request', async () => { + const pool = createPool() + const workspace = await pool.service.openWorkspace({ directory: '/work/demo' }) + pool.clientFor('/work/demo').responses.push(engineeringSnapshot('id-/work/demo', 2)) + + await expect( + pool.service.engineeringSnapshot({ + expectedWorkspaceRevision: 1, + workspaceHandle: workspace.workspaceHandle, + }), + ).rejects.toThrow('ENGINEERING_WORKSPACE_REVISION_MISMATCH') + }) + + it('rejects an Engineering Snapshot for a different ECC Workspace identity', async () => { + const pool = createPool() + const workspace = await pool.service.openWorkspace({ directory: '/work/demo' }) + pool + .clientFor('/work/demo') + .responses.push(engineeringSnapshot('different-workspace')) + + await expect( + pool.service.engineeringSnapshot({ workspaceHandle: workspace.workspaceHandle }), + ).rejects.toThrow('ENGINEERING_WORKSPACE_ID_MISMATCH') + }) + + it('queries and closes an Engineering Snapshot session for an idle directory', async () => { + const pool = createPool() + const query = pool.service.engineeringSnapshotForDirectory('/work/idle') + const client = pool.clientFor('/work/idle') + client.responses.push( + { directory: '/work/idle', workspaceId: 'id-/work/idle' }, + { recovered: [] }, + engineeringSnapshot('id-/work/idle'), + { closed: true }, + ) + + await expect(query).resolves.toMatchObject({ workspaceId: 'id-/work/idle' }) + expect(client.calls.map((call) => call.method)).toEqual([ + 'workspace.open', + 'workspace.recover_interrupted', + 'workspace.engineering_snapshot', + 'workspace.close', + ]) + expect(pool.sidecarFor('/work/idle').shutdownCount).toBe(1) + }) + it('shuts down and removes a runtime when its last handle closes', async () => { const pool = createPool() const first = await pool.service.openWorkspace({ directory: '/work/demo' }) @@ -394,75 +559,190 @@ describe('EccRpcRuntimeService pool', () => { expect(pool.service.isWorkspaceRuntimeActive('/work/a')).toBe(false) }) - it('routes rpc.hello and rpc.ping through a control runtime without workspace sidecars', async () => { - const pool = createPool() - await expect(pool.service.rpcHello()).resolves.toEqual({ - capabilities: [], - eccVersion: '0.1.0', - version: 1, - }) - - pool.clientFor(null).responses.push({ ok: true }) - await expect(pool.service.rpcPing()).resolves.toEqual({ ok: true }) - - expect(pool.createCount()).toBe(1) - expect(pool.sidecars.has(null)).toBe(true) - expect(pool.sidecars.has('/work/demo')).toBe(false) - }) - - it('rpcShutdown closes every workspace runtime and the control runtime', async () => { + it('shutdown closes every workspace runtime and the control runtime', async () => { const pool = createPool() await pool.service.openWorkspace({ directory: '/work/a' }) await pool.service.openWorkspace({ directory: '/work/b' }) - await pool.service.rpcHello() + const describe = pool.service.describeWorkspaceSpec() + pool.clientFor(null).responses.push({}) + await describe - await expect(pool.service.rpcShutdown()).resolves.toEqual({ ok: true }) + await expect(pool.service.shutdown()).resolves.toEqual({ ok: true }) expect(pool.sidecarFor('/work/a').shutdownCount).toBe(1) expect(pool.sidecarFor('/work/b').shutdownCount).toBe(1) expect(pool.sidecarFor(null).shutdownCount).toBe(1) }) - it('requests ECC cancellation when GUI quit reaches a rendered-step safe boundary', async () => { + it('defers Electron shutdown while a Workspace operation is active', async () => { const pool = createPool() - await pool.service.openWorkspace({ directory: '/work/demo' }) + const workspace = await pool.service.openWorkspace({ directory: '/work/demo' }) const sidecar = pool.sidecarFor('/work/demo') - sidecar.shutdownError = new EccRpcShutdownDeferredError({ - operationId: 'operation-1', - safeToStop: true, - state: 'waiting_for_gui_ack', - step: 'Synthesis', - workspaceId: 'id-/work/demo', + pool.sidecarNotification('/work/demo', { + jsonrpc: '2.0', + method: 'runtime.event', + params: { + eventId: 'id-/work/demo:1', + kind: 'flow', + operationId: 'operation-1', + origin: 'gui', + payload: { sourceType: 'operation.started', state: 'running' }, + sequence: 1, + timestamp: 1, + type: 'operation.changed', + workspaceId: 'id-/work/demo', + }, }) - pool.clientFor('/work/demo').responses.push({ - accepted: true, - operationId: 'operation-1', - state: 'running', + const reopened = await pool.service.openWorkspace({ directory: '/work/demo' }) + expect(reopened.workspaceHandle).toBe(workspace.workspaceHandle) + expect( + pool + .clientFor('/work/demo') + .calls.filter((call) => call.method === 'workspace.open'), + ).toHaveLength(1) + + await expect(pool.service.shutdown()).resolves.toEqual({ + deferred: true, + ok: false, + shutdownBarrier: expect.objectContaining({ operationId: 'operation-1' }), }) + expect(sidecar.shutdownCount).toBe(0) + }) + + it('retains an active Runtime when its Renderer lease is released', async () => { + const pool = createPool() + const workspace = await pool.service.openWorkspace({ directory: '/work/demo' }) pool.sidecarNotification('/work/demo', { jsonrpc: '2.0', method: 'runtime.event', params: { - eventId: 'id-/work/demo:2', + eventId: 'active-1', kind: 'flow', operationId: 'operation-1', origin: 'gui', - payload: { state: 'Success', step: 'Synthesis', tool: 'yosys' }, + payload: { state: 'running', step: 'Route' }, + sequence: 1, + timestamp: 1, + type: 'operation.changed', + workspaceId: 'id-/work/demo', + }, + }) + + await expect( + pool.service.releaseWorkspace({ workspaceHandle: workspace.workspaceHandle }), + ).resolves.toEqual({ ok: true, retained: true }) + expect(pool.sidecarFor('/work/demo').shutdownCount).toBe(0) + await expect( + pool.service.workspaceSession(workspace.workspaceHandle), + ).resolves.toMatchObject({ + reused: true, + workspaceHandle: workspace.workspaceHandle, + }) + }) + + it('releases an unreferenced Session after its terminal snapshot and retains the outcome', async () => { + const pool = createPool() + const workspace = await pool.service.openWorkspace({ directory: '/work/demo' }) + const released = vi.fn() + pool.service.onWorkspaceReleased(released) + pool.sidecarNotification('/work/demo', { + jsonrpc: '2.0', + method: 'runtime.event', + params: { + eventId: 'active-release', + kind: 'flow', + operationId: 'operation-release', + origin: 'gui', + payload: { state: 'running', step: 'Route' }, + sequence: 1, + timestamp: 1, + type: 'operation.changed', + workspaceId: 'id-/work/demo', + }, + }) + await pool.service.releaseWorkspace({ workspaceHandle: workspace.workspaceHandle }) + pool.clientFor('/work/demo').responses.push({ + directory: '/work/demo', + flow: { steps: [] }, + home: {}, + lastEventId: 'terminal-release', + operations: [], + parameters: {}, + }) + + pool.sidecarNotification('/work/demo', { + jsonrpc: '2.0', + method: 'runtime.event', + params: { + eventId: 'terminal-release', + kind: 'flow', + operationId: 'operation-release', + origin: 'gui', + payload: { state: 'succeeded', step: 'Route' }, sequence: 2, timestamp: 2, - type: 'step.completed', + type: 'operation.changed', workspaceId: 'id-/work/demo', }, }) - await expect(pool.service.rpcShutdown()).resolves.toEqual({ - deferred: true, - ok: false, - shutdownBarrier: expect.objectContaining({ operationId: 'operation-1' }), + await vi.waitFor(() => + expect(released).toHaveBeenCalledWith(workspace.workspaceHandle), + ) + await expect( + pool.service.workspaceSession(workspace.workspaceHandle), + ).rejects.toThrow(WorkspaceSessionNotFoundError) + expect(pool.service.operationProjection().outcomes).toEqual([ + expect.objectContaining({ + operationId: 'operation-release', + state: 'succeeded', + workspaceDirectory: '/work/demo', + }), + ]) + }) + + it('force shuts down only the Runtime handles in a window-scoped request', async () => { + const pool = createPool() + const first = await pool.service.openWorkspace({ directory: '/work/a' }) + const second = await pool.service.openWorkspace({ directory: '/work/b' }) + + await pool.service.forceShutdown([first.workspaceHandle]) + + expect(pool.sidecarFor('/work/a').shutdownCount).toBe(1) + expect(pool.sidecarFor('/work/b').shutdownCount).toBe(0) + await expect(pool.service.workspaceSession(first.workspaceHandle)).rejects.toThrow( + WorkspaceSessionNotFoundError, + ) + await expect( + pool.service.workspaceSession(second.workspaceHandle), + ).resolves.toMatchObject({ + workspaceHandle: second.workspaceHandle, }) - expect(pool.clientFor('/work/demo').calls).toContainEqual({ - method: 'operation.cancel', - params: { operationId: 'operation-1' }, + }) + + it('does not let sibling Runtime work block a handle-scoped idle wait', async () => { + const pool = createPool() + const first = await pool.service.openWorkspace({ directory: '/work/a' }) + const second = await pool.service.openWorkspace({ directory: '/work/b' }) + pool.sidecarNotification('/work/b', { + jsonrpc: '2.0', + method: 'runtime.event', + params: { + eventId: 'active-b', + kind: 'flow', + operationId: 'operation-b', + origin: 'gui', + payload: { state: 'running', step: 'Route' }, + sequence: 1, + timestamp: 1, + type: 'operation.changed', + workspaceId: 'id-/work/b', + }, }) + + await expect( + pool.service.waitForIdle([first.workspaceHandle]), + ).resolves.toBeUndefined() + expect(pool.service.hasPendingRuntimeWork([second.workspaceHandle])).toBe(true) }) it('aggregates onEvent listeners and supports unsubscribe', async () => { @@ -481,6 +761,236 @@ describe('EccRpcRuntimeService pool', () => { expect(seen).toHaveLength(before) }) + it('aggregates active operations owned by every workspace runtime', async () => { + const pool = createPool() + await pool.service.openWorkspace({ directory: '/work/a' }) + await pool.service.openWorkspace({ directory: '/work/b' }) + for (const [index, directory] of ['/work/a', '/work/b'].entries()) { + pool.sidecarNotification(directory, { + jsonrpc: '2.0', + method: 'runtime.event', + params: { + eventId: `event-${index}`, + kind: 'step', + operationId: `operation-${index}`, + origin: 'gui', + payload: { state: 'queued', step: 'Route', workspaceRevision: 1 }, + sequence: 1, + timestamp: index, + type: 'operation.changed', + workspaceId: `id-${directory}`, + }, + }) + } + + expect( + pool.service.activeOperations().map((operation) => operation.operationId), + ).toEqual(['operation-0', 'operation-1']) + }) + + it('projects active operations with their workspace handles and generations', async () => { + const pool = createPool() + const workspaceA = await pool.service.openWorkspace({ directory: '/work/a' }) + const workspaceB = await pool.service.openWorkspace({ directory: '/work/b' }) + const invalidations: number[] = [] + const unsubscribe = pool.service.onOperationProjectionInvalidated((generation) => { + invalidations.push(generation) + }) + + for (const [index, directory] of ['/work/a', '/work/b'].entries()) { + pool.sidecarNotification(directory, { + jsonrpc: '2.0', + method: 'runtime.event', + params: { + eventId: `projection-${index}`, + kind: 'flow', + operationId: `operation-${index}`, + origin: 'gui', + payload: { + error: + index === 0 ? { code: 'C'.repeat(200), message: 'M'.repeat(1_000) } : null, + result: { artifact: 'x'.repeat(10_000) }, + state: 'running', + step: index === 0 ? 'R'.repeat(500) : 'Route', + workspaceRevision: 3, + }, + sequence: 1, + timestamp: index + 1, + type: 'operation.changed', + workspaceId: `id-${directory}`, + }, + }) + } + + expect(pool.service.operationProjection()).toEqual({ + creations: [], + finalizations: [], + generation: 2, + operations: [ + expect.objectContaining({ + operationId: 'operation-0', + workspaceDirectory: '/work/a', + workspaceHandle: workspaceA.workspaceHandle, + workspaceId: 'id-/work/a', + }), + expect.objectContaining({ + operationId: 'operation-1', + workspaceDirectory: '/work/b', + workspaceHandle: workspaceB.workspaceHandle, + workspaceId: 'id-/work/b', + }), + ], + outcomes: [], + }) + const bounded = pool.service.operationProjection().operations[0]! + expect(bounded.result).toBeNull() + expect(bounded.currentStep).toHaveLength(256) + expect(bounded.error?.code).toHaveLength(128) + expect(bounded.error?.message).toHaveLength(500) + expect(invalidations).toEqual([1, 2]) + + unsubscribe() + }) + + it('adopts the operation.start response when the start notification is missed', async () => { + const pool = createPool() + const workspace = await pool.service.openWorkspace({ directory: '/work/demo' }) + pool.clientFor('/work/demo').responses.push({ + createdAt: 1, + currentStep: 'Route', + currentTool: 'openroad', + error: null, + kind: 'flow', + operationId: 'operation-start-response', + origin: 'gui', + rerun: false, + result: null, + state: 'running', + step: '', + updatedAt: 1, + workspaceId: 'id-/work/demo', + }) + + await pool.service.startFlowOperation({ + expectedWorkspaceRevision: 1, + idempotencyKey: 'start-response', + workspaceHandle: workspace.workspaceHandle, + }) + + expect(pool.service.operationProjection().operations).toEqual([ + expect.objectContaining({ + operationId: 'operation-start-response', + workspaceHandle: workspace.workspaceHandle, + }), + ]) + }) + + it('repairs a missed terminal notification through operation.status', async () => { + const pool = createPool() + await pool.service.openWorkspace({ directory: '/work/demo' }) + pool.sidecarNotification('/work/demo', { + jsonrpc: '2.0', + method: 'runtime.event', + params: { + eventId: 'running-before-reconcile', + kind: 'flow', + operationId: 'operation-reconcile', + origin: 'gui', + payload: { state: 'running', step: 'Route' }, + sequence: 1, + timestamp: 1, + type: 'operation.changed', + workspaceId: 'id-/work/demo', + }, + }) + pool.clientFor('/work/demo').responses.push( + { + createdAt: 1, + currentStep: 'Route', + currentTool: 'openroad', + error: null, + kind: 'flow', + operationId: 'operation-reconcile', + origin: 'gui', + rerun: false, + result: {}, + state: 'succeeded', + step: '', + updatedAt: 2, + workspaceId: 'id-/work/demo', + }, + { + directory: '/work/demo', + flow: { steps: [] }, + home: {}, + lastEventId: 'terminal-reconciled', + operations: [], + parameters: {}, + }, + ) + + await pool.service.reconcileOperationProjection() + + expect(pool.service.operationProjection().operations).toEqual([]) + expect(pool.service.operationProjection().outcomes).toEqual([ + expect.objectContaining({ + operationId: 'operation-reconcile', + state: 'succeeded', + }), + ]) + await vi.waitFor(() => expect(pool.sidecarFor('/work/demo').shutdownCount).toBe(1)) + }) + + it('automatically retries a failed final snapshot when its Session is reopened', async () => { + const pool = createPool() + const workspace = await pool.service.openWorkspace({ directory: '/work/demo' }) + pool.sidecarNotification('/work/demo', { + jsonrpc: '2.0', + method: 'runtime.event', + params: { + eventId: 'terminal-failed', + kind: 'flow', + operationId: 'operation-1', + origin: 'gui', + payload: { state: 'failed', step: 'Route', workspaceRevision: 4 }, + sequence: 2, + timestamp: 20, + type: 'operation.changed', + workspaceId: 'id-/work/demo', + }, + }) + + await vi.waitFor(() => + expect(pool.service.operationProjection().finalizations).toEqual([ + expect.objectContaining({ + issue: expect.stringContaining('Unexpected RPC call'), + state: 'snapshot-failed', + workspaceHandle: workspace.workspaceHandle, + }), + ]), + ) + expect(pool.sidecarFor('/work/demo').shutdownCount).toBe(0) + await expect( + pool.service.releaseWorkspace({ workspaceHandle: workspace.workspaceHandle }), + ).resolves.toEqual({ ok: true, retained: true }) + + pool.clientFor('/work/demo').responses.push({ + directory: '/work/demo', + flow: { steps: [] }, + home: {}, + lastEventId: 'terminal-failed', + operations: [], + parameters: {}, + workspaceId: 'id-/work/demo', + workspaceRevision: 4, + }) + await expect( + pool.service.openWorkspace({ directory: '/work/demo' }), + ).resolves.toMatchObject({ workspaceHandle: workspace.workspaceHandle }) + expect(pool.service.operationProjection().finalizations).toEqual([]) + expect(pool.sidecarFor('/work/demo').shutdownCount).toBe(1) + }) + it('routes handles when ECC returns a resolved directory different from the request', async () => { const sidecars = new Map() const clients = new Map() diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeService.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeService.ts index 6edfb29bb..d3aedf7a5 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeService.ts @@ -1,4 +1,8 @@ import type { + EccBackgroundOperationProjection, + EccBackgroundOperationLogResult, + EccEngineeringSnapshot, + EccPersistedEngineeringSnapshot, EccFlowRunRequest, EccFlowRunResult, EccFlowRunStepRequest, @@ -11,22 +15,18 @@ import type { EccLayoutEditDiscardResult, EccLayoutEditSaveRequest, EccLayoutEditSaveResult, - EccRpcHelloResult, - EccRpcPingResult, - EccRpcShutdownResult, EccRuntimeEvent, EccRuntimeOperation, EccRuntimeOperationRequest, EccRuntimeStartFlowRequest, EccRuntimeStartStepRequest, - EccRuntimeStepRenderedAckRequest, EccWorkspaceCloseResult, + EccWorkspaceConfigurationUpdateRequest, EccWorkspaceCreateRequest, EccWorkspaceCreateResult, EccWorkspaceExportSignoffRequest, EccWorkspaceExportSignoffResult, EccWorkspaceHandleRequest, - EccWorkspaceInspectSignoffResult, EccWorkspaceHomeResult, EccWorkspaceInfoRequest, EccWorkspaceInfoResult, @@ -35,9 +35,15 @@ import type { EccWorkspaceRefreshConfigResult, EccWorkspaceResetFlowResult, EccWorkspaceRuntimeSnapshot, - EccWorkspaceSyncConfigRequest, - EccWorkspaceSyncConfigResult, + EccWorkspaceStepConfigurationUpdateRequest, + EccWorkspaceStepConfigurationReadRequest, + EccWorkspaceStepConfigurationReadResult, + EccWorkspaceSpecValidationRequest, + EccWorkspaceSpecValidationResult, + EccWorkspaceUpdateRequest, + EccWorkspaceUpdateResult, } from '@ecos-studio/shared' +import { open, stat } from 'node:fs/promises' import { electronLogger } from '../logger' @@ -49,6 +55,13 @@ import { type EccRpcRuntimeSidecar, } from './workspaceRuntime' import type { JsonRpcNotificationPayload } from './jsonRpcClient' +import type { RuntimeShutdownResult } from './runtimeClient' +import { RuntimeOperationProjection } from './runtimeOperationProjection' +import { mapStepConfigurationReadResult } from './stepConfigurationResult' +import { + hasPersistedWorkspace, + readPersistedEngineeringSnapshot, +} from './engineeringSnapshotReader' export type { EccRpcRuntimeClient, EccRpcRuntimeSidecar } @@ -60,9 +73,7 @@ export interface EccRpcRuntimeServiceOptions { ): EccRpcRuntimeSidecar onEvent?: (event: EccRuntimeEvent) => void lazyWorkspaceOpen?: boolean - snapshotLoader?: ( - directory: string, - ) => Promise> + managementRpc?: boolean } /** @@ -74,6 +85,15 @@ export class EccRpcRuntimeService { private readonly runtimes = new Map() private readonly handleToDirectory = new Map() private readonly eventListeners = new Set<(event: EccRuntimeEvent) => void>() + private readonly pendingReleaseHandles = new Set() + private readonly workspaceReleasedListeners = new Set< + (workspaceHandle: string) => void + >() + private readonly projection = new RuntimeOperationProjection({ + handleEntries: () => this.handleToDirectory, + runtimeForDirectory: (directory) => this.runtimes.get(directory), + runtimes: () => this.uniqueRuntimes(), + }) private controlRuntime: EccWorkspaceRuntime | null = null constructor(private readonly options: EccRpcRuntimeServiceOptions) {} @@ -134,19 +154,62 @@ export class EccRpcRuntimeService { return this.uniqueRuntimes().some((runtime) => runtime.isActive()) } - hasPendingRuntimeWork(): boolean { - return this.uniqueRuntimes().some((runtime) => runtime.hasPendingRuntimeWork()) + activeOperations(): EccRuntimeOperation[] { + return this.uniqueRuntimes().flatMap((runtime) => runtime.activeOperations()) + } + + operationProjection(): EccBackgroundOperationProjection { + return this.projection.snapshot() } - rpcHello(): Promise { - return this.getOrCreateControlRuntime().rpcHello() + async reconcileOperationProjection(): Promise { + const seen = new Set() + const reconciliations: Promise[] = [] + for (const [workspaceHandle, directory] of this.handleToDirectory) { + const runtime = this.runtimes.get(directory) + if (!runtime || seen.has(runtime)) continue + seen.add(runtime) + reconciliations.push(runtime.reconcileActiveOperations(workspaceHandle)) + } + await Promise.allSettled(reconciliations) + this.projection.refresh() + return this.projection.snapshot() + } + + onOperationProjectionInvalidated(listener: (generation: number) => void): () => void { + return this.projection.onInvalidated(listener) + } + + onWorkspaceReleased(listener: (workspaceHandle: string) => void): () => void { + this.workspaceReleasedListeners.add(listener) + return () => this.workspaceReleasedListeners.delete(listener) + } + + hasPendingRuntimeWork(workspaceHandles?: readonly string[]): boolean { + return this.runtimesForHandles(workspaceHandles).some((runtime) => + runtime.hasPendingRuntimeWork(), + ) + } + + waitForIdle(workspaceHandles?: readonly string[]): Promise { + if (!this.hasPendingRuntimeWork(workspaceHandles)) return Promise.resolve() + return new Promise((resolve) => { + const unsubscribe = this.onOperationProjectionInvalidated(() => { + if (this.hasPendingRuntimeWork(workspaceHandles)) return + unsubscribe() + resolve() + }) + }) } - rpcPing(): Promise { - return this.getOrCreateControlRuntime().rpcPing() + async flushPendingState(): Promise { + // Sidecar and application log writes are synchronous. One event-loop turn + // drains queued Runtime notifications before publishing the last projection. + await new Promise((resolve) => setImmediate(resolve)) + this.projection.refresh() } - async rpcShutdown(): Promise { + async shutdown(): Promise { const runtimes = this.uniqueRuntimes() const blockingRuntime = runtimes.find((runtime) => runtime.hasPendingRuntimeWork()) if (blockingRuntime) { @@ -172,9 +235,62 @@ export class EccRpcRuntimeService { return { ok: true } } + async forceShutdown(workspaceHandles?: readonly string[]): Promise { + if (!workspaceHandles) { + await Promise.allSettled( + this.uniqueRuntimes().map((runtime) => runtime.forceShutdown()), + ) + this.runtimes.clear() + this.handleToDirectory.clear() + this.controlRuntime = null + return + } + + const handles = new Set(workspaceHandles) + const runtimes = new Set() + for (const workspaceHandle of handles) { + try { + runtimes.add(this.runtimeForHandle(workspaceHandle)) + } catch { + // The handle may have completed release while Force quit was being confirmed. + } + } + await Promise.allSettled([...runtimes].map((runtime) => runtime.forceShutdown())) + for (const [workspaceHandle, directory] of this.handleToDirectory) { + const runtime = this.runtimes.get(directory) + if (!handles.has(workspaceHandle) && (!runtime || !runtimes.has(runtime))) continue + if (runtime) this.projection.rememberReleased(runtime, workspaceHandle, directory) + this.pendingReleaseHandles.delete(workspaceHandle) + this.handleToDirectory.delete(workspaceHandle) + for (const listener of this.workspaceReleasedListeners) listener(workspaceHandle) + } + for (const runtime of runtimes) this.removeRuntimeAliases(runtime) + this.projection.refresh() + } + + async inspectWorkspaceIdentity( + directory: string, + ): Promise<{ workspaceId?: string; workspaceRevision?: number }> { + const key = normalizeWorkspacePath(directory) + const existingHandle = [...this.handleToDirectory].find( + ([, candidate]) => candidate === key, + )?.[0] + if (existingHandle) return await this.workspaceSession(existingHandle) + + const opened = await this.openWorkspace({ directory: key }) + try { + return { + workspaceId: opened.workspaceId, + workspaceRevision: opened.workspaceRevision, + } + } finally { + await this.closeWorkspace({ workspaceHandle: opened.workspaceHandle }) + } + } + createWorkspace(request: EccWorkspaceCreateRequest): Promise { - const requestKey = normalizeWorkspacePath(request.directory) - const runtime = this.getOrCreateRuntime(request.directory) + const requestKey = normalizeWorkspacePath(request.targetDirectory) + const runtime = this.getOrCreateRuntime(request.targetDirectory) return runtime.createWorkspace(request).then(async (result) => { this.bindHandleToRuntime(result.workspaceHandle, requestKey, result.directory) await runtime.releaseIdleSidecar() @@ -182,11 +298,51 @@ export class EccRpcRuntimeService { }) } - openWorkspace(request: EccWorkspaceOpenRequest): Promise { + describeWorkspaceSpec(): Promise> { + return this.getOrCreateControlRuntime().describeWorkspaceSpec() + } + + validateWorkspaceSpec( + request: EccWorkspaceSpecValidationRequest, + ): Promise { + return this.getOrCreateControlRuntime().validateWorkspaceSpec(request) + } + + updateWorkspace(request: EccWorkspaceUpdateRequest): Promise { + return this.runtimeForHandle(request.workspaceHandle).updateWorkspace(request) + } + + updateWorkspaceConfiguration( + request: EccWorkspaceConfigurationUpdateRequest, + ): Promise { + return this.runtimeForHandle(request.workspaceHandle).updateWorkspaceConfiguration( + request, + ) + } + + updateWorkspaceStepConfiguration( + request: EccWorkspaceStepConfigurationUpdateRequest, + ): Promise { + return this.runtimeForHandle( + request.workspaceHandle, + ).updateWorkspaceStepConfiguration(request) + } + + async openWorkspace(request: EccWorkspaceOpenRequest): Promise { const requestKey = normalizeWorkspacePath(request.directory) const runtime = this.getOrCreateRuntime(request.directory) - return runtime.openWorkspace(request).then(async (result) => { - this.bindHandleToRuntime(result.workspaceHandle, requestKey, result.directory) + const retainedHandle = [...this.pendingReleaseHandles].find((workspaceHandle) => { + try { + return this.runtimeForHandle(workspaceHandle) === runtime + } catch { + return false + } + }) + if (retainedHandle) return await this.workspaceSession(retainedHandle) + + const result = await runtime.openWorkspace(request) + this.bindHandleToRuntime(result.workspaceHandle, requestKey, result.directory) + if (!result.reused) { try { await runtime.recoverInterrupted(result.workspaceHandle) } catch (error) { @@ -196,8 +352,19 @@ export class EccRpcRuntimeService { error, ) } - return result - }) + } + return result + } + + async workspaceSession(workspaceHandle: string): Promise { + this.pendingReleaseHandles.delete(workspaceHandle) + const runtime = this.runtimeForHandle(workspaceHandle) + const session = runtime.workspaceSession(workspaceHandle) + if (runtime.finalization()?.state === 'snapshot-failed') { + await runtime.retryFinalSnapshot() + this.projection.refresh() + } + return session } async closeWorkspace( @@ -208,12 +375,29 @@ export class EccRpcRuntimeService { try { return await runtime.closeWorkspace(request) } finally { + this.projection.rememberReleased(runtime, request.workspaceHandle, directory) + this.pendingReleaseHandles.delete(request.workspaceHandle) this.handleToDirectory.delete(request.workspaceHandle) if (!runtime.hasSessions()) { this.removeRuntimeAliases(runtime) await runtime.shutdown() } + this.projection.refresh() + for (const listener of this.workspaceReleasedListeners) { + listener(request.workspaceHandle) + } + } + } + + async releaseWorkspace( + request: EccWorkspaceHandleRequest, + ): Promise { + const runtime = this.runtimeForHandle(request.workspaceHandle) + if (runtime.hasPendingRuntimeWork()) { + this.pendingReleaseHandles.add(request.workspaceHandle) + return { ok: true, retained: true } } + return await this.closeWorkspace(request) } async workspaceHome( @@ -226,18 +410,33 @@ export class EccRpcRuntimeService { return this.runtimeForHandle(request.workspaceHandle).workspaceInfo(request) } + async readWorkspaceStepConfiguration( + request: EccWorkspaceStepConfigurationReadRequest, + ): Promise { + const result = await this.runtimeForHandle( + request.workspaceHandle, + ).readWorkspaceStepConfiguration(request) + return mapStepConfigurationReadResult(result) + } + + async readWorkspaceStepConfigurationForDirectory( + directory: string, + step: string, + ): Promise { + const result = + await this.getOrCreateControlRuntime().readWorkspaceStepConfigurationForDirectory( + directory, + step, + ) + return mapStepConfigurationReadResult(result) + } + async refreshConfig( request: EccWorkspaceHandleRequest, ): Promise { return this.runtimeForHandle(request.workspaceHandle).refreshConfig(request) } - async syncConfig( - request: EccWorkspaceSyncConfigRequest, - ): Promise { - return this.runtimeForHandle(request.workspaceHandle).syncConfig(request) - } - async resetFlow( request: EccWorkspaceHandleRequest, ): Promise { @@ -250,12 +449,6 @@ export class EccRpcRuntimeService { return this.runtimeForHandle(request.workspaceHandle).exportSignoff(request) } - async inspectSignoff( - request: EccWorkspaceHandleRequest, - ): Promise { - return this.runtimeForHandle(request.workspaceHandle).inspectSignoff(request) - } - layoutEditBegin(request: EccLayoutEditBeginRequest): Promise { return this.runtimeForHandle(request.workspaceHandle).layoutEditBegin(request) } @@ -282,12 +475,24 @@ export class EccRpcRuntimeService { return this.runtimeForHandle(request.workspaceHandle).runStep(request) } - startFlowOperation(request: EccRuntimeStartFlowRequest): Promise { - return this.runtimeForHandle(request.workspaceHandle).startFlowOperation(request) + async startFlowOperation( + request: EccRuntimeStartFlowRequest, + ): Promise { + const runtime = this.runtimeForHandle(request.workspaceHandle) + const operation = await runtime.startFlowOperation(request) + runtime.trackOperationSnapshot(operation) + this.projection.refresh() + return operation } - startStepOperation(request: EccRuntimeStartStepRequest): Promise { - return this.runtimeForHandle(request.workspaceHandle).startStepOperation(request) + async startStepOperation( + request: EccRuntimeStartStepRequest, + ): Promise { + const runtime = this.runtimeForHandle(request.workspaceHandle) + const operation = await runtime.startStepOperation(request) + runtime.trackOperationSnapshot(operation) + this.projection.refresh() + return operation } operationStatus(request: EccRuntimeOperationRequest): Promise { @@ -298,30 +503,34 @@ export class EccRpcRuntimeService { return this.runtimeForHandle(request.workspaceHandle).waitForOperation(request) } + async operationLog( + request: EccRuntimeOperationRequest, + ): Promise { + const path = this.runtimeForHandle(request.workspaceHandle).operationLogFile(request) + const size = (await stat(path)).size + const maxBytes = 64 * 1024 + const offset = Math.max(0, size - maxBytes) + const handle = await open(path, 'r') + try { + const buffer = Buffer.alloc(Math.min(size, maxBytes)) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset) + return { + content: buffer.subarray(0, bytesRead).toString('utf8'), + truncated: offset > 0, + } + } finally { + await handle.close() + } + } + cancelOperation( request: EccRuntimeOperationRequest, ): Promise<{ accepted: boolean; operationId: string; state: string }> { return this.runtimeForHandle(request.workspaceHandle).cancelOperation(request) } - acknowledgeStepRendered(request: EccRuntimeStepRenderedAckRequest): Promise<{ - accepted: boolean - duplicate: boolean - eventId: string - operationId: string - }> { - return this.runtimeForHandle(request.workspaceHandle).acknowledgeStepRendered(request) - } - - acknowledgeDetachedStepRendered(request: EccRuntimeStepRenderedAckRequest): Promise<{ - accepted: boolean - duplicate: boolean - eventId: string - operationId: string - }> { - return this.runtimeForHandle(request.workspaceHandle).acknowledgeDetachedStepRendered( - request, - ) + retryFinalSnapshot(request: EccWorkspaceHandleRequest): Promise { + return this.runtimeForHandle(request.workspaceHandle).retryFinalSnapshot() } workspaceSnapshot( @@ -330,6 +539,52 @@ export class EccRpcRuntimeService { return this.runtimeForHandle(request.workspaceHandle).workspaceSnapshot(request) } + async engineeringSnapshot( + request: EccWorkspaceHandleRequest, + ): Promise { + const snapshot = await this.rawEngineeringSnapshot(request.workspaceHandle) + if ( + request.expectedWorkspaceRevision !== undefined && + snapshot.workspaceRevision !== request.expectedWorkspaceRevision + ) { + throw new Error('ENGINEERING_WORKSPACE_REVISION_MISMATCH') + } + return { + ...snapshot, + artifacts: snapshot.artifacts.map( + ({ reference: _reference, ...artifact }) => artifact, + ), + } + } + + async engineeringSnapshotForDirectory( + directory: string, + ): Promise { + const key = normalizeWorkspacePath(directory) + if (hasPersistedWorkspace(key)) { + return await readPersistedEngineeringSnapshot(key) + } + const workspaceHandle = [...this.handleToDirectory].find( + ([, candidateDirectory]) => candidateDirectory === key, + )?.[0] + if (workspaceHandle) return await this.engineeringSnapshot({ workspaceHandle }) + + const opened = await this.openWorkspace({ directory: key }) + try { + return await this.engineeringSnapshot({ workspaceHandle: opened.workspaceHandle }) + } finally { + await this.closeWorkspace({ workspaceHandle: opened.workspaceHandle }) + } + } + + private async rawEngineeringSnapshot( + workspaceHandle: string, + ): Promise { + return await this.runtimeForHandle(workspaceHandle).engineeringSnapshot({ + workspaceHandle, + }) + } + private getOrCreateRuntime(directory: string): EccWorkspaceRuntime { const key = normalizeWorkspacePath(directory) if (!key) { @@ -342,8 +597,8 @@ export class EccRpcRuntimeService { this.options.createSidecar(key, onEvent, onNotification), directory: key, lazyWorkspaceOpen: this.options.lazyWorkspaceOpen, + managementRpc: this.options.managementRpc, onEvent: (event) => this.emit(event), - snapshotLoader: this.options.snapshotLoader, }) this.runtimes.set(key, runtime) } @@ -359,12 +614,28 @@ export class EccRpcRuntimeService { ) } + private runtimesForHandles( + workspaceHandles?: readonly string[], + ): EccWorkspaceRuntime[] { + if (!workspaceHandles) return this.uniqueRuntimes() + const runtimes = new Set() + for (const workspaceHandle of workspaceHandles) { + try { + runtimes.add(this.runtimeForHandle(workspaceHandle)) + } catch { + // A scoped handle may finish releasing while shutdown state is reconciling. + } + } + return [...runtimes] + } + private getOrCreateControlRuntime(): EccWorkspaceRuntime { if (!this.controlRuntime) { this.controlRuntime = new EccWorkspaceRuntime({ createSidecar: (onEvent, onNotification) => this.options.createSidecar(null, onEvent, onNotification), directory: null, + managementRpc: this.options.managementRpc, onEvent: (event) => this.emit(event), }) } @@ -440,5 +711,29 @@ export class EccRpcRuntimeService { for (const listener of this.eventListeners) { listener(event) } + this.projection.refresh() + if (event.type === 'runtime.idle') { + void this.releaseUnreferencedIdleSessions() + } + } + + private async releaseUnreferencedIdleSessions(): Promise { + for (const workspaceHandle of this.pendingReleaseHandles) { + let runtime: EccWorkspaceRuntime + try { + runtime = this.runtimeForHandle(workspaceHandle) + } catch { + this.pendingReleaseHandles.delete(workspaceHandle) + continue + } + if (runtime.hasPendingRuntimeWork()) continue + await this.closeWorkspace({ workspaceHandle }).catch((error) => { + electronLogger.error( + '[runtime] failed to release background Workspace %s: %s', + workspaceHandle, + error, + ) + }) + } } } diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeSidecarLifecycle.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeSidecarLifecycle.test.ts new file mode 100644 index 000000000..a5b092d5d --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeSidecarLifecycle.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' +import { RuntimeSidecarLifecycle } from './runtimeSidecarLifecycle' + +describe('RuntimeSidecarLifecycle', () => { + it('retains a failed final snapshot and releases only after a successful retry', async () => { + const captureFinalSnapshot = vi + .fn() + .mockRejectedValueOnce(new Error('snapshot damaged')) + .mockResolvedValueOnce(undefined) + const closeSidecar = vi.fn().mockResolvedValue(undefined) + const lifecycle = new RuntimeSidecarLifecycle({ + captureFinalSnapshot, + closeSidecar, + emitError: vi.fn(), + emitIdle: vi.fn(), + hasActiveOperations: () => false, + }) + + lifecycle.finalizeOperation('workspace-1') + await lifecycle.waitForFinalSnapshot() + + expect(lifecycle.finalization()).toEqual({ + issue: 'Failed to persist final ECC snapshot: snapshot damaged', + state: 'snapshot-failed', + workspaceId: 'workspace-1', + }) + expect(closeSidecar).not.toHaveBeenCalled() + + await expect(lifecycle.retryFinalSnapshot()).resolves.toBe(true) + expect(captureFinalSnapshot).toHaveBeenCalledTimes(2) + expect(closeSidecar).toHaveBeenCalledOnce() + expect(lifecycle.finalization()).toBeNull() + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeSidecarLifecycle.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeSidecarLifecycle.ts index 47aa6a455..b0e6d5dc6 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeSidecarLifecycle.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeSidecarLifecycle.ts @@ -1,12 +1,9 @@ -const DEFAULT_DIAGNOSTIC_IDLE_TIMEOUT_MS = 30_000 - export interface RuntimeSidecarLifecycleOptions { captureFinalSnapshot(workspaceId: string): Promise closeSidecar(): Promise emitError(message: string): void emitIdle(): void hasActiveOperations(): boolean - diagnosticIdleTimeoutMs?: number } /** @@ -15,8 +12,12 @@ export interface RuntimeSidecarLifecycleOptions { * diagnostics, then release it without leaking a long-lived sidecar. */ export class RuntimeSidecarLifecycle { - private diagnosticReleaseTimer: ReturnType | null = null private finalSnapshotTask: Promise | null = null + private finalizationState: { + issue?: string + state: 'finalizing' | 'snapshot-failed' + workspaceId: string + } | null = null constructor(private readonly options: RuntimeSidecarLifecycleOptions) {} @@ -28,10 +29,18 @@ export class RuntimeSidecarLifecycle { return this.finalSnapshotTask } - releaseAfterSuccessfulOperation(workspaceId: string): void { + finalization() { + return this.finalizationState ? { ...this.finalizationState } : null + } + + hasFinalizationBlocker(): boolean { + return this.finalizationState !== null + } + + finalizeOperation(workspaceId: string): void { if (this.finalSnapshotTask || this.options.hasActiveOperations()) return - this.cancelDiagnosticRelease() - const task = this.finishSuccessfulOperation(workspaceId) + this.finalizationState = { state: 'finalizing', workspaceId } + const task = this.finishOperation(workspaceId) this.finalSnapshotTask = task void task.finally(() => { if (this.finalSnapshotTask === task) { @@ -41,32 +50,23 @@ export class RuntimeSidecarLifecycle { }) } - retainFailedOperationForDiagnostics(): void { - if (this.options.hasActiveOperations() || this.diagnosticReleaseTimer) return - const timeoutMs = - this.options.diagnosticIdleTimeoutMs ?? DEFAULT_DIAGNOSTIC_IDLE_TIMEOUT_MS - this.diagnosticReleaseTimer = setTimeout(() => { - this.diagnosticReleaseTimer = null - if (this.options.hasActiveOperations()) return - void this.options.closeSidecar().then( - () => this.options.emitIdle(), - (error: unknown) => this.options.emitError(errorMessage(error)), - ) - }, timeoutMs) - } - - cancelDiagnosticRelease(): void { - if (!this.diagnosticReleaseTimer) return - clearTimeout(this.diagnosticReleaseTimer) - this.diagnosticReleaseTimer = null + async retryFinalSnapshot(): Promise { + const workspaceId = this.finalizationState?.workspaceId + if (!workspaceId || this.finalizationState?.state !== 'snapshot-failed') return false + this.finalizeOperation(workspaceId) + await this.finalSnapshotTask + return this.finalizationState === null } - private async finishSuccessfulOperation(workspaceId: string): Promise { + private async finishOperation(workspaceId: string): Promise { try { await this.options.captureFinalSnapshot(workspaceId) await this.options.closeSidecar() + this.finalizationState = null } catch (error) { - this.options.emitError(errorMessage(error)) + const issue = errorMessage(error) + this.finalizationState = { issue, state: 'snapshot-failed', workspaceId } + this.options.emitError(issue) } } } diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/sidecarProcess.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/sidecarProcess.test.ts index b2e331625..08918ac71 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/sidecarProcess.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/sidecarProcess.test.ts @@ -149,6 +149,7 @@ describe('EccRpcSidecarProcess', () => { const spawn = vi.fn(() => children.shift()!) let runtimeEnv: NodeJS.ProcessEnv = { PATH: '/tools/v1/bin' } const sidecar = new EccRpcSidecarProcess({ + managementRpc: true, envProvider: async () => runtimeEnv, spawn, }) @@ -223,6 +224,7 @@ describe('EccRpcSidecarProcess', () => { const spawn = vi.fn(() => child) let runtimeEnv: NodeJS.ProcessEnv = { PATH: '/tools/v1/bin' } const sidecar = new EccRpcSidecarProcess({ + managementRpc: true, envProvider: async () => runtimeEnv, shutdownTimeoutMs: 25, spawn, @@ -254,6 +256,7 @@ describe('EccRpcSidecarProcess', () => { const child = new FakeChild() let runtimeEnv: NodeJS.ProcessEnv = { PATH: '/tools/v1/bin' } const sidecar = new EccRpcSidecarProcess({ + managementRpc: true, envProvider: async () => runtimeEnv, shutdownTimeoutMs: 25, spawn: () => child, @@ -316,7 +319,10 @@ describe('EccRpcSidecarProcess', () => { it('does not signal a sidecar when ECC defers shutdown for an active operation', async () => { const child = new FakeChild() - const sidecar = new EccRpcSidecarProcess({ spawn: () => child }) + const sidecar = new EccRpcSidecarProcess({ + managementRpc: true, + spawn: () => child, + }) await sidecar.start() const shutdown = sidecar.shutdown() @@ -335,7 +341,10 @@ describe('EccRpcSidecarProcess', () => { it('waits for the sidecar process to exit after rpc.shutdown is acknowledged', async () => { const child = new FakeChild() - const sidecar = new EccRpcSidecarProcess({ spawn: () => child }) + const sidecar = new EccRpcSidecarProcess({ + managementRpc: true, + spawn: () => child, + }) await sidecar.start() let settled = false @@ -355,6 +364,21 @@ describe('EccRpcSidecarProcess', () => { await expect(shutdown).resolves.toBeUndefined() }) + it('terminates the backend runtime without a management RPC', async () => { + const child = new FakeChild() + const sidecar = new EccRpcSidecarProcess({ spawn: () => child }) + await sidecar.start() + + const shutdown = sidecar.shutdown() + await vi.waitFor(() => { + expect(child.signals).toEqual(['SIGTERM']) + }) + expect(child.stdin.chunks).toEqual([]) + child.emit('close', 0, null) + + await expect(shutdown).resolves.toBeUndefined() + }) + it('forwards sidecar JSON-RPC notifications to the runtime owner', async () => { const child = new FakeChild() const notifications: unknown[] = [] @@ -503,6 +527,7 @@ describe('EccRpcSidecarProcess', () => { vi.useFakeTimers() const child = new FakeChild() const sidecar = new EccRpcSidecarProcess({ + managementRpc: true, shutdownTimeoutMs: 25, spawn: () => child, }) @@ -520,4 +545,14 @@ describe('EccRpcSidecarProcess', () => { message: 'ECC RPC sidecar did not exit after SIGKILL.', }) }) + + it('force terminates the running sidecar with SIGKILL', async () => { + const child = new FakeChild() + const sidecar = new EccRpcSidecarProcess({ spawn: () => child }) + await sidecar.start() + + await sidecar.forceShutdown() + + expect(child.signals).toEqual(['SIGKILL']) + }) }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/sidecarProcess.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/sidecarProcess.ts index 751589c3b..c1fe35063 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/sidecarProcess.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/sidecarProcess.ts @@ -36,6 +36,7 @@ export type EccRpcSidecarSpawn = ( ) => SpawnedEccRpcSidecar export interface EccRpcSidecarProcessOptions { + managementRpc?: boolean command?: string commandArgs?: string[] env?: NodeJS.ProcessEnv @@ -181,9 +182,7 @@ export class EccRpcSidecarProcess { this.shuttingDown = false this.outputTail = '' this.launchError = null - this.appendLog( - `[sidecar] spawning ${this.command} rpc serve --stdio --persistent-db\n`, - ) + this.appendLog(`[sidecar] spawning ${launch.command} ${launch.args.join(' ')}\n`) const child = this.spawnImpl(launch.command, launch.args, { env, @@ -288,6 +287,12 @@ export class EccRpcSidecarProcess { await this.stopForRestart(child) } + async forceShutdown(): Promise { + this.shuttingDown = true + this.clearForceKillTimer() + this.child?.kill('SIGKILL') + } + /** * Move a legacy workspace-owned sidecar log before ECC deletes rerun artifacts. * stderr is appended by path, so updating logFile synchronously prevents the @@ -322,6 +327,7 @@ export class EccRpcSidecarProcess { } private async stopForRestart(child: SpawnedEccRpcSidecar): Promise { + this.shuttingDown = true let didExit = false let resolveExit: (() => void) | undefined const onClose = () => { @@ -336,9 +342,10 @@ export class EccRpcSidecarProcess { try { this.clearForceKillTimer() const client = this.client - const shutdownResult = client - ? await this.requestShutdown(client) - : { kind: 'failed' as const } + const shutdownResult = + this.options.managementRpc && client + ? await this.requestShutdown(client) + : { kind: 'failed' as const } if (shutdownResult.kind === 'deferred') { this.shuttingDown = false throw new EccRpcShutdownDeferredError(shutdownResult.shutdownBarrier) @@ -372,7 +379,6 @@ export class EccRpcSidecarProcess { private async requestShutdown( client: EccJsonRpcClient, ): Promise { - this.shuttingDown = true try { const result = await client.call<{ deferred?: boolean diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/stepConfigurationResult.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/stepConfigurationResult.ts new file mode 100644 index 000000000..626346db3 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/stepConfigurationResult.ts @@ -0,0 +1,72 @@ +import type { EccWorkspaceStepConfigurationReadResult } from '@ecos-studio/shared' + +export function mapStepConfigurationReadResult( + result: unknown, +): EccWorkspaceStepConfigurationReadResult { + if (!isRecord(result) || typeof result.step !== 'string') { + return invalidResult('') + } + if (result.status === 'available') { + if ( + !hasWorkspaceIdentity(result) || + typeof result.stepId !== 'string' || + !Array.isArray(result.parameters) || + !result.parameters.every(isPublicParameterRecord) + ) { + return invalidResult(result.step) + } + return result as EccWorkspaceStepConfigurationReadResult + } + if ( + (result.status === 'missing' || result.status === 'unavailable') && + typeof result.reason === 'string' + ) { + if ( + result.reason === 'step_configuration_unavailable' && + !hasWorkspaceIdentity(result) + ) { + return invalidResult(result.step) + } + if ( + result.status === 'unavailable' && + result.reason === 'step_configuration_unavailable' + ) { + return { ...result, status: 'missing' } as EccWorkspaceStepConfigurationReadResult + } + return result as EccWorkspaceStepConfigurationReadResult + } + return invalidResult(result.step) +} + +function hasWorkspaceIdentity(result: Record): boolean { + return ( + typeof result.workspaceId === 'string' && + typeof result.workspaceRevision === 'number' && + Number.isInteger(result.workspaceRevision) && + result.workspaceRevision >= 1 + ) +} + +function isPublicParameterRecord(parameter: unknown): boolean { + return ( + isRecord(parameter) && + typeof parameter.param === 'string' && + typeof parameter.type === 'string' && + typeof parameter.applies === 'string' && + typeof parameter.description === 'string' && + 'value' in parameter && + 'default' in parameter + ) +} + +function invalidResult(step: string): EccWorkspaceStepConfigurationReadResult { + return { + reason: 'step_configuration_invalid_response', + status: 'unavailable', + step, + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/transport.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/transport.test.ts index 5953c4afa..6b5744d3c 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/transport.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/transport.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { ENGINEERING_SNAPSHOT_MAX_BYTES } from '@ecos-studio/shared' import { ContentLengthDecoder, @@ -62,6 +63,16 @@ describe('ECC RPC stdio transport', () => { ) }) + it('rejects an oversized protocol body before buffering it', () => { + const decoder = new ContentLengthDecoder() + + expect(() => + decoder.feed(`Content-Length: ${ENGINEERING_SNAPSHOT_MAX_BYTES + 1}\r\n\r\n`), + ).toThrow( + `Content-Length ${ENGINEERING_SNAPSHOT_MAX_BYTES + 1} exceeds ${ENGINEERING_SNAPSHOT_MAX_BYTES} bytes.`, + ) + }) + it('resynchronizes at the next valid frame after a stdout preamble', () => { const decoder = new ContentLengthDecoder() const frame = encodeContentLengthFrame('{"jsonrpc":"2.0","id":1,"result":true}') diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/transport.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/transport.ts index c5d1ad6c8..65aad5f26 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/transport.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/transport.ts @@ -1,3 +1,5 @@ +import { ENGINEERING_SNAPSHOT_MAX_BYTES } from '@ecos-studio/shared' + const HEADER_SEPARATOR = Buffer.from('\r\n\r\n', 'ascii') const CONTENT_LENGTH_PATTERN = /^Content-Length:\s*(\d+)$/i const CONTENT_LENGTH_PREFIX = Buffer.from('Content-Length:', 'ascii') @@ -95,6 +97,11 @@ export class ContentLengthDecoder { if (!Number.isSafeInteger(value) || value < 0) { throw new TransportError(`Invalid Content-Length value: ${match[1]}`) } + if (value > ENGINEERING_SNAPSHOT_MAX_BYTES) { + throw new TransportError( + `Content-Length ${value} exceeds ${ENGINEERING_SNAPSHOT_MAX_BYTES} bytes.`, + ) + } return value } diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceConfigMigration.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceConfigMigration.test.ts deleted file mode 100644 index f513f5960..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceConfigMigration.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' - -import { migrateWorkspaceConfigFilenames } from './workspaceConfigMigration' - -const temporaryDirectories: string[] = [] - -const filenameMigrations = [ - ['flow_config.json', 'flow_ecc.json'], - ['db_default_config.json', 'db_ecc.json'], - ['cts_default_config.json', 'cts_ecc.json'], - ['drc_default_config.json', 'drc_ecc.json'], - ['fp_default_config.json', 'floorplan_ecc.json'], - ['rt_default_config.json', 'route_ecc.json'], - ['pl_default_config.json', 'filler_ecc.json'], - ['rcx.json', 'rcx_ecc.json'], - ['sta.json', 'sta_ecc.json'], - ['dreamplace.json', 'dreamplace_ecc.json'], -] as const - -function createWorkspace(): string { - const directory = mkdtempSync(join(tmpdir(), 'ecos-workspace-config-migration-')) - temporaryDirectories.push(directory) - mkdirSync(join(directory, 'config')) - return directory -} - -describe('migrateWorkspaceConfigFilenames', () => { - afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { force: true, recursive: true }) - } - }) - - it('renames legacy config files and rewrites flow ConfigPath values', async () => { - const directory = createWorkspace() - const configDirectory = join(directory, 'config') - const configPaths = Object.fromEntries( - filenameMigrations.map(([legacy]) => [legacy, join(configDirectory, legacy)]), - ) - - writeFileSync( - join(configDirectory, 'flow_config.json'), - JSON.stringify({ ConfigPath: configPaths }), - ) - for (const [legacy] of filenameMigrations.slice(1)) { - writeFileSync(join(configDirectory, legacy), '{}') - } - - await migrateWorkspaceConfigFilenames(directory) - - for (const [legacy, canonical] of filenameMigrations) { - expect(existsSync(join(configDirectory, legacy))).toBe(false) - expect(existsSync(join(configDirectory, canonical))).toBe(true) - } - const migratedFlow = JSON.parse( - readFileSync(join(configDirectory, 'flow_ecc.json'), 'utf8'), - ) as { ConfigPath: Record } - for (const [legacy, canonical] of filenameMigrations) { - expect(migratedFlow.ConfigPath[legacy]).toBe(join(configDirectory, canonical)) - } - }) -}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceConfigMigration.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceConfigMigration.ts deleted file mode 100644 index 07b197b98..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceConfigMigration.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { readFile, rename, rm, stat, writeFile } from 'node:fs/promises' -import { basename, dirname, join } from 'node:path' - -interface WorkspaceConfigFilenameMigration { - canonical: string - legacy: string -} - -const CONFIG_FILENAME_MIGRATIONS: readonly WorkspaceConfigFilenameMigration[] = [ - { canonical: 'flow_ecc.json', legacy: 'flow_config.json' }, - { canonical: 'db_ecc.json', legacy: 'db_default_config.json' }, - { canonical: 'cts_ecc.json', legacy: 'cts_default_config.json' }, - { canonical: 'drc_ecc.json', legacy: 'drc_default_config.json' }, - { canonical: 'floorplan_ecc.json', legacy: 'fp_default_config.json' }, - { canonical: 'route_ecc.json', legacy: 'rt_default_config.json' }, - { canonical: 'filler_ecc.json', legacy: 'pl_default_config.json' }, - { canonical: 'rcx_ecc.json', legacy: 'rcx.json' }, - { canonical: 'sta_ecc.json', legacy: 'sta.json' }, - { canonical: 'dreamplace_ecc.json', legacy: 'dreamplace.json' }, -] - -const inFlightMigrations = new Map>() - -function isErrno(error: unknown, code: string): boolean { - return (error as NodeJS.ErrnoException).code === code -} - -async function exists(path: string): Promise { - try { - await stat(path) - return true - } catch (error) { - if (isErrno(error, 'ENOENT')) return false - throw error - } -} - -async function isFile(path: string): Promise { - try { - return (await stat(path)).isFile() - } catch (error) { - if (isErrno(error, 'ENOENT')) return false - throw error - } -} - -async function isDirectory(path: string): Promise { - try { - return (await stat(path)).isDirectory() - } catch (error) { - if (isErrno(error, 'ENOENT')) return false - throw error - } -} - -async function renameLegacyConfig( - configDirectory: string, - migration: WorkspaceConfigFilenameMigration, -): Promise { - const legacyPath = join(configDirectory, migration.legacy) - const canonicalPath = join(configDirectory, migration.canonical) - if (!(await isFile(legacyPath)) || (await exists(canonicalPath))) return - - try { - await rename(legacyPath, canonicalPath) - } catch (error) { - // Another concurrent GUI read may have completed this idempotent rename. - if (isErrno(error, 'ENOENT')) return - throw error - } -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -async function writeJsonAtomically(path: string, value: unknown): Promise { - const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp` - try { - await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8') - await rename(temporaryPath, path) - } catch (error) { - await rm(temporaryPath, { force: true }).catch(() => undefined) - throw error - } -} - -async function rewriteFlowConfigPaths(configDirectory: string): Promise { - const flowPath = join(configDirectory, 'flow_ecc.json') - if (!(await isFile(flowPath))) return - - const flow: unknown = JSON.parse(await readFile(flowPath, 'utf8')) - if (!isRecord(flow) || !isRecord(flow.ConfigPath)) return - - const canonicalByLegacyFilename = new Map( - CONFIG_FILENAME_MIGRATIONS.map(({ canonical, legacy }) => [legacy, canonical]), - ) - let changed = false - for (const [key, rawPath] of Object.entries(flow.ConfigPath)) { - if (typeof rawPath !== 'string') continue - const canonicalFilename = canonicalByLegacyFilename.get(basename(rawPath)) - if (!canonicalFilename) continue - const canonicalPath = join(dirname(rawPath), canonicalFilename) - if (canonicalPath === rawPath) continue - flow.ConfigPath[key] = canonicalPath - changed = true - } - - if (changed) await writeJsonAtomically(flowPath, flow) -} - -async function migrate(directory: string): Promise { - const configDirectory = join(directory, 'config') - if (!(await isDirectory(configDirectory))) return - - await Promise.all( - CONFIG_FILENAME_MIGRATIONS.map((migration) => - renameLegacyConfig(configDirectory, migration), - ), - ) - await rewriteFlowConfigPaths(configDirectory) -} - -/** - * Converts a legacy workspace in place before any code resolves canonical ECC - * config paths. Calls for the same directory share one migration operation. - */ -export function migrateWorkspaceConfigFilenames(directory: string): Promise { - const existing = inFlightMigrations.get(directory) - if (existing) return existing - - const migration = migrate(directory).finally(() => { - inFlightMigrations.delete(directory) - }) - inFlightMigrations.set(directory, migration) - return migration -} diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerun.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerun.test.ts index 3ea379751..da3aa88cb 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerun.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerun.test.ts @@ -14,13 +14,7 @@ import { join } from 'node:path' import type { DesktopAgentWorkspaceRerunContract } from '@ecos-studio/shared' import { afterEach, describe, expect, it, vi } from 'vitest' -import { - executeWorkspaceRerun, - prepareWorkspaceRerun, - rewriteHomeJsonSourcePaths, - rewriteJsonSourcePathStrings, - rewriteSourceRootedPath, -} from './workspaceRerun' +import { executeWorkspaceRerun, prepareWorkspaceRerun } from './workspaceRerun' const temporaryRoots: string[] = [] @@ -41,7 +35,6 @@ async function writeSourceWorkspace(): Promise<{ const source = join(root, 'gcd') const flow = JSON.stringify({ steps: [ - { name: 'Floorplan', state: 'Success', tool: 'ecc' }, { name: 'place', state: 'Success', tool: 'dreamplace' }, { name: 'CTS', state: 'Success', tool: 'ecc' }, { name: 'legalization', state: 'Success', tool: 'dreamplace' }, @@ -50,7 +43,6 @@ async function writeSourceWorkspace(): Promise<{ const artifact = Buffer.from('place-def') await mkdir(join(source, 'home'), { recursive: true }) await mkdir(join(source, 'config'), { recursive: true }) - await mkdir(join(source, 'Floorplan_ecc', 'output'), { recursive: true }) await mkdir(join(source, 'place_dreamplace', 'output'), { recursive: true }) await mkdir(join(source, 'CTS_ecc', 'output'), { recursive: true }) await mkdir(join(source, 'legalization_dreamplace', 'output'), { recursive: true }) @@ -60,10 +52,6 @@ async function writeSourceWorkspace(): Promise<{ join(source, 'config', 'dreamplace_ecc.json'), '{"density_weight":0.01}\n', ) - await writeFile( - join(source, 'Floorplan_ecc', 'output', 'gcd_Floorplan.def.gz'), - 'checkpoint', - ) await writeFile( join(source, 'place_dreamplace', 'output', 'gcd_place.def.gz'), artifact, @@ -102,15 +90,8 @@ function contractFor( source_workspace: source, target_step: 'place', target_workspace: `${source}_rerun_place`, - writes: [ - { - file: 'home/parameters.json', - json_path: ['Target density'], - knob_id: 'place.target_density', - surface: 'parameters', - value: 0.55, - }, - ], + step_configurations: [], + workspace_parameters: { target_density: 0.55 }, } } @@ -134,7 +115,7 @@ describe('prepareWorkspaceRerun', () => { ).resolves.toContain(contract.rerun_id) await expect( readFile(`${contract.target_workspace}/home/parameters.json`, 'utf8'), - ).resolves.toContain('0.55') + ).resolves.toContain('0.45') }) it('accepts a numbered isolated rerun target', async () => { @@ -148,18 +129,12 @@ describe('prepareWorkspaceRerun', () => { }) }) - it('preserves the predecessor checkpoint and empties the rerun suffix', async () => { + it('empties the target and downstream steps without restoring FixFanout', async () => { const { artifact, flow, source } = await writeSourceWorkspace() const contract = contractFor(source, flow, artifact) await prepareWorkspaceRerun(contract) - await expect( - readFile( - `${contract.target_workspace}/Floorplan_ecc/output/gcd_Floorplan.def.gz`, - 'utf8', - ), - ).resolves.toBe('checkpoint') await expect( readdir(`${contract.target_workspace}/place_dreamplace`), ).resolves.toEqual([]) @@ -172,177 +147,12 @@ describe('prepareWorkspaceRerun', () => { await readFile(`${contract.target_workspace}/home/flow.json`, 'utf8'), ) as { steps: Array<{ name: string; state: string; runtime?: string }> } expect(targetFlow.steps).toEqual([ - { name: 'Floorplan', state: 'Success', tool: 'ecc' }, { name: 'place', state: 'Unstart', tool: 'dreamplace', runtime: '' }, { name: 'CTS', state: 'Unstart', tool: 'ecc', runtime: '' }, { name: 'legalization', state: 'Unstart', tool: 'dreamplace', runtime: '' }, ]) }) - it('accepts the LEC result JSON as stage evidence and wipes the LEC stage', async () => { - const root = await mkdtemp(join(tmpdir(), 'ecos-workspace-rerun-lec-')) - temporaryRoots.push(root) - const source = join(root, 'gcd') - const flow = JSON.stringify({ - steps: [ - { name: 'place', state: 'Success', tool: 'dreamplace' }, - { name: 'postRouteLec', state: 'Success', tool: 'yosys_lec', runtime: '12s' }, - ], - }) - const artifact = Buffer.from('place-def') - const lecResult = Buffer.from('{"status":"proven"}\n') - await mkdir(join(source, 'home'), { recursive: true }) - await mkdir(join(source, 'place_dreamplace', 'output'), { recursive: true }) - await mkdir(join(source, 'postRouteLec_yosys_lec', 'output'), { recursive: true }) - await writeFile(join(source, 'home', 'flow.json'), flow) - await writeFile(join(source, 'home', 'parameters.json'), '{}\n') - await writeFile( - join(source, 'home', 'home.json'), - `${JSON.stringify({ - monitor: { - step: ['place - analysis', 'postRouteLec - analysis'], - memory: ['1', '2'], - runtime: ['1', '2'], - instance: ['1', '2'], - frequency: ['1', '2'], - }, - })}\n`, - ) - await writeFile( - join(source, 'home', 'checklist.json'), - `${JSON.stringify({ - schema_version: 3, - kind: 'signoff_checklist', - status: 'ready', - summary: { passed: 1, blocked: 0, attention: 0, unavailable: 0 }, - checklist: [ - { id: 'lec.postroute', step: 'postRouteLec', state: 'pass', blocked: false }, - ], - })}\n`, - ) - await writeFile( - join(source, 'place_dreamplace', 'output', 'gcd_place.def.gz'), - artifact, - ) - await writeFile( - join(source, 'postRouteLec_yosys_lec', 'output', 'gcd_postRouteLec_result.json'), - lecResult, - ) - - const contract: DesktopAgentWorkspaceRerunContract = { - design_id: 'gcd', - end_step: 'postRouteLec', - execution_scope: 'single_step', - parameter_patch: [], - requires_gui_review: true, - rerun_id: 'gcd_rerun_postroutelec', - schema_version: 'flow-agent.workspace_rerun_contract.v1', - source_stage_artifact: 'postRouteLec_yosys_lec/output/gcd_postRouteLec_result.json', - source_flow_json_sha256: sha256(flow), - source_stage_artifact_sha256: sha256(lecResult), - source_workspace: source, - target_step: 'postRouteLec', - target_workspace: `${source}_rerun_postroutelec`, - writes: [], - } - - await expect(prepareWorkspaceRerun(contract)).resolves.toEqual({ - directory: contract.target_workspace, - }) - - // The LEC stage is wiped while the earlier completed stage is preserved. - await expect( - readdir(join(contract.target_workspace, 'postRouteLec_yosys_lec')), - ).resolves.toEqual([]) - await expect( - readFile( - join(contract.target_workspace, 'place_dreamplace', 'output', 'gcd_place.def.gz'), - 'utf8', - ), - ).resolves.toBe('place-def') - - const targetFlow = JSON.parse( - await readFile(join(contract.target_workspace, 'home', 'flow.json'), 'utf8'), - ) as { steps: Array<{ name: string; state: string; runtime?: string }> } - expect(targetFlow.steps).toEqual([ - { name: 'place', state: 'Success', tool: 'dreamplace' }, - { name: 'postRouteLec', state: 'Unstart', tool: 'yosys_lec', runtime: '' }, - ]) - - const home = JSON.parse( - await readFile(join(contract.target_workspace, 'home', 'home.json'), 'utf8'), - ) as { monitor: { step: string[]; memory: string[] } } - expect(home.monitor.step).toEqual(['place - analysis']) - expect(home.monitor.memory).toEqual(['1']) - - const checklist = JSON.parse( - await readFile(join(contract.target_workspace, 'home', 'checklist.json'), 'utf8'), - ) as { checklist: Array<{ step: string }> } - expect(checklist.checklist).toEqual([]) - }) - - it('targets the sizer stage with sanitized directory and rerun id', async () => { - const root = await mkdtemp(join(tmpdir(), 'ecos-workspace-rerun-sizer-')) - temporaryRoots.push(root) - const source = join(root, 'gcd') - const flow = JSON.stringify({ - steps: [ - { name: 'CTS', state: 'Success', tool: 'ecc' }, - { name: 'Timing optimization', state: 'Success', tool: 'sizer' }, - ], - }) - const artifact = Buffer.from('sizer-def') - await mkdir(join(source, 'home'), { recursive: true }) - await mkdir(join(source, 'CTS_ecc', 'output'), { recursive: true }) - await mkdir(join(source, 'timing_optimization_sizer', 'output'), { - recursive: true, - }) - await writeFile(join(source, 'home', 'flow.json'), flow) - await writeFile(join(source, 'home', 'parameters.json'), '{}\n') - await writeFile(join(source, 'CTS_ecc', 'output', 'gcd_CTS.def.gz'), 'checkpoint') - await writeFile( - join( - source, - 'timing_optimization_sizer', - 'output', - 'gcd_timing_optimization.def.gz', - ), - artifact, - ) - - const contract: DesktopAgentWorkspaceRerunContract = { - design_id: 'gcd', - end_step: 'Timing optimization', - execution_scope: 'single_step', - parameter_patch: [], - requires_gui_review: true, - rerun_id: 'gcd_rerun_timing_optimization', - schema_version: 'flow-agent.workspace_rerun_contract.v1', - source_stage_artifact: - 'timing_optimization_sizer/output/gcd_timing_optimization.def.gz', - source_flow_json_sha256: sha256(flow), - source_stage_artifact_sha256: sha256(artifact), - source_workspace: source, - target_step: 'Timing optimization', - target_workspace: `${source}_rerun_timing_optimization`, - writes: [], - } - - await expect(prepareWorkspaceRerun(contract)).resolves.toEqual({ - directory: contract.target_workspace, - }) - - // The real sizer directory is wiped; no unsanitized junk directory appears. - await expect( - readdir(join(contract.target_workspace, 'timing_optimization_sizer')), - ).resolves.toEqual([]) - await expect( - readdir(join(contract.target_workspace, 'CTS_ecc', 'output')), - ).resolves.toContain('gcd_CTS.def.gz') - const targetEntries = await readdir(contract.target_workspace) - expect(targetEntries).not.toContain('Timing optimization_sizer') - }) - it('rewrites home.json paths and prunes post-target home aggregates', async () => { const { artifact, flow, source } = await writeSourceWorkspace() await mkdir(join(source, 'drc_ecc', 'analysis'), { recursive: true }) @@ -357,9 +167,15 @@ describe('prepareWorkspaceRerun', () => { checklist: `${source}/home/checklist.json`, metrics: { 'drc dist.': `${source}/drc_ecc/analysis/drc.png`, + 'fanout dist.': `${source}/fixFanout_ecc/output/fanout.png`, }, monitor: { - step: ['place - analysis', 'CTS - analysis', 'legalization - analysis'], + step: [ + 'fixFanout - analysis', + 'place - analysis', + 'CTS - analysis', + 'legalization - analysis', + ], memory: ['1', '2', '3', '4'], runtime: ['1', '2', '3', '4'], instance: ['1', '2', '3', '4'], @@ -380,8 +196,8 @@ describe('prepareWorkspaceRerun', () => { summary: { passed: 1, blocked: 2, attention: 0, unavailable: 0 }, checklist: [ { - id: 'artifact.floorplan', - step: 'Floorplan', + id: 'artifact.fixFanout', + step: 'fixFanout', state: 'pass', blocked: false, }, @@ -432,9 +248,9 @@ describe('prepareWorkspaceRerun', () => { summary: { passed: number; blocked: number } checklist: Array<{ step: string }> } - expect(checklist.checklist.map((item) => item.step)).toEqual(['Floorplan']) + expect(checklist.checklist).toEqual([]) expect(checklist.summary).toEqual({ - passed: 1, + passed: 0, blocked: 0, attention: 0, unavailable: 0, @@ -455,19 +271,28 @@ describe('prepareWorkspaceRerun', () => { const { artifact, flow, source } = await writeSourceWorkspace() const contract = contractFor(source, flow, artifact) const runtime = { - refreshConfig: vi.fn().mockResolvedValue({}), startFlowOperation: vi.fn().mockResolvedValue({ operationId: 'operation-flow' }), startStepOperation: vi.fn().mockResolvedValue({ operationId: 'operation-place' }), - syncConfig: vi.fn().mockResolvedValue({}), + updateWorkspaceConfiguration: vi.fn().mockResolvedValue({ workspaceRevision: 2 }), + updateWorkspaceStepConfiguration: vi + .fn() + .mockResolvedValue({ workspaceRevision: 3 }), waitForOperation: vi.fn().mockResolvedValue({ error: null, state: 'succeeded' }), } - await executeWorkspaceRerun(contract, runtime, 'target-gui-handle') + await executeWorkspaceRerun(contract, runtime, 'target-gui-handle', 1) - expect(runtime.syncConfig).not.toHaveBeenCalled() - expect(runtime.refreshConfig).toHaveBeenCalledWith({ - workspaceHandle: 'target-gui-handle', - }) + expect(runtime.updateWorkspaceConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: { + design: {}, + parameters: { target_density: 0.55 }, + pdk: {}, + }, + expectedWorkspaceRevision: 1, + workspaceHandle: 'target-gui-handle', + }), + ) expect(runtime.startStepOperation).toHaveBeenCalledWith( expect.objectContaining({ rerun: false, @@ -481,62 +306,54 @@ describe('prepareWorkspaceRerun', () => { }) }) - it('materializes resolved step-config writes in the isolated workspace', async () => { + it('does not materialize parameters while preparing the isolated workspace', async () => { const { artifact, flow, source } = await writeSourceWorkspace() const contract = contractFor(source, flow, artifact) contract.parameter_patch = [{ knob_id: 'place.density_weight', value: 0.1 }] - contract.writes = [ - { - file: 'config/dreamplace_ecc.json', - json_path: ['density_weight'], - knob_id: 'place.density_weight', - surface: 'step_config', - value: 0.1, - }, - ] + contract.workspace_parameters = { 'place.density_weight': 0.1 } + contract.step_configurations = [] await prepareWorkspaceRerun(contract) await expect( readFile(`${contract.target_workspace}/config/dreamplace_ecc.json`, 'utf8'), - ).resolves.toContain('0.1') + ).resolves.toContain('0.01') }) - it('syncs step-config writes and executes every full-flow step in order', async () => { + it('updates parameters atomically and executes every full-flow step in order', async () => { const { artifact, flow, source } = await writeSourceWorkspace() const contract = contractFor(source, flow, artifact) contract.end_step = 'Harden' contract.execution_scope = 'full_flow' contract.parameter_patch = [{ knob_id: 'place.density_weight', value: 0.1 }] - contract.writes = [ - { - file: 'config/dreamplace_ecc.json', - json_path: ['density_weight'], - knob_id: 'place.density_weight', - surface: 'step_config', - value: 0.1, - }, - ] + contract.workspace_parameters = { 'place.density_weight': 0.1 } + contract.step_configurations = [] const runtime = { - refreshConfig: vi.fn().mockResolvedValue({}), startFlowOperation: vi.fn().mockResolvedValue({ operationId: 'operation-flow' }), startStepOperation: vi .fn() .mockImplementation(async (request: { step: string }) => ({ operationId: `operation-${request.step}`, })), - syncConfig: vi.fn().mockResolvedValue({}), + updateWorkspaceConfiguration: vi.fn().mockResolvedValue({ workspaceRevision: 2 }), waitForOperation: vi.fn().mockResolvedValue({ error: null, state: 'succeeded' }), } - await executeWorkspaceRerun(contract, runtime, 'target-gui-handle') + await executeWorkspaceRerun(contract, runtime, 'target-gui-handle', 1) - expect(runtime.syncConfig).toHaveBeenCalledWith({ - configPath: `${contract.target_workspace}/config/dreamplace_ecc.json`, - workspaceHandle: 'target-gui-handle', - }) + expect(runtime.updateWorkspaceConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: { + design: {}, + parameters: { 'place.density_weight': 0.1 }, + pdk: {}, + }, + expectedWorkspaceRevision: 1, + }), + ) expect(runtime.startStepOperation).not.toHaveBeenCalled() expect(runtime.startFlowOperation).toHaveBeenCalledWith({ + expectedWorkspaceRevision: 2, idempotencyKey: expect.any(String), rerun: false, workspaceHandle: 'target-gui-handle', @@ -547,20 +364,23 @@ describe('prepareWorkspaceRerun', () => { }) }) - it('rejects a nonempty patch without resolved workspace writes', async () => { + it('rejects a nonempty patch without domain updates', async () => { const { artifact, flow, source } = await writeSourceWorkspace() const contract = contractFor(source, flow, artifact) - contract.writes = [] + contract.workspace_parameters = null as never await expect(prepareWorkspaceRerun(contract)).rejects.toThrow( 'Workspace rerun contract is invalid', ) }) - it('rejects a resolved write that differs from the confirmed patch', async () => { + it('rejects unsafe Step Option keys', async () => { const { artifact, flow, source } = await writeSourceWorkspace() const contract = contractFor(source, flow, artifact) - contract.writes![0]!.value = 0.45 + contract.workspace_parameters = {} + contract.step_configurations = [ + { step_id: 'place', options: JSON.parse('{"__proto__": {"polluted": true}}') }, + ] await expect(prepareWorkspaceRerun(contract)).rejects.toThrow( 'Workspace rerun contract is invalid', @@ -617,7 +437,6 @@ describe('prepareWorkspaceRerun', () => { await readFile(`${contract.target_workspace}/home/flow.json`, 'utf8'), ) as { steps: Array<{ name: string; state: string }> } expect(targetFlow.steps.map((step) => step.name)).toEqual([ - 'Floorplan', 'place', 'CTS', 'legalization', @@ -635,87 +454,6 @@ describe('prepareWorkspaceRerun', () => { expect(targetFlow.steps.find((step) => step.name === 'Harden')?.state).toBe('Unstart') }) - it('backfills steps inserted mid-flow for legacy full_flow reruns', async () => { - const root = await mkdtemp(join(tmpdir(), 'ecos-workspace-rerun-legacy-')) - temporaryRoots.push(root) - const source = join(root, 'gcd') - // A flow completed before Timing Opt and postRouteLec existed. - const legacyNames = [ - 'Synthesis', - 'Floorplan', - 'place', - 'CTS', - 'legalization', - 'route', - 'drc', - 'lvs', - 'filler', - 'RCX', - 'sta', - 'Harden', - ] - const legacyTools: Record = { - Synthesis: 'yosys', - place: 'dreamplace', - legalization: 'dreamplace', - } - const flow = JSON.stringify({ - steps: legacyNames.map((name) => ({ - name, - state: 'Success', - tool: legacyTools[name] ?? 'ecc', - })), - }) - const artifact = Buffer.from('synthesis-def') - await mkdir(join(source, 'home'), { recursive: true }) - await mkdir(join(source, 'Synthesis_yosys', 'output'), { recursive: true }) - await writeFile(join(source, 'home', 'flow.json'), flow) - await writeFile(join(source, 'home', 'parameters.json'), '{}\n') - await writeFile( - join(source, 'Synthesis_yosys', 'output', 'gcd_Synthesis.def.gz'), - artifact, - ) - - const contract: DesktopAgentWorkspaceRerunContract = { - design_id: 'gcd', - end_step: 'Harden', - execution_scope: 'full_flow', - parameter_patch: [], - requires_gui_review: true, - rerun_id: 'gcd_rerun_synthesis', - schema_version: 'flow-agent.workspace_rerun_contract.v1', - source_stage_artifact: 'Synthesis_yosys/output/gcd_Synthesis.def.gz', - source_flow_json_sha256: sha256(flow), - source_stage_artifact_sha256: sha256(artifact), - source_workspace: source, - target_step: 'Synthesis', - target_workspace: `${source}_rerun_synthesis`, - writes: [], - } - - await prepareWorkspaceRerun(contract) - - const targetFlow = JSON.parse( - await readFile(join(contract.target_workspace, 'home', 'flow.json'), 'utf8'), - ) as { steps: Array<{ name: string; state: string; tool: string }> } - expect(targetFlow.steps.map((step) => [step.name, step.tool, step.state])).toEqual([ - ['Synthesis', 'yosys', 'Unstart'], - ['Floorplan', 'ecc', 'Unstart'], - ['place', 'dreamplace', 'Unstart'], - ['CTS', 'ecc', 'Unstart'], - ['legalization', 'dreamplace', 'Unstart'], - ['Timing optimization', 'sizer', 'Unstart'], - ['route', 'ecc', 'Unstart'], - ['drc', 'ecc', 'Unstart'], - ['lvs', 'ecc', 'Unstart'], - ['filler', 'ecc', 'Unstart'], - ['postRouteLec', 'yosys_lec', 'Unstart'], - ['RCX', 'ecc', 'Unstart'], - ['sta', 'ecc', 'Unstart'], - ['Harden', 'ecc', 'Unstart'], - ]) - }) - it.each([ [ 'targets a non-isolated directory', @@ -741,46 +479,6 @@ describe('prepareWorkspaceRerun', () => { contract.parameter_patch = [{ knob_id: 'place.target_density', value: 1 }] }, ], - [ - 'uses a prototype-polluting json_path', - (contract: DesktopAgentWorkspaceRerunContract) => { - contract.parameter_patch = [{ knob_id: 'place.density_weight', value: 0.1 }] - contract.writes = [ - { - file: 'config/dreamplace_ecc.json', - json_path: ['__proto__', 'toString'], - knob_id: 'place.density_weight', - surface: 'step_config', - value: 0.1, - }, - ] - }, - ], - [ - 'aliases the same parameter leaf through both config files', - (contract: DesktopAgentWorkspaceRerunContract) => { - contract.parameter_patch = [ - { knob_id: 'place.target_density', value: 0.55 }, - { knob_id: 'place.target_overflow', value: 0.1 }, - ] - contract.writes = [ - { - file: 'home/params.toml', - json_path: ['target_density'], - knob_id: 'place.target_density', - surface: 'parameters', - value: 0.55, - }, - { - file: 'home/parameters.json', - json_path: ['Target density'], - knob_id: 'place.target_overflow', - surface: 'parameters', - value: 0.1, - }, - ] - }, - ], ])('fails closed before copying when the contract %s', async (_case, mutate) => { const { artifact, flow, source } = await writeSourceWorkspace() const contract = contractFor(source, flow, artifact) @@ -804,245 +502,3 @@ describe('prepareWorkspaceRerun', () => { await expect(prepareWorkspaceRerun(contract)).rejects.toThrow('outside') }) }) - -describe('prepareWorkspaceRerun with home/params.toml workspaces', () => { - it('applies parameter writes to home/params.toml after display-key canonicalization', async () => { - const { artifact, flow, source } = await writeSourceWorkspace() - await rm(join(source, 'home', 'parameters.json')) - await writeFile( - join(source, 'home', 'params.toml'), - [ - '[design]', - 'name = "gcd"', - '', - '[params]', - 'design = "gcd"', - 'target_density = 0.45', - '', - ].join('\n'), - ) - const contract = contractFor(source, flow, artifact) - contract.writes = [ - { - file: 'home/params.toml', - json_path: ['Target density'], - knob_id: 'place.target_density', - surface: 'parameters', - value: 0.55, - }, - ] - - await expect(prepareWorkspaceRerun(contract)).resolves.toEqual({ - directory: contract.target_workspace, - }) - - const written = await readFile( - `${contract.target_workspace}/home/params.toml`, - 'utf8', - ) - expect(written).toContain('target_density = 0.55') - expect(written).not.toContain('0.45') - }) - - it('follows disk reality when the contract file says parameters.json but params.toml exists', async () => { - const { artifact, flow, source } = await writeSourceWorkspace() - await rm(join(source, 'home', 'parameters.json')) - await writeFile( - join(source, 'home', 'params.toml'), - ['[params]', 'target_density = 0.45', ''].join('\n'), - ) - const contract = contractFor(source, flow, artifact) - contract.writes = [ - { - file: 'home/parameters.json', - json_path: ['target_density'], - knob_id: 'place.target_density', - surface: 'parameters', - value: 0.55, - }, - ] - - await expect(prepareWorkspaceRerun(contract)).resolves.toEqual({ - directory: contract.target_workspace, - }) - - const written = await readFile( - `${contract.target_workspace}/home/params.toml`, - 'utf8', - ) - expect(written).toContain('target_density = 0.55') - await expect( - readFile(`${contract.target_workspace}/home/parameters.json`, 'utf8'), - ).rejects.toThrow(/ENOENT/) - }) - - it('refuses to materialize parameters through a symlinked config', async () => { - const { artifact, flow, source } = await writeSourceWorkspace() - await rm(join(source, 'home', 'parameters.json')) - const outside = join(source, 'outside.toml') - await writeFile(outside, '[params]\ntarget_density = 0.45\n') - await symlink(outside, join(source, 'home', 'params.toml')) - const contract = contractFor(source, flow, artifact) - contract.writes = [ - { - file: 'home/params.toml', - json_path: ['target_density'], - knob_id: 'place.target_density', - surface: 'parameters', - value: 0.55, - }, - ] - - await expect(prepareWorkspaceRerun(contract)).rejects.toThrow(/symlink/i) - await expect(readFile(outside, 'utf8')).resolves.toBe( - '[params]\ntarget_density = 0.45\n', - ) - }) - - it('rewrites source-rooted values in home/params.toml', async () => { - const { artifact, flow, source } = await writeSourceWorkspace() - await rm(join(source, 'home', 'parameters.json')) - await writeFile( - join(source, 'home', 'params.toml'), - [ - '[params]', - 'design = "gcd"', - 'target_density = 0.45', - `source_output_path = "${source}/place_dreamplace/output"`, - `note = "compare ${source}-old against this run"`, - '', - ].join('\n'), - ) - const contract = contractFor(source, flow, artifact) - - await prepareWorkspaceRerun(contract) - - const written = await readFile( - `${contract.target_workspace}/home/params.toml`, - 'utf8', - ) - expect(written).toContain(`${contract.target_workspace}/place_dreamplace/output`) - expect(written).not.toContain(`${source}/place_dreamplace/output`) - // Prose that merely shares the prefix is never rewritten. - expect(written).toContain(`compare ${source}-old against this run`) - }) - - it('refuses to rewrite through a home directory swapped for a symlink', async () => { - const root = await mkdtemp(join(tmpdir(), 'ecos-workspace-rerun-home-swap-')) - temporaryRoots.push(root) - const home = join(root, 'home') - const outside = join(root, 'outside') - await mkdir(home) - await mkdir(outside) - const original = [ - '[params]', - 'design = "gcd"', - 'source_output_path = "/src/ws/place_dreamplace/output"', - '', - ].join('\n') - await writeFile(join(home, 'params.toml'), original) - await writeFile(join(outside, 'params.toml'), original) - const authorizedHome = home - - await rm(home, { recursive: true }) - await symlink(outside, home) - - await expect( - rewriteHomeJsonSourcePaths(authorizedHome, { - sourceWorkspace: '/src/ws', - sourceWorkspaceRaw: '/src/ws', - targetWorkspace: '/src/ws_rerun', - }), - ).rejects.toThrow(/authorized|no longer resolves|parent directory changed|symlink/i) - await expect(readFile(join(outside, 'params.toml'), 'utf8')).resolves.toBe(original) - }) - - it('refuses to rewrite home/params.toml when an untouched float cannot round-trip', async () => { - const { artifact, flow, source } = await writeSourceWorkspace() - await rm(join(source, 'home', 'parameters.json')) - await writeFile( - join(source, 'home', 'params.toml'), - [ - '[params]', - 'design = "gcd"', - `source_output_path = "${source}/place_dreamplace/output"`, - '[flow]', - 'threshold = 0.12345678901234567', - '', - ].join('\n'), - ) - const contract = contractFor(source, flow, artifact) - - await expect(prepareWorkspaceRerun(contract)).rejects.toThrow(/cannot round-trip/) - }) -}) - -describe('rewriteJsonSourcePathStrings', () => { - it('rewrites JSON-escaped Windows path tokens without breaking the document', () => { - const rewritten = rewriteJsonSourcePathStrings( - '{"origin":"C:\\\\runs\\\\gcd\\\\origin\\\\gcd.v","keep":0.55}', - [String.raw`C:\runs\gcd`], - String.raw`C:\runs\gcd_rerun_place`, - ) - expect(JSON.parse(rewritten)).toEqual({ - origin: String.raw`C:\runs\gcd_rerun_place\origin\gcd.v`, - keep: 0.55, - }) - }) - - it('re-escapes a native Windows replacement into slash-based JSON', () => { - const rewritten = rewriteJsonSourcePathStrings( - '{"origin":"C:/runs/gcd/origin/gcd.v"}', - [String.raw`C:\runs\gcd`], - String.raw`C:\runs\gcd_rerun_place`, - ) - expect(JSON.parse(rewritten)).toEqual({ - origin: String.raw`C:\runs\gcd_rerun_place\origin\gcd.v`, - }) - }) - - it('does not rewrite object keys that look like workspace paths', () => { - const rewritten = rewriteJsonSourcePathStrings( - '{"/src/ws/cache":"metadata","origin":"/src/ws/origin/gcd.v"}', - ['/src/ws'], - '/src/ws_rerun', - ) - expect(JSON.parse(rewritten)).toEqual({ - '/src/ws/cache': 'metadata', - origin: '/src/ws_rerun/origin/gcd.v', - }) - }) - - it('rewrites string values inside arrays', () => { - const rewritten = rewriteJsonSourcePathStrings( - '{"files":["/src/ws/origin/gcd.v"]}', - ['/src/ws'], - '/src/ws_rerun', - ) - expect(JSON.parse(rewritten)).toEqual({ - files: ['/src/ws_rerun/origin/gcd.v'], - }) - }) -}) - -describe('rewriteSourceRootedPath', () => { - it('rewrites Windows leaves against a slash-terminated prefix', () => { - expect( - rewriteSourceRootedPath( - String.raw`C:\runs\gcd\origin\gcd.v`, - [String.raw`C:\runs\gcd/`], - String.raw`C:\runs\gcd_rerun_place`, - ), - ).toBe(String.raw`C:\runs\gcd_rerun_place\origin\gcd.v`) - }) - - it('leaves prose that only shares the prefix untouched', () => { - expect( - rewriteSourceRootedPath( - String.raw`compare C:\runs\gcd-old against this run`, - [String.raw`C:\runs\gcd`], - String.raw`C:\runs\gcd_rerun_place`, - ), - ).toBe(String.raw`compare C:\runs\gcd-old against this run`) - }) -}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerun.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerun.ts index d390f3f31..575973e9b 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerun.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerun.ts @@ -12,44 +12,14 @@ import { } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { - assignOwnJsonPathValue, - hasSafeJsonPath, - parameterWritesMatchPatch, - type DesktopAgentWorkspaceParameterWrite, - type DesktopAgentWorkspaceRerunContract, -} from '@ecos-studio/shared' +import type { DesktopAgentWorkspaceRerunContract } from '@ecos-studio/shared' import { isPathWithinRoot, isRelativePathOutsideRoot } from '../pathScope' import { - assertNoSubMillisecondDatetimes, - editWorkspaceParameters, - locateWorkspaceParametersFile, - parseTomlDocument, - readWorkspaceConfigContained, - stringifyTomlDocument, - WORKSPACE_CONFIG_BASENAME, - writeTextAtomically, -} from '../workspaceParametersFile' - -interface WorkspaceRerunRuntime { - refreshConfig(request: { workspaceHandle: string }): Promise - startFlowOperation(request: { - idempotencyKey: string - rerun: boolean - workspaceHandle: string - }): Promise<{ operationId: string }> - startStepOperation(request: { - idempotencyKey: string - rerun: boolean - step: string - workspaceHandle: string - }): Promise<{ operationId: string }> - syncConfig(request: { configPath: string; workspaceHandle: string }): Promise - waitForOperation(request: { - operationId: string - workspaceHandle: string - }): Promise<{ error: { message: string } | null; state: string }> -} + executeWorkspaceRerunDomain, + hasValidWorkspaceRerunDomainUpdates, + isWorkspaceRerunParameterValue, + type WorkspaceRerunRuntime, +} from './workspaceRerunDomain' const FLOW_STEP_SEQUENCE = [ 'Synthesis', @@ -69,6 +39,7 @@ const FLOW_STEP_SEQUENCE = [ ] as const const FLOW_STEPS: Set = new Set(FLOW_STEP_SEQUENCE) const CATALOG_END_STEP = FLOW_STEP_SEQUENCE[FLOW_STEP_SEQUENCE.length - 1]! +const OBSOLETE_STEP_DIRECTORY = 'fixFanout_ecc' /** Default tool names when extending a short source flow to the catalog end. */ const DEFAULT_STEP_TOOLS: Record<(typeof FLOW_STEP_SEQUENCE)[number], string> = { Synthesis: 'yosys', @@ -104,6 +75,10 @@ function rerunStageDirectoryName(stepName: string, tool: string): string { function rerunStepSlug(stepName: string): string { return stepName.trim().split(/\s+/).join('_').toLowerCase() } + +function isObsoleteFlowStep(stepName: string): boolean { + return stepName.toLowerCase().replace(/[\s_-]/g, '') === 'fixfanout' +} const AUTHORIZED_KNOBS = { place: new Set([ 'place.target_density', @@ -211,7 +186,6 @@ export async function prepareWorkspaceRerun( targetStep: contract.target_step, targetWorkspace: verified.targetWorkspace, }) - await materializeWorkspaceRerunParameterWrites(stagedWorkspace, verified.writes) const stagedHome = await resolvePathWithinWorkspace( stagedWorkspace, join(stagedWorkspace, 'home'), @@ -239,50 +213,15 @@ export async function executeWorkspaceRerun( contract: DesktopAgentWorkspaceRerunContract, runtime: WorkspaceRerunRuntime, workspaceHandle: string, + initialWorkspaceRevision: number | undefined, ): Promise { - const writes = contract.writes ?? [] - if (!hasValidParameterWrites(contract.parameter_patch, writes)) { - throw new Error('Workspace rerun contract is invalid.') - } - for (const file of new Set( - writes.filter((write) => write.surface === 'step_config').map((write) => write.file), - )) { - await runtime.syncConfig({ - configPath: join(contract.target_workspace, file), - workspaceHandle, - }) - } - if (writes.length > 0) await runtime.refreshConfig({ workspaceHandle }) - if (contract.execution_scope === 'full_flow') { - const operation = await runtime.startFlowOperation({ - idempotencyKey: randomUUID(), - rerun: false, - workspaceHandle, - }) - const completed = await runtime.waitForOperation({ - operationId: operation.operationId, - workspaceHandle, - }) - if (completed.state !== 'succeeded') { - throw new Error(completed.error?.message || 'Rerun flow failed') - } - return - } - - const step = contract.target_step - const operation = await runtime.startStepOperation({ - idempotencyKey: randomUUID(), - rerun: false, - step, - workspaceHandle, - }) - const completed = await runtime.waitForOperation({ - operationId: operation.operationId, + await executeWorkspaceRerunDomain( + contract, + runtime, workspaceHandle, - }) - if (completed.state !== 'succeeded') { - throw new Error(completed.error?.message || `Rerun step failed: ${step}`) - } + initialWorkspaceRevision, + FLOW_STEPS, + ) } async function verifyWorkspaceRerunContract( @@ -290,9 +229,7 @@ async function verifyWorkspaceRerunContract( ): Promise<{ sourceWorkspace: string targetWorkspace: string - writes: DesktopAgentWorkspaceParameterWrite[] }> { - const writes = contract.writes ?? [] if ( contract.schema_version !== 'flow-agent.workspace_rerun_contract.v1' || contract.requires_gui_review !== true || @@ -306,7 +243,7 @@ async function verifyWorkspaceRerunContract( !isAbsolute(contract.source_workspace) || !isAbsolute(contract.target_workspace) || !hasValidParameterPatch(contract.parameter_patch) || - !hasValidParameterWrites(contract.parameter_patch, writes) || + !hasValidWorkspaceRerunDomainUpdates(contract, FLOW_STEPS) || !hasAuthorizedParameterPatch(contract.target_step, contract.parameter_patch) || (contract.execution_scope !== 'single_step' && contract.execution_scope !== 'full_flow') || @@ -385,7 +322,7 @@ async function verifyWorkspaceRerunContract( if (sha256(await readFile(artifact)) !== contract.source_stage_artifact_sha256) { throw new Error('Workspace rerun source artifact evidence is stale.') } - return { sourceWorkspace, targetWorkspace, writes } + return { sourceWorkspace, targetWorkspace } } function isValidRerunRange( @@ -479,43 +416,10 @@ function hasValidParameterPatch( return false } knobs.add(item.knob_id) - return isValidParameterValue(item.value) + return isWorkspaceRerunParameterValue(item.value) }) } -function hasValidParameterWrites( - patch: DesktopAgentWorkspaceRerunContract['parameter_patch'], - writes: DesktopAgentWorkspaceParameterWrite[], -): boolean { - if (!Array.isArray(writes) || writes.length !== patch.length) return false - if ( - !writes.every( - (write) => hasValidJsonPath(write.json_path) && isValidParameterValue(write.value), - ) - ) { - return false - } - return parameterWritesMatchPatch(patch, writes) -} - -function hasValidJsonPath(path: (string | number)[]): boolean { - return hasSafeJsonPath(path) -} - -function isValidParameterValue( - value: DesktopAgentWorkspaceRerunContract['parameter_patch'][number]['value'], -): boolean { - if (typeof value === 'boolean') return true - if (typeof value === 'string') return isSafeParameterString(value) - if (typeof value === 'number') return Number.isFinite(value) - if (!Array.isArray(value) || value.length > 64) return false - return value.every( - (item) => - (typeof item === 'number' && Number.isFinite(item)) || - (typeof item === 'string' && isSafeParameterString(item)), - ) -} - function hasAuthorizedParameterPatch( targetStep: string, patch: DesktopAgentWorkspaceRerunContract['parameter_patch'], @@ -577,100 +481,6 @@ function isSafeParameterString(value: string): boolean { ) } -async function materializeWorkspaceRerunParameterWrites( - workspace: string, - writes: DesktopAgentWorkspaceParameterWrite[], -): Promise { - const writesByFile = new Map() - for (const write of writes) { - const fileWrites = writesByFile.get(write.file) ?? [] - fileWrites.push(write) - writesByFile.set(write.file, fileWrites) - } - for (const [file, fileWrites] of writesByFile) { - if (file === 'home/params.toml' || file === 'home/parameters.json') { - await materializeParameterSurfaceWrites(workspace, fileWrites) - continue - } - const path = await resolvePathWithinWorkspace( - workspace, - join(workspace, file), - `parameter file ${file}`, - ) - const raw = await readFile(path, 'utf8') - const document = parseWorkspaceParameterDocument(raw, file) - for (const write of fileWrites) setWorkspaceParameterValue(document, write) - const serialized = JSON.stringify(document, null, detectJsonIndent(raw)) - await writeFile(path, raw.endsWith('\n') ? `${serialized}\n` : serialized, 'utf8') - } -} - -/** - * Apply `surface: 'parameters'` writes to the workspace configuration that - * actually exists on disk: `home/params.toml` when present, `home/parameters.json` - * otherwise. The contract's `file` field names the format the Agent saw; disk - * reality wins when the two disagree, and the `json_path` keys are interpreted - * in the on-disk file's vocabulary (display keys for JSON, flat snake_case - * for TOML). - */ -async function materializeParameterSurfaceWrites( - workspace: string, - writes: DesktopAgentWorkspaceParameterWrite[], -): Promise { - const location = await locateWorkspaceParametersFile(workspace) - if (!location) { - throw new Error( - `Workspace rerun parameter file is invalid: neither home/params.toml nor home/parameters.json exists`, - ) - } - const locationStats = await lstat(location.path) - if (locationStats.isSymbolicLink()) { - // A symlinked config path makes the materialization target ambiguous — - // refuse it, matching the save/edit paths and ECC's own symlink refusal. - throw new Error( - `Refusing to materialize rerun parameters through a symlink: ${location.path}`, - ) - } - const validatedPath = await resolvePathWithinWorkspace( - workspace, - location.path, - 'workspace configuration', - ) - await editWorkspaceParameters(workspace, writes, { - format: location.format, - path: validatedPath, - spelledPath: location.path, - }) -} - -function parseWorkspaceParameterDocument( - raw: string, - file: string, -): Record { - try { - const document = JSON.parse(raw) - if (typeof document !== 'object' || document === null || Array.isArray(document)) { - throw new Error('not an object') - } - return document as Record - } catch { - throw new Error(`Workspace rerun parameter file is invalid: ${file}`) - } -} - -function setWorkspaceParameterValue( - document: Record, - write: DesktopAgentWorkspaceParameterWrite, -): void { - assignOwnJsonPathValue(document, write.json_path, write.value, () => { - throw new Error(`Parameter ${write.knob_id} does not exist.`) - }) -} - -function detectJsonIndent(raw: string): number { - return /^\s*[[{]\s*\n(\s+)\S/.exec(raw)?.[1]?.length ?? 2 -} - function completedStepTool(flowText: string, targetStep: string): string | null { try { const flow = JSON.parse(flowText) as { steps?: unknown } @@ -779,7 +589,7 @@ async function rewriteAndPruneWorkspaceRerunHome(options: { const flow = parseWorkspaceFlow(await readFile(join(home, 'flow.json'), 'utf8')) const toolByStep = new Map(flow.steps.map((step) => [step.name, step.tool])) const wipedStageNames = new Set(FLOW_STEP_SEQUENCE.slice(targetIndex)) - const wipedDirectories = new Set() + const wipedDirectories = new Set([OBSOLETE_STEP_DIRECTORY]) for (const stageName of wipedStageNames) { const tool = toolByStep.get(stageName) ?? @@ -908,56 +718,6 @@ export function rewriteSourceRootedPath( return value } -/** - * Rewrite source-workspace prefixes inside parsed TOML string scalars: a - * value is workspace-rooted only when it equals the source prefix or lives - * under it, so prose sharing the prefix is untouched and TOML escaping is - * always correct (textual replacement would corrupt escaped paths). - */ -function rewriteTomlSourcePathLeaves( - node: unknown, - prefixes: string[], - targetWorkspace: string, -): boolean { - const rewriteValue = (value: string): string => - rewriteSourceRootedPath(value, prefixes, targetWorkspace) - const visit = (current: unknown): boolean => { - if (Array.isArray(current)) { - let changed = false - for (let index = 0; index < current.length; index += 1) { - const item = current[index] - if (typeof item === 'string') { - const rewritten = rewriteValue(item) - if (rewritten !== item) { - current[index] = rewritten - changed = true - } - } else { - changed = visit(item) || changed - } - } - return changed - } - if (current !== null && typeof current === 'object' && !(current instanceof Date)) { - let changed = false - for (const [key, item] of Object.entries(current)) { - if (typeof item === 'string') { - const rewritten = rewriteValue(item) - if (rewritten !== item) { - ;(current as Record)[key] = rewritten - changed = true - } - } else { - changed = visit(item) || changed - } - } - return changed - } - return false - } - return visit(node) -} - export async function rewriteHomeJsonSourcePaths( homeDirectory: string, options: { @@ -995,7 +755,7 @@ export async function rewriteHomeJsonSourcePaths( } for (const entry of entries) { - if (entry !== WORKSPACE_CONFIG_BASENAME && !entry.endsWith('.json')) continue + if (!entry.endsWith('.json')) continue if (entry === 'flow_agent_workspace_rerun_contract.v1.json') continue const filePath = join(homeDirectory, entry) const canonicalPath = join(authorizedParent, entry) @@ -1006,28 +766,14 @@ export async function rewriteHomeJsonSourcePaths( // cannot retarget the rewrite. const entryStats = await lstat(filePath) if (!entryStats.isFile() || entryStats.isSymbolicLink()) continue - const original = await readWorkspaceConfigContained(filePath, canonicalPath) - if (entry === WORKSPACE_CONFIG_BASENAME) { - // Parse and rewrite only string scalars whose value is the source - // prefix or lives under it: prose is untouched and TOML escaping - // stays correct (a textual replacement corrupts escaped paths and - // misses their unescaped form). - assertNoSubMillisecondDatetimes(original, filePath) - const document = parseTomlDocument(original, filePath) - if (rewriteTomlSourcePathLeaves(document, prefixes, options.targetWorkspace)) { - await writeTextAtomically(filePath, stringifyTomlDocument(document), { - authorizedParent, - }) - } - continue - } + const original = await readFile(canonicalPath, 'utf8') const rewritten = rewriteJsonSourcePathStrings( original, prefixes, options.targetWorkspace, ) if (rewritten !== original) { - await writeTextAtomically(filePath, rewritten, { authorizedParent }) + await writeFile(filePath, rewritten, 'utf8') } } } @@ -1126,6 +872,7 @@ function pruneWorkspaceRerunMonitor( const keepIndexes: number[] = [] steps.forEach((label, index) => { const stage = monitorStepStage(label) + if (isObsoleteFlowStep(stage ?? label)) return if (!stage) { keepIndexes.push(index) return @@ -1157,7 +904,7 @@ function monitorStepStage(label: string): string | null { const separator = ' - ' const index = label.indexOf(separator) const prefix = (index >= 0 ? label.slice(0, index) : label).trim() - return FLOW_STEPS.has(prefix) ? prefix : null + return FLOW_STEPS.has(prefix) || isObsoleteFlowStep(prefix) ? prefix : null } function pathBelongsToWipedStage( @@ -1202,7 +949,10 @@ async function pruneWorkspaceRerunChecklistJson( const kept = items.filter((item) => { if (!item || typeof item !== 'object') return true const step = (item as { step?: unknown }).step - return typeof step !== 'string' || !wipedStageNames.has(step) + return ( + typeof step !== 'string' || + (!isObsoleteFlowStep(step) && !wipedStageNames.has(step)) + ) }) let passed = 0 @@ -1274,19 +1024,26 @@ function parseWorkspaceFlow(flowText: string): { try { const data = JSON.parse(flowText) as { steps?: unknown } if (!Array.isArray(data.steps)) throw new Error('steps are missing') - const steps = data.steps.map((value) => { - if ( - typeof value !== 'object' || - value === null || - !FLOW_STEPS.has((value as { name?: unknown }).name as string) || - typeof (value as { tool?: unknown }).tool !== 'string' || - !/^[A-Za-z0-9_-]+$/.test((value as { tool: string }).tool) || - typeof (value as { state?: unknown }).state !== 'string' - ) { - throw new Error('step is invalid') - } - return value as WorkspaceFlowStep - }) + const steps = data.steps + .filter( + (value) => + typeof value !== 'object' || + value === null || + !isObsoleteFlowStep(String((value as { name?: unknown }).name ?? '')), + ) + .map((value) => { + if ( + typeof value !== 'object' || + value === null || + !FLOW_STEPS.has((value as { name?: unknown }).name as string) || + typeof (value as { tool?: unknown }).tool !== 'string' || + !/^[A-Za-z0-9_-]+$/.test((value as { tool: string }).tool) || + typeof (value as { state?: unknown }).state !== 'string' + ) { + throw new Error('step is invalid') + } + return value as WorkspaceFlowStep + }) if (new Set(steps.map((step) => step.name)).size !== steps.length) { throw new Error('step names are duplicated') } diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerunDomain.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerunDomain.ts new file mode 100644 index 000000000..e88ff6070 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRerunDomain.ts @@ -0,0 +1,139 @@ +import { randomUUID } from 'node:crypto' + +import type { DesktopAgentWorkspaceRerunContract } from '@ecos-studio/shared' + +export interface WorkspaceRerunRuntime { + startFlowOperation(request: { + expectedWorkspaceRevision: number + idempotencyKey: string + rerun: boolean + workspaceHandle: string + }): Promise<{ operationId: string }> + startStepOperation(request: { + expectedWorkspaceRevision: number + idempotencyKey: string + rerun: boolean + step: string + workspaceHandle: string + }): Promise<{ operationId: string }> + updateWorkspaceConfiguration(request: { + commandId: string + configuration: { + design: Record + parameters: Record + pdk: Record + } + expectedWorkspaceRevision: number + workspaceHandle: string + }): Promise<{ workspaceRevision: number }> + waitForOperation(request: { + operationId: string + workspaceHandle: string + }): Promise<{ error: { message: string } | null; state: string }> +} + +export function isWorkspaceRerunParameterValue(value: unknown): boolean { + if (typeof value === 'boolean') return true + if (typeof value === 'string') return isSafeParameterString(value) + if (typeof value === 'number') return Number.isFinite(value) + if (!Array.isArray(value) || value.length > 64) return false + return value.every( + (item) => + (typeof item === 'number' && Number.isFinite(item)) || + (typeof item === 'string' && isSafeParameterString(item)), + ) +} + +export function hasValidWorkspaceRerunDomainUpdates( + contract: DesktopAgentWorkspaceRerunContract, + flowSteps: ReadonlySet, +): boolean { + if ( + !contract.workspace_parameters || + typeof contract.workspace_parameters !== 'object' || + Array.isArray(contract.workspace_parameters) || + !Array.isArray(contract.step_configurations) || + contract.step_configurations.length !== 0 + ) { + return false + } + const validOptions = (value: unknown): boolean => { + if (isWorkspaceRerunParameterValue(value)) return true + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + return Object.entries(value).every( + ([key, entry]) => + !['__proto__', 'prototype', 'constructor'].includes(key) && validOptions(entry), + ) + } + return ( + validOptions(contract.workspace_parameters) && + flowSteps.has(contract.target_step) && + contract.step_configurations.length === 0 + ) +} + +export async function executeWorkspaceRerunDomain( + contract: DesktopAgentWorkspaceRerunContract, + runtime: WorkspaceRerunRuntime, + workspaceHandle: string, + initialWorkspaceRevision: number | undefined, + flowSteps: ReadonlySet, +): Promise { + if (!hasValidWorkspaceRerunDomainUpdates(contract, flowSteps)) { + throw new Error('Workspace rerun contract is invalid.') + } + if (!Number.isInteger(initialWorkspaceRevision)) { + throw new Error('Workspace rerun revision is unavailable.') + } + let workspaceRevision = initialWorkspaceRevision! + if (Object.keys(contract.workspace_parameters).length) { + const updated = await runtime.updateWorkspaceConfiguration({ + commandId: randomUUID(), + configuration: { + design: {}, + parameters: contract.workspace_parameters, + pdk: {}, + }, + expectedWorkspaceRevision: workspaceRevision, + workspaceHandle, + }) + workspaceRevision = updated.workspaceRevision + } + const operation = + contract.execution_scope === 'full_flow' + ? await runtime.startFlowOperation({ + expectedWorkspaceRevision: workspaceRevision, + idempotencyKey: randomUUID(), + rerun: false, + workspaceHandle, + }) + : await runtime.startStepOperation({ + expectedWorkspaceRevision: workspaceRevision, + idempotencyKey: randomUUID(), + rerun: false, + step: contract.target_step, + workspaceHandle, + }) + const completed = await runtime.waitForOperation({ + operationId: operation.operationId, + workspaceHandle, + }) + if (completed.state !== 'succeeded') { + throw new Error( + completed.error?.message || + (contract.execution_scope === 'full_flow' + ? 'Rerun flow failed' + : `Rerun step failed: ${contract.target_step}`), + ) + } +} + +function isSafeParameterString(value: string): boolean { + return ( + value.length <= 256 && + !value.includes('`') && + !value.includes('..') && + !value.split('').some((character) => character.charCodeAt(0) < 32) && + !/[;&|]|\$\(/.test(value) + ) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.test.ts index 824c81f74..9626be4e3 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.test.ts @@ -1,15 +1,5 @@ -import type { - EccRuntimeEvent, - EccWorkspaceInspectSignoffResult, -} from '@ecos-studio/shared' -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' +import type { EccRuntimeEvent } from '@ecos-studio/shared' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -19,7 +9,6 @@ import { type EccRpcRuntimeClient, type EccRpcRuntimeSidecar, } from './workspaceRuntime' -import { EccJsonRpcError } from './jsonRpcClient' import type { JsonRpcNotificationPayload } from './jsonRpcClient' interface RpcCall { @@ -90,7 +79,7 @@ function createService( directory = '/work/demo', options: Pick< ConstructorParameters[0], - 'diagnosticIdleTimeoutMs' | 'lazyWorkspaceOpen' | 'snapshotLoader' + 'managementRpc' | 'lazyWorkspaceOpen' > = {}, ) { const client = new FakeRpcClient() @@ -121,13 +110,361 @@ function createService( } describe('EccWorkspaceRuntime', () => { - it('creates a workspace from a runtime-specific payload', async () => { - const { client, service } = createService('/work/frontend') + it('updates canonical configuration with the current machine bindings', async () => { + const { client, service } = createService() + const workspaceBindings = { + mpc: { template: { design: 'gcd' } }, + pdk: { root: '/pdks/old' }, + } + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 1, + }) + const opened = await service.openWorkspace({ + directory: '/work/demo', + workspaceBindings, + }) + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 2, + }) + + await expect( + service.updateWorkspaceConfiguration({ + commandId: 'configuration-1', + configuration: { + design: { name: 'gcd', topModule: 'gcd', clockPort: 'clk' }, + parameters: { frequency_max: 200 }, + pdk: { familyId: 'ics55' }, + }, + expectedWorkspaceRevision: 1, + pdkRoot: '/pdks/current', + workspaceHandle: opened.workspaceHandle, + }), + ).resolves.toMatchObject({ workspaceRevision: 2 }) + + expect(client.calls.at(-1)).toMatchObject({ + method: 'workspace.configuration.update', + params: { + commandId: 'configuration-1', + expectedWorkspaceRevision: 1, + workspaceBindings: { + mpc: { template: { design: 'gcd' } }, + pdk: { root: '/pdks/current' }, + }, + workspaceId: 'workspace-1', + }, + }) + expect(service.workspaceSession(opened.workspaceHandle).workspaceRevision).toBe(2) + }) + + it('reads Step Configuration without entering the operation queue', async () => { + const { client, service, events } = createService() + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 4, + }) + const opened = await service.openWorkspace({ directory: '/work/demo' }) + client.responses.push({ + parameters: [ + { + applies: 'cts', + default: 0.08, + description: 'CTS skew bound', + param: 'cts.skew_bound', + type: 'float', + value: 0.08, + }, + ], + status: 'available', + step: 'CTS', + stepId: 'CTS', + workspaceId: 'workspace-1', + workspaceRevision: 4, + }) + + await expect( + service.readWorkspaceStepConfiguration({ + step: 'CTS', + workspaceHandle: opened.workspaceHandle, + }), + ).resolves.toMatchObject({ status: 'available', workspaceRevision: 4 }) + + expect(client.calls.at(-1)).toEqual({ + method: 'workspace.step_configuration.read', + params: { step: 'CTS', workspaceId: 'workspace-1' }, + }) + expect( + events.filter( + (event) => + 'method' in event && event.method === 'workspace.step_configuration.read', + ), + ).toEqual([]) + expect(service.isActive()).toBe(false) + }) + + it('reuses a committed Step Configuration for the same Workspace Revision', async () => { + const { client, service } = createService() + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 4, + }) + const opened = await service.openWorkspace({ directory: '/work/demo' }) + const configuration = { + parameters: [ + { + applies: 'cts', + default: 0.08, + description: 'CTS skew bound', + param: 'cts.skew_bound', + type: 'float', + value: 0.08, + }, + ], + status: 'available' as const, + step: 'CTS', + stepId: 'CTS', + workspaceId: 'workspace-1', + workspaceRevision: 4, + } + client.responses.push(configuration) + + await expect( + service.readWorkspaceStepConfiguration({ + step: 'CTS', + workspaceHandle: opened.workspaceHandle, + }), + ).resolves.toMatchObject({ status: 'available', workspaceRevision: 4 }) + await expect( + service.readWorkspaceStepConfiguration({ + step: 'CTS', + workspaceHandle: opened.workspaceHandle, + }), + ).resolves.toEqual(configuration) + + expect( + client.calls.filter((call) => call.method === 'workspace.step_configuration.read'), + ).toHaveLength(1) + }) + + it('coalesces in-flight Step Configuration reads for the same Revision', async () => { + const { client, service } = createService() + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 4, + }) + const opened = await service.openWorkspace({ directory: '/work/demo' }) + const pending = deferred>() + client.responses.push(pending.promise) + + const first = service.readWorkspaceStepConfiguration({ + step: 'CTS', + workspaceHandle: opened.workspaceHandle, + }) + const second = service.readWorkspaceStepConfiguration({ + step: 'CTS', + workspaceHandle: opened.workspaceHandle, + }) + pending.resolve({ + parameters: [], + status: 'available', + step: 'CTS', + stepId: 'CTS', + workspaceId: 'workspace-1', + workspaceRevision: 4, + }) + + await expect(Promise.all([first, second])).resolves.toEqual([ + expect.objectContaining({ status: 'available', workspaceRevision: 4 }), + expect.objectContaining({ status: 'available', workspaceRevision: 4 }), + ]) + expect( + client.calls.filter((call) => call.method === 'workspace.step_configuration.read'), + ).toHaveLength(1) + }) + + it('invalidates cached Step Configuration after a Step Parameter update', async () => { + const { client, service } = createService() + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 4, + }) + const opened = await service.openWorkspace({ directory: '/work/demo' }) + client.responses.push({ + parameters: [ + { + applies: 'cts', + default: 0.08, + description: '', + param: 'cts.skew_bound', + type: 'float', + value: 0.08, + }, + ], + status: 'available', + step: 'CTS', + stepId: 'CTS', + workspaceId: 'workspace-1', + workspaceRevision: 4, + }) + await service.readWorkspaceStepConfiguration({ + step: 'CTS', + workspaceHandle: opened.workspaceHandle, + }) + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 5, + }) + await service.updateWorkspaceStepConfiguration({ + commandId: 'step-configuration-1', + expectedWorkspaceRevision: 4, + parameters: { 'cts.skew_bound': 0.1 }, + stepId: 'CTS', + workspaceHandle: opened.workspaceHandle, + }) + client.responses.push({ + parameters: [ + { + applies: 'cts', + default: 0.08, + description: '', + param: 'cts.skew_bound', + type: 'float', + value: 0.1, + }, + ], + status: 'available', + step: 'CTS', + stepId: 'CTS', + workspaceId: 'workspace-1', + workspaceRevision: 5, + }) + + await expect( + service.readWorkspaceStepConfiguration({ + step: 'CTS', + workspaceHandle: opened.workspaceHandle, + }), + ).resolves.toMatchObject({ workspaceRevision: 5 }) + expect( + client.calls.filter((call) => call.method === 'workspace.step_configuration.read'), + ).toHaveLength(2) + }) + + it('updates Step Parameters by identity without a file path', async () => { + const { client, service } = createService() + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 1, + }) + const opened = await service.openWorkspace({ directory: '/work/demo' }) + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 2, + }) + + await service.updateWorkspaceStepConfiguration({ + commandId: 'step-configuration-1', + expectedWorkspaceRevision: 1, + parameters: { 'floorplan.ifp.thread_number': 8 }, + stepId: 'Floorplan', + workspaceHandle: opened.workspaceHandle, + }) + + expect(client.calls.at(-1)).toEqual({ + method: 'workspace.step_configuration.update', + params: { + commandId: 'step-configuration-1', + expectedWorkspaceRevision: 1, + parameters: { 'floorplan.ifp.thread_number': 8 }, + stepId: 'Floorplan', + workspaceId: 'workspace-1', + }, + }) + }) + + it('does not emit flow lifecycle events when configuration update is rejected', async () => { + const { client, events, service } = createService() + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 1, + }) + const opened = await service.openWorkspace({ directory: '/work/demo' }) client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/frontend', workspaceId: 'frontend-1' }, + new Error('value 1.3 out of range [0.01, 1.0] for floorplan.core_util'), ) + await expect( + service.updateWorkspaceConfiguration({ + commandId: 'configuration-1', + configuration: { + design: {}, + parameters: { 'floorplan.core_util': 1.3 }, + pdk: {}, + }, + expectedWorkspaceRevision: 1, + workspaceHandle: opened.workspaceHandle, + }), + ).rejects.toMatchObject({ + message: 'value 1.3 out of range [0.01, 1.0] for floorplan.core_util', + }) + + expect(events).not.toContainEqual( + expect.objectContaining({ type: 'operation.started' }), + ) + expect(events).not.toContainEqual( + expect.objectContaining({ type: 'operation.failed' }), + ) + expect(events).not.toContainEqual( + expect.objectContaining({ type: 'operation.completed' }), + ) + }) + + it('does not emit flow lifecycle events when step configuration update is rejected', async () => { + const { client, events, service } = createService() + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 1, + }) + const opened = await service.openWorkspace({ directory: '/work/demo' }) + client.responses.push( + new Error('value 1.3 out of range [0.01, 1.0] for floorplan.core_util'), + ) + + await expect( + service.updateWorkspaceStepConfiguration({ + commandId: 'step-configuration-1', + expectedWorkspaceRevision: 1, + parameters: { 'floorplan.core_util': 1.3 }, + stepId: 'Floorplan', + workspaceHandle: opened.workspaceHandle, + }), + ).rejects.toMatchObject({ + message: 'value 1.3 out of range [0.01, 1.0] for floorplan.core_util', + }) + + expect(events).not.toContainEqual( + expect.objectContaining({ + method: 'workspace.step_configuration.update', + }), + ) + }) + + it('creates a workspace from a runtime-specific payload', async () => { + const { client, service } = createService('/work/frontend') + client.responses.push({ directory: '/work/frontend', workspaceId: 'frontend-1' }) + await expect( service.createWorkspacePayload({ cpu_filelist: '/work/cpu.f', @@ -147,7 +484,7 @@ describe('EccWorkspaceRuntime', () => { }) }) - it('migrates legacy configs before a lazy workspace open returns', async () => { + it('leaves legacy config migration to ECC during a lazy open', async () => { const directory = mkdtempSync(join(tmpdir(), 'ecos-workspace-runtime-open-')) const configDirectory = join(directory, 'config') mkdirSync(configDirectory) @@ -165,96 +502,19 @@ describe('EccWorkspaceRuntime', () => { const { service, sidecar } = createService(directory, { lazyWorkspaceOpen: true }) await service.openWorkspace({ directory }) - expect(existsSync(join(configDirectory, 'flow_config.json'))).toBe(false) - expect(existsSync(join(configDirectory, 'db_default_config.json'))).toBe(false) - expect(existsSync(join(configDirectory, 'flow_ecc.json'))).toBe(true) - expect(existsSync(join(configDirectory, 'db_ecc.json'))).toBe(true) - expect( - JSON.parse(readFileSync(join(configDirectory, 'flow_ecc.json'), 'utf8')), - ).toMatchObject({ - ConfigPath: { idb_path: join(configDirectory, 'db_ecc.json') }, - }) + expect(existsSync(join(configDirectory, 'flow_config.json'))).toBe(true) + expect(existsSync(join(configDirectory, 'db_default_config.json'))).toBe(true) + expect(existsSync(join(configDirectory, 'flow_ecc.json'))).toBe(false) + expect(existsSync(join(configDirectory, 'db_ecc.json'))).toBe(false) expect(sidecar.startCount).toBe(0) } finally { rmSync(directory, { force: true, recursive: true }) } }) - it('opens an idle workspace from a bounded snapshot without spawning ECC', async () => { - let loaderCalls = 0 - const { service, sidecar } = createService('/work/demo', { - lazyWorkspaceOpen: true, - snapshotLoader: async (directory) => { - loaderCalls += 1 - return { - directory, - flow: { steps: [] }, - home: { flow: '/work/demo/home/flow.json' }, - lastEventId: 'disk:1', - operations: [], - parameters: {}, - } - }, - }) - - const workspace = await service.openWorkspace({ directory: '/work/demo' }) - await expect( - service.workspaceSnapshot({ workspaceHandle: workspace.workspaceHandle }), - ).resolves.toMatchObject({ - directory: '/work/demo', - lastEventId: 'disk:1', - workspaceHandle: workspace.workspaceHandle, - }) - - expect(loaderCalls).toBe(1) - expect(sidecar.startCount).toBe(0) - }) - - it('shares one idle snapshot read across concurrent renderer requests', async () => { - const pending = deferred<{ - directory: string - flow: { steps: [] } - home: Record - lastEventId: string - operations: [] - parameters: Record - }>() - let loaderCalls = 0 - const { service } = createService('/nfs/demo', { - lazyWorkspaceOpen: true, - snapshotLoader: async () => { - loaderCalls += 1 - return await pending.promise - }, - }) - const workspace = await service.openWorkspace({ directory: '/nfs/demo' }) - - const first = service.workspaceSnapshot({ - workspaceHandle: workspace.workspaceHandle, - }) - const second = service.workspaceSnapshot({ - workspaceHandle: workspace.workspaceHandle, - }) - expect(loaderCalls).toBe(1) - - pending.resolve({ - directory: '/nfs/demo', - flow: { steps: [] }, - home: {}, - lastEventId: 'disk:1', - operations: [], - parameters: {}, - }) - await expect(Promise.all([first, second])).resolves.toEqual([ - expect.objectContaining({ lastEventId: 'disk:1' }), - expect.objectContaining({ lastEventId: 'disk:1' }), - ]) - }) - it('invalidates the cached flow snapshot after refreshing workspace config', async () => { const { client, service } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { directory: '/work/demo', @@ -303,10 +563,7 @@ describe('EccWorkspaceRuntime', () => { it('maps protocol notifications to the matching GUI workspace handle', async () => { const { client, events, service, sidecarNotification } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) const workspace = await service.openWorkspace({ directory: '/work/demo' }) sidecarNotification({ @@ -316,16 +573,16 @@ describe('EccWorkspaceRuntime', () => { eventId: 'workspace-1:1', operationId: 'operation-1', origin: 'gui', - payload: { step: 'Synthesis', tool: 'yosys' }, + payload: { sourceType: 'step.started', step: 'Synthesis', tool: 'yosys' }, sequence: 1, timestamp: 1, - type: 'step.started', + type: 'execution.progress', workspaceId: 'workspace-1', }, }) expect(events).toContainEqual({ - event: expect.objectContaining({ type: 'step.started' }), + event: expect.objectContaining({ type: 'execution.progress' }), type: 'runtime.protocol', workspaceDirectory: '/work/demo', workspaceHandle: workspace.workspaceHandle, @@ -335,10 +592,8 @@ describe('EccWorkspaceRuntime', () => { it('starts GUI flow operations without waiting for the long-running result', async () => { const { client, service } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { - awaitingEventId: null, createdAt: 1, currentStep: '', currentTool: '', @@ -358,6 +613,7 @@ describe('EccWorkspaceRuntime', () => { await expect( service.startFlowOperation({ + expectedWorkspaceRevision: 1, idempotencyKey: 'request-1', workspaceHandle: workspace.workspaceHandle, }), @@ -365,6 +621,7 @@ describe('EccWorkspaceRuntime', () => { expect(client.calls.at(-1)).toEqual({ method: 'operation.start_flow', params: { + expectedWorkspaceRevision: 1, idempotencyKey: 'request-1', origin: 'gui', rerun: false, @@ -377,7 +634,6 @@ describe('EccWorkspaceRuntime', () => { const { client, events, service, sidecarEvent } = createService() const flowResult = deferred<{ rerun: boolean }>() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, flowResult.promise, ) @@ -418,10 +674,7 @@ describe('EccWorkspaceRuntime', () => { it('binds a late sidecar progress event to the active workspace session', async () => { const { client, events, service, sidecarEvent } = createService('/work/frontend') - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/frontend', workspaceId: 'frontend-1' }, - ) + client.responses.push({ directory: '/work/frontend', workspaceId: 'frontend-1' }) const workspace = await service.openWorkspace({ directory: '/work/frontend' }) sidecarEvent({ @@ -444,12 +697,14 @@ describe('EccWorkspaceRuntime', () => { }) it('cancels the matching in-flight operation and emits a cancelled event', async () => { - const { client, events, service, sidecar } = createService() + const { client, events, service, sidecar } = createService('/work/frontend', { + managementRpc: true, + }) client.responses.push( { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, + { directory: '/work/frontend', workspaceId: 'workspace-1' }, ) - const workspace = await service.openWorkspace({ directory: '/work/demo' }) + const workspace = await service.openWorkspace({ directory: '/work/frontend' }) const blockedFlow = deferred<{ rerun: boolean }>() client.responses.push(blockedFlow.promise) @@ -494,10 +749,8 @@ describe('EccWorkspaceRuntime', () => { it('forwards GUI single-step rerun reset intent to ECC', async () => { const { client, service } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { - awaitingEventId: null, createdAt: 1, currentStep: 'Floorplan', currentTool: '', @@ -517,6 +770,7 @@ describe('EccWorkspaceRuntime', () => { await expect( service.startStepOperation({ + expectedWorkspaceRevision: 1, idempotencyKey: 'request-2', rerun: true, resetDependents: true, @@ -528,6 +782,7 @@ describe('EccWorkspaceRuntime', () => { expect(client.calls.at(-1)).toEqual({ method: 'operation.start_step', params: { + expectedWorkspaceRevision: 1, idempotencyKey: 'request-2', origin: 'gui', rerun: true, @@ -540,10 +795,7 @@ describe('EccWorkspaceRuntime', () => { it('resolves an operation waiter from its terminal protocol event', async () => { const { client, service, sidecarNotification } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) const workspace = await service.openWorkspace({ directory: '/work/demo' }) const completed = service.waitForOperation({ operationId: 'operation-1', @@ -558,10 +810,16 @@ describe('EccWorkspaceRuntime', () => { kind: 'step', operationId: 'operation-1', origin: 'gui', - payload: { result: { state: 'Success' }, step: 'place', tool: 'dreamplace' }, + payload: { + result: { state: 'Success' }, + sourceType: 'operation.completed', + state: 'succeeded', + step: 'place', + tool: 'dreamplace', + }, sequence: 2, timestamp: 2, - type: 'operation.completed', + type: 'operation.changed', workspaceId: 'workspace-1', }, }) @@ -575,10 +833,7 @@ describe('EccWorkspaceRuntime', () => { it('persists the terminal snapshot before releasing a successful flow sidecar', async () => { const { client, service, sidecar, sidecarNotification } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) const workspace = await service.openWorkspace({ directory: '/work/demo' }) client.responses.push({ directory: '/work/demo', @@ -597,10 +852,14 @@ describe('EccWorkspaceRuntime', () => { kind: 'flow', operationId: 'operation-1', origin: 'gui', - payload: { result: { rerun: false } }, + payload: { + result: { rerun: false }, + sourceType: 'operation.completed', + state: 'succeeded', + }, sequence: 2, timestamp: 2, - type: 'operation.completed', + type: 'operation.changed', workspaceId: 'workspace-1', }, }) @@ -632,7 +891,6 @@ describe('EccWorkspaceRuntime', () => { parameters: Record }>() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { directory: '/work/demo', @@ -656,10 +914,10 @@ describe('EccWorkspaceRuntime', () => { kind: 'flow', operationId: 'operation-1', origin: 'gui', - payload: { step: 'Harden', tool: 'ecc' }, + payload: { sourceType: 'step.started', step: 'Harden', tool: 'ecc' }, sequence: 1, timestamp: 1, - type: 'step.started', + type: 'execution.progress', workspaceId: 'workspace-1', }, }) @@ -675,10 +933,16 @@ describe('EccWorkspaceRuntime', () => { kind: 'flow', operationId: 'operation-1', origin: 'gui', - payload: { result: { state: 'Success' }, step: 'Harden', tool: 'ecc' }, + payload: { + result: { state: 'Success' }, + sourceType: 'operation.completed', + state: 'succeeded', + step: 'Harden', + tool: 'ecc', + }, sequence: 2, timestamp: 2, - type: 'operation.completed', + type: 'operation.changed', workspaceId: 'workspace-1', }, }) @@ -713,272 +977,165 @@ describe('EccWorkspaceRuntime', () => { ).toHaveLength(2) }) - it('persists a detached step snapshot before releasing its GUI ACK gate', async () => { - const { client, service } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - { - directory: '/work/demo', - flow: { steps: [] }, - home: {}, - lastEventId: 'workspace-1:3', - operations: [], - parameters: {}, - }, - { - accepted: true, - duplicate: false, - eventId: 'workspace-1:3', - operationId: 'operation-1', - }, - ) + it('reads Step Configuration while terminal finalization is pending', async () => { + const { client, service, sidecarNotification } = createService() + const finalSnapshot = deferred>() + client.responses.push({ + directory: '/work/demo', + workspaceId: 'workspace-1', + workspaceRevision: 4, + }) const workspace = await service.openWorkspace({ directory: '/work/demo' }) + client.responses.push(finalSnapshot.promise, { + parameters: [ + { + applies: 'place', + default: 0.2, + description: 'Placement target density', + param: 'place.target_density', + type: 'float', + value: 0.49, + }, + ], + status: 'available', + step: 'Legalization', + stepId: 'Legalization', + workspaceId: 'workspace-1', + workspaceRevision: 4, + }) - await expect( - service.acknowledgeDetachedStepRendered({ - eventId: 'workspace-1:3', + sidecarNotification({ + jsonrpc: '2.0', + method: 'runtime.event', + params: { + eventId: 'workspace-1:2', + kind: 'flow', operationId: 'operation-1', - stepCommitId: 'operation-1:step:1', - workspaceHandle: workspace.workspaceHandle, - workspaceRevision: 1, - }), - ).resolves.toMatchObject({ accepted: true }) - - expect(client.calls.slice(-2)).toEqual([ - { method: 'workspace.snapshot', params: { workspaceId: 'workspace-1' } }, - { - method: 'operation.ack_step_rendered', - params: { - eventId: 'workspace-1:3', - operationId: 'operation-1', - stepCommitId: 'operation-1:step:1', - workspaceRevision: 1, + origin: 'gui', + payload: { + result: { state: 'Success' }, + sourceType: 'operation.completed', + state: 'succeeded', + step: 'Legalization', + tool: 'dreamplace', }, + sequence: 2, + timestamp: 2, + type: 'operation.changed', + workspaceId: 'workspace-1', }, - ]) - }) - - it('releases a failed operation sidecar after the diagnostic idle timeout', async () => { - vi.useFakeTimers() - try { - const { sidecar, sidecarNotification } = createService('/work/demo', { - diagnosticIdleTimeoutMs: 25, - }) - - sidecarNotification({ - jsonrpc: '2.0', - method: 'runtime.event', - params: { - eventId: 'workspace-1:2', - kind: 'flow', - operationId: 'operation-1', - origin: 'gui', - payload: { error: { code: 'command_failed', message: 'failed' } }, - sequence: 2, - timestamp: 2, - type: 'operation.failed', - workspaceId: 'workspace-1', - }, - }) - - await vi.advanceTimersByTimeAsync(25) - expect(sidecar.shutdownCount).toBe(1) - } finally { - vi.useRealTimers() - } - }) - - it('forwards the wizard flow range when creating a workspace', async () => { - const { client, service } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) - const flowConfig = { - start_step: 'Synthesis', - end_step: 'Harden', - steps: ['Synthesis', 'RCX', 'sta', 'Harden'], - } - - await service.createWorkspace({ - directory: '/work/demo', - flowConfig, - pdkJson: '/pdks/ics55/pdk.json', - sdc: '/constraints/top.sdc', }) - expect(client.calls.at(-1)).toEqual({ - method: 'workspace.create', - params: expect.objectContaining({ - flowConfig, - pdkJson: '/pdks/ics55/pdk.json', - sdc: '/constraints/top.sdc', - }), + const configuration = service.readWorkspaceStepConfiguration({ + step: 'Legalization', + workspaceHandle: workspace.workspaceHandle, }) - }) - - it('omits empty flowConfig when creating a workspace', async () => { - const { client, service } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, + await waitForQueuedOperation() + const readStartedBeforeFinalization = client.calls.some( + (call) => call.method === 'workspace.step_configuration.read', ) + expect(readStartedBeforeFinalization).toBe(true) - await service.createWorkspace({ + finalSnapshot.resolve({ directory: '/work/demo', - flowConfig: {}, - pdkJson: '/pdks/ics55/pdk.json', + flow: { steps: [] }, + home: {}, + lastEventId: 'workspace-1:2', + operations: [], + parameters: {}, }) - expect(client.calls.at(-1)).toEqual({ - method: 'workspace.create', - params: expect.not.objectContaining({ - flowConfig: expect.anything(), - }), + await expect(configuration).resolves.toMatchObject({ + status: 'available', + workspaceId: 'workspace-1', }) }) - it('retries workspace creation without sdc when an older runtime rejects the field', async () => { - const { client, service } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0a5', version: 1 }, - new EccJsonRpcError(-32602, 'invalid_request', { - message: 'unknown field: sdc', - }), - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) - - await expect( - service.createWorkspace({ - directory: '/work/demo', - pdkJson: '/pdks/ics55/pdk.json', - sdc: '/constraints/top.sdc', - }), - ).resolves.toEqual({ + it('finalizes a failed operation before releasing its sidecar', async () => { + const { client, service, sidecar, sidecarNotification } = createService('/work/demo') + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) + await service.openWorkspace({ directory: '/work/demo' }) + client.responses.push({ directory: '/work/demo', - workspaceHandle: expect.stringMatching(/^workspace-/), - }) - - expect(client.calls.at(-2)).toEqual({ - method: 'workspace.create', - params: expect.objectContaining({ - pdkJson: '/pdks/ics55/pdk.json', - sdc: '/constraints/top.sdc', - }), - }) - expect(client.calls.at(-1)).toEqual({ - method: 'workspace.create', - params: expect.not.objectContaining({ - sdc: expect.anything(), - }), + flow: { steps: [] }, + home: {}, + lastEventId: 'workspace-1:2', + operations: [], + parameters: {}, }) - }) - it('retries workspace creation without flowConfig and sdc for older runtimes', async () => { - const { client, service } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0a4', version: 1 }, - new EccJsonRpcError(-32602, 'invalid_request', { - message: 'unknown field: flowConfig', - }), - new EccJsonRpcError(-32602, 'invalid_request', { - message: 'unknown field: sdc', - }), - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) - const flowConfig = { - start_step: 'Synthesis', - end_step: 'Harden', - steps: ['Synthesis', 'RCX', 'sta', 'Harden'], - } - - await expect( - service.createWorkspace({ - directory: '/work/demo', - flowConfig, - pdkJson: '/pdks/ics55/pdk.json', - sdc: '/constraints/top.sdc', - }), - ).resolves.toEqual({ - directory: '/work/demo', - workspaceHandle: expect.stringMatching(/^workspace-/), + sidecarNotification({ + jsonrpc: '2.0', + method: 'runtime.event', + params: { + eventId: 'workspace-1:2', + kind: 'flow', + operationId: 'operation-1', + origin: 'gui', + payload: { + error: { code: 'command_failed', message: 'failed' }, + sourceType: 'operation.failed', + state: 'failed', + }, + sequence: 2, + timestamp: 2, + type: 'operation.changed', + workspaceId: 'workspace-1', + }, }) - expect(client.calls.at(-3)).toEqual({ - method: 'workspace.create', - params: expect.objectContaining({ - flowConfig, - sdc: '/constraints/top.sdc', - }), - }) - expect(client.calls.at(-2)).toEqual({ - method: 'workspace.create', - params: expect.not.objectContaining({ - flowConfig: expect.anything(), - }), - }) - expect(client.calls.at(-2)?.params).toEqual( - expect.objectContaining({ - sdc: '/constraints/top.sdc', - }), - ) - expect(client.calls.at(-1)).toEqual({ - method: 'workspace.create', - params: expect.not.objectContaining({ - flowConfig: expect.anything(), - sdc: expect.anything(), - }), - }) + await vi.waitFor(() => expect(sidecar.shutdownCount).toBe(1)) + expect(client.calls.some((call) => call.method === 'workspace.snapshot')).toBe(true) + expect(sidecar.shutdownCount).toBe(1) }) - it('retries workspace creation without the default sdc field for older runtimes', async () => { + it('forwards the wizard flow range when creating a workspace', async () => { const { client, service } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0a5', version: 1 }, - new EccJsonRpcError(-32602, 'invalid_request', { - message: 'unknown field: sdc', - }), - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) - + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) await service.createWorkspace({ - directory: '/work/demo', - pdkJson: '/pdks/ics55/pdk.json', + commandId: 'workspace-create-flow-range', + targetDirectory: '/work/demo', + workspaceBindings: { inputs: { sdc: '/constraints/top.sdc' } }, + workspaceSpec: { + flow: { + flowId: 'harden', + fromStepId: 'Synthesis', + throughStepId: 'Harden', + }, + }, }) - expect(client.calls.at(-2)).toEqual({ - method: 'workspace.create', - params: expect.objectContaining({ - pdkJson: '/pdks/ics55/pdk.json', - sdc: '', - }), - }) expect(client.calls.at(-1)).toEqual({ method: 'workspace.create', - params: expect.not.objectContaining({ - sdc: expect.anything(), + params: expect.objectContaining({ + commandId: 'workspace-create-flow-range', + targetDirectory: '/work/demo', + workspaceSpec: expect.objectContaining({ + flow: { + flowId: 'harden', + fromStepId: 'Synthesis', + throughStepId: 'Harden', + }, + }), }), }) }) - it('lazy-starts the sidecar, performs rpc.hello, and opens workspaces', async () => { + it('starts the sidecar and sends the first owned business RPC directly', async () => { const { client, events, service, sidecar } = createService() - client.responses.push( - { capabilities: ['workspace.open'], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) const result = await service.openWorkspace({ directory: '/work/demo' }) expect(sidecar.startCount).toBe(1) expect(client.calls).toEqual([ - { method: 'rpc.hello', params: { version: 1 } }, { method: 'workspace.open', params: { directory: '/work/demo' } }, ]) expect(result).toEqual({ directory: '/work/demo', workspaceHandle: expect.stringMatching(/^workspace-/), + workspaceId: 'workspace-1', + workspaceRevision: 1, }) expect(events).toContainEqual({ type: 'runtime.ready', @@ -989,7 +1146,6 @@ describe('EccWorkspaceRuntime', () => { it('moves a legacy sidecar log before rerunning a flow step', async () => { const { client, service, sidecar } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { state: 'Success', step: 'placement' }, ) @@ -997,6 +1153,7 @@ describe('EccWorkspaceRuntime', () => { const workspace = await service.openWorkspace({ directory: '/work/demo' }) await expect( service.runStep({ + expectedWorkspaceRevision: 1, rerun: true, step: 'placement', workspaceHandle: workspace.workspaceHandle, @@ -1008,6 +1165,7 @@ describe('EccWorkspaceRuntime', () => { method: 'flow.run_step', options: { timeoutMs: 0 }, params: { + expectedWorkspaceRevision: 1, rerun: true, step: 'placement', workspaceId: 'workspace-1', @@ -1018,7 +1176,6 @@ describe('EccWorkspaceRuntime', () => { it('exports signoff through the stored ECC workspace id and preserves the output path', async () => { const { client, service } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { outputPath: '/exports/custom package.tar.gz' }, ) @@ -1041,63 +1198,16 @@ describe('EccWorkspaceRuntime', () => { }) }) - it('inspects signoff through the stored ECC workspace id', async () => { - const { client, service } = createService() - const review: EccWorkspaceInspectSignoffResult = { - groups: [], - risks: [ - { - details: [ - { - kind: 'artifact', - label: 'Harden GDS', - location: 'Harden_ecc/output/gcd_Harden.gds', - reason: 'Required file is missing or empty', - owner: 'checklist', - policy: 'block', - state: 'failed', - evidence: [ - { - kind: 'file', - path: 'Harden_ecc/output/gcd_Harden.gds', - }, - ], - }, - ], - severity: 'blocked', - summary: '1 required resource missing', - title: 'Harden resources missing', - }, - ], - status: 'blocked', - } - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - review, - ) - - const workspace = await service.openWorkspace({ directory: '/work/demo' }) - await expect( - service.inspectSignoff({ workspaceHandle: workspace.workspaceHandle }), - ).resolves.toEqual(review) - - expect(client.calls.at(-1)).toEqual({ - method: 'workspace.inspect_signoff', - params: { workspaceId: 'workspace-1' }, - }) - }) - it('emits rerun metadata when a full flow rerun starts', async () => { const { client, events, service } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { rerun: true }, ) const workspace = await service.openWorkspace({ directory: '/work/demo' }) await service.runFlow({ + expectedWorkspaceRevision: 1, rerun: true, workspaceHandle: workspace.workspaceHandle, }) @@ -1116,13 +1226,13 @@ describe('EccWorkspaceRuntime', () => { it('moves a legacy sidecar log before sending a rerun request', async () => { const { client, service, sidecar } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { rerun: true }, ) const workspace = await service.openWorkspace({ directory: '/work/demo' }) await service.runFlow({ + expectedWorkspaceRevision: 1, rerun: true, workspaceHandle: workspace.workspaceHandle, }) @@ -1131,16 +1241,17 @@ describe('EccWorkspaceRuntime', () => { expect(client.calls.at(-1)).toEqual({ method: 'flow.run', options: { timeoutMs: 0 }, - params: { rerun: true, workspaceId: 'workspace-1' }, + params: { + expectedWorkspaceRevision: 1, + rerun: true, + workspaceId: 'workspace-1', + }, }) }) it('cleans runtime activity tracking when an operation-started listener throws', async () => { const { client, service } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) const workspace = await service.openWorkspace({ directory: '/work/demo' }) service.onEvent((event) => { @@ -1151,6 +1262,7 @@ describe('EccWorkspaceRuntime', () => { await expect( service.runFlow({ + expectedWorkspaceRevision: 1, rerun: false, workspaceHandle: workspace.workspaceHandle, }), @@ -1159,58 +1271,9 @@ describe('EccWorkspaceRuntime', () => { expect(service.isActive()).toBe(false) }) - it('serializes all RPC operations through a per-runtime queue', async () => { - const { client, service } = createService() - client.responses.push({ capabilities: [], eccVersion: '0.1.0', version: 1 }) - await service.rpcHello() - await Promise.resolve() - client.calls.length = 0 - - const firstPing = deferred<{ ok: boolean }>() - client.responses.push(firstPing.promise, { ok: true }) - - const first = service.rpcPing() - const second = service.rpcPing() - await waitForQueuedOperation() - - expect(client.calls).toEqual([{ method: 'rpc.ping', params: undefined }]) - firstPing.resolve({ ok: true }) - await expect(first).resolves.toEqual({ ok: true }) - await expect(second).resolves.toEqual({ ok: true }) - expect(client.calls).toEqual([ - { method: 'rpc.ping', params: undefined }, - { method: 'rpc.ping', params: undefined }, - ]) - }) - - it('bypasses the operation queue when shutting down the sidecar', async () => { - const { client, service, sidecar } = createService() - client.responses.push({ capabilities: [], eccVersion: '0.1.0', version: 1 }) - await service.rpcHello() - await Promise.resolve() - client.calls.length = 0 - - const blockedPing = deferred<{ ok: boolean }>() - client.responses.push(blockedPing.promise) - - const ping = service.rpcPing() - await waitForQueuedOperation() - - await expect(service.rpcShutdown()).resolves.toEqual({ ok: true }) - - expect(sidecar.shutdownCount).toBe(1) - expect(client.calls).toEqual([{ method: 'rpc.ping', params: undefined }]) - - blockedPing.resolve({ ok: true }) - await expect(ping).resolves.toEqual({ ok: true }) - }) - it('enriches unexpected runtime exits with the in-flight operation', async () => { const { client, events, service, sidecarEvent } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) const workspace = await service.openWorkspace({ directory: '/work/demo' }) const blockedFlow = deferred<{ rerun: boolean }>() @@ -1250,9 +1313,7 @@ describe('EccWorkspaceRuntime', () => { it('restarts and reopens the active workspace on the next call after exit', async () => { const { client, service, sidecar, sidecarEvent } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-2' }, { recovered: [] }, { rerun: false }, @@ -1275,20 +1336,21 @@ describe('EccWorkspaceRuntime', () => { await expect( service.runFlow({ + expectedWorkspaceRevision: 1, rerun: false, workspaceHandle: workspace.workspaceHandle, }), ).resolves.toEqual({ rerun: false }) expect(sidecar.startCount).toBe(3) - expect(client.calls.slice(2)).toEqual([ - { method: 'rpc.hello', params: { version: 1 } }, + expect(client.calls.slice(1)).toEqual([ { method: 'workspace.open', params: { directory: '/work/demo' } }, { method: 'workspace.recover_interrupted', params: { workspaceId: 'workspace-2' } }, { method: 'flow.run', options: { timeoutMs: 0 }, params: { + expectedWorkspaceRevision: 1, rerun: false, workspaceId: 'workspace-2', }, @@ -1299,9 +1361,7 @@ describe('EccWorkspaceRuntime', () => { it('recovers a persisted interruption when the start notification was lost', async () => { const { client, service, sidecarEvent } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-2' }, { recovered: [] }, ) @@ -1326,9 +1386,7 @@ describe('EccWorkspaceRuntime', () => { it('retries crash recovery on the next workspace snapshot after a transient failure', async () => { const { client, service, sidecarEvent } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-2' }, new Error('temporary recovery failure'), { @@ -1378,13 +1436,11 @@ describe('EccWorkspaceRuntime', () => { const { client, events, service, sidecar, sidecarEvent, sidecarNotification } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { operationId: 'operation-place', state: 'running', }, - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-2' }, { recovered: [ @@ -1399,6 +1455,7 @@ describe('EccWorkspaceRuntime', () => { ) const workspace = await service.openWorkspace({ directory: '/work/demo' }) await service.startStepOperation({ + expectedWorkspaceRevision: 1, idempotencyKey: 'place-1', step: 'place', workspaceHandle: workspace.workspaceHandle, @@ -1411,10 +1468,10 @@ describe('EccWorkspaceRuntime', () => { kind: 'step', operationId: 'operation-place', origin: 'gui', - payload: {}, + payload: { sourceType: 'operation.started', state: 'running' }, sequence: 1, timestamp: 1, - type: 'operation.started', + type: 'operation.changed', workspaceId: 'workspace-1', }, }) @@ -1466,7 +1523,6 @@ describe('EccWorkspaceRuntime', () => { it('replays previous-run recovery after the workspace snapshot is requested', async () => { const { client, events, service } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { recovered: [ @@ -1507,7 +1563,6 @@ describe('EccWorkspaceRuntime', () => { it('invalidates a cached snapshot after recovering an interrupted step', async () => { const { client, service } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-1' }, { directory: '/work/demo', @@ -1549,16 +1604,19 @@ describe('EccWorkspaceRuntime', () => { ]) }) - it('handshakes and reopens retained sessions when the sidecar returns a new client', async () => { + it('reopens retained sessions when the sidecar returns a new client', async () => { const { client, service, sidecar } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) - const workspace = await service.openWorkspace({ directory: '/work/demo' }) + const workspaceBindings = { + inputs: {}, + pdk: { root: '/pdks/ics55', version: '1.10.102' }, + } + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) + const workspace = await service.openWorkspace({ + directory: '/work/demo', + workspaceBindings, + }) const replacementClient = new FakeRpcClient() replacementClient.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-2' }, { rerun: false }, ) @@ -1566,18 +1624,22 @@ describe('EccWorkspaceRuntime', () => { await expect( service.runFlow({ + expectedWorkspaceRevision: 1, rerun: false, workspaceHandle: workspace.workspaceHandle, }), ).resolves.toEqual({ rerun: false }) expect(replacementClient.calls).toEqual([ - { method: 'rpc.hello', params: { version: 1 } }, - { method: 'workspace.open', params: { directory: '/work/demo' } }, + { + method: 'workspace.open', + params: { directory: '/work/demo', workspaceBindings }, + }, { method: 'flow.run', options: { timeoutMs: 0 }, params: { + expectedWorkspaceRevision: 1, rerun: false, workspaceId: 'workspace-2', }, @@ -1588,7 +1650,6 @@ describe('EccWorkspaceRuntime', () => { it('closes a shared ECC workspace only after its final GUI handle is released', async () => { const { client, service } = createService() client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, { directory: '/work/demo', workspaceId: 'workspace-shared' }, { directory: '/work/demo', workspaceId: 'workspace-shared' }, { ok: true }, @@ -1617,26 +1678,16 @@ describe('EccWorkspaceRuntime', () => { it('does not send a stale workspace id when close replaces the sidecar client', async () => { const { client, service, sidecar } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) const workspace = await service.openWorkspace({ directory: '/work/demo' }) const replacementClient = new FakeRpcClient() - replacementClient.responses.push({ - capabilities: [], - eccVersion: '0.1.0', - version: 1, - }) sidecar.client = replacementClient await expect( service.closeWorkspace({ workspaceHandle: workspace.workspaceHandle }), ).resolves.toEqual({ ok: true }) - expect(replacementClient.calls).toEqual([ - { method: 'rpc.hello', params: { version: 1 } }, - ]) + expect(replacementClient.calls).toEqual([]) await expect( service.runFlow({ rerun: false, @@ -1647,10 +1698,7 @@ describe('EccWorkspaceRuntime', () => { it('releases the GUI handle when server-side workspace close fails', async () => { const { client, service } = createService() - client.responses.push( - { capabilities: [], eccVersion: '0.1.0', version: 1 }, - { directory: '/work/demo', workspaceId: 'workspace-1' }, - ) + client.responses.push({ directory: '/work/demo', workspaceId: 'workspace-1' }) const workspace = await service.openWorkspace({ directory: '/work/demo' }) client.responses.push(new Error('server close failed')) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.ts index c0420a214..a80077a40 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' import type { EccFlowRunRequest, EccFlowRunResult, @@ -12,22 +13,19 @@ import type { EccLayoutEditDiscardResult, EccLayoutEditSaveRequest, EccLayoutEditSaveResult, - EccRpcHelloResult, - EccRpcPingResult, - EccRpcShutdownResult, + EccPersistedEngineeringSnapshot, EccRuntimeEvent, EccRuntimeOperation, EccRuntimeOperationRequest, EccRuntimeStartFlowRequest, EccRuntimeStartStepRequest, - EccRuntimeStepRenderedAckRequest, EccWorkspaceCloseResult, + EccWorkspaceConfigurationUpdateRequest, EccWorkspaceCreateRequest, EccWorkspaceCreateResult, EccWorkspaceExportSignoffRequest, EccWorkspaceExportSignoffResult, EccWorkspaceHandleRequest, - EccWorkspaceInspectSignoffResult, EccWorkspaceHomeResult, EccWorkspaceInfoRequest, EccWorkspaceInfoResult, @@ -35,10 +33,16 @@ import type { EccWorkspaceOpenResult, EccWorkspaceRefreshConfigResult, EccWorkspaceResetFlowResult, + EccWorkspaceStepConfigurationUpdateRequest, + EccWorkspaceStepConfigurationReadRequest, + EccWorkspaceStepConfigurationReadResult, EccWorkspaceRuntimeSnapshot, - EccWorkspaceSyncConfigRequest, - EccWorkspaceSyncConfigResult, + EccWorkspaceSpecValidationRequest, + EccWorkspaceSpecValidationResult, + EccWorkspaceUpdateRequest, + EccWorkspaceUpdateResult, } from '@ecos-studio/shared' +import { validateEngineeringSnapshot } from '@ecos-studio/shared' import { normalizeRuntimeError } from './errors' import { electronLogger } from '../logger' @@ -47,7 +51,12 @@ import { RuntimeOperationTracker, isRuntimeProtocolPayload, } from './runtimeOperationTracker' -import type { EccRpcRuntimeClient, EccRpcRuntimeSidecar } from './runtimeClient' +import type { + EccRpcRuntimeClient, + EccRpcRuntimeSidecar, + RuntimeShutdownBarrier, + RuntimeShutdownResult, +} from './runtimeClient' import { RuntimeSidecarLifecycle } from './runtimeSidecarLifecycle' import { WorkspaceRuntimeCommands, @@ -56,14 +65,15 @@ import { type RuntimeOperationMetadata, } from './workspaceRuntimeCommands' import { WorkspaceSessionRegistry } from './workspaceSessions' -import { WorkspaceSnapshotCache } from './workspaceSnapshotCache' +import { WorkspaceStepConfigurationCache } from './workspaceStepConfigurationCache' +import { readPersistedEngineeringSnapshot } from './engineeringSnapshotReader' export type { EccRpcRuntimeClient, EccRpcRuntimeSidecar } from './runtimeClient' export interface EccWorkspaceRuntimeOptions { /** * Bound workspace directory for this runtime. `null` is used for the - * control runtime (rpc.hello / rpc.ping only). + * control runtime. */ directory: string | null createSidecar( @@ -71,12 +81,9 @@ export interface EccWorkspaceRuntimeOptions { onNotification: (notification: JsonRpcNotificationPayload) => void, ): EccRpcRuntimeSidecar onEvent?: (event: EccRuntimeEvent) => void - diagnosticIdleTimeoutMs?: number + managementRpc?: boolean lazyWorkspaceOpen?: boolean sessions?: WorkspaceSessionRegistry - snapshotLoader?: ( - directory: string, - ) => Promise> } interface InFlightOperation { @@ -100,10 +107,10 @@ export class EccWorkspaceRuntime { private readonly sessions: WorkspaceSessionRegistry private readonly sidecar: EccRpcRuntimeSidecar private client: EccRpcRuntimeClient | null = null + private managementHelloResult: unknown = null private readonly eventListeners = new Set<(event: EccRuntimeEvent) => void>() /** Compatibility cancellation state for the legacy frontend RPC facade. */ private readonly cancelledOperationIds = new Set() - private helloResult: EccRpcHelloResult | null = null private inFlightOperation: InFlightOperation | null = null private inFlightCount = 0 private readonly operationTracker = new RuntimeOperationTracker() @@ -111,7 +118,10 @@ export class EccWorkspaceRuntime { private readonly failedCrashRecoveries = new Map() private readonly pendingRecoveryEvents: EccRuntimeEvent[] = [] private readonly sidecarLifecycle: RuntimeSidecarLifecycle - private readonly snapshotCache = new WorkspaceSnapshotCache() + private cachedSnapshot: Omit | null = + null + private readonly stepConfigurationCache = + new WorkspaceStepConfigurationCache() private readonly commands: WorkspaceRuntimeCommands private queue = Promise.resolve() private ready = false @@ -127,16 +137,15 @@ export class EccWorkspaceRuntime { this.sidecarLifecycle = new RuntimeSidecarLifecycle({ captureFinalSnapshot: async (workspaceId) => { const client = this.client - if (!client) return + if (!client) throw new Error('ECC Runtime client is unavailable.') const snapshot = await client.call< Omit >('workspace.snapshot', { workspaceId }) - this.snapshotCache.set(snapshot) + this.cachedSnapshot = snapshot }, closeSidecar: async () => { await this.shutdown() }, - diagnosticIdleTimeoutMs: options.diagnosticIdleTimeoutMs, emitError: (text) => { this.emit({ text, @@ -157,6 +166,7 @@ export class EccWorkspaceRuntime { enqueue: (method, workspaceHandle, operation, metadata) => this.enqueue(method, workspaceHandle, operation, metadata), ensureStarted: () => this.ensureStarted(), + hasActiveOperations: () => this.operationTracker.hasActiveOperations(), lazyWorkspaceOpen: Boolean(options.lazyWorkspaceOpen), resolveEccWorkspaceId: (workspaceHandle) => this.resolveEccWorkspaceId(workspaceHandle), @@ -185,6 +195,45 @@ export class EccWorkspaceRuntime { return this.inFlightCount > 0 || this.operationTracker.hasActiveOperations() } + activeOperations(): EccRuntimeOperation[] { + return this.operationTracker.activeOperations() + } + + recentOperationOutcomes(): EccRuntimeOperation[] { + return this.operationTracker.recentOutcomes() + } + + trackOperationSnapshot(operation: EccRuntimeOperation): void { + if (this.operationTracker.knowsOperation(operation.operationId)) return + this.commitReconciledOperation(operation) + } + + async reconcileActiveOperations(workspaceHandle: string): Promise { + const active = this.operationTracker.activeOperations() + if (!active.length) return + const client = await this.ensureStarted() + await this.resolveEccWorkspaceId(workspaceHandle) + const results = await Promise.allSettled( + active.map((operation) => + client.call( + 'operation.status', + { + operationId: operation.operationId, + }, + { timeoutMs: 1_000 }, + ), + ), + ) + for (const [index, result] of results.entries()) { + if ( + result.status === 'fulfilled' && + result.value.operationId === active[index]?.operationId + ) { + this.commitReconciledOperation(result.value) + } + } + } + hasInFlightOperation(operationId?: string): boolean { const operation = this.inFlightOperation return Boolean(operation && (!operationId || operation.operationId === operationId)) @@ -213,6 +262,9 @@ export class EccWorkspaceRuntime { ): Promise { return this.enqueue(method, undefined, async () => { const client = await this.ensureStarted() + if (method === 'rpc.hello' && this.options.managementRpc) { + return this.managementHelloResult as T + } return await client.call(method, params, options) }) } @@ -227,13 +279,30 @@ export class EccWorkspaceRuntime { payload, { timeoutMs: 0 }, ) - const session = this.sessions.activate(response.directory, response.workspaceId) - return { directory: session.directory, workspaceHandle: session.workspaceHandle } + const session = this.sessions.activate( + response.directory, + response.workspaceId, + response.workspaceRevision ?? 1, + ) + return { + directory: session.directory, + workspaceHandle: session.workspaceHandle, + workspaceId: session.eccWorkspaceId ?? undefined, + workspaceRevision: session.workspaceRevision, + } }) } hasPendingRuntimeWork(): boolean { - return this.isActive() || this.sidecarLifecycle.hasFinalSnapshotTask() + return this.isActive() || this.sidecarLifecycle.hasFinalizationBlocker() + } + + finalization() { + return this.sidecarLifecycle.finalization() + } + + retryFinalSnapshot(): Promise { + return this.sidecarLifecycle.retryFinalSnapshot() } shutdownBarrier(): { @@ -248,7 +317,8 @@ export class EccWorkspaceRuntime { const operationId = this.inFlightOperation?.operationId ?? this.operationTracker.firstActiveOperationId() - if (!operationId && !this.sidecarLifecycle.hasFinalSnapshotTask()) return null + const finalization = this.sidecarLifecycle.finalization() + if (!operationId && !finalization) return null return { cancelRequested: false, interruptibility: 'deferred', @@ -258,7 +328,7 @@ export class EccWorkspaceRuntime { ? this.inFlightOperation ? 'request_in_flight' : 'running' - : 'finalizing', + : (finalization?.state ?? 'finalizing'), step: '', workspaceId: this.boundDirectory ?? '', } @@ -271,37 +341,69 @@ export class EccWorkspaceRuntime { } } - rpcHello(): Promise { - return this.enqueue('rpc.hello', undefined, async () => { - await this.ensureStarted() - if (!this.helloResult) { - throw new Error('ECC RPC hello completed without a result.') - } - return this.helloResult - }) + createWorkspace(request: EccWorkspaceCreateRequest): Promise { + return this.commands.createWorkspace(request) } - rpcPing(): Promise { - return this.enqueue('rpc.ping', undefined, async () => { - const client = await this.ensureStarted() - return await client.call('rpc.ping') - }) + describeWorkspaceSpec(): Promise> { + return this.commands.describeWorkspaceSpec() } - rpcShutdown(): Promise { - return this.shutdown() + validateWorkspaceSpec( + request: EccWorkspaceSpecValidationRequest, + ): Promise { + return this.commands.validateWorkspaceSpec(request) } - createWorkspace(request: EccWorkspaceCreateRequest): Promise { - return this.commands.createWorkspace(request) + async updateWorkspace( + request: EccWorkspaceUpdateRequest, + ): Promise { + const result = await this.commands.updateWorkspace(request) + this.stepConfigurationCache.clear() + return result + } + + async updateWorkspaceConfiguration( + request: EccWorkspaceConfigurationUpdateRequest, + ): Promise { + const result = await this.commands.updateWorkspaceConfiguration(request) + this.cachedSnapshot = null + this.stepConfigurationCache.clear() + return result + } + + async updateWorkspaceStepConfiguration( + request: EccWorkspaceStepConfigurationUpdateRequest, + ): Promise { + const result = await this.commands.updateWorkspaceStepConfiguration(request) + this.cachedSnapshot = null + this.stepConfigurationCache.clear() + return result } openWorkspace(request: EccWorkspaceOpenRequest): Promise { return this.commands.openWorkspace(request) } - closeWorkspace(request: EccWorkspaceHandleRequest): Promise { - return this.commands.closeWorkspace(request) + workspaceSession(workspaceHandle: string): EccWorkspaceOpenResult { + const session = this.sessions.require(workspaceHandle) + return { + directory: session.directory, + reused: true, + workspaceHandle: session.workspaceHandle, + workspaceId: session.eccWorkspaceId ?? undefined, + workspaceRevision: session.workspaceRevision, + } + } + + async closeWorkspace( + request: EccWorkspaceHandleRequest, + ): Promise { + try { + return await this.commands.closeWorkspace(request) + } finally { + this.stepConfigurationCache.clear() + } } workspaceHome(request: EccWorkspaceHandleRequest): Promise { @@ -312,19 +414,40 @@ export class EccWorkspaceRuntime { return this.commands.workspaceInfo(request) } + async readWorkspaceStepConfiguration( + request: EccWorkspaceStepConfigurationReadRequest, + ): Promise { + const session = this.sessions.require(request.workspaceHandle) + const workspaceId = session.eccWorkspaceId + const cacheKey = + workspaceId && session.workspaceRevision > 0 + ? `${workspaceId}:${session.workspaceRevision}:${request.step}` + : null + if (!cacheKey) { + return await this.commands.readWorkspaceStepConfiguration(request) + } + return await this.stepConfigurationCache.load(cacheKey, () => + this.commands.readWorkspaceStepConfiguration(request), + ) + } + + async readWorkspaceStepConfigurationForDirectory( + directory: string, + step: string, + ): Promise { + const client = await this.ensureStarted() + return await client.call( + 'workspace.step_configuration.read', + { directory, step }, + ) + } + async refreshConfig( request: EccWorkspaceHandleRequest, ): Promise { const result = await this.commands.refreshConfig(request) - this.snapshotCache.clear() - return result - } - - async syncConfig( - request: EccWorkspaceSyncConfigRequest, - ): Promise { - const result = await this.commands.syncConfig(request) - this.snapshotCache.clear() + this.cachedSnapshot = null + this.stepConfigurationCache.clear() return result } @@ -332,7 +455,8 @@ export class EccWorkspaceRuntime { request: EccWorkspaceHandleRequest, ): Promise { const result = await this.commands.resetFlow(request) - this.snapshotCache.clear() + this.cachedSnapshot = null + this.stepConfigurationCache.clear() return result } @@ -342,12 +466,6 @@ export class EccWorkspaceRuntime { return this.commands.exportSignoff(request) } - inspectSignoff( - request: EccWorkspaceHandleRequest, - ): Promise { - return this.commands.inspectSignoff(request) - } - layoutEditBegin(request: EccLayoutEditBeginRequest): Promise { return this.commands.layoutEditBegin(request) } @@ -356,8 +474,12 @@ export class EccWorkspaceRuntime { return this.commands.layoutEditApply(request) } - layoutEditSave(request: EccLayoutEditSaveRequest): Promise { - return this.commands.layoutEditSave(request) + async layoutEditSave( + request: EccLayoutEditSaveRequest, + ): Promise { + const result = await this.commands.layoutEditSave(request) + this.stepConfigurationCache.clear() + return result } layoutEditDiscard( @@ -406,6 +528,7 @@ export class EccWorkspaceRuntime { } const workspaceId = await this.resolveEccWorkspaceId(request.workspaceHandle) return await client.call('operation.start_flow', { + expectedWorkspaceRevision: request.expectedWorkspaceRevision, idempotencyKey: request.idempotencyKey, origin: 'gui', rerun: Boolean(request.rerun), @@ -423,6 +546,7 @@ export class EccWorkspaceRuntime { } const workspaceId = await this.resolveEccWorkspaceId(request.workspaceHandle) return await client.call('operation.start_step', { + expectedWorkspaceRevision: request.expectedWorkspaceRevision, idempotencyKey: request.idempotencyKey, origin: 'gui', rerun: Boolean(request.rerun), @@ -446,6 +570,15 @@ export class EccWorkspaceRuntime { return this.operationTracker.waitFor(request.operationId) } + operationLogFile(request: EccRuntimeOperationRequest): string { + this.sessions.require(request.workspaceHandle) + if (!this.operationTracker.knowsOperation(request.operationId)) { + throw new Error('ECC Operation is not owned by this Workspace Session.') + } + if (!this.sidecar.logFile) throw new Error('ECC Runtime log is unavailable.') + return this.sidecar.logFile + } + async cancelOperation( request: EccRuntimeOperationRequest, ): Promise<{ accepted: boolean; operationId: string; state: string }> { @@ -454,46 +587,6 @@ export class EccWorkspaceRuntime { return await client.call('operation.cancel', { operationId: request.operationId }) } - async acknowledgeStepRendered(request: EccRuntimeStepRenderedAckRequest): Promise<{ - accepted: boolean - duplicate: boolean - eventId: string - operationId: string - }> { - const client = await this.ensureStarted() - await this.resolveEccWorkspaceId(request.workspaceHandle) - return await client.call('operation.ack_step_rendered', { - eventId: request.eventId, - operationId: request.operationId, - ...(request.stepCommitId ? { stepCommitId: request.stepCommitId } : {}), - ...(typeof request.workspaceRevision === 'number' - ? { workspaceRevision: request.workspaceRevision } - : {}), - }) - } - - /** - * A workspace page may detach while a GUI flow is stopped at a step boundary. - * Main first captures the authoritative in-memory snapshot, then sends the - * same idempotent ACK that a renderer would have sent after painting it. - */ - async acknowledgeDetachedStepRendered( - request: EccRuntimeStepRenderedAckRequest, - ): Promise<{ - accepted: boolean - duplicate: boolean - eventId: string - operationId: string - }> { - const client = await this.ensureStarted() - const workspaceId = await this.resolveEccWorkspaceId(request.workspaceHandle) - const snapshot = await client.call< - Omit - >('workspace.snapshot', { workspaceId }) - this.snapshotCache.set(snapshot) - return await this.acknowledgeStepRendered(request) - } - async workspaceSnapshot( request: EccWorkspaceHandleRequest, ): Promise { @@ -505,30 +598,62 @@ export class EccWorkspaceRuntime { const finalSnapshotTask = this.sidecarLifecycle.waitForFinalSnapshot() if (finalSnapshotTask) await finalSnapshotTask - const cachedSnapshot = this.snapshotCache.get() + const cachedSnapshot = this.cachedSnapshot if (!this.isActive() && cachedSnapshot) { this.flushPendingRecoveryEvents() return { ...cachedSnapshot, workspaceHandle: request.workspaceHandle } } - const session = this.sessions.require(request.workspaceHandle) - if (!this.isActive() && this.options.snapshotLoader) { - const snapshot = await this.snapshotCache.loadIdle( - session.directory, - this.options.snapshotLoader, - ) - this.flushPendingRecoveryEvents() - return { ...snapshot, workspaceHandle: request.workspaceHandle } - } + this.sessions.require(request.workspaceHandle) const client = await this.ensureStarted() const workspaceId = await this.resolveEccWorkspaceId(request.workspaceHandle) const snapshot = await client.call< Omit >('workspace.snapshot', { workspaceId }) - this.snapshotCache.set(snapshot) + this.cachedSnapshot = snapshot this.flushPendingRecoveryEvents() return { ...snapshot, workspaceHandle: request.workspaceHandle } } + async engineeringSnapshot( + request: EccWorkspaceHandleRequest, + ): Promise { + const session = this.sessions.require(request.workspaceHandle) + if (!existsSync(session.directory)) { + return await this.readLegacyEngineeringSnapshot(request, session.eccWorkspaceId) + } + return await readPersistedEngineeringSnapshot( + session.directory, + session.eccWorkspaceId ?? undefined, + ) + } + + private async readLegacyEngineeringSnapshot( + request: EccWorkspaceHandleRequest, + workspaceId: string | null, + ): Promise { + const client = await this.ensureStarted() + const snapshot = await client.call>( + 'workspace.engineering_snapshot', + workspaceId ? { workspaceId } : {}, + ) + const validated = validateEngineeringSnapshot(snapshot, workspaceId ?? undefined) + if (!validated.ok) throw new Error(validated.issue.code) + const { artifacts, flow, qor, signoff } = validated.sections + if (artifacts.status !== 'ready') throw new Error(artifacts.issues[0]?.code) + if (flow.status !== 'ready') throw new Error(flow.issues[0]?.code) + if (qor.status !== 'ready') throw new Error(qor.issues[0]?.code) + if (signoff.status !== 'ready') throw new Error(signoff.issues[0]?.code) + return { + ...validated.snapshot, + analysis: qor.data.analysis, + artifacts: artifacts.data, + flow: flow.data, + metrics: qor.data.metrics, + qorAssessment: qor.data.qorAssessment, + signoffAssessment: signoff.data, + } + } + async recoverInterrupted( workspaceHandle: string, operationId = '', @@ -542,7 +667,7 @@ export class EccWorkspaceRuntime { ...(operationId ? { operationId } : {}), }, ) - if (result.recovered.length > 0) this.snapshotCache.clear() + if (result.recovered.length > 0) this.cachedSnapshot = null for (const recovered of result.recovered) { const step = recovered.step || 'Flow step' const event: EccRuntimeEvent = { @@ -569,8 +694,14 @@ export class EccWorkspaceRuntime { return result.recovered } - async shutdown(): Promise { - this.sidecarLifecycle.cancelDiagnosticRelease() + async shutdown(): Promise { + if (!this.options.managementRpc && this.isActive()) { + return { + deferred: true, + ok: false, + shutdownBarrier: this.shutdownBarrier() ?? undefined, + } + } try { await this.sidecar.shutdown() } catch (error) { @@ -581,22 +712,36 @@ export class EccWorkspaceRuntime { throw error } this.client = null + this.managementHelloResult = null this.ready = false - this.helloResult = null this.sessions.clearEccWorkspaceIds() + this.stepConfigurationCache.clear() this.operationTracker.rejectAll( new Error('ECC sidecar shut down before the operation completed.'), ) return { ok: true } } + async forceShutdown(): Promise { + if (this.sidecar.forceShutdown) await this.sidecar.forceShutdown() + else await this.sidecar.shutdown() + this.client = null + this.managementHelloResult = null + this.ready = false + this.sessions.clearEccWorkspaceIds() + this.stepConfigurationCache.clear() + this.operationTracker.rejectAll( + new Error('ECC sidecar was terminated during Force quit.'), + ) + } + async releaseIdleSidecar(): Promise { if (this.hasPendingRuntimeWork()) return await this.shutdown() } async cancelAtSafeShutdownBoundary( - shutdownBarrier: NonNullable, + shutdownBarrier: RuntimeShutdownBarrier, ): Promise { if (!shutdownBarrier.safeToStop || !shutdownBarrier.operationId) return const client = this.client @@ -605,22 +750,23 @@ export class EccWorkspaceRuntime { } private async ensureStarted(): Promise { - this.sidecarLifecycle.cancelDiagnosticRelease() const client = await this.sidecar.start() if (client !== this.client) { this.client = client + this.managementHelloResult = null this.ready = false - this.helloResult = null this.sessions.clearEccWorkspaceIds() + this.stepConfigurationCache.clear() this.operationTracker.reset(new Error('ECC sidecar client was replaced.')) } - if (this.ready && this.helloResult) { - return client - } + if (this.ready) return client - this.helloResult = await client.call('rpc.hello', { - version: 1, - }) + if (this.options.managementRpc) { + const helloResult = await client.call>('rpc.hello', { + version: 1, + }) + this.managementHelloResult = helloResult + } this.ready = true this.emit({ type: 'runtime.ready', @@ -638,8 +784,15 @@ export class EccWorkspaceRuntime { const client = this.client ?? (await this.ensureStarted()) const response = await client.call('workspace.open', { directory: session.directory, + ...(session.workspaceBindings + ? { workspaceBindings: session.workspaceBindings } + : {}), }) - this.sessions.rebind(workspaceHandle, response.workspaceId) + this.sessions.rebind( + workspaceHandle, + response.workspaceId, + response.workspaceRevision ?? 1, + ) return response.workspaceId } @@ -661,58 +814,65 @@ export class EccWorkspaceRuntime { operationId, workspaceHandle, } + const emitOperationLifecycle = isFlowOperationMethod(method) try { - this.emit({ - logFile: this.sidecar.logFile ?? undefined, - method, - operationId, - ...metadata, - type: 'operation.started', - workspaceDirectory: runtimeDirectory ?? undefined, - workspaceHandle, - }) - const result = await operation() - this.emit({ - logFile: this.sidecar.logFile ?? undefined, - method, - operationId, - ...metadata, - type: 'operation.completed', - workspaceDirectory: runtimeDirectory ?? undefined, - workspaceHandle, - }) - return result - } catch (error) { - const normalized = normalizeRuntimeError(error, { - logFile: this.sidecar.logFile, - method, - operationId, - workspaceHandle, - }) - if (this.cancelledOperationIds.has(operationId)) { + if (emitOperationLifecycle) { this.emit({ - logFile: normalized.logFile, + logFile: this.sidecar.logFile ?? undefined, method, operationId, ...metadata, - type: 'operation.cancelled', + type: 'operation.started', workspaceDirectory: runtimeDirectory ?? undefined, workspaceHandle, }) - } else { + } + const result = await operation() + if (emitOperationLifecycle) { this.emit({ - code: normalized.code, - details: normalized.details, - logFile: normalized.logFile, - message: normalized.message, + logFile: this.sidecar.logFile ?? undefined, method, operationId, ...metadata, - type: 'operation.failed', + type: 'operation.completed', workspaceDirectory: runtimeDirectory ?? undefined, workspaceHandle, }) } + return result + } catch (error) { + const normalized = normalizeRuntimeError(error, { + logFile: this.sidecar.logFile, + method, + operationId, + workspaceHandle, + }) + if (emitOperationLifecycle) { + if (this.cancelledOperationIds.has(operationId)) { + this.emit({ + logFile: normalized.logFile, + method, + operationId, + ...metadata, + type: 'operation.cancelled', + workspaceDirectory: runtimeDirectory ?? undefined, + workspaceHandle, + }) + } else { + this.emit({ + code: normalized.code, + details: normalized.details, + logFile: normalized.logFile, + message: normalized.message, + method, + operationId, + ...metadata, + type: 'operation.failed', + workspaceDirectory: runtimeDirectory ?? undefined, + workspaceHandle, + }) + } + } throw normalized } finally { this.cancelledOperationIds.delete(operationId) @@ -754,9 +914,10 @@ export class EccWorkspaceRuntime { const workspaceHandle = inFlight?.workspaceHandle ?? this.sessions.active?.workspaceHandle this.client = null + this.managementHelloResult = null this.ready = false - this.helloResult = null this.sessions.clearEccWorkspaceIds() + this.stepConfigurationCache.clear() this.operationTracker.rejectAll( new Error('ECC sidecar exited before the operation completed.'), ) @@ -812,9 +973,18 @@ export class EccWorkspaceRuntime { protocolEvent.operationId, ) const session = this.sessions.findByEccWorkspaceId(protocolEvent.workspaceId) + const committedRevision = protocolEvent.payload.workspaceRevision + if (session && typeof committedRevision === 'number') { + if (committedRevision !== session.workspaceRevision) { + this.stepConfigurationCache.clear() + } + this.sessions.updateRevision(session.workspaceHandle, committedRevision) + } const isTerminal = this.operationTracker.track(protocolEvent) + const operationState = protocolEvent.payload.state if ( - protocolEvent.type === 'operation.failed' && + protocolEvent.type === 'operation.changed' && + (operationState === 'failed' || operationState === 'interrupted') && isTerminal && !terminalAlreadyRecorded ) { @@ -835,22 +1005,17 @@ export class EccWorkspaceRuntime { } } if ( - protocolEvent.type === 'operation.completed' && + protocolEvent.type === 'operation.changed' && + ['succeeded', 'failed', 'cancelled', 'interrupted'].includes( + String(operationState), + ) && isTerminal && !terminalAlreadyRecorded ) { // The prior cache may describe the final step as Ongoing. A fresh page - // must wait for the terminal snapshot or fall back to the bounded disk - // loader if capture fails. - this.snapshotCache.clear() - this.sidecarLifecycle.releaseAfterSuccessfulOperation(protocolEvent.workspaceId) - } else if ( - isTerminal && - !terminalAlreadyRecorded && - (protocolEvent.type === 'operation.failed' || - protocolEvent.type === 'operation.cancelled') - ) { - this.sidecarLifecycle.retainFailedOperationForDiagnostics() + // must wait for the terminal ECC snapshot. + this.cachedSnapshot = null + this.sidecarLifecycle.finalizeOperation(protocolEvent.workspaceId) } this.emit({ event: protocolEvent, @@ -878,6 +1043,12 @@ export class EccWorkspaceRuntime { } } + private commitReconciledOperation(operation: EccRuntimeOperation): void { + if (!this.operationTracker.reconcile(operation)) return + this.cachedSnapshot = null + this.sidecarLifecycle.finalizeOperation(operation.workspaceId) + } + private flushPendingRecoveryEvents(): void { for (const event of this.pendingRecoveryEvents.splice(0)) this.emit(event) } @@ -935,9 +1106,11 @@ export class EccWorkspaceRuntime { } } -function shutdownBarrierFrom( - error: unknown, -): NonNullable | null { +function isFlowOperationMethod(method: string): boolean { + return method === 'flow.run' || method === 'flow.run_step' +} + +function shutdownBarrierFrom(error: unknown): RuntimeShutdownBarrier | null { if (!(error instanceof Error) || !('shutdownBarrier' in error)) return null const barrier = (error as Error & { shutdownBarrier?: unknown }).shutdownBarrier if (typeof barrier !== 'object' || barrier === null || Array.isArray(barrier)) @@ -947,6 +1120,6 @@ function shutdownBarrierFrom( typeof value.state === 'string' && typeof value.step === 'string' && typeof value.workspaceId === 'string' - ? (value as NonNullable) + ? (value as unknown as RuntimeShutdownBarrier) : null } diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntimeCommands.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntimeCommands.ts index 6bc7a4e80..542e4c3d2 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntimeCommands.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntimeCommands.ts @@ -12,6 +12,7 @@ import type { EccLayoutEditSaveRequest, EccLayoutEditSaveResult, EccWorkspaceCloseResult, + EccWorkspaceConfigurationUpdateRequest, EccWorkspaceCreateRequest, EccWorkspaceCreateResult, EccWorkspaceExportSignoffRequest, @@ -20,23 +21,26 @@ import type { EccWorkspaceHomeResult, EccWorkspaceInfoRequest, EccWorkspaceInfoResult, - EccWorkspaceInspectSignoffResult, EccWorkspaceOpenRequest, EccWorkspaceOpenResult, EccWorkspaceRefreshConfigResult, EccWorkspaceResetFlowResult, - EccWorkspaceSyncConfigRequest, - EccWorkspaceSyncConfigResult, + EccWorkspaceStepConfigurationUpdateRequest, + EccWorkspaceStepConfigurationReadRequest, + EccWorkspaceStepConfigurationReadResult, + EccWorkspaceSpecValidationRequest, + EccWorkspaceSpecValidationResult, + EccWorkspaceUpdateRequest, + EccWorkspaceUpdateResult, } from '@ecos-studio/shared' -import { EccJsonRpcError } from './jsonRpcClient' import type { EccRpcRuntimeClient, EccRpcRuntimeSidecar } from './runtimeClient' -import { migrateWorkspaceConfigFilenames } from './workspaceConfigMigration' import { WorkspaceSessionRegistry } from './workspaceSessions' export interface EccWorkspaceSessionResult { directory: string workspaceId: string + workspaceRevision?: number } export type RuntimeOperation = () => Promise @@ -56,6 +60,7 @@ interface WorkspaceRuntimeCommandContext { metadata?: RuntimeOperationMetadata, ): Promise ensureStarted(): Promise + hasActiveOperations(): boolean lazyWorkspaceOpen: boolean resolveEccWorkspaceId(workspaceHandle: string): Promise sessions: WorkspaceSessionRegistry @@ -68,58 +73,183 @@ export class WorkspaceRuntimeCommands { createWorkspace(request: EccWorkspaceCreateRequest): Promise { return this.context.enqueue('workspace.create', undefined, async () => { const client = await this.context.ensureStarted() - const payloadOptions = { includeFlowConfig: true, includeSdc: true } - let response: EccWorkspaceSessionResult | null = null - while (!response) { - try { - response = await client.call( - 'workspace.create', - workspaceCreatePayload(request, payloadOptions), - ) - } catch (error) { - if ( - payloadOptions.includeFlowConfig && - isUnknownJsonRpcFieldError(error, 'flowConfig') - ) { - payloadOptions.includeFlowConfig = false - continue - } - if (payloadOptions.includeSdc && isUnknownJsonRpcFieldError(error, 'sdc')) { - payloadOptions.includeSdc = false - continue - } - throw error - } - } + const response = await client.call('workspace.create', { + ...request, + }) const session = this.context.sessions.activate( response.directory, response.workspaceId, + response.workspaceRevision ?? 1, + request.workspaceBindings, ) - return { directory: session.directory, workspaceHandle: session.workspaceHandle } + return { + directory: session.directory, + workspaceHandle: session.workspaceHandle, + workspaceId: session.eccWorkspaceId ?? undefined, + workspaceRevision: session.workspaceRevision, + } }) } openWorkspace(request: EccWorkspaceOpenRequest): Promise { return this.context.enqueue('workspace.open', undefined, async () => { - await migrateWorkspaceConfigFilenames(request.directory) + const existing = this.context.sessions.findByDirectory(request.directory) + if (existing && this.context.hasActiveOperations()) { + if (request.workspaceBindings) { + this.context.sessions.updateBindings( + existing.workspaceHandle, + request.workspaceBindings, + ) + } + return { + directory: existing.directory, + reused: true, + workspaceHandle: existing.workspaceHandle, + workspaceId: existing.eccWorkspaceId ?? undefined, + workspaceRevision: existing.workspaceRevision, + } + } if (this.context.lazyWorkspaceOpen) { const existing = this.context.sessions.findByDirectory(request.directory) - const session = - existing ?? this.context.sessions.activate(request.directory, null) - return { directory: session.directory, workspaceHandle: session.workspaceHandle } + if (existing && request.workspaceBindings) { + this.context.sessions.updateBindings( + existing.workspaceHandle, + request.workspaceBindings, + ) + } + const session = existing + ? this.context.sessions.require(existing.workspaceHandle) + : this.context.sessions.activate( + request.directory, + null, + 0, + request.workspaceBindings, + ) + return { + directory: session.directory, + reused: Boolean(existing), + workspaceHandle: session.workspaceHandle, + } } const client = await this.context.ensureStarted() const response = await client.call('workspace.open', { directory: request.directory, + ...(request.workspaceBindings + ? { workspaceBindings: request.workspaceBindings } + : {}), }) const session = this.context.sessions.activate( response.directory, response.workspaceId, + response.workspaceRevision ?? 1, + request.workspaceBindings, + ) + return { + directory: session.directory, + workspaceHandle: session.workspaceHandle, + workspaceId: session.eccWorkspaceId ?? undefined, + workspaceRevision: session.workspaceRevision, + } + }) + } + + describeWorkspaceSpec(): Promise> { + return this.context.enqueue('workspace_spec.describe', undefined, async () => { + const client = await this.context.ensureStarted() + return await client.call>('workspace_spec.describe') + }) + } + + validateWorkspaceSpec( + request: EccWorkspaceSpecValidationRequest, + ): Promise { + return this.context.enqueue('workspace_spec.validate', undefined, async () => { + const client = await this.context.ensureStarted() + return await client.call( + 'workspace_spec.validate', + { ...request }, ) - return { directory: session.directory, workspaceHandle: session.workspaceHandle } }) } + async updateWorkspace( + request: EccWorkspaceUpdateRequest, + ): Promise { + const result = await this.workspaceCall( + 'workspace.update', + request, + (workspaceId) => ({ + commandId: request.commandId, + expectedWorkspaceRevision: request.expectedWorkspaceRevision, + workspaceBindings: request.workspaceBindings, + workspaceId, + workspaceSpec: request.workspaceSpec, + }), + ) + this.context.sessions.updateRevision( + request.workspaceHandle, + result.workspaceRevision, + ) + this.context.sessions.updateBindings( + request.workspaceHandle, + request.workspaceBindings, + ) + return result + } + + async updateWorkspaceConfiguration( + request: EccWorkspaceConfigurationUpdateRequest, + ): Promise { + const session = this.context.sessions.require(request.workspaceHandle) + const currentBindings = session.workspaceBindings ?? {} + const currentPdk = currentBindings.pdk + const workspaceBindings = { + ...currentBindings, + pdk: { + ...(typeof currentPdk === 'object' && currentPdk !== null ? currentPdk : {}), + ...(request.pdkRoot ? { root: request.pdkRoot } : {}), + }, + } + const result = await this.workspaceCall( + 'workspace.configuration.update', + request, + (workspaceId) => ({ + commandId: request.commandId, + configuration: request.configuration, + expectedWorkspaceRevision: request.expectedWorkspaceRevision, + workspaceBindings, + workspaceId, + }), + ) + this.context.sessions.updateRevision( + request.workspaceHandle, + result.workspaceRevision, + ) + this.context.sessions.updateBindings(request.workspaceHandle, workspaceBindings) + return result + } + + async updateWorkspaceStepConfiguration( + request: EccWorkspaceStepConfigurationUpdateRequest, + ): Promise { + const result = await this.workspaceCall( + 'workspace.step_configuration.update', + request, + (workspaceId) => ({ + commandId: request.commandId, + expectedWorkspaceRevision: request.expectedWorkspaceRevision, + parameters: request.parameters, + stepId: request.stepId, + workspaceId, + }), + ) + this.context.sessions.updateRevision( + request.workspaceHandle, + result.workspaceRevision, + ) + return result + } + closeWorkspace(request: EccWorkspaceHandleRequest): Promise { return this.context.enqueue('workspace.close', request.workspaceHandle, async () => { try { @@ -164,6 +294,24 @@ export class WorkspaceRuntimeCommands { })) } + async readWorkspaceStepConfiguration( + request: EccWorkspaceStepConfigurationReadRequest, + ): Promise { + const client = await this.context.ensureStarted() + const workspaceId = await this.context.resolveEccWorkspaceId(request.workspaceHandle) + const result = await client.call( + 'workspace.step_configuration.read', + { + step: request.step, + workspaceId, + }, + ) + if (result.workspaceId && result.workspaceId !== workspaceId) { + throw new Error('ECC Step Configuration response belongs to another Workspace.') + } + return result + } + refreshConfig( request: EccWorkspaceHandleRequest, ): Promise { @@ -172,19 +320,24 @@ export class WorkspaceRuntimeCommands { })) } - syncConfig( - request: EccWorkspaceSyncConfigRequest, - ): Promise { - return this.workspaceCall('workspace.sync_config', request, (workspaceId) => ({ - configPath: request.configPath, - workspaceId, - })) - } - - resetFlow(request: EccWorkspaceHandleRequest): Promise { - return this.workspaceCall('workspace.reset_flow', request, (workspaceId) => ({ - workspaceId, - })) + async resetFlow( + request: EccWorkspaceHandleRequest, + ): Promise { + const result = await this.workspaceCall( + 'workspace.reset_flow', + request, + (workspaceId) => ({ + expectedWorkspaceRevision: request.expectedWorkspaceRevision, + workspaceId, + }), + ) + if (typeof result.workspaceRevision === 'number') { + this.context.sessions.updateRevision( + request.workspaceHandle, + result.workspaceRevision, + ) + } + return result } exportSignoff( @@ -202,14 +355,6 @@ export class WorkspaceRuntimeCommands { ) } - inspectSignoff( - request: EccWorkspaceHandleRequest, - ): Promise { - return this.workspaceCall('workspace.inspect_signoff', request, (workspaceId) => ({ - workspaceId, - })) - } - layoutEditBegin(request: EccLayoutEditBeginRequest): Promise { return this.workspaceCall('layout.edit.begin', request, (workspaceId) => ({ ...(request.expectedSourceFingerprint @@ -237,19 +382,33 @@ export class WorkspaceRuntimeCommands { ) } - layoutEditSave(request: EccLayoutEditSaveRequest): Promise { - return this.context.enqueue('layout.edit.save', request.workspaceHandle, async () => { - const client = await this.context.ensureStarted() - await this.context.resolveEccWorkspaceId(request.workspaceHandle) - return await client.call( - 'layout.edit.save', - { - editSessionId: request.editSessionId, - expectedRevision: request.expectedRevision, - }, - { timeoutMs: 0 }, + async layoutEditSave( + request: EccLayoutEditSaveRequest, + ): Promise { + const result = await this.context.enqueue( + 'layout.edit.save', + request.workspaceHandle, + async () => { + const client = await this.context.ensureStarted() + await this.context.resolveEccWorkspaceId(request.workspaceHandle) + return await client.call( + 'layout.edit.save', + { + editSessionId: request.editSessionId, + expectedRevision: request.expectedRevision, + expectedWorkspaceRevision: request.expectedWorkspaceRevision, + }, + { timeoutMs: 0 }, + ) + }, + ) + if (typeof result.workspaceRevision === 'number') { + this.context.sessions.updateRevision( + request.workspaceHandle, + result.workspaceRevision, ) - }) + } + return result } layoutEditDiscard( @@ -282,7 +441,11 @@ export class WorkspaceRuntimeCommands { ) return await client.call( 'flow.run', - { rerun, workspaceId }, + { + expectedWorkspaceRevision: request.expectedWorkspaceRevision, + rerun, + workspaceId, + }, { timeoutMs: 0 }, ) }, @@ -304,7 +467,12 @@ export class WorkspaceRuntimeCommands { ) return await client.call( 'flow.run_step', - { rerun, step: request.step, workspaceId }, + { + expectedWorkspaceRevision: request.expectedWorkspaceRevision, + rerun, + step: request.step, + workspaceId, + }, { timeoutMs: 0 }, ) }, @@ -315,53 +483,28 @@ export class WorkspaceRuntimeCommands { private workspaceCall( method: string, request: EccWorkspaceHandleRequest, - params: (workspaceId: string) => Record, + params: (workspaceId: string, workspaceRevision: number) => Record, options?: { timeoutMs?: number }, + metadata?: RuntimeOperationMetadata, ): Promise { - return this.context.enqueue(method, request.workspaceHandle, async () => { - const client = await this.context.ensureStarted() - const workspaceId = await this.context.resolveEccWorkspaceId( - request.workspaceHandle, - ) - return await client.call(method, params(workspaceId), options) - }) - } -} - -function isUnknownJsonRpcFieldError(error: unknown, field: string): boolean { - if (!(error instanceof EccJsonRpcError) || error.code !== -32602) return false - const data = error.data - return ( - typeof data === 'object' && - data !== null && - 'message' in data && - data.message === `unknown field: ${field}` - ) -} - -function workspaceCreatePayload( - request: EccWorkspaceCreateRequest, - options: { includeFlowConfig: boolean; includeSdc: boolean }, -): Record { - return { - directory: request.directory, - filelist: request.filelist ?? '', - ...(options.includeFlowConfig && hasEntries(request.flowConfig) - ? { flowConfig: request.flowConfig } - : {}), - originDef: request.originDef ?? '', - originVerilog: request.originVerilog ?? '', - parameters: request.parameters ?? {}, - pdk: request.pdk ?? '', - pdkJson: request.pdkJson ?? null, - pdkRoot: request.pdkRoot ?? '', - rtlList: request.rtlList ?? [], - ...(options.includeSdc ? { sdc: request.sdc ?? '' } : {}), + return this.context.enqueue( + method, + request.workspaceHandle, + async () => { + const client = await this.context.ensureStarted() + const workspaceId = await this.context.resolveEccWorkspaceId( + request.workspaceHandle, + ) + const workspaceRevision = this.context.sessions.require( + request.workspaceHandle, + ).workspaceRevision + return await client.call( + method, + params(workspaceId, workspaceRevision), + options, + ) + }, + metadata, + ) } } - -function hasEntries( - value: Record | undefined, -): value is Record { - return value !== undefined && Object.keys(value).length > 0 -} diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSessions.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSessions.test.ts index 2ac3beb46..6e90dd2e2 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSessions.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSessions.test.ts @@ -17,6 +17,7 @@ describe('WorkspaceSessionRegistry', () => { directory: '/work/demo', eccWorkspaceId: 'workspace-1', workspaceHandle: 'workspace-handle-1', + workspaceRevision: 0, }) }) @@ -45,6 +46,7 @@ describe('WorkspaceSessionRegistry', () => { directory: '/work/demo', eccWorkspaceId: 'workspace-2', workspaceHandle: 'workspace-handle-1', + workspaceRevision: 0, }) }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSessions.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSessions.ts index abb278308..656e01d50 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSessions.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSessions.ts @@ -1,9 +1,12 @@ import { randomUUID } from 'node:crypto' +import { normalizeWorkspacePath } from '../workspacePath' export interface WorkspaceSessionRecord { directory: string eccWorkspaceId: string | null + workspaceBindings?: Record workspaceHandle: string + workspaceRevision: number } export class WorkspaceSessionNotFoundError extends Error { @@ -38,11 +41,18 @@ export class WorkspaceSessionRegistry { return this.sessions.size } - activate(directory: string, eccWorkspaceId: string | null): WorkspaceSessionRecord { + activate( + directory: string, + eccWorkspaceId: string | null, + workspaceRevision = 0, + workspaceBindings?: Record, + ): WorkspaceSessionRecord { const session = { directory, eccWorkspaceId, + ...(workspaceBindings ? { workspaceBindings } : {}), workspaceHandle: this.idProvider(), + workspaceRevision, } this.sessions.set(session.workspaceHandle, session) this.activeHandle = session.workspaceHandle @@ -54,6 +64,7 @@ export class WorkspaceSessionRegistry { this.sessions.set(workspaceHandle, { ...session, eccWorkspaceId: null, + workspaceRevision: 0, }) } } @@ -65,16 +76,34 @@ export class WorkspaceSessionRegistry { this.activeHandle = Array.from(this.sessions.keys()).at(-1) ?? null } - rebind(workspaceHandle: string, eccWorkspaceId: string): WorkspaceSessionRecord { + rebind( + workspaceHandle: string, + eccWorkspaceId: string, + workspaceRevision = 0, + ): WorkspaceSessionRecord { const session = this.require(workspaceHandle) const rebound = { ...session, eccWorkspaceId, + workspaceRevision, } this.sessions.set(workspaceHandle, rebound) return { ...rebound } } + updateRevision(workspaceHandle: string, workspaceRevision: number): void { + const session = this.require(workspaceHandle) + this.sessions.set(workspaceHandle, { ...session, workspaceRevision }) + } + + updateBindings( + workspaceHandle: string, + workspaceBindings: Record, + ): void { + const session = this.require(workspaceHandle) + this.sessions.set(workspaceHandle, { ...session, workspaceBindings }) + } + hasOtherEccWorkspaceReference( workspaceHandle: string, eccWorkspaceId: string, @@ -100,8 +129,9 @@ export class WorkspaceSessionRegistry { } findByDirectory(directory: string): WorkspaceSessionRecord | null { + const normalizedDirectory = normalizeWorkspacePath(directory) for (const session of this.sessions.values()) { - if (session.directory === directory) { + if (normalizeWorkspacePath(session.directory) === normalizedDirectory) { return { ...session } } } diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotCache.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotCache.test.ts deleted file mode 100644 index 0218db79d..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotCache.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { - WorkspaceSnapshotCache, - type DetachedWorkspaceSnapshot, -} from './workspaceSnapshotCache' - -function deferred() { - let resolve!: (value: T) => void - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve - }) - return { promise, resolve } -} - -describe('WorkspaceSnapshotCache', () => { - it('coalesces concurrent idle loads into one bounded loader request', async () => { - const cache = new WorkspaceSnapshotCache() - const pending = deferred() - const loader = vi.fn<(directory: string) => Promise>( - () => pending.promise, - ) - - const first = cache.loadIdle('/nfs/workspace', loader) - const second = cache.loadIdle('/nfs/workspace', loader) - - expect(loader).toHaveBeenCalledOnce() - pending.resolve({ - directory: '/nfs/workspace', - flow: { steps: [] }, - home: {}, - lastEventId: 'disk:1', - operations: [], - parameters: {}, - }) - await expect(Promise.all([first, second])).resolves.toEqual([ - expect.objectContaining({ lastEventId: 'disk:1' }), - expect.objectContaining({ lastEventId: 'disk:1' }), - ]) - }) - - it('does not restore an invalidated snapshot when an earlier idle read finishes', async () => { - const cache = new WorkspaceSnapshotCache() - const pending = deferred() - const staleLoader = vi.fn<(directory: string) => Promise>( - () => pending.promise, - ) - const staleRead = cache.loadIdle('/nfs/workspace', staleLoader) - - cache.clear() - pending.resolve({ - directory: '/nfs/workspace', - flow: { steps: [] }, - home: {}, - lastEventId: 'disk:stale', - operations: [], - parameters: {}, - }) - await expect(staleRead).resolves.toMatchObject({ lastEventId: 'disk:stale' }) - - const currentLoader = vi.fn(async () => ({ - directory: '/nfs/workspace', - flow: { steps: [] }, - home: {}, - lastEventId: 'disk:current', - operations: [], - parameters: {}, - })) - await expect(cache.loadIdle('/nfs/workspace', currentLoader)).resolves.toMatchObject({ - lastEventId: 'disk:current', - }) - expect(currentLoader).toHaveBeenCalledOnce() - }) -}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotCache.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotCache.ts deleted file mode 100644 index b7b96dca9..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotCache.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { EccWorkspaceRuntimeSnapshot } from '@ecos-studio/shared' - -export type DetachedWorkspaceSnapshot = Omit< - EccWorkspaceRuntimeSnapshot, - 'workspaceHandle' -> - -/** - * An idle workspace can be requested by several renderer surfaces at once. - * Coalescing the first bounded NFS read prevents those surfaces from competing - * for Electron's I/O workers while preserving the last authoritative snapshot. - */ -export class WorkspaceSnapshotCache { - private generation = 0 - private latest: DetachedWorkspaceSnapshot | null = null - private pendingLoad: Promise | null = null - - get(): DetachedWorkspaceSnapshot | null { - return this.latest - } - - set(snapshot: DetachedWorkspaceSnapshot): void { - this.latest = snapshot - } - - clear(): void { - this.generation += 1 - this.latest = null - this.pendingLoad = null - } - - async loadIdle( - directory: string, - loader: (directory: string) => Promise, - ): Promise { - if (this.latest) return this.latest - if (!this.pendingLoad) { - const loadGeneration = this.generation - const load = loader(directory).then((snapshot) => { - if (this.generation === loadGeneration) { - this.latest = snapshot - } - return snapshot - }) - const pending = load.finally(() => { - if (this.pendingLoad === pending) { - this.pendingLoad = null - } - }) - this.pendingLoad = pending - } - return await this.pendingLoad - } -} diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.test.ts deleted file mode 100644 index 538cc9f52..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { - appendFileSync, - mkdirSync, - mkdtempSync, - rmSync, - symlinkSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const growAfterStat = vi.hoisted(() => ({ - path: null as string | null, -})) - -const shortReadPath = vi.hoisted(() => ({ - path: null as string | null, -})) - -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - open: async ( - path: Parameters[0], - flags?: Parameters[1], - ) => { - const handle = await actual.open(path, flags) - if (growAfterStat.path && String(path) === growAfterStat.path) { - const originalStat = handle.stat.bind(handle) - handle.stat = (async () => { - const info = await originalStat() - appendFileSync(growAfterStat.path!, 'x'.repeat(512 * 1024)) - return info - }) as typeof handle.stat - } - if (shortReadPath.path && String(path) === shortReadPath.path) { - const originalRead = handle.read.bind(handle) - let first = true - handle.read = (async (options?: Parameters[0]) => { - if (first && options && typeof options === 'object' && 'length' in options) { - first = false - const length = Math.min(1, Number(options.length) || 0) - return await originalRead({ ...options, length }) - } - return await originalRead(options) - }) as typeof handle.read - } - return handle - }, - } -}) - -import { WorkspaceSnapshotLoader } from './workspaceSnapshotLoader' - -const temporaryDirectories: string[] = [] - -function createWorkspace(): string { - const directory = mkdtempSync(join(tmpdir(), 'ecos-workspace-snapshot-')) - temporaryDirectories.push(directory) - mkdirSync(join(directory, 'home')) - return directory -} - -describe('WorkspaceSnapshotLoader', () => { - afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { force: true, recursive: true }) - } - }) - - it('loads only lightweight workspace JSON summaries', async () => { - const directory = createWorkspace() - writeFileSync( - join(directory, 'home', 'home.json'), - JSON.stringify({ flow: 'home/flow.json' }), - ) - writeFileSync( - join(directory, 'home', 'flow.json'), - JSON.stringify({ - steps: [ - { - name: 'Synthesis', - runtime: '1s', - state: 'Success', - tool: 'yosys', - }, - ], - }), - ) - writeFileSync( - join(directory, 'home', 'parameters.json'), - JSON.stringify({ PDK: 'ics55' }), - ) - - await expect(new WorkspaceSnapshotLoader().load(directory)).resolves.toMatchObject({ - directory, - flow: { steps: [{ name: 'Synthesis', state: 'Success', tool: 'yosys' }] }, - home: { flow: 'home/flow.json' }, - operations: [], - parameters: { PDK: 'ics55' }, - }) - }) - - it('rejects an oversized JSON resource instead of transferring it to the renderer', async () => { - const directory = createWorkspace() - writeFileSync(join(directory, 'home', 'home.json'), '{}') - writeFileSync(join(directory, 'home', 'parameters.json'), '{}') - writeFileSync(join(directory, 'home', 'flow.json'), 'x'.repeat(512 * 1024 + 1)) - - await expect(new WorkspaceSnapshotLoader().load(directory)).rejects.toThrow( - 'Workspace snapshot resource exceeds', - ) - }) - - it('rejects a file that grows past the cap after the opened handle is statted', async () => { - const directory = createWorkspace() - const flowPath = join(directory, 'home', 'flow.json') - writeFileSync(join(directory, 'home', 'home.json'), '{}') - writeFileSync(join(directory, 'home', 'parameters.json'), '{}') - writeFileSync(flowPath, '{}') - - growAfterStat.path = flowPath - try { - await expect(new WorkspaceSnapshotLoader().load(directory)).rejects.toThrow( - 'Workspace snapshot resource exceeds', - ) - } finally { - growAfterStat.path = null - } - }) - - it('reassembles a snapshot file that arrives in short reads', async () => { - const directory = createWorkspace() - const flowPath = join(directory, 'home', 'flow.json') - writeFileSync(join(directory, 'home', 'home.json'), '{}') - writeFileSync(join(directory, 'home', 'parameters.json'), '{}') - writeFileSync( - flowPath, - JSON.stringify({ - steps: [{ name: 'Synthesis', runtime: '1s', state: 'Success', tool: 'yosys' }], - }), - ) - - shortReadPath.path = flowPath - try { - await expect(new WorkspaceSnapshotLoader().load(directory)).resolves.toMatchObject({ - flow: { steps: [{ name: 'Synthesis', state: 'Success', tool: 'yosys' }] }, - }) - } finally { - shortReadPath.path = null - } - }) - - it('rejects a symlinked parameters file instead of reading its target', async () => { - const directory = createWorkspace() - const external = join(directory, 'external.toml') - writeFileSync(external, '[params]\ndesign = "external"\n') - symlinkSync(external, join(directory, 'home', 'params.toml')) - writeFileSync(join(directory, 'home', 'home.json'), '{}') - writeFileSync(join(directory, 'home', 'flow.json'), JSON.stringify({ steps: [] })) - - await expect(new WorkspaceSnapshotLoader().load(directory)).rejects.toThrow( - /symlink/i, - ) - }) - - it('rejects reads redirected by a symlinked home directory', async () => { - const directory = createWorkspace() - const external = mkdtempSync(join(tmpdir(), 'ecos-snapshot-external-')) - temporaryDirectories.push(external) - mkdirSync(join(external, 'home')) - writeFileSync( - join(external, 'home', 'params.toml'), - '[params]\ndesign = "external"\n', - ) - rmSync(join(directory, 'home'), { recursive: true, force: true }) - symlinkSync(join(external, 'home'), join(directory, 'home')) - - await expect(new WorkspaceSnapshotLoader().load(directory)).rejects.toThrow( - /outside the workspace/i, - ) - }) - - it('loads the bounded configuration snapshot used for project baseline sync', async () => { - const directory = createWorkspace() - mkdirSync(join(directory, 'config')) - writeFileSync( - join(directory, 'home', 'parameters.json'), - JSON.stringify({ Design: 'gcd', PDK: 'ics55' }), - ) - writeFileSync( - join(directory, 'home', 'pdk.json'), - JSON.stringify({ tech_lef: ['/pdks/ics55/tech.lef'] }), - ) - writeFileSync( - join(directory, 'config', 'db_ecc.json'), - JSON.stringify({ INPUT: { rtl_list: ['/sources/gcd.sv'] } }), - ) - - await expect( - new WorkspaceSnapshotLoader().loadBaselineSnapshot(directory), - ).resolves.toEqual({ - parameters: { Design: 'gcd', PDK: 'ics55' }, - pdk: { tech_lef: ['/pdks/ics55/tech.lef'] }, - db: { INPUT: { rtl_list: ['/sources/gcd.sv'] } }, - }) - }) -}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.ts deleted file mode 100644 index c32e16b05..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { lstat, open, realpath } from 'node:fs/promises' -import { constants } from 'node:fs' -import { join, sep } from 'node:path' -import type { - EccWorkspaceRuntimeSnapshot, - EccRuntimeStepSnapshot, -} from '@ecos-studio/shared' - -import { migrateWorkspaceConfigFilenames } from './workspaceConfigMigration' -import { - locateWorkspaceParametersFile, - parseWorkspaceParametersText, -} from '../workspaceParametersFile' - -const MAX_SNAPSHOT_FILE_BYTES = 512 * 1024 - -type DetachedWorkspaceSnapshot = Omit - -export interface WorkspaceBaselineSnapshot { - db: Record - parameters: Record - pdk: Record -} - -/** - * Bounded, symlink-refusing file read for snapshot resources: the resolved - * path must stay inside the workspace (a symlinked ancestor like - * `home -> /outside` is rejected), the leaf is opened once with O_NOFOLLOW, - * and the size cap is enforced on the opened handle — so a replacement - * planted between checks and open cannot smuggle in a symlink or an - * oversized file. The parent containment is revalidated after the read, so - * a mid-read swap discards the data instead of returning it. - */ -async function readSnapshotText( - path: string, - workspaceDirectory: string, -): Promise { - const resolvedRoot = await realpath(workspaceDirectory) - const assertContained = async (): Promise => { - const resolvedPath = await realpath(path) - if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(resolvedRoot + sep)) { - throw new Error( - `Refusing to read workspace snapshot resource outside the workspace: ${path}`, - ) - } - } - - const metadata = await lstat(path) - if (metadata.isSymbolicLink()) { - throw new Error( - `Refusing to read workspace snapshot resource through a symlink: ${path}`, - ) - } - await assertContained() - let handle: Awaited> | null = null - try { - handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) - const opened = await handle.stat() - if (opened.size > MAX_SNAPSHOT_FILE_BYTES) { - throw new Error( - `Workspace snapshot resource exceeds ${MAX_SNAPSHOT_FILE_BYTES} bytes: ${path}`, - ) - } - // Bound the actual read, not just the pre-read size: another process - // can append after stat() and handle.readFile() would otherwise consume - // the expanded file into the renderer snapshot. Loop until EOF or the - // cap: a single FileHandle.read() may return a short count. - const buffer = Buffer.allocUnsafe(MAX_SNAPSHOT_FILE_BYTES + 1) - let total = 0 - while (total < buffer.length) { - const { bytesRead } = await handle.read({ - buffer, - length: buffer.length - total, - offset: total, - position: total, - }) - if (bytesRead === 0) break - total += bytesRead - } - if (total > MAX_SNAPSHOT_FILE_BYTES) { - throw new Error( - `Workspace snapshot resource exceeds ${MAX_SNAPSHOT_FILE_BYTES} bytes: ${path}`, - ) - } - const text = buffer.subarray(0, total).toString('utf8') - await assertContained() - return text - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ELOOP') { - throw new Error( - `Refusing to read workspace snapshot resource through a symlink: ${path}`, - ) - } - throw error - } finally { - await handle?.close() - } -} - -async function readJsonObject( - path: string, - workspaceDirectory: string, -): Promise> { - try { - const parsed: unknown = JSON.parse(await readSnapshotText(path, workspaceDirectory)) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {} - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} - throw error - } -} - -/** - * Parameters companion of readJsonObject: same size cap and ENOENT-tolerance, - * but format-aware (home/params.toml preferred, home/parameters.json fallback). - */ -async function readParametersObject(directory: string): Promise> { - const location = await locateWorkspaceParametersFile(directory) - if (!location) return {} - try { - return parseWorkspaceParametersText( - await readSnapshotText(location.path, directory), - location.format, - directory, - ) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {} - throw error - } -} - -function flowStepsFrom(flow: Record): EccRuntimeStepSnapshot[] { - const rawSteps = Array.isArray(flow.steps) ? flow.steps : [] - return rawSteps.flatMap((rawStep) => { - if (!rawStep || typeof rawStep !== 'object' || Array.isArray(rawStep)) return [] - const step = rawStep as Record - if (typeof step.name !== 'string' || typeof step.tool !== 'string') return [] - return [ - { - name: step.name, - peakMemory: - typeof step['peak memory (mb)'] === 'number' ? step['peak memory (mb)'] : 0, - runtime: typeof step.runtime === 'string' ? step.runtime : '', - state: typeof step.state === 'string' ? step.state : 'Unstart', - tool: step.tool, - }, - ] - }) -} - -/** - * A bounded, one-shot read path for an idle workspace. It intentionally reads - * only the three lightweight JSON summaries and never traverses directories, - * watches paths, or transfers logs/artifacts to the renderer. - */ -export class WorkspaceSnapshotLoader { - async load(directory: string): Promise { - await migrateWorkspaceConfigFilenames(directory) - const homeDirectory = join(directory, 'home') - const [home, flow, parameters] = await Promise.all([ - readJsonObject(join(homeDirectory, 'home.json'), directory), - readJsonObject(join(homeDirectory, 'flow.json'), directory), - readParametersObject(directory), - ]) - return { - directory, - flow: { steps: flowStepsFrom(flow) }, - home, - lastEventId: `disk:${Date.now()}`, - operations: [], - parameters, - } - } - - /** - * Reads only the persisted configuration needed to refresh a project - * baseline. The same per-file size limit as idle runtime recovery applies. - */ - async loadBaselineSnapshot(directory: string): Promise { - await migrateWorkspaceConfigFilenames(directory) - const [parameters, pdk, db] = await Promise.all([ - readParametersObject(directory), - readJsonObject(join(directory, 'home', 'pdk.json'), directory), - readJsonObject(join(directory, 'config', 'db_ecc.json'), directory), - ]) - return { db, parameters, pdk } - } -} diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceStepConfigurationCache.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceStepConfigurationCache.test.ts new file mode 100644 index 000000000..dc278046e --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceStepConfigurationCache.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' + +import { WorkspaceStepConfigurationCache } from './workspaceStepConfigurationCache' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, reject, resolve } +} + +describe('WorkspaceStepConfigurationCache', () => { + it('reuses a committed value for the same key', async () => { + const cache = new WorkspaceStepConfigurationCache() + let loads = 0 + + await expect( + cache.load('workspace-1:4:CTS', async () => { + loads += 1 + return 'first' + }), + ).resolves.toBe('first') + await expect( + cache.load('workspace-1:4:CTS', async () => { + loads += 1 + return 'second' + }), + ).resolves.toBe('first') + + expect(loads).toBe(1) + }) + + it('coalesces in-flight loads for the same key', async () => { + const cache = new WorkspaceStepConfigurationCache() + const pending = deferred() + let loads = 0 + + const first = cache.load('workspace-1:4:CTS', () => { + loads += 1 + return pending.promise + }) + const second = cache.load('workspace-1:4:CTS', () => { + loads += 1 + return Promise.resolve('ignored') + }) + pending.resolve('shared') + + await expect(Promise.all([first, second])).resolves.toEqual(['shared', 'shared']) + expect(loads).toBe(1) + }) + + it('loads again after clear', async () => { + const cache = new WorkspaceStepConfigurationCache() + let loads = 0 + await cache.load('workspace-1:4:CTS', async () => { + loads += 1 + return 'first' + }) + cache.clear() + + await expect( + cache.load('workspace-1:4:CTS', async () => { + loads += 1 + return 'second' + }), + ).resolves.toBe('second') + expect(loads).toBe(2) + }) + + it('does not cache a rejected load', async () => { + const cache = new WorkspaceStepConfigurationCache() + await expect( + cache.load('workspace-1:4:CTS', async () => { + throw new Error('unavailable') + }), + ).rejects.toThrow('unavailable') + + await expect(cache.load('workspace-1:4:CTS', async () => 'recovered')).resolves.toBe( + 'recovered', + ) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceStepConfigurationCache.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceStepConfigurationCache.ts new file mode 100644 index 000000000..31c487d0f --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceStepConfigurationCache.ts @@ -0,0 +1,31 @@ +export class WorkspaceStepConfigurationCache { + private readonly inFlight = new Map>() + private readonly values = new Map() + + clear(): void { + this.inFlight.clear() + this.values.clear() + } + + load(key: string, loader: () => Promise): Promise { + const cached = this.values.get(key) + if (cached !== undefined) return Promise.resolve(cached) + const pending = this.inFlight.get(key) + if (pending) return pending + const request = loader().then( + (value) => { + if (this.inFlight.get(key) === request) { + this.inFlight.delete(key) + this.values.set(key, value) + } + return value + }, + (error: unknown) => { + if (this.inFlight.get(key) === request) this.inFlight.delete(key) + throw error + }, + ) + this.inFlight.set(key, request) + return request + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntime.test.ts b/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntime.test.ts index 684f2ce9b..9703a0ffa 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntime.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntime.test.ts @@ -180,12 +180,13 @@ describe('normalizeFrontendRuntimeEvent', () => { kind: 'flow', operationId: 'frontend-op-1', origin: 'gui', - type: 'step.completed', + type: 'workspace.committed', workspaceId: 'workspace-frontend-1', }) expect(normalized.event.payload).toMatchObject({ state: 'Success', step: 'prepare', + sourceType: 'step.completed', tool: 'fe', }) }) @@ -213,11 +214,12 @@ describe('normalizeFrontendRuntimeEvent', () => { expect(normalized.event).toMatchObject({ kind: 'flow', operationId: 'frontend-op-2', - type: 'subflow.stage', + type: 'execution.progress', }) expect(normalized.event.payload).toMatchObject({ state: 'Success', step: 'prepare', + sourceType: 'subflow.stage', subflowPeakMemory: 12.5, subflowRuntime: '0:0:1', subflowStep: 'collect inputs', diff --git a/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntime.ts b/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntime.ts index 8fce68183..6d0728226 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntime.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntime.ts @@ -128,6 +128,7 @@ export function normalizeFrontendRuntimeEvent(event: EccRuntimeEvent): EccRuntim const sequence = ++legacyFrontendProtocolSequence const payload: Record = { ...data, + sourceType: protocolType, ...(step ? { step } : {}), ...(state ? { state } : {}), ...(subflowStep ? { subflowStep } : {}), @@ -144,7 +145,8 @@ export function normalizeFrontendRuntimeEvent(event: EccRuntimeEvent): EccRuntim payload, sequence, timestamp: Date.now(), - type: protocolType, + type: + protocolType === 'step.completed' ? 'workspace.committed' : 'execution.progress', workspaceId: workspaceHandle, }, type: 'runtime.protocol', diff --git a/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntimeService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntimeService.test.ts index 0461e7ae7..412350c66 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntimeService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntimeService.test.ts @@ -19,8 +19,7 @@ function createRuntime() { directory: '/work/frontend', workspaceHandle: 'handle-1', }), - rpcPing: vi.fn().mockResolvedValue({ ok: true }), - rpcShutdown: vi.fn().mockResolvedValue({ ok: true }), + shutdown: vi.fn().mockResolvedValue({ ok: true }), runFlow: vi.fn().mockResolvedValue({ rerun: false }), runStepPayload: vi.fn().mockResolvedValue({ state: 'Success', step: 'sim' }), workspaceHome: vi.fn().mockResolvedValue({ path: '/work/frontend/home/home.json' }), @@ -55,6 +54,21 @@ describe('FrontendRpcRuntimeService', () => { expect(service.isWorkspaceRuntimeActive('/work/frontend')).toBe(true) }) + it('preserves frontend management calls', async () => { + const runtime = createRuntime() + const service = new FrontendRpcRuntimeService({ + runtime: runtime as unknown as EccRpcRuntimeService, + }) + + await service.rpcHello() + await service.rpcPing() + await service.rpcShutdown() + + expect(runtime.callRuntime).toHaveBeenNthCalledWith(1, 'rpc.hello', { version: 1 }) + expect(runtime.callRuntime).toHaveBeenNthCalledWith(2, 'rpc.ping') + expect(runtime.shutdown).toHaveBeenCalledOnce() + }) + it('normalizes frontend progress before exposing the runtime event stream', () => { const runtime = createRuntime() const service = new FrontendRpcRuntimeService({ diff --git a/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntimeService.ts b/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntimeService.ts index bba5aa0d2..62e7338bb 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntimeService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/frontendRpcRuntimeService.ts @@ -1,9 +1,9 @@ import type { EccFlowRunResult, EccFlowRunStepResult, - EccRpcHelloResult, - EccRpcPingResult, - EccRpcShutdownResult, + DesignRuntimeHelloResult, + DesignRuntimePingResult, + DesignRuntimeShutdownResult, EccRuntimeEvent, EccWorkspaceCloseResult, EccWorkspaceCreateResult, @@ -14,9 +14,7 @@ import type { import { EccRpcRuntimeService } from './eccRpc/runtimeService' import { normalizeFrontendRuntimeEvent } from './frontendRpcRuntime' -export interface FrontendRpcHelloResult extends Omit { - eccFeVersion: string -} +export type FrontendRpcHelloResult = DesignRuntimeHelloResult & { eccFeVersion: string } export interface FrontendRpcRuntimeServiceOptions { runtime: EccRpcRuntimeService @@ -45,12 +43,12 @@ export class FrontendRpcRuntimeService { return this.runtime.callRuntime('rpc.hello', { version: 1 }) } - rpcPing(): Promise { - return this.runtime.rpcPing() + rpcPing(): Promise { + return this.runtime.callRuntime('rpc.ping') } - rpcShutdown(): Promise { - return this.runtime.rpcShutdown() + rpcShutdown(): Promise { + return this.runtime.shutdown() } cancelOperationLegacy( @@ -97,21 +95,6 @@ export class FrontendRpcRuntimeService { return this.runtime.refreshConfig({ workspaceHandle }) } - async syncConfig(workspaceHandle: string, configPath: string) { - const result = (await this.runtime.syncConfig({ - configPath, - workspaceHandle, - })) as unknown as Record - return { - configPath: String(result.configPath ?? result.config_path ?? configPath), - directory: String(result.directory ?? ''), - parametersChanged: Boolean( - result.parametersChanged ?? result.parameters_changed ?? false, - ), - refreshed: Boolean(result.refreshed), - } - } - resetFlow(workspaceHandle: string) { return this.runtime.resetFlow({ workspaceHandle }) } diff --git a/ecos/gui/apps/desktop-electron/electron/services/hdlModuleDiscovery.test.ts b/ecos/gui/apps/desktop-electron/electron/services/hdlModuleDiscovery.test.ts new file mode 100644 index 000000000..2324a65a6 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/hdlModuleDiscovery.test.ts @@ -0,0 +1,272 @@ +import { gzipSync } from 'node:zlib' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it } from 'vitest' +import { + DEFAULT_HDL_MODULE_DISCOVERY_BOUNDS, + discoverHdlModules, +} from './hdlModuleDiscovery' + +describe('discoverHdlModules', () => { + let tempRoot = '' + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }) + tempRoot = '' + } + }) + + it('collects unique module and macromodule names from RTL, including ifdef branches', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-')) + const top = join(tempRoot, 'top.v') + const child = join(tempRoot, 'child.sv') + await writeFile( + top, + ` + (* keep *) module gcd ( + input clk + ); + Foo u_foo ( + .clk(clk) + ); + endmodule + \`ifdef USE_ALT + macromodule AltTop; + endmacromodule + \`endif + // module CommentedOut + /* module BlockedOut */ + interface bus_if; + endinterface + package pkg; + endpackage + `, + ) + await writeFile( + child, + ` + module Foo; + endmodule + module gcd; + endmodule + `, + ) + + const result = await discoverHdlModules({ + rtlPaths: [top, child], + designName: 'gcd', + }) + + expect(result.status).toBe('complete') + expect(result.candidates).toEqual(['gcd', 'AltTop', 'Foo']) + expect(result.suggested).toBe('gcd') + expect(result.reason).toBeUndefined() + }) + + it('decompresses gzip HDL before parsing', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-gz-')) + const gzPath = join(tempRoot, 'top.v.gz') + await writeFile(gzPath, gzipSync(Buffer.from('module GzipTop;\nendmodule\n'))) + + const result = await discoverHdlModules({ + rtlPaths: [gzPath], + designName: 'GzipTop', + }) + + expect(result.status).toBe('complete') + expect(result.candidates).toEqual(['GzipTop']) + expect(result.suggested).toBe('GzipTop') + }) + + it('expands filelist HDL paths without nested -f -v -y', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-f-')) + const nested = join(tempRoot, 'nested.f') + const listed = join(tempRoot, 'listed.v') + const ignoredNested = join(tempRoot, 'ignored.v') + const filelist = join(tempRoot, 'sources.f') + await writeFile(ignoredNested, 'module NestedTop;\nendmodule\n') + await writeFile(listed, 'module ListedTop;\nendmodule\n') + await writeFile(nested, `${ignoredNested}\n`) + await writeFile( + filelist, + ` + ${listed} + -f ${nested} + -v /lib/cells.v + -y /lib + `, + ) + + const result = await discoverHdlModules({ + filelistPath: filelist, + designName: 'ListedTop', + }) + + expect(result.status).toBe('complete') + expect(result.candidates).toEqual(['ListedTop']) + }) + + it('treats parameterized instances as instantiations of discovered modules', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-param-')) + const rtl = join(tempRoot, 'design.v') + await writeFile( + rtl, + ` + module child; + endmodule + module parent; + child #( + .W(8) + ) u_child ( + .clk(clk) + ); + endmodule + `, + ) + + const result = await discoverHdlModules({ + rtlPaths: [rtl], + designName: 'other', + }) + + expect(result.suggested).toBe('parent') + }) + + it('ranks an uninstantiated discovered module over an instantiated child', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-rank-')) + const top = join(tempRoot, 'design.v') + await writeFile( + top, + ` + module child; + endmodule + module parent; + child u_child ( + .clk(clk) + ); + endmodule + `, + ) + + const result = await discoverHdlModules({ + rtlPaths: [top], + designName: 'other', + }) + + expect(result.status).toBe('complete') + expect(result.candidates).toEqual(['child', 'parent']) + expect(result.suggested).toBe('parent') + }) + + it('keeps source workspace top when it is still present', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-source-')) + const netlist = join(tempRoot, 'netlist.v') + await writeFile( + netlist, + ` + module gcd_top; + endmodule + module leftover; + endmodule + `, + ) + + const result = await discoverHdlModules({ + originVerilogPath: netlist, + designName: 'leftover', + sourceTopModule: 'gcd_top', + manifestTopModule: 'leftover', + }) + + expect(result.suggested).toBe('gcd_top') + }) + + it('matches heuristic identifiers case-sensitively', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-case-')) + const rtl = join(tempRoot, 'top.v') + await writeFile(rtl, 'module Gcd;\nendmodule\nmodule other;\nendmodule\n') + + const result = await discoverHdlModules({ + rtlPaths: [rtl], + designName: 'gcd', + manifestTopModule: 'gcd', + }) + + expect(result.candidates).toEqual(['Gcd', 'other']) + expect(result.suggested).toBe('Gcd') + }) + + it('fails closed on partial read failure without a truncated list', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-partial-')) + const readable = join(tempRoot, 'ok.v') + const missing = join(tempRoot, 'missing.v') + await writeFile(readable, 'module ok;\nendmodule\n') + + const result = await discoverHdlModules({ + rtlPaths: [readable, missing], + designName: 'ok', + }) + + expect(result).toMatchObject({ + status: 'partial_read_failure', + candidates: [], + suggested: '', + }) + expect(result.reason).toMatch(/could not be read/i) + }) + + it('allows incomplete discovery when a bound is hit instead of returning a truncated list', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-bound-')) + const first = join(tempRoot, 'a.v') + const second = join(tempRoot, 'b.v') + await writeFile(first, 'module first;\nendmodule\n') + await writeFile(second, 'module second;\nendmodule\n') + + const result = await discoverHdlModules( + { + rtlPaths: [first, second], + designName: 'first', + }, + { ...DEFAULT_HDL_MODULE_DISCOVERY_BOUNDS, maxFiles: 1 }, + ) + + expect(result.status).toBe('incomplete') + expect(result.candidates).toEqual([]) + expect(result.suggested).toBe('') + expect(result.reason).toMatch(/too large|did not finish|bound/i) + }) + + it('rejects mixed RTL and filelist discovery sets', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-mixed-')) + const rtl = join(tempRoot, 'top.v') + const filelist = join(tempRoot, 'sources.f') + await writeFile(rtl, 'module MixedTop;\nendmodule\n') + await writeFile(filelist, `${rtl}\n`) + + const result = await discoverHdlModules({ + rtlPaths: [rtl], + filelistPath: filelist, + designName: 'MixedTop', + }) + + expect(result.status).toBe('incomplete') + expect(result.candidates).toEqual([]) + expect(result.reason).toMatch(/exactly one/i) + }) + + it('reports total read failure when every HDL path is unreadable', async () => { + tempRoot = await mkdtemp(join(tmpdir(), 'hdl-discover-all-')) + const missing = join(tempRoot, 'gone.v') + + const result = await discoverHdlModules({ + rtlPaths: [missing], + designName: 'gone', + }) + + expect(result.status).toBe('total_read_failure') + expect(result.candidates).toEqual([]) + expect(result.reason).toMatch(/unreadable/i) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/hdlModuleDiscovery.ts b/ecos/gui/apps/desktop-electron/electron/services/hdlModuleDiscovery.ts new file mode 100644 index 000000000..d3cfbb2ca --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/hdlModuleDiscovery.ts @@ -0,0 +1,424 @@ +import { gunzipSync } from 'node:zlib' +import { readFile, stat } from 'node:fs/promises' +import { basename, dirname } from 'node:path' +import { + isHdlFilePath, + normalizeLocalPath, + type HdlModuleDiscoveryRequest, + type HdlModuleDiscoveryResult, +} from '@ecos-studio/shared' +import { parseFilelistContent, resolveFilelistPath } from './designFilelist' + +export const DEFAULT_HDL_MODULE_DISCOVERY_BOUNDS = { + maxFiles: 256, + maxFileBytes: 8 * 1024 * 1024, + maxTotalBytes: 32 * 1024 * 1024, + timeoutMs: 8_000, +} + +export type HdlModuleDiscoveryBounds = typeof DEFAULT_HDL_MODULE_DISCOVERY_BOUNDS + +const MODULE_DECLARATION = + /(?:\(\*[\s\S]*?\*\))?\s*\b(?:macromodule|module)\s+([A-Za-z_][A-Za-z0-9_$]*)\b/g +const END_MODULE = /\b(?:endmodule|endmacromodule)\b/g +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/ +const TESTBENCH_NAME = /(?:^tb_)|(?:_tb$)|(?:_test$)/i + +export async function discoverHdlModules( + request: HdlModuleDiscoveryRequest, + bounds: HdlModuleDiscoveryBounds = DEFAULT_HDL_MODULE_DISCOVERY_BOUNDS, +): Promise { + const startedAt = Date.now() + const sourcePaths = await resolveDiscoveryPaths(request, bounds) + if (sourcePaths.status !== 'ok') return sourcePaths.result + if (sourcePaths.paths.length === 0) { + return { status: 'complete', candidates: [], suggested: '' } + } + if (sourcePaths.paths.length > bounds.maxFiles) { + return incompleteResult('The selected HDL set is too large to scan completely.') + } + + const texts: { path: string; text: string }[] = [] + let readFailures = 0 + let totalBytes = 0 + + for (const path of sourcePaths.paths) { + if (Date.now() - startedAt > bounds.timeoutMs) { + return incompleteResult('HDL discovery did not finish before the time limit.') + } + const read = await readDiscoveryFile(path, bounds, totalBytes) + if (read.kind === 'incomplete') return incompleteResult(read.reason) + if (read.kind === 'failure') { + readFailures += 1 + continue + } + totalBytes += read.bytes + texts.push({ path, text: read.text }) + } + + if (sourcePaths.paths.length > 0 && readFailures === sourcePaths.paths.length) { + return { + status: 'total_read_failure', + candidates: [], + suggested: '', + reason: 'Every selected HDL path is unreadable.', + } + } + if (readFailures > 0) { + return { + status: 'partial_read_failure', + candidates: [], + suggested: '', + reason: 'One or more selected HDL files could not be read.', + } + } + + const modules = collectDeclaredModules(texts.map((entry) => entry.text).join('\n')) + const candidates = [...new Set(modules.names)] + const suggested = rankSuggestedModule({ + candidates, + designName: request.designName?.trim() ?? '', + manifestTopModule: request.manifestTopModule?.trim() || undefined, + sourceTopModule: request.sourceTopModule?.trim() || undefined, + instantiated: modules.instantiated, + fileStems: sourcePaths.paths.map(hdlFileStem), + }) + return { + status: 'complete', + candidates, + suggested, + } +} + +async function resolveDiscoveryPaths( + request: HdlModuleDiscoveryRequest, + bounds: HdlModuleDiscoveryBounds, +): Promise< + | { status: 'ok'; paths: string[] } + | { status: 'error'; result: HdlModuleDiscoveryResult } +> { + const filelistPath = request.filelistPath?.trim() ?? '' + const originVerilogPath = request.originVerilogPath?.trim() ?? '' + const rtlPaths = (request.rtlPaths ?? []) + .map((path) => path.trim()) + .filter(Boolean) + .map((path) => normalizeLocalPath(path)) + if ( + (filelistPath && originVerilogPath) || + (filelistPath && rtlPaths.length > 0) || + (originVerilogPath && rtlPaths.length > 0) + ) { + return { + status: 'error', + result: { + status: 'incomplete', + candidates: [], + suggested: '', + reason: 'HDL discovery requires exactly one of RTL, filelist, or origin Verilog.', + }, + } + } + if (filelistPath) { + const expanded = await expandFilelistHdlPaths(filelistPath, bounds) + if (expanded.status !== 'ok') return expanded + return { status: 'ok', paths: uniquePaths(expanded.paths) } + } + if (originVerilogPath) { + return { status: 'ok', paths: [normalizeLocalPath(originVerilogPath)] } + } + return { status: 'ok', paths: uniquePaths(rtlPaths) } +} + +async function expandFilelistHdlPaths( + filelistPath: string, + bounds: HdlModuleDiscoveryBounds, +): Promise< + | { status: 'ok'; paths: string[] } + | { status: 'error'; result: HdlModuleDiscoveryResult } +> { + const read = await readDiscoveryFile(filelistPath, bounds, 0) + if (read.kind === 'incomplete') { + return { status: 'error', result: incompleteResult(read.reason) } + } + if (read.kind === 'failure') { + return { + status: 'error', + result: { + status: 'total_read_failure', + candidates: [], + suggested: '', + reason: 'Every selected HDL path is unreadable.', + }, + } + } + + const filelistDir = dirname(filelistPath) + const paths: string[] = [] + for (const line of parseFilelistContent(read.text)) { + if (line.kind !== 'file') continue + const resolved = resolveFilelistPath(line.path, filelistDir) + if (!isHdlFilePath(resolved)) continue + paths.push(normalizeLocalPath(resolved)) + } + return { status: 'ok', paths } +} + +async function readDiscoveryFile( + path: string, + bounds: HdlModuleDiscoveryBounds, + totalBytes: number, +): Promise< + | { kind: 'ok'; text: string; bytes: number } + | { kind: 'failure' } + | { kind: 'incomplete'; reason: string } +> { + try { + const fileStat = await stat(path) + if (fileStat.size > bounds.maxFileBytes) { + return { + kind: 'incomplete', + reason: 'The selected HDL set is too large to scan completely.', + } + } + if (totalBytes + fileStat.size > bounds.maxTotalBytes) { + return { + kind: 'incomplete', + reason: 'The selected HDL set is too large to scan completely.', + } + } + const bytes = await readFile(path) + const text = decodeHdlBytes(path, bytes) + if (text === null) return { kind: 'failure' } + if (Buffer.byteLength(text) > bounds.maxFileBytes) { + return { + kind: 'incomplete', + reason: 'The selected HDL set is too large to scan completely.', + } + } + return { kind: 'ok', text, bytes: Math.max(fileStat.size, Buffer.byteLength(text)) } + } catch { + return { kind: 'failure' } + } +} + +function decodeHdlBytes(path: string, bytes: Buffer): string | null { + try { + const raw = path.toLowerCase().endsWith('.gz') ? gunzipSync(bytes) : bytes + return raw.toString('utf8') + } catch { + return null + } +} + +function collectDeclaredModules(source: string): { + names: string[] + instantiated: Set +} { + const stripped = stripVerilogComments(source) + const names: string[] = [] + const bodies = new Map() + let match: RegExpExecArray | null + MODULE_DECLARATION.lastIndex = 0 + while ((match = MODULE_DECLARATION.exec(stripped))) { + const name = match[1] + if (!name || !IDENTIFIER.test(name)) continue + names.push(name) + const bodyStart = MODULE_DECLARATION.lastIndex + END_MODULE.lastIndex = bodyStart + const endMatch = END_MODULE.exec(stripped) + const bodyEnd = endMatch ? endMatch.index : stripped.length + const body = stripped.slice(bodyStart, bodyEnd) + const existing = bodies.get(name) + if (existing) existing.push(body) + else bodies.set(name, [body]) + MODULE_DECLARATION.lastIndex = bodyEnd + } + + const uniqueNames = [...new Set(names)] + const instantiated = new Set() + for (const [owner, ownerBodies] of bodies) { + for (const candidate of uniqueNames) { + if (candidate === owner || instantiated.has(candidate)) continue + if (ownerBodies.some((body) => bodyInstantiates(body, candidate))) { + instantiated.add(candidate) + } + } + } + return { names, instantiated } +} + +function bodyInstantiates(body: string, moduleName: string): boolean { + const escaped = moduleName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const direct = new RegExp(String.raw`\b${escaped}\s+[A-Za-z_][A-Za-z0-9_$]*\s*\(`) + if (direct.test(body)) return true + + const parameterized = new RegExp(String.raw`\b${escaped}\s*#\s*\(`, 'g') + let match: RegExpExecArray | null + while ((match = parameterized.exec(body))) { + const afterParams = skipBalancedParens(body, match.index + match[0].length - 1) + if (afterParams < 0) continue + if (/^\s*[A-Za-z_][A-Za-z0-9_$]*\s*\(/.test(body.slice(afterParams))) return true + } + return false +} + +function skipBalancedParens(source: string, openIndex: number): number { + if (source[openIndex] !== '(') return -1 + let depth = 0 + for (let index = openIndex; index < source.length; index += 1) { + const char = source[index] + if (char === '(') depth += 1 + else if (char === ')') { + depth -= 1 + if (depth === 0) return index + 1 + } + } + return -1 +} + +function rankSuggestedModule(input: { + candidates: string[] + designName: string + manifestTopModule?: string + sourceTopModule?: string + instantiated: Set + fileStems: string[] +}): string { + const candidates = input.candidates + if (candidates.length === 0) return '' + const present = new Set(candidates) + const ranked = [input.sourceTopModule, input.manifestTopModule, input.designName] + for (const name of ranked) { + if (name && present.has(name)) return name + } + + const uninstantiated = candidates.filter((name) => !input.instantiated.has(name)) + if (uninstantiated.length === 1) return uninstantiated[0] + + const closenessPool = uninstantiated.filter((name) => !TESTBENCH_NAME.test(name)) + const closest = closestIdentifier( + closenessPool.length > 0 ? closenessPool : uninstantiated, + input.designName, + input.fileStems, + ) + if (closest) return closest + if (uninstantiated.length > 0) return uninstantiated[0] + return candidates[0] +} + +function closestIdentifier( + names: string[], + designName: string, + fileStems: string[], +): string | undefined { + if (names.length === 0) return undefined + let bestName = names[0] + let bestScore = Number.NEGATIVE_INFINITY + for (const name of names) { + const score = identifierCloseness(name, designName, fileStems) + if (score > bestScore) { + bestScore = score + bestName = name + } + } + return bestName +} + +function identifierCloseness( + name: string, + designName: string, + fileStems: string[], +): number { + const lower = name.toLowerCase() + const design = designName.toLowerCase() + if (design && lower === design) return 1_000 + if (fileStems.some((stem) => stem.toLowerCase() === lower)) return 900 + if (design && (lower.startsWith(design) || design.startsWith(lower))) { + return 800 - Math.abs(lower.length - design.length) + } + const distances = [ + design ? levenshtein(lower, design) : Number.POSITIVE_INFINITY, + ...fileStems.map((stem) => levenshtein(lower, stem.toLowerCase())), + ] + const distance = Math.min(...distances) + return Number.isFinite(distance) ? -distance : Number.NEGATIVE_INFINITY +} + +function levenshtein(left: string, right: string): number { + if (left === right) return 0 + if (!left) return right.length + if (!right) return left.length + const previous = Array.from({ length: right.length + 1 }, (_, index) => index) + for (let i = 1; i <= left.length; i += 1) { + let lastDiagonal = previous[0] + previous[0] = i + for (let j = 1; j <= right.length; j += 1) { + const nextDiagonal = previous[j] + const cost = left[i - 1] === right[j - 1] ? 0 : 1 + previous[j] = Math.min(previous[j] + 1, previous[j - 1] + 1, lastDiagonal + cost) + lastDiagonal = nextDiagonal + } + } + return previous[right.length] +} + +function stripVerilogComments(source: string): string { + let output = '' + for (let index = 0; index < source.length; ) { + const current = source[index] + const next = source[index + 1] + if (current === '/' && next === '/') { + index += 2 + while (index < source.length && source[index] !== '\n') index += 1 + continue + } + if (current === '/' && next === '*') { + const end = source.indexOf('*/', index + 2) + if (end < 0) break + output += ' ' + index = end + 2 + continue + } + if (current === '"') { + output += current + index += 1 + while (index < source.length) { + output += source[index] + if (source[index] === '\\' && index + 1 < source.length) { + output += source[index + 1] + index += 2 + continue + } + if (source[index] === '"') { + index += 1 + break + } + index += 1 + } + continue + } + output += current + index += 1 + } + return output +} + +function hdlFileStem(path: string): string { + const base = basename(path) + const withoutGz = base.toLowerCase().endsWith('.gz') ? base.slice(0, -3) : base + const extensionStart = withoutGz.lastIndexOf('.') + return extensionStart > 0 ? withoutGz.slice(0, extensionStart) : withoutGz +} + +function uniquePaths(paths: string[]): string[] { + return [...new Set(paths)] +} + +function incompleteResult(reason: string): HdlModuleDiscoveryResult { + return { + status: 'incomplete', + candidates: [], + suggested: '', + reason, + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/logTailService.ts b/ecos/gui/apps/desktop-electron/electron/services/logTailService.ts deleted file mode 100644 index 6e490a95c..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/logTailService.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { stat } from 'node:fs/promises' -import { watch, type FSWatcher } from 'chokidar' -import { dirname, isAbsolute, join, relative } from 'node:path' -import type { - DesktopProjectLogTailEvent, - DesktopProjectLogTailSubscriptionOptions, - DesktopProjectTextFileUpdate, -} from '@ecos-studio/shared' -import type { ProjectScopeProvider } from './workspaceService' -import { isPathWithinRoot, isSameOrAncestorPath } from './pathScope' - -export interface LogTailTextReader { - readOptionalProjectTextFileUpdate( - path: string, - fromOffsetBytes: number, - maxChars: number, - ): Promise -} - -export interface LogTailServiceOptions { - projectScopeProvider: ProjectScopeProvider - textReader: LogTailTextReader -} - -const DEFAULT_MAX_INITIAL_CHARS = 192 * 1024 -const DEFAULT_MAX_CHUNK_CHARS = 192 * 1024 -const DEFAULT_RETRY_DELAY_MS = 1200 -const MIN_RETRY_DELAY_MS = 250 -const MAX_RETRY_DELAY_MS = 8000 -const SYNC_DEBOUNCE_DELAY_MS = 80 - -function boundedTextCharCount(maxChars: number): number { - return Math.max(1, Math.min(Math.floor(maxChars), 2 * 1024 * 1024)) -} - -function boundedRetryDelayMs(delayMs: number): number { - return Math.max(MIN_RETRY_DELAY_MS, Math.min(Math.floor(delayMs), MAX_RETRY_DELAY_MS)) -} - -function isNodeErrorWithCode(error: unknown, code: string): boolean { - return ( - typeof error === 'object' && error !== null && 'code' in error && error.code === code - ) -} - -function isSamePath(path: string, otherPath: string): boolean { - return relative(path, otherPath) === '' -} - -function shouldIgnoreWatchPath(path: string, targetPath: string): boolean { - return !isSameOrAncestorPath(path, targetPath) -} - -async function findProjectFileWatchDirectory( - path: string, - rootPath: string, -): Promise { - let candidate = dirname(path) - - while (candidate && isPathWithinRoot(candidate, rootPath)) { - try { - const candidateStats = await stat(candidate) - if (candidateStats.isDirectory()) return candidate - } catch (error) { - if (!isNodeErrorWithCode(error, 'ENOENT')) { - throw error - } - } - - candidate = dirname(candidate) - } - - return rootPath -} - -interface LogTailSubscriptionState { - subscriptionId: string - canonicalPath: string - watchDirectory: string - listener: (event: DesktopProjectLogTailEvent) => void - maxInitialChars: number - maxChunkChars: number - baseRetryDelayMs: number - retryDelayMs: number - hasSnapshot: boolean - wasMissing: boolean - currentOffsetBytes: number - currentSizeBytes: number - closed: boolean - watcher: FSWatcher | null - syncTimer: ReturnType | null - retryTimer: ReturnType | null - syncInFlight: boolean - syncQueued: boolean -} - -export class LogTailService { - private readonly projectScopeProvider: ProjectScopeProvider - private readonly textReader: LogTailTextReader - private readonly subscriptions = new Map() - private nextSubscriptionId = 1 - private readonly emitEvent = this.emit.bind(this) - - constructor(options: LogTailServiceOptions) { - this.projectScopeProvider = options.projectScopeProvider - this.textReader = options.textReader - } - - async subscribeProjectLogTail( - path: string, - options: DesktopProjectLogTailSubscriptionOptions = {}, - listener: (event: DesktopProjectLogTailEvent) => void, - ): Promise { - const canonicalPath = await this.projectScopeProvider.requestProjectPathAccess(path) - const projectRoot = await this.projectScopeProvider.getProjectRoot() - const watchDirectory = await findProjectFileWatchDirectory(canonicalPath, projectRoot) - const subscriptionId = `project-log-tail-${this.nextSubscriptionId++}` - const state: LogTailSubscriptionState = { - subscriptionId, - canonicalPath, - watchDirectory, - listener, - maxInitialChars: boundedTextCharCount( - options.maxInitialChars ?? DEFAULT_MAX_INITIAL_CHARS, - ), - maxChunkChars: boundedTextCharCount( - options.maxChunkChars ?? DEFAULT_MAX_CHUNK_CHARS, - ), - baseRetryDelayMs: boundedRetryDelayMs( - options.pollIntervalMs ?? DEFAULT_RETRY_DELAY_MS, - ), - retryDelayMs: boundedRetryDelayMs(options.pollIntervalMs ?? DEFAULT_RETRY_DELAY_MS), - hasSnapshot: false, - wasMissing: false, - currentOffsetBytes: 0, - currentSizeBytes: 0, - closed: false, - watcher: null, - syncTimer: null, - retryTimer: null, - syncInFlight: false, - syncQueued: false, - } - - this.subscriptions.set(subscriptionId, state) - this.startWatcher(state) - void this.scheduleSync(state, 0) - return subscriptionId - } - - async unsubscribeProjectLogTail(subscriptionId: string): Promise { - const state = this.subscriptions.get(subscriptionId) - if (!state) return - await this.closeSubscription(state, 'unsubscribed') - this.subscriptions.delete(subscriptionId) - } - - async clearProjectRoot(): Promise { - await Promise.all( - [...this.subscriptions.values()].map(async (state) => { - await this.closeSubscription(state, 'project-root-cleared') - }), - ) - this.subscriptions.clear() - } - - private emit(state: LogTailSubscriptionState, event: DesktopProjectLogTailEvent): void { - if (state.closed) return - state.listener(event) - } - - private clearSyncTimer(state: LogTailSubscriptionState): void { - if (state.syncTimer === null) return - clearTimeout(state.syncTimer) - state.syncTimer = null - } - - private clearRetryTimer(state: LogTailSubscriptionState): void { - if (state.retryTimer === null) return - clearTimeout(state.retryTimer) - state.retryTimer = null - } - - private scheduleRetry(state: LogTailSubscriptionState): void { - if (state.closed) return - this.clearRetryTimer(state) - const delay = state.retryDelayMs - state.retryDelayMs = Math.min(state.retryDelayMs * 2, MAX_RETRY_DELAY_MS) - state.retryTimer = setTimeout(() => { - state.retryTimer = null - void this.scheduleSync(state, 0) - }, delay) - } - - private scheduleSync( - state: LogTailSubscriptionState, - delayMs = SYNC_DEBOUNCE_DELAY_MS, - ): void { - if (state.closed) return - this.clearRetryTimer(state) - if (state.syncInFlight) { - state.syncQueued = true - return - } - if (state.syncTimer !== null) return - state.syncTimer = setTimeout(() => { - state.syncTimer = null - void this.performSync(state) - }, delayMs) - } - - private startWatcher(state: LogTailSubscriptionState): void { - const watcher = watch(state.watchDirectory, { - ignored: (path) => shouldIgnoreWatchPath(path, state.canonicalPath), - ignoreInitial: true, - persistent: false, - }) - state.watcher = watcher - - watcher.on('all', (eventType, changedPath) => { - if ( - eventType !== 'add' && - eventType !== 'addDir' && - eventType !== 'change' && - eventType !== 'unlink' && - eventType !== 'unlinkDir' - ) { - return - } - if (!isSameOrAncestorPath(changedPath, state.canonicalPath)) return - this.scheduleSync(state) - }) - - watcher.on('raw', (rawEventType, rawPath, details) => { - if (rawEventType !== 'change' && rawEventType !== 'rename') return - if (typeof rawPath !== 'string' || !rawPath) return - const watchedPath = - typeof details === 'object' && - details !== null && - 'watchedPath' in details && - typeof details.watchedPath === 'string' - ? details.watchedPath - : state.watchDirectory - const changedPath = isAbsolute(rawPath) ? rawPath : join(watchedPath, rawPath) - if (!isSamePath(changedPath, state.canonicalPath)) return - this.scheduleSync(state) - }) - - watcher.on('error', (error) => { - if (state.closed) return - this.emit(state, { - subscriptionId: state.subscriptionId, - path: state.canonicalPath, - eventType: 'error', - reason: error instanceof Error ? error.message : String(error), - }) - this.scheduleRetry(state) - }) - } - - private async performSync(state: LogTailSubscriptionState): Promise { - if (state.closed) return - - if (state.syncInFlight) { - state.syncQueued = true - return - } - - state.syncInFlight = true - try { - const maxChars = state.hasSnapshot ? state.maxChunkChars : state.maxInitialChars - const update = await this.textReader.readOptionalProjectTextFileUpdate( - state.canonicalPath, - state.currentOffsetBytes, - maxChars, - ) - - if (state.closed) return - - if (update === null) { - if (!state.wasMissing) { - this.emitEvent(state, { - subscriptionId: state.subscriptionId, - path: state.canonicalPath, - eventType: 'waiting', - reason: 'missing', - }) - } - state.wasMissing = true - this.scheduleRetry(state) - return - } - - const isInitialSnapshot = !state.hasSnapshot - const wasMissing = state.wasMissing - const isReset = !isInitialSnapshot && (update.reset || wasMissing) - const eventType = isInitialSnapshot ? 'snapshot' : isReset ? 'reset' : 'append' - const shouldSkip = - eventType === 'append' && - update.content.length === 0 && - update.nextOffsetBytes === state.currentOffsetBytes && - update.sizeBytes === state.currentSizeBytes - - if (shouldSkip) { - state.wasMissing = false - state.retryDelayMs = state.baseRetryDelayMs - this.clearRetryTimer(state) - return - } - - this.emitEvent(state, { - subscriptionId: state.subscriptionId, - path: state.canonicalPath, - eventType, - content: update.content, - fromOffsetBytes: update.fromOffsetBytes, - nextOffsetBytes: update.nextOffsetBytes, - sizeBytes: update.sizeBytes, - reset: update.reset || isReset, - truncated: update.truncated, - }) - - state.hasSnapshot = true - state.wasMissing = false - state.currentOffsetBytes = update.nextOffsetBytes - state.currentSizeBytes = update.sizeBytes - state.retryDelayMs = state.baseRetryDelayMs - this.clearRetryTimer(state) - } catch (error) { - if (state.closed) return - this.emitEvent(state, { - subscriptionId: state.subscriptionId, - path: state.canonicalPath, - eventType: 'error', - reason: error instanceof Error ? error.message : String(error), - }) - this.scheduleRetry(state) - } finally { - state.syncInFlight = false - if (state.syncQueued && !state.closed) { - state.syncQueued = false - this.scheduleSync(state, 0) - } - } - } - - private async closeSubscription( - state: LogTailSubscriptionState, - reason: 'unsubscribed' | 'project-root-cleared', - ): Promise { - if (state.closed) return - state.closed = true - this.clearSyncTimer(state) - this.clearRetryTimer(state) - this.emitEvent(state, { - subscriptionId: state.subscriptionId, - path: state.canonicalPath, - eventType: 'closed', - reason, - }) - const watcher = state.watcher - state.watcher = null - if (watcher) { - await watcher.close() - } - } -} diff --git a/ecos/gui/apps/desktop-electron/electron/services/logger.test.ts b/ecos/gui/apps/desktop-electron/electron/services/logger.test.ts index 8acbb22e1..70499407e 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/logger.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/logger.test.ts @@ -152,6 +152,56 @@ describe('createElectronLogger', () => { ) }) + it('keeps file logging when the terminal write fails with EIO or EPIPE', () => { + const fileSink = vi.fn() + const broken = Object.assign(new Error('write EIO'), { code: 'EIO' }) + const consoleSink = { + debug: vi.fn(), + error: vi.fn(() => { + throw broken + }), + info: vi.fn(), + warn: vi.fn(), + } + const logger = createElectronLogger({ + consoleSink, + env: { ECOS_ELECTRON_LOG_LEVEL: 'error' }, + fileSink, + isTty: true, + now: () => new Date('2026-05-12T08:36:17.209Z'), + }) + + expect(() => logger.error('[desktop] Failed to launch main window')).not.toThrow() + expect(fileSink).toHaveBeenCalledWith( + '2026-05-12T08:36:17.209Z ERROR [desktop] Failed to launch main window', + ) + + const pipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }) + consoleSink.error.mockImplementation(() => { + throw pipe + }) + expect(() => logger.error('[desktop] status after hangup')).not.toThrow() + }) + + it('still throws unexpected console write failures', () => { + const consoleSink = { + debug: vi.fn(), + error: vi.fn(() => { + throw new Error('disk full') + }), + info: vi.fn(), + warn: vi.fn(), + } + const logger = createElectronLogger({ + consoleSink, + env: { ECOS_ELECTRON_LOG_LEVEL: 'error' }, + isTty: false, + now: localTestDate, + }) + + expect(() => logger.error('[desktop] unexpected sink failure')).toThrow('disk full') + }) + it('writes configured file logs to one launch session file', async () => { const directory = await createTempDirectory('ecos-logger-session-') tempDirectories.push(directory) diff --git a/ecos/gui/apps/desktop-electron/electron/services/logger.ts b/ecos/gui/apps/desktop-electron/electron/services/logger.ts index f6cf41cf0..39fe2bc99 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/logger.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/logger.ts @@ -158,27 +158,43 @@ function formatFileLine(level: LogLevelName, rawMessage: string, date: Date): st return `${date.toISOString()} ${LEVEL_LABELS[level]} ${scopePrefix}${body}` } +function isBrokenPipeError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false + } + + const code = 'code' in error ? error.code : undefined + return code === 'EIO' || code === 'EPIPE' +} + function writeToConsole( consoleSink: ConsoleSink, level: LogLevelName, line: string, ): void { - if (level === 'debug') { - consoleSink.debug(line) - return - } + try { + if (level === 'debug') { + consoleSink.debug(line) + return + } - if (level === 'info') { - consoleSink.info(line) - return - } + if (level === 'info') { + consoleSink.info(line) + return + } - if (level === 'warning') { - consoleSink.warn(line) - return - } + if (level === 'warning') { + consoleSink.warn(line) + return + } - consoleSink.error(line) + consoleSink.error(line) + } catch (error) { + if (isBrokenPipeError(error)) { + return + } + throw error + } } export function createElectronLogger( diff --git a/ecos/gui/apps/desktop-electron/electron/services/productCommandService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/productCommandService.test.ts new file mode 100644 index 000000000..63d821eb4 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/productCommandService.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest' +import { executeProductCommand } from './productCommandService' + +describe('executeProductCommand Workspace creation', () => { + it('routes an owned canonical configuration update to the Runtime', async () => { + const updateWorkspaceConfiguration = vi.fn().mockResolvedValue({ + workspaceRevision: 2, + }) + const payload = { + commandId: 'configuration-1', + configuration: { + design: { name: 'gcd', topModule: 'gcd', clockPort: 'clk' }, + parameters: { frequency_max: 200 }, + pdk: { familyId: 'ics55' }, + }, + expectedWorkspaceRevision: 1, + workspaceHandle: 'handle-1', + } + + await expect( + executeProductCommand({ command: 'workspace.updateConfiguration', payload }, { + ownsWorkspaceHandle: (handle: string) => handle === 'handle-1', + runtime: { updateWorkspaceConfiguration } as never, + } as never), + ).resolves.toEqual({ workspaceRevision: 2 }) + expect(updateWorkspaceConfiguration).toHaveBeenCalledWith(payload) + }) + + it('routes Step Parameters without exposing a configuration path', async () => { + const updateWorkspaceStepConfiguration = vi.fn().mockResolvedValue({ + workspaceRevision: 2, + }) + const payload = { + commandId: 'step-configuration-1', + expectedWorkspaceRevision: 1, + parameters: { 'floorplan.ifp.thread_number': 8 }, + stepId: 'Floorplan', + workspaceHandle: 'handle-1', + } + + await expect( + executeProductCommand({ command: 'workspace.updateStepConfiguration', payload }, { + ownsWorkspaceHandle: (handle: string) => handle === 'handle-1', + runtime: { updateWorkspaceStepConfiguration } as never, + } as never), + ).resolves.toEqual({ workspaceRevision: 2 }) + expect(updateWorkspaceStepConfiguration).toHaveBeenCalledWith(payload) + }) + + it('rejects a Step Configuration update that still uses options', async () => { + await expect( + executeProductCommand( + { + command: 'workspace.updateStepConfiguration', + payload: { + commandId: 'step-configuration-1', + expectedWorkspaceRevision: 1, + options: { ifp: { thread_number: 8 } }, + stepId: 'Floorplan', + workspaceHandle: 'handle-1', + }, + } as never, + { + ownsWorkspaceHandle: (handle: string) => handle === 'handle-1', + runtime: { updateWorkspaceStepConfiguration: vi.fn() } as never, + } as never, + ), + ).rejects.toThrow('Product Command requires parameters') + }) + + it('rejects invalid optional Project identity fields at the command boundary', async () => { + await expect( + executeProductCommand( + { + command: 'workspace.create', + payload: { + commandId: 'command-1', + projectRoot: 42, + targetDirectory: '/projects/demo/ws_1', + workspaceBindings: {}, + workspaceSpec: {}, + }, + } as never, + {} as never, + ), + ).rejects.toThrow('Product Command requires projectRoot') + }) + + it('journals registration failure and releases the uncommitted Runtime handle', async () => { + const failCreate = vi.fn().mockResolvedValue(undefined) + const releaseWorkspace = vi.fn().mockResolvedValue({ ok: true }) + const registerCreateWorkspace = vi + .fn() + .mockRejectedValue(new Error('manifest unavailable')) + const payload = { + commandId: 'command-1', + projectId: 'project-1', + projectRoot: '/projects/demo', + targetDirectory: '/projects/demo/ws_1', + workspaceBindings: {}, + workspaceSpec: {}, + } + + await expect( + executeProductCommand( + { command: 'workspace.create', payload }, + { + beginCreate: vi.fn().mockResolvedValue({ + creationId: 'creation-1', + targetDirectory: payload.targetDirectory, + }), + failCreate, + markCreateWorkspaceCreated: vi.fn().mockResolvedValue(undefined), + ownsWorkspaceHandle: () => false, + prepareCreate: vi.fn(async (request) => request), + registerCreateWorkspace, + runtime: { + cancelOperation: vi.fn(), + createWorkspace: vi.fn().mockResolvedValue({ + directory: payload.targetDirectory, + workspaceHandle: 'handle-1', + }), + exportSignoff: vi.fn(), + releaseWorkspace, + resetFlow: vi.fn(), + retryFinalSnapshot: vi.fn(), + startFlowOperation: vi.fn(), + startStepOperation: vi.fn(), + updateWorkspaceStepConfiguration: vi.fn(), + updateWorkspaceConfiguration: vi.fn(), + updateWorkspace: vi.fn(), + }, + trackCreateResult: vi.fn(), + }, + ), + ).rejects.toThrow('manifest unavailable') + + expect(failCreate).toHaveBeenCalledWith('creation-1', expect.any(Error)) + expect(releaseWorkspace).toHaveBeenCalledWith({ workspaceHandle: 'handle-1' }) + }) + + it('creates the Runtime Workspace at the journal-authorized target', async () => { + const createWorkspace = vi.fn().mockResolvedValue({}) + const payload = { + commandId: 'command-1', + projectRoot: '/projects/link', + targetDirectory: '/projects/link/ws_1', + workspaceBindings: {}, + workspaceSpec: {}, + } + + await executeProductCommand( + { command: 'workspace.create', payload }, + { + beginCreate: vi.fn().mockResolvedValue({ + creationId: 'creation-1', + targetDirectory: '/projects/real/ws_1', + }), + failCreate: vi.fn(), + markCreateWorkspaceCreated: vi.fn(), + ownsWorkspaceHandle: () => false, + prepareCreate: vi.fn(async (request) => request), + registerCreateWorkspace: vi.fn(), + runtime: { + cancelOperation: vi.fn(), + createWorkspace, + exportSignoff: vi.fn(), + releaseWorkspace: vi.fn(), + resetFlow: vi.fn(), + retryFinalSnapshot: vi.fn(), + startFlowOperation: vi.fn(), + startStepOperation: vi.fn(), + updateWorkspaceStepConfiguration: vi.fn(), + updateWorkspaceConfiguration: vi.fn(), + updateWorkspace: vi.fn(), + }, + trackCreateResult: vi.fn(), + }, + ) + + expect(createWorkspace).toHaveBeenCalledWith({ + ...payload, + targetDirectory: '/projects/real/ws_1', + }) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/productCommandService.ts b/ecos/gui/apps/desktop-electron/electron/services/productCommandService.ts new file mode 100644 index 000000000..c9fefc3ad --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/productCommandService.ts @@ -0,0 +1,259 @@ +import type { + EccRuntimeOperationRequest, + EccRuntimeStartFlowRequest, + EccRuntimeStartStepRequest, + EccWorkspaceConfigurationUpdateRequest, + EccWorkspaceCreateRequest, + EccWorkspaceExportSignoffRequest, + EccWorkspaceHandleRequest, + EccWorkspaceStepConfigurationUpdateRequest, + EccWorkspaceUpdateRequest, + ProductCommandRequest, +} from '@ecos-studio/shared' + +interface ProductCommandRuntime { + cancelOperation(request: EccRuntimeOperationRequest): Promise + retryFinalSnapshot(request: EccWorkspaceHandleRequest): Promise + createWorkspace(request: EccWorkspaceCreateRequest): Promise + exportSignoff(request: EccWorkspaceExportSignoffRequest): Promise + releaseWorkspace(request: EccWorkspaceHandleRequest): Promise + resetFlow(request: EccWorkspaceHandleRequest): Promise + startFlowOperation(request: EccRuntimeStartFlowRequest): Promise + startStepOperation(request: EccRuntimeStartStepRequest): Promise + updateWorkspaceStepConfiguration( + request: EccWorkspaceStepConfigurationUpdateRequest, + ): Promise + updateWorkspaceConfiguration( + request: EccWorkspaceConfigurationUpdateRequest, + ): Promise + updateWorkspace(request: EccWorkspaceUpdateRequest): Promise +} + +interface ProductCommandContext { + beginCreate?( + request: EccWorkspaceCreateRequest, + ): Promise<{ creationId: string; targetDirectory: string }> + completeCreate?(creationId: string): Promise + continueCreate?(creationId: string): Promise<{ recovered: boolean; issue?: string }> + abandonCreate?(creationId: string): Promise<{ abandoned: boolean }> + failCreate?(creationId: string, error: unknown): Promise + markCreateWorkspaceCreated?( + creationId: string, + result: { workspaceId?: string; workspaceRevision?: number }, + ): Promise + registerCreateWorkspace?(creationId: string): Promise + ownsWorkspaceHandle(workspaceHandle: string): boolean + prepareCreate(request: EccWorkspaceCreateRequest): Promise + runtime: ProductCommandRuntime + trackCreateResult(result: unknown): void +} + +export async function executeProductCommand( + value: unknown, + context: ProductCommandContext, +): Promise { + const request = readProductCommandRequest(value) + if (request.command === 'workspace.continueCreation') { + if (!context.continueCreate) + throw new Error('Workspace creation recovery is unavailable.') + return await context.continueCreate(request.payload.creationId) + } + if (request.command === 'workspace.abandonCreation') { + if (!context.abandonCreate) + throw new Error('Workspace creation recovery is unavailable.') + return await context.abandonCreate(request.payload.creationId) + } + if (request.command === 'workspace.completeCreation') { + if (!context.completeCreate) + throw new Error('Workspace creation journal is unavailable.') + await context.completeCreate(request.payload.creationId) + return { completed: true } + } + if (request.command === 'workspace.failCreation') { + if (!context.failCreate) throw new Error('Workspace creation journal is unavailable.') + await context.failCreate(request.payload.creationId, request.payload.issue) + return { completed: false } + } + if (request.command === 'workspace.create') { + const prepared = await context.prepareCreate(request.payload) + const creation = await context.beginCreate?.({ + ...prepared, + ...(request.payload.projectId ? { projectId: request.payload.projectId } : {}), + ...(request.payload.projectRoot + ? { projectRoot: request.payload.projectRoot } + : {}), + }) + const creationId = creation?.creationId + let result: unknown + try { + result = await context.runtime.createWorkspace({ + ...prepared, + targetDirectory: creation?.targetDirectory ?? prepared.targetDirectory, + }) + if (creationId) { + await context.markCreateWorkspaceCreated?.( + creationId, + isRecord(result) ? result : {}, + ) + await context.registerCreateWorkspace?.(creationId) + } + context.trackCreateResult(result) + return creationId && isRecord(result) ? { ...result, creationId } : result + } catch (error) { + let journalError: unknown + if (creationId) { + try { + await context.failCreate?.(creationId, error) + } catch (failure) { + journalError = failure + } + } + if (isRecord(result) && typeof result.workspaceHandle === 'string') { + await context.runtime + .releaseWorkspace({ workspaceHandle: result.workspaceHandle }) + .catch(() => undefined) + } + if (journalError) throw journalError + throw error + } + } + + const workspaceHandle = request.payload.workspaceHandle + if (!context.ownsWorkspaceHandle(workspaceHandle)) { + throw new Error('Product Command does not own this Workspace handle') + } + + switch (request.command) { + case 'workspace.run': + return await context.runtime.startFlowOperation(request.payload) + case 'workspace.runStep': + return await context.runtime.startStepOperation(request.payload) + case 'workspace.update': { + const draft = await context.prepareCreate({ + ...request.payload.draft, + commandId: request.payload.commandId, + }) + return await context.runtime.updateWorkspace({ + commandId: request.payload.commandId, + expectedWorkspaceRevision: request.payload.expectedWorkspaceRevision, + workspaceBindings: draft.workspaceBindings, + workspaceHandle, + workspaceSpec: draft.workspaceSpec, + }) + } + case 'workspace.updateConfiguration': + return await context.runtime.updateWorkspaceConfiguration(request.payload) + case 'workspace.updateStepConfiguration': + return await context.runtime.updateWorkspaceStepConfiguration(request.payload) + case 'workspace.cancel': + return await context.runtime.cancelOperation(request.payload) + case 'workspace.retrySnapshot': + return { + recovered: await context.runtime.retryFinalSnapshot(request.payload), + } + case 'workspace.reset': + return await context.runtime.resetFlow(request.payload) + case 'workspace.exportSignoff': + return await context.runtime.exportSignoff(request.payload) + } +} + +function readProductCommandRequest(value: unknown): ProductCommandRequest { + if (!isRecord(value) || typeof value.command !== 'string' || !isRecord(value.payload)) { + throw new Error('Invalid Product Command') + } + const payload = value.payload + switch (value.command) { + case 'workspace.create': + requireString(payload, 'commandId') + requireString(payload, 'targetDirectory') + requireOptionalString(payload, 'projectId') + requireOptionalString(payload, 'projectRoot') + requireRecord(payload, 'workspaceBindings') + requireRecord(payload, 'workspaceSpec') + break + case 'workspace.run': + requireString(payload, 'workspaceHandle') + requireString(payload, 'idempotencyKey') + validateRevision(payload.expectedWorkspaceRevision) + break + case 'workspace.runStep': + requireString(payload, 'workspaceHandle') + requireString(payload, 'idempotencyKey') + requireString(payload, 'step') + validateRevision(payload.expectedWorkspaceRevision) + break + case 'workspace.update': + requireString(payload, 'commandId') + requireString(payload, 'workspaceHandle') + if (!isRecord(payload.draft)) throw new Error('Workspace update requires a draft') + requireRecord(payload.draft, 'workspaceBindings') + requireRecord(payload.draft, 'workspaceSpec') + validateRevision(payload.expectedWorkspaceRevision) + break + case 'workspace.updateConfiguration': + requireString(payload, 'commandId') + requireString(payload, 'workspaceHandle') + requireRecord(payload, 'configuration') + validateRevision(payload.expectedWorkspaceRevision) + break + case 'workspace.updateStepConfiguration': + requireString(payload, 'commandId') + requireString(payload, 'workspaceHandle') + requireString(payload, 'stepId') + requireRecord(payload, 'parameters') + validateRevision(payload.expectedWorkspaceRevision) + break + case 'workspace.cancel': + requireString(payload, 'workspaceHandle') + requireString(payload, 'operationId') + break + case 'workspace.retrySnapshot': + requireString(payload, 'workspaceHandle') + break + case 'workspace.continueCreation': + case 'workspace.abandonCreation': + case 'workspace.completeCreation': + requireString(payload, 'creationId') + break + case 'workspace.failCreation': + requireString(payload, 'creationId') + requireString(payload, 'issue') + break + case 'workspace.reset': + requireString(payload, 'workspaceHandle') + validateRevision(payload.expectedWorkspaceRevision) + break + case 'workspace.exportSignoff': + requireString(payload, 'workspaceHandle') + requireString(payload, 'outputPath') + break + default: + throw new Error('Unsupported Product Command') + } + return value as unknown as ProductCommandRequest +} + +function requireString(payload: Record, key: string): void { + if (typeof payload[key] !== 'string' || !payload[key].trim()) { + throw new Error(`Product Command requires ${key}`) + } +} + +function requireRecord(payload: Record, key: string): void { + if (!isRecord(payload[key])) throw new Error(`Product Command requires ${key}`) +} + +function requireOptionalString(payload: Record, key: string): void { + if (payload[key] !== undefined) requireString(payload, key) +} + +function validateRevision(value: unknown): void { + if (!Number.isInteger(value) || Number(value) < 0) { + throw new Error('Product Command revision must be a non-negative integer') + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/ecos/gui/apps/renderer/src/utils/projectAnalysisSnapshot.ts b/ecos/gui/apps/desktop-electron/electron/services/projectAnalysisSnapshot.ts similarity index 67% rename from ecos/gui/apps/renderer/src/utils/projectAnalysisSnapshot.ts rename to ecos/gui/apps/desktop-electron/electron/services/projectAnalysisSnapshot.ts index 00eecbf51..a142c0d6f 100644 --- a/ecos/gui/apps/renderer/src/utils/projectAnalysisSnapshot.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/projectAnalysisSnapshot.ts @@ -1,4 +1,10 @@ -import type { FlowStep, ProjectStepStatus } from './projectManagement' +import type { + ProjectAnalysisAvailability, + ProjectAnalysisArtifactStatus, + ProjectAnalysisSnapshot, + ProjectAnalysisStepSnapshot, + ProjectManifestFlowStep as FlowStep, +} from '@ecos-studio/shared' import { hasCurrentQorHotspotText, hasCurrentQorMetricsText, @@ -14,49 +20,8 @@ import { qorSummaryStatus, resolveWorkspaceSignoffReadiness, resolveWorkspaceTimingConstraints, - type ProjectQorAnalysisIntegrityIssue, - type ProjectQorBlockingIssue, - type ProjectQorDetailDescriptor, - type ProjectQorHardGateFailure, - type ProjectQorHotspot, - type ProjectQorMetricRecord, - type ProjectQorMissingMetric, - type ProjectQorSignoffReadiness, - type ProjectQorTimingCoverage, - type ProjectQorTimingConstraints, - type ProjectQorTimingIssue, type ProjectQorWorkspaceInput, - type QorGateStatus, -} from './projectQorTrend' - -export type ProjectAnalysisArtifactStatus = 'available' | 'missing' | 'invalid' -export type ProjectAnalysisAvailability = 'available' | 'incomplete' | 'unavailable' - -export interface ProjectAnalysisStepSnapshot { - step: FlowStep - flowStatus: ProjectStepStatus | undefined - artifactStatus: ProjectAnalysisArtifactStatus - summaryArtifactStatus: ProjectAnalysisArtifactStatus - hotspotArtifactStatus: ProjectAnalysisArtifactStatus - metrics: ProjectQorMetricRecord[] - summaryStatus: QorGateStatus | null - blockingIssues: ProjectQorBlockingIssue[] - missingMetrics: ProjectQorMissingMetric[] - hardGateFailures: ProjectQorHardGateFailure[] - hotspots: ProjectQorHotspot[] - details: ProjectQorDetailDescriptor[] - integrityIssues: ProjectQorAnalysisIntegrityIssue[] - timingIssues: ProjectQorTimingIssue[] - timingCoverage: ProjectQorTimingCoverage | null -} - -export interface ProjectAnalysisSnapshot { - workspaceId: string - workspacePath: string - steps: Partial> - signoffReadiness: ProjectQorSignoffReadiness - timingConstraints: ProjectQorTimingConstraints -} +} from './qorAnalysis' /** * A snapshot exists for every flow step, including ones with no analysis files. Keep @@ -86,7 +51,6 @@ export function buildProjectAnalysisSnapshot( return { workspaceId: input.workspaceId, - workspacePath: input.workspacePath, steps, signoffReadiness: resolveWorkspaceSignoffReadiness(input), timingConstraints: resolveWorkspaceTimingConstraints(input), @@ -110,7 +74,7 @@ function buildStepSnapshot( hotspotArtifactStatus: hotspotArtifactStatus(hotspotText), metrics: normalizeQorMetrics({ workspaceId: input.workspaceId, - workspacePath: input.workspacePath, + workspaceKey: input.workspaceKey, step, text: metricsText, }), diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectComparisonEvidence.test.ts b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonEvidence.test.ts new file mode 100644 index 000000000..27c139085 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonEvidence.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi } from 'vitest' +import { + projectManifestFlowSteps, + validateEngineeringSnapshot, + type EccPersistedEngineeringSnapshot, +} from '@ecos-studio/shared' +import { representativeProjectComparisonFixture } from './backendProjectComparison.fixture' +import { BackendProjectComparisonService } from './backendProjectComparisonService' + +function validated(snapshot: EccPersistedEngineeringSnapshot) { + const result = validateEngineeringSnapshot(snapshot) + if (!result.ok) throw new Error(result.issue.code) + return { ...result, readBytes: 1 } +} + +function invalidated( + previous: EccPersistedEngineeringSnapshot, + steps: readonly string[], +) { + const snapshot = structuredClone(previous) + snapshot.workspaceRevision += 1 + snapshot.stalePredecessor = { + workspaceRevision: previous.workspaceRevision, + invalidatedStepIds: [...steps], + } + snapshot.analysis.steps = snapshot.analysis.steps.filter( + (step) => !steps.includes(step.stepId), + ) + snapshot.artifacts = snapshot.artifacts.filter( + (artifact) => !steps.includes(artifact.stepId ?? ''), + ) + snapshot.flow = { + steps: projectManifestFlowSteps.map((name) => ({ + name, + state: steps.includes(name) ? 'Unstart' : 'Success', + })), + } + snapshot.metrics = snapshot.metrics.filter( + (metric) => !steps.some((step) => step.toLowerCase() === metric.analysis_group), + ) + snapshot.qorAssessment = { + status: 'ready', + score: { gate: 'incomplete', threshold: 60, value: null }, + metrics: snapshot.metrics, + steps: snapshot.analysis.steps.map((step) => ({ + stepId: step.stepId, + name: step.stepId, + order: step.order, + status: 'pass', + summaryMetricCount: 14, + })), + } + snapshot.signoffAssessment = { status: 'attention', groups: [], risks: [] } + return snapshot +} + +async function harness(steps: readonly string[] = projectManifestFlowSteps) { + const fixture = representativeProjectComparisonFixture() + const previous = fixture.engineeringSnapshots.ws_0002! + const current = invalidated(previous, steps) + ;(previous.flow as { steps: Array<{ state: string }> }).steps[0]!.state = 'Warning' + const readVerifiedArtifacts = vi.fn().mockResolvedValue({ ok: true, files: [] }) + const read = () => ({ ...validated(current), staleSnapshot: validated(previous) }) + const service = new BackendProjectComparisonService( + { + readManifest: async () => fixture.manifest, + resolveProjectRoot: async (path) => path, + readEngineeringSnapshot: async ({ workspacePath }) => + workspacePath.endsWith('ws_0002') + ? read() + : validated(fixture.engineeringSnapshots.ws_0001!), + readVerifiedArtifacts, + }, + () => + ({ + startProject: async () => {}, + reconcile: async () => {}, + close: async () => {}, + }) as never, + ) + const selected = await service.selectProject(11, { + projectRootLocator: '/projects/gcd', + }) + if (!selected.ok) throw new Error('selection failed') + const contextId = selected.projectComparisonContextId + const comparison = await service.getComparison(11, contextId) + if (!comparison.ok) throw new Error('comparison failed') + const findings = (step: string) => + service.getStepFindings(11, { + projectComparisonContextId: contextId, + projectWorkspaceId: 'ws_0002', + step, + }) + return { + service, + contextId, + comparison: comparison.data, + previous, + current, + findings, + readVerifiedArtifacts, + } +} + +describe('Project Comparison previous results', () => { + it('keeps old Findings inspectable without adding them to current comparison or progress', async () => { + const { comparison, findings, previous, readVerifiedArtifacts } = await harness() + expect(comparison.workspaceSnapshots).toMatchObject({ + data: { + flowStates: { ws_0002: { Synth: 'unstart', Legal: 'unstart' } }, + items: expect.arrayContaining([ + expect.objectContaining({ + workspaceId: 'ws_0002', + resultState: { + workspaceRevision: 15, + pendingStepIds: [...projectManifestFlowSteps], + previous: { + workspaceRevision: 14, + completedStepCount: projectManifestFlowSteps.length, + stepCount: projectManifestFlowSteps.length, + }, + }, + }), + ]), + }, + }) + expect(comparison.trend).toMatchObject({ + data: { + baselineWorkspaceId: 'ws_0001', + workspaces: expect.arrayContaining([ + expect.objectContaining({ + workspaceId: 'ws_0002', + overallScore: null, + records: [], + }), + ]), + }, + }) + expect(comparison.stepComparisons).toMatchObject({ + data: { + steps: expect.arrayContaining([ + expect.objectContaining({ + stepId: 'Legal', + workspaces: expect.arrayContaining([ + { workspaceId: 'ws_0002', status: 'unstart', metrics: [] }, + ]), + }), + ]), + }, + }) + const result = await findings('Legal') + expect(result).toMatchObject({ + ok: true, + data: { + resultState: 'stale', + currentWorkspaceRevision: 15, + workspaceRevision: 14, + details: { flowStatus: 'success', metrics: expect.any(Array) }, + }, + }) + if (!result.ok) throw new Error('findings failed') + expect(result.data.details.metrics.length).toBeGreaterThan(0) + expect( + result.data.details.metrics.every((metric) => !metric.baselineComparison), + ).toBe(true) + expect(readVerifiedArtifacts).toHaveBeenCalledWith( + expect.objectContaining({ + artifacts: previous.artifacts + .filter((artifact) => artifact.stepId === 'Legal') + .map(({ reference, sha256, sizeBytes }) => ({ reference, sha256, sizeBytes })), + }), + ) + }) + + it('keeps unaffected steps current and replaces old evidence after a Step commit', async () => { + const { service, contextId, current, previous, findings } = await harness(['Legal']) + expect(await findings('CTS')).toMatchObject({ + ok: true, + data: { resultState: 'current', workspaceRevision: 15 }, + }) + expect(await findings('Legal')).toMatchObject({ + ok: true, + data: { resultState: 'stale', workspaceRevision: 14 }, + }) + current.workspaceRevision += 1 + current.analysis.steps.push( + structuredClone(previous.analysis.steps.find((step) => step.stepId === 'Legal')!), + ) + current.artifacts.push( + ...previous.artifacts.filter((artifact) => artifact.stepId === 'Legal'), + ) + await service.refreshComparison(11, contextId) + expect(await findings('Legal')).toMatchObject({ + ok: true, + data: { resultState: 'current', workspaceRevision: 16 }, + }) + }) + + it('reports an unstarted Step as a normal empty result', async () => { + const { current, service, contextId, findings, readVerifiedArtifacts } = + await harness() + delete current.stalePredecessor + await service.refreshComparison(11, contextId) + expect(await findings('Legal')).toMatchObject({ + ok: true, + data: { + resultState: 'not-started', + workspaceRevision: 15, + details: { metrics: [] }, + }, + }) + expect(readVerifiedArtifacts).not.toHaveBeenCalled() + }) + + it('still verifies old artifacts and reports actual read failures', async () => { + const { findings, readVerifiedArtifacts } = await harness() + readVerifiedArtifacts.mockResolvedValue({ + ok: false, + code: 'ARTIFACT_REVISION_MISMATCH', + reference: 'legalization_dreamplace/analysis/qor_metrics.json', + }) + expect(await findings('Legal')).toMatchObject({ + ok: false, + code: 'ARTIFACT_REVISION_MISMATCH', + }) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectComparisonEvidence.ts b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonEvidence.ts new file mode 100644 index 000000000..ffde26294 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonEvidence.ts @@ -0,0 +1,114 @@ +import { + parseProjectManifestFlowStep, + type ProjectAnalysisSnapshot, + type ProjectManifest, + type ProjectManifestWorkspace, + type ProjectStepComparison, +} from '@ecos-studio/shared' +import type { ProjectEngineeringSnapshotReadResult } from './projectManagementReadService' +import type { + CommittedFindingsResult, + CommittedFindingsWorkspace, +} from './projectStepFindingsService' +import { buildProjectComparisonSnapshots } from './projectComparisonProjection' +import { projectQorInputForWorkspace, workspaceFlowStates } from './workspaceQorAnalysis' + +type Snapshot = NonNullable +type CurrentSnapshot = Extract + +function result( + snapshot: Snapshot, + analysis: ProjectAnalysisSnapshot, +): CommittedFindingsResult | null { + if ( + snapshot.sections.qor.status !== 'ready' || + snapshot.sections.artifacts.status !== 'ready' + ) + return null + return { + analysis, + comparisonMetrics: {}, + engineeringSnapshot: { + analysis: snapshot.sections.qor.data.analysis, + artifacts: snapshot.sections.artifacts.data, + workspaceId: snapshot.snapshot.workspaceId, + workspaceRevision: snapshot.snapshot.workspaceRevision, + }, + } +} + +export function projectComparisonEvidence( + snapshot: CurrentSnapshot, + manifest: ProjectManifest, + workspace: ProjectManifestWorkspace, + analysis: ProjectAnalysisSnapshot, + comparisons: ProjectStepComparison[], +): CommittedFindingsWorkspace | null { + const current = result(snapshot, analysis) + if (!current) return null + const flowStates = workspaceFlowStates( + snapshot.sections.flow.status === 'ready' ? snapshot.sections.flow.data : undefined, + ) + const completed = new Set( + current.engineeringSnapshot.analysis.steps.map( + (step) => parseProjectManifestFlowStep(step.stepId) ?? step.stepId, + ), + ) + const pendingStepIds = (snapshot.snapshot.stalePredecessor?.invalidatedStepIds ?? []) + .map((step) => parseProjectManifestFlowStep(step) ?? step) + .filter( + (step) => + !completed.has(step) && + !['success', 'reused', 'skipped'].includes(flowStates[step] ?? ''), + ) + analysis.resultState = { + workspaceRevision: snapshot.snapshot.workspaceRevision, + pendingStepIds, + } + let previous: CommittedFindingsResult | undefined + const stale = snapshot.staleSnapshot + if ( + pendingStepIds.length && + stale?.sections.qor.status === 'ready' && + stale.sections.artifacts.status === 'ready' + ) { + const flow = + stale.sections.flow.status === 'ready' ? stale.sections.flow.data : undefined + const qor = stale.sections.qor.data + const input = projectQorInputForWorkspace(manifest, workspace.workspace_id, { + analysis: qor.analysis, + metrics: qor.metrics, + qorAssessment: qor.qorAssessment, + ...(flow ? { flow } : {}), + ...(stale.sections.signoff.status === 'ready' + ? { signoffAssessment: stale.sections.signoff.data } + : {}), + }) + if (input) { + const oldAnalysis = buildProjectComparisonSnapshots([input])[0]! + previous = result(stale, oldAnalysis) ?? undefined + const states = Object.values(workspaceFlowStates(flow)) + analysis.resultState.previous = { + workspaceRevision: stale.snapshot.workspaceRevision, + completedStepCount: states.filter((state) => + ['success', 'warning', 'reused', 'skipped'].includes(state), + ).length, + stepCount: states.length, + } + } + } + return { + ...current, + comparisonMetrics: Object.fromEntries( + comparisons.map((comparison) => [ + comparison.stepId, + comparison.workspaces.find( + (candidate) => candidate.workspaceId === workspace.workspace_id, + )?.metrics ?? [], + ]), + ), + projectWorkspaceId: workspace.workspace_id, + workspacePath: workspace.workspace_path, + ...(previous ? { previous } : {}), + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectComparisonFileWatcher.test.ts b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonFileWatcher.test.ts new file mode 100644 index 000000000..d66c7d982 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonFileWatcher.test.ts @@ -0,0 +1,227 @@ +import { EventEmitter } from 'node:events' +import { mkdir, mkdtemp, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { FSWatcher } from 'chokidar' +import { ProjectComparisonFileWatcher } from './projectComparisonFileWatcher' + +class FakeWatcher extends EventEmitter { + close = vi.fn(async () => undefined) +} + +describe('ProjectComparisonFileWatcher', () => { + afterEach(() => vi.useRealTimers()) + + it('watches only manifest and snapshot directories at depth zero and survives replace events', async () => { + vi.useFakeTimers() + const created: Array<{ + path: string + options: Record + watcher: FakeWatcher + }> = [] + const watch = vi.fn((path: string, options: Record) => { + const watcher = new FakeWatcher() + created.push({ path, options, watcher }) + return watcher as unknown as FSWatcher + }) + const onManifestChanged = vi.fn() + const onSnapshotChanged = vi.fn() + const watcher = new ProjectComparisonFileWatcher( + { onError: vi.fn(), onManifestChanged, onSnapshotChanged }, + watch as never, + async (path) => path, + ) + + const projectReady = watcher.startProject('/project') + expect(created[0]).toMatchObject({ + path: '/project', + options: { depth: 0, ignoreInitial: true, persistent: false, usePolling: false }, + }) + created[0]!.watcher.emit('ready') + await projectReady + + const workspacesReady = watcher.reconcile('/project', [ + '/project/ws_1', + '/project/ws_2', + ]) + await vi.waitFor(() => expect(created).toHaveLength(3)) + expect(created.slice(1).map((entry) => entry.path)).toEqual([ + '/project/ws_1/home', + '/project/ws_2/home', + ]) + created[1]!.watcher.emit('ready') + created[2]!.watcher.emit('ready') + await workspacesReady + + created[0]!.watcher.emit('all', 'change', '/project/ignored.json') + created[0]!.watcher.emit('all', 'unlink', '/project/project.json') + created[0]!.watcher.emit('all', 'add', '/project/project.json') + created[1]!.watcher.emit( + 'all', + 'change', + '/project/ws_1/home/engineering-snapshot.json', + ) + await vi.advanceTimersByTimeAsync(50) + + expect(onManifestChanged).toHaveBeenCalledOnce() + expect(onSnapshotChanged).toHaveBeenCalledOnce() + expect(onSnapshotChanged).toHaveBeenCalledWith('/project/ws_1') + + await watcher.reconcile('/project', ['/project/ws_2']) + expect(created[1]!.watcher.close).toHaveBeenCalledOnce() + await watcher.close() + expect(created[0]!.watcher.close).toHaveBeenCalledOnce() + expect(created[2]!.watcher.close).toHaveBeenCalledOnce() + }) + + it('continues observing project.json after consecutive atomic replacements', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'ecos-comparison-watch-')) + const workspaceRoot = join(projectRoot, 'ws_1') + await mkdir(join(workspaceRoot, 'home'), { recursive: true }) + await writeFile(join(projectRoot, 'project.json'), '{}') + await writeFile(join(workspaceRoot, 'home', 'engineering-snapshot.json'), '{}') + let resolveChange: (() => void) | null = null + let changeCount = 0 + const nextChange = () => + new Promise((resolve) => { + resolveChange = resolve + }) + const watcher = new ProjectComparisonFileWatcher({ + onError: (error) => { + throw error + }, + onManifestChanged: () => { + changeCount += 1 + resolveChange?.() + resolveChange = null + }, + onSnapshotChanged: vi.fn(), + }) + + try { + await watcher.startProject(projectRoot) + await watcher.reconcile(projectRoot, [workspaceRoot]) + for (const revision of [1, 2]) { + const changed = nextChange() + const staged = join(projectRoot, `project.${revision}.tmp`) + await writeFile(staged, JSON.stringify({ revision })) + await rename(staged, join(projectRoot, 'project.json')) + await changed + } + expect(changeCount).toBe(2) + } finally { + await watcher.close() + await rm(projectRoot, { force: true, recursive: true }) + } + }) + + it('refuses to watch a Workspace symlink outside the Project root', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'ecos-comparison-project-')) + const outside = await mkdtemp(join(tmpdir(), 'ecos-comparison-outside-')) + const workspaceRoot = join(projectRoot, 'ws_1') + await symlink(outside, workspaceRoot, 'dir') + const watcher = new ProjectComparisonFileWatcher({ + onError: vi.fn(), + onManifestChanged: vi.fn(), + onSnapshotChanged: vi.fn(), + }) + + try { + await expect(watcher.reconcile(projectRoot, [workspaceRoot])).rejects.toThrow( + 'outside the Project root', + ) + } finally { + await watcher.close() + await Promise.all([ + rm(projectRoot, { force: true, recursive: true }), + rm(outside, { force: true, recursive: true }), + ]) + } + }) + + it('keeps valid Workspace watchers when a selected baseline is missing', async () => { + const created: FakeWatcher[] = [] + const watch = vi.fn(() => { + const watcher = new FakeWatcher() + created.push(watcher) + return watcher as unknown as FSWatcher + }) + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }) + const watcher = new ProjectComparisonFileWatcher( + { onError: vi.fn(), onManifestChanged: vi.fn(), onSnapshotChanged: vi.fn() }, + watch as never, + async (path) => { + if (path.includes('ws_missing')) throw missing + return path + }, + ) + + const pending = watcher.reconcile('/project', [ + '/project/ws_current', + '/project/ws_missing', + ]) + await vi.waitFor(() => expect(created).toHaveLength(1)) + created[0]!.emit('ready') + await pending + + expect(watch).toHaveBeenCalledWith( + '/project/ws_current/home', + expect.objectContaining({ depth: 0 }), + ) + await watcher.close() + }) + + it('observes creation of an initially missing current Snapshot', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'ecos-comparison-project-')) + const workspaceRoot = join(projectRoot, 'ws_current') + await mkdir(workspaceRoot) + const onSnapshotChanged = vi.fn() + const watcher = new ProjectComparisonFileWatcher({ + onError: (error) => { + throw error + }, + onManifestChanged: vi.fn(), + onSnapshotChanged, + }) + + try { + await watcher.reconcile(projectRoot, [workspaceRoot]) + await mkdir(join(workspaceRoot, 'home')) + await writeFile(join(workspaceRoot, 'home', 'engineering-snapshot.json'), '{}') + + await vi.waitFor( + () => expect(onSnapshotChanged).toHaveBeenCalledWith(workspaceRoot), + { timeout: 3000 }, + ) + } finally { + await watcher.close() + await rm(projectRoot, { force: true, recursive: true }) + } + }) + + it('refuses to watch a home symlink outside its Workspace root', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'ecos-comparison-project-')) + const outside = await mkdtemp(join(tmpdir(), 'ecos-comparison-home-outside-')) + const workspaceRoot = join(projectRoot, 'ws_1') + await mkdir(workspaceRoot) + await symlink(outside, join(workspaceRoot, 'home'), 'dir') + const watcher = new ProjectComparisonFileWatcher({ + onError: vi.fn(), + onManifestChanged: vi.fn(), + onSnapshotChanged: vi.fn(), + }) + + try { + await expect(watcher.reconcile(projectRoot, [workspaceRoot])).rejects.toThrow( + 'outside the Workspace root', + ) + } finally { + await watcher.close() + await Promise.all([ + rm(projectRoot, { force: true, recursive: true }), + rm(outside, { force: true, recursive: true }), + ]) + } + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectComparisonFileWatcher.ts b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonFileWatcher.ts new file mode 100644 index 000000000..2912b9d89 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonFileWatcher.ts @@ -0,0 +1,195 @@ +import { watch, type FSWatcher } from 'chokidar' +import { realpath } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { isPathWithinRoot } from './pathScope' + +const DEBOUNCE_MS = 50 + +export interface ProjectComparisonFileWatcherCallbacks { + onError(error: unknown): void + onManifestChanged(): void + onSnapshotChanged(workspaceRoot: string): void +} + +type Watch = typeof watch +type Canonicalize = (path: string) => Promise + +export class ProjectComparisonFileWatcher { + private projectWatcher: FSWatcher | null = null + private readonly workspaceWatchers = new Map() + private readonly timers = new Map>() + private failed = false + + constructor( + private readonly callbacks: ProjectComparisonFileWatcherCallbacks, + private readonly watchDirectory: Watch = watch, + private readonly canonicalize: Canonicalize = realpath, + ) {} + + async startProject(projectRoot: string): Promise { + const root = resolve(projectRoot) + const target = join(root, 'project.json') + const watcher = this.create(root, target, () => this.callbacks.onManifestChanged()) + this.projectWatcher = watcher + try { + await ready(watcher) + } catch (error) { + this.projectWatcher = null + await watcher.close() + throw error + } + } + + async reconcile(projectRoot: string, workspaceRoots: readonly string[]): Promise { + const project = resolve(projectRoot) + const expected = new Map< + string, + { directory: string; target: string; allowedParents: string[] } + >() + for (const root of workspaceRoots) { + const locator = resolve(root) + let canonical: string + try { + canonical = await this.canonicalize(locator) + } catch (error) { + if (isNodeErrorWithCode(error, 'ENOENT')) continue + throw error + } + if (canonical === project || !isPathWithinRoot(canonical, project)) { + throw new Error('Workspace watcher path resolves outside the Project root.') + } + const unresolvedHome = join(canonical, 'home') + try { + const home = await this.canonicalize(unresolvedHome) + if (!isPathWithinRoot(home, canonical)) { + throw new Error( + 'Workspace home watcher path resolves outside the Workspace root.', + ) + } + expected.set(locator, { + directory: home, + target: join(home, 'engineering-snapshot.json'), + allowedParents: [], + }) + } catch (error) { + if (!isNodeErrorWithCode(error, 'ENOENT')) throw error + expected.set(locator, { + directory: canonical, + target: join(unresolvedHome, 'engineering-snapshot.json'), + allowedParents: [unresolvedHome], + }) + } + } + await Promise.all( + [...this.workspaceWatchers].flatMap(([root, watcher]) => { + if (expected.has(root)) return [] + this.workspaceWatchers.delete(root) + return [watcher.close()] + }), + ) + await Promise.all( + [...expected].flatMap(([workspaceRoot, targetInfo]) => { + if (this.workspaceWatchers.has(workspaceRoot)) return [] + const watcher = this.create( + targetInfo.directory, + targetInfo.target, + () => this.callbacks.onSnapshotChanged(workspaceRoot), + targetInfo.allowedParents, + ) + this.workspaceWatchers.set(workspaceRoot, watcher) + return [ + ready(watcher).catch(async (error) => { + this.workspaceWatchers.delete(workspaceRoot) + await watcher.close() + throw error + }), + ] + }), + ) + } + + async close(): Promise { + for (const timer of this.timers.values()) clearTimeout(timer) + this.timers.clear() + const watchers = [ + ...(this.projectWatcher ? [this.projectWatcher] : []), + ...this.workspaceWatchers.values(), + ] + this.projectWatcher = null + this.workspaceWatchers.clear() + await Promise.all(watchers.map((watcher) => watcher.close())) + } + + private create( + directory: string, + target: string, + callback: () => void, + allowedParents: readonly string[] = [], + ): FSWatcher { + const allowed = new Set( + [directory, target, ...allowedParents].map((path) => resolve(path)), + ) + const watcher = this.watchDirectory(directory, { + depth: allowedParents.length ? 1 : 0, + followSymlinks: false, + ignored: (path) => { + const candidate = resolve(path) + return !allowed.has(candidate) + }, + ignoreInitial: true, + persistent: false, + usePolling: false, + }) + watcher.on('all', (event, changedPath) => { + if (!['add', 'change', 'unlink'].includes(event)) return + if (resolve(changedPath) !== target) return + this.schedule(target, callback) + }) + watcher.on('error', (error) => { + if (this.failed) return + this.failed = true + this.callbacks.onError(error) + }) + return watcher + } + + private schedule(key: string, callback: () => void): void { + const pending = this.timers.get(key) + if (pending) clearTimeout(pending) + this.timers.set( + key, + setTimeout(() => { + this.timers.delete(key) + callback() + }, DEBOUNCE_MS), + ) + } +} + +function isNodeErrorWithCode(error: unknown, code: string): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === code + ) +} + +function ready(watcher: FSWatcher): Promise { + return new Promise((resolveReady, rejectReady) => { + const cleanup = () => { + watcher.off('ready', onReady) + watcher.off('error', onError) + } + const onReady = () => { + cleanup() + resolveReady() + } + const onError = (error: unknown) => { + cleanup() + rejectReady(error) + } + watcher.once('ready', onReady) + watcher.once('error', onError) + }) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectComparisonProjection.test.ts b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonProjection.test.ts new file mode 100644 index 000000000..d0d698bff --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonProjection.test.ts @@ -0,0 +1,41 @@ +import type { ProjectManifest, ProjectQorTrendSummary } from '@ecos-studio/shared' +import { describe, expect, it } from 'vitest' +import { + buildProjectComparisonSteps, + type ProjectComparisonInput, +} from './projectComparisonProjection' + +describe('buildProjectComparisonSteps', () => { + it('builds the ordered configured-step union with explicit absence states', () => { + const workspace = (workspaceId: string, startStep: string, endStep: string) => ({ + workspace_id: workspaceId, + start_step: startStep, + end_step: endStep, + branch_from: null, + }) + const manifest = { + workspaces: [ + workspace('ws-a', 'Synthesis', 'Place'), + workspace('ws-b', 'Synth', 'CTS'), + workspace('ws-c', 'Synth', 'Synth'), + ], + } as unknown as ProjectManifest + const inputs = [ + { workspaceId: 'ws-a' }, + { workspaceId: 'ws-b' }, + ] as unknown as ProjectComparisonInput[] + const trend = { workspaces: [] } as unknown as ProjectQorTrendSummary + + const result = buildProjectComparisonSteps(manifest, inputs, trend, { + 'ws-a': { Synthesis: 'success', Place: 'unstart', fixFanout: 'success' }, + 'ws-b': { Synth: 'success', LEC: 'success', CTS: 'unstart' }, + }) + + expect(result.map((step) => step.stepId)).toEqual(['Synth', 'LEC', 'Place', 'CTS']) + expect(result.find((step) => step.stepId === 'LEC')?.workspaces).toEqual([ + { workspaceId: 'ws-a', status: 'not_applicable', metrics: [] }, + { workspaceId: 'ws-b', status: 'success', metrics: [] }, + { workspaceId: 'ws-c', status: 'unavailable', metrics: [] }, + ]) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectComparisonProjection.ts b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonProjection.ts new file mode 100644 index 000000000..d26ad1f3a --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/projectComparisonProjection.ts @@ -0,0 +1,205 @@ +import { + projectManifestFlowSteps, + parseProjectManifestFlowStep, + type ProjectAnalysisSnapshot, + type ProjectManifest, + type ProjectQorMetricRecord, + type ProjectQorTrendSummary, + type ProjectQorTrendWorkspaceSummary, + type ProjectRecommendation, + type ProjectStepComparison, + type ProjectStepStatus, + type ReadIssue, + type ReadSection, +} from '@ecos-studio/shared' +import { buildProjectAnalysisSnapshot } from './projectAnalysisSnapshot' +import { buildProjectQorTrendSummary } from './qorAnalysis' +import { projectQorInputForWorkspace } from './workspaceQorAnalysis' + +const FLOW_STEPS = projectManifestFlowSteps + +function comparisonStepId(stepId: string): string | null { + const canonical = parseProjectManifestFlowStep(stepId) + if (canonical) return canonical + return stepId.toLowerCase().replace(/[\s_-]/g, '') === 'fixfanout' ? null : stepId +} + +export type ProjectComparisonInput = NonNullable< + ReturnType +> + +export function buildProjectComparisonTrend( + inputs: ProjectComparisonInput[], + baselineWorkspaceId: string | null, +): ProjectQorTrendSummary { + return publicTrend(buildProjectQorTrendSummary(inputs, { baselineWorkspaceId })) +} + +export function buildProjectComparisonSnapshots( + inputs: ProjectComparisonInput[], +): ProjectAnalysisSnapshot[] { + return inputs.map((input) => + publicSnapshot(buildProjectAnalysisSnapshot(input, FLOW_STEPS)), + ) +} + +export function buildProjectComparisonSteps( + manifest: ProjectManifest, + inputs: ProjectComparisonInput[], + trend: ProjectQorTrendSummary, + flowStates: Record>, +): ProjectStepComparison[] { + const stepIds = new Set() + const normalizedFlowStates: typeof flowStates = {} + for (const [workspaceId, states] of Object.entries(flowStates)) { + const normalized: Record = {} + for (const [rawStepId, status] of Object.entries(states)) { + const stepId = comparisonStepId(rawStepId) + if (!stepId) continue + normalized[stepId] = status + stepIds.add(stepId) + } + normalizedFlowStates[workspaceId] = normalized + } + for (const workspace of manifest.workspaces) { + for (const rawStepId of [ + workspace.start_step, + workspace.end_step, + workspace.branch_from?.source_step, + ]) { + if (!rawStepId) continue + const stepId = comparisonStepId(rawStepId) + if (stepId) stepIds.add(stepId) + } + } + const knownOrder = new Map(FLOW_STEPS.map((step, order) => [step, order])) + const metricsByWorkspace = new Map( + trend.workspaces.map((workspace) => [ + workspace.workspaceId, + workspace.comparisonRecords ?? workspace.records, + ]), + ) + const availableWorkspaceIds = new Set(inputs.map((input) => input.workspaceId)) + const unknownSteps = [...stepIds] + .filter((step) => !knownOrder.has(step as (typeof FLOW_STEPS)[number])) + .sort((left, right) => left.localeCompare(right)) + return [...stepIds] + .map((stepId) => ({ + stepId, + order: + knownOrder.get(stepId as (typeof FLOW_STEPS)[number]) ?? + FLOW_STEPS.length + unknownSteps.indexOf(stepId), + name: stepId, + workspaces: manifest.workspaces.map((workspace) => { + return { + workspaceId: workspace.workspace_id, + status: (!availableWorkspaceIds.has(workspace.workspace_id) + ? 'unavailable' + : (normalizedFlowStates[workspace.workspace_id]?.[stepId] ?? + 'not_applicable')) as ProjectStepComparison['workspaces'][number]['status'], + metrics: (metricsByWorkspace.get(workspace.workspace_id) ?? []).filter( + (metric) => + comparisonStepId(metric.step) === stepId && metric.stepRole !== 'hidden', + ), + } + }), + })) + .sort((left, right) => left.order - right.order) +} + +export function comparisonSection(data: T, issues: ReadIssue[]): ReadSection { + return issues.length + ? { status: 'partial', data, issues } + : { status: 'ready', data, issues: [] } +} + +export function workspaceIssue(workspaceId: string, error?: unknown): ReadIssue { + if (error && typeof error === 'object' && 'code' in error) { + const issue = error as Record + const actualSize = issue.actualSizeBytes + const allowedSize = issue.allowedSizeBytes + const sizeDetail = + typeof actualSize === 'number' && typeof allowedSize === 'number' + ? ` (${actualSize}/${allowedSize} bytes)` + : '' + const detail = typeof issue.detail === 'string' ? issue.detail : '' + return { + code: String(issue.code), + detail: `${workspaceId}${detail ? `: ${detail}` : ''}${sizeDetail}`, + } + } + return { + code: 'WORKSPACE_ANALYSIS_FAILED', + detail: error instanceof Error ? `${workspaceId}: ${error.message}` : workspaceId, + } +} + +export function selectRecommendation( + workspaces: ProjectQorTrendWorkspaceSummary[], +): ProjectRecommendation | null { + const best = workspaces + .filter( + (workspace) => + workspace.overallScore !== null && + (workspace.dataQuality.status === 'complete' || + workspace.dataQuality.status === 'limited'), + ) + .sort((left, right) => (right.overallScore ?? -1) - (left.overallScore ?? -1))[0] + return best?.overallScore === null || !best + ? null + : { + workspaceId: best.workspaceId, + score: best.overallScore, + reasons: [`Highest eligible QoR score: ${best.overallScore}`], + } +} + +function publicMetric( + metric: ProjectQorMetricRecord & { workspaceKey?: string }, +): ProjectQorMetricRecord { + const { workspaceKey: _workspaceKey, ...result } = metric + return result +} + +function publicSnapshot( + snapshot: ReturnType, +): ProjectAnalysisSnapshot { + return { + ...snapshot, + steps: Object.fromEntries( + Object.entries(snapshot.steps).map(([step, value]) => [ + step, + value ? { ...value, metrics: value.metrics.map(publicMetric) } : value, + ]), + ), + } +} + +function publicTrend( + trend: ReturnType, +): ProjectQorTrendSummary { + return { + ...trend, + workspaces: trend.workspaces.map((workspace) => { + const { workspaceKey: _workspaceKey, ...result } = workspace + return { + ...result, + records: workspace.records.map(publicMetric), + ...(workspace.comparisonRecords + ? { comparisonRecords: workspace.comparisonRecords.map(publicMetric) } + : {}), + } + }), + timingClosure: { + issues: trend.timingClosure.issues, + coverage: trend.timingClosure.coverage, + triage: trend.timingClosure.triage, + criticalCount: trend.timingClosure.criticalCount, + warningCount: trend.timingClosure.warningCount, + cleanWorkspaceCount: trend.timingClosure.cleanWorkspaceCount, + atRiskWorkspaceCount: trend.timingClosure.atRiskWorkspaceCount, + incompleteWorkspaceCount: trend.timingClosure.incompleteWorkspaceCount, + unavailableWorkspaceCount: trend.timingClosure.unavailableWorkspaceCount, + }, + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectExecutionOverlay.ts b/ecos/gui/apps/desktop-electron/electron/services/projectExecutionOverlay.ts new file mode 100644 index 000000000..b695ffd32 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/projectExecutionOverlay.ts @@ -0,0 +1,132 @@ +import { + parseProjectManifestFlowStep, + type BackendProjectActiveOperation, + type BackendProjectExecutionInvalidatedEvent, + type BackendProjectExecutionSnapshotResult, + type EccRuntimeOperation, + type ProjectStepStatus, +} from '@ecos-studio/shared' + +export interface CommittedProjectWorkspace { + engineeringWorkspaceId: string + projectWorkspaceId: string + stepStatuses: Record + workspaceRevision: number +} + +type InvalidationListener = ( + windowId: number, + event: BackendProjectExecutionInvalidatedEvent, +) => void + +interface ExecutionContext { + generation: number + windowId: number + workspaces: CommittedProjectWorkspace[] +} + +export class ProjectExecutionOverlay { + private readonly contexts = new Map() + private readonly listeners = new Set() + + constructor(private readonly activeOperations: () => EccRuntimeOperation[]) {} + + register(windowId: number, contextId: string): void { + this.contexts.set(contextId, { generation: 0, windowId, workspaces: [] }) + } + + unregister(contextId: string): void { + this.contexts.delete(contextId) + } + + setCommittedWorkspaces( + contextId: string, + workspaces: CommittedProjectWorkspace[], + ): void { + const context = this.contexts.get(contextId) + if (context) context.workspaces = workspaces + } + + get(windowId: number, contextId: string): BackendProjectExecutionSnapshotResult { + const context = this.contexts.get(contextId) + if (!context || context.windowId !== windowId) { + return { ok: false, code: 'unknown-context' } + } + return { + ok: true, + projectComparisonContextId: contextId, + generation: context.generation, + data: { + operations: projectOperations(this.activeOperations(), context.workspaces), + }, + } + } + + invalidate(): void { + for (const [contextId, context] of this.contexts) { + context.generation += 1 + const event = { + generation: context.generation, + projectComparisonContextId: contextId, + } + for (const listener of this.listeners) listener(context.windowId, event) + } + } + + onInvalidated(listener: InvalidationListener): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } +} + +function projectOperations( + operations: EccRuntimeOperation[], + workspaces: CommittedProjectWorkspace[], +): BackendProjectActiveOperation[] { + const byEngineeringIdentity = new Map( + workspaces.map((workspace) => [ + `${workspace.engineeringWorkspaceId}\0${workspace.workspaceRevision}`, + workspace, + ]), + ) + return operations.flatMap((operation) => + projectOperation(operation, byEngineeringIdentity), + ) +} + +function projectOperation( + operation: EccRuntimeOperation, + workspaces: Map, +): BackendProjectActiveOperation[] { + if ( + (operation.state !== 'queued' && operation.state !== 'running') || + typeof operation.workspaceRevision !== 'number' + ) { + return [] + } + const workspace = workspaces.get( + `${operation.workspaceId}\0${operation.workspaceRevision}`, + ) + if (!workspace) return [] + const step = parseProjectManifestFlowStep(operation.currentStep || operation.step) + const committedStep = step ? workspace.stepStatuses[step] : undefined + return [ + { + cancelRequested: Boolean(operation.cancelRequested), + engineeringWorkspaceId: operation.workspaceId, + kind: operation.kind, + operationId: operation.operationId, + projectWorkspaceId: workspace.projectWorkspaceId, + rerun: operation.rerun, + state: operation.state, + step: + committedStep === 'success' || + committedStep === 'reused' || + committedStep === 'skipped' + ? null + : step, + updatedAt: operation.updatedAt, + workspaceRevision: operation.workspaceRevision, + }, + ] +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectManagementReadService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/projectManagementReadService.test.ts index 537cc8ff7..906c6a3f0 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/projectManagementReadService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/projectManagementReadService.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createHash } from 'node:crypto' import { mkdir, mkdtemp, @@ -11,35 +12,97 @@ import { import { join } from 'node:path' import { tmpdir } from 'node:os' import { - createProjectManifestDraft, - projectManagementWorkspaceSummaryPaths, - registerWorkspaceInManifest, + ENGINEERING_SNAPSHOT_MAX_BYTES, + projectManifestForPresentation, + type EccProjectManifest, + type EccPersistedEngineeringSnapshot, } from '@ecos-studio/shared' -import { ProjectManagementReadService } from './projectManagementReadService' +import { + PROJECT_FINDINGS_ARTIFACT_MAX_BYTES, + ProjectManagementReadService, +} from './projectManagementReadService' const temporaryDirectories: string[] = [] +function engineeringSnapshot(): EccPersistedEngineeringSnapshot { + return { + analysis: { steps: [] }, + artifacts: [], + checklist: {}, + flow: { steps: [] }, + metrics: [], + parameters: {}, + qorAssessment: { + status: 'unavailable', + metrics: [], + score: { value: null, threshold: 60, gate: 'unavailable' }, + steps: [], + }, + schemaVersion: 1, + signoffAssessment: { status: 'ready', groups: [], risks: [] }, + workspaceId: 'engineering-workspace', + workspaceRevision: 1, + } +} + async function createProject(): Promise<{ projectRoot: string; workspaceRoot: string }> { const projectRoot = await mkdtemp(join(tmpdir(), 'ecos-project-management-read-')) temporaryDirectories.push(projectRoot) const workspaceRoot = join(projectRoot, 'ws_0001') await mkdir(join(workspaceRoot, 'home'), { recursive: true }) - const manifest = registerWorkspaceInManifest( - createProjectManifestDraft({ - rootPath: projectRoot, - name: 'gcd', - designName: 'gcd', - }), + const now = '2026-08-09T00:00:00.000Z' + const manifest = { + schema_version: 1, + project_id: 'proj_gcd', + name: 'gcd', + design_name: 'gcd', + root_path: projectRoot, + created_at: now, + updated_at: now, + objectives: {}, + workspaces: [ + { + workspace_id: 'ws_0001', + name: 'ws_0001', + workspace_path: 'ws_0001', + source_workspace_id: null, + lifecycle: 'active', + created_at: now, + updated_at: now, + }, + ], + mpc: null, + best_workspace: null, + qor_baseline: null, + } + await writeFile(join(projectRoot, 'project.json'), JSON.stringify(manifest)) + return { projectRoot, workspaceRoot } +} + +function createReadService( + readStepConfiguration?: ( + workspacePath: string, + step: string, + ) => Promise, + readWorkspaceConfiguration?: ( + workspacePath: string, + ) => Promise, +): ProjectManagementReadService { + return new ProjectManagementReadService( { - projectRoot, - workspacePath: workspaceRoot, - now: '2026-08-09T00:00:00.000Z', + discover: async () => null, + load: async (projectRoot) => + projectManifestForPresentation( + JSON.parse( + await readFile(join(projectRoot, 'project.json'), 'utf8'), + ) as EccProjectManifest, + projectRoot, + ), }, + readStepConfiguration, + readWorkspaceConfiguration, ) - await writeFile(join(projectRoot, 'project.json'), JSON.stringify(manifest)) - await writeFile(join(workspaceRoot, 'home', 'flow.json'), '{"steps":[]}') - return { projectRoot, workspaceRoot } } describe('ProjectManagementReadService', () => { @@ -52,45 +115,20 @@ describe('ProjectManagementReadService', () => { }) it('reads a historical project and its declared workspace without an active workspace scope', async () => { - const { projectRoot, workspaceRoot } = await createProject() - const service = new ProjectManagementReadService() + const { projectRoot } = await createProject() + const service = createReadService() - await expect(service.readManifest(projectRoot)).resolves.toContain( - '"workspace_id":"ws_0001"', - ) + await expect(service.readManifest(projectRoot)).resolves.toMatchObject({ + project_id: 'proj_gcd', + workspaces: [{ workspace_id: 'ws_0001' }], + }) await expect(service.listProjectEntries(projectRoot)).resolves.toEqual([ 'project.json', 'ws_0001', ]) - await expect( - service.readWorkspaceTexts({ - projectRoot, - workspacePath: workspaceRoot, - paths: ['home/flow.json', 'sta_ecc/analysis/qor_metrics.json'], - }), - ).resolves.toEqual({ - texts: { - 'home/flow.json': '{"steps":[]}', - 'sta_ecc/analysis/qor_metrics.json': null, - }, - unavailablePaths: [], - }) - await expect( - service.readWorkspaceTexts({ - projectRoot, - workspacePath: workspaceRoot, - paths: [...projectManagementWorkspaceSummaryPaths], - }), - ).resolves.toMatchObject({ - texts: expect.objectContaining({ - 'home/flow.json': '{"steps":[]}', - 'lvs_ecc/analysis/qor_metrics.json': null, - }), - unavailablePaths: [], - }) }) - it('returns project.json text even when root_path does not match the selected directory', async () => { + it('derives the project root from the selected manifest directory', async () => { const { projectRoot } = await createProject() const manifest = JSON.parse( await readFile(join(projectRoot, 'project.json'), 'utf8'), @@ -100,108 +138,378 @@ describe('ProjectManagementReadService', () => { manifest.root_path = '/old/location/gcd' await writeFile(join(projectRoot, 'project.json'), JSON.stringify(manifest)) - await expect( - new ProjectManagementReadService().readManifest(projectRoot), - ).resolves.toContain('"root_path":"/old/location/gcd"') + await expect(createReadService().readManifest(projectRoot)).resolves.toMatchObject({ + root_path: projectRoot, + }) + }) + + it('reuses canonical design defaults from an existing Project Workspace', async () => { + const { projectRoot, workspaceRoot } = await createProject() + const readWorkspaceConfiguration = vi.fn().mockResolvedValue({ + workspaceSpec: { + design: { name: 'gcd', topModule: 'gcd_top', clockPort: 'clk_i' }, + inputs: [ + { inputId: 'rtl-main', role: 'rtl' }, + { inputId: 'rtl-helper', role: 'rtl' }, + { inputId: 'constraints', role: 'sdc' }, + ], + }, + workspaceBindings: { + inputs: { + 'rtl-main': `${workspaceRoot}/origin/gcd.v`, + 'rtl-helper': `${workspaceRoot}/origin/helper.sv`, + constraints: `${workspaceRoot}/origin/gcd.sdc`, + }, + }, + }) + + const manifest = await createReadService( + undefined, + readWorkspaceConfiguration, + ).readManifest(projectRoot) + + expect(readWorkspaceConfiguration).toHaveBeenCalledWith(workspaceRoot) + expect(manifest?.base_design).toMatchObject({ + clock: 'clk_i', + rtl_list: [`${workspaceRoot}/origin/gcd.v`, `${workspaceRoot}/origin/helper.sv`], + sdc: `${workspaceRoot}/origin/gcd.sdc`, + top_module: 'gcd_top', + }) }) - it('rejects undeclared workspaces and files outside the summary allowlist', async () => { + it('rejects undeclared workspaces for Step Configuration reads', async () => { const { projectRoot } = await createProject() const undeclaredWorkspace = join(projectRoot, 'ws_0002') await mkdir(undeclaredWorkspace) - const service = new ProjectManagementReadService() + const service = createReadService(vi.fn()) await expect( - service.readWorkspaceTexts({ + service.readWorkspaceStepConfiguration({ projectRoot, + step: 'CTS', workspacePath: undeclaredWorkspace, - paths: ['home/flow.json'], }), ).rejects.toThrow('not declared') - await expect( - service.readWorkspaceTexts({ - projectRoot, - workspacePath: join(projectRoot, 'ws_0001'), - paths: ['../../settings.json'], - }), - ).rejects.toThrow('not allowed') + }) + + it('throws ENOTDIR when the project root exists but is not a directory', async () => { + const { projectRoot } = await createProject() + const filePath = join(projectRoot, 'not-a-project') + await writeFile(filePath, 'not a directory') + const service = createReadService() + + await expect(service.readManifest(filePath)).rejects.toMatchObject({ + code: 'ENOTDIR', + }) + }) + + it('throws ENOENT when the project root directory is gone', async () => { + const missingRoot = join(tmpdir(), `ecos-project-management-missing-${Date.now()}`) + const service = createReadService() + + await expect(service.readManifest(missingRoot)).rejects.toMatchObject({ + code: 'ENOENT', + }) }) it('requires a valid manifest before listing project root entries', async () => { const emptyRoot = await mkdtemp(join(tmpdir(), 'ecos-project-management-empty-')) temporaryDirectories.push(emptyRoot) - const service = new ProjectManagementReadService() + const service = createReadService() await expect(service.listProjectEntries(emptyRoot)).rejects.toThrow( 'Project manifest does not exist.', ) }) - it('keeps readable workspace summaries when one optional artifact exceeds the limit', async () => { + it('reads Step Options through the ECC domain reader', async () => { const { projectRoot, workspaceRoot } = await createProject() - const metricsPath = join(workspaceRoot, 'sta_ecc', 'analysis', 'qor_metrics.json') - await mkdir(join(workspaceRoot, 'sta_ecc', 'analysis'), { recursive: true }) - await writeFile(metricsPath, 'x'.repeat(256 * 1024 + 1)) - const service = new ProjectManagementReadService() + const readStepConfiguration = vi.fn().mockResolvedValue({ + options: { cts_buf_list: 'BUF' }, + status: 'available', + step: 'CTS', + stepId: 'CTS', + workspaceId: 'workspace-1', + workspaceRevision: 1, + }) + const service = createReadService(readStepConfiguration) await expect( - service.readWorkspaceTexts({ + service.readWorkspaceStepConfiguration({ projectRoot, + step: 'CTS', workspacePath: workspaceRoot, - paths: ['home/flow.json', 'sta_ecc/analysis/qor_metrics.json'], }), ).resolves.toEqual({ - texts: { - 'home/flow.json': '{"steps":[]}', - 'sta_ecc/analysis/qor_metrics.json': null, + options: { cts_buf_list: 'BUF' }, + step: 'CTS', + stepId: 'CTS', + status: 'available', + workspaceId: 'workspace-1', + workspaceRevision: 1, + }) + expect(readStepConfiguration).toHaveBeenCalledWith(workspaceRoot, 'CTS') + }) + + it('reads and validates one persisted Engineering Snapshot without a Runtime session', async () => { + const { projectRoot, workspaceRoot } = await createProject() + const text = JSON.stringify(engineeringSnapshot()) + await writeFile(join(workspaceRoot, 'home', 'engineering-snapshot.json'), text) + + const result = await createReadService().readEngineeringSnapshot({ + projectRoot, + workspacePath: workspaceRoot, + }) + + expect(result).toMatchObject({ + ok: true, + readBytes: Buffer.byteLength(text), + sections: { + artifacts: { status: 'ready' }, + flow: { status: 'ready' }, + qor: { status: 'ready' }, + signoff: { status: 'ready' }, + }, + snapshot: { + workspaceId: 'engineering-workspace', + workspaceRevision: 1, + }, + }) + }) + + it('loads the matching stale predecessor without replacing the current Revision', async () => { + const { projectRoot, workspaceRoot } = await createProject() + const stale = engineeringSnapshot() + const current = { + ...engineeringSnapshot(), + workspaceRevision: 2, + stalePredecessor: { + workspaceRevision: 1, + invalidatedStepIds: ['Place', 'CTS'], + }, + } + await writeFile( + join(workspaceRoot, 'home', 'engineering-snapshot.json'), + JSON.stringify(current), + ) + await writeFile( + join(workspaceRoot, 'home', 'engineering-snapshot.stale.json'), + JSON.stringify(stale), + ) + + const result = await createReadService().readEngineeringSnapshot({ + projectRoot, + workspacePath: workspaceRoot, + }) + + expect(result).toMatchObject({ + ok: true, + snapshot: { workspaceRevision: 2 }, + staleSnapshot: { + ok: true, + snapshot: { workspaceRevision: 1 }, }, - unavailablePaths: ['sta_ecc/analysis/qor_metrics.json'], }) }) - it('rejects an allowed artifact path that resolves outside its workspace', async () => { + it('returns a stable reason when the Engineering Snapshot is missing', async () => { const { projectRoot, workspaceRoot } = await createProject() - const flowPath = join(workspaceRoot, 'home', 'flow.json') - await unlink(flowPath) - await symlink(join(projectRoot, 'project.json'), flowPath) - const service = new ProjectManagementReadService() await expect( - service.readWorkspaceTexts({ + createReadService().readEngineeringSnapshot({ projectRoot, workspacePath: workspaceRoot, - paths: ['home/flow.json'], }), - ).rejects.toThrow('outside its workspace') + ).resolves.toEqual({ + ok: false, + readBytes: 0, + issue: { code: 'ENGINEERING_SNAPSHOT_MISSING' }, + }) }) - it('serves step-config files from a declared workspace but rejects unlisted config paths', async () => { + it('returns a stable reason for an unsupported Engineering Snapshot schema', async () => { const { projectRoot, workspaceRoot } = await createProject() - const configDir = join(workspaceRoot, 'config') - await mkdir(configDir) - await writeFile(join(configDir, 'cts_ecc.json'), '{"cts_buf_list":"BUF"}') - const service = new ProjectManagementReadService() + await writeFile( + join(workspaceRoot, 'home', 'engineering-snapshot.json'), + JSON.stringify({ ...engineeringSnapshot(), schemaVersion: 99 }), + ) + + await expect( + createReadService().readEngineeringSnapshot({ + projectRoot, + workspacePath: workspaceRoot, + }), + ).resolves.toMatchObject({ + ok: false, + issue: { code: 'ENGINEERING_SNAPSHOT_SCHEMA_UNSUPPORTED' }, + }) + }) + + it('rejects a declared Workspace symlink that resolves outside the Project', async () => { + const { projectRoot, workspaceRoot } = await createProject() + const outside = await mkdtemp(join(tmpdir(), 'ecos-project-workspace-outside-')) + temporaryDirectories.push(outside) + await mkdir(join(outside, 'home')) + await writeFile( + join(outside, 'home', 'engineering-snapshot.json'), + JSON.stringify(engineeringSnapshot()), + ) + await rm(workspaceRoot, { recursive: true }) + await symlink(outside, workspaceRoot, 'dir') + + await expect( + createReadService().readEngineeringSnapshot({ + projectRoot, + workspacePath: workspaceRoot, + }), + ).resolves.toEqual({ + ok: false, + readBytes: 0, + issue: { code: 'WORKSPACE_PATH_OUTSIDE_PROJECT' }, + }) + }) + + it('rejects an oversized Engineering Snapshot before parsing with exact sizes', async () => { + const { projectRoot, workspaceRoot } = await createProject() + await writeFile( + join(workspaceRoot, 'home', 'engineering-snapshot.json'), + Buffer.alloc(ENGINEERING_SNAPSHOT_MAX_BYTES + 1), + ) await expect( - service.readWorkspaceTexts({ + createReadService().readEngineeringSnapshot({ projectRoot, workspacePath: workspaceRoot, - paths: ['home/flow.json', 'config/cts_ecc.json', 'config/rcx.json'], }), ).resolves.toEqual({ - texts: { - 'home/flow.json': '{"steps":[]}', - 'config/cts_ecc.json': '{"cts_buf_list":"BUF"}', - 'config/rcx.json': null, + ok: false, + readBytes: ENGINEERING_SNAPSHOT_MAX_BYTES + 1, + issue: { + code: 'ENGINEERING_SNAPSHOT_TOO_LARGE', + actualSizeBytes: ENGINEERING_SNAPSHOT_MAX_BYTES + 1, + allowedSizeBytes: ENGINEERING_SNAPSHOT_MAX_BYTES, }, - unavailablePaths: [], + }) + }) + + it('defers Artifact realpath validation until the declared Artifact is requested', async () => { + const { projectRoot, workspaceRoot } = await createProject() + const outside = join(projectRoot, 'outside.json') + const reference = 'sta_ecc/analysis/qor_metrics.json' + await writeFile(outside, '{}') + await mkdir(join(workspaceRoot, 'sta_ecc', 'analysis'), { recursive: true }) + await symlink(outside, join(workspaceRoot, reference)) + const snapshot = engineeringSnapshot() + snapshot.artifacts.push({ + artifactId: 'artifact-metrics', + availability: 'available', + kind: 'qor_metrics', + name: 'qor_metrics.json', + reference, + sha256: 'a'.repeat(64), + sizeBytes: 2, + stepId: 'sta', + }) + await writeFile( + join(workspaceRoot, 'home', 'engineering-snapshot.json'), + JSON.stringify(snapshot), + ) + + const service = createReadService() + const result = await service.readEngineeringSnapshot({ + projectRoot, + workspacePath: workspaceRoot, + }) + + expect(result.ok && result.sections.artifacts).toEqual({ + status: 'ready', + data: snapshot.artifacts, + issues: [], }) await expect( - service.readWorkspaceTexts({ + service.readVerifiedArtifact({ + artifact: { reference, sha256: 'a'.repeat(64), sizeBytes: 2 }, projectRoot, workspacePath: workspaceRoot, - paths: ['config/evil.json'], }), - ).rejects.toThrow('not allowed') + ).resolves.toMatchObject({ + ok: false, + code: 'ARTIFACT_REFERENCE_OUTSIDE_WORKSPACE', + }) + }) + + it('reads only bounded artifacts whose size, hash, and JSON match the Snapshot', async () => { + const { projectRoot, workspaceRoot } = await createProject() + const reference = 'route_ecc/analysis/qor_metrics.json' + const path = join(workspaceRoot, reference) + const valid = '{"schema_version":3,"metrics":[]}' + await mkdir(join(workspaceRoot, 'route_ecc', 'analysis'), { recursive: true }) + await writeFile(path, valid) + const request = (sizeBytes: number, sha256: string) => ({ + artifacts: [{ reference, sha256, sizeBytes }], + projectRoot, + workspacePath: workspaceRoot, + }) + const service = createReadService() + + await expect( + service.readVerifiedArtifacts( + request( + Buffer.byteLength(valid), + createHash('sha256').update(valid).digest('hex'), + ), + ), + ).resolves.toEqual({ ok: true, texts: { [reference]: valid } }) + await expect( + service.readVerifiedArtifact({ + ...request( + Buffer.byteLength(valid), + createHash('sha256').update(valid).digest('hex'), + ), + artifact: request( + Buffer.byteLength(valid), + createHash('sha256').update(valid).digest('hex'), + ).artifacts[0]!, + }), + ).resolves.toEqual({ ok: true, bytes: new TextEncoder().encode(valid) }) + + await expect( + service.readVerifiedArtifacts( + request(Buffer.byteLength(valid) + 1, 'a'.repeat(64)), + ), + ).resolves.toMatchObject({ ok: false, code: 'ARTIFACT_REVISION_MISMATCH' }) + await expect( + service.readVerifiedArtifacts(request(Buffer.byteLength(valid), 'a'.repeat(64))), + ).resolves.toMatchObject({ ok: false, code: 'ARTIFACT_REVISION_MISMATCH' }) + + const invalidJson = 'x'.repeat(Buffer.byteLength(valid)) + await writeFile(path, invalidJson) + await expect( + service.readVerifiedArtifacts( + request( + Buffer.byteLength(invalidJson), + createHash('sha256').update(invalidJson).digest('hex'), + ), + ), + ).resolves.toMatchObject({ ok: false, code: 'FINDINGS_ARTIFACT_INVALID_JSON' }) + + await writeFile(path, 'x'.repeat(PROJECT_FINDINGS_ARTIFACT_MAX_BYTES + 1)) + await expect( + service.readVerifiedArtifacts( + request(PROJECT_FINDINGS_ARTIFACT_MAX_BYTES + 1, 'a'.repeat(64)), + ), + ).resolves.toMatchObject({ ok: false, code: 'FINDINGS_ARTIFACT_TOO_LARGE' }) + + await unlink(path) + await expect( + service.readVerifiedArtifacts(request(Buffer.byteLength(valid), 'a'.repeat(64))), + ).resolves.toMatchObject({ ok: false, code: 'ARTIFACT_REFERENCE_MISSING' }) + + const outside = join(projectRoot, 'outside-findings.json') + await writeFile(outside, valid) + await symlink(outside, path) + await expect( + service.readVerifiedArtifacts(request(Buffer.byteLength(valid), 'a'.repeat(64))), + ).resolves.toMatchObject({ ok: false, code: 'ARTIFACT_REFERENCE_OUTSIDE_WORKSPACE' }) }) }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectManagementReadService.ts b/ecos/gui/apps/desktop-electron/electron/services/projectManagementReadService.ts index 44c9f3344..cc4448a30 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/projectManagementReadService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/projectManagementReadService.ts @@ -1,25 +1,109 @@ +import { createHash } from 'node:crypto' import { open, readdir, realpath, stat } from 'node:fs/promises' -import { join, relative, resolve } from 'node:path' +import { isAbsolute, join, relative, resolve } from 'node:path' import { - parseProjectManifest, - projectManagementWorkspaceReadablePaths, + ENGINEERING_SNAPSHOT_MAX_BYTES, + parseEngineeringSnapshotJson, } from '@ecos-studio/shared' import type { - DesktopProjectManagementWorkspaceTextsRequest, - DesktopProjectManagementWorkspaceTextsResult, + DesktopProjectManagementWorkspaceStepConfigurationRequest, + DesktopProjectManagementWorkspaceStepConfigurationResult, + EngineeringSnapshotValidationResult, + ProjectManifest, } from '@ecos-studio/shared' import { isPathWithinRoot } from './pathScope' const PROJECT_MANIFEST_MAX_BYTES = 512 * 1024 -const PROJECT_WORKSPACE_TEXT_MAX_BYTES = 256 * 1024 -const PROJECT_WORKSPACE_READ_CONCURRENCY = 4 -const PROJECT_WORKSPACE_READ_LIMIT = projectManagementWorkspaceReadablePaths.length +export const PROJECT_FINDINGS_ARTIFACT_MAX_BYTES = 1024 * 1024 +export const PROJECT_BINARY_ARTIFACT_MAX_BYTES = 16 * 1024 * 1024 -const PROJECT_MANAGEMENT_WORKSPACE_PATHS = new Set( - projectManagementWorkspaceReadablePaths, -) +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export type ProjectEngineeringSnapshotReadResult = EngineeringSnapshotValidationResult & { + readBytes: number + staleSnapshot?: Extract +} + +export type VerifiedProjectArtifactsReadResult = + | { ok: true; texts: Record } + | { + ok: false + code: + | 'ARTIFACT_REVISION_MISMATCH' + | 'FINDINGS_ARTIFACT_INVALID_JSON' + | 'FINDINGS_ARTIFACT_TOO_LARGE' + | 'ARTIFACT_REFERENCE_MISSING' + | 'ARTIFACT_REFERENCE_OUTSIDE_WORKSPACE' + | 'FINDINGS_READ_FAILED' + reference: string + } + +export type VerifiedProjectArtifactReadResult = + | { ok: true; bytes: Uint8Array } + | Exclude + +export interface ProjectManifestReader { + discover(directory: string): Promise + load(projectRoot: string): Promise +} + +export interface ProjectWorkspaceConfiguration { + workspaceBindings: Record + workspaceSpec: Record +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function inputPaths( + configuration: ProjectWorkspaceConfiguration, + role: string, +): string[] { + const inputs = Array.isArray(configuration.workspaceSpec.inputs) + ? configuration.workspaceSpec.inputs + : [] + const bindings = isRecord(configuration.workspaceBindings.inputs) + ? configuration.workspaceBindings.inputs + : {} + return inputs.flatMap((value) => { + if (!isRecord(value) || value.role !== role) return [] + const path = stringValue(bindings[stringValue(value.inputId)]) + return path ? [path] : [] + }) +} -class ProjectManagementWorkspacePathError extends Error {} +function applyWorkspaceDesignDefaults( + manifest: ProjectManifest, + configuration: ProjectWorkspaceConfiguration, +): ProjectManifest { + const design = isRecord(configuration.workspaceSpec.design) + ? configuration.workspaceSpec.design + : {} + const rtlList = inputPaths(configuration, 'rtl') + const filelist = inputPaths(configuration, 'filelist')[0] + const sdc = inputPaths(configuration, 'sdc')[0] + const originVerilog = inputPaths(configuration, 'netlist')[0] + const originDef = inputPaths(configuration, 'def')[0] + + return { + ...manifest, + base_design: { + ...manifest.base_design, + ...(filelist ? { filelist } : {}), + ...(originDef ? { origin_def: originDef } : {}), + ...(originVerilog ? { origin_verilog: originVerilog } : {}), + ...(rtlList.length ? { rtl_list: rtlList } : {}), + ...(sdc ? { sdc } : {}), + ...(stringValue(design.clockPort) ? { clock: stringValue(design.clockPort) } : {}), + ...(stringValue(design.topModule) + ? { top_module: stringValue(design.topModule) } + : {}), + }, + } +} function pathsEqual(leftPath: string, rightPath: string): boolean { return relative(resolve(leftPath), resolve(rightPath)) === '' @@ -35,7 +119,12 @@ async function canonicalizeExistingDirectory(path: string): Promise { const canonicalPath = await realpath(path) const pathStats = await stat(canonicalPath) if (!pathStats.isDirectory()) { - throw new Error(`Project management path is not a directory: ${path}`) + throw Object.assign( + new Error(`Project management path is not a directory: ${path}`), + { + code: 'ENOTDIR', + }, + ) } return canonicalPath } @@ -61,34 +150,57 @@ async function readOptionalBoundedTextFile( } } -async function mapWithConcurrency( - values: readonly T[], - concurrency: number, - mapper: (value: T) => Promise, -): Promise { - const results: R[] = [] - let nextIndex = 0 - const workers = Array.from( - { length: Math.min(Math.max(concurrency, 1), values.length) }, - async () => { - while (nextIndex < values.length) { - const index = nextIndex - nextIndex += 1 - results[index] = await mapper(values[index]!) - } - }, - ) - await Promise.all(workers) - return results -} - export class ProjectManagementReadService { - async readManifest(projectRoot: string): Promise { + constructor( + private readonly projectManifestReader: ProjectManifestReader, + private readonly readStepConfiguration?: ( + workspacePath: string, + step: string, + ) => Promise, + private readonly readWorkspaceConfiguration?: ( + workspacePath: string, + ) => Promise, + ) {} + + async readManifest(projectRoot: string): Promise { const root = await canonicalizeExistingDirectory(projectRoot) - return await readOptionalBoundedTextFile( + const content = await readOptionalBoundedTextFile( join(root, 'project.json'), PROJECT_MANIFEST_MAX_BYTES, ) + if (!content) return null + const manifest = await this.projectManifestReader.load(root) + if (!this.readWorkspaceConfiguration) return manifest + + const sourceWorkspace = + manifest.workspaces.find( + (workspace) => + workspace.workspace_id === manifest.qor_baseline?.workspace_id && + workspace.status !== 'archived', + ) ?? manifest.workspaces.find((workspace) => workspace.status !== 'archived') + if (!sourceWorkspace) return manifest + const sourceWorkspacePath = resolve(root, sourceWorkspace.workspace_path) + if ( + pathsEqual(sourceWorkspacePath, root) || + !isPathWithinRoot(sourceWorkspacePath, root) + ) { + return manifest + } + + try { + return applyWorkspaceDesignDefaults( + manifest, + await this.readWorkspaceConfiguration(sourceWorkspacePath), + ) + } catch { + return manifest + } + } + + async discoverProject(directory: string): Promise { + return await this.projectManifestReader.discover( + await canonicalizeExistingDirectory(directory), + ) } async listProjectEntries(projectRoot: string): Promise { @@ -103,44 +215,180 @@ export class ProjectManagementReadService { .sort((left, right) => left.localeCompare(right)) } - async readWorkspaceTexts( - request: DesktopProjectManagementWorkspaceTextsRequest, - ): Promise { - const paths = normalizeRequestedPaths(request.paths) - const project = await this.loadProject(request.projectRoot) - if (!project.manifest) { - throw new Error('Project manifest does not exist.') + async readWorkspaceStepConfiguration( + request: DesktopProjectManagementWorkspaceStepConfigurationRequest, + ): Promise { + if ( + !this.readStepConfiguration || + !/^[A-Za-z0-9][A-Za-z0-9 _-]{0,127}$/.test(request.step) + ) { + throw new Error('Workspace Step Configuration request is invalid.') } + const project = await this.loadProject(request.projectRoot) + if (!project.manifest) throw new Error('Project manifest does not exist.') const workspacePath = await this.resolveDeclaredWorkspace( project.root, project.manifest.workspaces.map((workspace) => workspace.workspace_path), request.workspacePath, ) - const entries = await mapWithConcurrency( - paths, - PROJECT_WORKSPACE_READ_CONCURRENCY, - async (path) => { + const result = await this.readStepConfiguration(workspacePath, request.step) + if (!isRecord(result) || typeof result.status !== 'string') { + throw new Error('ECC Step Configuration is unavailable.') + } + return result + } + + async readEngineeringSnapshot(request: { + projectRoot: string + workspacePath: string + }): Promise { + let readBytes = 0 + try { + const projectRoot = await canonicalizeExistingDirectory(request.projectRoot) + const workspaceCandidate = resolve(request.workspacePath) + if ( + pathsEqual(workspaceCandidate, projectRoot) || + !isPathWithinRoot(workspaceCandidate, projectRoot) + ) { + return snapshotFailure('WORKSPACE_PATH_OUTSIDE_PROJECT', readBytes) + } + const workspaceRoot = await canonicalizeExistingDirectory(workspaceCandidate) + if (!isPathWithinRoot(workspaceRoot, projectRoot)) { + return snapshotFailure('WORKSPACE_PATH_OUTSIDE_PROJECT', readBytes) + } + + let snapshotPath: string + try { + snapshotPath = await realpath( + join(workspaceRoot, 'home', 'engineering-snapshot.json'), + ) + } catch (error) { + if (isNodeErrorWithCode(error, 'ENOENT')) { + return snapshotFailure('ENGINEERING_SNAPSHOT_MISSING', readBytes) + } + throw error + } + if (!isPathWithinRoot(snapshotPath, workspaceRoot)) { + return snapshotFailure('WORKSPACE_PATH_OUTSIDE_PROJECT', readBytes) + } + + const file = await readBoundedSnapshot(snapshotPath) + readBytes = file.sizeBytes + if (!file.bytes) { + return { + ok: false, + readBytes, + issue: { + code: 'ENGINEERING_SNAPSHOT_TOO_LARGE', + actualSizeBytes: readBytes, + allowedSizeBytes: ENGINEERING_SNAPSHOT_MAX_BYTES, + }, + } + } + const validated = parseEngineeringSnapshotJson(file.bytes) + if (!validated.ok || !validated.snapshot.stalePredecessor) { + return { ...validated, readBytes } + } + try { + const stalePath = await realpath( + join(workspaceRoot, 'home', 'engineering-snapshot.stale.json'), + ) + if (!isPathWithinRoot(stalePath, workspaceRoot)) { + return { ...validated, readBytes } + } + const staleFile = await readBoundedSnapshot(stalePath) + if (!staleFile.bytes) return { ...validated, readBytes } + const staleSnapshot = parseEngineeringSnapshotJson(staleFile.bytes) + if ( + staleSnapshot.ok && + staleSnapshot.snapshot.workspaceId === validated.snapshot.workspaceId && + staleSnapshot.snapshot.workspaceRevision === + validated.snapshot.stalePredecessor.workspaceRevision + ) { + return { ...validated, readBytes, staleSnapshot } + } + } catch { + // A missing or invalid predecessor must not hide the current Revision. + } + return { ...validated, readBytes } + } catch { + return snapshotFailure('ENGINEERING_SNAPSHOT_READ_FAILED', readBytes) + } + } + + async readVerifiedArtifacts(request: { + projectRoot: string + workspacePath: string + artifacts: Array<{ reference: string; sha256: string; sizeBytes: number }> + }): Promise { + if ( + request.artifacts.length < 1 || + request.artifacts.length > 4 || + new Set(request.artifacts.map((artifact) => artifact.reference)).size !== + request.artifacts.length + ) { + return { ok: false, code: 'ARTIFACT_REFERENCE_OUTSIDE_WORKSPACE', reference: '' } + } + try { + const project = await this.loadProject(request.projectRoot) + if (!project.manifest) { + return { ok: false, code: 'FINDINGS_READ_FAILED', reference: '' } + } + const workspaceRoot = await this.resolveDeclaredWorkspace( + project.root, + project.manifest.workspaces.map((workspace) => workspace.workspace_path), + request.workspacePath, + ) + const texts: Record = {} + for (const artifact of request.artifacts) { + const result = await readVerifiedArtifactBytes( + workspaceRoot, + artifact, + PROJECT_FINDINGS_ARTIFACT_MAX_BYTES, + ) + if (!result.ok) return result + let text: string try { + text = new TextDecoder('utf-8', { fatal: true }).decode(result.bytes) + JSON.parse(text) + } catch { return { - path, - text: await this.readWorkspaceTextFile( - workspacePath, - path, - PROJECT_WORKSPACE_TEXT_MAX_BYTES, - ), - unavailable: false, + ok: false, + code: 'FINDINGS_ARTIFACT_INVALID_JSON', + reference: artifact.reference, } - } catch (error) { - if (error instanceof ProjectManagementWorkspacePathError) throw error - return { path, text: null, unavailable: true } } - }, - ) - return { - texts: Object.fromEntries(entries.map(({ path, text }) => [path, text])), - unavailablePaths: entries - .filter(({ unavailable }) => unavailable) - .map(({ path }) => path), + texts[artifact.reference] = text + } + return { ok: true, texts } + } catch { + return { ok: false, code: 'FINDINGS_READ_FAILED', reference: '' } + } + } + + async readVerifiedArtifact(request: { + projectRoot: string + workspacePath: string + artifact: { reference: string; sha256: string; sizeBytes: number } + }): Promise { + try { + const project = await this.loadProject(request.projectRoot) + if (!project.manifest) { + return { ok: false, code: 'FINDINGS_READ_FAILED', reference: '' } + } + const workspaceRoot = await this.resolveDeclaredWorkspace( + project.root, + project.manifest.workspaces.map((workspace) => workspace.workspace_path), + request.workspacePath, + ) + const result = await readVerifiedArtifactBytes( + workspaceRoot, + request.artifact, + PROJECT_BINARY_ARTIFACT_MAX_BYTES, + ) + return result.ok ? { ok: true, bytes: Uint8Array.from(result.bytes) } : result + } catch { + return { ok: false, code: 'FINDINGS_READ_FAILED', reference: '' } } } @@ -150,9 +398,9 @@ export class ProjectManagementReadService { join(root, 'project.json'), PROJECT_MANIFEST_MAX_BYTES, ) - if (!content) return { content: null, manifest: null, root } + if (!content) return { manifest: null, root } - const manifest = parseProjectManifest(content) + const manifest = await this.projectManifestReader.load(root) const manifestRoot = await canonicalizeExistingDirectory(manifest.root_path) if (!pathsEqual(root, manifestRoot)) { throw new Error( @@ -165,7 +413,7 @@ export class ProjectManagementReadService { throw new Error('Project manifest contains a workspace outside the project root.') } } - return { content, manifest, root } + return { manifest, root } } private async resolveDeclaredWorkspace( @@ -188,38 +436,140 @@ export class ProjectManagementReadService { } return canonicalPath } +} - private async readWorkspaceTextFile( - workspaceRoot: string, - relativePath: string, - maxBytes: number, - ): Promise { - const requestedPath = join(workspaceRoot, relativePath) - let canonicalPath: string - try { - canonicalPath = await realpath(requestedPath) - } catch (error) { - if (isNodeErrorWithCode(error, 'ENOENT')) return null - throw error - } - if (!isPathWithinRoot(canonicalPath, workspaceRoot)) { - throw new ProjectManagementWorkspacePathError( - 'Project management workspace file resolves outside its workspace.', +async function readVerifiedArtifactBytes( + workspaceRoot: string, + artifact: { reference: string; sha256: string; sizeBytes: number }, + maxBytes: number, +): Promise< + { ok: true; bytes: Buffer } | Exclude +> { + const unsafe = (): Exclude => ({ + ok: false, + code: 'ARTIFACT_REFERENCE_OUTSIDE_WORKSPACE', + reference: artifact.reference, + }) + if ( + !artifact.reference || + isAbsolute(artifact.reference) || + !Number.isSafeInteger(artifact.sizeBytes) || + artifact.sizeBytes < 0 || + !/^[a-f0-9]{64}$/.test(artifact.sha256) + ) { + return unsafe() + } + const candidate = resolve(workspaceRoot, artifact.reference) + if (candidate === workspaceRoot || !isPathWithinRoot(candidate, workspaceRoot)) { + return unsafe() + } + let canonicalPath: string + try { + canonicalPath = await realpath(candidate) + } catch (error) { + return isNodeErrorWithCode(error, 'ENOENT') + ? { ok: false, code: 'ARTIFACT_REFERENCE_MISSING', reference: artifact.reference } + : { ok: false, code: 'FINDINGS_READ_FAILED', reference: artifact.reference } + } + if (!isPathWithinRoot(canonicalPath, workspaceRoot)) return unsafe() + + const handle = await open(canonicalPath, 'r') + try { + const fileStats = await handle.stat() + if (!fileStats.isFile()) { + return { + ok: false, + code: 'ARTIFACT_REFERENCE_MISSING', + reference: artifact.reference, + } + } + if (fileStats.size > maxBytes || artifact.sizeBytes > maxBytes) { + return { + ok: false, + code: 'FINDINGS_ARTIFACT_TOO_LARGE', + reference: artifact.reference, + } + } + if (fileStats.size !== artifact.sizeBytes) { + return { + ok: false, + code: 'ARTIFACT_REVISION_MISMATCH', + reference: artifact.reference, + } + } + const buffer = Buffer.alloc(artifact.sizeBytes + 1) + let offset = 0 + while (offset < buffer.length) { + const { bytesRead } = await handle.read( + buffer, + offset, + buffer.length - offset, + offset, ) + if (bytesRead === 0) break + offset += bytesRead + } + if (offset > maxBytes) { + return { + ok: false, + code: 'FINDINGS_ARTIFACT_TOO_LARGE', + reference: artifact.reference, + } + } + if (offset !== artifact.sizeBytes) { + return { + ok: false, + code: 'ARTIFACT_REVISION_MISMATCH', + reference: artifact.reference, + } + } + const bytes = buffer.subarray(0, offset) + if (createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) { + return { + ok: false, + code: 'ARTIFACT_REVISION_MISMATCH', + reference: artifact.reference, + } } - return await readOptionalBoundedTextFile(canonicalPath, maxBytes) + return { ok: true, bytes } + } finally { + await handle.close() } } -function normalizeRequestedPaths(paths: string[]): string[] { - const uniquePaths = [...new Set(paths)] - if (uniquePaths.length === 0 || uniquePaths.length > PROJECT_WORKSPACE_READ_LIMIT) { - throw new Error('Project management workspace read has an invalid path count.') - } - for (const path of uniquePaths) { - if (!PROJECT_MANAGEMENT_WORKSPACE_PATHS.has(path)) { - throw new Error(`Project management workspace path is not allowed: ${path}`) +function snapshotFailure( + code: string, + readBytes: number, +): ProjectEngineeringSnapshotReadResult { + return { ok: false, readBytes, issue: { code } } +} + +async function readBoundedSnapshot( + path: string, +): Promise<{ bytes: Buffer | null; sizeBytes: number }> { + const handle = await open(path, 'r') + try { + const initialSize = (await handle.stat()).size + if (initialSize > ENGINEERING_SNAPSHOT_MAX_BYTES) { + return { bytes: null, sizeBytes: initialSize } + } + const buffer = Buffer.alloc(ENGINEERING_SNAPSHOT_MAX_BYTES + 1) + let offset = 0 + while (offset < buffer.length) { + const { bytesRead } = await handle.read( + buffer, + offset, + buffer.length - offset, + offset, + ) + if (bytesRead === 0) break + offset += bytesRead + } + if (offset > ENGINEERING_SNAPSHOT_MAX_BYTES) { + return { bytes: null, sizeBytes: Math.max(offset, (await handle.stat()).size) } } + return { bytes: buffer.subarray(0, offset), sizeBytes: offset } + } finally { + await handle.close() } - return uniquePaths } diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectManifestMutationValidation.ts b/ecos/gui/apps/desktop-electron/electron/services/projectManifestMutationValidation.ts new file mode 100644 index 000000000..098143254 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/projectManifestMutationValidation.ts @@ -0,0 +1,142 @@ +import type { ProjectManifestMutation } from '@ecos-studio/shared' + +export function validateProjectManifestMutation( + mutation: unknown, +): asserts mutation is ProjectManifestMutation { + if (!isRecord(mutation) || typeof mutation.type !== 'string') { + throw new Error('Project manifest mutation is required') + } + + switch (mutation.type) { + case 'create': + requireString(mutation.name, 'Project manifest create mutation name') + requireString(mutation.designName, 'Project manifest create mutation designName') + validateProjectManifestMpc(mutation.mpc) + return + case 'register-workspace': { + const input = requireRecord( + mutation.input, + 'Project manifest workspace registration input', + ) + requireString(input.projectRoot, 'Project manifest workspace projectRoot') + requireString(input.workspacePath, 'Project manifest workspace path') + requireOptionalString(input.projectName, 'Project manifest workspace projectName') + requireOptionalString( + input.sourceWorkspaceId, + 'Project manifest source workspace id', + ) + requireOptionalString(input.sourceStep, 'Project manifest source step') + requireOptionalString(input.sourceOutputPath, 'Project manifest source output path') + requireOptionalString(input.sourceOutputType, 'Project manifest source output type') + requireOptionalString(input.startStep, 'Project manifest start step') + requireOptionalString(input.endStep, 'Project manifest end step') + if (input.config !== undefined) validateWorkspaceConfig(input.config) + return + } + case 'archive-workspace': + case 'delete-workspace': + requireString(mutation.workspaceId, 'Project manifest workspace id') + if ( + mutation.type === 'delete-workspace' && + mutation.deleteDirectory !== undefined && + typeof mutation.deleteDirectory !== 'boolean' + ) { + throw new Error( + 'Project manifest deleteDirectory must be a boolean when provided', + ) + } + return + case 'select-qor-baseline': + requireString(mutation.workspaceId, 'Project manifest QoR baseline workspace id') + requireOptionalString(mutation.reason, 'Project manifest QoR baseline reason') + return + case 'record-replacement-backup': { + const input = requireRecord( + mutation.input, + 'Project manifest replacement backup input', + ) + requireString(input.replacementId, 'Workspace replacement id') + requireOptionalString( + input.fallbackStartStep, + 'Project manifest fallback start step', + ) + requireOptionalString(input.fallbackEndStep, 'Project manifest fallback end step') + return + } + default: + throw new Error('Unsupported project manifest mutation') + } +} + +function validateWorkspaceConfig(value: unknown): void { + const config = requireRecord(value, 'Project manifest workspace config') + for (const key of ['pdk', 'pdk_root', 'origin_verilog', 'origin_def']) { + requireOptionalString(config[key], `Project manifest workspace config ${key}`) + } + if ( + config.rtl_list !== undefined && + (!Array.isArray(config.rtl_list) || + config.rtl_list.some((item) => typeof item !== 'string')) + ) { + throw new Error( + 'Project manifest workspace config rtl_list must be an array of strings', + ) + } + if (config.parameters !== undefined && !isRecord(config.parameters)) { + throw new Error('Project manifest workspace config parameters must be an object') + } +} + +function validateProjectManifestMpc(value: unknown): void { + if (value === undefined || value === null) return + const mpc = requireRecord(value, 'Project manifest MPC') + const resourceId = requireString(mpc.resource_id, 'Project manifest MPC resource_id') + if (!resourceId.startsWith('mpc:') || resourceId.length === 4) { + throw new Error('Project manifest MPC resource_id must be an MPC resource id') + } + requireString(mpc.display_name, 'Project manifest MPC display_name') + requireString(mpc.installed_version, 'Project manifest MPC installed_version') + const mpcPath = normalizeMpcPath(requireString(mpc.path, 'Project manifest MPC path')) + const specPath = normalizeMpcPath( + requireString(mpc.spec_path, 'Project manifest MPC spec_path'), + ) + if (specPath !== `${mpcPath}/spec/spec.json.in`) { + throw new Error( + 'Project manifest MPC spec_path must reference spec/spec.json.in below MPC path', + ) + } + const design = requireRecord(mpc.design, 'Project manifest MPC design') + if (!Number.isInteger(design.index) || (design.index as number) < 0) { + throw new Error('Project manifest MPC design index must be a non-negative integer') + } + requireString(design.design_name, 'Project manifest MPC design design_name') + requireOptionalString(design.directory, 'Project manifest MPC design directory') + requireRecord(mpc.core_template, 'Project manifest MPC core_template') +} + +function normalizeMpcPath(path: string): string { + const normalized = path.replace(/\\/g, '/') + return normalized.length <= 1 ? normalized : normalized.replace(/\/+$/g, '') +} + +function requireRecord(value: unknown, name: string): Record { + if (!isRecord(value)) throw new Error(`${name} must be an object`) + return value +} + +function requireString(value: unknown, name: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${name} must be a non-empty string`) + } + return value +} + +function requireOptionalString(value: unknown, name: string): void { + if (value !== undefined && typeof value !== 'string') { + throw new Error(`${name} must be a string when provided`) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectManifestService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/projectManifestService.test.ts index 27c147544..03e8bd2fc 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/projectManifestService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/projectManifestService.test.ts @@ -1,888 +1,214 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { parseProjectManifest } from '@ecos-studio/shared' +import { describe, expect, it, vi } from 'vitest' import { ProjectManifestService, - type ProjectManifestBaselineSnapshotProvider, type ProjectManifestReplacementProvider, } from './projectManifestService' -const temporaryDirectories: string[] = [] - -async function createTemporaryProject(): Promise { - const directory = await mkdtemp(join(tmpdir(), 'ecos-project-manifest-service-')) - temporaryDirectories.push(directory) - return directory +const projectRoot = '/projects/gcd' +const emptyManifest = { + schema_version: 1 as const, + project_id: 'proj_gcd', + name: 'gcd', + design_name: 'gcd', + description: '', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + objectives: { primary: 'timing', directions: {} }, + workspaces: [], + mpc: null, + best_workspace: null, + qor_baseline: null, } function createService( - projectRoot: string, - replacementProvider?: ProjectManifestReplacementProvider, - baselineSnapshotProvider?: ProjectManifestBaselineSnapshotProvider, -): ProjectManifestService { - return new ProjectManifestService( - { - resolveProjectRoot: async (path) => { - if (path !== projectRoot) throw new Error('Unexpected project root') - return projectRoot - }, - }, - replacementProvider, - baselineSnapshotProvider, - ) + callRuntime = vi.fn(), + replacement?: ProjectManifestReplacementProvider, +) { + return { + callRuntime, + service: new ProjectManifestService( + { resolveProjectRoot: async (path) => path }, + replacement, + { callRuntime }, + ), + } } describe('ProjectManifestService', () => { - afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => rm(directory, { force: true, recursive: true })), + it('discovers a nested Workspace through the ECC Runtime', async () => { + const { callRuntime, service } = createService( + vi + .fn() + .mockResolvedValueOnce({ projectId: 'proj_gcd', projectRoot }) + .mockResolvedValueOnce(emptyManifest), ) - }) - it('serializes concurrent workspace registrations for the same project', async () => { - const projectRoot = await createTemporaryProject() - const service = createService(projectRoot) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'gcd', designName: 'gcd' }, + await expect(service.discover(`${projectRoot}/runs/ws-1`)).resolves.toMatchObject({ + project_id: 'proj_gcd', + root_path: projectRoot, }) - - await Promise.all([ - service.mutate({ - projectRoot, - mutation: { - type: 'register-workspace', - input: { - projectRoot, - projectName: 'gcd', - workspacePath: join(projectRoot, 'ws_0001'), - }, - }, - }), - service.mutate({ - projectRoot, - mutation: { - type: 'register-workspace', - input: { - projectRoot, - projectName: 'gcd', - workspacePath: join(projectRoot, 'ws_0002'), - }, - }, - }), + expect(callRuntime.mock.calls).toEqual([ + ['project.discover', { directory: `${projectRoot}/runs/ws-1` }], + ['project.manifest.load', { projectRoot }], ]) - - const manifest = parseProjectManifest( - await readFile(join(projectRoot, 'project.json'), 'utf8'), - ) - expect(manifest.workspaces.map((workspace) => workspace.workspace_id).sort()).toEqual( - ['ws_0001', 'ws_0002'], - ) }) - it('writes project manifests atomically and refuses to overwrite an existing project manifest', async () => { - const projectRoot = await createTemporaryProject() - const service = createService(projectRoot) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'gcd', designName: 'gcd' }, - }) - - await expect( - service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'replacement', designName: 'replacement' }, - }), - ).rejects.toThrow('Project manifest already exists') - - const manifest = parseProjectManifest( - await readFile(join(projectRoot, 'project.json'), 'utf8'), + it('delegates Manifest mutation to the ECC Runtime', async () => { + const { callRuntime, service } = createService( + vi.fn().mockResolvedValue(emptyManifest), ) - expect(manifest.name).toBe('gcd') - expect( - (await readdir(projectRoot)).filter((entry) => entry.endsWith('.tmp')), - ).toEqual([]) - }) - - it('writes the selected MPC association when creating a project manifest', async () => { - const projectRoot = await createTemporaryProject() - const service = createService(projectRoot) - const mpcPath = '/resources/mpcs/mpc-frame/0.1.0' const result = await service.mutate({ projectRoot, - mutation: { - type: 'create', - name: 'gcd', - designName: 'gcd', - mpc: { - resource_id: 'mpc:mpc-frame', - display_name: 'MPC Frame', - installed_version: '0.1.0', - path: mpcPath, - spec_path: `${mpcPath}/spec/spec.json.in`, - design: { index: 0, design_name: 'frame' }, - core_template: { minimum_area: 100, maximum_area: 500 }, - }, - }, + mutation: { type: 'archive-workspace', workspaceId: 'ws-1' }, }) - expect(parseProjectManifest(result.content).mpc).toEqual({ - resource_id: 'mpc:mpc-frame', - display_name: 'MPC Frame', - installed_version: '0.1.0', - path: mpcPath, - spec_path: `${mpcPath}/spec/spec.json.in`, - design: { index: 0, design_name: 'frame' }, - core_template: { minimum_area: 100, maximum_area: 500 }, + expect(callRuntime).toHaveBeenCalledWith('project.manifest.mutate', { + projectRoot, + mutation: { type: 'archive-workspace', workspaceId: 'ws-1' }, }) - }) - - it('atomically synchronizes the selected baseline without replacing project design_name', async () => { - const projectRoot = await createTemporaryProject() - const workspaceOne = join(projectRoot, 'ws_0001') - const workspaceTwo = join(projectRoot, 'ws_0002') - const snapshots: string[] = [] - const service = createService(projectRoot, undefined, { - loadBaselineSnapshot: async (workspacePath) => { - snapshots.push(workspacePath) - return { - parameters: { - PDK: 'ics55-baseline', - 'PDK Root': '/pdks/ics55-baseline', - Design: 'workspace_specific_design', - 'Top module': 'baseline_top', - Clock: 'baseline_clk', - 'Frequency max [MHz]': 123, - 'Max fanout': 17, - }, - pdk: {}, - db: { - INPUT: { - rtl_list: ['/sources/baseline.sv'], - origin_def: '/sources/baseline.def', - origin_verilog: '/sources/baseline.v', - }, - }, - } - }, + expect(result.manifest).toMatchObject({ + project_id: emptyManifest.project_id, + root_path: projectRoot, + workspaces: [], }) + }) - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'project label', designName: 'project_design' }, - }) - for (const workspacePath of [workspaceOne, workspaceTwo]) { - await service.mutate({ - projectRoot, - mutation: { - type: 'register-workspace', - input: { projectRoot, workspacePath }, + it('registers a missing Workspace through the ECC Runtime', async () => { + const registered = { + ...emptyManifest, + workspaces: [ + { + workspace_id: 'ws-1', + name: 'ws-1', + workspace_path: 'ws-1', + source_workspace_id: null, + lifecycle: 'active', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', }, - }) + ], } + const callRuntime = vi + .fn() + .mockResolvedValueOnce(emptyManifest) + .mockResolvedValueOnce(registered) + const service = createService(callRuntime).service - const result = await service.mutate({ + const evidence = await service.ensureWorkspaceRegistration( projectRoot, - mutation: { - type: 'select-qor-baseline', - workspaceId: 'ws_0002', - reason: 'Selected from Dashboard QoR Overview', - }, - }) - - const manifest = parseProjectManifest(result.content) - expect(snapshots).toEqual([workspaceTwo]) - expect(manifest.design_name).toBe('project_design') - expect(manifest.qor_baseline).toEqual({ - workspace_id: 'ws_0002', - reason: 'Selected from Dashboard QoR Overview', - }) - expect(manifest.base_design).toMatchObject({ - pdk: 'ics55-baseline', - pdk_root: '/pdks/ics55-baseline', - top_module: 'baseline_top', - clock: 'baseline_clk', - rtl_list: ['/sources/baseline.sv'], - origin_def: '/sources/baseline.def', - origin_verilog: '/sources/baseline.v', - parameters: { - design: 'project_design', - frequency_max: 123, - max_fanout: 17, - }, - }) - await expect(readFile(join(projectRoot, 'project.json'), 'utf8')).resolves.toBe( - result.content, + `${projectRoot}/ws-1`, + 'proj_gcd', ) - }) - - it('retains canonical params.toml geometry when synchronizing the baseline', async () => { - const projectRoot = await createTemporaryProject() - const workspacePath = join(projectRoot, 'ws_0001') - const service = createService(projectRoot, undefined, { - loadBaselineSnapshot: async () => ({ - parameters: { - pdk: 'ics55', - pdk_root: '/pdks/ics55', - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - frequency_max: 150, - max_fanout: 24, - die: { size: [46.2, 47.4], area: 2189.88 }, - core: { utilitization: 0.4, margin: [3, 3] }, - }, - pdk: {}, - db: { INPUT: {} }, - }), - }) - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'project label', designName: 'project_design' }, - }) - await service.mutate({ + expect(callRuntime).toHaveBeenLastCalledWith('project.manifest.mutate', { projectRoot, mutation: { type: 'register-workspace', - input: { projectRoot, workspacePath }, + input: { projectRoot, workspacePath: `${projectRoot}/ws-1` }, }, }) - - const result = await service.mutate({ - projectRoot, - mutation: { type: 'select-qor-baseline', workspaceId: 'ws_0001' }, - }) - - expect(parseProjectManifest(result.content).base_design.parameters).toMatchObject({ - design: 'project_design', - top_module: 'gcd', - clock: 'clk', - frequency_max: 150, - max_fanout: 24, - die_width: 46.2, - die_height: 47.4, - utilitization: 0.4, - margin: 3, - }) - }) - - it('rejects a baseline snapshot holding a bigint die dimension instead of shifting positions', async () => { - const projectRoot = await createTemporaryProject() - const workspacePath = join(projectRoot, 'ws_0001') - const service = createService(projectRoot, undefined, { - loadBaselineSnapshot: async () => ({ - parameters: { - pdk: 'ics55', - pdk_root: '/pdks/ics55', - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - die: { size: [9007199254740993n, 47.4], area: 2189.88 }, - core: { utilitization: 0.4, margin: [3, 3] }, - }, - pdk: {}, - db: { INPUT: {} }, - }), - }) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'project label', designName: 'project_design' }, - }) - await service.mutate({ - projectRoot, - mutation: { type: 'register-workspace', input: { projectRoot, workspacePath } }, - }) - - await expect( - service.mutate({ - projectRoot, - mutation: { type: 'select-qor-baseline', workspaceId: 'ws_0001' }, - }), - ).rejects.toThrow(/not a finite number/i) - }) - - it('rejects a baseline snapshot holding a non-finite frequency instead of serializing null', async () => { - const projectRoot = await createTemporaryProject() - const workspacePath = join(projectRoot, 'ws_0001') - const service = createService(projectRoot, undefined, { - loadBaselineSnapshot: async () => ({ - parameters: { - pdk: 'ics55', - pdk_root: '/pdks/ics55', - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - frequency_max: Infinity, - core: { utilitization: 0.4, margin: [3, 3] }, - }, - pdk: {}, - db: { INPUT: {} }, - }), - }) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'project label', designName: 'project_design' }, - }) - await service.mutate({ - projectRoot, - mutation: { type: 'register-workspace', input: { projectRoot, workspacePath } }, - }) - - await expect( - service.mutate({ - projectRoot, - mutation: { type: 'select-qor-baseline', workspaceId: 'ws_0001' }, - }), - ).rejects.toThrow(/cannot represent losslessly/i) - }) - - it('rejects a baseline snapshot holding a scalar die instead of defaulting geometry', async () => { - const projectRoot = await createTemporaryProject() - const workspacePath = join(projectRoot, 'ws_0001') - const service = createService(projectRoot, undefined, { - loadBaselineSnapshot: async () => ({ - parameters: { - pdk: 'ics55', - pdk_root: '/pdks/ics55', - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - die: new Date('1979-05-27T00:00:00Z'), - core: { utilitization: 0.4, margin: [3, 3] }, - }, - pdk: {}, - db: { INPUT: {} }, - }), - }) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'project label', designName: 'project_design' }, - }) - await service.mutate({ - projectRoot, - mutation: { type: 'register-workspace', input: { projectRoot, workspacePath } }, - }) - - await expect( - service.mutate({ - projectRoot, - mutation: { type: 'select-qor-baseline', workspaceId: 'ws_0001' }, - }), - ).rejects.toThrow(/scalar where a parameter table/i) - }) - - it('rejects a baseline snapshot holding a scalar die.size instead of dropping geometry', async () => { - const projectRoot = await createTemporaryProject() - const workspacePath = join(projectRoot, 'ws_0001') - const service = createService(projectRoot, undefined, { - loadBaselineSnapshot: async () => ({ - parameters: { - pdk: 'ics55', - pdk_root: '/pdks/ics55', - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - die: { size: new Date('1979-05-27T00:00:00Z') }, - core: { utilitization: 0.4, margin: [3, 3] }, - }, - pdk: {}, - db: { INPUT: {} }, - }), - }) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'project label', designName: 'project_design' }, - }) - await service.mutate({ - projectRoot, - mutation: { type: 'register-workspace', input: { projectRoot, workspacePath } }, - }) - - await expect( - service.mutate({ - projectRoot, - mutation: { type: 'select-qor-baseline', workspaceId: 'ws_0001' }, - }), - ).rejects.toThrow(/die\/core dimension array/i) - }) - - it('reads canonical die_area geometry when selecting a QoR baseline', async () => { - const projectRoot = await createTemporaryProject() - const workspacePath = join(projectRoot, 'ws_0001') - const service = createService(projectRoot, undefined, { - loadBaselineSnapshot: async () => ({ - parameters: { - pdk: 'ics55', - pdk_root: '/pdks/ics55', - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - die_area: { - width: 120, - height: 80, - utilitization: 0.5, - margin: 4, - mode: 'width_height', - }, - }, - pdk: {}, - db: { INPUT: {} }, - }), - }) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'project label', designName: 'project_design' }, - }) - await service.mutate({ - projectRoot, - mutation: { type: 'register-workspace', input: { projectRoot, workspacePath } }, - }) - await service.mutate({ - projectRoot, - mutation: { type: 'select-qor-baseline', workspaceId: 'ws_0001' }, - }) - - const manifest = parseProjectManifest( - await readFile(join(projectRoot, 'project.json'), 'utf8'), - ) - expect(manifest.base_design.parameters).toEqual( - expect.objectContaining({ - die_width: 120, - die_height: 80, - utilitization: 0.5, - margin: 4, - die_area_mode: 'width_height', - }), - ) - }) - - it('infers width_height when canonical die_area has dimensions but no mode', async () => { - const projectRoot = await createTemporaryProject() - const workspacePath = join(projectRoot, 'ws_0001') - const service = createService(projectRoot, undefined, { - loadBaselineSnapshot: async () => ({ - parameters: { - pdk: 'ics55', - pdk_root: '/pdks/ics55', - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - die_area: { width: 120, height: 80, utilitization: 0.5, margin: 4 }, - }, - pdk: {}, - db: { INPUT: {} }, - }), - }) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'project label', designName: 'project_design' }, + expect(evidence).toMatchObject({ + projectId: 'proj_gcd', + workspaceId: 'ws-1', + workspacePath: `${projectRoot}/ws-1`, }) - await service.mutate({ - projectRoot, - mutation: { type: 'register-workspace', input: { projectRoot, workspacePath } }, - }) - await service.mutate({ - projectRoot, - mutation: { type: 'select-qor-baseline', workspaceId: 'ws_0001' }, - }) - - const manifest = parseProjectManifest( - await readFile(join(projectRoot, 'project.json'), 'utf8'), - ) - expect(manifest.base_design.parameters).toEqual( - expect.objectContaining({ - die_width: 120, - die_height: 80, - die_area_mode: 'width_height', - }), - ) }) - it('refuses an asymmetric core.margin that the baseline scalar cannot represent', async () => { - const projectRoot = await createTemporaryProject() - const workspacePath = join(projectRoot, 'ws_0001') - const service = createService(projectRoot, undefined, { - loadBaselineSnapshot: async () => ({ - parameters: { - pdk: 'ics55', - pdk_root: '/pdks/ics55', - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - die_area: { width: 120, height: 80, utilitization: 0.5, margin: 2 }, - core: { margin: [5, 7] }, + it('accepts a CLI Workspace whose stable id differs from its directory name', async () => { + const registered = { + ...emptyManifest, + workspaces: [ + { + workspace_id: 'baseline', + name: 'Baseline', + workspace_path: 'runs/default', }, - pdk: {}, - db: { INPUT: {} }, - }), - }) + ], + } + const { service } = createService(vi.fn().mockResolvedValue(registered)) - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'project label', designName: 'project_design' }, - }) - await service.mutate({ - projectRoot, - mutation: { type: 'register-workspace', input: { projectRoot, workspacePath } }, - }) await expect( - service.mutate({ + service.inspectWorkspaceRegistration( projectRoot, - mutation: { type: 'select-qor-baseline', workspaceId: 'ws_0001' }, - }), - ).rejects.toThrow(/asymmetric core.margin/i) - }) - - it('does not write a partial baseline mutation when its snapshot is incomplete', async () => { - const projectRoot = await createTemporaryProject() - const workspacePath = join(projectRoot, 'ws_0001') - const service = createService(projectRoot, undefined, { - loadBaselineSnapshot: async () => ({ - parameters: { PDK: 'ics55' }, - pdk: {}, - db: {}, - }), - }) - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'gcd', designName: 'gcd' }, - }) - await service.mutate({ - projectRoot, - mutation: { - type: 'register-workspace', - input: { projectRoot, workspacePath }, - }, + `${projectRoot}/runs/default`, + 'proj_gcd', + ), + ).resolves.toMatchObject({ + projectId: 'proj_gcd', + workspaceId: 'baseline', + workspacePath: `${projectRoot}/runs/default`, }) - const manifestPath = join(projectRoot, 'project.json') - const before = await readFile(manifestPath, 'utf8') - - await expect( - service.mutate({ - projectRoot, - mutation: { type: 'select-qor-baseline', workspaceId: 'ws_0001' }, - }), - ).rejects.toThrow('Baseline workspace snapshot is incomplete') - - await expect(readFile(manifestPath, 'utf8')).resolves.toBe(before) }) - it('does not overwrite a malformed manifest when a mutation cannot be parsed', async () => { - const projectRoot = await createTemporaryProject() - const manifestPath = join(projectRoot, 'project.json') - await writeFile(manifestPath, '{not json', 'utf8') - const service = createService(projectRoot) - - await expect( - service.mutate({ - projectRoot, - mutation: { type: 'delete-workspace', workspaceId: 'ws_0001' }, - }), - ).rejects.toThrow('Invalid project manifest JSON') - await expect(readFile(manifestPath, 'utf8')).resolves.toBe('{not json') - }) - - it('does not treat an existing empty manifest as absent', async () => { - const projectRoot = await createTemporaryProject() - const manifestPath = join(projectRoot, 'project.json') - await writeFile(manifestPath, '', 'utf8') - const service = createService(projectRoot) - - await expect( - service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'gcd', designName: 'gcd' }, - }), - ).rejects.toThrow('Invalid project manifest JSON') - await expect(readFile(manifestPath, 'utf8')).resolves.toBe('') - }) - - it('rejects malformed mutation payloads before reading or writing a manifest', async () => { - const projectRoot = await createTemporaryProject() - const service = createService(projectRoot) - - await expect( - service.mutate({ - projectRoot, - mutation: { - type: 'register-workspace', - } as never, - }), - ).rejects.toThrow('Project manifest workspace registration input must be an object') - - await expect( - service.mutate({ - projectRoot, - mutation: { - type: 'create', - name: 'gcd', - designName: 'gcd', - mpc: { - resource_id: 'mpc:mpc-frame', - display_name: 'MPC Frame', - installed_version: '0.1.0', - path: '/resources/mpcs/mpc-frame/0.1.0', - spec_path: '/resources/mpcs/mpc-frame/0.1.0/spec.json.in', - design: { index: 0, design_name: 'frame' }, - core_template: { minimum_area: 100, maximum_area: 500 }, - }, + it('preserves a changed registration during recovery cleanup', async () => { + const changed = { + ...emptyManifest, + workspaces: [ + { + workspace_id: 'ws-1', + name: 'changed', + workspace_path: 'ws-1', }, - }), - ).rejects.toThrow('MPC spec_path must reference spec/spec.json.in') - - await expect( - readFile(join(projectRoot, 'project.json'), 'utf8'), - ).rejects.toMatchObject({ - code: 'ENOENT', - }) - }) - - it('rejects a manifest whose root_path points at another project directory', async () => { - const projectRoot = await createTemporaryProject() - const otherProjectRoot = await createTemporaryProject() - const manifestPath = join(projectRoot, 'project.json') - const content = JSON.stringify({ - schema_version: 1, - project_id: 'proj_gcd', - name: 'gcd', - design_name: 'gcd', - root_path: otherProjectRoot, - created_at: '2026-07-01T00:00:00.000Z', - updated_at: '2026-07-01T00:00:00.000Z', - base_design: { rtl_list: [] }, - objectives: { primary: 'timing', directions: {} }, - workspaces: [], - best_workspace: null, - }) - await writeFile(manifestPath, content, 'utf8') - const service = new ProjectManifestService({ - resolveProjectRoot: async (path) => path, - }) + ], + } + const { service } = createService(vi.fn().mockResolvedValue(changed)) await expect( - service.mutate({ - projectRoot, - mutation: { type: 'delete-workspace', workspaceId: 'ws_0001' }, + service.removeWorkspaceRegistration(projectRoot, { + fingerprint: 'old', + projectId: 'proj_gcd', + workspaceId: 'ws-1', + workspacePath: `${projectRoot}/ws-1`, }), - ).rejects.toThrow('root_path does not match') - await expect(readFile(manifestPath, 'utf8')).resolves.toBe(content) + ).rejects.toThrow('registration changed') }) - it('deletes a manifest-owned workspace directory through the main-process transaction', async () => { - const projectRoot = await createTemporaryProject() - const workspacePath = join(projectRoot, 'ws_0001') - const replacement = { - id: 'replacement-1', - targetPath: workspacePath, - backupPath: join(projectRoot, '.ws_0001.replace-backup-1'), + it('keeps directory replacement in Electron and Manifest mutation in ECC', async () => { + const callRuntime = vi + .fn() + .mockResolvedValueOnce({ + ...emptyManifest, + workspaces: [{ workspace_id: 'ws-1', name: 'ws-1', workspace_path: 'ws-1' }], + }) + .mockResolvedValueOnce(emptyManifest) + const replacement: ProjectManifestReplacementProvider = { + getProjectDirectoryReplacement: vi.fn(() => ({ + backupPath: `${projectRoot}/.backup/ws-1`, + projectRoot, + targetPath: `${projectRoot}/ws-1`, + })), + prepareManagedProjectWorkspaceDirectoryReplacement: vi.fn(async () => ({ + backupPath: `${projectRoot}/.backup/ws-1`, + id: 'replace-1', + projectRoot, + targetPath: `${projectRoot}/ws-1`, + })), + setProjectDirectoryReplacementRecoveryMode: vi.fn(async () => undefined), + finalizeProjectDirectoryReplacement: vi.fn(async () => undefined), + restoreProjectDirectoryReplacement: vi.fn(async () => undefined), + retainProjectDirectoryReplacement: vi.fn(async () => undefined), } - const calls: string[] = [] - const service = createService(projectRoot, { - finalizeProjectDirectoryReplacement: async (replacementId) => { - calls.push(`finalize:${replacementId}`) - }, - getProjectDirectoryReplacement: (replacementId) => ({ - ...replacement, - id: replacementId, - projectRoot, - }), - prepareManagedProjectWorkspaceDirectoryReplacement: async ( - root, - workspaceId, - path, - ) => { - calls.push(`prepare:${root}:${workspaceId}:${path}`) - return replacement - }, - retainProjectDirectoryReplacement: async () => undefined, - restoreProjectDirectoryReplacement: async (replacementId) => { - calls.push(`restore:${replacementId}`) - }, - setProjectDirectoryReplacementRecoveryMode: async (replacementId, mode) => { - calls.push(`mode:${replacementId}:${mode}`) - }, - }) + const service = createService(callRuntime, replacement).service await service.mutate({ projectRoot, - mutation: { type: 'create', name: 'gcd', designName: 'gcd' }, + mutation: { type: 'delete-workspace', workspaceId: 'ws-1', deleteDirectory: true }, }) - await service.mutate({ - projectRoot, - mutation: { - type: 'register-workspace', - input: { - projectRoot, - projectName: 'gcd', - workspacePath, - }, - }, - }) - await service.mutate({ + expect( + replacement.prepareManagedProjectWorkspaceDirectoryReplacement, + ).toHaveBeenCalledWith(projectRoot, 'ws-1', `${projectRoot}/ws-1`) + expect(callRuntime).toHaveBeenLastCalledWith('project.manifest.mutate', { projectRoot, mutation: { type: 'delete-workspace', - workspaceId: 'ws_0001', + workspaceId: 'ws-1', deleteDirectory: true, }, }) - - expect(calls).toEqual([ - `prepare:${projectRoot}:ws_0001:${workspacePath}`, - 'mode:replacement-1:delete', - 'finalize:replacement-1', - ]) - const manifest = parseProjectManifest( - await readFile(join(projectRoot, 'project.json'), 'utf8'), + expect(replacement.finalizeProjectDirectoryReplacement).toHaveBeenCalledWith( + 'replace-1', ) - expect(manifest.workspaces).toEqual([]) - }) - - it('records a replacement backup from the trusted token and releases it after writing', async () => { - const projectRoot = await createTemporaryProject() - const retainedReplacementIds: string[] = [] - const replacementRecoveryModes: Array<{ id: string; mode: 'delete' | 'retain' }> = [] - const replacementProvider: ProjectManifestReplacementProvider = { - finalizeProjectDirectoryReplacement: async () => undefined, - getProjectDirectoryReplacement: (replacementId) => { - expect(replacementId).toBe('replacement-1') - return { - backupPath: join(projectRoot, '.ws_0001.replace-backup-1'), - projectRoot, - targetPath: join(projectRoot, 'ws_0001'), - } - }, - prepareManagedProjectWorkspaceDirectoryReplacement: async () => null, - retainProjectDirectoryReplacement: async (replacementId) => { - retainedReplacementIds.push(replacementId) - }, - restoreProjectDirectoryReplacement: async () => undefined, - setProjectDirectoryReplacementRecoveryMode: async (replacementId, mode) => { - replacementRecoveryModes.push({ id: replacementId, mode }) - }, - } - const service = createService(projectRoot, replacementProvider) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'gcd', designName: 'gcd' }, - }) - const result = await service.mutate({ - projectRoot, - mutation: { - type: 'record-replacement-backup', - input: { - replacementId: 'replacement-1', - fallbackStartStep: 'Synth', - fallbackEndStep: 'Harden', - }, - }, - }) - - const manifest = parseProjectManifest(result.content) - expect(manifest.workspaces).toMatchObject([ - { - workspace_id: '.ws_0001.replace-backup-1', - workspace_path: join(projectRoot, '.ws_0001.replace-backup-1'), - status: 'archived', - start_step: 'Synth', - end_step: 'Harden', - }, - ]) - expect(retainedReplacementIds).toEqual(['replacement-1']) - expect(replacementRecoveryModes).toEqual([{ id: 'replacement-1', mode: 'retain' }]) - }) - - it('keeps a durable replacement-backup mutation when token cleanup is deferred', async () => { - const projectRoot = await createTemporaryProject() - const service = createService(projectRoot, { - finalizeProjectDirectoryReplacement: async () => undefined, - getProjectDirectoryReplacement: () => ({ - backupPath: join(projectRoot, '.ws_0001.replace-backup-1'), - projectRoot, - targetPath: join(projectRoot, 'ws_0001'), - }), - prepareManagedProjectWorkspaceDirectoryReplacement: async () => null, - retainProjectDirectoryReplacement: async () => { - throw new Error('journal cleanup failed') - }, - restoreProjectDirectoryReplacement: async () => undefined, - setProjectDirectoryReplacementRecoveryMode: async () => undefined, - }) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'gcd', designName: 'gcd' }, - }) - const result = await service.mutate({ - projectRoot, - mutation: { - type: 'record-replacement-backup', - input: { replacementId: 'replacement-1' }, - }, - }) - - expect(result.cleanupPending).toBe(true) - expect(parseProjectManifest(result.content).workspaces).toMatchObject([ - { - workspace_id: '.ws_0001.replace-backup-1', - workspace_path: join(projectRoot, '.ws_0001.replace-backup-1'), - }, - ]) - }) - - it('rejects a replacement token from another project without changing the manifest', async () => { - const projectRoot = await createTemporaryProject() - const foreignProjectRoot = await createTemporaryProject() - const retainedReplacementIds: string[] = [] - const service = createService(projectRoot, { - finalizeProjectDirectoryReplacement: async () => undefined, - getProjectDirectoryReplacement: () => ({ - backupPath: join(foreignProjectRoot, '.ws_0001.replace-backup-1'), - projectRoot: foreignProjectRoot, - targetPath: join(foreignProjectRoot, 'ws_0001'), - }), - prepareManagedProjectWorkspaceDirectoryReplacement: async () => null, - retainProjectDirectoryReplacement: async (replacementId) => { - retainedReplacementIds.push(replacementId) - }, - restoreProjectDirectoryReplacement: async () => undefined, - setProjectDirectoryReplacementRecoveryMode: async () => undefined, - }) - - await service.mutate({ - projectRoot, - mutation: { type: 'create', name: 'gcd', designName: 'gcd' }, - }) - const manifestPath = join(projectRoot, 'project.json') - const before = await readFile(manifestPath, 'utf8') - - await expect( - service.mutate({ - projectRoot, - mutation: { - type: 'record-replacement-backup', - input: { replacementId: 'replacement-1' }, - }, - }), - ).rejects.toThrow('does not belong to this project manifest') - - await expect(readFile(manifestPath, 'utf8')).resolves.toBe(before) - expect(retainedReplacementIds).toEqual([]) }) }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectManifestService.ts b/ecos/gui/apps/desktop-electron/electron/services/projectManifestService.ts index b5272df23..c16b9839c 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/projectManifestService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/projectManifestService.ts @@ -1,24 +1,15 @@ -import { randomUUID } from 'node:crypto' -import { readFile, rename, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { - applyProjectManifestMutation, - parseProjectManifest, - recordReplacementBackupInManifest, - serializeProjectManifest, - synchronizeProjectBaseline, - type ProjectManifest, - type ProjectManifestBaseDesign, - type ProjectManifestMutation, - type ProjectManifestMutationRequest, - type ProjectManifestMutationResult, - type WorkspaceDirectoryReplacement, +import { basename, isAbsolute, resolve } from 'node:path' +import { projectManifestForPresentation } from '@ecos-studio/shared' +import type { + EccProjectManifest, + ProjectManifest, + ProjectManifestMutation, + ProjectManifestMutationRequest, + ProjectManifestMutationResult, + WorkspaceDirectoryReplacement, } from '@ecos-studio/shared' -import { - WorkspaceSnapshotLoader, - type WorkspaceBaselineSnapshot, -} from './eccRpc/workspaceSnapshotLoader' import { isPathWithinRoot } from './pathScope' +import { validateProjectManifestMutation } from './projectManifestMutationValidation' export interface ProjectManifestScopeProvider { resolveProjectRoot(path: string): Promise @@ -44,8 +35,19 @@ export interface ProjectManifestReplacementProvider { ): Promise } -export interface ProjectManifestBaselineSnapshotProvider { - loadBaselineSnapshot(directory: string): Promise +export interface ProjectManifestRuntime { + callRuntime( + method: string, + params?: Record, + options?: { timeoutMs?: number }, + ): Promise +} + +export interface WorkspaceRegistrationEvidence { + fingerprint: string + projectId: string + workspaceId: string + workspacePath: string } export class ProjectManifestService { @@ -53,70 +55,152 @@ export class ProjectManifestService { constructor( private readonly projectScopeProvider: ProjectManifestScopeProvider, - private readonly replacementProvider?: ProjectManifestReplacementProvider, - private readonly baselineSnapshotProvider: ProjectManifestBaselineSnapshotProvider = new WorkspaceSnapshotLoader(), + private readonly replacementProvider: ProjectManifestReplacementProvider | undefined, + private readonly runtime: ProjectManifestRuntime, ) {} async mutate( request: ProjectManifestMutationRequest, ): Promise { - if ( - !request || - typeof request.projectRoot !== 'string' || - !request.projectRoot.trim() - ) { + if (!request?.projectRoot?.trim()) { throw new Error('Project manifest mutation requires a project root') } validateProjectManifestMutation(request.mutation) - const projectRoot = await this.projectScopeProvider.resolveProjectRoot( request.projectRoot, ) + return await this.enqueue(projectRoot, () => + this.mutateThroughRuntime(projectRoot, request.mutation), + ) + } + + async load(requestedProjectRoot: string): Promise { + const projectRoot = + await this.projectScopeProvider.resolveProjectRoot(requestedProjectRoot) + return projectManifestForPresentation( + await this.loadManifest(projectRoot), + projectRoot, + ) + } + + async discover(directory: string): Promise { + const discovered = await this.runtime.callRuntime<{ + projectId: string + projectRoot: string + } | null>('project.discover', { directory }) + if (!discovered) return null + const projectRoot = await this.projectScopeProvider.resolveProjectRoot( + discovered.projectRoot, + ) + const manifest = await this.loadManifest(projectRoot) + if (manifest.project_id !== discovered.projectId) { + throw new Error('Discovered Project identity changed while loading its Manifest.') + } + return projectManifestForPresentation(manifest, projectRoot) + } + + async inspectWorkspaceRegistration( + requestedProjectRoot: string, + workspacePath: string, + expectedProjectId?: string, + ): Promise { + const projectRoot = + await this.projectScopeProvider.resolveProjectRoot(requestedProjectRoot) + return await this.enqueue(projectRoot, async () => { + const manifest = await this.loadManifest(projectRoot) + requireProjectIdentity(manifest, expectedProjectId) + return workspaceRegistrationEvidence(manifest, workspacePath, projectRoot) + }) + } + + async ensureWorkspaceRegistration( + requestedProjectRoot: string, + workspacePath: string, + expectedProjectId?: string, + ): Promise { + const projectRoot = + await this.projectScopeProvider.resolveProjectRoot(requestedProjectRoot) return await this.enqueue(projectRoot, async () => { - const manifestPath = join(projectRoot, 'project.json') - const currentContent = await readOptionalTextFile(manifestPath) - const currentManifest = - currentContent === null ? null : parseProjectManifest(currentContent) - if (currentManifest) { - const manifestRoot = await this.projectScopeProvider.resolveProjectRoot( - currentManifest.root_path, + const manifest = await this.loadManifest(projectRoot) + requireProjectIdentity(manifest, expectedProjectId) + const existing = workspaceRegistrationEvidence(manifest, workspacePath, projectRoot) + if (existing) return existing + const updated = await this.mutateManifest(projectRoot, { + type: 'register-workspace', + input: { projectRoot, workspacePath }, + }) + return workspaceRegistrationEvidence(updated, workspacePath, projectRoot)! + }) + } + + async removeWorkspaceRegistration( + requestedProjectRoot: string, + evidence: WorkspaceRegistrationEvidence, + ): Promise { + const projectRoot = + await this.projectScopeProvider.resolveProjectRoot(requestedProjectRoot) + await this.enqueue(projectRoot, async () => { + const manifest = await this.loadManifest(projectRoot) + const current = workspaceRegistrationEvidence( + manifest, + evidence.workspacePath, + projectRoot, + ) + if (!current || current.fingerprint !== evidence.fingerprint) { + throw new Error( + 'Project manifest registration changed after Workspace creation; registration was preserved.', ) - if (manifestRoot !== projectRoot) { - throw new Error( - 'Project manifest root_path does not match its containing directory.', - ) - } } - if (request.mutation.type === 'create' && currentManifest) { - throw new Error('Project manifest already exists.') + await this.mutateManifest(projectRoot, { + type: 'delete-workspace', + workspaceId: evidence.workspaceId, + }) + }) + } + + private async mutateThroughRuntime( + projectRoot: string, + requestedMutation: ProjectManifestMutation, + ): Promise { + let mutation: ProjectManifestMutation | Record = requestedMutation + let directoryReplacement: WorkspaceDirectoryReplacement | null = null + if (requestedMutation.type === 'record-replacement-backup') { + const replacement = this.requireProjectReplacement( + requestedMutation.input.replacementId, + projectRoot, + ) + mutation = { + type: 'register-workspace', + input: { + lifecycle: 'archived', + name: `${basename(replacement.targetPath)} backup`, + workspacePath: replacement.backupPath, + }, } - const manifest = - request.mutation.type === 'record-replacement-backup' - ? this.applyReplacementBackupMutation( - currentManifest, - projectRoot, - request.mutation, - ) - : request.mutation.type === 'select-qor-baseline' - ? await this.applyQorBaselineMutation(currentManifest, request.mutation) - : applyProjectManifestMutation(currentManifest, projectRoot, request.mutation) - const directoryReplacement = - request.mutation.type === 'delete-workspace' && request.mutation.deleteDirectory - ? await this.prepareManagedWorkspaceDeletion( - currentManifest, - projectRoot, - request.mutation.workspaceId, - ) - : null - const content = serializeProjectManifest(manifest) - try { - if (request.mutation.type === 'record-replacement-backup') { - await this.setReplacementRecoveryMode( - request.mutation.input.replacementId, + await this.setReplacementRecoveryMode( + requestedMutation.input.replacementId, + projectRoot, + 'retain', + ) + } + if ( + requestedMutation.type === 'delete-workspace' && + requestedMutation.deleteDirectory + ) { + const manifest = await this.loadManifest(projectRoot) + const workspace = manifest.workspaces.find( + (candidate) => candidate.workspace_id === requestedMutation.workspaceId, + ) + if (workspace) { + if (!this.replacementProvider) { + throw new Error('Workspace replacement support is unavailable.') + } + directoryReplacement = + await this.replacementProvider.prepareManagedProjectWorkspaceDirectoryReplacement( projectRoot, - 'retain', + requestedMutation.workspaceId, + absoluteWorkspacePath(projectRoot, workspace.workspace_path), ) - } if (directoryReplacement) { await this.setReplacementRecoveryMode( directoryReplacement.id, @@ -124,89 +208,56 @@ export class ProjectManifestService { 'delete', ) } - await writeTextFileAtomically(manifestPath, content) - } catch (error) { - if (directoryReplacement) { - await this.replacementProvider!.restoreProjectDirectoryReplacement( - directoryReplacement.id, - ).catch(() => undefined) - } - throw error - } - let cleanupPending = false - if (request.mutation.type === 'record-replacement-backup') { - try { - await this.replacementProvider!.retainProjectDirectoryReplacement( - request.mutation.input.replacementId, - ) - } catch { - // The manifest now references the backup and recovery mode is retain. - cleanupPending = true - } } + } + + let manifest: EccProjectManifest + try { + manifest = await this.mutateManifest(projectRoot, mutation) + } catch (error) { if (directoryReplacement) { - try { + await this.replacementProvider!.restoreProjectDirectoryReplacement( + directoryReplacement.id, + ).catch(() => undefined) + } + throw error + } + + let cleanupPending = false + const replacementId = + requestedMutation.type === 'record-replacement-backup' + ? requestedMutation.input.replacementId + : directoryReplacement?.id + if (replacementId) { + try { + if (requestedMutation.type === 'record-replacement-backup') { + await this.replacementProvider!.retainProjectDirectoryReplacement(replacementId) + } else { await this.replacementProvider!.finalizeProjectDirectoryReplacement( - directoryReplacement.id, + replacementId, ) - } catch { - cleanupPending = true } + } catch { + cleanupPending = true } - return { content, ...(cleanupPending ? { cleanupPending } : {}) } - }) - } - - private applyReplacementBackupMutation( - currentManifest: ReturnType | null, - projectRoot: string, - mutation: Extract< - ProjectManifestMutationRequest['mutation'], - { type: 'record-replacement-backup' } - >, - ) { - if (!currentManifest) throw new Error('Project manifest does not exist.') - if (!this.replacementProvider) { - throw new Error('Workspace replacement support is unavailable.') } - const replacement = this.requireProjectReplacement( - mutation.input.replacementId, - projectRoot, - ) - return recordReplacementBackupInManifest(currentManifest, { - backupPath: replacement.backupPath, - targetPath: replacement.targetPath, - fallbackStartStep: mutation.input.fallbackStartStep, - fallbackEndStep: mutation.input.fallbackEndStep, - }) + return { + manifest: projectManifestForPresentation(manifest, projectRoot), + ...(cleanupPending ? { cleanupPending } : {}), + } } - private async applyQorBaselineMutation( - currentManifest: ProjectManifest | null, - mutation: Extract< - ProjectManifestMutationRequest['mutation'], - { type: 'select-qor-baseline' } - >, - ): Promise { - if (!currentManifest) throw new Error('Project manifest does not exist.') - const workspace = currentManifest.workspaces.find( - (candidate) => - candidate.workspace_id === mutation.workspaceId && - candidate.status !== 'archived', - ) - if (!workspace) { - throw new Error( - `Workspace ${mutation.workspaceId} is not available for the project QoR baseline.`, - ) - } + private loadManifest(projectRoot: string): Promise { + return this.runtime.callRuntime('project.manifest.load', { projectRoot }) + } - const snapshot = await this.baselineSnapshotProvider.loadBaselineSnapshot( - workspace.workspace_path, - ) - return synchronizeProjectBaseline(currentManifest, { - workspaceId: workspace.workspace_id, - reason: mutation.reason, - baseDesign: baselineBaseDesign(currentManifest.base_design, snapshot), + private mutateManifest( + projectRoot: string, + mutation: ProjectManifestMutation | Record, + ): Promise { + return this.runtime.callRuntime('project.manifest.mutate', { + projectRoot, + mutation, }) } @@ -215,36 +266,13 @@ export class ProjectManifestService { projectRoot: string, recoveryMode: 'delete' | 'retain', ): Promise { - if (!this.replacementProvider) { - throw new Error('Workspace replacement support is unavailable.') - } this.requireProjectReplacement(replacementId, projectRoot) - await this.replacementProvider.setProjectDirectoryReplacementRecoveryMode( + await this.replacementProvider!.setProjectDirectoryReplacementRecoveryMode( replacementId, recoveryMode, ) } - private async prepareManagedWorkspaceDeletion( - currentManifest: ReturnType | null, - projectRoot: string, - workspaceId: string, - ): Promise { - if (!currentManifest) return null - const workspace = currentManifest.workspaces.find( - (candidate) => candidate.workspace_id === workspaceId, - ) - if (!workspace) return null - if (!this.replacementProvider) { - throw new Error('Workspace replacement support is unavailable.') - } - return await this.replacementProvider.prepareManagedProjectWorkspaceDirectoryReplacement( - projectRoot, - workspaceId, - workspace.workspace_path, - ) - } - private requireProjectReplacement(replacementId: string, projectRoot: string) { if (!this.replacementProvider) { throw new Error('Workspace replacement support is unavailable.') @@ -269,408 +297,63 @@ export class ProjectManifestService { () => undefined, ) this.queues.set(projectRoot, queued) - try { return await next } finally { - if (this.queues.get(projectRoot) === queued) { - this.queues.delete(projectRoot) - } + if (this.queues.get(projectRoot) === queued) this.queues.delete(projectRoot) } } } -function validateProjectManifestMutation( - mutation: unknown, -): asserts mutation is ProjectManifestMutation { - if (!isRecord(mutation) || typeof mutation.type !== 'string') { - throw new Error('Project manifest mutation is required') - } - - switch (mutation.type) { - case 'create': - requireString(mutation.name, 'Project manifest create mutation name') - requireString(mutation.designName, 'Project manifest create mutation designName') - validateProjectManifestMpc(mutation.mpc) - return - case 'register-workspace': { - const input = requireRecord( - mutation.input, - 'Project manifest workspace registration input', - ) - requireString(input.projectRoot, 'Project manifest workspace projectRoot') - requireString(input.workspacePath, 'Project manifest workspace path') - requireOptionalString(input.projectName, 'Project manifest workspace projectName') - requireOptionalString( - input.sourceWorkspaceId, - 'Project manifest source workspace id', - ) - requireOptionalString(input.sourceStep, 'Project manifest source step') - requireOptionalString(input.sourceOutputPath, 'Project manifest source output path') - requireOptionalString(input.sourceOutputType, 'Project manifest source output type') - requireOptionalString(input.startStep, 'Project manifest start step') - requireOptionalString(input.endStep, 'Project manifest end step') - if (input.config !== undefined) validateWorkspaceConfig(input.config) - return - } - case 'archive-workspace': - case 'delete-workspace': - requireString(mutation.workspaceId, 'Project manifest workspace id') - if ( - mutation.type === 'delete-workspace' && - mutation.deleteDirectory !== undefined - ) { - if (typeof mutation.deleteDirectory !== 'boolean') { - throw new Error( - 'Project manifest deleteDirectory must be a boolean when provided', - ) - } - } - return - case 'select-qor-baseline': - requireString(mutation.workspaceId, 'Project manifest QoR baseline workspace id') - requireOptionalString(mutation.reason, 'Project manifest QoR baseline reason') - return - case 'record-replacement-backup': { - const input = requireRecord( - mutation.input, - 'Project manifest replacement backup input', - ) - requireString(input.replacementId, 'Workspace replacement id') - requireOptionalString( - input.fallbackStartStep, - 'Project manifest fallback start step', - ) - requireOptionalString(input.fallbackEndStep, 'Project manifest fallback end step') - return - } - default: - throw new Error('Unsupported project manifest mutation') - } -} - -function validateWorkspaceConfig(value: unknown): void { - const config = requireRecord(value, 'Project manifest workspace config') - for (const key of ['pdk', 'pdk_root', 'origin_verilog', 'origin_def']) { - requireOptionalString(config[key], `Project manifest workspace config ${key}`) - } - if (config.rtl_list !== undefined) { - if ( - !Array.isArray(config.rtl_list) || - config.rtl_list.some((item) => typeof item !== 'string') - ) { - throw new Error( - 'Project manifest workspace config rtl_list must be an array of strings', - ) - } - } - if (config.parameters !== undefined && !isRecord(config.parameters)) { - throw new Error('Project manifest workspace config parameters must be an object') - } -} - -function baselineBaseDesign( - current: ProjectManifestBaseDesign, - snapshot: WorkspaceBaselineSnapshot, -): ProjectManifestBaseDesign { - const parameters = snapshot.parameters - const dbInput = recordValue(snapshot.db.INPUT) ?? {} - const nextParameters: Record = { - ...current.parameters, - ...normalizedBaselineParameters(parameters), - } - const next: ProjectManifestBaseDesign = { - ...current, - parameters: nextParameters, - } - const pdk = firstString(parameters.PDK, parameters.pdk) - const pdkRoot = firstString(parameters['PDK Root'], parameters.pdk_root) - const topModule = firstString( - parameters['Top module'], - parameters['Top Module'], - parameters.top_module, +function workspaceRegistrationEvidence( + manifest: EccProjectManifest, + workspacePath: string, + projectRoot: string, +): WorkspaceRegistrationEvidence | null { + const normalizedPath = normalizePath(workspacePath) + const workspaceId = basename(normalizedPath) + const candidates = manifest.workspaces.filter( + (workspace) => + workspace.workspace_id === workspaceId || + absoluteWorkspacePath(projectRoot, workspace.workspace_path) === normalizedPath, ) - const clock = firstString(parameters.Clock, parameters.clock) - if (!pdk || !topModule || !clock) { - throw new Error( - 'Baseline workspace snapshot is incomplete: PDK, top module, and clock are required.', - ) - } - const rtlList = stringArray(dbInput.rtl_list, dbInput.rtl_paths) - const originVerilog = firstString(dbInput.origin_verilog, dbInput.verilog_path) - const originDef = firstString(dbInput.origin_def, dbInput.def_path) - - if (pdk) next.pdk = pdk - if (pdkRoot) next.pdk_root = pdkRoot - if (topModule) next.top_module = topModule - if (clock) next.clock = clock - if (rtlList.length > 0) next.rtl_list = rtlList - if (originVerilog) next.origin_verilog = originVerilog - if (originDef) next.origin_def = originDef - return next -} - -function normalizedBaselineParameters( - parameters: Record, -): Record { - const die = recordValue(parameters.Die) ?? recordValue(parameters.die) ?? {} - const core = recordValue(parameters.Core) ?? recordValue(parameters.core) ?? {} - const dieArea = - recordValue(parameters['Die Area']) ?? recordValue(parameters.die_area) ?? {} - const dieSize = numberArray(die.Size ?? die.size) - const margins = numberArray(core.Margin ?? core.margin) - const hasCanonicalDieSize = dieArea.width != null && dieArea.height != null - const normalized: Record = { - design: firstString(parameters.Design, parameters.design), - top_module: firstString( - parameters['Top module'], - parameters['Top Module'], - parameters.top_module, - ), - clock: firstString(parameters.Clock, parameters.clock), - frequency_max: firstValue( - parameters['Frequency max [MHz]'], - parameters.frequency_max, - ), - max_fanout: firstValue(parameters['Max fanout'], parameters.max_fanout), - die_area_mode: - firstString(dieArea.mode, parameters.die_area_mode) || - (hasCanonicalDieSize || dieSize.length >= 2 ? 'width_height' : ''), - die_width: firstValue(dieArea.width, dieSize[0], parameters.die_width), - die_height: firstValue(dieArea.height, dieSize[1], parameters.die_height), - utilitization: firstValue( - dieArea.utilitization, - core.Utilitization, - core.utilitization, - parameters.utilitization, - ), - margin: firstValue(scalarMarginFromCore(margins), dieArea.margin, parameters.margin), - } - assertBaselineScalarsSafe(normalized) - return Object.fromEntries( - Object.entries(normalized).filter(([, value]) => value !== undefined && value !== ''), + if (candidates.length === 0) return null + const matchingPaths = candidates.filter( + (candidate) => + absoluteWorkspacePath(projectRoot, candidate.workspace_path) === normalizedPath, ) -} - -function assertBaselineScalarClass(value: unknown): void { - if ( - value instanceof Date || - typeof value === 'bigint' || - (typeof value === 'number' && !Number.isFinite(value)) - ) { + if (matchingPaths.length !== 1 || candidates.length !== 1) { throw new Error( - 'Baseline workspace snapshot holds a parameter value the manifest cannot ' + - 'represent losslessly; edit the workspace configuration manually', + 'Project manifest has a conflicting Workspace identity or path; registration was preserved.', ) } -} - -function firstString(...values: unknown[]): string { - for (const value of values) assertBaselineScalarClass(value) - return ( - values - .find( - (value): value is string => typeof value === 'string' && value.trim().length > 0, - ) - ?.trim() ?? '' - ) -} - -function firstValue(...values: unknown[]): unknown { - for (const value of values) assertBaselineScalarClass(value) - return values.find((value) => value !== undefined && value !== null) -} - -function stringArray(...values: unknown[]): string[] { - for (const value of values) { - if (!Array.isArray(value)) continue - const entries = value.filter( - (entry): entry is string => typeof entry === 'string' && entry.trim().length > 0, - ) - if (entries.length > 0) return entries + const workspace = matchingPaths[0]! + return { + fingerprint: JSON.stringify(workspace), + projectId: manifest.project_id, + workspaceId: workspace.workspace_id, + workspacePath: normalizedPath, } - return [] } -function scalarMarginFromCore(margins: number[]): number | undefined { - if (margins.length === 0) return undefined - if (!margins.every((item) => Object.is(item, margins[0]))) { - throw new Error( - 'Baseline workspace snapshot holds an asymmetric core.margin that the ' + - 'manifest cannot represent as a single scalar; edit the workspace ' + - 'configuration manually', - ) - } - return margins[0] -} - -function numberArray(value: unknown): number[] { - if (value == null) return [] - if (!Array.isArray(value)) { - throw new Error( - 'Baseline workspace snapshot holds a scalar where a die/core dimension ' + - 'array was expected; edit the workspace configuration manually', - ) - } - // Positional semantics: dropping or rounding ANY element would silently - // shift the rest into the wrong slots (die.size[0] -> die_width). Numeric - // strings convert (legacy JSON writes them); everything else fails loud. - return value.map((entry) => { - if (typeof entry === 'number') { - if (!Number.isFinite(entry)) { - throw new Error( - 'Baseline workspace snapshot holds a die/core dimension that is not a ' + - 'finite number; edit the workspace configuration manually', - ) - } - if (Number.isInteger(entry) && !Number.isSafeInteger(entry)) { - throw new Error( - 'Baseline workspace snapshot holds a die/core dimension that exceeds ' + - 'the safe integer range; edit the workspace configuration manually', - ) - } - return entry - } - if (typeof entry === 'string' && entry.trim() !== '') { - const parsed = Number(entry.trim()) - if (!Number.isFinite(parsed) || String(parsed) !== entry.trim()) { - throw new Error( - 'Baseline workspace snapshot holds a die/core dimension that cannot ' + - 'round-trip as a JavaScript number; edit the workspace configuration manually', - ) - } - if (Number.isInteger(parsed) && !Number.isSafeInteger(parsed)) { - throw new Error( - 'Baseline workspace snapshot holds a die/core dimension that exceeds ' + - 'the safe integer range; edit the workspace configuration manually', - ) - } - return parsed - } - throw new Error( - 'Baseline workspace snapshot holds a die/core dimension that is not a ' + - 'finite number; edit the workspace configuration manually', - ) - }) -} - -/** - * The manifest serializes to JSON: non-finite numbers would become null, - * bigints would throw inside JSON.stringify, and TOML dates would persist - * as lossy strings. Reject every unsupported scalar before constructing the - * baseline, never after serializing it. - */ -function assertBaselineScalarsSafe(value: unknown): void { - if (typeof value === 'number' && !Number.isFinite(value)) { - throw new Error( - 'Baseline workspace snapshot holds a non-finite parameter value; ' + - 'edit the workspace configuration manually', - ) - } - if (typeof value === 'bigint' || value instanceof Date) { - throw new Error( - 'Baseline workspace snapshot holds a parameter value the manifest cannot ' + - 'represent losslessly; edit the workspace configuration manually', - ) - } - if (Array.isArray(value)) { - for (const item of value) assertBaselineScalarsSafe(item) - return - } - if (value !== null && typeof value === 'object' && !(value instanceof Date)) { - for (const item of Object.values(value)) assertBaselineScalarsSafe(item) - } -} - -function recordValue(value: unknown): Record | null { - if (value === null || value === undefined) return null - if (value instanceof Date || Array.isArray(value) || typeof value !== 'object') { - // A scalar where a table is expected (e.g. die = 1979-05-27) must not - // degrade into an empty table and silently lose the baseline geometry. - throw new Error( - 'Baseline workspace snapshot holds a scalar where a parameter table was ' + - 'expected; edit the workspace configuration manually', - ) - } - return value as Record -} - -function validateProjectManifestMpc(value: unknown): void { - if (value === undefined || value === null) return - const mpc = requireRecord(value, 'Project manifest MPC') - const resourceId = requireString(mpc.resource_id, 'Project manifest MPC resource_id') - if (!resourceId.startsWith('mpc:') || resourceId.length === 4) { - throw new Error('Project manifest MPC resource_id must be an MPC resource id') - } - requireString(mpc.display_name, 'Project manifest MPC display_name') - requireString(mpc.installed_version, 'Project manifest MPC installed_version') - const mpcPath = normalizeMpcPath(requireString(mpc.path, 'Project manifest MPC path')) - const specPath = normalizeMpcPath( - requireString(mpc.spec_path, 'Project manifest MPC spec_path'), +function absoluteWorkspacePath(projectRoot: string, workspacePath: string): string { + return normalizePath( + isAbsolute(workspacePath) ? workspacePath : resolve(projectRoot, workspacePath), ) - if (specPath !== `${mpcPath}/spec/spec.json.in`) { - throw new Error( - 'Project manifest MPC spec_path must reference spec/spec.json.in below MPC path', - ) - } - const design = requireRecord(mpc.design, 'Project manifest MPC design') - if (!Number.isInteger(design.index) || (design.index as number) < 0) { - throw new Error('Project manifest MPC design index must be a non-negative integer') - } - requireString(design.design_name, 'Project manifest MPC design design_name') - requireOptionalString(design.directory, 'Project manifest MPC design directory') - requireRecord(mpc.core_template, 'Project manifest MPC core_template') } -function normalizeMpcPath(path: string): string { +function normalizePath(path: string): string { const normalized = path.replace(/\\/g, '/') - return normalized.length <= 1 ? normalized : normalized.replace(/\/+$/g, '') -} - -function requireRecord(value: unknown, name: string): Record { - if (!isRecord(value)) throw new Error(`${name} must be an object`) - return value -} - -function requireString(value: unknown, name: string): string { - if (typeof value !== 'string' || !value.trim()) { - throw new Error(`${name} must be a non-empty string`) - } - return value -} - -function requireOptionalString(value: unknown, name: string): void { - if (value !== undefined && typeof value !== 'string') { - throw new Error(`${name} must be a string when provided`) - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -async function readOptionalTextFile(path: string): Promise { - try { - return await readFile(path, 'utf8') - } catch (error) { - if (isNodeErrorWithCode(error, 'ENOENT')) return null - throw error - } + return normalized.length > 1 ? normalized.replace(/\/+$/g, '') : normalized } -async function writeTextFileAtomically(path: string, content: string): Promise { - const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp` - try { - await writeFile(temporaryPath, content, 'utf8') - await rename(temporaryPath, path) - } catch (error) { - await rm(temporaryPath, { force: true }).catch(() => undefined) - throw error +function requireProjectIdentity( + manifest: EccProjectManifest, + expectedProjectId?: string, +): void { + if (expectedProjectId && manifest.project_id !== expectedProjectId) { + throw new Error( + 'Project manifest identity changed after Workspace creation; registration was preserved.', + ) } } - -function isNodeErrorWithCode(error: unknown, code: string): boolean { - return ( - typeof error === 'object' && error !== null && 'code' in error && error.code === code - ) -} diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectScopeService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/projectScopeService.test.ts index 6f991d6c4..bab0c1169 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/projectScopeService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/projectScopeService.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest' -import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { ProjectScopeService } from './projectScopeService' import { runWithWindowScope } from './windowScopeContext' +import type { ProjectManifest } from '@ecos-studio/shared' const tempDirectories: string[] = [] @@ -31,6 +32,32 @@ async function writeProjectManifest( ) } +function projectScopeWithManifest(): ProjectScopeService { + return new ProjectScopeService({ + loadProjectManifest: async (projectRoot) => { + const source = JSON.parse( + await readFile(join(projectRoot, 'project.json'), 'utf8'), + ) as { design_name: string; workspaces: ProjectManifest['workspaces'] } + return { + schema_version: 1, + project_id: 'proj_gcd', + name: 'gcd', + design_name: source.design_name, + description: '', + root_path: projectRoot, + created_at: '', + updated_at: '', + base_design: { parameters: {}, rtl_list: [] }, + objectives: { primary: 'timing', directions: {} }, + workspaces: source.workspaces, + mpc: null, + best_workspace: null, + qor_baseline: null, + } + }, + }) +} + describe('ProjectScopeService', () => { afterEach(async () => { await Promise.all( @@ -47,7 +74,7 @@ describe('ProjectScopeService', () => { await mkdir(nested, { recursive: true }) await writeFile(file, '{}') - const service = new ProjectScopeService() + const service = projectScopeWithManifest() await runWithWindowScope(1, async () => { const registeredRoot = await service.registerProjectRoot(root) @@ -69,7 +96,7 @@ describe('ProjectScopeService', () => { it('canonicalizes a manifest project root without changing the active workspace root', async () => { const activeRoot = await createTempDir('ecos-active-project-root-') const manifestRoot = await createTempDir('ecos-manifest-project-root-') - const service = new ProjectScopeService() + const service = projectScopeWithManifest() await runWithWindowScope(1, async () => { await service.registerProjectRoot(activeRoot) @@ -78,10 +105,28 @@ describe('ProjectScopeService', () => { }) }) + it('canonicalizes a prospective creation target and rejects a symlink escape', async () => { + const projectRoot = await createTempDir('ecos-creation-project-') + const outside = await createTempDir('ecos-creation-outside-') + const linkedTarget = join(projectRoot, 'ws_link') + await symlink(outside, linkedTarget) + const service = projectScopeWithManifest() + + await expect( + service.canonicalizeProjectTarget(projectRoot, join(projectRoot, 'ws_new')), + ).resolves.toEqual({ + projectRoot, + targetDirectory: join(projectRoot, 'ws_new'), + }) + await expect( + service.canonicalizeProjectTarget(projectRoot, linkedTarget), + ).rejects.toThrow('outside the Project root') + }) + it('adds the workspace parent as a read root without replacing the active root', async () => { const projectRoot = await createTempDir('ecos-parent-project-root-') - const workspaceRoot = join(projectRoot, 'ws_0004') - const siblingWorkspace = join(projectRoot, 'ws_0001') + const workspaceRoot = join(projectRoot, 'runs', 'ws_0004') + const siblingWorkspace = join(projectRoot, 'runs', 'ws_0001') const manifestPath = join(projectRoot, 'project.json') const siblingFlowPath = join(siblingWorkspace, 'home', 'flow.json') const unrelatedFile = join(projectRoot, 'unrelated.txt') @@ -91,7 +136,7 @@ describe('ProjectScopeService', () => { await writeFile(siblingFlowPath, '{"steps":[]}') await writeFile(unrelatedFile, 'not a workspace artifact') - const service = new ProjectScopeService() + const service = projectScopeWithManifest() await runWithWindowScope(1, async () => { await service.registerProjectRoot(workspaceRoot) await expect(service.registerProjectReadRoot(projectRoot)).resolves.toBe( @@ -129,7 +174,7 @@ describe('ProjectScopeService', () => { await writeProjectManifest(projectRoot, [workspaceRoot, backupWorkspace]) await writeFile(backupFlowPath, '{"steps":[]}') - const service = new ProjectScopeService() + const service = projectScopeWithManifest() await runWithWindowScope(1, async () => { await service.registerProjectRoot(workspaceRoot) await service.registerProjectReadRoot(projectRoot) @@ -148,7 +193,7 @@ describe('ProjectScopeService', () => { await mkdir(join(siblingWorkspace, 'home'), { recursive: true }) await writeProjectManifest(projectRoot, [siblingWorkspace]) - const service = new ProjectScopeService() + const service = projectScopeWithManifest() await runWithWindowScope(1, async () => { await service.registerProjectRoot(workspaceRoot) @@ -169,7 +214,7 @@ describe('ProjectScopeService', () => { await runWithWindowScope(1, async () => { await service.registerProjectRoot(workspaceRoot) await expect(service.registerProjectReadRoot(unrelatedRoot)).rejects.toThrow( - 'Project read root must be the active workspace root or its parent directory', + 'Project read root must contain the active workspace root', ) }) }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectScopeService.ts b/ecos/gui/apps/desktop-electron/electron/services/projectScopeService.ts index 59858919d..1309c38f1 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/projectScopeService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/projectScopeService.ts @@ -1,7 +1,6 @@ import { readFile, readdir, realpath, stat } from 'node:fs/promises' import { dirname, join, relative, resolve, win32 } from 'node:path' import { - parseProjectManifest, type PdkDetectedFiles, type ProjectManifest, type ScannedPdkDirectory, @@ -32,6 +31,7 @@ export interface ProjectReadGrantProvider { } export interface ProjectScopeServiceOptions { + loadProjectManifest?: (projectRoot: string) => Promise readGrantProvider?: ProjectReadGrantProvider } @@ -123,7 +123,10 @@ async function manifestWorkspaceRoots( return await Promise.all( manifest.workspaces.map(async (workspace) => { const workspacePath = resolve(workspace.workspace_path) - if (!pathsEqual(dirname(workspacePath), projectRoot)) { + if ( + pathsEqual(workspacePath, projectRoot) || + !isPathWithinRoot(workspacePath, projectRoot) + ) { throw new Error( 'Project read root manifest contains a workspace outside the project', ) @@ -225,9 +228,11 @@ export class ProjectScopeService { private readonly extraRootsByWindowId = new Map() private readonly pendingExtraRootsByWindowId = new Map() private readonly approvedExtraRootsByProject = new Map() + private readonly loadProjectManifest: ProjectScopeServiceOptions['loadProjectManifest'] private readonly readGrantProvider: ProjectReadGrantProvider | undefined constructor(options: ProjectScopeServiceOptions = {}) { + this.loadProjectManifest = options.loadProjectManifest this.readGrantProvider = options.readGrantProvider } @@ -235,6 +240,29 @@ export class ProjectScopeService { return await canonicalizeExistingDirectory(path) } + async canonicalizeProjectTarget( + requestedProjectRoot: string, + requestedTarget: string, + ): Promise<{ projectRoot: string; targetDirectory: string }> { + const projectRoot = resolve(requestedProjectRoot) + const targetDirectory = resolve(requestedTarget) + if (pathsEqual(projectRoot, targetDirectory)) { + const parent = await canonicalizeExistingDirectory(dirname(projectRoot)) + const canonical = await canonicalizePotentialPathWithinRoot(projectRoot, parent) + return { projectRoot: canonical, targetDirectory: canonical } + } + + const canonicalRoot = await this.resolveProjectRoot(projectRoot) + const canonicalTarget = await canonicalizePotentialPathWithinRoot( + targetDirectory, + canonicalRoot, + ) + if (!isPathWithinRoot(canonicalTarget, canonicalRoot)) { + throw new Error('Workspace creation target is outside the Project root.') + } + return { projectRoot: canonicalRoot, targetDirectory: canonicalTarget } + } + async getProjectRoot(): Promise { const root = this.rootsByWindowId.get(requireWindowScopeId()) if (!root) { @@ -324,17 +352,16 @@ export class ProjectScopeService { this.readScopesByWindowId.delete(windowId) return canonicalPath } - if (!pathsEqual(canonicalPath, dirname(activeProjectRoot))) { - throw new Error( - 'Project read root must be the active workspace root or its parent directory', - ) + if (!isPathWithinRoot(activeProjectRoot, canonicalPath)) { + throw new Error('Project read root must contain the active workspace root') } let manifest: ProjectManifest try { - manifest = parseProjectManifest( - await readFile(join(canonicalPath, 'project.json'), 'utf8'), - ) + if (!this.loadProjectManifest) { + throw new Error('Project Manifest loader is unavailable') + } + manifest = await this.loadProjectManifest(canonicalPath) } catch (error) { throw new Error( `Project read root must have a valid project.json: ${ diff --git a/ecos/gui/apps/desktop-electron/electron/services/projectStepFindingsService.ts b/ecos/gui/apps/desktop-electron/electron/services/projectStepFindingsService.ts new file mode 100644 index 000000000..134e43f10 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/projectStepFindingsService.ts @@ -0,0 +1,270 @@ +import { + parseProjectManifestFlowStep, + type BackendProjectFindingsIssueCode, + type BackendProjectStepFindings, + type BackendProjectStepFindingsResult, + type EccEngineeringAnalysisFile, + type EccPersistedEngineeringSnapshot, + type ProjectAnalysisSnapshot, + type ProjectManifestFlowStep, + type ProjectQorMetricRecord, +} from '@ecos-studio/shared' +import type { VerifiedProjectArtifactsReadResult } from './projectManagementReadService' + +interface FindingsArtifactReader { + readVerifiedArtifacts(request: { + projectRoot: string + workspacePath: string + artifacts: Array<{ reference: string; sha256: string; sizeBytes: number }> + }): Promise +} + +export interface CommittedFindingsResult { + analysis: ProjectAnalysisSnapshot + comparisonMetrics: Partial> + engineeringSnapshot: Pick< + EccPersistedEngineeringSnapshot, + 'analysis' | 'artifacts' | 'workspaceId' | 'workspaceRevision' + > +} + +export interface CommittedFindingsWorkspace extends CommittedFindingsResult { + previous?: CommittedFindingsResult + projectWorkspaceId: string + workspacePath: string +} + +interface FindingsContext { + generation: number + projectRoot: string + ready: boolean + windowId: number + workspaces: Map +} + +const MAX_VERIFIED_FINDINGS_CACHE_ENTRIES = 128 + +export class ProjectStepFindingsService { + private readonly contexts = new Map() + private readonly cache = new Map() + + constructor(private readonly reader: FindingsArtifactReader) {} + + register(windowId: number, contextId: string, projectRoot: string): void { + this.contexts.set(contextId, { + generation: 0, + projectRoot, + ready: false, + windowId, + workspaces: new Map(), + }) + } + + unregister(contextId: string): void { + this.contexts.delete(contextId) + this.clearContextCache(contextId) + } + + invalidate(contextId: string, generation: number): void { + const context = this.contexts.get(contextId) + if (!context) return + context.generation = generation + context.ready = false + this.clearContextCache(contextId) + } + + commit( + contextId: string, + generation: number, + workspaces: CommittedFindingsWorkspace[], + ): void { + const context = this.contexts.get(contextId) + if (!context || context.generation !== generation) return + context.workspaces = new Map( + workspaces.map((workspace) => [workspace.projectWorkspaceId, workspace]), + ) + context.ready = true + } + + async get( + windowId: number, + request: { + projectComparisonContextId: string + projectWorkspaceId: string + step: string + }, + ): Promise { + const context = this.contexts.get(request.projectComparisonContextId) + if (!context || context.windowId !== windowId) { + return { ok: false, code: 'unknown-context' } + } + if (!context.ready) { + return { ok: false, code: 'FINDINGS_SNAPSHOT_REVISION_CHANGED' } + } + const workspace = context.workspaces.get(request.projectWorkspaceId) + if (!workspace) return { ok: false, code: 'FINDINGS_WORKSPACE_UNAVAILABLE' } + const step = parseProjectManifestFlowStep(request.step) + if (!step) return { ok: false, code: 'FINDINGS_STEP_UNAVAILABLE' } + const currentWorkspaceRevision = workspace.engineeringSnapshot.workspaceRevision + const pending = workspace.analysis.resultState?.pendingStepIds.includes(step) ?? false + const source = + pending && + workspace.previous?.engineeringSnapshot.analysis.steps.some( + (candidate) => parseProjectManifestFlowStep(candidate.stepId) === step, + ) + ? workspace.previous + : workspace + const analysisStep = source.engineeringSnapshot.analysis.steps.find( + (candidate) => parseProjectManifestFlowStep(candidate.stepId) === step, + ) + const details = source.analysis.steps[step] + if (!details || (!analysisStep && details.flowStatus === undefined)) { + return { ok: false, code: 'FINDINGS_STEP_UNAVAILABLE' } + } + const data: BackendProjectStepFindings = { + details: { ...details, metrics: source.comparisonMetrics[step] ?? details.metrics }, + engineeringWorkspaceId: source.engineeringSnapshot.workspaceId, + projectWorkspaceId: request.projectWorkspaceId, + step, + workspaceRevision: source.engineeringSnapshot.workspaceRevision, + currentWorkspaceRevision, + resultState: + source !== workspace + ? 'stale' + : pending + ? 'pending-rerun' + : details.flowStatus === 'unstart' && !analysisStep + ? 'not-started' + : 'current', + } + if (!analysisStep) { + return { + ok: true, + projectComparisonContextId: request.projectComparisonContextId, + generation: context.generation, + freshness: 'current', + data, + } + } + const artifacts = declaredArtifacts(source.engineeringSnapshot, analysisStep, step) + if (!artifacts.ok) return artifacts + + const generation = context.generation + const key = cacheKey( + request.projectComparisonContextId, + { ...workspace, ...source }, + step, + ) + const read = await this.reader.readVerifiedArtifacts({ + artifacts: artifacts.data, + projectRoot: context.projectRoot, + workspacePath: workspace.workspacePath, + }) + if ( + this.contexts.get(request.projectComparisonContextId) !== context || + !context.ready || + context.generation !== generation + ) { + return { ok: false, code: 'FINDINGS_SNAPSHOT_REVISION_CHANGED' } + } + if (!read.ok) return this.readFailure(read, key, context, request) + + this.cache.set(key, data) + if (this.cache.size > MAX_VERIFIED_FINDINGS_CACHE_ENTRIES) { + this.cache.delete(this.cache.keys().next().value!) + } + return { + ok: true, + projectComparisonContextId: request.projectComparisonContextId, + generation, + freshness: 'current', + data, + } + } + + private readFailure( + read: Exclude, + key: string, + context: FindingsContext, + request: { projectComparisonContextId: string }, + ): BackendProjectStepFindingsResult { + const cached = this.cache.get(key) + if (cached) { + return { + ok: true, + projectComparisonContextId: request.projectComparisonContextId, + generation: context.generation, + freshness: 'last-committed', + data: cached, + issue: { code: read.code, detail: read.reference }, + } + } + return { + ok: false, + code: read.code, + ...(read.reference ? { detail: read.reference } : {}), + } + } + + private clearContextCache(contextId: string): void { + for (const key of this.cache.keys()) { + if (key.startsWith(`${contextId}\0`)) this.cache.delete(key) + } + } +} + +function declaredArtifacts( + snapshot: Pick, + analysisStep: { + hotspots: EccEngineeringAnalysisFile + metrics: EccEngineeringAnalysisFile + summary: EccEngineeringAnalysisFile + timingIssues: EccEngineeringAnalysisFile | null + }, + step: ProjectManifestFlowStep, +): + | { ok: true; data: Array<{ reference: string; sha256: string; sizeBytes: number }> } + | { ok: false; code: BackendProjectFindingsIssueCode } { + const files = [analysisStep.metrics, analysisStep.summary, analysisStep.hotspots] + if (step === 'STA' && analysisStep.timingIssues) files.push(analysisStep.timingIssues) + const artifacts = [] + for (const file of files) { + if (file.status !== 'available') { + return { ok: false, code: 'ARTIFACT_REFERENCE_MISSING' } + } + const artifact = snapshot.artifacts.find( + (candidate) => + candidate.artifactId === file.artifactId && + candidate.stepId !== undefined && + parseProjectManifestFlowStep(candidate.stepId) === step, + ) + if ( + !artifact || + artifact.availability !== 'available' || + artifact.sizeBytes === undefined || + artifact.sha256 === undefined + ) { + return { ok: false, code: 'ARTIFACT_REFERENCE_MISSING' } + } + artifacts.push({ + reference: artifact.reference, + sha256: artifact.sha256, + sizeBytes: artifact.sizeBytes, + }) + } + return { ok: true, data: artifacts } +} + +function cacheKey( + contextId: string, + workspace: CommittedFindingsWorkspace, + step: ProjectManifestFlowStep, +): string { + return [ + contextId, + workspace.projectWorkspaceId, + workspace.engineeringSnapshot.workspaceId, + workspace.engineeringSnapshot.workspaceRevision, + step, + ].join('\0') +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.test.ts b/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.test.ts new file mode 100644 index 000000000..50dcd5ff1 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { buildProjectQorTrendSummary, normalizeQorMetrics } from './qorAnalysis' + +function routeMetricText(value: number): string { + return JSON.stringify({ + schema_version: 3, + metrics: [ + { + id: 'route_wirelength', + display_name: 'Route wirelength', + value, + unit: 'um', + category: 'routability_physical', + direction: 'lower_is_better', + scope: 'design', + corner: null, + analysis_group: 'route', + project_role: 'trend', + step_role: 'primary', + confidence: 'high', + rating: { gate: false, score: true, trend: true }, + source: { + kind: 'feature', + path: 'feature/route.step.json', + selector: '/metrics/route_wirelength', + }, + }, + ], + }) +} + +describe('qorAnalysis', () => { + it('normalizes metric polarity without owning the ECC scoring policy', () => { + const records = normalizeQorMetrics({ + step: 'STA', + text: JSON.stringify({ + metrics: [ + { + analysis_group: 'timing', + category: 'timing', + confidence: 'high', + corner: null, + corner_context: null, + display_name: 'STA Setup WNS', + id: 'sta_setup_wns', + direction: 'higher_is_better', + project_role: 'final', + rating: { gate: true, score: true, trend: true }, + scope: 'signoff', + source: { + kind: 'feature', + path: 'feature/STA.step.json', + selector: '/metrics/sta_setup_wns', + }, + step_role: 'primary', + unit: 'ns', + value: -0.12, + }, + ], + integrity: { + invalid_detail_ids: [], + invalid_metric_source_ids: [], + status: 'pass', + }, + schema_version: 3, + step: 'STA', + }), + workspaceId: 'ws-a', + workspaceKey: '/project/ws-a', + }) + + expect(records).toMatchObject([ + { + metricName: 'sta_setup_wns', + polarity: 'higher_is_better', + step: 'STA', + unit: 'ns', + value: -0.12, + verdict: 'fail', + }, + ]) + }) + + it('returns baseline verdicts and the leading metric value', () => { + const workspace = (workspaceId: string, value: number) => ({ + workspaceId, + workspaceName: workspaceId, + workspaceKey: workspaceId, + createdAt: '2026-01-01T00:00:00Z', + status: 'success' as const, + branchFrom: null, + stepMetricTexts: { Route: routeMetricText(value) }, + stepStatuses: { Route: 'success' as const }, + }) + const trend = buildProjectQorTrendSummary( + [workspace('ws_a', 1100), workspace('ws_b', 1000)], + { baselineWorkspaceId: 'ws_a' }, + ) + + const candidate = trend.workspaces[1]?.comparisonRecords?.[0] + expect(candidate).toMatchObject({ + leads: true, + baselineComparison: { + absoluteDelta: -100, + relativeDeltaPct: -9.090909, + verdict: 'improvement', + }, + }) + }) +}) diff --git a/ecos/gui/apps/renderer/src/utils/projectQorTrend.ts b/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts similarity index 74% rename from ecos/gui/apps/renderer/src/utils/projectQorTrend.ts rename to ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts index 673d3cc55..5ce2700c6 100644 --- a/ecos/gui/apps/renderer/src/utils/projectQorTrend.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts @@ -1,11 +1,9 @@ import type { - FlowStep, + EccQorSnapshotExtension, + ProjectManifestFlowStep as FlowStep, + ProjectManifestWorkspaceStatus as ProjectWorkspaceStatus, ProjectStepStatus, - ProjectWorkspaceStatus, -} from './projectManagement' -import { adaptQorReport, emptyAdaptedQor, isQorReportStale } from './qorReportAdapter' -import { parseQorReport } from '@ecos-studio/shared' -import type { QorReportPowerObservation } from '@ecos-studio/shared' +} from '@ecos-studio/shared' export type QorDimension = | 'timing' @@ -27,7 +25,7 @@ export type QorGateStatus = 'pass' | 'blocked' | 'incomplete' | 'unavailable' export interface ProjectQorWorkspaceInput { workspaceId: string workspaceName: string - workspacePath: string + workspaceKey: string createdAt: string status: ProjectWorkspaceStatus branchFrom: { @@ -38,21 +36,30 @@ export interface ProjectQorWorkspaceInput { stepSummaryTexts?: Partial> stepHotspotTexts?: Partial> staTimingIssuesText?: string | null - /** Workspace-level ``home/qor_report.json`` written by ECC (scoring authority). */ - qorReportText?: string | null - stepStatuses: Partial> + stepStatuses: Record + normalizedMetrics?: ProjectQorMetricRecord[] + /** Present on production inputs, including null when ECC has no valid Snapshot. */ + authoritativeAssessment?: { + gateStatus: QorGateStatus + score: number | null + scoreThreshold: number + areaScoringStep: FlowStep | null + dimensionScores: Partial> + signoffStatus: 'ready' | 'attention' | 'blocked' + } | null + qorSnapshotExtension?: EccQorSnapshotExtension | null } export interface QorStepMetricInput { workspaceId: string - workspacePath: string + workspaceKey: string step: FlowStep text: string | null | undefined } export interface ProjectQorMetricRecord { workspaceId: string - workspacePath: string + workspaceKey: string step: FlowStep metricName: string displayName: string @@ -69,6 +76,16 @@ export interface ProjectQorMetricRecord { stepRole: 'primary' | 'secondary' | 'detail' | 'hidden' sourceFile: string confidence: 'high' | 'medium' | 'low' + verdict?: 'pass' | 'warning' | 'fail' | 'unavailable' + baselineComparison?: ProjectQorMetricBaselineComparison + leads?: boolean +} + +export interface ProjectQorMetricBaselineComparison { + baselineValue: number | null + absoluteDelta: number | null + relativeDeltaPct: number | null + verdict: 'baseline' | 'improvement' | 'regression' | 'unchanged' | 'not-comparable' } export interface ProjectQorCornerContext { @@ -87,7 +104,7 @@ export interface ProjectQorMetricRating { } export interface ProjectQorSignoffGroup { - step: FlowStep + step: 'RCX' | 'STA' id: string status: QorGateStatus gate: boolean @@ -100,67 +117,11 @@ export interface ProjectQorSignoffReadiness { groups: ProjectQorSignoffGroup[] } -/** - * The five physical QoR coordinates of the ECC-QoR draft 3 record - * (Q_T, Q_I, Q_A, Q_P, Q_R). Scores come only from the ECC-written - * ``home/qor_report.json``; the GUI never recomputes them. - */ -export type QphysKey = 'timing' | 'interconnect' | 'area' | 'power' | 'robustness' - -export type QorScalarStatus = 'GREEN' | 'YELLOW' | 'ORANGE' | 'RED' | 'FAIL' | 'NOT_RATED' - -export interface QphysScoreFeature { - featureId: string - value: number | null - state: string - interpretation: string -} - -export interface QphysScore { - key: QphysKey - value: number | null - state: string - features: QphysScoreFeature[] -} - -export interface ProjectQorDiagnosisInterventionView { - hypothesis: string - tier: 'TIER_1_FEASIBILITY' | 'TIER_2_BOTTLENECK' | 'TIER_3_OPPORTUNITY' - confidence: 'HIGH' | 'MEDIUM' | 'LOW' - parameterKnob: string | null - validationProcedure: string | null -} - -export interface ProjectQorDiagnosisView { - diagnosisId: string - state: string - severity: number - confidence: 'HIGH' | 'MEDIUM' | 'LOW' - interpretation: string - affectedDimensions: string[] - interventions: ProjectQorDiagnosisInterventionView[] -} - -export interface ProjectQorEvidenceView { - index: number | null - state: string - integrity: number | null - coverage: number | null - consistency: number | null -} - export interface ProjectQorSignoffComparisonContext { rcxCornerFingerprint: string | null staPvtRcFingerprint: string | null } -export interface ProjectQorUnsupportedModule { - id: string - label: string - reason: string - status: '待后续开发' -} - export interface ProjectQorBlockingIssue { step: FlowStep metric: string @@ -248,23 +209,15 @@ export interface ProjectQorDataQuality { export interface ProjectQorTrendWorkspaceSummary { workspaceId: string workspaceName: string - workspacePath: string + workspaceKey: string status: QorStatus overallScore: number | null + scoreThreshold: number gateStatus: QorGateStatus signoffReadiness: ProjectQorSignoffReadiness signoffComparison: ProjectQorSignoffComparisonContext - /** Five-coordinate physical QoR record from the ECC report. */ - dimensionScores: Partial> - /** 'qor-v3' when a current ECC report was consumed; null otherwise. */ - scoringEngine: 'qor-v3' | null - /** Profile the ECC report scored under. Null without a report. */ - profile: string | null - qphys: QphysScore[] - diagnoses: ProjectQorDiagnosisView[] - evidence: ProjectQorEvidenceView | null - /** Raw power observation from the ECC report, when available. */ - power?: QorReportPowerObservation | null + areaScoringStep: FlowStep | null + dimensionScores: Partial> records: ProjectQorMetricRecord[] /** Full per-step records used for baseline comparison counts in Home. */ comparisonRecords?: ProjectQorMetricRecord[] @@ -275,6 +228,7 @@ export interface ProjectQorTrendWorkspaceSummary { dataQuality: ProjectQorDataQuality missingAnalysisSteps: FlowStep[] missingMetrics: string[] + qorSnapshotExtension?: EccQorSnapshotExtension } export interface ProjectQorTrendSummary { @@ -282,24 +236,17 @@ export interface ProjectQorTrendSummary { trendPoints: ProjectQorTrendPoint[] baselineWorkspaceId: string | null baselineLabel: string + scoreThreshold: number regressions: ProjectQorRegression[] improvements: ProjectQorDelta[] risks: ProjectQorRisk[] timingClosure: ProjectQorTimingSummary - unsupportedModules: ProjectQorUnsupportedModule[] } export interface ProjectQorTrendOptions { baselineWorkspaceId?: string | null } -export interface ProjectQorTrendReportMetadata { - projectId?: string - projectName?: string - projectPath?: string - generatedAt?: string -} - export interface ProjectQorTrendPoint { workspaceId: string label: string @@ -321,40 +268,6 @@ export interface ProjectQorDelta { state: 'improvement' | 'regression' | 'neutral' } -/** - * A current-workspace view of the project's explicit-baseline comparison. The project - * trend keeps only changes in its top-level lists; Home also needs unchanged metrics to - * present a meaningful per-step comparison denominator. - */ -export interface ProjectQorWorkspaceComparisonDelta extends ProjectQorDelta { - step: FlowStep - unit?: string -} - -/** - * A paired metric is retained for the detail view even when its QoR rule is - * informational only. The dashboard verdict continues to use `deltas`, which - * contains only directional (higher/lower is better) metrics. - */ -export interface ProjectQorWorkspaceComparisonMetric extends ProjectQorWorkspaceComparisonDelta { - polarity: ProjectQorMetricRecord['polarity'] - baselinePolarity: ProjectQorMetricRecord['polarity'] - isDirectional: boolean -} - -export interface ProjectQorWorkspaceComparison { - workspaceId: string - workspaceName: string - score: number | null - baselineWorkspaceId: string | null - baselineWorkspaceName: string | null - baselineScore: number | null - isBaselineWorkspace: boolean - available: boolean - metrics: ProjectQorWorkspaceComparisonMetric[] - deltas: ProjectQorWorkspaceComparisonDelta[] -} - export interface ProjectQorRegression extends ProjectQorDelta { message: string } @@ -487,9 +400,6 @@ const QOR_FLOW_STEPS: FlowStep[] = [ 'Harden', ] -/** The 0-100 QoR score line that separates the Home pass and fail presentation. */ -export const QOR_SCORE_THRESHOLD = 60 - const QOR_DIMENSIONS: QorDimension[] = [ 'timing', 'power_integrity', @@ -507,69 +417,21 @@ const QOR_POLARITIES: QorPolarity[] = [ ] const QOR_CONFIDENCES: QorMetricConfidence[] = ['high', 'medium', 'low'] - const QOR_PROJECT_ROLES: QorMetricProjectRole[] = ['final', 'trend', 'gate', 'none'] - const QOR_STEP_ROLES: QorMetricStepRole[] = ['primary', 'secondary', 'detail', 'hidden'] -const QPHYS_KEYS: QphysKey[] = ['timing', 'interconnect', 'area', 'power', 'robustness'] - -const QPHYS_LABELS: Record = { - timing: 'Timing Quality', - interconnect: 'Interconnect', - area: 'Area Efficiency', - power: 'Power', - robustness: 'Robustness', -} - -const UNSUPPORTED_MODULES: ProjectQorUnsupportedModule[] = [ - { - id: 'sta_analysis', - label: 'STA QoR analysis', - reason: - 'sta_ecc/analysis/qor_metrics.json is not available in the current workspace data.', - status: '待后续开发', - }, - { - id: 'power_ir_em_analysis', - label: 'Power / IR / EM analysis', - reason: 'Power, IR, and EM metrics are not generated into step analysis files yet.', - status: '待后续开发', - }, - { - id: 'qor_metrics_standard_output', - label: 'Standard qor_metrics.json', - reason: - 'No schema v3 qor_metrics.json artifact is available in the current workspace data.', - status: '待后续开发', - }, - { - id: 'qor_summary_standard_output', - label: 'Standard qor_summary.json', - reason: 'No schema v4 step QoR summary is available in the current workspace data.', - status: '待后续开发', - }, - { - id: 'qor_hotspots', - label: 'Spatial hotspot QoR data', - reason: - 'No schema v3 qor_hotspots.json artifact is available in the current workspace data.', - status: '待后续开发', - }, - { - id: 'project_qor_cache', - label: 'Project-level QoR cache', - reason: - 'First version computes from loaded workspace analysis snapshots without a persistent cache.', - status: '待后续开发', - }, -] - export function normalizeQorMetrics(input: QorStepMetricInput): ProjectQorMetricRecord[] { const record = parseJsonObject(input.text) if (record?.schema_version !== 3 || !Array.isArray(record.metrics)) return [] - return record.metrics.flatMap((rawMetric) => { + return normalizeQorMetricRecords(input, record.metrics) +} + +export function normalizeQorMetricRecords( + input: Omit, + metrics: unknown[], +): ProjectQorMetricRecord[] { + return metrics.flatMap((rawMetric) => { if (!rawMetric || typeof rawMetric !== 'object' || Array.isArray(rawMetric)) { return [] } @@ -609,7 +471,7 @@ export function normalizeQorMetrics(input: QorStepMetricInput): ProjectQorMetric return [ { workspaceId: input.workspaceId, - workspacePath: input.workspacePath, + workspaceKey: input.workspaceKey, step: input.step, metricName, displayName: @@ -627,11 +489,31 @@ export function normalizeQorMetrics(input: QorStepMetricInput): ProjectQorMetric stepRole, sourceFile, confidence: qorConfidenceValue(metric.confidence), + verdict: metricVerdict(metricName, value), }, ] }) } +function metricVerdict( + metricName: string, + value: number, +): NonNullable { + if ( + metricName.includes('drc') || + metricName.includes('lvs') || + metricName.includes('violation') || + metricName.includes('missing_corner') || + metricName.includes('parse_failure') + ) { + return value === 0 ? 'pass' : value <= 3 ? 'warning' : 'fail' + } + if (metricName.includes('wns') || metricName.includes('tns')) { + return value >= 0 ? 'pass' : 'fail' + } + return 'pass' +} + function qorMetricRatingValue(value: unknown): ProjectQorMetricRating | null { if ( isRecord(value) && @@ -663,11 +545,20 @@ export function buildProjectQorTrendSummary( options: ProjectQorTrendOptions = {}, ): ProjectQorTrendSummary { const sortedInputs = [...workspaces].sort(compareWorkspaceInput) - const workspaceSummaries = sortedInputs.map(buildWorkspaceSummary) - const baselineWorkspace = resolveExplicitBaselineWorkspace( - workspaceSummaries, + const rawWorkspaceSummaries = sortedInputs.map(buildWorkspaceSummary) + const rawBaselineWorkspace = resolveExplicitBaselineWorkspace( + rawWorkspaceSummaries, options.baselineWorkspaceId, ) + const workspaceSummaries = annotateMetricComparisons( + rawWorkspaceSummaries, + rawBaselineWorkspace, + ) + const baselineWorkspace = rawBaselineWorkspace + ? (workspaceSummaries.find( + (workspace) => workspace.workspaceId === rawBaselineWorkspace.workspaceId, + ) ?? null) + : null const { regressions, improvements } = buildWorkspaceDeltas( workspaceSummaries, baselineWorkspace?.workspaceId ?? null, @@ -695,429 +586,16 @@ export function buildProjectQorTrendSummary( baselineLabel: baselineWorkspace ? baselineWorkspace.workspaceName || baselineWorkspace.workspaceId : 'Sequential workspace baseline', + scoreThreshold: + workspaceSummaries.find((workspace) => workspace.scoreThreshold > 0) + ?.scoreThreshold ?? 0, regressions, improvements, risks, timingClosure, - unsupportedModules: buildUnsupportedModules(sortedInputs, workspaceSummaries), - } -} - -export interface ProjectQorScoreDimensionDetail { - key: QphysKey - label: string - value: number | null - state: string - features: QphysScoreFeature[] -} - -export interface ProjectQorScoreDetail { - overallScore: number | null - gateStatus: QorGateStatus - profile: string | null - evidence: ProjectQorEvidenceView | null - dimensions: ProjectQorScoreDimensionDetail[] -} - -/** - * Score detail straight from the ECC report's five-coordinate Qphys - * record. Features carry their own formulas and interpretations, so the - * GUI has nothing to recompute and nothing to invent. - */ -export function buildProjectQorScoreDetail( - workspace: ProjectQorTrendWorkspaceSummary, -): ProjectQorScoreDetail { - const dimensions = QPHYS_KEYS.map((key) => { - const qphys = workspace.qphys.find((candidate) => candidate.key === key) ?? null - return { - key, - label: QPHYS_LABELS[key], - value: qphys?.value ?? workspace.dimensionScores[key] ?? null, - state: qphys?.state ?? 'UNKNOWN', - features: qphys?.features ?? [], - } - }) - return { - overallScore: workspace.overallScore, - gateStatus: workspace.gateStatus, - profile: workspace.profile, - evidence: workspace.evidence, - dimensions, - } -} - -/** - * Projects use an explicit baseline when one is selected in project.json. Build the - * current workspace's complete paired-metric comparison from that same source. The - * directional subset feeds dashboard verdicts; the full set feeds the per-step detail. - */ -export function buildProjectQorWorkspaceComparison( - summary: ProjectQorTrendSummary, - workspaceId: string, -): ProjectQorWorkspaceComparison { - const workspace = - summary.workspaces.find((candidate) => candidate.workspaceId === workspaceId) ?? null - const baseline = summary.baselineWorkspaceId - ? (summary.workspaces.find( - (candidate) => candidate.workspaceId === summary.baselineWorkspaceId, - ) ?? null) - : null - - if (!workspace) { - return { - workspaceId, - workspaceName: workspaceId, - score: null, - baselineWorkspaceId: baseline?.workspaceId ?? null, - baselineWorkspaceName: baseline?.workspaceName ?? null, - baselineScore: baseline?.overallScore ?? null, - isBaselineWorkspace: false, - available: false, - metrics: [], - deltas: [], - } - } - - const isBaselineWorkspace = workspace.workspaceId === baseline?.workspaceId - if (!baseline || isBaselineWorkspace) { - return { - workspaceId: workspace.workspaceId, - workspaceName: workspace.workspaceName, - score: workspace.overallScore, - baselineWorkspaceId: baseline?.workspaceId ?? null, - baselineWorkspaceName: baseline?.workspaceName ?? null, - baselineScore: baseline?.overallScore ?? null, - isBaselineWorkspace, - available: false, - metrics: [], - deltas: [], - } - } - - const baselineRecords = recordsByComparisonKey( - baseline.comparisonRecords ?? baseline.records, - ) - const metrics = Array.from( - recordsByComparisonKey(workspace.comparisonRecords ?? workspace.records).values(), - ).flatMap((record) => { - const baselineRecord = baselineRecords.get(comparisonRecordKey(record)) - if (!baselineRecord) return [] - const isDirectional = - baselineRecord.polarity === record.polarity && - (record.polarity === 'lower_is_better' || record.polarity === 'higher_is_better') - const delta = buildDelta( - record, - baselineRecord, - workspace.workspaceName || workspace.workspaceId, - baseline.workspaceName || baseline.workspaceId, - ) - return [ - { - ...delta, - state: isDirectional ? delta.state : 'neutral', - step: record.step, - unit: record.unit, - polarity: record.polarity, - baselinePolarity: baselineRecord.polarity, - isDirectional, - }, - ] - }) - const deltas = metrics.filter((metric) => metric.isDirectional) - - return { - workspaceId: workspace.workspaceId, - workspaceName: workspace.workspaceName, - score: workspace.overallScore, - baselineWorkspaceId: baseline.workspaceId, - baselineWorkspaceName: baseline.workspaceName || baseline.workspaceId, - baselineScore: baseline.overallScore, - isBaselineWorkspace: false, - available: true, - metrics: metrics.sort(compareDeltaMagnitude), - deltas: deltas.sort(compareDeltaMagnitude), - } -} - -export function buildProjectQorTrendReport( - summary: ProjectQorTrendSummary, - metadata: ProjectQorTrendReportMetadata = {}, -) { - return { - schema_version: 3, - generated_at: metadata.generatedAt ?? new Date().toISOString(), - project: { - id: metadata.projectId ?? '', - name: metadata.projectName ?? '', - path: metadata.projectPath ?? '', - }, - baseline_workspace_id: summary.baselineWorkspaceId, - baseline_label: summary.baselineLabel, - trend_points: summary.trendPoints.map((point) => ({ - workspace_id: point.workspaceId, - label: point.label, - score: point.score, - status: point.status, - })), - workspaces: summary.workspaces.map((workspace) => ({ - workspace_id: workspace.workspaceId, - workspace_name: workspace.workspaceName, - workspace_path: workspace.workspacePath, - status: workspace.status, - overall_score: workspace.overallScore, - gate_status: workspace.gateStatus, - signoff_readiness: { - status: workspace.signoffReadiness.status, - score_eligible: workspace.signoffReadiness.scoreEligible, - reason_codes: workspace.signoffReadiness.reasonCodes, - groups: workspace.signoffReadiness.groups.map((group) => ({ - step: group.step, - id: group.id, - status: group.status, - gate: group.gate, - })), - }, - signoff_comparison: { - rcx_corner_fingerprint: workspace.signoffComparison.rcxCornerFingerprint, - sta_pvt_rc_fingerprint: workspace.signoffComparison.staPvtRcFingerprint, - }, - scoring_engine: workspace.scoringEngine, - profile: workspace.profile, - dimension_scores: workspace.dimensionScores, - qphys: workspace.qphys.map((dimension) => ({ - key: dimension.key, - value: dimension.value, - state: dimension.state, - features: dimension.features.map((feature) => ({ - feature_id: feature.featureId, - value: feature.value, - state: feature.state, - interpretation: feature.interpretation, - })), - })), - diagnoses: workspace.diagnoses, - evidence: workspace.evidence, - record_count: workspace.records.length, - records: workspace.records.map((record) => ({ - step: record.step, - metric_name: record.metricName, - display_name: record.displayName, - value: record.value, - unit: record.unit ?? '', - dimension: record.dimension, - polarity: record.polarity, - scope: record.scope, - corner: record.corner, - corner_context: record.cornerContext, - analysis_group: record.analysisGroup, - rating: record.rating, - project_role: record.projectRole, - step_role: record.stepRole, - source_file: record.sourceFile, - confidence: record.confidence, - })), - blocking_issues: workspace.blockingIssues.map((issue) => ({ - step: issue.step, - metric: issue.metric, - display_name: issue.displayName, - value: issue.value, - reason: issue.reason, - })), - hotspots: workspace.hotspots.map((hotspot) => ({ - step: hotspot.step, - kind: hotspot.kind, - severity: hotspot.severity, - metric: hotspot.metric, - display_name: hotspot.displayName, - value: hotspot.value, - source_file: hotspot.sourceFile, - description: hotspot.description, - })), - timing_constraints: { - status: workspace.timingConstraints.status, - fingerprint: workspace.timingConstraints.fingerprint, - source_file: workspace.timingConstraints.sourceFile, - step: workspace.timingConstraints.step, - }, - analysis_integrity: workspace.analysisIntegrityIssues.map((issue) => ({ - step: issue.step, - invalid_metric_source_ids: issue.invalidMetricSourceIds, - invalid_detail_ids: issue.invalidDetailIds, - })), - data_quality: { - status: workspace.dataQuality.status, - completed_step_count: workspace.dataQuality.completedStepCount, - analyzed_step_count: workspace.dataQuality.analyzedStepCount, - missing_completed_analysis_steps: - workspace.dataQuality.missingCompletedAnalysisSteps, - available_metric_count: workspace.dataQuality.availableMetricCount, - missing_metric_count: workspace.dataQuality.missingMetricCount, - missing_metric_coverage: workspace.dataQuality.missingMetricCoverage.map( - (coverage) => ({ - step: coverage.step, - missing_metric_count: coverage.missingMetricCount, - }), - ), - invalid_source_count: workspace.dataQuality.invalidSourceCount, - }, - missing_analysis_steps: workspace.missingAnalysisSteps, - missing_metrics: workspace.missingMetrics, - })), - regressions: summary.regressions.map((regression) => ({ - workspace_id: regression.workspaceId, - workspace_name: regression.workspaceName, - baseline_workspace_id: regression.baselineWorkspaceId, - baseline_workspace_name: regression.baselineWorkspaceName, - metric_name: regression.metricName, - display_name: regression.displayName, - current_value: regression.currentValue, - baseline_value: regression.baselineValue, - absolute_delta: regression.absoluteDelta, - relative_delta_pct: regression.relativeDeltaPct, - state: regression.state, - message: regression.message, - })), - improvements: summary.improvements.map((improvement) => ({ - workspace_id: improvement.workspaceId, - workspace_name: improvement.workspaceName, - baseline_workspace_id: improvement.baselineWorkspaceId, - baseline_workspace_name: improvement.baselineWorkspaceName, - metric_name: improvement.metricName, - display_name: improvement.displayName, - current_value: improvement.currentValue, - baseline_value: improvement.baselineValue, - absolute_delta: improvement.absoluteDelta, - relative_delta_pct: improvement.relativeDeltaPct, - state: improvement.state, - })), - risks: summary.risks.map((risk) => ({ - workspace_id: risk.workspaceId, - workspace_name: risk.workspaceName, - step: risk.step, - kind: risk.kind, - severity: risk.severity, - metric: risk.metric, - display_name: risk.displayName, - value: risk.value, - message: risk.message, - })), - timing_closure: { - critical_count: summary.timingClosure.criticalCount, - warning_count: summary.timingClosure.warningCount, - clean_workspace_count: summary.timingClosure.cleanWorkspaceCount, - at_risk_workspace_count: summary.timingClosure.atRiskWorkspaceCount, - incomplete_workspace_count: summary.timingClosure.incompleteWorkspaceCount, - unavailable_workspace_count: summary.timingClosure.unavailableWorkspaceCount, - corner_coverage: summary.timingClosure.coverage.map((coverage) => ({ - workspace_id: coverage.workspaceId, - workspace_name: coverage.workspaceName, - missing_corner_count: coverage.missingCornerCount, - available_artifact_count: coverage.availableArtifactCount, - })), - triage: summary.timingClosure.triage.map((triage) => ({ - issue_id: triage.issueId, - workspace_id: triage.workspaceId, - workspace_name: triage.workspaceName, - baseline_workspace_id: triage.baselineWorkspaceId, - baseline_workspace_name: triage.baselineWorkspaceName, - state: triage.state, - severity: triage.severity, - analysis_type: triage.analysisType, - corner: triage.corner, - path_group: triage.pathGroup, - check_type: triage.checkType, - current_slack_ns: triage.currentSlackNs, - baseline_slack_ns: triage.baselineSlackNs, - slack_delta_ns: triage.slackDeltaNs, - physical_context: triage.physicalContext.map((signal) => ({ - metric_name: signal.metricName, - display_name: signal.displayName, - unit: signal.unit ?? '', - current_value: signal.currentValue, - baseline_value: signal.baselineValue, - absolute_delta: signal.absoluteDelta, - relative_delta_pct: signal.relativeDeltaPct, - })), - review_hints: triage.reviewHints.map((hint) => ({ - id: hint.id, - label: hint.label, - })), - })), - artifact_paths: summary.timingClosure.artifactPaths.map((artifact) => ({ - workspace_id: artifact.workspaceId, - workspace_name: artifact.workspaceName, - corner: artifact.corner, - report_dir: artifact.reportDir, - feature_dir: artifact.featureDir, - qor_summary_file: artifact.qorSummaryFile, - timing_paths_file: artifact.timingPathsFile, - })), - issues: summary.timingClosure.issues.map((issue) => ({ - issue_id: issue.issueId, - workspace_id: issue.workspaceId, - workspace_name: issue.workspaceName, - severity: issue.severity, - analysis_type: issue.analysisType, - corner: issue.corner, - path_group: issue.pathGroup, - check_type: issue.checkType, - slack_ns: issue.slackNs, - launch_clock_network_delay_ns: issue.launchClockNetworkDelayNs, - capture_clock_network_delay_ns: issue.captureClockNetworkDelayNs, - clock_network_delay_delta_ns: issue.clockNetworkDelayDeltaNs, - })), - }, - unsupported_modules: summary.unsupportedModules.map((module) => ({ - id: module.id, - label: module.label, - reason: module.reason, - status: module.status, - })), } } -export function serializeProjectQorTrendReport( - summary: ProjectQorTrendSummary, - metadata: ProjectQorTrendReportMetadata = {}, -): string { - return `${JSON.stringify(buildProjectQorTrendReport(summary, metadata), null, 2)}\n` -} - -function buildUnsupportedModules( - inputs: ProjectQorWorkspaceInput[], - workspaces: ProjectQorTrendWorkspaceSummary[], -): ProjectQorUnsupportedModule[] { - const hasStandardQorMetrics = inputs.some((workspace) => - Object.values(workspace.stepMetricTexts).some(hasStandardQorMetricsText), - ) - const hasStandardQorSummary = inputs.some((workspace) => - Object.values(workspace.stepSummaryTexts ?? {}).some(hasCurrentQorSummaryText), - ) - const hasStandardQorHotspots = inputs.some((workspace) => - Object.values(workspace.stepHotspotTexts ?? {}).some(hasCurrentQorHotspotText), - ) - const records = workspaces.flatMap((workspace) => workspace.records) - const hasStaAnalysis = records.some((record) => record.step === 'STA') - const hasPowerIntegrityAnalysis = records.some( - (record) => record.dimension === 'power_integrity', - ) - - return UNSUPPORTED_MODULES.filter((module) => { - if (module.id === 'qor_metrics_standard_output' && hasStandardQorMetrics) { - return false - } - if (module.id === 'qor_summary_standard_output' && hasStandardQorSummary) { - return false - } - if (module.id === 'qor_hotspots' && hasStandardQorHotspots) { - return false - } - if (module.id === 'sta_analysis' && hasStaAnalysis) return false - if (module.id === 'power_ir_em_analysis' && hasPowerIntegrityAnalysis) return false - return true - }).map((module) => ({ ...module })) -} - function resolveExplicitBaselineWorkspace( workspaces: ProjectQorTrendWorkspaceSummary[], baselineWorkspaceId: string | null | undefined, @@ -1131,27 +609,27 @@ function resolveExplicitBaselineWorkspace( function buildWorkspaceSummary( workspace: ProjectQorWorkspaceInput, ): ProjectQorTrendWorkspaceSummary { - // Scoring authority: the ECC-written workspace report. Metric texts stay - // a pure data channel (records, comparison, coverage) and never feed the - // score; without a current report the workspace is simply not rated. - const report = parseQorReport(workspace.qorReportText) - const stale = report !== null && isQorReportStale(report, workspace.stepStatuses) - const adapted = report !== null && !stale ? adaptQorReport(report) : emptyAdaptedQor() - - const records = QOR_FLOW_STEPS.flatMap((step) => - normalizeQorMetrics({ - workspaceId: workspace.workspaceId, - workspacePath: workspace.workspacePath, - step, - text: workspace.stepMetricTexts[step], - }), - ) + const records = + workspace.normalizedMetrics ?? + QOR_FLOW_STEPS.flatMap((step) => + normalizeQorMetrics({ + workspaceId: workspace.workspaceId, + workspaceKey: workspace.workspaceKey, + step, + text: workspace.stepMetricTexts[step], + }), + ) const timingConstraints = resolveWorkspaceTimingConstraints(workspace) - const projectRecords = selectProjectRecords(records) + const snapshotAssessment = workspace.authoritativeAssessment + const areaScoringStep = snapshotAssessment?.areaScoringStep ?? null + const projectRecords = records const missingAnalysisSteps = QOR_FLOW_STEPS.filter((step) => { if (step === 'LVS' && workspace.stepStatuses.LVS === undefined) return false return !workspace.stepMetricTexts[step] }) + const blockingIssues = QOR_FLOW_STEPS.flatMap((step) => + normalizeQorSummaryBlockingIssues(step, workspace.stepSummaryTexts?.[step]), + ) const summaryMissingMetrics = QOR_FLOW_STEPS.flatMap((step) => normalizeQorSummaryMissingMetrics(step, workspace.stepSummaryTexts?.[step]), ) @@ -1168,6 +646,7 @@ function buildWorkspaceSummary( const missingMetricCoverage = buildMissingMetricCoverage( records, summaryMissingMetrics, + areaScoringStep, workspace.stepStatuses, ) const dataQuality = buildWorkspaceDataQuality( @@ -1177,33 +656,73 @@ function buildWorkspaceSummary( missingMetricCoverage, analysisIntegrityIssues, ) + const gateStatus = resolveWorkspaceGateStatus( + workspace.stepStatuses, + workspace.stepSummaryTexts, + blockingIssues, + ) + const hasSnapshotAssessment = 'authoritativeAssessment' in workspace + const signoffReadiness = hasSnapshotAssessment + ? snapshotSignoffReadiness(snapshotAssessment) + : resolveWorkspaceSignoffReadiness(workspace) const signoffComparison = resolveWorkspaceSignoffComparisonContext(workspace) + const effectiveGateStatus = hasSnapshotAssessment + ? (snapshotAssessment?.gateStatus ?? 'unavailable') + : combineGateStatus(gateStatus, signoffReadiness.status) + const dimensionScores = snapshotAssessment?.dimensionScores ?? {} + const overallScore = snapshotAssessment?.score ?? null return { workspaceId: workspace.workspaceId, workspaceName: workspace.workspaceName, - workspacePath: workspace.workspacePath, - status: workspaceStatus(workspace.status, adapted.scalarStatus), - overallScore: adapted.overallScore, - gateStatus: adapted.gateStatus, - signoffReadiness: adapted.signoffReadiness, + workspaceKey: workspace.workspaceKey, + status: workspaceStatus(workspace.status, overallScore, effectiveGateStatus), + overallScore, + scoreThreshold: snapshotAssessment?.scoreThreshold ?? 0, + gateStatus: effectiveGateStatus, + signoffReadiness, signoffComparison, - scoringEngine: adapted.scoringEngine, - profile: adapted.profile, - dimensionScores: adapted.dimensionScores, - qphys: adapted.qphys, - diagnoses: adapted.diagnoses, - evidence: adapted.evidence, - power: adapted.power, + areaScoringStep, + dimensionScores, records: projectRecords, comparisonRecords: records, - blockingIssues: adapted.blockingIssues, + blockingIssues, hotspots, timingConstraints, analysisIntegrityIssues, dataQuality, missingAnalysisSteps, missingMetrics, + ...(workspace.qorSnapshotExtension + ? { qorSnapshotExtension: workspace.qorSnapshotExtension } + : {}), + } +} + +function snapshotSignoffReadiness( + assessment: ProjectQorWorkspaceInput['authoritativeAssessment'], +): ProjectQorSignoffReadiness { + if (!assessment) { + return { + status: 'unavailable', + scoreEligible: false, + reasonCodes: ['engineering_snapshot_unavailable'], + groups: [], + } + } + return { + status: + assessment.signoffStatus === 'ready' + ? 'pass' + : assessment.signoffStatus === 'blocked' + ? 'blocked' + : 'incomplete', + scoreEligible: assessment.score !== null, + reasonCodes: + assessment.signoffStatus === 'ready' + ? [] + : [`signoff_assessment_${assessment.signoffStatus}`], + groups: [], } } @@ -1248,8 +767,50 @@ function buildWorkspaceDataQuality( } } +const PROJECT_GATE_STEPS: FlowStep[] = ['DRC', 'LVS', 'RCX', 'STA'] + +function workspaceGateSteps( + stepStatuses: ProjectQorWorkspaceInput['stepStatuses'], +): FlowStep[] { + return PROJECT_GATE_STEPS.filter((step) => stepStatuses[step] !== undefined) +} + +function resolveWorkspaceGateStatus( + stepStatuses: ProjectQorWorkspaceInput['stepStatuses'], + summaryTexts: ProjectQorWorkspaceInput['stepSummaryTexts'], + blockingIssues: ProjectQorBlockingIssue[], +): QorGateStatus { + const knownStepStatuses = Object.values(stepStatuses).length > 0 + if (!knownStepStatuses) { + return blockingIssues.length > 0 ? 'blocked' : 'unavailable' + } + if (blockingIssues.length > 0) return 'blocked' + + const gateSteps = workspaceGateSteps(stepStatuses) + if (gateSteps.length === 0) return 'unavailable' + + for (const step of gateSteps) { + if (!isCompletedStepStatus(stepStatuses[step])) return 'incomplete' + const status = qorSummaryStatus(summaryTexts?.[step]) + if (status === 'blocked') return 'blocked' + if (status !== 'pass') return 'incomplete' + } + return 'pass' +} + function isCompletedStepStatus(status: ProjectStepStatus | undefined): boolean { - return status === 'success' || status === 'reused' + return status === 'success' || status === 'warning' || status === 'reused' +} + +function combineGateStatus( + baseStatus: QorGateStatus, + signoffStatus: QorGateStatus, +): QorGateStatus { + if (baseStatus === 'blocked' || signoffStatus === 'blocked') return 'blocked' + if (baseStatus === 'incomplete' || signoffStatus === 'incomplete') return 'incomplete' + if (baseStatus === 'unavailable' || signoffStatus === 'unavailable') + return 'unavailable' + return 'pass' } export function resolveWorkspaceSignoffReadiness( @@ -1354,39 +915,6 @@ function stableStaPvtRcFingerprint(value: unknown): string | null { : null } -function selectProjectRecords( - records: ProjectQorMetricRecord[], -): ProjectQorMetricRecord[] { - const selected = new Map() - for (const record of records) { - if (record.projectRole === 'none') continue - - const key = projectRecordKey(record) - const current = selected.get(key) - if (!current || compareProjectRecordSelection(record, current) < 0) { - selected.set(key, record) - } - } - return Array.from(selected.values()).sort((left, right) => - left.metricName.localeCompare(right.metricName), - ) -} - -function compareProjectRecordSelection( - left: ProjectQorMetricRecord, - right: ProjectQorMetricRecord, -): number { - const rolePriority: Record = { - final: 0, - gate: 1, - trend: 2, - none: 3, - } - const roleDelta = rolePriority[left.projectRole] - rolePriority[right.projectRole] - if (roleDelta !== 0) return roleDelta - return QOR_FLOW_STEPS.indexOf(right.step) - QOR_FLOW_STEPS.indexOf(left.step) -} - function buildProjectQorRisks( workspaces: ProjectQorTrendWorkspaceSummary[], ): ProjectQorRisk[] { @@ -1448,15 +976,9 @@ function buildSignoffReadinessRisks( ): ProjectQorRisk[] { const readiness = workspace.signoffReadiness if (readiness.status === 'pass') return [] - // Reason codes are signoff gate ids (GATE_DRC, GATE_SETUP_SLACK, ...); - // attribute the risk to the earliest blocking gate's step. - const step = readiness.reasonCodes.some((code) => code.includes('DRC')) - ? 'DRC' - : readiness.reasonCodes.some((code) => code.includes('LVS')) - ? 'LVS' - : readiness.reasonCodes.some((code) => code.includes('HARDEN')) - ? 'Harden' - : 'STA' + const step = readiness.reasonCodes.some((code) => code.startsWith('sta_')) + ? 'STA' + : 'RCX' const severity = readiness.status === 'blocked' ? 'critical' @@ -1466,8 +988,8 @@ function buildSignoffReadinessRisks( const message = readiness.reasonCodes.length ? readiness.reasonCodes.join(', ') : readiness.status === 'unavailable' - ? 'Signoff feasibility is unavailable without a current QoR report.' - : `Signoff feasibility is ${readiness.status}.` + ? 'RCX and STA signoff readiness is unavailable.' + : `RCX and STA signoff readiness is ${readiness.status}.` return [ { workspaceId: workspace.workspaceId, @@ -1490,6 +1012,7 @@ function buildWorkspaceDataQualityRisks( const referenceStep = quality.missingCompletedAnalysisSteps[0] ?? workspace.analysisIntegrityIssues[0]?.step ?? + workspace.areaScoringStep ?? 'Route' if (quality.status === 'incomplete' && quality.missingCompletedAnalysisSteps.length) { return [ @@ -2059,13 +1582,13 @@ function recordsAreComparable( record: ProjectQorMetricRecord, ): boolean { if (record.step === 'RCX') { - // The v3 gate registry has no RCX tapeout gate; RCX envelope numbers - // compare only when both reports certify full corner coverage. - const hasFullCoverage = (workspace: ProjectQorTrendWorkspaceSummary) => - workspace.evidence !== null && workspace.evidence.coverage === 1 return ( - hasFullCoverage(current) && - hasFullCoverage(baseline) && + current.signoffReadiness.groups.some( + (group) => group.step === 'RCX' && group.status === 'pass', + ) && + baseline.signoffReadiness.groups.some( + (group) => group.step === 'RCX' && group.status === 'pass', + ) && current.signoffComparison.rcxCornerFingerprint !== null && current.signoffComparison.rcxCornerFingerprint === baseline.signoffComparison.rcxCornerFingerprint @@ -2131,6 +1654,106 @@ function cornerContextIdentity(context: ProjectQorCornerContext | null): string ].join('|') } +function annotateMetricComparisons( + workspaces: ProjectQorTrendWorkspaceSummary[], + baseline: ProjectQorTrendWorkspaceSummary | null, +): ProjectQorTrendWorkspaceSummary[] { + const baselineRecords = baseline + ? recordsByComparisonKey(baseline.comparisonRecords ?? baseline.records) + : new Map() + const leadingByKey = new Map() + const recordsByKey = new Map() + for (const workspace of workspaces) { + for (const record of workspace.comparisonRecords ?? workspace.records) { + if (record.value === null) continue + const key = comparisonRecordKey(record) + const records = recordsByKey.get(key) ?? [] + records.push(record) + recordsByKey.set(key, records) + } + } + for (const [key, records] of recordsByKey) { + const polarity = records[0]?.polarity + const values = records + .map((record) => record.value) + .filter((value): value is number => value !== null) + if ( + values.length < 2 || + new Set(values).size < 2 || + (polarity !== 'lower_is_better' && polarity !== 'higher_is_better') + ) { + continue + } + leadingByKey.set( + key, + polarity === 'lower_is_better' ? Math.min(...values) : Math.max(...values), + ) + } + + const annotate = ( + record: ProjectQorMetricRecord, + workspace: ProjectQorTrendWorkspaceSummary, + ): ProjectQorMetricRecord => { + const key = comparisonRecordKey(record) + const baselineRecord = baselineRecords.get(key) + let comparison: ProjectQorMetricBaselineComparison + if (baseline && workspace.workspaceId === baseline.workspaceId) { + comparison = { + baselineValue: record.value, + absoluteDelta: 0, + relativeDeltaPct: 0, + verdict: 'baseline', + } + } else if ( + !baselineRecord || + record.value === null || + baselineRecord.value === null + ) { + comparison = { + baselineValue: baselineRecord?.value ?? null, + absoluteDelta: null, + relativeDeltaPct: null, + verdict: 'not-comparable', + } + } else { + const delta = buildDelta( + record, + baselineRecord, + workspace.workspaceName, + baseline?.workspaceName ?? '', + ) + comparison = { + baselineValue: delta.baselineValue, + absoluteDelta: delta.absoluteDelta, + relativeDeltaPct: delta.relativeDeltaPct, + verdict: + record.polarity === 'lower_is_better' || record.polarity === 'higher_is_better' + ? delta.state === 'neutral' + ? 'unchanged' + : delta.state + : 'not-comparable', + } + } + return { + ...record, + baselineComparison: comparison, + leads: leadingByKey.get(key) === record.value, + } + } + + return workspaces.map((workspace) => ({ + ...workspace, + records: workspace.records.map((record) => annotate(record, workspace)), + ...(workspace.comparisonRecords + ? { + comparisonRecords: workspace.comparisonRecords.map((record) => + annotate(record, workspace), + ), + } + : {}), + })) +} + function buildDelta( record: ProjectQorMetricRecord, baseline: ProjectQorMetricRecord, @@ -2199,6 +1822,7 @@ function buildMissingMetrics( function buildMissingMetricCoverage( records: ProjectQorMetricRecord[], summaryMissingMetrics: Array<{ step: FlowStep; metricName: string }>, + areaScoringStep: FlowStep | null, stepStatuses: ProjectQorWorkspaceInput['stepStatuses'] = {}, ): ProjectQorMissingMetricCoverage[] { const metricIdsByStep = new Map>() @@ -2209,7 +1833,7 @@ function buildMissingMetricCoverage( } for (const metricName of buildMissingMetrics(records, stepStatuses)) { - const step = missingMetricProducerStep(metricName) + const step = missingMetricProducerStep(metricName, areaScoringStep) if (step) addMetric(step, metricName) } for (const metric of summaryMissingMetrics) { @@ -2222,7 +1846,10 @@ function buildMissingMetricCoverage( }) } -function missingMetricProducerStep(metricName: string): FlowStep | null { +function missingMetricProducerStep( + metricName: string, + areaScoringStep: FlowStep | null, +): FlowStep | null { switch (metricName) { case 'route_wirelength': case 'route_via_count': @@ -2239,7 +1866,7 @@ function missingMetricProducerStep(metricName: string): FlowStep | null { return 'CTS' case 'die_area': case 'core_utilization': - return 'Floor' + return areaScoringStep ?? 'Floor' default: return null } @@ -2251,7 +1878,8 @@ function uniqueStrings(values: string[]): string[] { function workspaceStatus( workspaceStatus: ProjectWorkspaceStatus, - scalarStatus: QorScalarStatus, + score: number | null, + gateStatus: QorGateStatus, ): QorStatus { if ( workspaceStatus === 'failed' || @@ -2261,20 +1889,13 @@ function workspaceStatus( ) { return workspaceStatus === 'failed' ? 'Red' : 'Blocked' } - switch (scalarStatus) { - case 'GREEN': - return 'Green' - case 'YELLOW': - return 'Yellow' - case 'ORANGE': - return 'Orange' - case 'FAIL': - case 'RED': - return 'Red' - default: - // NOT_RATED: no current ECC report means there is nothing to show. - return 'Blocked' - } + if (gateStatus === 'blocked') return 'Orange' + if (gateStatus === 'incomplete') return 'Yellow' + if (score === null) return 'Blocked' + if (score >= 40) return 'Green' + if (score >= 25) return 'Yellow' + if (score >= 10) return 'Orange' + return 'Red' } function parseJsonObject( @@ -2296,7 +1917,7 @@ export function resolveWorkspaceTimingConstraints( const entries = QOR_FLOW_STEPS.flatMap((step) => { const context = normalizeTimingConstraintContext({ workspaceId: workspace.workspaceId, - workspacePath: workspace.workspacePath, + workspaceKey: workspace.workspaceKey, step, text: workspace.stepMetricTexts[step], }) @@ -2504,11 +2125,6 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => stringValue(item) !== null) } -function hasStandardQorMetricsText(text: string | null | undefined): boolean { - const record = parseJsonObject(text) - return record?.schema_version === 3 && Array.isArray(record.metrics) -} - export function hasCurrentQorMetricsText(text: string | null | undefined): boolean { const record = parseJsonObject(text) if (record?.schema_version !== 3 || !Array.isArray(record.metrics)) return false diff --git a/ecos/gui/apps/desktop-electron/electron/services/windowService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/windowService.test.ts index a47d229b9..58cf3c812 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/windowService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/windowService.test.ts @@ -69,7 +69,7 @@ describe('windowService', () => { it('bridges resize and maximize state changes to renderer event channels', () => { const windowDouble = createWindowDouble(false) - const dispose = bindWindowEvents(windowDouble) + const dispose = bindWindowEvents(windowDouble, { onCloseRequest: vi.fn() }) windowDouble.listeners.get('resize')?.() windowDouble.listeners.get('maximize')?.() @@ -97,17 +97,16 @@ describe('windowService', () => { expect(windowDouble.closeListeners.size).toBe(0) }) - it('requests renderer cleanup before allowing a native window close to finish', () => { + it('requests coordinated cleanup before allowing a native window close to finish', () => { const windowDouble = createWindowDouble(false) - const dispose = bindWindowEvents(windowDouble) + const onCloseRequest = vi.fn() + const dispose = bindWindowEvents(windowDouble, { onCloseRequest }) const firstCloseEvent = { preventDefault: vi.fn() } windowDouble.closeListeners.get('close')?.(firstCloseEvent) expect(firstCloseEvent.preventDefault).toHaveBeenCalledTimes(1) - expect(windowDouble.webContents.send).toHaveBeenCalledWith( - desktopApiEventChannels.windowCloseRequested, - ) + expect(onCloseRequest).toHaveBeenCalledOnce() confirmWindowClose(windowDouble) diff --git a/ecos/gui/apps/desktop-electron/electron/services/windowService.ts b/ecos/gui/apps/desktop-electron/electron/services/windowService.ts index c079128a5..17325ac9e 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/windowService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/windowService.ts @@ -60,7 +60,10 @@ export function isWindowMaximized(window: BrowserWindowLike): boolean { return window.isMaximized() } -export function bindWindowEvents(window: BrowserWindowLike): () => void { +export function bindWindowEvents( + window: BrowserWindowLike, + options: { onCloseRequest: () => void }, +): () => void { const listeners: Array<['maximize' | 'resize' | 'unmaximize', WindowEventListener]> = [ [ 'resize', @@ -88,7 +91,7 @@ export function bindWindowEvents(window: BrowserWindowLike): () => void { } event.preventDefault() - window.webContents.send(desktopApiEventChannels.windowCloseRequested) + options.onCloseRequest() } for (const [eventName, listener] of listeners) { diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournal.test.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournal.test.ts new file mode 100644 index 000000000..1b38f9011 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournal.test.ts @@ -0,0 +1,445 @@ +import { mkdtemp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WorkspaceCreationJournal } from './workspaceCreationJournal' +import type { WorkspaceRegistrationEvidence } from './projectManifestService' + +const roots: string[] = [] + +async function journal( + isWorkspace: (path: string) => Promise = vi.fn(async () => false), + inspectWorkspaceIdentity: ( + path: string, + ) => Promise<{ workspaceId?: string; workspaceRevision?: number }> = vi.fn( + async () => ({}), + ), +) { + const root = await mkdtemp(join(tmpdir(), 'ecos-creation-journal-')) + roots.push(root) + const settings = new Map() + let registration: WorkspaceRegistrationEvidence | null = null + const projectManifestService = { + ensureWorkspaceRegistration: vi.fn(async (_projectRoot, workspacePath) => { + registration ??= evidence(workspacePath) + return registration + }), + inspectWorkspaceRegistration: vi.fn(async () => registration), + removeWorkspaceRegistration: vi.fn(async (_projectRoot, current) => { + if (registration?.fingerprint !== current.fingerprint) { + throw new Error('Registration changed.') + } + registration = null + }), + } + const settingsStore = { + get: vi.fn(async (key: string) => settings.get(key) ?? null) as never, + set: vi.fn(async (key: string, value: unknown) => { + settings.set(key, value) + }) as never, + } + return { + projectManifestService, + root, + service: new WorkspaceCreationJournal({ + canonicalizePaths: async (projectRoot, targetDirectory) => ({ + projectRoot, + targetDirectory, + }), + directory: root, + inspectWorkspaceIdentity, + isWorkspace, + projectManifestService, + settingsStore, + }), + setRegistration: (value: WorkspaceRegistrationEvidence | null) => { + registration = value + }, + settings, + settingsStore, + } +} + +function evidence(workspacePath: string): WorkspaceRegistrationEvidence { + return { + fingerprint: JSON.stringify({ workspace_id: 'ws_1', workspace_path: workspacePath }), + projectId: 'project-1', + workspaceId: 'ws_1', + workspacePath, + } +} + +function recentWorkspace(targetDirectory: string) { + return { + designTool: 'backend', + id: targetDirectory, + lastOpened: '2026-09-04T00:00:00.000Z', + name: 'ws_1', + path: targetDirectory, + } +} + +afterEach(async () => { + const { rm } = await import('node:fs/promises') + await Promise.all( + roots.splice(0).map((root) => rm(root, { force: true, recursive: true })), + ) +}) + +describe('WorkspaceCreationJournal', () => { + it('writes authorized intent atomically and removes it only after completion', async () => { + const { root, service, setRegistration, settings } = await journal() + const targetDirectory = join(root, 'project', 'ws_1') + const record = await service.begin(7, { + commandId: 'command-1', + projectId: 'project-1', + projectRoot: join(root, 'project'), + targetDirectory, + workspaceBindings: { inputs: {} }, + workspaceSpec: { design: { name: 'gcd' } }, + }) + + expect(await service.entriesForWindow(7)).toEqual([ + expect.objectContaining({ + creationId: record.creationId, + ownerWindowId: 7, + stage: 'intent-recorded', + status: 'active', + targetDirectory, + }), + ]) + expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + const stored = JSON.parse( + await readFile(join(root, `${record.creationId}.json`), 'utf8'), + ) + expect(stored.intent.workspaceSpec).toEqual({ design: { name: 'gcd' } }) + + await service.markWorkspaceCreated( + record.creationId, + { + workspaceId: 'engineering-1', + workspaceRevision: 2, + }, + 7, + ) + expect(await service.entriesForWindow(7)).toEqual([ + expect.objectContaining({ stage: 'workspace-created' }), + ]) + + setRegistration(evidence(targetDirectory)) + await service.registerWorkspace(record.creationId, 7) + expect(await service.entriesForWindow(7)).toEqual([ + expect.objectContaining({ stage: 'manifest-registered' }), + ]) + await service.markWorkspaceCreated(record.creationId, {}, 7) + expect(await service.entriesForWindow(7)).toEqual([ + expect.objectContaining({ stage: 'manifest-registered' }), + ]) + settings.set('recent_projects', [recentWorkspace(targetDirectory)]) + await service.complete(record.creationId, 7) + expect(await readdir(root)).toEqual([]) + }) + + it('loads interrupted and corrupt records as attention without deleting targets', async () => { + const { root, service } = await journal() + const targetDirectory = join(root, 'project', 'ws_1') + await mkdir(targetDirectory, { recursive: true }) + const record = await service.begin(8, { + commandId: 'command-2', + projectRoot: join(root, 'project'), + targetDirectory, + workspaceBindings: {}, + workspaceSpec: {}, + }) + await writeFile(join(root, 'corrupt.json'), '{', 'utf8') + + const restarted = new WorkspaceCreationJournal({ + canonicalizePaths: async (projectRoot, targetDirectory) => ({ + projectRoot, + targetDirectory, + }), + directory: root, + inspectWorkspaceIdentity: async () => ({}), + isWorkspace: async () => false, + projectManifestService: { + ensureWorkspaceRegistration: vi.fn(), + inspectWorkspaceRegistration: vi.fn(async () => null), + removeWorkspaceRegistration: vi.fn(), + }, + settingsStore: { + get: vi.fn(async () => null) as never, + set: vi.fn() as never, + }, + }) + await restarted.initialize() + const entries = await restarted.entriesForWindow(999) + + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + creationId: record.creationId, + status: 'unfinished', + targetDirectory, + }), + expect.objectContaining({ status: 'invalid' }), + ]), + ) + expect(await readdir(targetDirectory)).toEqual([]) + await expect(restarted.abandon('corrupt', 999)).rejects.toThrow('manual quarantine') + expect(await readdir(root)).toContain('corrupt.json') + }) + + it('rejects active creation actions from a different window', async () => { + const { root, service } = await journal() + const record = await service.begin(7, { + commandId: 'command-owner', + projectRoot: join(root, 'project'), + targetDirectory: join(root, 'project', 'ws_1'), + workspaceBindings: {}, + workspaceSpec: {}, + }) + + await expect(service.markWorkspaceCreated(record.creationId, {}, 8)).rejects.toThrow( + 'not owned by this window', + ) + await expect(service.abandon(record.creationId, 7)).rejects.toThrow( + 'not awaiting recovery', + ) + }) + + it('serializes Force-quit unfinished state ahead of later stage writes', async () => { + const { root, service } = await journal() + const record = await service.begin(7, { + commandId: 'command-force-race', + projectRoot: join(root, 'project'), + targetDirectory: join(root, 'project', 'ws_1'), + workspaceBindings: {}, + workspaceSpec: {}, + }) + + const persistUnfinished = service.markActiveUnfinished(new Set([7])) + const lateStage = service.markWorkspaceCreated(record.creationId, {}, 7) + await persistUnfinished + + await expect(lateStage).rejects.toThrow('not owned by this window') + await expect(service.entriesForWindow(7)).resolves.toEqual([ + expect.objectContaining({ status: 'unfinished' }), + ]) + }) + + it('reports malformed persisted fields as invalid without inspecting the target', async () => { + const first = await journal() + const targetDirectory = join(first.root, 'project', 'ws_1') + const record = await first.service.begin(1, { + commandId: 'command-malformed', + projectRoot: join(first.root, 'project'), + targetDirectory, + workspaceBindings: {}, + workspaceSpec: {}, + }) + const journalPath = join(first.root, `${record.creationId}.json`) + const stored = JSON.parse(await readFile(journalPath, 'utf8')) + delete stored.createdAt + await writeFile(journalPath, JSON.stringify(stored), 'utf8') + const isWorkspace = vi.fn(async () => true) + const restarted = new WorkspaceCreationJournal({ + canonicalizePaths: async (projectRoot, target) => ({ + projectRoot, + targetDirectory: target, + }), + directory: first.root, + inspectWorkspaceIdentity: async () => ({}), + isWorkspace, + projectManifestService: first.projectManifestService, + settingsStore: first.settingsStore, + }) + + await restarted.initialize() + + expect(await restarted.entriesForWindow(1)).toEqual([ + expect.objectContaining({ creationId: record.creationId, status: 'invalid' }), + ]) + expect(isWorkspace).not.toHaveBeenCalled() + }) + + it('continues only a complete Workspace and abandons without deleting files', async () => { + const isWorkspace = vi.fn(async () => true) + const { projectManifestService, root, service, settings } = await journal(isWorkspace) + const targetDirectory = join(root, 'project', 'ws_1') + await mkdir(targetDirectory, { recursive: true }) + await writeFile(join(targetDirectory, 'keep.txt'), 'keep', 'utf8') + const first = await service.begin(1, { + commandId: 'command-3', + projectRoot: join(root, 'project'), + targetDirectory, + workspaceBindings: {}, + workspaceSpec: {}, + }) + await service.markUnfinished(first.creationId, 'Application exited.', 1) + + await expect(service.continueInitialization(first.creationId, 1)).resolves.toEqual({ + recovered: true, + }) + expect(isWorkspace).toHaveBeenCalledWith(targetDirectory) + expect(projectManifestService.ensureWorkspaceRegistration).toHaveBeenCalledWith( + join(root, 'project'), + targetDirectory, + undefined, + ) + expect(settings.get('recent_projects')).toEqual([ + expect.objectContaining({ path: targetDirectory }), + ]) + + const second = await service.begin(1, { + commandId: 'command-4', + projectRoot: join(root, 'project'), + targetDirectory, + workspaceBindings: {}, + workspaceSpec: {}, + }) + await service.markUnfinished(second.creationId, 'Application exited.', 1) + await expect(service.abandon(second.creationId, 1)).resolves.toEqual({ + abandoned: true, + }) + expect(await readFile(join(targetDirectory, 'keep.txt'), 'utf8')).toBe('keep') + }) + + it('stops recovery when the created Workspace identity changed', async () => { + const inspectWorkspaceIdentity = vi.fn(async () => ({ + workspaceId: 'engineering-other', + workspaceRevision: 3, + })) + const { root, service } = await journal( + vi.fn(async () => true), + inspectWorkspaceIdentity, + ) + const record = await service.begin(1, { + commandId: 'command-identity', + projectRoot: join(root, 'project'), + targetDirectory: join(root, 'project', 'ws_1'), + workspaceBindings: {}, + workspaceSpec: {}, + }) + await service.markWorkspaceCreated( + record.creationId, + { workspaceId: 'engineering-created', workspaceRevision: 2 }, + 1, + ) + await service.markUnfinished(record.creationId, 'Interrupted.', 1) + + await expect(service.continueInitialization(record.creationId, 1)).resolves.toEqual( + expect.objectContaining({ + issue: expect.stringContaining('does not match'), + recovered: false, + }), + ) + }) + + it('retains the journal when normal completion cannot confirm Project registration', async () => { + const { root, service, settings } = await journal(async () => true) + const targetDirectory = join(root, 'project', 'ws_1') + const record = await service.begin(1, { + commandId: 'command-5', + projectRoot: join(root, 'project'), + targetDirectory, + workspaceBindings: {}, + workspaceSpec: {}, + }) + await service.markWorkspaceCreated(record.creationId, {}, 1) + settings.set('recent_projects', [recentWorkspace(targetDirectory)]) + + await expect(service.complete(record.creationId, 1)).rejects.toThrow( + 'Project manifest does not contain', + ) + expect(await readdir(root)).toContain(`${record.creationId}.json`) + }) + + it('abandons only exact registrations introduced after the journal began', async () => { + const first = await journal(async () => false) + const targetDirectory = join(first.root, 'project', 'ws_1') + await mkdir(targetDirectory, { recursive: true }) + await writeFile(join(targetDirectory, 'keep.txt'), 'keep', 'utf8') + const record = await first.service.begin(1, { + commandId: 'command-ownership', + projectRoot: join(first.root, 'project'), + targetDirectory, + workspaceBindings: {}, + workspaceSpec: {}, + }) + first.setRegistration(evidence(targetDirectory)) + await first.service.registerWorkspace(record.creationId, 1) + await first.service.markUnfinished(record.creationId, 'Interrupted.', 1) + + await expect(first.service.abandon(record.creationId, 1)).resolves.toEqual({ + abandoned: true, + }) + + expect( + first.projectManifestService.removeWorkspaceRegistration, + ).toHaveBeenCalledOnce() + expect(first.settings.has('recent_projects')).toBe(false) + expect(await readFile(join(targetDirectory, 'keep.txt'), 'utf8')).toBe('keep') + }) + + it('keeps registrations and journal when a post-crash owner cannot be proved', async () => { + const first = await journal(async () => false) + const targetDirectory = join(first.root, 'project', 'ws_1') + const record = await first.service.begin(1, { + commandId: 'command-unproved', + projectRoot: join(first.root, 'project'), + targetDirectory, + workspaceBindings: {}, + workspaceSpec: {}, + }) + first.setRegistration(evidence(targetDirectory)) + await first.service.registerWorkspace(record.creationId, 1) + first.settings.set('recent_projects', [recentWorkspace(targetDirectory)]) + await first.service.markUnfinished(record.creationId, 'Interrupted.', 1) + + await expect(first.service.abandon(record.creationId, 1)).rejects.toThrow( + 'ownership cannot be confirmed', + ) + expect( + first.projectManifestService.removeWorkspaceRegistration, + ).not.toHaveBeenCalled() + expect(first.settings.get('recent_projects')).toHaveLength(1) + expect(await readdir(first.root)).toContain(`${record.creationId}.json`) + }) + + it('reconciles an unambiguously completed interrupted creation on startup', async () => { + const isWorkspace = vi.fn(async () => true) + const first = await journal(isWorkspace) + const targetDirectory = join(first.root, 'project', 'ws_1') + const record = await first.service.begin(1, { + commandId: 'command-6', + projectRoot: join(first.root, 'project'), + targetDirectory, + workspaceBindings: {}, + workspaceSpec: {}, + }) + await first.service.markWorkspaceCreated(record.creationId, {}, 1) + first.setRegistration(evidence(targetDirectory)) + first.settings.set('recent_projects', [recentWorkspace(targetDirectory)]) + + const restarted = new WorkspaceCreationJournal({ + canonicalizePaths: async (projectRoot, targetDirectory) => ({ + projectRoot, + targetDirectory, + }), + directory: first.root, + inspectWorkspaceIdentity: async () => ({}), + isWorkspace, + projectManifestService: first.projectManifestService, + settingsStore: first.settingsStore, + }) + await restarted.initialize() + + expect(await readdir(first.root)).toEqual([]) + expect(await restarted.entriesForWindow(1)).toEqual([ + expect.objectContaining({ + creationId: record.creationId, + status: 'recovered', + }), + ]) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournal.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournal.ts new file mode 100644 index 000000000..48bdd1ed6 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournal.ts @@ -0,0 +1,499 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import type { + EccBackgroundWorkspaceCreation, + EccWorkspaceCreateRequest, + EccWorkspaceCreationStage, +} from '@ecos-studio/shared' +import { isPathWithinRoot } from './pathScope' +import { + authorizeCreationRecord, + creationTargetExists, + nodeErrorCode, + requireActiveCreationOwner, + requireCreationRecovery, + workspaceCreationIdentityMatches, + type CreationPathAuthorizer, +} from './workspaceCreationJournalAuthorization' +import { + boundedIssue, + isCreationJournalRecord, + monotonicCreationStage, + normalizeCreationPath, + projectCreationRecord, + sanitizedRecord, + type CreationJournalRecord, +} from './workspaceCreationJournalRecord' +import { + captureRegistrationOwnership, + confirmApplicationRegistration, + confirmManifestRegistration, + isUnambiguouslyComplete, + removeOwnedRegistrations, + type WorkspaceCreationRegistrationOptions, +} from './workspaceCreationRegistrations' + +interface InvalidJournal { + entry: EccBackgroundWorkspaceCreation + path: string +} + +export interface WorkspaceCreationJournalOptions + extends WorkspaceCreationRegistrationOptions, CreationPathAuthorizer { + directory: string + inspectWorkspaceIdentity( + targetDirectory: string, + ): Promise<{ workspaceId?: string; workspaceRevision?: number }> +} + +export class WorkspaceCreationJournal { + private readonly records = new Map() + private readonly invalid = new Map() + private readonly recovered: EccBackgroundWorkspaceCreation[] = [] + private readonly listeners = new Set<(generation: number) => void>() + private readonly actions = new Map>() + private initialized = false + private initializing: Promise | null = null + private projectionGeneration = 0 + + constructor(private readonly options: WorkspaceCreationJournalOptions) {} + + get generation(): number { + return this.projectionGeneration + } + + onInvalidated(listener: (generation: number) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + initialize(): Promise { + if (this.initialized) return Promise.resolve() + this.initializing ??= this.load() + .then(() => { + this.initialized = true + }) + .finally(() => { + this.initializing = null + }) + return this.initializing + } + + async begin( + ownerWindowId: number, + request: EccWorkspaceCreateRequest, + ): Promise { + await this.initialize() + if ( + [...this.records.values()].some( + (record) => + record.ownerWindowId === ownerWindowId && + record.commandId === request.commandId, + ) + ) { + throw new Error('Workspace creation command is already journaled.') + } + if ( + !isAbsolute(request.targetDirectory) || + (request.projectRoot !== undefined && !isAbsolute(request.projectRoot)) + ) { + throw new Error('Workspace creation paths must be absolute.') + } + const requestedTarget = resolve(request.targetDirectory) + const requestedRoot = resolve(request.projectRoot ?? dirname(requestedTarget)) + const { projectRoot, targetDirectory } = await this.options.canonicalizePaths( + requestedRoot, + requestedTarget, + ) + if ( + !isAbsolute(targetDirectory) || + !isAbsolute(projectRoot) || + !isPathWithinRoot(targetDirectory, projectRoot) + ) { + throw new Error('Workspace creation target is outside the Project root.') + } + const now = Date.now() + const registrations = await captureRegistrationOwnership( + this.options, + projectRoot, + targetDirectory, + request.projectId, + ) + const record: CreationJournalRecord = { + ...registrations, + commandId: request.commandId, + createdAt: now, + creationId: randomUUID(), + intent: { + ...(request.projectId ? { projectId: request.projectId } : {}), + projectRoot, + workspaceBindings: sanitizedRecord(request.workspaceBindings), + workspaceSpec: sanitizedRecord(request.workspaceSpec), + }, + ownerWindowId, + stage: 'intent-recorded', + status: 'active', + targetDirectory, + targetExistedBefore: await creationTargetExists(targetDirectory), + updatedAt: now, + version: 1, + } + await this.write(record) + this.records.set(record.creationId, record) + this.invalidate() + return structuredClone(record) + } + + complete(creationId: string, ownerWindowId: number): Promise { + return this.serialize(creationId, async () => { + const record = this.requireRecord(creationId) + requireActiveCreationOwner(record, ownerWindowId) + await authorizeCreationRecord(this.options, record) + const withManifest = await this.advance( + await confirmManifestRegistration(this.options, record, false), + 'manifest-registered', + ) + const withApplication = await this.advance( + await confirmApplicationRegistration(this.options, withManifest, false), + 'application-registered', + ) + await this.finish(withApplication, false) + }) + } + + markWorkspaceCreated( + creationId: string, + result: { workspaceId?: string; workspaceRevision?: number }, + ownerWindowId: number, + ): Promise { + return this.serialize(creationId, async () => { + const record = this.requireRecord(creationId) + requireActiveCreationOwner(record, ownerWindowId) + const updated: CreationJournalRecord = { + ...record, + stage: monotonicCreationStage(record.stage, 'workspace-created'), + updatedAt: Date.now(), + ...(result.workspaceId ? { workspaceId: result.workspaceId } : {}), + ...(typeof result.workspaceRevision === 'number' + ? { workspaceRevision: result.workspaceRevision } + : {}), + } + await this.write(updated) + this.records.set(creationId, updated) + this.invalidate() + }) + } + + registerWorkspace(creationId: string, ownerWindowId: number): Promise { + return this.serialize(creationId, async () => { + const record = this.requireRecord(creationId) + requireActiveCreationOwner(record, ownerWindowId) + await authorizeCreationRecord(this.options, record) + await this.advance( + await confirmManifestRegistration(this.options, record, true), + 'manifest-registered', + ) + }) + } + + markUnfinished( + creationId: string, + issue: string, + ownerWindowId: number, + ): Promise { + return this.serialize(creationId, async () => { + const record = this.requireRecord(creationId) + requireActiveCreationOwner(record, ownerWindowId) + await this.updateUnfinished(record, issue) + }) + } + + async markActiveUnfinished(ownerWindowIds?: ReadonlySet): Promise { + await this.initialize() + await Promise.all( + [...this.records.values()] + .filter( + (record) => + record.status === 'active' && + (!ownerWindowIds || ownerWindowIds.has(record.ownerWindowId)), + ) + .map((record) => + this.enqueue(record.creationId, async () => { + const current = this.records.get(record.creationId) + if (!current || current.status !== 'active') return + await this.updateUnfinished(current, 'Application exited during creation.') + }), + ), + ) + } + + async entriesForWindow(windowId: number): Promise { + await this.initialize() + return [ + ...[...this.records.values()] + .filter( + (record) => record.status === 'unfinished' || record.ownerWindowId === windowId, + ) + .map(projectCreationRecord), + ...[...this.invalid.values()].map(({ entry }) => ({ ...entry })), + ...this.recovered.map((entry) => ({ ...entry })), + ] + } + + async allEntries(): Promise { + await this.initialize() + return [ + ...[...this.records.values()].map(projectCreationRecord), + ...[...this.invalid.values()].map(({ entry }) => ({ ...entry })), + ...this.recovered.map((entry) => ({ ...entry })), + ] + } + + async allowsRegistration( + windowId: number, + projectRoot: string, + targetDirectory: string, + ): Promise { + await this.initialize() + const project = normalizeCreationPath(projectRoot) + const target = normalizeCreationPath(targetDirectory) + return [...this.records.values()].some( + (record) => + record.status === 'active' && + record.ownerWindowId === windowId && + normalizeCreationPath(record.intent.projectRoot) === project && + normalizeCreationPath(record.targetDirectory) === target, + ) + } + + continueInitialization( + creationId: string, + _ownerWindowId: number, + ): Promise<{ recovered: boolean; issue?: string }> { + return this.serialize(creationId, async () => { + const record = this.requireRecord(creationId) + requireCreationRecovery(record) + await authorizeCreationRecord(this.options, record) + if (!(await this.options.isWorkspace(record.targetDirectory))) { + const issue = + 'The target is not a complete ECOS Workspace. Files were left unchanged.' + await this.updateUnfinished(record, issue) + return { recovered: false, issue } + } + const identity = await this.options.inspectWorkspaceIdentity(record.targetDirectory) + if (!workspaceCreationIdentityMatches(record, identity)) { + const issue = + 'The Workspace identity or Revision does not match this creation record. Files were left unchanged.' + await this.updateUnfinished(record, issue) + return { recovered: false, issue } + } + const withManifest = await this.advance( + await confirmManifestRegistration(this.options, record, true), + 'manifest-registered', + ) + const withApplication = await this.advance( + await confirmApplicationRegistration(this.options, withManifest, true), + 'application-registered', + ) + await this.finish(withApplication, true) + return { recovered: true } + }) + } + + abandon(creationId: string, _ownerWindowId: number): Promise<{ abandoned: boolean }> { + return this.serialize(creationId, async () => { + const record = this.records.get(creationId) + if (record) { + requireCreationRecovery(record) + await authorizeCreationRecord(this.options, record) + await removeOwnedRegistrations(this.options, record) + await rm(this.pathFor(creationId), { force: true }) + this.records.delete(creationId) + } else { + const invalid = this.invalid.get(creationId) + if (!invalid) throw new Error('Workspace creation journal was not found.') + throw new Error( + 'Invalid creation journals require manual quarantine and were left unchanged.', + ) + } + this.invalidate() + return { abandoned: true } + }) + } + + private async load(): Promise { + let names: string[] + try { + names = await readdir(this.options.directory) + } catch (error) { + if (nodeErrorCode(error) === 'ENOENT') return + throw error + } + for (const name of names) { + if (!name.endsWith('.json')) continue + const path = join(this.options.directory, name) + try { + const parsed: unknown = JSON.parse(await readFile(path, 'utf8')) + if (!isCreationJournalRecord(parsed)) throw new Error('Invalid schema.') + await authorizeCreationRecord(this.options, parsed) + if ( + normalizeCreationPath(parsed.intent.projectRoot) !== + normalizeCreationPath(parsed.targetDirectory) && + !parsed.manifestRegistration + ) { + throw new Error('Managed Workspace registration evidence is missing.') + } + if (parsed.stage === 'completed') { + await rm(path, { force: true }) + this.recordRecovered(parsed) + continue + } + const record = + parsed.status === 'active' + ? { + ...parsed, + issue: 'Application exited during creation.', + status: 'unfinished' as const, + updatedAt: Date.now(), + } + : parsed + if (record !== parsed) await this.write(record) + try { + if ( + (await isUnambiguouslyComplete(this.options, record)) && + workspaceCreationIdentityMatches( + record, + await this.options.inspectWorkspaceIdentity(record.targetDirectory), + ) + ) { + await rm(path, { force: true }) + this.recordRecovered(record) + continue + } + this.records.set(record.creationId, record) + } catch (error) { + const unfinished = { + ...record, + issue: boundedIssue(error instanceof Error ? error.message : String(error)), + status: 'unfinished' as const, + updatedAt: Date.now(), + } + await this.write(unfinished) + this.records.set(unfinished.creationId, unfinished) + } + } catch (error) { + const creationId = name.slice(0, -5) + this.invalid.set(creationId, { + entry: { + creationId, + issue: boundedIssue(error instanceof Error ? error.message : String(error)), + status: 'invalid', + updatedAt: Date.now(), + }, + path, + }) + } + } + if (this.records.size || this.invalid.size) this.invalidate() + } + + private async write(record: CreationJournalRecord): Promise { + await mkdir(this.options.directory, { recursive: true }) + const path = this.pathFor(record.creationId) + const temporaryPath = `${path}.${randomUUID()}.tmp` + try { + await writeFile(temporaryPath, JSON.stringify(record), 'utf8') + await rename(temporaryPath, path) + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined) + throw error + } + } + + private pathFor(creationId: string): string { + return join(this.options.directory, `${creationId}.json`) + } + + private requireRecord(creationId: string): CreationJournalRecord { + const record = this.records.get(creationId) + if (!record) throw new Error('Workspace creation journal was not found.') + return record + } + + private async advance( + record: CreationJournalRecord, + stage: EccWorkspaceCreationStage, + ): Promise { + const nextStage = monotonicCreationStage(record.stage, stage) + if (nextStage === record.stage) return record + const updated = { ...record, stage: nextStage, updatedAt: Date.now() } + await this.write(updated) + this.records.set(record.creationId, updated) + this.invalidate() + return updated + } + + private async finish(record: CreationJournalRecord, recovered: boolean): Promise { + const completed = await this.advance(record, 'completed') + await rm(this.pathFor(record.creationId), { force: true }) + this.records.delete(record.creationId) + if (recovered) this.recordRecovered(completed) + this.invalidate() + } + + private recordRecovered(record: CreationJournalRecord): void { + this.recovered.push({ + creationId: record.creationId, + issue: 'Workspace creation was recovered and registration is complete.', + ...(record.intent.projectId ? { projectId: record.intent.projectId } : {}), + projectRoot: record.intent.projectRoot, + stage: 'completed', + status: 'recovered', + targetDirectory: record.targetDirectory, + updatedAt: Date.now(), + }) + if (this.recovered.length > 16) this.recovered.splice(0, this.recovered.length - 16) + } + + private async updateUnfinished( + record: CreationJournalRecord, + issue: string, + ): Promise { + const updated = { + ...record, + issue: boundedIssue(issue), + status: 'unfinished' as const, + updatedAt: Date.now(), + } + await this.write(updated) + this.records.set(record.creationId, updated) + this.invalidate() + } + + private async serialize(creationId: string, action: () => Promise): Promise { + await this.initialize() + return await this.enqueue(creationId, action) + } + + private async enqueue(creationId: string, action: () => Promise): Promise { + const key = this.records.get(creationId)?.intent.projectRoot ?? creationId + const previous = this.actions.get(key) ?? Promise.resolve() + const next = previous.then(action, action) + this.actions.set(key, next) + void next.then( + () => { + if (this.actions.get(key) === next) this.actions.delete(key) + }, + () => { + if (this.actions.get(key) === next) this.actions.delete(key) + }, + ) + return await next + } + + private invalidate(): void { + this.projectionGeneration += 1 + for (const listener of this.listeners) listener(this.projectionGeneration) + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournalAuthorization.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournalAuthorization.ts new file mode 100644 index 000000000..9cf0f9178 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournalAuthorization.ts @@ -0,0 +1,81 @@ +import { isAbsolute } from 'node:path' +import { isPathWithinRoot } from './pathScope' +import { + normalizeCreationPath, + type CreationJournalRecord, +} from './workspaceCreationJournalRecord' + +export interface CreationPathAuthorizer { + canonicalizePaths( + projectRoot: string, + targetDirectory: string, + ): Promise<{ projectRoot: string; targetDirectory: string }> +} + +export async function authorizeCreationRecord( + authorizer: CreationPathAuthorizer, + record: CreationJournalRecord, +): Promise { + if ( + !isAbsolute(record.targetDirectory) || + !isAbsolute(record.intent.projectRoot) || + !isPathWithinRoot(record.targetDirectory, record.intent.projectRoot) + ) { + throw new Error('Workspace creation journal path is outside the Project root.') + } + const canonical = await authorizer.canonicalizePaths( + record.intent.projectRoot, + record.targetDirectory, + ) + if ( + normalizeCreationPath(canonical.projectRoot) !== + normalizeCreationPath(record.intent.projectRoot) || + normalizeCreationPath(canonical.targetDirectory) !== + normalizeCreationPath(record.targetDirectory) + ) { + throw new Error('Workspace creation journal paths are no longer canonical.') + } +} + +export function requireActiveCreationOwner( + record: CreationJournalRecord, + ownerWindowId: number, +): void { + if (record.status !== 'active' || record.ownerWindowId !== ownerWindowId) { + throw new Error('Workspace creation action is not owned by this window.') + } +} + +export function requireCreationRecovery(record: CreationJournalRecord): void { + if (record.status !== 'unfinished') { + throw new Error('Workspace creation is not awaiting recovery.') + } +} + +export function workspaceCreationIdentityMatches( + record: CreationJournalRecord, + identity: { workspaceId?: string; workspaceRevision?: number }, +): boolean { + return !( + (record.workspaceId && identity.workspaceId !== record.workspaceId) || + (record.workspaceRevision !== undefined && + identity.workspaceRevision !== record.workspaceRevision) + ) +} + +export async function creationTargetExists(path: string): Promise { + try { + await stat(path) + return true + } catch (error) { + if (nodeErrorCode(error) === 'ENOENT') return false + throw error + } +} + +export function nodeErrorCode(error: unknown): string | undefined { + return error && typeof error === 'object' && 'code' in error + ? String(error.code) + : undefined +} +import { stat } from 'node:fs/promises' diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournalRecord.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournalRecord.ts new file mode 100644 index 000000000..19a9e92a6 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationJournalRecord.ts @@ -0,0 +1,190 @@ +import { resolve } from 'node:path' +import type { + EccBackgroundWorkspaceCreation, + EccWorkspaceCreationStage, +} from '@ecos-studio/shared' + +export interface RegistrationOwnership { + beforeFingerprint: string | null + introduced?: boolean + registeredFingerprint?: string +} + +export interface CreationJournalRecord { + applicationRegistration: RegistrationOwnership + commandId: string + createdAt: number + creationId: string + intent: { + projectId?: string + projectRoot: string + workspaceBindings: Record + workspaceSpec: Record + } + issue?: string + manifestRegistration?: RegistrationOwnership & { + workspaceId: string + workspacePath: string + } + ownerWindowId: number + stage: EccWorkspaceCreationStage + status: 'active' | 'unfinished' + targetDirectory: string + targetExistedBefore: boolean + updatedAt: number + version: 1 + workspaceId?: string + workspaceRevision?: number +} + +export function projectCreationRecord( + record: CreationJournalRecord, +): EccBackgroundWorkspaceCreation { + return { + commandId: record.commandId, + creationId: record.creationId, + ...(record.issue ? { issue: record.issue } : {}), + ownerWindowId: record.ownerWindowId, + ...(record.intent.projectId ? { projectId: record.intent.projectId } : {}), + projectRoot: record.intent.projectRoot, + stage: record.stage, + status: record.status, + targetDirectory: record.targetDirectory, + targetExistedBefore: record.targetExistedBefore, + updatedAt: record.updatedAt, + } +} + +export function isCreationJournalRecord(value: unknown): value is CreationJournalRecord { + if (!isRecord(value)) return false + const intent = value.intent + return ( + value.version === 1 && + isNonEmptyString(value.commandId) && + isNonEmptyString(value.creationId) && + typeof value.ownerWindowId === 'number' && + Number.isInteger(value.ownerWindowId) && + value.ownerWindowId >= 0 && + isNonEmptyString(value.targetDirectory) && + typeof value.targetExistedBefore === 'boolean' && + isTimestamp(value.createdAt) && + isTimestamp(value.updatedAt) && + isRegistrationOwnership(value.applicationRegistration) && + (value.manifestRegistration === undefined || + (isRecord(value.manifestRegistration) && + isRegistrationOwnership(value.manifestRegistration) && + isNonEmptyString(value.manifestRegistration.workspaceId) && + isNonEmptyString(value.manifestRegistration.workspacePath))) && + (value.status === 'active' || value.status === 'unfinished') && + (value.issue === undefined || + (typeof value.issue === 'string' && value.issue.length <= 500)) && + [ + 'intent-recorded', + 'workspace-created', + 'manifest-registered', + 'application-registered', + 'completed', + ].includes(String(value.stage)) && + isRecord(intent) && + isNonEmptyString(intent.projectRoot) && + (intent.projectId === undefined || isNonEmptyString(intent.projectId)) && + isRecord(intent.workspaceBindings) && + isRecord(intent.workspaceSpec) && + (value.workspaceId === undefined || isNonEmptyString(value.workspaceId)) && + (value.workspaceRevision === undefined || + (typeof value.workspaceRevision === 'number' && + Number.isInteger(value.workspaceRevision) && + value.workspaceRevision >= 0)) && + hasValidStageEvidence(value) + ) +} + +export function sanitizedRecord(value: Record): Record { + return JSON.parse( + JSON.stringify(value, (key, item) => + /password|secret|token|credential/i.test(key) ? undefined : item, + ), + ) as Record +} + +export function boundedIssue(value: string): string { + return value.replace(/\s+/g, ' ').trim().slice(0, 500) +} + +export function fingerprint(value: Record): string { + return JSON.stringify(value) +} + +export function normalizeCreationPath(path: string): string { + const normalized = resolve(path).replace(/\\/g, '/') + return normalized.length > 1 ? normalized.replace(/\/+$/g, '') : normalized +} + +export function basenameCreationPath(path: string): string { + return normalizeCreationPath(path).split('/').filter(Boolean).pop() ?? '' +} + +export function stageRank(stage: EccWorkspaceCreationStage): number { + return [ + 'intent-recorded', + 'workspace-created', + 'manifest-registered', + 'application-registered', + 'completed', + ].indexOf(stage) +} + +export function monotonicCreationStage( + current: EccWorkspaceCreationStage, + next: EccWorkspaceCreationStage, +): EccWorkspaceCreationStage { + return stageRank(next) > stageRank(current) ? next : current +} + +function isRegistrationOwnership(value: unknown): value is RegistrationOwnership { + if (!isRecord(value)) return false + return ( + (value.beforeFingerprint === null || typeof value.beforeFingerprint === 'string') && + (value.introduced === undefined || typeof value.introduced === 'boolean') && + (value.registeredFingerprint === undefined || + typeof value.registeredFingerprint === 'string') + ) +} + +function hasValidStageEvidence(value: Record): boolean { + const rank = stageRank(value.stage as EccWorkspaceCreationStage) + if (rank < 0) return false + const manifest = value.manifestRegistration + if ( + rank >= stageRank('manifest-registered') && + manifest !== undefined && + (!isRecord(manifest) || + !isNonEmptyString(manifest.registeredFingerprint) || + typeof manifest.introduced !== 'boolean') + ) { + return false + } + if (rank >= stageRank('application-registered')) { + const application = value.applicationRegistration + if ( + !isRecord(application) || + !isNonEmptyString(application.registeredFingerprint) || + typeof application.introduced !== 'boolean' + ) { + return false + } + } + return true +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && Boolean(value.trim()) +} + +function isTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationModel.test.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationModel.test.ts new file mode 100644 index 000000000..25f1bee28 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationModel.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' + +import { buildWorkspaceCreationModel } from './workspaceCreationModel' + +describe('buildWorkspaceCreationModel', () => { + it('keeps defaulted and inapplicable parameters discoverable', () => { + const model = buildWorkspaceCreationModel( + { + flowDefinitions: [{ flowId: 'synth', stepIds: ['Synthesis'] }], + parameterCatalog: [ + { id: 'frequency_max', default: 100, appliesTo: 'synthesis' }, + { id: 'top_layer', default: 'MET5', appliesTo: 'routing' }, + { id: 'target_density', default: 0.2, appliesTo: 'placement' }, + ], + }, + [], + { flowId: 'synth' }, + ) + + expect(model.parameters).toEqual([ + expect.objectContaining({ + state: 'defaulted', + value: 100, + }), + expect.objectContaining({ + inapplicableReason: 'flow:synth', + state: 'inapplicable', + }), + expect.objectContaining({ + inapplicableReason: 'flow:synth', + state: 'inapplicable', + }), + ]) + expect(model.controls).toEqual({ + flowBoundaries: true, + manualPdkFiles: true, + mpc: true, + pdkVersion: true, + }) + }) + + it('combines project, input, PDK, MPC, and explicit parameter context', () => { + const mpc = { designId: 'cpu', resourceId: 'mpc:cpu', version: '1' } + const model = buildWorkspaceCreationModel( + { + parameterCatalog: [ + { default: 100, id: 'frequency_max', type: 'float' }, + { default: 0.6, id: 'core_utilization', type: 'float' }, + ], + }, + [], + { + explicitParameters: { core_utilization: 0.72 }, + flowId: 'harden', + inputMode: 'rtl', + mpc, + pdk: { familyId: 'ics55', mode: 'default', version: '1.10.100' }, + projectPresetParameters: { + frequency_max: 200, + core_utilization: 0.65, + }, + }, + ) + + expect(model.context).toEqual({ + flowId: 'harden', + inputMode: 'rtl', + mpc, + pdk: { familyId: 'ics55', mode: 'default', version: '1.10.100' }, + }) + expect(model.parameters).toMatchObject([ + { source: 'projectPreset', state: 'defaulted', value: 200 }, + { source: 'user', state: 'explicit', value: 0.72 }, + ]) + }) + + it('treats catalog applies names as the same flow steps as workspace identities', () => { + const model = buildWorkspaceCreationModel( + { + flowDefinitions: [ + { + flowId: 'rtl2gds', + stepIds: ['Synthesis', 'place', 'route'], + }, + ], + parameterCatalog: [ + { id: 'frequency_mhz', default: 100, appliesTo: 'synthesis' }, + { id: 'target_density', default: 0.2, appliesTo: 'placement' }, + { id: 'bottom_layer', default: 'MET2', appliesTo: 'routing' }, + { id: 'tech', default: '', appliesTo: 'pdk' }, + ], + }, + [], + { flowId: 'rtl2gds' }, + ) + + expect(model.parameters).toEqual([ + expect.objectContaining({ + definition: expect.objectContaining({ id: 'frequency_mhz' }), + state: 'defaulted', + }), + expect.objectContaining({ + definition: expect.objectContaining({ id: 'target_density' }), + state: 'defaulted', + }), + expect.objectContaining({ + definition: expect.objectContaining({ id: 'bottom_layer' }), + state: 'defaulted', + }), + expect.objectContaining({ + definition: expect.objectContaining({ id: 'tech' }), + inapplicableReason: 'flow:rtl2gds', + state: 'inapplicable', + }), + ]) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationModel.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationModel.ts new file mode 100644 index 000000000..f0a0a9854 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationModel.ts @@ -0,0 +1,96 @@ +import { + parseProjectManifestFlowStep, + type PdkInstallationSnapshot, + type WorkspaceCreationModel, + type WorkspaceCreationModelRequest, +} from '@ecos-studio/shared' + +export function buildWorkspaceCreationModel( + discovery: Record, + pdkInstallations: PdkInstallationSnapshot[], + request: WorkspaceCreationModelRequest = {}, +): WorkspaceCreationModel { + const catalog = Array.isArray(discovery.parameterCatalog) + ? discovery.parameterCatalog.filter(isRecord) + : [] + const applicableSteps = flowSteps(discovery, request.flowId) + return { + controls: { + flowBoundaries: true, + manualPdkFiles: true, + mpc: true, + pdkVersion: true, + }, + context: { + ...(request.flowId ? { flowId: request.flowId } : {}), + ...(request.inputMode ? { inputMode: request.inputMode } : {}), + mpc: request.mpc ?? null, + pdk: request.pdk ?? null, + }, + discovery, + parameters: catalog.flatMap((definition) => { + if (typeof definition.id !== 'string') return [] + const hasExplicit = Object.hasOwn(request.explicitParameters ?? {}, definition.id) + const hasProjectPreset = Object.hasOwn( + request.projectPresetParameters ?? {}, + definition.id, + ) + const explicit = request.explicitParameters?.[definition.id] + const preset = request.projectPresetParameters?.[definition.id] + const appliesTo = + typeof definition.appliesTo === 'string' + ? normalizeStep(definition.appliesTo) + : '' + const applicable = !applicableSteps || !appliesTo || applicableSteps.has(appliesTo) + return [ + { + definition: definition as Record & { id: string }, + state: applicable + ? hasExplicit + ? ('explicit' as const) + : ('defaulted' as const) + : ('inapplicable' as const), + ...(applicable + ? { + source: hasExplicit + ? ('user' as const) + : hasProjectPreset + ? ('projectPreset' as const) + : ('catalogDefault' as const), + value: hasExplicit + ? explicit + : hasProjectPreset + ? preset + : definition.default, + } + : { inapplicableReason: `flow:${request.flowId}` }), + }, + ] + }), + pdkInstallations, + } +} + +function flowSteps( + discovery: Record, + flowId: string | undefined, +): Set | null { + if (!flowId || !Array.isArray(discovery.flowDefinitions)) return null + const flow = discovery.flowDefinitions + .filter(isRecord) + .find((candidate) => candidate.flowId === flowId) + if (!flow || !Array.isArray(flow.stepIds)) return new Set() + return new Set( + flow.stepIds + .filter((step): step is string => typeof step === 'string') + .map(normalizeStep), + ) +} + +function normalizeStep(step: string): string { + return parseProjectManifestFlowStep(step) ?? step.trim().toLowerCase() +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationRegistrations.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationRegistrations.ts new file mode 100644 index 000000000..229fd60da --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceCreationRegistrations.ts @@ -0,0 +1,253 @@ +import type { DesktopSettingsValue } from '@ecos-studio/shared' +import type { ProjectManifestService } from './projectManifestService' +import { + basenameCreationPath, + fingerprint, + normalizeCreationPath, + type CreationJournalRecord, +} from './workspaceCreationJournalRecord' + +export interface WorkspaceCreationRegistrationOptions { + isWorkspace(path: string): Promise + projectManifestService: Pick< + ProjectManifestService, + | 'ensureWorkspaceRegistration' + | 'inspectWorkspaceRegistration' + | 'removeWorkspaceRegistration' + > + settingsStore: { + get( + key: string, + ): Promise + set(key: string, value: DesktopSettingsValue): Promise + } +} + +export async function captureRegistrationOwnership( + options: WorkspaceCreationRegistrationOptions, + projectRoot: string, + targetDirectory: string, + projectId?: string, +): Promise< + Pick +> { + const managed = + normalizeCreationPath(projectRoot) !== normalizeCreationPath(targetDirectory) + const manifestBefore = managed + ? await options.projectManifestService.inspectWorkspaceRegistration( + projectRoot, + targetDirectory, + projectId, + ) + : null + const recentBefore = await recentWorkspaceEntry(options, targetDirectory) + return { + applicationRegistration: { + beforeFingerprint: recentBefore ? fingerprint(recentBefore) : null, + }, + ...(managed + ? { + manifestRegistration: { + beforeFingerprint: manifestBefore?.fingerprint ?? null, + workspaceId: basenameCreationPath(targetDirectory), + workspacePath: normalizeCreationPath(targetDirectory), + }, + } + : {}), + } +} + +export async function confirmManifestRegistration( + options: WorkspaceCreationRegistrationOptions, + record: CreationJournalRecord, + ensure: boolean, +): Promise { + const ownership = record.manifestRegistration + if (!ownership) return record + const evidence = ensure + ? await options.projectManifestService.ensureWorkspaceRegistration( + record.intent.projectRoot, + record.targetDirectory, + record.intent.projectId, + ) + : await options.projectManifestService.inspectWorkspaceRegistration( + record.intent.projectRoot, + record.targetDirectory, + record.intent.projectId, + ) + if (!evidence) { + throw new Error('Project manifest does not contain the created Workspace.') + } + return { + ...record, + manifestRegistration: { + ...ownership, + introduced: ownership.beforeFingerprint === null, + registeredFingerprint: evidence.fingerprint, + }, + } +} + +export async function confirmApplicationRegistration( + options: WorkspaceCreationRegistrationOptions, + record: CreationJournalRecord, + ensure: boolean, +): Promise { + const entry = ensure + ? await ensureRecentWorkspaceEntry(options, record.targetDirectory) + : await recentWorkspaceEntry(options, record.targetDirectory) + if (!entry) throw new Error('Workspace was not added to recent Workspaces.') + return { + ...record, + applicationRegistration: { + ...record.applicationRegistration, + introduced: record.applicationRegistration.beforeFingerprint === null, + registeredFingerprint: fingerprint(entry), + }, + } +} + +export async function removeOwnedRegistrations( + options: WorkspaceCreationRegistrationOptions, + record: CreationJournalRecord, +): Promise { + const manifest = record.manifestRegistration + const currentManifest = + manifest?.beforeFingerprint === null + ? await options.projectManifestService.inspectWorkspaceRegistration( + record.intent.projectRoot, + record.targetDirectory, + record.intent.projectId, + ) + : null + if ( + currentManifest && + (manifest?.introduced !== true || + !manifest.registeredFingerprint || + currentManifest.fingerprint !== manifest.registeredFingerprint) + ) { + throw new Error( + 'Project manifest registration ownership cannot be confirmed; registration was preserved.', + ) + } + + const recentEntries = await recentWorkspaceEntries(options) + const currentApplication = + record.applicationRegistration.beforeFingerprint === null + ? recentEntryForTarget(recentEntries, record.targetDirectory) + : null + const expectedApplication = record.applicationRegistration.registeredFingerprint + if ( + currentApplication && + (record.applicationRegistration.introduced !== true || + !expectedApplication || + fingerprint(currentApplication) !== expectedApplication) + ) { + throw new Error( + 'Recent Workspace registration ownership cannot be confirmed; registration was preserved.', + ) + } + + if (currentApplication) { + await options.settingsStore.set( + 'recent_projects', + recentEntries.filter( + (entry) => + normalizeCreationPath(String(entry.path ?? '')) !== + normalizeCreationPath(record.targetDirectory), + ) as DesktopSettingsValue, + ) + } + try { + if (currentManifest) { + await options.projectManifestService.removeWorkspaceRegistration( + record.intent.projectRoot, + manifest?.registeredFingerprint + ? { ...currentManifest, fingerprint: manifest.registeredFingerprint } + : currentManifest, + ) + } + } catch (error) { + if (currentApplication) { + await options.settingsStore + .set('recent_projects', recentEntries as DesktopSettingsValue) + .catch(() => undefined) + } + throw error + } +} + +export async function isUnambiguouslyComplete( + options: WorkspaceCreationRegistrationOptions, + record: CreationJournalRecord, +): Promise { + if (!(await options.isWorkspace(record.targetDirectory))) return false + if (record.manifestRegistration) { + const manifest = await options.projectManifestService.inspectWorkspaceRegistration( + record.intent.projectRoot, + record.targetDirectory, + record.intent.projectId, + ) + if (!manifest) return false + const expected = record.manifestRegistration.registeredFingerprint + if (expected && manifest.fingerprint !== expected) return false + if (!expected && record.manifestRegistration.beforeFingerprint !== null) return false + } + const application = await recentWorkspaceEntry(options, record.targetDirectory) + if (!application) return false + const expected = record.applicationRegistration.registeredFingerprint + if (expected && fingerprint(application) !== expected) return false + return ( + expected !== undefined || record.applicationRegistration.beforeFingerprint === null + ) +} + +async function ensureRecentWorkspaceEntry( + options: WorkspaceCreationRegistrationOptions, + targetDirectory: string, +): Promise> { + const existing = await recentWorkspaceEntry(options, targetDirectory) + if (existing) return existing + const entry = { + designTool: 'backend', + id: normalizeCreationPath(targetDirectory), + lastOpened: new Date().toISOString(), + name: basenameCreationPath(targetDirectory), + path: normalizeCreationPath(targetDirectory), + } + await options.settingsStore.set('recent_projects', [ + entry, + ...(await recentWorkspaceEntries(options)), + ] as unknown as DesktopSettingsValue) + return entry +} + +async function recentWorkspaceEntry( + options: WorkspaceCreationRegistrationOptions, + targetDirectory: string, +): Promise | null> { + return recentEntryForTarget(await recentWorkspaceEntries(options), targetDirectory) +} + +async function recentWorkspaceEntries( + options: WorkspaceCreationRegistrationOptions, +): Promise[]> { + const value: unknown = await options.settingsStore.get('recent_projects') + return Array.isArray(value) ? value.filter(isRecord) : [] +} + +function recentEntryForTarget( + entries: Record[], + targetDirectory: string, +): Record | null { + const target = normalizeCreationPath(targetDirectory) + const matches = entries.filter( + (entry) => normalizeCreationPath(String(entry.path ?? '')) === target, + ) + if (matches.length > 1) throw new Error('Recent Workspace registration is ambiguous.') + return matches[0] ?? null +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceDashboardAnalysis.test.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceDashboardAnalysis.test.ts new file mode 100644 index 000000000..74e36787d --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceDashboardAnalysis.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { workspaceDashboardMetrics } from './workspaceDashboardAnalysis' + +describe('workspaceDashboardMetrics', () => { + it('projects key metrics exclusively from normalized snapshot metrics', () => { + const metrics = workspaceDashboardMetrics([ + { + id: 'core_area', + name: 'Core Area', + polarity: 'trend_only', + stepId: 'Place', + unit: 'um2', + value: 1700, + }, + { + id: 'die_area', + name: 'Die Area', + polarity: 'trend_only', + stepId: 'Place', + unit: 'um2', + value: 2798.4, + }, + { + id: 'std_cell_count', + name: 'Standard Cell Count', + polarity: 'trend_only', + stepId: 'Place', + unit: 'count', + value: 286, + }, + { + id: 'synthesis_port_count', + name: 'Synthesis Port Count', + polarity: 'trend_only', + stepId: 'Synthesis', + unit: 'count', + value: 54, + }, + { + id: 'synthesis_cell_count', + name: 'Synthesis Cell Count', + polarity: 'trend_only', + stepId: 'Synthesis', + unit: 'count', + value: 314, + }, + { + id: 'synthesis_wire_count', + name: 'Synthesis Wire Count', + polarity: 'trend_only', + stepId: 'Synthesis', + unit: 'count', + value: 350, + }, + ]) + + expect(metrics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'die-area', value: 2798.4 }), + expect.objectContaining({ id: 'core-area', value: 1700 }), + expect.objectContaining({ id: 'io-pins', value: 54 }), + expect.objectContaining({ id: 'instances', value: 314 }), + expect.objectContaining({ id: 'nets', value: 350 }), + expect.objectContaining({ id: 'std-cell-number', value: 286 }), + ]), + ) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceDashboardAnalysis.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceDashboardAnalysis.ts new file mode 100644 index 000000000..425778987 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceDashboardAnalysis.ts @@ -0,0 +1,95 @@ +import { + parseProjectManifestFlowStep, + type MetricValue, + type WorkspaceDashboardMetric, +} from '@ecos-studio/shared' + +const METRICS: ReadonlyArray<{ + id: string + label: string + sources: readonly string[] + unit: string +}> = [ + { id: 'die-area', label: 'Die Area', sources: ['die_area'], unit: 'um2' }, + { id: 'core-area', label: 'Core Area', sources: ['core_area'], unit: 'um2' }, + { + id: 'core-utilization', + label: 'Core Utility', + sources: ['core_utilization'], + unit: '%', + }, + { + id: 'io-pins', + label: 'IO Pin', + sources: ['pin_count', 'io_pin_count', 'total_pins', 'synthesis_port_count'], + unit: '', + }, + { + id: 'instances', + label: 'Instance Number', + sources: ['instance_count', 'total_instances', 'synthesis_cell_count'], + unit: '', + }, + { id: 'macro-number', label: 'Macro Number', sources: ['macro_count'], unit: '' }, + { id: 'macro-area', label: 'Macro Area', sources: ['macro_area'], unit: 'um2' }, + { + id: 'std-cell-number', + label: 'Std Cell Number', + sources: ['std_cell_count'], + unit: '', + }, + { + id: 'std-cell-area', + label: 'Std Cell Area', + sources: ['std_cell_area'], + unit: 'um2', + }, + { id: 'io-pad-number', label: 'IO Pad Number', sources: ['io_pad_count'], unit: '' }, + { + id: 'nets', + label: 'Net number', + sources: ['net_count', 'total_nets', 'synthesis_wire_count'], + unit: '', + }, + { + id: 'frequency', + label: 'Frequency', + sources: ['sta_frequency_mhz', 'frequency_mhz'], + unit: 'MHz', + }, + { id: 'setup-wns', label: 'Setup WNS', sources: ['sta_setup_wns'], unit: 'ns' }, + { id: 'setup-tns', label: 'Setup TNS', sources: ['sta_setup_tns'], unit: 'ns' }, + { id: 'hold-wns', label: 'Hold WNS', sources: ['sta_hold_wns'], unit: 'ns' }, + { id: 'hold-tns', label: 'Hold TNS', sources: ['sta_hold_tns'], unit: 'ns' }, +] + +export function workspaceDashboardMetrics( + qorMetrics: readonly MetricValue[], + currentStepIds: readonly string[] = [], +): WorkspaceDashboardMetric[] { + const values = new Map( + qorMetrics.flatMap((metric) => + metric.value === null ? [] : ([[metric.id, metric.value]] as const), + ), + ) + const currentSteps = new Set(currentStepIds.map(canonicalStepIdentity)) + for (const metric of qorMetrics) { + if (metric.value !== null && currentSteps.has(canonicalStepIdentity(metric.stepId))) { + values.set(metric.id, metric.value) + } + } + return METRICS.map((metric) => ({ + id: metric.id, + label: metric.label, + value: + metric.sources.flatMap((source) => { + const value = values.get(source) + return value === undefined ? [] : [value] + })[0] ?? null, + unit: metric.unit, + })) +} + +function canonicalStepIdentity(stepId: string): string { + return (parseProjectManifestFlowStep(stepId) ?? stepId).trim().toLowerCase() +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceParametersFile.test.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceParametersFile.test.ts deleted file mode 100644 index 3e162f5d5..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceParametersFile.test.ts +++ /dev/null @@ -1,1364 +0,0 @@ -import { - chmodSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - symlinkSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' - -import { - applyQueuedWorkspaceParameterWrites, - hasWorkspaceConfigShadow, - editWorkspaceParameters, - locateWorkspaceParametersFile, - mergePayloadIntoTomlDocument, - mergeTomlSections, - readWorkspaceParameters, - restoreTextIfCurrentRevision, - writeWorkspaceParameters, -} from './workspaceParametersFile' - -const temporaryDirectories: string[] = [] - -function createWorkspace(): string { - const directory = mkdtempSync(join(tmpdir(), 'ecos-workspace-parameters-')) - temporaryDirectories.push(directory) - mkdirSync(join(directory, 'home'), { recursive: true }) - return directory -} - -function writeHomeFile(root: string, name: string, content: string): void { - writeFileSync(join(root, 'home', name), content) -} - -const ECC_TOML = ` -[design] -name = "gcd" -top = "gcd" -clock_port = "clk" -frequency_mhz = 100.0 - -[pdk] -name = "ics55" -root = "/pdk/ics55" - -[flow] -preset = "rtl2gds" - -[params] -pdk = "ics55" -design = "gcd" -top_module = "gcd" -clock = "clk" -frequency_max = 100.0 -max_fanout = 20 -target_density = 0.2 -pdk_root = "/pdk/ics55" -pdk_config = "home/pdk.json" - -[params.core] -utilitization = 0.2 -margin = [ 2, 2 ] -` - -const JSON_PARAMETERS = JSON.stringify( - { - PDK: 'ICS55', - Design: 'gcd', - 'Top module': 'gcd', - 'Frequency max [MHz]': 100, - }, - null, - 4, -) - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { force: true, recursive: true }) - } -}) - -describe('locateWorkspaceParametersFile', () => { - it('prefers home/params.toml over home/parameters.json', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', JSON_PARAMETERS) - writeHomeFile(root, 'params.toml', ECC_TOML) - const location = await locateWorkspaceParametersFile(root) - expect(location?.format).toBe('toml') - expect(location?.path).toBe(join(root, 'home', 'params.toml')) - }) - - it('falls back to home/parameters.json', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', JSON_PARAMETERS) - const location = await locateWorkspaceParametersFile(root) - expect(location?.format).toBe('json') - expect(location?.path).toBe(join(root, 'home', 'parameters.json')) - }) - - it('returns null when neither file exists', async () => { - const root = createWorkspace() - expect(await locateWorkspaceParametersFile(root)).toBeNull() - }) - - it('detects the TOML/JSON shadow pair', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', JSON_PARAMETERS) - expect(await hasWorkspaceConfigShadow(root)).toBe(false) - writeHomeFile(root, 'params.toml', ECC_TOML) - expect(await hasWorkspaceConfigShadow(root)).toBe(true) - }) - - it('treats a dangling legacy symlink as a shadow (lexists semantics)', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - symlinkSync(join(root, 'home', 'gone.json'), join(root, 'home', 'parameters.json')) - expect(await hasWorkspaceConfigShadow(root)).toBe(true) - }) - - it('refuses a broken params.toml symlink instead of falling back to parameters.json', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', JSON_PARAMETERS) - symlinkSync(join(root, 'home', 'missing.toml'), join(root, 'home', 'params.toml')) - await expect(locateWorkspaceParametersFile(root)).rejects.toThrow( - /symlink|params.toml/i, - ) - await expect(readWorkspaceParameters(root)).rejects.toThrow(/symlink|params.toml/i) - }) -}) - -describe('mergeTomlSections', () => { - it('flattens [params] with [design]/[pdk] mirrors overriding mapped keys', () => { - const document = { - design: { name: 'gcd', top: 'gcd', clock_port: 'clk', frequency_mhz: 100.0 }, - pdk: { name: 'ics55', root: '/pdk/ics55' }, - params: { design: 'stale', frequency_max: 50, max_fanout: 20 }, - } - expect(mergeTomlSections(document, '/ws')).toEqual({ - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - frequency_max: 100.0, - pdk: 'ics55', - pdk_root: '/pdk/ics55', - max_fanout: 20, - }) - }) - - it('keeps [params] values when section mirrors are empty', () => { - const document = { - design: { name: '' }, - params: { design: 'gcd', frequency_max: 100 }, - } - const merged = mergeTomlSections(document, '/ws') - expect(merged.design).toBe('gcd') - expect(merged.frequency_max).toBe(100) - }) - - it('resolves workspace-relative pdk_config against the workspace root', () => { - const document = { params: { pdk_config: 'home/pdk.json' } } - const merged = mergeTomlSections(document, '/ws') - expect(merged.pdk_config).toBe(join('/ws', 'home/pdk.json')) - }) - - it('keeps absolute pdk_config unchanged', () => { - const document = { params: { pdk_config: '/elsewhere/pdk.json' } } - const merged = mergeTomlSections(document, '/ws') - expect(merged.pdk_config).toBe('/elsewhere/pdk.json') - }) - - it('ignores the [flow] section', () => { - const document = { flow: { preset: 'rtl2gds' }, params: { design: 'gcd' } } - const merged = mergeTomlSections(document, '/ws') - expect(merged).toEqual({ design: 'gcd' }) - }) -}) - -describe('readWorkspaceParameters', () => { - it('reads and flattens home/params.toml', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - const parameters = await readWorkspaceParameters(root) - expect(parameters).toMatchObject({ - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - frequency_max: 100.0, - max_fanout: 20, - target_density: 0.2, - pdk: 'ics55', - pdk_root: '/pdk/ics55', - core: { utilitization: 0.2, margin: [2, 2] }, - }) - expect(parameters?.pdk_config).toBe(join(root, 'home/pdk.json')) - }) - - it('reads home/parameters.json unchanged', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', JSON_PARAMETERS) - const parameters = await readWorkspaceParameters(root) - expect(parameters).toEqual({ - PDK: 'ICS55', - Design: 'gcd', - 'Top module': 'gcd', - 'Frequency max [MHz]': 100, - }) - }) - - it('returns null when neither file exists', async () => { - const root = createWorkspace() - expect(await readWorkspaceParameters(root)).toBeNull() - }) - - it('throws on malformed TOML instead of falling back', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', '[design\nname = ') - await expect(readWorkspaceParameters(root)).rejects.toThrow(/toml/i) - }) - - it('throws on malformed JSON instead of falling back', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', '{not json') - await expect(readWorkspaceParameters(root)).rejects.toThrow(/json/i) - }) - - it('rejects a GUI-known scalar that a section mirror presents as an array', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'params.toml', - ` -[design] -name = "gcd" -frequency_mhz = [100] - -[params] -pdk = "ics55" -design = "gcd" -`, - ) - await expect(readWorkspaceParameters(root)).rejects.toThrow(/not a scalar/) - }) - - it('rejects an array in a GUI-known nested scalar such as die_area.width', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'params.toml', - ` -[params] -design = "gcd" -pdk = "ics55" - -[params.die_area] -width = [120] -height = 80 -`, - ) - await expect(readWorkspaceParameters(root)).rejects.toThrow(/not a scalar/) - }) - - it('rejects a JSON GUI-known scalar that is already a nested table', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'parameters.json', - JSON.stringify({ Design: { extra: 'keep' }, PDK: 'ics55' }), - ) - await expect(readWorkspaceParameters(root)).rejects.toThrow(/not a scalar/) - }) - - it('keeps a JSON workspace whose canonical duplicate is an inert table', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'parameters.json', - JSON.stringify({ - PDK: 'ics55', - Design: 'gcd', - 'Max fanout': 20, - max_fanout: { future: true }, - }), - ) - const parameters = await readWorkspaceParameters(root) - expect(parameters?.['Max fanout']).toBe(20) - expect(parameters?.max_fanout).toEqual({ future: true }) - }) -}) - -describe('mergePayloadIntoTomlDocument', () => { - it('merges display-key payload into [params] and re-syncs mirrors', () => { - const document = { - design: { name: 'gcd', top: 'gcd', clock_port: 'clk', frequency_mhz: 100.0 }, - pdk: { name: 'ics55', root: '/pdk/ics55' }, - flow: { preset: 'rtl2gds' }, - params: { - design: 'gcd', - top_module: 'gcd', - clock: 'clk', - frequency_max: 100.0, - max_fanout: 20, - sta_max_paths: 1000, - }, - } - const merged = mergePayloadIntoTomlDocument( - document, - { 'Frequency max [MHz]': 200, 'Max fanout': 32 }, - '/ws', - ) - expect(merged.params).toMatchObject({ frequency_max: 200, max_fanout: 32 }) - expect(merged.params.sta_max_paths).toBe(1000) - expect(merged.design).toMatchObject({ frequency_mhz: 200 }) - expect(merged.flow).toEqual({ preset: 'rtl2gds' }) - }) - - it('merges die/core subtrees leaf-wise and keeps [flow] untouched', () => { - const document = { - params: { - core: { utilitization: 0.2, margin: [2, 2], future_knob: 'keep' }, - design: 'gcd', - }, - flow: { preset: 'syn_sta' }, - } - const merged = mergePayloadIntoTomlDocument( - document, - { Core: { Utilitization: 0.45, Margin: [3, 3] } }, - '/ws', - ) - // Known members update and unknown nested members survive the save; - // arrays replace wholesale. - expect(merged.params.core).toEqual({ - utilitization: 0.45, - margin: [3, 3], - future_knob: 'keep', - }) - expect(merged.flow).toEqual({ preset: 'syn_sta' }) - }) - - it('stores pdk_config relative when it points inside the workspace', () => { - const document = { params: { design: 'gcd' } } - const merged = mergePayloadIntoTomlDocument( - document, - { pdk_config: '/ws/home/pdk.json' }, - '/ws', - ) - expect(merged.params.pdk_config).toBe('home/pdk.json') - }) - - it('folds Configure Die/Core geometry into an existing die_area table', () => { - const document = { - params: { - design: 'gcd', - die_area: { - width: 100, - height: 80, - utilitization: 0.4, - margin: 2, - extra: 'keep', - }, - }, - } - const merged = mergePayloadIntoTomlDocument( - document, - { - Die: { Size: [120, 90], Area: 10800 }, - Core: { Utilitization: 0.55, Margin: [4, 4] }, - }, - '/ws', - ) - expect(merged.params.die_area).toEqual({ - width: 120, - height: 90, - utilitization: 0.55, - margin: 4, - extra: 'keep', - }) - expect(merged.params.die).toEqual({ area: 10800 }) - expect(merged.params.core).toBeUndefined() - }) - - it('keeps geometry fields that die_area cannot represent', () => { - const document = { - params: { - design: 'gcd', - die_area: { width: 100, height: 80, utilitization: 0.4, margin: 2 }, - die: { size: [100, 80], area: 8000 }, - core: { size: [80, 60], area: 4800, utilitization: 0.4, margin: [2, 2] }, - }, - } - const merged = mergePayloadIntoTomlDocument( - document, - { - Die: { Size: [120, 90], Area: 10800 }, - Core: { Size: [96, 72], Area: 6912, Utilitization: 0.55, Margin: [4, 4] }, - }, - '/ws', - ) - expect(merged.params.die_area).toEqual({ - width: 120, - height: 90, - utilitization: 0.55, - margin: 4, - }) - expect(merged.params.die).toEqual({ area: 10800 }) - expect(merged.params.core).toEqual({ size: [96, 72], area: 6912 }) - }) - - it('keeps an asymmetric core.margin that die_area cannot represent', () => { - const document = { - params: { - design: 'gcd', - die_area: { width: 100, height: 80, utilitization: 0.4, margin: 2 }, - core: { utilitization: 0.4, margin: [2, 2] }, - }, - } - const merged = mergePayloadIntoTomlDocument( - document, - { Core: { Utilitization: 0.55, Margin: [5, 7] } }, - '/ws', - ) - expect(merged.params.die_area).toMatchObject({ utilitization: 0.55, margin: 2 }) - expect(merged.params.core).toEqual({ margin: [5, 7] }) - }) - - it('keeps unknown nested die/core leaves when folding geometry into die_area', () => { - const document = { - params: { - design: 'gcd', - die_area: { width: 100, height: 80, utilitization: 0.4, margin: 2 }, - core: { utilitization: 0.4, margin: [2, 2], future_knob: 'keep' }, - }, - } - const merged = mergePayloadIntoTomlDocument( - document, - { Core: { Utilitization: 0.55, Margin: [4, 4] } }, - '/ws', - ) - expect(merged.params.die_area).toMatchObject({ utilitization: 0.55, margin: 4 }) - expect(merged.params.core).toEqual({ future_knob: 'keep' }) - }) - - it('keeps outside pdk_config absolute', () => { - const document = { params: { design: 'gcd' } } - const merged = mergePayloadIntoTomlDocument( - document, - { pdk_config: '/elsewhere/pdk.json' }, - '/ws', - ) - expect(merged.params.pdk_config).toBe('/elsewhere/pdk.json') - }) -}) - -describe('writeWorkspaceParameters', () => { - it('round-trips a TOML write: edit survives, other sections preserved', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - const location = await writeWorkspaceParameters(root, { - 'Frequency max [MHz]': 250, - 'Max fanout': 24, - }) - expect(location.format).toBe('toml') - - const parameters = await readWorkspaceParameters(root) - expect(parameters?.frequency_max).toBe(250) - expect(parameters?.max_fanout).toBe(24) - expect(parameters?.design).toBe('gcd') - expect(parameters?.sta_max_paths).toBeUndefined() - - const text = readFileSync(join(root, 'home', 'params.toml'), 'utf8') - expect(text).toContain('[flow]') - expect(text).toContain('preset = "rtl2gds"') - expect(text).toContain('frequency_mhz = 250.0') - expect(text).not.toContain('parameters.json') - }) - - it('preserves integral float tokens and integer tokens through a rewrite', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'params.toml', - ECC_TOML.replace( - 'preset = "rtl2gds"', - 'preset = "rtl2gds"\nthreshold = 1.0\ncount = 2', - ), - ) - await writeWorkspaceParameters(root, { design: 'gcd' }) - const text = readFileSync(join(root, 'home', 'params.toml'), 'utf8') - expect(text).toMatch(/threshold\s*=\s*1\.0\b/) - expect(text).toMatch(/count\s*=\s*2\b/) - expect(text).not.toMatch(/count\s*=\s*2\.0\b/) - expect(text).toContain('frequency_mhz = 100.0') - expect(text).toContain('max_fanout = 20') - }) - - it('writes home/parameters.json merging the payload into the existing document', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', JSON_PARAMETERS) - const location = await writeWorkspaceParameters(root, { - PDK: 'ICS55', - Design: 'gcd', - 'Max fanout': 48, - }) - expect(location.format).toBe('json') - const written = JSON.parse( - readFileSync(join(root, 'home', 'parameters.json'), 'utf8'), - ) - // The payload overrides its own keys; keys the GUI does not display - // (frontend extras, unrelated agent edits) survive the save. - expect(written).toEqual({ - PDK: 'ICS55', - Design: 'gcd', - 'Top module': 'gcd', - 'Frequency max [MHz]': 100, - 'Max fanout': 48, - }) - }) - - it.each([ - ['0.123456789012345678901234', 'extra decimal digits'], - ['0.12345678901234567', '17-digit decimal'], - ['123456789012345678901e-20', 'integer-mantissa exponent'], - ['0.123_456_789_012_345_678', 'underscore-decorated TOML float'], - ])( - 'rejects a high-precision float %s (%s) that a rewrite would silently round', - async (literal) => { - const root = createWorkspace() - const content = `${ECC_TOML}\n[flow]\nthreshold = ${literal}\n` - writeHomeFile(root, 'params.toml', content) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /cannot round-trip/, - ) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(content) - }, - ) - - it('rejects a high-precision float in a multiline array that a rewrite would silently round', async () => { - const root = createWorkspace() - const content = ECC_TOML.replace( - 'preset = "rtl2gds"', - 'preset = "rtl2gds"\nweights = [\n 0.12345678901234567,\n]', - ) - writeHomeFile(root, 'params.toml', content) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /cannot round-trip/, - ) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(content) - }) - - it('does not treat identifier-embedded digits as numeric values', async () => { - const root = createWorkspace() - const content = `${ECC_TOML}\ncorner1e20 = "slow"\n` - writeHomeFile(root, 'params.toml', content) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).resolves.toBeTruthy() - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toContain( - 'corner1e20', - ) - }) - - it('does not treat dashed or dotted key segments as numeric values', async () => { - const root = createWorkspace() - const content = `foo-1e20 = "keep"\n${ECC_TOML}\n[params.extra]\nfoo.1e20 = "keep"\n` - writeHomeFile(root, 'params.toml', content) - await expect(readWorkspaceParameters(root)).resolves.toMatchObject({ design: 'gcd' }) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).resolves.toBeTruthy() - const written = readFileSync(join(root, 'home', 'params.toml'), 'utf8') - expect(written).toContain('keep') - const parameters = await readWorkspaceParameters(root) - expect(parameters).toMatchObject({ design: 'gcd' }) - }) - - it('rejects a JSON float that cannot round-trip through Number', async () => { - const root = createWorkspace() - const content = '{ "Design": "gcd", "threshold": 0.12345678901234567 }\n' - writeHomeFile(root, 'parameters.json', content) - await expect( - writeWorkspaceParameters(root, { Design: 'gcd', 'Max fanout': 48 }), - ).rejects.toThrow(/cannot round-trip/) - expect(readFileSync(join(root, 'home', 'parameters.json'), 'utf8')).toBe(content) - }) - - it('rejects a home/parameters.json holding an unsafe integer instead of rounding it', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'parameters.json', - '{ "Design": "gcd", "Area": 17912481922736482372 }\n', - ) - await expect( - writeWorkspaceParameters(root, { Design: 'gcd', 'Max fanout': 48 }), - ).rejects.toThrow(/MAX_SAFE_INTEGER/) - // The file is left untouched. - expect(readFileSync(join(root, 'home', 'parameters.json'), 'utf8')).toContain( - '17912481922736482372', - ) - }) - - it('preserves unknown nested keys in home/parameters.json saves', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'parameters.json', - '{ "Design": "gcd", "Core": { "Utilitization": 0.2, "Extra": "keep" } }\n', - ) - await writeWorkspaceParameters(root, { Core: { Utilitization: 0.45 } }) - const written = JSON.parse( - readFileSync(join(root, 'home', 'parameters.json'), 'utf8'), - ) as Record> - expect(written.Core).toEqual({ Utilitization: 0.45, Extra: 'keep' }) - }) - - it('rejects a non-object JSON root on save instead of overwriting it', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', '[1, 2, 3]\n') - await expect(writeWorkspaceParameters(root, { Design: 'gcd' })).rejects.toThrow( - /JSON object/i, - ) - expect(readFileSync(join(root, 'home', 'parameters.json'), 'utf8')).toBe( - '[1, 2, 3]\n', - ) - }) - - it('rejects a GUI-known scalar that is already a nested table in [params]', async () => { - const root = createWorkspace() - const content = ` -[design] -name = "gcd" - -[params] -pdk = "ics55" -top_module = "gcd" - -[params.design] -future = "keep" -` - writeHomeFile(root, 'params.toml', content) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /not a scalar/, - ) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(content) - }) - - it('rejects a JSON GUI-known scalar that became a table before the queued save', async () => { - const root = createWorkspace() - const content = JSON.stringify({ - PDK: 'ics55', - Design: 'gcd', - 'Max fanout': { future: true }, - }) - writeHomeFile(root, 'parameters.json', content) - await expect(writeWorkspaceParameters(root, { 'Max fanout': 48 })).rejects.toThrow( - /not a scalar/, - ) - expect(readFileSync(join(root, 'home', 'parameters.json'), 'utf8')).toBe(content) - }) - - it('rejects a [pdk] scalar that would overwrite a nested [params.pdk_config] table', async () => { - const root = createWorkspace() - const content = ` -[pdk] -config = "home/pdk.json" - -[params] -pdk = "ics55" -design = "gcd" - -[params.pdk_config] -future = "keep" -` - writeHomeFile(root, 'params.toml', content) - await expect(readWorkspaceParameters(root)).rejects.toThrow(/not a scalar/) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /not a scalar/, - ) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(content) - }) - - it('rejects a non-table TOML section on save instead of replacing it', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', 'params = [1]\n') - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /must be a table/i, - ) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe('params = [1]\n') - }) - - it('rejects a TOML date scalar as a section instead of flattening it away', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', 'params = 2026-08-27\n') - await expect(readWorkspaceParameters(root)).rejects.toThrow(/must be a table/i) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /must be a table/i, - ) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe( - 'params = 2026-08-27\n', - ) - }) - - it('rejects invalid calendar dates instead of normalizing them on save', async () => { - const root = createWorkspace() - const content = ECC_TOML.replace( - 'preset = "rtl2gds"', - 'preset = "rtl2gds"\ncheckpoint = 2023-02-30', - ) - writeHomeFile(root, 'params.toml', content) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /invalid calendar date/i, - ) - await expect( - editWorkspaceParameters(root, [{ json_path: ['design'], value: 'aes' }]), - ).rejects.toThrow(/invalid calendar date/i) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(content) - }) - - it('rejects sub-millisecond datetimes instead of truncating them on save', async () => { - const root = createWorkspace() - const content = `${ECC_TOML}\n[params.flow_meta]\ncheckpoint = 07:32:00.999999\n` - writeHomeFile(root, 'params.toml', content) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /millisecond precision/i, - ) - await expect( - editWorkspaceParameters(root, [{ json_path: ['design'], value: 'aes' }]), - ).rejects.toThrow(/millisecond precision/i) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(content) - }) - - it('accepts millisecond-precision datetimes and time-looking comments', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'params.toml', - `${ECC_TOML}\n# checkpoint was 07:32:00.999999 here\nmeta_note = "see 07:32:00.999999 in the log"\n`, - ) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).resolves.toBeTruthy() - }) - - it('still rejects sub-millisecond datetimes after multiline strings with embedded quotes', async () => { - const root = createWorkspace() - const content = `${ECC_TOML}\nnote = """one " quote"""\ncheckpoint = 07:32:00.999999\n` - writeHomeFile(root, 'params.toml', content) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /millisecond precision/i, - ) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(content) - }) - - it.each([ - ['four-quote closer', 'note = """foo""""\n'], - ['five-quote closer', 'note = """foo"""""\n'], - ])( - 'still rejects sub-millisecond datetimes after a %s multiline string', - async (_label, note) => { - const root = createWorkspace() - const content = `${ECC_TOML}\n${note}checkpoint = 1979-05-27T07:32:00.999999Z\n` - writeHomeFile(root, 'params.toml', content) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /millisecond precision/i, - ) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(content) - }, - ) - - it('rejects a nested Map payload instead of serializing it as an empty table', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - await expect( - writeWorkspaceParameters(root, { - design: 'gcd', - core: new Map([['utilitization', 0.5]]) as unknown as Record, - }), - ).rejects.toThrow(/plain record|not a scalar|not representable/i) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(ECC_TOML) - }) - - it('rejects undefined payload leaves instead of silently deleting them', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - await expect( - writeWorkspaceParameters(root, { Design: undefined as unknown as string }), - ).rejects.toThrow(/undefined/) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(ECC_TOML) - }) - - it('rejects null, Date, and bigint edit values instead of silently rewriting them', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - await expect( - editWorkspaceParameters(root, [{ json_path: ['design'], value: null }]), - ).rejects.toThrow(/null/) - await expect( - editWorkspaceParameters(root, [ - { json_path: ['design'], value: new Date('2026-08-27T00:00:00Z') }, - ]), - ).rejects.toThrow(/losslessly/) - await expect( - editWorkspaceParameters(root, [{ json_path: ['max_fanout'], value: 64n }]), - ).rejects.toThrow(/losslessly/) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(ECC_TOML) - }) - - it('rejects non-finite numbers in the incoming payload and edit values', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - await expect( - writeWorkspaceParameters(root, { target_density: Number.NaN }), - ).rejects.toThrow(/non-finite/) - await expect( - editWorkspaceParameters(root, [{ json_path: ['target_density'], value: Infinity }]), - ).rejects.toThrow(/non-finite/) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(ECC_TOML) - }) - - it('rejects a malformed TOML document on save instead of replacing it', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', '2026-08-27\n') - await expect(readWorkspaceParameters(root)).rejects.toThrow(/toml/i) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /toml/i, - ) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe('2026-08-27\n') - }) - - it('re-runs the writable guard inside the serialized operation', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - let calls = 0 - await expect( - writeWorkspaceParameters(root, { design: 'gcd' }, undefined, async () => { - calls += 1 - throw new Error('blocked') - }), - ).rejects.toThrow('blocked') - expect(calls).toBe(1) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(ECC_TOML) - }) - - it('re-checks the writable guard before the rename', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - let calls = 0 - await expect( - writeWorkspaceParameters(root, { design: 'gcd' }, undefined, async () => { - calls += 1 - if (calls === 2) throw new Error('blocked') - }), - ).rejects.toThrow('blocked') - expect(calls).toBe(2) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(ECC_TOML) - }) - - it('lands the save on the newly preferred config when the format migrates mid-queue', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', JSON_PARAMETERS) - const location = await writeWorkspaceParameters( - root, - { 'Max fanout': 48 }, - undefined, - async () => { - // Simulate the ecc migration landing while the save was queued. - writeHomeFile(root, 'params.toml', ECC_TOML) - }, - ) - expect(location.format).toBe('toml') - const parameters = await readWorkspaceParameters(root) - expect(parameters?.max_fanout).toBe(48) - }) - - it('preserves the existing file mode through an atomic replace', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - chmodSync(join(root, 'home', 'params.toml'), 0o600) - await writeWorkspaceParameters(root, { design: 'gcd' }) - expect(statSync(join(root, 'home', 'params.toml')).mode & 0o777).toBe(0o600) - }) - - it('refuses a symlinked config inside the serialized write', async () => { - const root = createWorkspace() - const alias = join(root, 'home', 'other.toml') - writeFileSync(alias, '[params]\ndesign = "gcd"\n') - symlinkSync(alias, join(root, 'home', 'params.toml')) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /symlink/i, - ) - expect(readFileSync(alias, 'utf8')).toBe('[params]\ndesign = "gcd"\n') - }) - - it('refuses a symlinked config pointing outside the config directory', async () => { - const root = createWorkspace() - const outside = join(root, 'outside.toml') - writeFileSync(outside, '[params]\ndesign = "gcd"\n') - symlinkSync(outside, join(root, 'home', 'params.toml')) - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /symlink|no longer resolves/i, - ) - expect(readFileSync(outside, 'utf8')).toBe('[params]\ndesign = "gcd"\n') - }) - - it('throws when no parameters file exists', async () => { - const root = createWorkspace() - await expect(writeWorkspaceParameters(root, { design: 'gcd' })).rejects.toThrow( - /not found/i, - ) - }) -}) - -describe('mergePayloadIntoTomlDocument regressions', () => { - it('deletes a mirror key when the corresponding parameter is emptied', () => { - const document = { - design: { name: 'gcd', top: 'gcd' }, - pdk: { root: '/pdk/ics55' }, - params: { design: '', top_module: 'gcd', pdk_root: '' }, - } - const merged = mergePayloadIntoTomlDocument( - document, - { design: '', pdk_root: '' }, - '/ws', - ) - expect('name' in merged.design).toBe(false) - expect(merged.design.top).toBe('gcd') - expect('root' in merged.pdk).toBe(false) - }) - - it('canonicalizes a hand-authored display key before merging so the edit wins', () => { - const document = { - params: { 'Target density': 0.45, design: 'gcd' }, - } - const merged = mergePayloadIntoTomlDocument(document, { target_density: 0.55 }, '/ws') - expect(merged.params.target_density).toBe(0.55) - expect('Target density' in merged.params).toBe(false) - }) - - it('keeps a section-only [pdk] config through a save', () => { - const document = { - pdk: { name: 'ics55', root: '/pdk/ics55', config: 'home/pdk.json' }, - params: { pdk: 'ics55', design: 'gcd' }, - } - const merged = mergePayloadIntoTomlDocument(document, { 'Max fanout': 32 }, '/ws') - expect(merged.pdk.config).toBe('home/pdk.json') - expect(merged.params.pdk_config).toBe('home/pdk.json') - }) -}) - -describe('editWorkspaceParameters', () => { - it('applies display-key paths to a TOML workspace after canonicalizing them', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - await editWorkspaceParameters(root, [ - { json_path: ['Target density'], value: 0.55 }, - { json_path: ['Core', 'Utilitization'], value: 0.45 }, - ]) - const parameters = await readWorkspaceParameters(root) - expect(parameters?.target_density).toBe(0.55) - expect(parameters?.core).toMatchObject({ utilitization: 0.45, margin: [2, 2] }) - }) - - it('applies flat paths to a TOML workspace unchanged', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - await editWorkspaceParameters(root, [{ json_path: ['max_fanout'], value: 64 }]) - const parameters = await readWorkspaceParameters(root) - expect(parameters?.max_fanout).toBe(64) - }) - - it('rejects edits to parameters that do not exist', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - await expect( - editWorkspaceParameters(root, [{ json_path: ['nonexistent_knob'], value: 1 }]), - ).rejects.toThrow(/does not exist/i) - }) - - it('rejects TOML edits when a GUI-known leaf already holds a Date or bigint', async () => { - const root = createWorkspace() - const content = ECC_TOML.replace( - 'target_density = 0.2', - 'target_density = 1979-05-27', - ) - writeHomeFile(root, 'params.toml', content) - await expect( - editWorkspaceParameters(root, [{ json_path: ['max_fanout'], value: 32 }]), - ).rejects.toThrow(/cannot be represented losslessly/) - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(content) - }) - - it('rejects TOML edits when a GUI-known scalar already holds a table or array', async () => { - const tableContent = ` -[params] -pdk = "ics55" -top_module = "gcd" -max_fanout = 20 - -[params.design] -extra = "keep-me" -` - const arrayRoot = createWorkspace() - const arrayContent = ECC_TOML.replace('name = "gcd"', 'name = ""').replace( - 'design = "gcd"', - 'design = ["gcd"]', - ) - writeHomeFile(arrayRoot, 'params.toml', arrayContent) - await expect( - editWorkspaceParameters(arrayRoot, [{ json_path: ['top_module'], value: 'aes' }]), - ).rejects.toThrow(/not a scalar/) - expect(readFileSync(join(arrayRoot, 'home', 'params.toml'), 'utf8')).toBe( - arrayContent, - ) - - const tableRoot = createWorkspace() - writeHomeFile(tableRoot, 'params.toml', tableContent) - await expect( - editWorkspaceParameters(tableRoot, [{ json_path: ['top_module'], value: 'aes' }]), - ).rejects.toThrow(/not a scalar/) - expect(readFileSync(join(tableRoot, 'home', 'params.toml'), 'utf8')).toBe( - tableContent, - ) - }) - - it('still allows TOML edits when an unknown leaf holds a Date', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'params.toml', - ECC_TOML.replace('max_fanout = 20', 'max_fanout = 20\ncheckpoint = 1979-05-27'), - ) - await editWorkspaceParameters(root, [{ json_path: ['max_fanout'], value: 32 }]) - const parameters = await readWorkspaceParameters(root) - expect(parameters?.max_fanout).toBe(32) - }) - - it('applies display-key paths to a home/parameters.json workspace', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', JSON_PARAMETERS) - await editWorkspaceParameters(root, [ - { json_path: ['Frequency max [MHz]'], value: 200 }, - ]) - const written = JSON.parse( - readFileSync(join(root, 'home', 'parameters.json'), 'utf8'), - ) as Record - expect(written['Frequency max [MHz]']).toBe(200) - }) - - it('applies canonical agent paths to a home/parameters.json workspace', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'parameters.json', - JSON.stringify( - { Design: 'gcd', 'Target density': 0.2, 'Routability opt flag': 1 }, - null, - 4, - ), - ) - await editWorkspaceParameters(root, [ - { json_path: ['target_density'], value: 0.55 }, - { json_path: ['routability_opt_flag'], value: 0 }, - ]) - const written = JSON.parse( - readFileSync(join(root, 'home', 'parameters.json'), 'utf8'), - ) as Record - expect(written['Target density']).toBe(0.55) - expect(written['Routability opt flag']).toBe(0) - expect(written).not.toHaveProperty('target_density') - }) - - it('throws when no parameters file exists', async () => { - const root = createWorkspace() - await expect( - editWorkspaceParameters(root, [{ json_path: ['design'], value: 'x' }]), - ).rejects.toThrow(/not found/i) - }) - - it('rejects edits when the legacy file holds an unsafe integer', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'parameters.json', - '{ "Design": "gcd", "Area": 9007199254740993 }\n', - ) - await expect( - editWorkspaceParameters(root, [{ json_path: ['Design'], value: 'aes' }]), - ).rejects.toThrow(/MAX_SAFE_INTEGER/) - // The file is left untouched. - expect(readFileSync(join(root, 'home', 'parameters.json'), 'utf8')).toContain( - '9007199254740993', - ) - }) - - it('rejects unsafe numbers in decimal and exponent forms', async () => { - for (const literal of ['9007199254740993.0', '9.007199254740993e15']) { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', `{ "Design": "gcd", "Area": ${literal} }\n`) - await expect( - editWorkspaceParameters(root, [{ json_path: ['Design'], value: 'aes' }]), - ).rejects.toThrow(/MAX_SAFE_INTEGER/) - expect(readFileSync(join(root, 'home', 'parameters.json'), 'utf8')).toContain( - literal, - ) - } - }) - - it('rejects numbers that overflow to a non-finite value', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', '{ "Design": "gcd", "Area": 1e400 }\n') - await expect( - editWorkspaceParameters(root, [{ json_path: ['Design'], value: 'aes' }]), - ).rejects.toThrow(/not representable/) - expect(readFileSync(join(root, 'home', 'parameters.json'), 'utf8')).toContain('1e400') - }) - - it('rejects unsafe numbers and non-object roots on reads', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'parameters.json', - '{ "Design": "gcd", "Area": 9007199254740993 }\n', - ) - await expect(readWorkspaceParameters(root)).rejects.toThrow(/MAX_SAFE_INTEGER/) - - const arrayRoot = createWorkspace() - writeHomeFile(arrayRoot, 'parameters.json', '[1, 2, 3]\n') - await expect(readWorkspaceParameters(arrayRoot)).rejects.toThrow(/JSON object/i) - }) - - it('accepts integers up to Number.MAX_SAFE_INTEGER and digit runs inside strings', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'parameters.json', - '{ "Design": "gcd17912481922736482372x", "Area": 9007199254740991, "Ratio": 1.5 }\n', - ) - await editWorkspaceParameters(root, [{ json_path: ['Design'], value: 'aes' }]) - const written = JSON.parse( - readFileSync(join(root, 'home', 'parameters.json'), 'utf8'), - ) as Record - expect(written.Design).toBe('aes') - expect(written.Area).toBe(9007199254740991) - }) -}) - -describe('hand-authored display keys in TOML', () => { - it('canonicalizes them on read and accepts edits against the canonical path', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'params.toml', - '[params]\n"Target density" = 0.45\ndesign = "gcd"\n', - ) - const parameters = await readWorkspaceParameters(root) - expect(parameters?.target_density).toBe(0.45) - expect(parameters && 'Target density' in parameters).toBe(false) - - await editWorkspaceParameters(root, [{ json_path: ['target_density'], value: 0.55 }]) - const updated = await readWorkspaceParameters(root) - expect(updated?.target_density).toBe(0.55) - }) -}) - -describe('malformed TOML sections', () => { - it('rejects a non-table [params] section instead of treating it as empty', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', 'params = [1]\n') - await expect(readWorkspaceParameters(root)).rejects.toThrow(/must be a table/i) - }) - - it('rejects a scalar [design] section', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', 'design = "gcd"\n[params]\ntop_module = "gcd"\n') - await expect(readWorkspaceParameters(root)).rejects.toThrow(/must be a table/i) - }) -}) - -describe('editWorkspaceParameters with an authorized location', () => { - it('operates on exactly the authorized file instead of re-locating', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - const authorized = join(root, 'home', 'params.toml') - await editWorkspaceParameters(root, [{ json_path: ['max_fanout'], value: 48 }], { - format: 'toml', - path: authorized, - }) - const parameters = await readWorkspaceParameters(root) - expect(parameters?.max_fanout).toBe(48) - }) -}) - -describe('json_path hardening', () => { - it('rejects prototype-related segments instead of mutating Object.prototype', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', JSON_PARAMETERS) - await expect( - editWorkspaceParameters(root, [{ json_path: ['__proto__', 'toString'], value: 1 }]), - ).rejects.toThrow(/not allowed/i) - expect(({} as Record).toString).toBe(Object.prototype.toString) - }) - - it('rejects a constructor segment on a legacy workspace', async () => { - const root = createWorkspace() - writeHomeFile(root, 'parameters.json', JSON_PARAMETERS) - await expect( - editWorkspaceParameters(root, [{ json_path: ['constructor'], value: {} }]), - ).rejects.toThrow(/not allowed/i) - }) -}) - -describe('write hardening', () => { - it('parses 64-bit TOML integers beyond the 53-bit safe range', async () => { - const root = createWorkspace() - writeHomeFile( - root, - 'params.toml', - '[params]\nseed = 9007199254740993\ndesign = "gcd"\n', - ) - const parameters = await readWorkspaceParameters(root) - expect(parameters?.seed).toBe(9007199254740993n) - expect(parameters?.design).toBe('gcd') - }) - - it('writes atomically without reusing an existing temp file', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - const staleTemps = (await import('node:fs/promises')).readdir(join(root, 'home')) - await writeWorkspaceParameters(root, { 'Frequency max [MHz]': 175 }) - const parameters = await readWorkspaceParameters(root) - expect(parameters?.frequency_max).toBe(175) - expect((await staleTemps).filter((name) => name.endsWith('.tmp'))).toEqual([]) - }) -}) - -describe('parameter write serialization', () => { - it('serializes overlapping save and edit operations so no update is lost', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - - const [saved] = await Promise.all([ - writeWorkspaceParameters(root, { 'Frequency max [MHz]': 175 }), - editWorkspaceParameters(root, [{ json_path: ['max_fanout'], value: 48 }]), - ]) - - const parameters = await readWorkspaceParameters(root) - expect(parameters?.frequency_max).toBe(175) - expect(parameters?.max_fanout).toBe(48) - expect(saved.format).toBe('toml') - }) -}) - -describe('queued agent rollback', () => { - it('restores the parameter file when a later step-config write fails', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - const original = readFileSync(join(root, 'home', 'params.toml'), 'utf8') - const missingStep = join(root, 'config', 'dreamplace_ecc.json') - - await expect( - applyQueuedWorkspaceParameterWrites( - root, - [{ json_path: ['max_fanout'], value: 64 }], - [ - { - canonicalPath: missingStep, - edits: [{ json_path: ['density_weight'], value: 0.1 }], - spelledPath: missingStep, - }, - ], - ), - ).rejects.toThrow(/ENOENT|no such file|missing or empty/i) - - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(original) - }) - - it('does not restore a later Configure save that replaced the agent revision', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - const path = join(root, 'home', 'params.toml') - const previous = readFileSync(path, 'utf8') - await editWorkspaceParameters(root, [{ json_path: ['max_fanout'], value: 64 }]) - const agentRevision = readFileSync(path, 'utf8') - await writeWorkspaceParameters(root, { 'Max fanout': 80 }) - const configureRevision = readFileSync(path, 'utf8') - - await expect( - restoreTextIfCurrentRevision(path, path, agentRevision, previous), - ).resolves.toBe('skipped') - expect(readFileSync(path, 'utf8')).toBe(configureRevision) - expect(configureRevision).not.toBe(agentRevision) - }) - - it('re-checks the runtime guard immediately before a step-config rename', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - mkdirSync(join(root, 'config'), { recursive: true }) - writeFileSync( - join(root, 'config', 'dreamplace_ecc.json'), - '{\n "density_weight": 0.2\n}\n', - ) - const original = readFileSync(join(root, 'home', 'params.toml'), 'utf8') - const originalStep = readFileSync(join(root, 'config', 'dreamplace_ecc.json'), 'utf8') - let guardCalls = 0 - - await expect( - applyQueuedWorkspaceParameterWrites( - root, - [{ json_path: ['max_fanout'], value: 64 }], - [ - { - canonicalPath: join(root, 'config', 'dreamplace_ecc.json'), - edits: [{ json_path: ['density_weight'], value: 0.1 }], - spelledPath: join(root, 'config', 'dreamplace_ecc.json'), - }, - ], - undefined, - async () => { - guardCalls += 1 - // 1 queue, 2 prepare, 3 parameter rename, 4 step pre-read, - // 5 step pre-rename. Fail only on the last so rollback can still run. - if (guardCalls === 5) { - throw new Error('workspace flow is running') - } - }, - ), - ).rejects.toThrow(/workspace flow is running/) - - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).toBe(original) - expect(readFileSync(join(root, 'config', 'dreamplace_ecc.json'), 'utf8')).toBe( - originalStep, - ) - }) - - it('surfaces a rollback failure when the restore rename is blocked', async () => { - const root = createWorkspace() - writeHomeFile(root, 'params.toml', ECC_TOML) - const original = readFileSync(join(root, 'home', 'params.toml'), 'utf8') - const missingStep = join(root, 'config', 'dreamplace_ecc.json') - let guardCalls = 0 - - await expect( - applyQueuedWorkspaceParameterWrites( - root, - [{ json_path: ['max_fanout'], value: 64 }], - [ - { - canonicalPath: missingStep, - edits: [{ json_path: ['density_weight'], value: 0.1 }], - spelledPath: missingStep, - }, - ], - undefined, - async () => { - guardCalls += 1 - // Parameter commit succeeds (1-3). Step read then fails; restore is 5. - if (guardCalls === 5) { - throw new Error('workspace flow is running') - } - }, - ), - ).rejects.toThrow(/rollback failed/) - - expect(readFileSync(join(root, 'home', 'params.toml'), 'utf8')).not.toBe(original) - }) -}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceParametersFile.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceParametersFile.ts deleted file mode 100644 index a6eee125f..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceParametersFile.ts +++ /dev/null @@ -1,1649 +0,0 @@ -import { - lstat, - open, - realpath, - rename, - rm, - stat, - writeFile, - chmod, -} from 'node:fs/promises' -import { constants } from 'node:fs' -import { randomInt } from 'node:crypto' -import { dirname, isAbsolute, join, relative, resolve } from 'node:path' - -import { parse, stringify } from 'smol-toml' -import { - assignOwnJsonPathValue, - isForbiddenJsonPathSegment, - normalizeParameterKey, - normalizeParameterKeys, - readOwnJsonPathSegment, -} from '@ecos-studio/shared' - -export const WORKSPACE_CONFIG_BASENAME = 'params.toml' -export const JSON_PARAMETERS_BASENAME = 'parameters.json' - -export type WorkspaceParametersFormat = 'toml' | 'json' - -export interface WorkspaceParametersFileLocation { - format: WorkspaceParametersFormat - path: string - /** - * The path as spelled before canonicalization (authorizedLocation only): - * reads and writes address the spelled leaf with no-follow semantics, so - * an alias swapped in after authorization fails instead of redirecting - * the operation to the alias target. - */ - spelledPath?: string -} - -// Mirrors of the section mapping in ecc chipcompiler/data/workspace_config.py -// (_DESIGN_SECTION_KEYS / _PDK_SECTION_KEYS). Keep aligned with ecc. -const DESIGN_SECTION_KEYS: Readonly> = { - design: 'name', - top_module: 'top', - clock: 'clock_port', - frequency_max: 'frequency_mhz', -} - -const PDK_SECTION_KEYS: Readonly> = { - pdk: 'name', - pdk_root: 'root', - pdk_config: 'config', -} - -function isErrno(error: unknown, code: string): boolean { - return (error as NodeJS.ErrnoException).code === code -} - -async function isFile(path: string): Promise { - try { - return (await stat(path)).isFile() - } catch (error) { - if (isErrno(error, 'ENOENT')) return false - throw error - } -} - -/** - * Python os.path.lexists semantics: true when the entry exists at all, - * including a dangling symlink. - */ -async function lexists(path: string): Promise { - try { - await lstat(path) - return true - } catch (error) { - if (isErrno(error, 'ENOENT')) return false - throw error - } -} - -/** - * Distinguish a missing preferred config from a dangling symlink or other - * non-regular entry. `stat()` follows links, so a broken `home/params.toml` - * would otherwise look absent and fall through to `parameters.json`. - */ -async function assertPreferredConfigIsRegularFile(path: string): Promise { - try { - const info = await lstat(path) - if (info.isSymbolicLink()) { - throw new Error( - `Refusing to use ${path}: the preferred workspace configuration is a symlink`, - ) - } - if (!info.isFile()) { - throw new Error( - `Refusing to use ${path}: the preferred workspace configuration is not a regular file`, - ) - } - return true - } catch (error) { - if (isErrno(error, 'ENOENT')) return false - throw error - } -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value) -} - -function isPlainRecord(value: unknown): value is Record { - if (!isRecord(value)) return false - const prototype = Object.getPrototypeOf(value) - return prototype === Object.prototype || prototype === null -} - -/** - * Merge an overlay into a base document leaf-wise: plain records merge - * recursively (unknown nested keys survive a save the GUI did not touch), - * while arrays, scalars, and class instances (e.g. TOML dates) replace. - */ -function mergeRecordsPreservingUnknown( - base: Record, - overlay: Record, -): Record { - const merged: Record = { ...base } - for (const [key, value] of Object.entries(overlay)) { - const existing = merged[key] - merged[key] = - isPlainRecord(existing) && isPlainRecord(value) - ? mergeRecordsPreservingUnknown(existing, value) - : preserveTomlNumericKind(existing, value) - } - return merged -} - -/** - * smol-toml stringify({ numbersAsFloat: true }) writes Number as a float - * (`1.0`) and BigInt as an integer (`1`). GUI/agent payloads arrive as - * Number, so an integer overlay on a BigInt leaf must stay BigInt or a - * rewrite would change `max_fanout = 20` into `max_fanout = 20.0`. - */ -function preserveTomlNumericKind(existing: unknown, overlay: unknown): unknown { - if (Array.isArray(overlay)) { - const existingItems = Array.isArray(existing) ? existing : [] - return overlay.map((item, index) => - preserveTomlNumericKind(existingItems[index], item), - ) - } - if ( - typeof overlay !== 'number' || - !Number.isInteger(overlay) || - !Number.isSafeInteger(overlay) - ) { - return overlay - } - if (typeof existing === 'bigint') return BigInt(overlay) - return overlay -} - -function reviveSafeTomlIntegers(value: unknown): unknown { - if (typeof value === 'bigint') { - if ( - value > BigInt(Number.MAX_SAFE_INTEGER) || - value < BigInt(Number.MIN_SAFE_INTEGER) - ) { - return value - } - return Number(value) - } - if (Array.isArray(value)) return value.map((item) => reviveSafeTomlIntegers(item)) - if (!isPlainRecord(value)) return value - const revived: Record = {} - for (const [key, item] of Object.entries(value)) { - Object.defineProperty(revived, key, { - value: reviveSafeTomlIntegers(item), - writable: true, - enumerable: true, - configurable: true, - }) - } - return revived -} - -function hasValue(value: unknown): boolean { - if (value === null || value === undefined) return false - if (typeof value === 'string') return value.trim() !== '' - return true -} - -/** - * Locate the workspace's persisted parameters: `home/params.toml` (preferred) - * first, `home/parameters.json` (JSON workspaces, including ecc-fe) as - * fallback when the preferred file is absent. - */ -/** - * Both the canonical TOML and the legacy JSON exist: the JSON is inert - * (the TOML wins) and the user should delete it — surfaced as a one-shot - * renderer toast by the parameters read path. Existence uses lstat - * (Python lexists semantics, mirroring the CLI's workspace_config_shadowed - * probe): a dangling legacy symlink still shadows. - */ -export async function hasWorkspaceConfigShadow(root: string): Promise { - const tomlPath = join(root, 'home', WORKSPACE_CONFIG_BASENAME) - if (!(await lexists(tomlPath))) return false - return lexists(join(root, 'home', JSON_PARAMETERS_BASENAME)) -} - -export async function locateWorkspaceParametersFile( - root: string, -): Promise { - const tomlPath = join(root, 'home', WORKSPACE_CONFIG_BASENAME) - if (await assertPreferredConfigIsRegularFile(tomlPath)) { - return { format: 'toml', path: tomlPath } - } - const jsonPath = join(root, 'home', JSON_PARAMETERS_BASENAME) - if (await isFile(jsonPath)) { - return { format: 'json', path: jsonPath } - } - return null -} - -/** - * The writable sections must be plain tables: a scalar/array section (or a - * scalar-like object such as a TOML date) is a configuration error, never a - * silently-empty section to overwrite. Shared by the read flatten and every - * write merge. - */ -function assertTomlSectionShapes(document: Record): void { - for (const section of ['params', 'design', 'pdk'] as const) { - if (section in document && !isPlainRecord(document[section])) { - throw new Error( - `Invalid workspace configuration: [${section}] must be a table, got ${ - Array.isArray(document[section]) ? 'array' : typeof document[section] - }`, - ) - } - } -} - -/** - * Flatten an params.toml document into the canonical flat parameter payload. - * Mirrors ecc's `_merge_payload`: `[params]` is the base, then non-empty - * `[design]`/`[pdk]` mirror values override their mapped parameter keys. - * A workspace-relative `pdk_config` resolves against the workspace root. - * Keys are canonicalized on the way out so a hand-authored display key - * (e.g. `Target density`) cannot shadow the same parameter elsewhere. - * A section that exists but is not a table is a configuration error, - * never a silently-empty section. - */ -export function mergeTomlSections( - document: Record, - workspaceRoot: string, -): Record { - assertTomlSectionShapes(document) - const params: Record = { - ...(normalizeParameterKeys( - isRecord(document.params) ? document.params : {}, - ) as Record), - } - const design = isRecord(document.design) ? document.design : {} - const pdk = isRecord(document.pdk) ? document.pdk : {} - - for (const [paramKey, sectionKey] of Object.entries(DESIGN_SECTION_KEYS)) { - const value = design[sectionKey] - if (hasValue(value)) params[paramKey] = value - } - for (const [paramKey, sectionKey] of Object.entries(PDK_SECTION_KEYS)) { - const value = pdk[sectionKey] - if (hasValue(value)) params[paramKey] = value - } - - const pdkConfig = params.pdk_config - if (typeof pdkConfig === 'string' && pdkConfig && !isAbsolute(pdkConfig)) { - params.pdk_config = join(workspaceRoot, pdkConfig) - } - return params -} - -/** - * smol-toml parses datetimes at millisecond resolution, so a date scalar - * with finer precision (e.g. 07:32:00.999999) silently truncates on every - * save — including untouched values in [flow] and unknown sections. Refuse - * the write instead of corrupting the document. Strings and comments are - * skipped; a time-looking token inside a multiline string may fail closed, - * which is still safer than a silent truncation. - */ -export function assertNoSubMillisecondDatetimes(text: string, label: string): void { - let index = 0 - while (index < text.length) { - const char = text[index] - if (char === '#') { - while (index < text.length && text[index] !== '\n') index += 1 - continue - } - if (char === '"' || char === "'") { - const quote = char - if (text[index + 1] === quote && text[index + 2] === quote) { - // Multiline string: 1–2 quotes may sit immediately before the - // closer (`"""foo""""` / `"""foo"""""`), so a 3/4/5-quote run is - // the terminator. Escapes apply only in basic multiline strings. - index += 3 - while (index < text.length) { - if (quote === '"' && text[index] === '\\') { - index += 2 - continue - } - if ( - text[index] === quote && - text[index + 1] === quote && - text[index + 2] === quote - ) { - index += 3 - if (text[index] === quote) index += 1 - if (text[index] === quote) index += 1 - break - } - index += 1 - } - continue - } - index += 1 - while (index < text.length && text[index] !== quote) { - index += quote === '"' && text[index] === '\\' ? 2 : 1 - } - index += 1 - continue - } - const timeMatch = /^\d{2}:\d{2}:\d{2}\.(\d+)/.exec(text.slice(index)) - if (timeMatch && timeMatch[1].length > 3) { - throw new Error( - `Refusing to rewrite ${label}: datetime ${timeMatch[0]} exceeds ` + - 'millisecond precision and would be truncated', - ) - } - index += 1 - } -} - -/** - * Parse a TOML document that must hold a plain table at its root: a scalar - * document (e.g. a bare TOML date) is a configuration error, not an empty - * parameter set to overwrite on the next save. - */ -export function parseTomlDocument(text: string, label: string): Record { - assertTomlNumbersSafe(text, label) - const document: unknown = parse(text, { integersAsBigInt: true }) - if (!isPlainRecord(document)) { - throw new Error( - `Invalid workspace configuration: ${label} must contain a TOML table at the root`, - ) - } - // Check [params] before [design]/[pdk] mirrors overwrite a nested table - // with a scalar. `[params.design] future = "keep"` plus `[design] name` - // would otherwise load as the design name and the next save would delete - // `future`. - if (isPlainRecord(document.params)) { - assertGuiKnownTomlLeavesLossless( - normalizeParameterKeys(document.params) as Record, - label, - ) - } - return document -} - -/** - * Integers stay BigInt and floats stay Number so a rewrite can emit `1` - * vs `1.0`. Callers that return parameters over IPC must revive safe - * integers first. - */ -export function stringifyTomlDocument(document: Record): string { - return stringify(document, { numbersAsFloat: true }) -} - -/** - * Parse workspace parameters from file content of the given format. Pure: - * no filesystem access, so callers can run it behind their own path-scope - * authorization. Parse failures throw. - */ -export function parseWorkspaceParametersText( - text: string, - format: WorkspaceParametersFormat, - workspaceRoot: string, -): Record { - if (format === 'json') { - const parsed: unknown = parseJsonPreservingIntegers( - text, - join(workspaceRoot, 'home', JSON_PARAMETERS_BASENAME), - ) - if (!isRecord(parsed)) { - throw new Error( - 'Invalid workspace configuration: parameters JSON must contain a JSON object', - ) - } - // Collision parity with ecc: a long display key wins over its already- - // canonical duplicate. Validate the normalized semantic copy so an inert - // `max_fanout` table cannot reject a usable `Max fanout` workspace, then - // return the original JSON shape to frontend consumers. - assertGuiKnownTomlLeavesLossless( - normalizeParameterKeys(parsed) as Record, - join(workspaceRoot, 'home', JSON_PARAMETERS_BASENAME), - ) - return parsed - } - const document = parseTomlDocument( - text, - join(workspaceRoot, 'home', WORKSPACE_CONFIG_BASENAME), - ) - const flattened = reviveSafeTomlIntegers( - mergeTomlSections(document, workspaceRoot), - ) as Record - assertGuiKnownTomlLeavesLossless( - flattened, - join(workspaceRoot, 'home', WORKSPACE_CONFIG_BASENAME), - ) - return flattened -} - -/** - * Read the workspace parameters regardless of on-disk format. Returns null - * when neither file exists; parse failures propagate (never silently fall - * back to defaults). - */ -export async function readWorkspaceParameters( - root: string, -): Promise | null> { - const location = await locateWorkspaceParametersFile(root) - if (!location) return null - const text = await readFileNoFollow(location.path) - return parseWorkspaceParametersText(text, location.format, root) -} - -/** - * Read a config file through its spelled path: the spelled parent must - * resolve to the authorized (canonical) directory, and the leaf is opened - * no-follow. An alias swapped in after authorization therefore fails the - * open (ELOOP) instead of silently reading the alias target, and a parent - * directory swapped for a symlink fails the containment check — verified - * before AND after the read, so a mid-read swap discards the data instead - * of returning it. - */ -export async function readWorkspaceConfigContained( - spelledPath: string, - canonicalPath: string, -): Promise { - const authorizedParent = dirname(canonicalPath) - if ((await realpath(dirname(spelledPath))) !== authorizedParent) { - throw new Error( - `Refusing to read ${spelledPath}: it no longer resolves to the authorized config`, - ) - } - const text = await readFileNoFollow(spelledPath) - if ((await realpath(dirname(spelledPath))) !== authorizedParent) { - throw new Error( - `Refusing to read ${spelledPath}: parent directory changed during the read`, - ) - } - return text -} - -/** - * Read a config file without following a symlinked final component: when - * the file is swapped to a symlink after authorization, the open fails with - * ELOOP instead of leaking the link target into a merged document. - */ -export async function readFileNoFollow(path: string): Promise { - try { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) - try { - return await handle.readFile('utf8') - } finally { - await handle.close() - } - } catch (error) { - if (isErrno(error, 'ELOOP')) { - throw new Error(`Refusing to read through a symlink: ${path}`) - } - throw error - } -} - -export async function writeTextAtomically( - path: string, - content: string, - options?: { authorizedParent?: string }, -): Promise { - // Random suffix + exclusive create: a predictable name could be reused by a - // concurrent writer, and a pre-planted symlink could redirect the write. - const parent = dirname(path) - // The anchor is the parent the caller AUTHORIZED, never a freshly derived - // one: deriving it here could adopt a directory swapped in after - // authorization as the accepted target. - const canonicalParent = options?.authorizedParent ?? (await realpath(parent)) - // The replacement keeps the existing file's mode: a 0600 config must not - // become 0644 (umask default) after a save. - let mode: number | undefined - try { - mode = (await stat(path)).mode & 0o777 - } catch (error) { - if (!isErrno(error, 'ENOENT')) throw error - } - const temporaryPath = `${path}.${process.pid}.${Date.now()}.${randomInt(0, 1_000_000)}.tmp` - try { - if ((await realpath(parent)) !== canonicalParent) { - throw new Error( - `Refusing to write ${path}: parent directory changed before the write`, - ) - } - await writeFile(temporaryPath, content, { encoding: 'utf8', flag: 'wx' }) - if (mode !== undefined) { - await chmod(temporaryPath, mode) - } - // Write and rename both address the parent by pathname: revalidate that - // it still resolves to the authorized directory, so a symlink swapped in - // after authorization cannot redirect the rename outside the workspace. - // Node has no dirfd-relative rename, so the rename itself is followed by - // a final verification that fails loud (a misplaced file is reported, - // never silently removed — another swap could make the cleanup delete - // an unrelated file). - if ((await realpath(parent)) !== canonicalParent) { - throw new Error( - `Refusing to write ${path}: parent directory changed during the write`, - ) - } - await rename(temporaryPath, path) - if ((await realpath(parent)) !== canonicalParent) { - throw new Error( - `Refusing to write ${path}: parent directory changed during the rename; ` + - 'the new content may have landed outside the workspace — inspect the directory', - ) - } - } catch (error) { - // Only remove the temporary file while the parent still resolves to the - // authorized directory: after a swap, rm(temporaryPath) would resolve - // through the replacement and could delete an unrelated file planted - // under the same basename. An orphaned temp file is reported instead. - if ((await realpath(parent).catch(() => '')) === canonicalParent) { - await rm(temporaryPath, { force: true }).catch(() => undefined) - } - throw error - } -} - -/** - * Per-path serialization for read-modify-write operations. The GUI save and - * the agent/rerun edit paths both read the current configuration, merge in - * memory, then atomically replace the file; without a shared lock, two - * overlapping operations can read the same revision and the later rename - * silently discards the earlier operation's unrelated changes. - */ -const parameterWriteQueues = new Map>() - -export async function enqueueParameterWrite( - path: string, - operation: () => Promise, -): Promise { - const previous = parameterWriteQueues.get(path) ?? Promise.resolve() - const next = previous.then(operation, operation) - const settled = next.catch(() => undefined) - parameterWriteQueues.set(path, settled) - try { - return await next - } finally { - if (parameterWriteQueues.get(path) === settled) { - parameterWriteQueues.delete(path) - } - } -} - -export async function workspaceParameterWriteQueueKey( - root: string, - authorizedLocation?: WorkspaceParametersFileLocation, -): Promise { - const queueKey = authorizedLocation - ? dirname(authorizedLocation.path) - : await realpath(join(root, 'home')) - return `${queueKey}:parameters` -} - -/** - * Incoming payload/edit values face the same rules as the document on disk: - * a non-finite number would serialize as null (JSON) or inf/nan (TOML) - * instead of failing, and TOML stringify silently drops null/undefined. - * Checked recursively before any merge. - */ -const GUI_KNOWN_TOML_SCALAR_KEYS = new Set([ - 'pdk', - 'design', - 'description', - 'design_tool', - 'top_module', - 'clock', - 'frequency_max', - 'max_fanout', - 'target_density', - 'target_overflow', - 'global_right_padding', - 'cell_padding_x', - 'routability_opt_flag', - 'bottom_layer', - 'top_layer', - 'pdk_root', - 'pdk_config', -]) - -const GUI_KNOWN_TOML_TABLE_KEYS: Record> = { - die: new Set(['size', 'area']), - core: new Set([ - 'size', - 'area', - 'bounding_box', - 'utilitization', - 'margin', - 'aspect_ratio', - ]), - die_area: new Set(['width', 'height', 'utilitization', 'margin', 'mode']), -} - -const GUI_KNOWN_ARRAY_LEAVES = new Set(['die.size', 'core.size', 'core.margin']) - -/** - * Agent/rerun TOML edits stringify the whole flattened document. A Date, - * bigint, table, or array already sitting in a GUI-known scalar leaf would - * otherwise be rewritten successfully here, then fail or corrupt when the - * renderer reloads it. Nested GUI-known arrays (`die.size`, `core.margin`) - * are walked item-wise. Unknown leaves stay untouched so non-GUI knobs can - * still hold those scalars. - */ -function assertGuiKnownTomlLeavesLossless( - parameters: Record, - label: string, -): void { - for (const [key, value] of Object.entries(parameters)) { - const nestedKeys = GUI_KNOWN_TOML_TABLE_KEYS[key] - if (nestedKeys) { - if (isPlainRecord(value)) { - for (const [nestedKey, nested] of Object.entries(value)) { - if (!nestedKeys.has(nestedKey)) continue - assertGuiKnownNestedLossless( - nested, - `${label}:${key}.${nestedKey}`, - GUI_KNOWN_ARRAY_LEAVES.has(`${key}.${nestedKey}`), - ) - } - } else { - assertGuiKnownScalarLossless(value, `${label}:${key}`) - } - continue - } - if (!GUI_KNOWN_TOML_SCALAR_KEYS.has(key)) continue - assertGuiKnownScalarLossless(value, `${label}:${key}`) - } -} - -function assertGuiKnownNestedLossless( - value: unknown, - label: string, - allowArray: boolean, -): void { - if (Array.isArray(value)) { - if (!allowArray) { - throw new Error(`Refusing to rewrite ${label}: existing value is not a scalar`) - } - for (const item of value) assertGuiKnownScalarLossless(item, label) - return - } - assertGuiKnownScalarLossless(value, label) -} - -function assertGuiKnownScalarLossless(value: unknown, label: string): void { - if (value == null) return - if (value instanceof Date) { - throw new Error( - `Refusing to rewrite ${label}: existing value cannot be represented losslessly`, - ) - } - if (typeof value === 'bigint') { - if ( - value > BigInt(Number.MAX_SAFE_INTEGER) || - value < BigInt(Number.MIN_SAFE_INTEGER) - ) { - throw new Error( - `Refusing to rewrite ${label}: existing integer exceeds Number.MAX_SAFE_INTEGER`, - ) - } - return - } - if (Array.isArray(value) || typeof value === 'object') { - throw new Error(`Refusing to rewrite ${label}: existing value is not a scalar`) - } - if (typeof value === 'number' && !Number.isFinite(value)) { - throw new Error(`Refusing to rewrite ${label}: existing value is not a finite number`) - } - if ( - typeof value === 'number' && - Number.isInteger(value) && - !Number.isSafeInteger(value) - ) { - throw new Error( - `Refusing to rewrite ${label}: existing integer exceeds Number.MAX_SAFE_INTEGER`, - ) - } -} - -function assertFiniteNumbers(value: unknown, label: string): void { - if (value === undefined) { - throw new Error( - `Refusing to write ${label}: undefined would delete the parameter leaf`, - ) - } - if (value === null) { - throw new Error(`Refusing to write ${label}: null would delete the parameter leaf`) - } - if (typeof value === 'bigint' || value instanceof Date) { - throw new Error( - `Refusing to write ${label}: value cannot be represented losslessly in the workspace configuration`, - ) - } - if (typeof value === 'number' && !Number.isFinite(value)) { - throw new Error(`Refusing to write ${label}: non-finite number in parameters payload`) - } - if ( - typeof value === 'number' && - Number.isInteger(value) && - !Number.isSafeInteger(value) - ) { - throw new Error( - `Refusing to write ${label}: integer ${value} exceeds Number.MAX_SAFE_INTEGER`, - ) - } - if (Array.isArray(value)) { - for (const item of value) { - if (item === undefined) { - throw new Error( - `Refusing to write ${label}: sparse array values are not representable`, - ) - } - assertFiniteNumbers(item, label) - } - return - } - if (isPlainRecord(value)) { - for (const item of Object.values(value)) assertFiniteNumbers(item, label) - return - } - if (typeof value === 'object') { - throw new Error( - `Refusing to write ${label}: value must be a plain record, array, or scalar`, - ) - } -} - -/** - * GUI Configure saves emit Die/Core tables. On a TOML workspace whose - * canonical geometry already lives under `die_area`, fold the overlapping - * leaves into that table and drop only those mapped keys so a save cannot - * leave two disagreeing representations. Fields with no `die_area` - * equivalent (`die.area`, `core.size`, `core.area`) stay on Die/Core. - */ -function foldConfigureGeometryIntoDieArea( - existingParams: Record, - payload: Record, -): void { - const existingDieArea = existingParams.die_area - if (!isPlainRecord(existingDieArea)) return - const die = isPlainRecord(payload.die) ? payload.die : null - const core = isPlainRecord(payload.core) ? payload.core : null - if (!die && !core) return - - const overlay: Record = {} - const size = die?.size - if (Array.isArray(size) && size.length >= 2) { - overlay.width = size[0] - overlay.height = size[1] - } - if (core && Object.prototype.hasOwnProperty.call(core, 'utilitization')) { - overlay.utilitization = core.utilitization - } - const foldMargin = isSymmetricMargin(core?.margin) - if (foldMargin && Array.isArray(core?.margin) && core.margin.length > 0) { - overlay.margin = core.margin[0] - } - payload.die_area = mergeRecordsPreservingUnknown(existingDieArea, overlay) - const coreGeometryKeys = foldMargin - ? (['utilitization', 'margin'] as const) - : (['utilitization'] as const) - stripMigratedGeometryTable(payload, 'die', ['size']) - stripMigratedGeometryTable(payload, 'core', coreGeometryKeys) - stripMigratedGeometryTable(existingParams, 'die', ['size']) - stripMigratedGeometryTable(existingParams, 'core', coreGeometryKeys) -} - -function isSymmetricMargin(margin: unknown): boolean { - if (!Array.isArray(margin) || margin.length === 0) return false - const first = margin[0] - return margin.every((item) => Object.is(item, first)) -} - -function stripMigratedGeometryTable( - record: Record, - key: string, - geometryKeys: readonly string[], -): void { - const table = record[key] - if (!isPlainRecord(table)) { - delete record[key] - return - } - const next: Record = { ...table } - for (const geometryKey of geometryKeys) delete next[geometryKey] - if (Object.keys(next).length === 0) delete record[key] - else record[key] = next -} - -/** - * Merge a parameter payload into the existing TOML document. `[params]` is - * updated wholesale per top-level key (display keys are normalized first, - * mirroring ecc's `normalize_parameter_dict`), the `[design]`/`[pdk]` - * mirrors are re-synced from the merged params (mirroring ecc's - * `_split_payload`), `pdk_config` absolutes inside the workspace are stored - * relative (mirroring `render_workspace_config`), and every other section - * (`[flow]`, unknown sections) is preserved untouched. - * - * Existing `[params]` keys are canonicalized before merging: a hand-authored - * display key (e.g. `Target density`) would otherwise shadow the edit, - * because ecc gives the long key precedence on collisions. Mirrors of an - * emptied parameter are deleted so a stale non-empty section value cannot - * resurrect it on the next load. - */ -export function mergePayloadIntoTomlDocument( - document: Record, - payload: Record, - workspaceRoot: string, -): Record & { - design: Record - pdk: Record - params: Record -} { - assertTomlSectionShapes(document) - const flatPayload = normalizeParameterKeys(payload) as Record - // Seed from the FLATTENED document (ecc's _merge_payload semantics: - // non-empty section mirrors override [params]), not from [params] alone — - // a section-only value like [pdk] config is a live parameter that must - // survive the mirror re-sync instead of being deleted as a stale mirror. - const existingParams = mergeTomlSections(document, workspaceRoot) - // Flattened semantic record after [design]/[pdk] mirrors: a table that - // only appears once a section scalar overwrites [params] (or a section - // table that landed after the GUI loaded) must fail here, before the - // payload replaces it and deletes the subtree. - assertGuiKnownTomlLeavesLossless( - existingParams, - join(workspaceRoot, 'home', WORKSPACE_CONFIG_BASENAME), - ) - foldConfigureGeometryIntoDieArea(existingParams, flatPayload) - // Leaf-wise merge: unknown nested members (e.g. a future ecc knob under - // [params.core]) survive a save that only rewrites known fields; arrays, - // scalars, and date values replace wholesale. - const params = mergeRecordsPreservingUnknown(existingParams, flatPayload) - - const pdkConfig = params.pdk_config - if (typeof pdkConfig === 'string' && pdkConfig && isAbsolute(pdkConfig)) { - const resolvedConfig = resolve(pdkConfig) - const workspaceResolved = resolve(workspaceRoot) - const relativeConfig = relative(workspaceResolved, resolvedConfig) - if (relativeConfig && !relativeConfig.startsWith('..') && relativeConfig !== '') { - params.pdk_config = relativeConfig - } - } - - const design: Record = { - ...(isRecord(document.design) ? document.design : {}), - } - for (const [paramKey, sectionKey] of Object.entries(DESIGN_SECTION_KEYS)) { - const value = params[paramKey] - if (hasValue(value)) { - design[sectionKey] = value - } else { - delete design[sectionKey] - } - } - - const pdk: Record = { ...(isRecord(document.pdk) ? document.pdk : {}) } - for (const [paramKey, sectionKey] of Object.entries(PDK_SECTION_KEYS)) { - const value = params[paramKey] - if (hasValue(value)) { - pdk[sectionKey] = value - } else { - delete pdk[sectionKey] - } - } - - return { ...document, design, pdk, params } -} - -/** - * Persist workspace parameters. On a TOML workspace the payload is merged - * into `home/params.toml`; on a JSON workspace (`parameters.json`, including - * ecc-fe) the JSON file is rewritten as-is. Throws when neither file exists. - * - * When `authorizedLocation` is provided (a path already authorized and - * canonicalized by the caller's path scope), the write uses exactly that - * file instead of re-locating it, closing the locate→authorize→write - * symlink swap window. - */ -export async function writeWorkspaceParameters( - root: string, - payload: Record, - authorizedLocation?: WorkspaceParametersFileLocation, - assertWritable?: () => Promise, -): Promise { - const location = authorizedLocation ?? (await locateWorkspaceParametersFile(root)) - if (!location) { - throw new Error( - `Workspace parameters file not found: ${join(root, 'home', WORKSPACE_CONFIG_BASENAME)} or ${join(root, 'home', JSON_PARAMETERS_BASENAME)}`, - ) - } - // Serialize per canonical config slot, not per raw root spelling or file: - // equivalent roots ("/ws" vs "/ws/.", native vs slash-normalized) must - // share one queue, and two operations must never interleave across the - // two formats (a JSON workspace can grow an params.toml mid-queue). - return await enqueueParameterWrite( - await workspaceParameterWriteQueueKey(root, authorizedLocation), - async () => { - // Runtime-activity guards re-run INSIDE the queue: a flow starting while - // this operation waited behind another writer must still block it. - await assertWritable?.() - assertFiniteNumbers(payload, location.path) - // Re-locate at the head of the queue: when the preferred config changed - // while this operation waited (parameters.json -> params.toml migration), - // the write must land where subsequent reads will look. - const onDisk = await locateWorkspaceParametersFile(root) - if (!onDisk) { - throw new Error( - `Workspace parameters file not found: ${join(root, 'home', WORKSPACE_CONFIG_BASENAME)} or ${join(root, 'home', JSON_PARAMETERS_BASENAME)}`, - ) - } - const spelledPath = onDisk.path - const canonicalPath = authorizedLocation - ? authorizedLocation.path - : await realpath(onDisk.path) - if (onDisk.format === 'json') { - // Merge into the existing document: the payload is the GUI's known - // parameter set, not the whole file — keys the GUI does not display - // (frontend extras, unrelated agent edits) must survive a save. - const raw = await readWorkspaceConfigContained(spelledPath, canonicalPath) - const existing = parseJsonPreservingIntegers(raw, onDisk.path) - if (!isRecord(existing)) { - throw new Error( - `Invalid workspace configuration: ${onDisk.path} must contain a JSON object`, - ) - } - // Re-validate after the contained queue-time read: a scalar that - // became a table since the GUI loaded must fail instead of being - // replaced by the save payload. - assertGuiKnownTomlLeavesLossless( - normalizeParameterKeys(existing) as Record, - onDisk.path, - ) - const merged = mergeRecordsPreservingUnknown(existing, payload) - // One more guard pass between the merge and the rename: a flow that - // started while this operation read and merged must still block the - // commit (the remaining window is the rename itself; closing it needs - // the runtime's own cross-process lock). - await assertWritable?.() - await writeTextAtomically(spelledPath, `${JSON.stringify(merged, null, 4)}\n`, { - authorizedParent: dirname(canonicalPath), - }) - return onDisk - } - const raw = await readWorkspaceConfigContained(spelledPath, canonicalPath) - assertNoSubMillisecondDatetimes(raw, onDisk.path) - const document = parseTomlDocument(raw, onDisk.path) - const merged = mergePayloadIntoTomlDocument(document, payload, root) - await assertWritable?.() - await writeTextAtomically(spelledPath, stringifyTomlDocument(merged), { - authorizedParent: dirname(canonicalPath), - }) - return onDisk - }, - ) -} - -export interface WorkspaceParameterEdit { - json_path: readonly (string | number)[] - value: unknown -} - -export interface WorkspaceParameterCommitResult { - canonicalPath: string - format: WorkspaceParametersFormat - path: string - previousContent: string - writtenContent: string -} - -export interface PreparedStepConfigWrite { - canonicalPath: string - edits: readonly WorkspaceParameterEdit[] - spelledPath: string -} - -export function serializeJsonDocument( - document: Record, - raw: string, -): string { - const serialized = JSON.stringify(document, null, detectJsonIndent(raw)) - return raw.endsWith('\n') ? `${serialized}\n` : serialized -} - -export async function readJsonObjectContained( - spelledPath: string, - canonicalPath: string, - label = spelledPath, -): Promise<{ document: Record; raw: string }> { - const raw = await readWorkspaceConfigContained(spelledPath, canonicalPath) - const parsed: unknown = parseJsonPreservingIntegers(raw, label) - if (!isRecord(parsed)) { - throw new Error( - `Invalid workspace configuration: ${label} must contain a JSON object`, - ) - } - return { document: parsed, raw } -} - -/** - * Restore `previous` only when the spelled file still holds `expectedCurrent` - * (the revision this operation wrote). A later Configure save that landed - * after our write must not be clobbered. The caller must already hold the - * parameter write queue. - */ -export async function restoreTextIfCurrentRevision( - spelledPath: string, - canonicalPath: string, - expectedCurrent: string, - previous: string, - assertWritable?: () => Promise, -): Promise<'restored' | 'skipped'> { - const current = await readWorkspaceConfigContained(spelledPath, canonicalPath) - if (current !== expectedCurrent) return 'skipped' - // Re-run the runtime/active-workspace guard immediately before the restore - // rename: a flow that started after the failed write must not observe a - // rollback happening underneath it. A blocked restore is reported as a - // rollback failure instead of silently leaving mixed revisions. - await assertWritable?.() - await writeTextAtomically(spelledPath, previous, { - authorizedParent: dirname(canonicalPath), - }) - return 'restored' -} - -function detectJsonIndent(raw: string): number { - return /^\s*[[{]\s*\n(\s+)\S/.exec(raw)?.[1]?.length ?? 4 -} - -/** - * JSON.parse silently rounds numbers that cannot round-trip as IEEE-754 - * values, so reading or rewriting a config would corrupt literals the - * operation never touched. Scan number tokens outside strings and refuse - * any token whose parsed form does not stringify back to itself. - */ -function assertJsonNumbersSafe(text: string, label: string): void { - let index = 0 - while (index < text.length) { - const char = text[index] - if (char === '"') { - index += 1 - while (index < text.length && text[index] !== '"') { - index += text[index] === '\\' ? 2 : 1 - } - index += 1 - continue - } - if (char === '-' || (char >= '0' && char <= '9')) { - const token = matchJsonNumberToken(text, index) - if (token) { - assertNumberTokenRoundTrips(token, label) - index += token.length - continue - } - } - index += 1 - } -} - -function parseJsonPreservingIntegers(text: string, label: string): unknown { - assertJsonNumbersSafe(text, label) - return JSON.parse(text) -} - -/** - * smol-toml also parses floats at IEEE-754 precision. A later stringify - * would silently shorten an untouched high-precision token, so refuse - * those tokens before rewriting the document. - */ -function assertTomlNumbersSafe(text: string, label: string): void { - let index = 0 - let expectingValue = false - let arrayDepth = 0 - while (index < text.length) { - const char = text[index] - if (char === '#') { - while (index < text.length && text[index] !== '\n') index += 1 - continue - } - if (char === '"' || char === "'") { - const quote = char - if (text[index + 1] === quote && text[index + 2] === quote) { - index += 3 - while (index < text.length) { - if (quote === '"' && text[index] === '\\') { - index += 2 - continue - } - if ( - text[index] === quote && - text[index + 1] === quote && - text[index + 2] === quote - ) { - index += 3 - if (text[index] === quote) index += 1 - if (text[index] === quote) index += 1 - break - } - index += 1 - } - expectingValue = false - continue - } - index += 1 - while (index < text.length && text[index] !== quote) { - index += quote === '"' && text[index] === '\\' ? 2 : 1 - } - index += 1 - expectingValue = false - continue - } - if (char === '=' || char === ',') { - expectingValue = true - index += 1 - continue - } - if (char === '[') { - // A `[` in value position (or inside an array) starts an array and - // keeps the next token as a value, including after newlines. A table - // header is not in value position. - if (expectingValue || arrayDepth > 0) { - arrayDepth += 1 - expectingValue = true - } else { - expectingValue = false - } - index += 1 - continue - } - if (char === ']') { - if (arrayDepth > 0) arrayDepth -= 1 - expectingValue = false - index += 1 - continue - } - if (char === '{' || char === '}') { - expectingValue = false - index += 1 - continue - } - if (char === '\n') { - if (arrayDepth === 0) expectingValue = false - index += 1 - continue - } - if (expectingValue && char >= '0' && char <= '9') { - const dateToken = matchTomlCalendarDateToken(text, index) - if (dateToken) { - assertTomlCalendarDateValid(dateToken, label) - index += dateToken.length - expectingValue = false - continue - } - } - if ( - expectingValue && - (char === '+' || char === '-' || (char >= '0' && char <= '9')) - ) { - const token = matchTomlNumberToken(text, index) - if (token) { - if (token.includes('.') || /[eE]/.test(token)) { - assertNumberTokenRoundTrips(token, label) - } - index += token.length - expectingValue = false - continue - } - } - if (char !== ' ' && char !== '\t' && char !== '\r') expectingValue = false - index += 1 - } -} - -function matchJsonNumberToken(text: string, index: number): string | null { - return ( - /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(index))?.[0] ?? null - ) -} - -function matchTomlNumberToken(text: string, index: number): string | null { - const token = - /^[+-]?(?:\d(?:_?\d)*)(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?/.exec( - text.slice(index), - )?.[0] ?? null - if (!token) return null - const next = text[index + token.length] - // Dates (`1979-05-27`) and times (`07:32:00`) also start with digits. - if (next === '-' || next === ':') return null - return token -} - -function matchTomlCalendarDateToken(text: string, index: number): string | null { - return /^\d{4}-\d{2}-\d{2}/.exec(text.slice(index))?.[0] ?? null -} - -function assertTomlCalendarDateValid(token: string, label: string): void { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(token) - if (!match) return - const year = Number(match[1]) - const month = Number(match[2]) - const day = Number(match[3]) - // setUTCFullYear, not Date.UTC: years 0–99 must stay as-is instead of - // mapping onto 1900–1999. - const date = new Date(0) - date.setUTCFullYear(year, month - 1, day) - if ( - date.getUTCFullYear() !== year || - date.getUTCMonth() !== month - 1 || - date.getUTCDate() !== day - ) { - throw new Error( - `Invalid calendar date ${token} in ${label}: smol-toml would normalize it on rewrite`, - ) - } -} - -/** - * A number token is safe to rewrite only when its significant decimal form - * matches JS's shortest round-trip of the IEEE-754 value. Extra digits - * (`0.12345678901234567`), over-long integer-mantissa exponents, and - * underscore-decorated TOML floats that stringify to a different spelling - * would otherwise be silently shortened. - */ -function assertNumberTokenRoundTrips(token: string, label: string): void { - const normalized = token.replace(/_/g, '') - const parsed = Number(normalized) - if (!Number.isFinite(parsed)) { - throw new Error( - `Unsafe number ${token} in ${label}: not representable as a finite number`, - ) - } - if (Number.isInteger(parsed) && Math.abs(parsed) > Number.MAX_SAFE_INTEGER) { - throw new Error( - `Unsafe number ${token} in ${label}: exceeds ` + - 'Number.MAX_SAFE_INTEGER and would lose precision', - ) - } - const original = significantDecimal(normalized) - const shortest = significantDecimal(String(parsed)) - if (original.digits !== shortest.digits || original.exp !== shortest.exp) { - throw new Error( - `Unsafe number ${token} in ${label}: cannot round-trip as a JavaScript number`, - ) - } -} - -function significantDecimal(token: string): { digits: string; exp: number } { - let text = token - let sign = '' - if (text.startsWith('+')) text = text.slice(1) - if (text.startsWith('-')) { - sign = '-' - text = text.slice(1) - } - let exp = 0 - const exponentIndex = text.search(/[eE]/) - if (exponentIndex !== -1) { - exp = Number(text.slice(exponentIndex + 1)) - text = text.slice(0, exponentIndex) - } - const dot = text.indexOf('.') - if (dot !== -1) { - exp -= text.length - dot - 1 - text = `${text.slice(0, dot)}${text.slice(dot + 1)}` - } - text = text.replace(/^0+/, '') || '0' - if (text !== '0') { - const trailing = /0+$/.exec(text) - if (trailing) { - exp += trailing[0].length - text = text.slice(0, text.length - trailing[0].length) - } - } else { - exp = 0 - } - return { digits: `${sign}${text}`, exp } -} - -/** - * Existing-path-only set: every segment of the path must already exist, - * mirroring the agent write contract (no invented keys). String segments - * must be own properties — an inherited lookup (`__proto__`, `constructor`) - * would otherwise pass the existence check and let an assignment mutate - * `Object.prototype` inside the Electron main process. - */ -function setJsonPathValue( - document: Record, - jsonPath: readonly (string | number)[], - value: unknown, - label: string, - resolveDisplayKeys = false, -): void { - if (jsonPath.some((segment) => isForbiddenJsonPathSegment(segment))) { - throw new Error( - `Parameter path ${JSON.stringify(jsonPath)} is not allowed in ${label}.`, - ) - } - const resolvedPath = resolveDisplayKeys - ? resolveExistingJsonPath(document, jsonPath) - : jsonPath - let node: unknown = document - for (const segment of resolvedPath.slice(0, -1)) { - node = readOwnJsonPathSegment(node, segment) - } - const last = resolvedPath[resolvedPath.length - 1] - const existing = last === undefined ? undefined : readOwnJsonPathSegment(node, last) - assignOwnJsonPathValue( - document, - resolvedPath, - preserveTomlNumericKind(existing, value), - () => { - throw new Error( - `Parameter path ${JSON.stringify(jsonPath)} does not exist in ${label}.`, - ) - }, - ) -} - -/** - * JSON workspaces store display keys (`Target density`). Agent/rerun - * contracts may spell the canonical leaf (`target_density`). Match existing - * own keys by the ecc mechanical rule; when a long key and its canonical - * duplicate both exist, the long key wins. - */ -function resolveExistingJsonPath( - document: Record, - jsonPath: readonly (string | number)[], -): (string | number)[] { - let node: unknown = document - const resolved: (string | number)[] = [] - for (const segment of jsonPath) { - const actual = resolveExistingJsonPathSegment(node, segment) - if (actual === undefined) return [...jsonPath] - resolved.push(actual) - node = readOwnJsonPathSegment(node, actual) - } - return resolved -} - -function resolveExistingJsonPathSegment( - node: unknown, - segment: string | number, -): string | number | undefined { - if (typeof segment === 'number') { - return Array.isArray(node) && segment < node.length ? segment : undefined - } - if (!isRecord(node)) return undefined - const canonical = normalizeParameterKey(segment) - const matches: string[] = [] - for (const key of Object.keys(node)) { - if ( - Object.prototype.hasOwnProperty.call(node, key) && - normalizeParameterKey(key) === canonical - ) { - matches.push(key) - } - } - if (matches.length === 0) return undefined - if (matches.length === 1) return matches[0] - return matches.find((key) => key !== canonical) ?? matches[0] -} - -/** - * Apply existing-path-only edits to the workspace configuration that - * actually exists on disk (`home/params.toml` preferred, `home/parameters.json` - * fallback). Edit paths are interpreted in the on-disk file's vocabulary: - * display keys for JSON, and for TOML every string segment is canonicalized - * through the ecc mechanical rule, so an agent emitting display-key paths - * keeps working on both formats. - * - * When `authorizedLocation` is provided (a path already authorized and - * canonicalized by the caller's path scope), the operation uses exactly - * that file instead of re-locating it, closing the locate→authorize→read - * symlink swap window. - */ -export async function editWorkspaceParameters( - root: string, - edits: readonly WorkspaceParameterEdit[], - authorizedLocation?: WorkspaceParametersFileLocation, - assertWritable?: () => Promise, -): Promise { - const location = authorizedLocation ?? (await locateWorkspaceParametersFile(root)) - if (!location) { - throw new Error( - `Workspace parameters file not found: ${join(root, 'home', WORKSPACE_CONFIG_BASENAME)} or ${join(root, 'home', JSON_PARAMETERS_BASENAME)}`, - ) - } - return await enqueueParameterWrite( - await workspaceParameterWriteQueueKey(root, authorizedLocation), - async () => { - const committed = await commitWorkspaceParameterEdits( - root, - edits, - authorizedLocation, - assertWritable, - ) - return { format: committed.format, path: committed.path } - }, - ) -} - -export interface PreparedWorkspaceTextWrite { - canonicalPath: string - previousContent: string - spelledPath: string - writtenContent: string -} - -/** - * Read-modify in memory. The caller must already hold the parameter write - * queue; this does not enqueue or rename. - */ -export async function prepareWorkspaceParameterEdits( - root: string, - edits: readonly WorkspaceParameterEdit[], - authorizedLocation?: WorkspaceParametersFileLocation, - assertWritable?: () => Promise, -): Promise { - const location = authorizedLocation ?? (await locateWorkspaceParametersFile(root)) - if (!location) { - throw new Error( - `Workspace parameters file not found: ${join(root, 'home', WORKSPACE_CONFIG_BASENAME)} or ${join(root, 'home', JSON_PARAMETERS_BASENAME)}`, - ) - } - await assertWritable?.() - for (const edit of edits) { - if (edit.value === undefined) { - throw new Error( - `Refusing to write ${location.path}: undefined would delete the parameter leaf`, - ) - } - assertFiniteNumbers(edit.value, location.path) - } - const onDisk = await locateWorkspaceParametersFile(root) - if (!onDisk) { - throw new Error( - `Workspace parameters file not found: ${join(root, 'home', WORKSPACE_CONFIG_BASENAME)} or ${join(root, 'home', JSON_PARAMETERS_BASENAME)}`, - ) - } - const spelledPath = onDisk.path - const canonicalPath = authorizedLocation - ? authorizedLocation.path - : await realpath(onDisk.path) - const raw = await readWorkspaceConfigContained(spelledPath, canonicalPath) - if (onDisk.format === 'json') { - const parsed: unknown = parseJsonPreservingIntegers(raw, onDisk.path) - if (!isRecord(parsed)) { - throw new Error( - `Invalid workspace configuration: ${onDisk.path} must contain a JSON object`, - ) - } - const document = parsed - assertGuiKnownTomlLeavesLossless( - normalizeParameterKeys(document) as Record, - onDisk.path, - ) - for (const edit of edits) { - setJsonPathValue(document, edit.json_path, edit.value, onDisk.path, true) - } - return { - canonicalPath, - format: onDisk.format, - previousContent: raw, - spelledPath, - writtenContent: serializeJsonDocument(document, raw), - } - } - assertNoSubMillisecondDatetimes(raw, onDisk.path) - const document = parseTomlDocument(raw, onDisk.path) - const parameters = reviveSafeTomlIntegers(mergeTomlSections(document, root)) as Record< - string, - unknown - > - assertGuiKnownTomlLeavesLossless(parameters, onDisk.path) - for (const edit of edits) { - const normalizedPath = edit.json_path.map((segment) => - typeof segment === 'string' ? normalizeParameterKey(segment) : segment, - ) - setJsonPathValue(parameters, normalizedPath, edit.value, onDisk.path) - } - const merged = mergePayloadIntoTomlDocument(document, parameters, root) - return { - canonicalPath, - format: onDisk.format, - previousContent: raw, - spelledPath, - writtenContent: stringifyTomlDocument(merged), - } -} - -export async function commitWorkspaceParameterEdits( - root: string, - edits: readonly WorkspaceParameterEdit[], - authorizedLocation?: WorkspaceParametersFileLocation, - assertWritable?: () => Promise, -): Promise { - const prepared = await prepareWorkspaceParameterEdits( - root, - edits, - authorizedLocation, - assertWritable, - ) - await assertWritable?.() - await writeTextAtomically(prepared.spelledPath, prepared.writtenContent, { - authorizedParent: dirname(prepared.canonicalPath), - }) - return { - canonicalPath: prepared.canonicalPath, - format: prepared.format, - path: prepared.spelledPath, - previousContent: prepared.previousContent, - writtenContent: prepared.writtenContent, - } -} - -function applyErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -/** - * Apply parameter-surface edits and step-config writes in one queued - * operation. Later writes that fail roll back earlier files only when those - * files still hold the revision this operation produced, so a Configure save - * that landed in between is left intact. Rollback failures are surfaced. - * - * The caller must already have authorized every path; this function does not - * follow symlinks on the spelled leaves. - */ -export async function applyQueuedWorkspaceParameterWrites( - root: string, - parameterEdits: readonly WorkspaceParameterEdit[], - stepConfigWrites: readonly PreparedStepConfigWrite[], - authorizedLocation?: WorkspaceParametersFileLocation, - assertWritable?: () => Promise, -): Promise { - if (parameterEdits.length === 0 && stepConfigWrites.length === 0) return - return await enqueueParameterWrite( - await workspaceParameterWriteQueueKey(root, authorizedLocation), - async () => { - await assertWritable?.() - const restorations: Array<{ - canonicalPath: string - expectedCurrent: string - previous: string - spelledPath: string - }> = [] - try { - if (parameterEdits.length > 0) { - const committed = await commitWorkspaceParameterEdits( - root, - parameterEdits, - authorizedLocation, - assertWritable, - ) - restorations.push({ - canonicalPath: committed.canonicalPath, - expectedCurrent: committed.writtenContent, - previous: committed.previousContent, - spelledPath: committed.path, - }) - } - for (const step of stepConfigWrites) { - await assertWritable?.() - const { document, raw } = await readJsonObjectContained( - step.spelledPath, - step.canonicalPath, - step.spelledPath, - ) - if (!raw.trim()) { - throw new Error(`${step.spelledPath} is missing or empty in this workspace.`) - } - for (const edit of step.edits) { - if (edit.value === undefined) { - throw new Error( - `Refusing to write ${step.spelledPath}: undefined would delete the parameter leaf`, - ) - } - assertFiniteNumbers(edit.value, step.spelledPath) - setJsonPathValue(document, edit.json_path, edit.value, step.spelledPath) - } - const writtenContent = serializeJsonDocument(document, raw) - // Guard again immediately before the rename: a flow that started - // during the read/parse must still block the step-config commit. - await assertWritable?.() - await writeTextAtomically(step.spelledPath, writtenContent, { - authorizedParent: dirname(step.canonicalPath), - }) - restorations.push({ - canonicalPath: step.canonicalPath, - expectedCurrent: writtenContent, - previous: raw, - spelledPath: step.spelledPath, - }) - } - } catch (error) { - const restoreErrors: unknown[] = [] - for (let index = restorations.length - 1; index >= 0; index -= 1) { - const restoration = restorations[index]! - try { - await restoreTextIfCurrentRevision( - restoration.spelledPath, - restoration.canonicalPath, - restoration.expectedCurrent, - restoration.previous, - assertWritable, - ) - } catch (restoreError) { - restoreErrors.push(restoreError) - } - } - if (restoreErrors.length > 0) { - throw new Error( - `${applyErrorMessage(error)}; rollback failed for ` + - `${restoreErrors.length} file(s): ` + - restoreErrors.map(applyErrorMessage).join('; '), - { cause: error }, - ) - } - throw error - } - }, - ) -} diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspacePdkBindings.test.ts b/ecos/gui/apps/desktop-electron/electron/services/workspacePdkBindings.test.ts new file mode 100644 index 000000000..44535524a --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspacePdkBindings.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import { + prepareWorkspaceCreateBinding, + prepareWorkspaceOpenBinding, +} from './workspacePdkBindings' + +function createDependencies( + requirement: Record = { + familyId: 'ics55', + version: '1.0.0', + mode: 'manual', + files: [ + { fileId: 'tech', role: 'tech', reference: 'tech.lef' }, + { fileId: 'lef', role: 'lef', reference: 'cells.lef' }, + { fileId: 'lib', role: 'liberty', reference: 'typ.lib' }, + ], + mpc: { + resourceId: 'mpc:frame', + version: '2.0.0', + designId: 'gcd', + sourceHash: 'hash', + }, + }, +) { + const callRuntime = vi.fn().mockResolvedValueOnce(requirement).mockResolvedValueOnce({ + projectId: 'proj_demo', + projectRoot: '/projects/demo', + }) + return { + callRuntime, + dependencies: { + eccRuntimeService: { callRuntime }, + pdkInventoryService: { + bindInstallation: vi.fn(), + resolveBinding: vi.fn(), + validateWorkspace: vi + .fn() + .mockResolvedValue({ root: '/pdks/ics55', version: '1.0.0' }), + }, + projectManagementReadService: { + readManifest: vi.fn(), + }, + resourceManagerService: { + getResource: vi.fn().mockResolvedValue({ + installed_version: '2.0.0', + status: 'installed', + }), + readMpcSpec: vi.fn().mockResolvedValue({ + resource_id: 'mpc:frame', + installed_version: '2.0.0', + spec_path: '/mpcs/frame/2.0.0/spec/spec.json.in', + spec: { + designs: [ + { + design_name: 'gcd', + core_template: { name: 'frame', minimum_area: 100 }, + }, + ], + }, + }), + }, + }, + } +} + +describe('prepareWorkspaceOpenBinding', () => { + it('resolves portable PDK and MPC requirements on the current machine', async () => { + const { dependencies } = createDependencies() + + await expect( + prepareWorkspaceOpenBinding(dependencies, '/projects/demo/runs/workspace'), + ).resolves.toEqual({ + directory: '/projects/demo/runs/workspace', + workspaceBindings: { + inputs: {}, + pdk: { + root: '/pdks/ics55', + version: '1.0.0', + files: { + tech: '/pdks/ics55/tech.lef', + lef: '/pdks/ics55/cells.lef', + lib: '/pdks/ics55/typ.lib', + }, + }, + mpc: { template: { name: 'frame', minimum_area: 100 } }, + }, + }) + expect(dependencies.pdkInventoryService.validateWorkspace).toHaveBeenCalledWith({ + projectId: 'proj_demo', + projectRoot: '/projects/demo', + requirement: expect.objectContaining({ familyId: 'ics55', version: null }), + }) + }) + + it('does not match ECC stdcell version against the inventory package version', async () => { + const { dependencies } = createDependencies({ + familyId: 'ics55', + version: 'V1p10C100', + mode: 'default', + }) + dependencies.pdkInventoryService.validateWorkspace.mockResolvedValue({ + root: '/pdks/ics55/1.10.102', + version: '1.10.102', + }) + + await expect( + prepareWorkspaceOpenBinding(dependencies, '/projects/demo/runs/workspace'), + ).resolves.toEqual({ + directory: '/projects/demo/runs/workspace', + workspaceBindings: { + inputs: {}, + pdk: { + root: '/pdks/ics55/1.10.102', + version: '1.10.102', + }, + }, + }) + expect(dependencies.pdkInventoryService.validateWorkspace).toHaveBeenCalledWith({ + projectId: 'proj_demo', + projectRoot: '/projects/demo', + requirement: { familyId: 'ics55', version: null, manualConfig: null }, + }) + }) + + it('keeps inspection available when local bindings are unavailable', async () => { + const { dependencies } = createDependencies() + dependencies.pdkInventoryService.validateWorkspace.mockRejectedValue( + new Error('not installed'), + ) + + await expect( + prepareWorkspaceOpenBinding(dependencies, '/projects/demo/runs/workspace'), + ).resolves.toEqual({ directory: '/projects/demo/runs/workspace' }) + }) +}) + +describe('prepareWorkspaceCreateBinding', () => { + it('reuses the persisted Project PDK requirement when the request omits it', async () => { + const { dependencies } = createDependencies() + const requirement = { familyId: 'ics55', manualConfig: null, version: null } + dependencies.projectManagementReadService!.readManifest = vi.fn().mockResolvedValue({ + base_design: { pdk_requirement: requirement }, + }) + dependencies.pdkInventoryService.resolveBinding.mockResolvedValue({}) + + await prepareWorkspaceCreateBinding(dependencies, { + commandId: 'create-1', + projectId: 'proj_demo', + projectRoot: '/projects/demo', + targetDirectory: '/projects/demo/runs/workspace', + workspaceBindings: { inputs: {}, pdk: { root: '/renderer/pdk' } }, + workspaceSpec: { pdk: { familyId: 'ics55', mode: 'default' } }, + }) + + expect(dependencies.projectManagementReadService.readManifest).toHaveBeenCalledWith( + '/projects/demo', + ) + expect(dependencies.pdkInventoryService.resolveBinding).toHaveBeenCalledWith({ + projectId: 'proj_demo', + projectRoot: '/projects/demo', + requirement, + }) + expect(dependencies.pdkInventoryService.validateWorkspace).toHaveBeenCalledWith({ + projectId: 'proj_demo', + projectRoot: '/projects/demo', + requirement, + }) + }) + + it('resolves the portable MPC requirement instead of trusting presentation placeholders', async () => { + const { dependencies } = createDependencies() + dependencies.pdkInventoryService.resolveBinding.mockResolvedValue({}) + + const result = await prepareWorkspaceCreateBinding(dependencies, { + commandId: 'create-1', + projectId: 'proj_demo', + projectRoot: '/projects/demo', + targetDirectory: '/projects/demo/runs/workspace', + pdkRequirement: { familyId: 'ics55', manualConfig: null, version: '1.0.0' }, + workspaceBindings: { + inputs: {}, + mpc: { template: {} }, + pdk: { root: '/renderer/pdk' }, + }, + workspaceSpec: { + mpc: { resourceId: 'mpc:frame', version: '2.0.0', designId: 'gcd' }, + pdk: { familyId: 'ics55', mode: 'default' }, + }, + }) + + expect(result.workspaceBindings.mpc).toEqual({ + template: { name: 'frame', minimum_area: 100 }, + }) + }) + + it('rejects creation when the portable MPC requirement is not installed', async () => { + const { dependencies } = createDependencies() + dependencies.pdkInventoryService.resolveBinding.mockResolvedValue({}) + dependencies.resourceManagerService.getResource.mockResolvedValue({ + installed_version: null, + status: 'available', + }) + + await expect( + prepareWorkspaceCreateBinding(dependencies, { + commandId: 'create-1', + projectId: 'proj_demo', + projectRoot: '/projects/demo', + targetDirectory: '/projects/demo/runs/workspace', + pdkRequirement: { familyId: 'ics55', manualConfig: null, version: '1.0.0' }, + workspaceBindings: { inputs: {}, pdk: { root: '/renderer/pdk' } }, + workspaceSpec: { + mpc: { resourceId: 'mpc:frame', version: '2.0.0', designId: 'gcd' }, + pdk: { familyId: 'ics55', mode: 'default' }, + }, + }), + ).rejects.toThrow('Project MPC Requirement is unbound') + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspacePdkBindings.ts b/ecos/gui/apps/desktop-electron/electron/services/workspacePdkBindings.ts new file mode 100644 index 000000000..effdf6860 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspacePdkBindings.ts @@ -0,0 +1,255 @@ +import { resolve } from 'node:path' +import { + type EccWorkspaceCreateRequest, + type EccWorkspaceOpenRequest, + type MpcSpecReadResult, + type PdkBindRequest, + type PdkBinding, + type PdkInstallationSnapshot, + type PdkRequirement, + type PdkResolveBindingRequest, + type PdkWorkspaceValidationRequest, + type ProjectManifest, + validateMpcSpec, +} from '@ecos-studio/shared' + +export interface WorkspacePdkBindingDependencies { + pdkInventoryService: { + bindInstallation(request: PdkBindRequest): Promise + resolveBinding(request: PdkResolveBindingRequest): Promise + validateWorkspace( + request: PdkWorkspaceValidationRequest, + ): Promise + } + projectManagementReadService?: { + readManifest(projectRoot: string): Promise + } + eccRuntimeService?: { + callRuntime?(method: string, params: Record): Promise + } + resourceManagerService?: { + getResource(resourceId: string): Promise + readMpcSpec(resourceId: string): Promise + } +} + +export async function prepareWorkspaceCreateBinding( + dependencies: WorkspacePdkBindingDependencies, + request: EccWorkspaceCreateRequest, +): Promise { + const projectRoot = request.projectRoot ?? '' + const persistedRequirement = + !request.pdkRequirement && projectRoot && dependencies.projectManagementReadService + ? (await dependencies.projectManagementReadService.readManifest(projectRoot)) + ?.base_design.pdk_requirement + : undefined + const requirement = request.pdkRequirement ?? persistedRequirement + if (!requirement) { + throw new Error('PDK Requirement is required for backend workspace creation') + } + + const projectId = request.projectId ?? '' + const binding = await dependencies.pdkInventoryService.resolveBinding({ + projectId, + projectRoot, + requirement, + }) + if (!binding) { + if (!request.pdkInstallationId) { + throw new Error('Project PDK Requirement is unbound') + } + await dependencies.pdkInventoryService.bindInstallation({ + installationId: request.pdkInstallationId, + requirement, + projectId, + projectRoot, + }) + } + const installation = await dependencies.pdkInventoryService.validateWorkspace({ + projectId, + projectRoot, + requirement, + }) + const requestedMpc = request.workspaceSpec.mpc + const mpcBinding = + requestedMpc === undefined + ? null + : await resolveMpcBinding(dependencies, requestedMpc) + if (requestedMpc !== undefined && !mpcBinding) { + throw new Error('Project MPC Requirement is unbound') + } + const { + pdkInstallationId: _pdkInstallationId, + pdkRequirement: _pdkRequirement, + ...runtimeRequest + } = request + const { mpc: _mpc, ...workspaceBindings } = request.workspaceBindings + const specPdk = isRecord(request.workspaceSpec.pdk) ? request.workspaceSpec.pdk : {} + const bindingPdk = isRecord(request.workspaceBindings.pdk) + ? request.workspaceBindings.pdk + : {} + return { + ...runtimeRequest, + workspaceBindings: { + ...workspaceBindings, + pdk: { + ...bindingPdk, + root: installation.root, + ...(requirement.version ? { version: requirement.version } : {}), + ...manualPdkFiles(specPdk, requirement, installation.root), + }, + ...(mpcBinding ? { mpc: mpcBinding } : {}), + }, + workspaceSpec: { + ...request.workspaceSpec, + pdk: { + ...specPdk, + familyId: requirement.familyId, + ...(requirement.version ? { version: requirement.version } : {}), + }, + }, + } +} + +export async function prepareWorkspaceOpenBinding( + dependencies: WorkspacePdkBindingDependencies, + directory: string, +): Promise { + const bindingRequirement = await dependencies.eccRuntimeService + ?.callRuntime?.>('workspace.binding_requirement', { + directory, + }) + .catch(() => null) + const pdk = bindingRequirement + if (!pdk || typeof pdk.familyId !== 'string') return { directory } + + const project = await dependencies.eccRuntimeService + ?.callRuntime?.<{ projectId: string; projectRoot: string } | null>( + 'project.discover', + { directory }, + ) + .catch(() => null) + if (!project) return { directory } + const { projectId, projectRoot } = project + // ECC pdk.version is the stdcell name, not the inventory package version. + const pdkRequirement = { + familyId: pdk.familyId, + version: null, + manualConfig: manualPdkConfig(pdk), + } + try { + const installation = await dependencies.pdkInventoryService.validateWorkspace({ + projectId, + projectRoot, + requirement: pdkRequirement, + }) + const mpcBinding = await resolveMpcBinding(dependencies, bindingRequirement?.mpc) + return { + directory, + workspaceBindings: { + inputs: {}, + pdk: { + root: installation.root, + ...(installation.version ? { version: installation.version } : {}), + ...manualPdkFiles(pdk, pdkRequirement, installation.root), + }, + ...(mpcBinding ? { mpc: mpcBinding } : {}), + }, + } + } catch { + return { directory } + } +} + +function manualPdkConfig(pdk: Record): PdkRequirement['manualConfig'] { + const files = pdk.files + if (pdk.mode !== 'manual' || !Array.isArray(files)) return null + const byRole = (role: string) => + files.flatMap((value) => { + if ( + !isRecord(value) || + value.role !== role || + typeof value.reference !== 'string' + ) { + return [] + } + return [value.reference] + }) + const tech = byRole('tech')[0] + const cellLefs = byRole('lef') + const liberty = byRole('liberty') + return tech && cellLefs.length && liberty.length + ? { techLef: tech, cellLefs, liberty } + : null +} + +async function resolveMpcBinding( + dependencies: WorkspacePdkBindingDependencies, + value: unknown, +): Promise<{ template: Record } | null> { + if (!isRecord(value) || !dependencies.resourceManagerService) return null + const resourceId = value.resourceId + const version = value.version + const designId = value.designId + if ( + typeof resourceId !== 'string' || + typeof version !== 'string' || + typeof designId !== 'string' + ) { + return null + } + try { + const resource = await dependencies.resourceManagerService.getResource(resourceId) + if ( + !isRecord(resource) || + resource.installed_version !== version || + (resource.status !== 'installed' && resource.status !== 'update_available') + ) { + return null + } + const result = await dependencies.resourceManagerService.readMpcSpec(resourceId) + const spec = validateMpcSpec(result.spec) + const design = spec.designs.find( + (candidate) => candidate.design.design_name === designId, + ) + return design ? { template: design.coreTemplate } : null + } catch { + return null + } +} + +function manualPdkFiles( + pdk: Record, + requirement: PdkRequirement, + root: string, +): { files?: Record } { + if (pdk.mode !== 'manual' || !Array.isArray(pdk.files) || !requirement.manualConfig) { + return {} + } + const files: Record = {} + let lefIndex = 0 + let libertyIndex = 0 + for (const value of pdk.files) { + if (!isRecord(value) || typeof value.fileId !== 'string') continue + if (value.role === 'tech') { + files[value.fileId] = resolve(root, requirement.manualConfig.techLef) + } + if (value.role === 'lef') { + files[value.fileId] = resolve( + root, + requirement.manualConfig.cellLefs[lefIndex++] ?? '', + ) + } + if (value.role === 'liberty') { + files[value.fileId] = resolve( + root, + requirement.manualConfig.liberty[libertyIndex++] ?? '', + ) + } + } + return Object.keys(files).length ? { files } : {} +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts new file mode 100644 index 000000000..9d41c94e0 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts @@ -0,0 +1,304 @@ +import { + validateEngineeringSnapshot, + type EccEngineeringSnapshot, + type EccQorSnapshotExtension, + type ProjectManifest, +} from '@ecos-studio/shared' +import { describe, expect, it } from 'vitest' +import { buildProjectQorTrendSummary } from './qorAnalysis' +import { analyzeWorkspaceQor, projectQorInputForWorkspace } from './workspaceQorAnalysis' + +function metricText(value: number): string { + return JSON.stringify({ + details: [], + integrity: { + invalid_detail_ids: [], + invalid_metric_source_ids: [], + status: 'pass', + }, + metrics: [ + { + analysis_group: 'route_quality', + category: 'routability_physical', + confidence: 'high', + corner: null, + corner_context: null, + direction: 'lower_is_better', + display_name: 'Route Wirelength', + id: 'route_wirelength', + project_role: 'final', + rating: { gate: false, score: true, trend: true }, + scope: 'route', + source: { + kind: 'feature', + path: 'feature/Route.step.json', + selector: '/metrics/route_wirelength', + }, + step_role: 'primary', + unit: 'um', + value, + }, + ], + schema_version: 3, + step: 'Route', + }) +} + +function qorSnapshotExtension(): EccQorSnapshotExtension { + return { + schemaVersion: 1, + scoringEngine: 'qor-v3', + status: 'available', + score: 73.5, + scalarStatus: 'GREEN', + profile: 'balanced', + qphys: { + timing: { value: 73.5, state: 'PASS', featureIds: ['timing.setup'] }, + }, + feasibility: { status: 'PASS', gates: [] }, + evidence: { + index: 90, + state: 'HIGH', + integrity: 1, + coverage: 1, + consistency: 1, + }, + diagnoses: [], + inflation: { + iPlace: null, + iRoute: null, + iTotal: null, + congestionSeverity: null, + compatibilityStatus: 'UNAVAILABLE', + }, + power: { totalUw: null, budgetUw: null, sourceKind: null, corner: null }, + artifactIds: [], + } +} + +function engineeringSnapshot(metricValue: number): EccEngineeringSnapshot { + const metric = JSON.parse(metricText(metricValue)).metrics[0] + return { + analysis: { steps: [] }, + artifacts: [], + checklist: {}, + flow: { steps: [{ name: 'Route', state: 'Success' }] }, + metrics: [metric], + parameters: {}, + qorAssessment: { + status: 'ready', + areaScoringStep: 'Route', + dimensionScores: { routability_physical: 73.5 }, + metrics: [metric], + score: { gate: 'pass', threshold: 60, value: 73.5 }, + steps: [ + { + name: 'Route', + order: 6, + status: 'pass', + stepId: 'Route', + summaryMetricCount: 1, + }, + ], + }, + schemaVersion: 1, + signoffAssessment: { groups: [], risks: [], status: 'ready' }, + workspaceId: 'ecc-workspace', + workspaceRevision: 1, + } +} + +function workspace(id: string, name: string) { + return { + branch_from: null, + created_at: '2026-08-30T00:00:00.000Z', + end_step: 'Harden', + metrics_summary: {}, + name, + parameter_patch: {}, + source_workspace_id: null, + start_step: 'Synth', + status: 'success' as const, + step_metrics: {}, + updated_at: '2026-08-30T00:00:00.000Z', + workspace_id: id, + workspace_path: `/project/${id}`, + } +} + +describe('analyzeWorkspaceQor', () => { + it('does not use non-archived Manifest status as committed Flow state', () => { + const snapshot = engineeringSnapshot(5000) + snapshot.flow = { steps: [] } + const failedWorkspace = { + ...workspace('current', 'Current'), + status: 'failed' as const, + } + const manifest = { + project_id: 'project-1', + name: 'demo', + design_name: 'gcd', + workspaces: [failedWorkspace], + qor_baseline: null, + } as ProjectManifest + + expect(projectQorInputForWorkspace(manifest, 'current', snapshot)?.status).toBe( + 'not_started', + ) + }) + + it('builds current QoR and the selected baseline comparison', () => { + const manifest = { + project_id: 'project-1', + name: 'demo', + design_name: 'gcd', + workspaces: [workspace('baseline', 'Baseline'), workspace('current', 'Current')], + qor_baseline: { reason: 'selected', workspace_id: 'baseline' }, + } as ProjectManifest + + const result = analyzeWorkspaceQor(manifest, 'current', { + baseline: engineeringSnapshot(5200), + current: engineeringSnapshot(5000), + }) + + expect(result.qor).toMatchObject({ + data: { + metrics: [{ id: 'route_wirelength', value: 5000 }], + score: { value: 73.5 }, + }, + status: 'ready', + }) + expect(result.baselineComparison).toMatchObject({ + data: { + baselineWorkspaceId: 'baseline', + deltas: [ + { + baselineValue: 5200, + currentValue: 5000, + metricId: 'route_wirelength', + verdict: 'improvement', + }, + ], + status: 'comparable', + }, + status: 'ready', + }) + + const projectInput = projectQorInputForWorkspace( + manifest, + 'current', + engineeringSnapshot(5000), + ) + expect(buildProjectQorTrendSummary([projectInput!]).workspaces[0]).toMatchObject({ + areaScoringStep: 'Route', + dimensionScores: { routability_physical: 73.5 }, + overallScore: result.qor.status === 'ready' ? result.qor.data.score.value : null, + scoreThreshold: 60, + }) + }) + + it('passes committed QoR Snapshot facts through the Backend projection', () => { + const manifest = { + project_id: 'project-1', + name: 'demo', + design_name: 'gcd', + workspaces: [workspace('current', 'Current')], + qor_baseline: null, + } as ProjectManifest + const snapshot = engineeringSnapshot(5000) + const extension = qorSnapshotExtension() + snapshot.qorSnapshotExtension = extension + + const input = projectQorInputForWorkspace(manifest, 'current', snapshot) + const summary = buildProjectQorTrendSummary([input!]).workspaces[0] + + expect(input?.qorSnapshotExtension).toBe(extension) + expect(summary?.qorSnapshotExtension).toBe(extension) + }) + + it('validates and projects a schema v3 Snapshot produced by ECC', () => { + const snapshot = engineeringSnapshot(5000) + snapshot.schemaVersion = 3 + snapshot.qorSnapshotExtension = qorSnapshotExtension() + + const validated = validateEngineeringSnapshot(snapshot) + + expect(validated.ok).toBe(true) + expect(validated.ok && validated.sections.qorSnapshotExtension).toMatchObject({ + status: 'ready', + data: { + schemaVersion: 1, + scoringEngine: 'qor-v3', + score: 73.5, + }, + }) + const manifest = { + project_id: 'project-1', + name: 'demo', + design_name: 'gcd', + workspaces: [workspace('current', 'Current')], + qor_baseline: null, + } as ProjectManifest + expect( + projectQorInputForWorkspace(manifest, 'current', snapshot)?.qorSnapshotExtension, + ).toEqual(snapshot.qorSnapshotExtension) + }) + + it('restores step metrics directly from an authoritative snapshot', () => { + const metric = JSON.parse(metricText(5000)).metrics[0] + const snapshot: EccEngineeringSnapshot = { + analysis: { steps: [] }, + artifacts: [], + checklist: {}, + flow: { steps: [{ name: 'CustomSignoff', state: 'Success' }] }, + metrics: [metric], + parameters: {}, + qorAssessment: { + status: 'ready', + areaScoringStep: null, + dimensionScores: { routability_physical: 73.5 }, + metrics: [metric], + score: { gate: 'pass', threshold: 60, value: 73.5 }, + steps: [ + { + name: 'CustomSignoff', + order: 6, + status: 'pass', + stepId: 'CustomSignoff', + summaryMetricCount: 1, + }, + ], + }, + schemaVersion: 1, + signoffAssessment: { groups: [], risks: [], status: 'ready' }, + workspaceId: 'ecc-current', + workspaceRevision: 1, + } + const manifest = { + project_id: 'project-1', + name: 'demo', + design_name: 'gcd', + workspaces: [workspace('current', 'Current')], + qor_baseline: null, + } as ProjectManifest + + const result = analyzeWorkspaceQor(manifest, 'current', { current: snapshot }) + + expect(result.qor).toMatchObject({ + data: { + metrics: [{ id: 'route_wirelength', stepId: 'CustomSignoff', value: 5000 }], + score: { value: 73.5 }, + }, + status: 'ready', + }) + expect( + result.qor.status === 'ready' + ? result.qor.data.steps.find((step) => step.stepId === 'CustomSignoff') + : null, + ).toMatchObject({ + status: 'pass', + stepId: 'CustomSignoff', + summaryMetricCount: 1, + }) + }) +}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts new file mode 100644 index 000000000..6243d8bfc --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts @@ -0,0 +1,485 @@ +import { + projectManagementWorkspaceStepAnalysisSpecs, + parseProjectManifestFlowStep, + type EccEngineeringSnapshot, + type EccQorSnapshotExtension, + type MetricComparison, + type MetricValue, + type ProjectManifest, + type ProjectManifestFlowStep, + type ReadSection, + type WorkspaceBaselineComparison, + type WorkspaceQorSummary, +} from '@ecos-studio/shared' +import { + normalizeQorMetricRecords, + type ProjectQorMetricRecord, + type ProjectQorWorkspaceInput, + type QorDimension, +} from './qorAnalysis' + +const FLOW_STEP_ALIASES: Record = { + synthesis: 'Synth', + synth: 'Synth', + floorplan: 'Floor', + floor: 'Floor', + lec: 'LEC', + place: 'Place', + placement: 'Place', + cts: 'CTS', + legalization: 'Legal', + legal: 'Legal', + 'timing optimization': 'Timing Opt', + timingoptimization: 'Timing Opt', + route: 'Route', + routing: 'Route', + drc: 'DRC', + lvs: 'LVS', + filler: 'Filler', + postroutelec: 'Post-route LEC', + rcx: 'RCX', + sta: 'STA', + harden: 'Harden', +} + +type ProjectStepStatus = NonNullable< + ProjectQorWorkspaceInput['stepStatuses'][ProjectManifestFlowStep] +> + +interface WorkspaceQorInput extends ProjectQorWorkspaceInput { + snapshotQor: WorkspaceQorSummary | null +} + +interface SnapshotQorProjection { + assessment: ProjectQorWorkspaceInput['authoritativeAssessment'] + qor: WorkspaceQorSummary | null + qorSnapshotExtension: EccQorSnapshotExtension | null +} + +export type WorkspaceEngineeringFacts = Pick< + EccEngineeringSnapshot, + 'analysis' | 'metrics' | 'qorAssessment' | 'qorSnapshotExtension' +> & + Partial> + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function flowStep(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return (FLOW_STEP_ALIASES[trimmed.toLowerCase()] ?? trimmed) || null +} + +function snapshotMetric(value: unknown, stepId: string): MetricValue | null { + const metric = record(value) + if (!metric) return null + const id = typeof metric.id === 'string' ? metric.id : '' + const name = + typeof metric.display_name === 'string' + ? metric.display_name + : typeof metric.name === 'string' + ? metric.name + : id + const number = metric.value + const polarity = metric.direction ?? metric.polarity + if ( + !id || + typeof number !== 'number' || + !Number.isFinite(number) || + !['higher_is_better', 'lower_is_better', 'target_range', 'trend_only'].includes( + String(polarity), + ) + ) { + return null + } + return { + id, + name, + stepId, + value: number, + ...(typeof metric.unit === 'string' && metric.unit ? { unit: metric.unit } : {}), + polarity: polarity as MetricValue['polarity'], + ...(typeof metric.corner === 'string' && metric.corner + ? { corner: metric.corner } + : {}), + ...(record(metric.corner_context) + ? { cornerContext: metric.corner_context as Record } + : {}), + } +} + +function snapshotQorProjection( + snapshot: WorkspaceEngineeringFacts | null | undefined, +): SnapshotQorProjection { + const empty: SnapshotQorProjection = { + assessment: null, + qor: null, + qorSnapshotExtension: snapshot?.qorSnapshotExtension ?? null, + } + if (!snapshot) return empty + const extension = snapshot.qorSnapshotExtension ?? null + const qor = record(snapshot.qorAssessment) + const score = record(qor?.score) + const gate = score?.gate + const value = score?.value + const threshold = score?.threshold + if ( + !['pass', 'blocked', 'incomplete', 'unavailable'].includes(String(gate)) || + !(value === null || (typeof value === 'number' && Number.isFinite(value))) || + typeof threshold !== 'number' || + !Number.isFinite(threshold) + ) { + return empty + } + const signoffStatus = snapshot.signoffAssessment?.status + const rawDimensions = record(qor?.dimensionScores) + const dimensionScores: Partial> = {} + for (const [dimension, dimensionScore] of Object.entries(rawDimensions ?? {})) { + if (!isQorDimension(dimension) || !finiteNumber(dimensionScore)) return empty + dimensionScores[dimension] = dimensionScore + } + const rawAreaStep = qor?.areaScoringStep + const areaScoringStep = + typeof rawAreaStep === 'string' ? parseProjectManifestFlowStep(rawAreaStep) : null + if (rawAreaStep !== undefined && rawAreaStep !== null && !areaScoringStep) return empty + const assessment = ['ready', 'attention', 'blocked'].includes(String(signoffStatus)) + ? { + gateStatus: gate as NonNullable< + ProjectQorWorkspaceInput['authoritativeAssessment'] + >['gateStatus'], + score: value as number | null, + scoreThreshold: threshold, + areaScoringStep, + dimensionScores, + signoffStatus: signoffStatus!, + } + : null + const rawMetrics = Array.isArray(qor?.metrics) ? qor.metrics : snapshot.metrics + if (!Array.isArray(rawMetrics) || !Array.isArray(qor?.steps)) { + return { assessment, qor: null, qorSnapshotExtension: extension } + } + + const metrics: MetricValue[] = [] + const steps: WorkspaceQorSummary['steps'] = [] + const seenSteps = new Set() + let offset = 0 + for (const rawStep of qor.steps) { + const stepRecord = record(rawStep) + const count = stepRecord?.summaryMetricCount + const order = stepRecord?.order + const stepId = flowStep(stepRecord?.stepId ?? stepRecord?.name) + const status = stepRecord?.status + if ( + !stepRecord || + !stepId || + seenSteps.has(stepId) || + !Number.isInteger(count) || + (count as number) < 0 || + !Number.isInteger(order) || + (order as number) < 0 || + !['pass', 'blocked', 'incomplete', 'unavailable'].includes(String(status)) + ) { + return { assessment, qor: null, qorSnapshotExtension: extension } + } + const nextOffset = offset + (count as number) + if (nextOffset > rawMetrics.length) { + return { assessment, qor: null, qorSnapshotExtension: extension } + } + const stepMetrics = rawMetrics + .slice(offset, nextOffset) + .map((metric) => snapshotMetric(metric, stepId)) + if (stepMetrics.some((metric) => metric === null)) { + return { assessment, qor: null, qorSnapshotExtension: extension } + } + seenSteps.add(stepId) + metrics.push(...(stepMetrics as MetricValue[])) + steps.push({ + stepId, + order: order as number, + name: typeof stepRecord.name === 'string' ? stepRecord.name : stepId, + metrics: stepMetrics as MetricValue[], + status: status as WorkspaceQorSummary['steps'][number]['status'], + summaryMetricCount: count as number, + }) + offset = nextOffset + } + if (offset !== rawMetrics.length) { + return { assessment, qor: null, qorSnapshotExtension: extension } + } + return { + assessment, + qorSnapshotExtension: extension, + qor: { + score: { + value: value as number | null, + gate: gate as WorkspaceQorSummary['score']['gate'], + threshold, + }, + metrics, + steps, + ...(extension ? { qorSnapshotExtension: extension } : {}), + }, + } +} + +function isQorDimension(value: string): value is QorDimension { + return [ + 'timing', + 'power_integrity', + 'routability_physical', + 'area_cost', + 'clock_robustness_dfm', + 'runtime', + ].includes(value) +} + +function finiteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function flowState(value: unknown): ProjectStepStatus | undefined { + if (typeof value !== 'string') return undefined + switch (value.trim().toLowerCase()) { + case 'success': + case 'succeeded': + case 'completed': + return 'success' + case 'warning': + return 'warning' + case 'reused': + return 'reused' + case 'skipped': + return 'skipped' + case 'ongoing': + case 'running': + return 'running' + case 'failed': + case 'invalid': + case 'incomplete': + return 'failed' + case 'pending': + case 'unstart': + case 'not_started': + return 'unstart' + default: + return undefined + } +} + +export function workspaceFlowStates(flow: unknown): Record { + const steps = record(flow)?.steps + if (!Array.isArray(steps)) return {} + return Object.fromEntries( + steps.flatMap((rawStep) => { + const stepRecord = record(rawStep) + const step = flowStep(stepRecord?.name ?? stepRecord?.stepId) + const state = flowState(stepRecord?.state ?? stepRecord?.status) + return step && state ? [[step, state]] : [] + }), + ) +} + +function workspaceStatus( + manifestStatus: ProjectQorWorkspaceInput['status'], + states: ProjectQorWorkspaceInput['stepStatuses'], +): ProjectQorWorkspaceInput['status'] { + if (manifestStatus === 'archived') return manifestStatus + const values = Object.values(states) + if (values.includes('failed')) return 'failed' + if (values.includes('running')) return 'running' + if (values.includes('unstart')) return 'in_progress' + if (values.includes('warning')) return 'warning' + if (values.some((state) => state === 'success' || state === 'reused')) { + return 'success' + } + return 'not_started' +} + +function snapshotComparisonMetrics( + snapshot: WorkspaceEngineeringFacts | null | undefined, + workspaceId: string, +): ProjectQorMetricRecord[] { + if (!snapshot) return [] + return snapshot.analysis.steps.flatMap((analysisStep) => { + const step = parseProjectManifestFlowStep(analysisStep.stepId) + const metrics = analysisStep.metrics.data?.metrics + if (analysisStep.metrics.status !== 'available' || !step || !Array.isArray(metrics)) { + return [] + } + return normalizeQorMetricRecords( + { step, workspaceId, workspaceKey: workspaceId }, + metrics, + ) + }) +} + +export function projectQorInputForWorkspace( + manifest: ProjectManifest, + workspaceId: string, + engineeringSnapshot?: WorkspaceEngineeringFacts | null, +): WorkspaceQorInput | null { + const workspace = manifest.workspaces.find( + (candidate) => candidate.workspace_id === workspaceId, + ) + if (!workspace) return null + const statuses = workspaceFlowStates(engineeringSnapshot?.flow) + const snapshot = snapshotQorProjection(engineeringSnapshot) + const analysisByStep = new Map( + (engineeringSnapshot?.analysis.steps ?? []).map((step) => [ + parseProjectManifestFlowStep(step.stepId) ?? step.stepId, + step, + ]), + ) + const analysisText = ( + file: { status: string; data: Record | null } | null | undefined, + ): string | null => + file?.status === 'available' && file.data ? JSON.stringify(file.data) : null + return { + branchFrom: workspace.branch_from, + createdAt: workspace.created_at, + staTimingIssuesText: analysisText( + engineeringSnapshot?.analysis.steps.find((step) => step.timingIssues) + ?.timingIssues ?? null, + ), + status: workspaceStatus(workspace.status, statuses), + authoritativeAssessment: snapshot.assessment, + normalizedMetrics: snapshotComparisonMetrics(engineeringSnapshot, workspaceId), + snapshotQor: snapshot.qor, + qorSnapshotExtension: snapshot.qorSnapshotExtension, + stepHotspotTexts: Object.fromEntries( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ + spec.step, + analysisText(analysisByStep.get(spec.step)?.hotspots), + ]), + ), + stepMetricTexts: Object.fromEntries( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ + spec.step, + analysisText(analysisByStep.get(spec.step)?.metrics), + ]), + ), + stepStatuses: statuses, + stepSummaryTexts: Object.fromEntries( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ + spec.step, + analysisText(analysisByStep.get(spec.step)?.summary), + ]), + ), + workspaceId, + workspaceName: workspace.name || workspaceId, + workspaceKey: workspaceId, + } +} + +function metricKey(metric: MetricValue): string { + return `${metric.stepId}:\0${metric.id}` +} + +function metricDelta( + current: MetricValue, + baseline: MetricValue, +): MetricComparison | null { + if (current.value === null || baseline.value === null) return null + const absoluteDelta = current.value - baseline.value + const directional = + current.polarity === baseline.polarity && + (current.polarity === 'higher_is_better' || current.polarity === 'lower_is_better') + const verdict = !directional + ? 'not-comparable' + : absoluteDelta === 0 + ? 'unchanged' + : (current.polarity === 'higher_is_better' && absoluteDelta > 0) || + (current.polarity === 'lower_is_better' && absoluteDelta < 0) + ? 'improvement' + : 'regression' + return { + metricId: current.id, + name: current.name, + stepId: current.stepId, + currentValue: current.value, + baselineValue: baseline.value, + absoluteDelta, + relativeDeltaPct: + baseline.value === 0 ? null : (absoluteDelta / Math.abs(baseline.value)) * 100, + ...(current.unit ? { unit: current.unit } : {}), + polarity: current.polarity, + verdict, + } +} + +export function analyzeWorkspaceQor( + manifest: ProjectManifest, + currentWorkspaceId: string, + snapshotsByWorkspaceId: Record, +): { + qor: ReadSection + baselineComparison: ReadSection +} { + const currentWorkspace = manifest.workspaces.find( + (workspace) => workspace.workspace_id === currentWorkspaceId, + ) + const current = snapshotQorProjection(snapshotsByWorkspaceId[currentWorkspaceId]) + const qor: ReadSection = current.qor + ? { status: 'ready', data: current.qor, issues: [] } + : { status: 'unavailable', issues: [{ code: 'WORKSPACE_QOR_UNAVAILABLE' }] } + if (!currentWorkspace) { + return { + qor, + baselineComparison: { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_BASELINE_UNAVAILABLE' }], + }, + } + } + + const baselineWorkspaceId = manifest.qor_baseline?.workspace_id + const baselineWorkspace = manifest.workspaces.find( + (workspace) => workspace.workspace_id === baselineWorkspaceId, + ) + const baseline = baselineWorkspaceId + ? snapshotQorProjection(snapshotsByWorkspaceId[baselineWorkspaceId]) + : null + if (!baselineWorkspaceId || !baselineWorkspace || !baseline?.qor) { + return { + qor, + baselineComparison: { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_BASELINE_UNAVAILABLE' }], + }, + } + } + + const baselineMetrics = new Map( + baseline.qor.metrics.map((metric) => [metricKey(metric), metric]), + ) + const deltas = + current.qor?.metrics.flatMap((metric) => { + const baselineMetric = baselineMetrics.get(metricKey(metric)) + if (!baselineMetric) return [] + const delta = metricDelta(metric, baselineMetric) + return delta ? [delta] : [] + }) ?? [] + return { + qor, + baselineComparison: { + status: 'ready', + data: { + baselineWorkspaceId, + baselineWorkspaceName: baselineWorkspace.name || baselineWorkspaceId, + baselineScore: baseline.qor.score, + deltas, + status: + currentWorkspaceId === baselineWorkspaceId + ? 'baseline' + : deltas.some((delta) => delta.verdict !== 'not-comparable') + ? 'comparable' + : 'not-comparable', + }, + issues: [], + }, + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.test.ts index 3f67bcb10..777fea602 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.test.ts @@ -1,5 +1,4 @@ -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { WorkspaceResourceFile } from '@ecos-studio/shared' @@ -32,7 +31,6 @@ function provider(root: string): ProjectScopeProviderDouble { return { getProjectRoot: vi.fn().mockResolvedValue(root), requestProjectPathAccess: vi.fn(async (path: string) => path), - requestWritableProjectPathAccess: vi.fn(async (path: string) => path), } } @@ -77,36 +75,6 @@ describe('WorkspaceResourceService', () => { ) }) - it('rejects a parameters save that omits the workspace binding', async () => { - const root = await tempWorkspace() - await writeWorkspace(root, [{ name: 'place', tool: 'ecc' }]) - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - - await expect( - service.writeParameters({ - parameters: { Design: 'gcd' }, - workspace: '', - }), - ).rejects.toThrow(/requires a workspace path/) - }) - - it('writes parameters when the workspace binding matches the active root', async () => { - const root = await tempWorkspace() - await writeWorkspace(root, [{ name: 'place', tool: 'ecc' }]) - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - - const result = await service.writeParameters({ - parameters: { Design: 'aes' }, - workspace: root, - }) - - expect(result.format).toBe('json') - const written = JSON.parse( - await readFile(join(root, 'home', 'parameters.json'), 'utf8'), - ) as Record - expect(written.Design).toBe('aes') - }) - it('builds an ECC step resource index from parameters and flow files', async () => { const root = await tempWorkspace() await mkdir(join(root, 'home'), { recursive: true }) @@ -150,65 +118,6 @@ describe('WorkspaceResourceService', () => { }) }) - it('builds the resource index from home/params.toml workspaces', async () => { - const root = await tempWorkspace() - await mkdir(join(root, 'home'), { recursive: true }) - await mkdir(join(root, 'place_ecc', 'output'), { recursive: true }) - await writeFile( - join(root, 'home', 'params.toml'), - [ - '[design]', - 'name = "gcd"', - 'top = "gcd"', - '', - '[pdk]', - 'name = "ics55"', - 'root = "/pdk/ics55"', - '', - '[params]', - 'design = "gcd"', - 'top_module = "gcd"', - 'pdk = "ics55"', - 'frequency_max = 100.0', - '', - ].join('\n'), - 'utf8', - ) - await writeJson(join(root, 'home', 'flow.json'), { - steps: [ - { name: 'place', tool: 'ecc', state: 'Success', runtime: '00:00:01', info: {} }, - ], - }) - await writeJson(join(root, 'home', 'home.json'), { - flow: join(root, 'home', 'flow.json'), - }) - await writeFile(join(root, 'place_ecc', 'output', 'gcd_place.json'), '{}', 'utf8') - - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - const index = await service.getIndex() - - expect(index.status).toBe('available') - expect(index.design).toBe('gcd') - expect(index.topModule).toBe('gcd') - expect(index.pdk).toBe('ics55') - expect(index.parameters).toMatchObject({ - design: 'gcd', - top_module: 'gcd', - pdk: 'ics55', - frequency_max: 100.0, - }) - expect(index.home.parametersJson).toMatchObject({ - path: join(root, 'home', 'params.toml'), - exists: true, - kind: 'parameters', - }) - expect(index.flow.steps).toHaveLength(1) - expect(index.flow.steps[0].directory).toBe(join(root, 'place_ecc')) - - const parameters = await service.readParameters() - expect(parameters).toMatchObject({ design: 'gcd', top_module: 'gcd', pdk: 'ics55' }) - }) - it('discovers every file below a step report directory', async () => { const root = await tempWorkspace() await writeWorkspace(root, [{ name: 'sta', tool: 'ecc' }]) @@ -269,199 +178,6 @@ describe('WorkspaceResourceService', () => { }) }) - it('indexes sizer layout, geometry, and analysis like other physical steps', async () => { - const root = await tempWorkspace() - await writeWorkspace(root, [ - { name: 'Timing optimization', tool: 'sizer', state: 'Success' }, - ]) - const stepDirectory = join(root, 'timing_optimization_sizer') - const outputDirectory = join(stepDirectory, 'output') - await mkdir(join(outputDirectory, 'gcd_Timing optimization_db'), { recursive: true }) - await mkdir(join(outputDirectory, 'geometry'), { recursive: true }) - await mkdir(join(stepDirectory, 'feature'), { recursive: true }) - await mkdir(join(stepDirectory, 'analysis'), { recursive: true }) - await writeFile(join(outputDirectory, 'gcd_timing_optimization.def.gz'), 'def') - await writeFile(join(outputDirectory, 'gcd_timing_optimization.v.gz'), 'verilog') - await writeFile(join(outputDirectory, 'gcd_Timing optimization.gds'), 'gds') - await writeFile(join(outputDirectory, 'gcd_Timing optimization.png'), 'png') - await writeFile(join(outputDirectory, 'geometry', 'geometry.manifest'), 'manifest') - await writeJson(join(stepDirectory, 'feature', 'Timing optimization.db.json'), { - 'Design Layout': { die_area: 1 }, - }) - await writeJson(join(stepDirectory, 'analysis', 'qor_metrics.json'), { metrics: [] }) - - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - const index = await service.getIndex() - const step = index.flow.steps[0] - - expect(step.resources.output.image).toMatchObject({ - path: join(outputDirectory, 'gcd_Timing optimization.png'), - exists: true, - kind: 'layout-image', - }) - expect(step.resources.output.def).toMatchObject({ - path: join(outputDirectory, 'gcd_timing_optimization.def.gz'), - exists: true, - }) - expect(step.resources.output.verilog).toMatchObject({ - path: join(outputDirectory, 'gcd_timing_optimization.v.gz'), - exists: true, - }) - expect(step.resources.output.geometryManifest).toMatchObject({ - path: join(outputDirectory, 'geometry', 'geometry.manifest'), - exists: true, - }) - expect(step.resources.feature.db).toMatchObject({ - path: join(stepDirectory, 'feature', 'Timing optimization.db.json'), - exists: true, - }) - expect(step.resources.analysis.metrics).toMatchObject({ - path: join(stepDirectory, 'analysis', 'qor_metrics.json'), - exists: true, - }) - - await expect( - service.resolveStepInfo({ step: 'Timing optimization', id: 'layout' }), - ).resolves.toMatchObject({ - step: 'Timing optimization', - id: 'layout', - response: 'available', - info: { - image: join(outputDirectory, 'gcd_Timing optimization.png'), - gds: join(outputDirectory, 'gcd_Timing optimization.gds'), - def: join(outputDirectory, 'gcd_timing_optimization.def.gz'), - geometryManifest: join(outputDirectory, 'geometry', 'geometry.manifest'), - }, - missing: [], - }) - await expect( - service.resolveStepInfo({ step: 'Timing optimization', id: 'analysis' }), - ).resolves.toMatchObject({ - step: 'Timing optimization', - id: 'analysis', - info: { - metrics: join(stepDirectory, 'analysis', 'qor_metrics.json'), - 'data summary': join(stepDirectory, 'feature', 'Timing optimization.db.json'), - 'step feature': join(stepDirectory, 'feature', 'Timing optimization.step.json'), - }, - }) - }) - - it('indexes the yosys_lec result JSON, log, reports, and subflow', async () => { - const root = await tempWorkspace() - await writeWorkspace(root, [ - { name: 'postRouteLec', tool: 'yosys_lec', state: 'Success' }, - ]) - const stepDirectory = join(root, 'postRouteLec_yosys_lec') - await mkdir(join(stepDirectory, 'output'), { recursive: true }) - await mkdir(join(stepDirectory, 'log'), { recursive: true }) - await mkdir(join(stepDirectory, 'report'), { recursive: true }) - await writeJson(join(stepDirectory, 'output', 'gcd_postRouteLec_result.json'), { - status: 'proven', - }) - await writeFile(join(stepDirectory, 'log', 'postRouteLec.log'), 'lec log', 'utf8') - await writeFile(join(stepDirectory, 'report', 'equiv_status.rpt'), 'status', 'utf8') - await writeJson(join(stepDirectory, 'subflow.json'), { subflow: [] }) - - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - const index = await service.getIndex() - const step = index.flow.steps[0]! - - expect(step.directory).toBe(stepDirectory) - expect(step.resources.output.result).toMatchObject({ - path: join(stepDirectory, 'output', 'gcd_postRouteLec_result.json'), - exists: true, - kind: 'output', - }) - expect(step.resources.log.file).toMatchObject({ - path: join(stepDirectory, 'log', 'postRouteLec.log'), - exists: true, - kind: 'log', - }) - expect(step.resources.subflow.path).toMatchObject({ exists: true }) - expect(step.resources.report['rpt:equiv_status.rpt']).toMatchObject({ - path: join(stepDirectory, 'report', 'equiv_status.rpt'), - exists: true, - }) - expect(step.resources.output.image).toBeUndefined() - expect(step.resources.output.def).toBeUndefined() - }) - - it('revalidates a proven LEC result against the current netlists', async () => { - const root = await tempWorkspace() - await writeWorkspace(root, [ - { name: 'Synthesis', tool: 'yosys', state: 'Success' }, - { name: 'filler', tool: 'ecc', state: 'Success' }, - { name: 'postRouteLec', tool: 'yosys_lec', state: 'Success' }, - ]) - const stepDirectory = join(root, 'postRouteLec_yosys_lec') - // ECC chains the postRouteLec golden to the Synthesis output verilog. - const goldenPath = join(root, 'Synthesis_yosys', 'output', 'gcd_Synthesis.v.gz') - const gatePath = join(root, 'filler_ecc', 'output', 'gcd_filler.v.gz') - await mkdir(join(root, 'Synthesis_yosys', 'output'), { recursive: true }) - await mkdir(join(root, 'filler_ecc', 'output'), { recursive: true }) - await mkdir(join(stepDirectory, 'output'), { recursive: true }) - const goldenText = 'module gcd; endmodule\n' - const gateText = 'gate-netlist-bytes' - await writeFile(goldenPath, goldenText, 'utf8') - await writeFile(gatePath, gateText, 'utf8') - const digest = (content: string) => createHash('sha256').update(content).digest('hex') - const resultPath = join(stepDirectory, 'output', 'gcd_postRouteLec_result.json') - const writeResult = (gate: string, gateContent: string) => - writeJson(resultPath, { - status: 'proven', - golden_verilog: goldenPath, - gate_verilog: gate, - golden_sha256: digest(goldenText), - gate_sha256: digest(gateContent), - golden_size_bytes: Buffer.byteLength(goldenText), - gate_size_bytes: Buffer.byteLength(gateContent), - }) - await writeResult(gatePath, gateText) - - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - const proven = await service.resolveStepInfo({ step: 'postRouteLec', id: 'analysis' }) - expect(proven.info['lec status']).toBe('proven') - expect(proven.info['lec result']).toBe(resultPath) - - // Same path, changed contents. - await writeFile(gatePath, `${gateText}-changed`, 'utf8') - const staleContent = await service.resolveStepInfo({ - step: 'postRouteLec', - id: 'analysis', - }) - expect(staleContent.info['lec status']).toBe('stale') - - // Restored contents, but the recorded gate is no longer the current input. - await writeFile(gatePath, gateText, 'utf8') - const otherPath = join(root, 'filler_ecc', 'output', 'gcd_other.v.gz') - await writeFile(otherPath, gateText, 'utf8') - await writeResult(otherPath, gateText) - const stalePath = await service.resolveStepInfo({ - step: 'postRouteLec', - id: 'analysis', - }) - expect(stalePath.info['lec status']).toBe('stale') - - await writeJson(resultPath, { status: 'incomplete' }) - const incomplete = await service.resolveStepInfo({ - step: 'postRouteLec', - id: 'analysis', - }) - expect(incomplete.info['lec status']).toBe('incomplete') - }) - - it('reports a missing LEC result when the step never ran', async () => { - const root = await tempWorkspace() - await writeWorkspace(root, [ - { name: 'postRouteLec', tool: 'yosys_lec', state: 'Unstart' }, - ]) - - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - const result = await service.resolveStepInfo({ step: 'postRouteLec', id: 'analysis' }) - expect(result.info['lec status']).toBe('missing') - }) - it('exposes workspace-level view package tech resources from the design view directory', async () => { const root = await tempWorkspace() await writeWorkspace(root, [{ name: 'place', tool: 'ecc' }]) @@ -770,40 +486,6 @@ describe('WorkspaceResourceService', () => { expect(result.info.config).toBeUndefined() }) - it.each([ - ['Floorplan', 'floorplan_ecc.json'], - ['CTS', 'cts_ecc.json'], - ['route', 'route_ecc.json'], - ['drc', 'drc_ecc.json'], - ['filler', 'filler_ecc.json'], - ['RCX', 'rcx_ecc.json'], - ['sta', 'sta_ecc.json'], - ['db', 'db_ecc.json'], - ])( - 'maps ECC %s config to the workspace config directory', - async (stepName, configFile) => { - const root = await tempWorkspace() - await writeWorkspace(root, [{ name: stepName, tool: 'ecc' }]) - await mkdir(join(root, 'config'), { recursive: true }) - await writeFile(join(root, 'config', configFile), '{}', 'utf8') - - const service = new WorkspaceResourceService({ - projectScopeProvider: provider(root), - }) - const result = await service.resolveStepInfo({ - step: stepName.toLowerCase(), - id: 'config', - }) - - expect(result).toMatchObject({ - step: stepName, - response: 'available', - info: { config: join(root, 'config', configFile) }, - missing: [], - }) - }, - ) - it.each([ ['Timing optimization', []], ['Signoff', []], @@ -993,7 +675,7 @@ describe('WorkspaceResourceService', () => { expect(result.message).toEqual( expect.arrayContaining([ `Workspace step not found: place`, - `Missing workspace parameters: ${join(root, 'home', 'params.toml')} or ${join(root, 'home', 'parameters.json')}`, + `Missing workspace parameters: ${join(root, 'home', 'parameters.json')}`, `Missing workspace flow: ${join(root, 'home', 'flow.json')}`, ]), ) @@ -1102,7 +784,7 @@ describe('WorkspaceResourceService', () => { expect(index.flow.steps).toEqual([]) expect(index.messages).toEqual( expect.arrayContaining([ - `Missing workspace parameters: ${join(root, 'home', 'params.toml')} or ${join(root, 'home', 'parameters.json')}`, + `Missing workspace parameters: ${join(root, 'home', 'parameters.json')}`, `Missing workspace flow: ${join(root, 'home', 'flow.json')}`, ]), ) @@ -1121,107 +803,3 @@ describe('WorkspaceResourceService', () => { expect(index.messages.join('\n')).toContain('Failed to parse') }) }) - -describe('writeParameters', () => { - it('writes parameters into home/params.toml and reports the format', async () => { - const root = await tempWorkspace() - await mkdir(join(root, 'home'), { recursive: true }) - await writeFile( - join(root, 'home', 'params.toml'), - [ - '[design]', - 'name = "gcd"', - '', - '[params]', - 'design = "gcd"', - 'frequency_max = 100.0', - '', - ].join('\n'), - 'utf8', - ) - - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - const result = await service.writeParameters({ - parameters: { 'Frequency max [MHz]': 150 }, - workspace: root, - }) - - expect(result.format).toBe('toml') - expect(result.path).toBe(join(root, 'home', 'params.toml')) - const parameters = await service.readParameters() - expect(parameters).toMatchObject({ design: 'gcd', frequency_max: 150 }) - }) - - it('writes home/parameters.json workspaces as JSON', async () => { - const root = await tempWorkspace() - await mkdir(join(root, 'home'), { recursive: true }) - await writeJson(join(root, 'home', 'parameters.json'), { Design: 'gcd' }) - - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - const result = await service.writeParameters({ - parameters: { Design: 'gcd', 'Max fanout': 24 }, - workspace: root, - }) - - expect(result.format).toBe('json') - const written = JSON.parse( - await readFile(join(root, 'home', 'parameters.json'), 'utf8'), - ) - expect(written).toEqual({ Design: 'gcd', 'Max fanout': 24 }) - }) - - it('refuses to write while the workspace runtime is active', async () => { - const root = await tempWorkspace() - await mkdir(join(root, 'home'), { recursive: true }) - await writeFile( - join(root, 'home', 'params.toml'), - ['[params]', 'design = "gcd"', ''].join('\n'), - 'utf8', - ) - - const service = new WorkspaceResourceService({ - projectScopeProvider: provider(root), - runtimeMutationGuard: { isWorkspaceRuntimeActive: async () => true }, - }) - - await expect( - service.writeParameters({ parameters: { frequency_max: 150 }, workspace: root }), - ).rejects.toThrow(/flow is running/i) - const parameters = await service.readParameters() - expect(parameters).toMatchObject({ design: 'gcd' }) - expect(parameters?.frequency_max).toBeUndefined() - }) - - it('rejects a malformed write request', async () => { - const root = await tempWorkspace() - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - await expect( - service.writeParameters( - {} as { parameters: Record; workspace: string }, - ), - ).rejects.toThrow(/parameters object/i) - }) - - it('rejects a save dispatched for a different workspace than the active one', async () => { - const root = await tempWorkspace() - await mkdir(join(root, 'home'), { recursive: true }) - await writeFile( - join(root, 'home', 'params.toml'), - ['[params]', 'design = "gcd"', ''].join('\n'), - 'utf8', - ) - const other = await tempWorkspace() - - const service = new WorkspaceResourceService({ projectScopeProvider: provider(root) }) - await expect( - service.writeParameters({ parameters: { design: 'gcd' }, workspace: other }), - ).rejects.toThrow(/active workspace changed/i) - - // A matching workspace writes through. - const result = await service.writeParameters({ - parameters: { design: 'gcd' }, - workspace: root, - }) - expect(result.format).toBe('toml') - }) -}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.ts index b0b51456b..7f5b77684 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.ts @@ -1,7 +1,5 @@ -import { createHash } from 'node:crypto' -import { createReadStream } from 'node:fs' -import { lstat, readdir, realpath, stat } from 'node:fs/promises' -import { dirname, join, relative, resolve } from 'node:path' +import { open, readdir, stat } from 'node:fs/promises' +import { join, relative } from 'node:path' import type { WorkspaceResourceFile, WorkspaceResourceIndex, @@ -11,29 +9,23 @@ import type { WorkspaceStepResource, WorkspaceTechResources, } from '@ecos-studio/shared' -import type { ProjectScopeProvider, RuntimeMutationGuard } from './workspaceService' -import { WORKSPACE_RUNTIME_MUTATION_BLOCKED_MESSAGE } from './workspaceService' -import { migrateWorkspaceConfigFilenames } from './eccRpc/workspaceConfigMigration' -import { - locateWorkspaceParametersFile, - JSON_PARAMETERS_BASENAME, - parseWorkspaceParametersText, - readWorkspaceConfigContained, - WORKSPACE_CONFIG_BASENAME, - writeWorkspaceParameters, - type WorkspaceParametersFileLocation, -} from './workspaceParametersFile' +import type { ProjectScopeProvider } from './workspaceService' type WorkspaceResourceFileKind = WorkspaceResourceFile['kind'] type ResourceBucketName = keyof WorkspaceStepResource['resources'] type StepFileBuckets = WorkspaceStepResource['resources'] +const WORKSPACE_INDEX_JSON_MAX_BYTES = 4 * 1024 * 1024 + +function isObsoleteFlowStep(value: string): boolean { + return value.toLowerCase().replace(/[\s_-]/g, '') === 'fixfanout' +} + interface WorkspaceResourceServiceOptions { projectScopeProvider: Pick< ProjectScopeProvider, - 'getProjectRoot' | 'requestProjectPathAccess' | 'requestWritableProjectPathAccess' + 'getProjectRoot' | 'requestProjectPathAccess' > - runtimeMutationGuard?: RuntimeMutationGuard } interface FlowStepInput { @@ -41,6 +33,7 @@ interface FlowStepInput { tool: string state: string runtime: string + peakMemoryMb?: number info: Record } @@ -56,11 +49,9 @@ interface StepInfoBuildResult { export class WorkspaceResourceService { private readonly projectScopeProvider: WorkspaceResourceServiceOptions['projectScopeProvider'] - private readonly runtimeMutationGuard?: RuntimeMutationGuard constructor(options: WorkspaceResourceServiceOptions) { this.projectScopeProvider = options.projectScopeProvider - this.runtimeMutationGuard = options.runtimeMutationGuard } async getIndex(): Promise { @@ -70,116 +61,17 @@ export class WorkspaceResourceService { async readHome(): Promise | null> { const root = await this.projectScopeProvider.getProjectRoot() - await migrateWorkspaceConfigFilenames(root) return await this.readJsonOrNull(join(root, 'home', 'home.json')) } async readFlow(): Promise | null> { const root = await this.projectScopeProvider.getProjectRoot() - await migrateWorkspaceConfigFilenames(root) return await this.readJsonOrNull(join(root, 'home', 'flow.json')) } async readParameters(): Promise | null> { const root = await this.projectScopeProvider.getProjectRoot() - await migrateWorkspaceConfigFilenames(root) - const location = await locateWorkspaceParametersFile(root) - if (!location) return null - try { - const canonicalPath = await this.projectScopeProvider.requestProjectPathAccess( - location.path, - ) - const raw = await readWorkspaceConfigContained(location.path, canonicalPath) - return parseWorkspaceParametersText(raw, location.format, root) - } catch (error) { - if (isNodeErrorWithCode(error, 'ENOENT')) { - return null - } - throw error - } - } - - /** - * Persist workspace parameters in the workspace's own format - * (home/params.toml preferred, home/parameters.json fallback). Refused - * while the workspace runtime is active, mirroring the mutation guard on - * direct config file writes. - */ - async writeParameters(request: { - parameters: Record - workspace: string - }): Promise<{ format: WorkspaceParametersFileLocation['format']; path: string }> { - if (!request || typeof request !== 'object' || !isRecord(request.parameters)) { - throw new Error('Workspace parameters write requires a parameters object') - } - if (typeof request.workspace !== 'string' || request.workspace.trim() === '') { - throw new Error('Workspace parameters write requires a workspace path') - } - const root = await this.projectScopeProvider.getProjectRoot() - // The save was dispatched for a specific workspace: refuse to land it - // in whichever workspace became active since (an openProject that - // registered a new root before the renderer committed the switch). - // Revalidated on every guard pass inside the serialized write, so a - // switch happening while the save queues behind another writer blocks - // it too. - const assertExpectedWorkspace = async (): Promise => { - const activeRoot = await this.projectScopeProvider.getProjectRoot() - const [expected, active] = await Promise.all([ - realpath(request.workspace), - realpath(activeRoot), - ]) - if (expected !== active) { - throw new Error( - `Refusing to write workspace parameters for ${request.workspace}: ` + - 'the active workspace changed before the save completed', - ) - } - } - await assertExpectedWorkspace() - const location = await locateWorkspaceParametersFile(root) - if (!location) { - throw new Error( - `Workspace parameters file not found: ${join(root, 'home', WORKSPACE_CONFIG_BASENAME)} or ${join(root, 'home', JSON_PARAMETERS_BASENAME)}`, - ) - } - const locationStats = await lstat(location.path) - if (locationStats.isSymbolicLink()) { - // A symlinked config path escapes the runtime mutation guard's - // spelled-path protection and makes the write target ambiguous — - // refuse it, matching the edit path and ECC's own symlink refusal. - throw new Error( - `Refusing to write workspace parameters through a symlink: ${location.path}`, - ) - } - const canonicalPath = - await this.projectScopeProvider.requestWritableProjectPathAccess(location.path) - if ( - this.runtimeMutationGuard && - (await this.runtimeMutationGuard.isWorkspaceRuntimeActive(root)) - ) { - throw new Error(WORKSPACE_RUNTIME_MUTATION_BLOCKED_MESSAGE) - } - const written = await writeWorkspaceParameters( - root, - request.parameters, - { - format: location.format, - path: canonicalPath, - spelledPath: location.path, - }, - // Re-checked inside the serialized operation: a flow starting while - // the save queued behind another writer must still block it. - async () => { - await assertExpectedWorkspace() - if ( - this.runtimeMutationGuard && - (await this.runtimeMutationGuard.isWorkspaceRuntimeActive(root)) - ) { - throw new Error(WORKSPACE_RUNTIME_MUTATION_BLOCKED_MESSAGE) - } - }, - ) - return { format: written.format, path: written.path } + return await this.readJsonOrNull(join(root, 'home', 'parameters.json')) } async resolveStepInfo( @@ -212,7 +104,7 @@ export class WorkspaceResourceService { } } - const stepInfoResult = await this.buildStepInfoResponse(request.id, step, index) + const stepInfoResult = await this.buildStepInfoResponse(request.id, step) const info = stepInfoResult.info const requiredFiles = this.requiredFilesForStepInfo(request.id, step) const missing = requiredFiles @@ -244,14 +136,11 @@ export class WorkspaceResourceService { private async buildIndex(): Promise { const root = await this.projectScopeProvider.getProjectRoot() - await migrateWorkspaceConfigFilenames(root) const messages: string[] = [] const statErrors: string[] = [] const homePath = join(root, 'home', 'home.json') const flowPath = join(root, 'home', 'flow.json') - const parametersLocation = await locateWorkspaceParametersFile(root) - const parametersPath = - parametersLocation?.path ?? join(root, 'home', JSON_PARAMETERS_BASENAME) + const parametersPath = join(root, 'home', 'parameters.json') const checklistPath = join(root, 'home', 'checklist.json') const [homeJson, flowJson, parametersJson, checklistJson] = await Promise.all([ @@ -262,28 +151,24 @@ export class WorkspaceResourceService { ]) const homeData = await this.readJsonForIndex(homePath, messages) - const parameters = await this.readParametersForIndex( - root, - parametersLocation, - messages, - ) + const parameters = await this.readJsonForIndex(parametersPath, messages) const flowData = await this.readJsonForIndex(flowPath, messages) - if (!parametersLocation) - messages.push( - `Missing workspace parameters: ${join(root, 'home', WORKSPACE_CONFIG_BASENAME)} or ${parametersPath}`, - ) + if (!parametersJson.exists) + messages.push(`Missing workspace parameters: ${parametersPath}`) if (!flowJson.exists) messages.push(`Missing workspace flow: ${flowPath}`) - const design = stringValue(parameters, 'Design') || stringValue(parameters, 'design') - const topModule = - stringValue(parameters, 'Top module') || stringValue(parameters, 'top_module') - const pdk = stringValue(parameters, 'PDK') || stringValue(parameters, 'pdk') + const design = stringValue(parameters, 'Design') + const topModule = stringValue(parameters, 'Top module') + const pdk = stringValue(parameters, 'PDK') const steps = isRecord(flowData) && Array.isArray(flowData.steps) ? flowData.steps .map(readFlowStep) - .filter((step): step is FlowStepInput => step !== null) + .filter( + (step): step is FlowStepInput => + step !== null && !isObsoleteFlowStep(step.name), + ) : [] const flowSteps = await Promise.all( steps.map((step) => @@ -337,27 +222,10 @@ export class WorkspaceResourceService { if (toolKey === 'yosys') { addYosysResources(resources, directory, design, step.name) - } else if (toolKey === 'ecc' || toolKey === 'sizer') { - addEccLikeResources(resources, root, directory, design, topModule, step.name) - if (toolKey === 'sizer') { - const safeStepName = step.name.trim().split(/\s+/).join('_').toLowerCase() - resources.output.def = createFile( - join(directory, 'output', `${design}_${safeStepName}.def.gz`), - 'output', - ) - resources.output.verilog = createFile( - join(directory, 'output', `${design}_${safeStepName}.v.gz`), - 'output', - ) - } + } else if (toolKey === 'ecc') { + addEccLikeResources(resources, directory, design, topModule, step.name) } else if (toolKey === 'dreamplace') { - addEccLikeResources(resources, root, directory, design, topModule, step.name) - resources.config.dreamplace = createFile( - join(root, 'config', 'dreamplace_ecc.json'), - 'config', - ) - } else if (toolKey === 'yosys_lec') { - addLecResources(resources, directory, design, step.name) + addEccLikeResources(resources, directory, design, topModule, step.name) } else if (isFrontendTool(toolKey)) { addFrontendResources(resources, directory, design, step.name) } else { @@ -372,6 +240,7 @@ export class WorkspaceResourceService { tool, state: step.state, runtime: step.runtime, + ...(step.peakMemoryMb === undefined ? {} : { peakMemoryMb: step.peakMemoryMb }), directory, info: step.info, resources, @@ -572,36 +441,23 @@ export class WorkspaceResourceService { } } - private async readParametersForIndex( - root: string, - location: WorkspaceParametersFileLocation | null, - messages: string[], - ): Promise | null> { - if (!location) return null - try { - const canonicalPath = await this.projectScopeProvider.requestProjectPathAccess( - location.path, - ) - const raw = await readWorkspaceConfigContained(location.path, canonicalPath) - return parseWorkspaceParametersText(raw, location.format, root) - } catch (error) { - if (isNodeErrorWithCode(error, 'ENOENT')) { - return null - } - messages.push( - formatErrorMessage( - `Failed to parse workspace parameters: ${location.path}`, - error, - ), - ) - return null - } - } - private async readJsonOrNull(path: string): Promise | null> { try { const canonicalPath = await this.projectScopeProvider.requestProjectPathAccess(path) - const raw = await readWorkspaceConfigContained(path, canonicalPath) + const handle = await open(canonicalPath, 'r') + let raw: string + try { + const buffer = Buffer.alloc(WORKSPACE_INDEX_JSON_MAX_BYTES + 1) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + if (bytesRead > WORKSPACE_INDEX_JSON_MAX_BYTES) { + throw new Error( + `Workspace JSON exceeds ${WORKSPACE_INDEX_JSON_MAX_BYTES} bytes: ${path}`, + ) + } + raw = buffer.subarray(0, bytesRead).toString('utf8') + } finally { + await handle.close() + } const parsed: unknown = JSON.parse(raw) return isRecord(parsed) ? parsed : {} } catch (error) { @@ -613,89 +469,9 @@ export class WorkspaceResourceService { } } - private async buildAnalysisStepInfo( - step: WorkspaceStepResource, - steps: WorkspaceStepResource[], - design: string, - ): Promise { - if (step.tool.toLowerCase() !== 'yosys_lec') { - return stepInfo(buildAnalysisInfo(step)) - } - return stepInfo({ - ...buildAnalysisInfo(step), - 'lec result': step.resources.output.result?.path, - 'lec status': await this.lecResultStatus(step, steps, design), - }) - } - - /** Mirrors ECC lec_result_status: rehash the recorded netlists so a stale proof degrades. */ - private async lecResultStatus( - step: WorkspaceStepResource, - steps: WorkspaceStepResource[], - design: string, - ): Promise { - const resultFile = step.resources.output.result - if (!resultFile?.exists) return 'missing' - const result = await this.readJsonOrNull(resultFile.path) - if (result?.status !== 'proven') return 'incomplete' - const workspaceRoot = dirname(step.directory) - const goldenCurrent = await this.lecNetlistIsCurrent(result, 'golden', { - expectedPath: lecExpectedGoldenPath(step, steps, design), - workspaceRoot, - }) - const gateCurrent = await this.lecNetlistIsCurrent(result, 'gate', { - expectedPath: lecExpectedGatePath(step, steps, design), - workspaceRoot, - }) - return goldenCurrent && gateCurrent ? 'proven' : 'stale' - } - - private async lecNetlistIsCurrent( - result: Record, - role: 'golden' | 'gate', - expected: { expectedPath: string | null; workspaceRoot: string }, - ): Promise { - const recordedPath = result[`${role}_verilog`] - const recordedSha = result[`${role}_sha256`] - const recordedSize = result[`${role}_size_bytes`] - if (typeof recordedPath !== 'string' || !recordedPath) return false - if (typeof recordedSha !== 'string' || !/^[0-9a-f]{64}$/.test(recordedSha)) - return false - if ( - typeof recordedSize !== 'number' || - !Number.isInteger(recordedSize) || - recordedSize < 0 - ) { - return false - } - // The recorded file must still be the currently selected flow input. - if ( - expected.expectedPath && - resolve(expected.workspaceRoot, recordedPath) !== - resolve(expected.workspaceRoot, expected.expectedPath) - ) { - return false - } - try { - const canonicalPath = - await this.projectScopeProvider.requestProjectPathAccess(recordedPath) - const hash = createHash('sha256') - let size = 0 - // Stream like ECC file_digest instead of loading whole netlists. - for await (const chunk of createReadStream(canonicalPath)) { - hash.update(chunk as Buffer) - size += (chunk as Buffer).length - } - return size === recordedSize && hash.digest('hex') === recordedSha - } catch { - return false - } - } - private async buildStepInfoResponse( id: WorkspaceStepInfoRequest['id'], step: WorkspaceStepResource, - index: WorkspaceResourceIndex, ): Promise { switch (id) { case 'layout': @@ -720,7 +496,7 @@ export class WorkspaceResourceService { case 'subflow': return stepInfo({ path: step.resources.subflow.path?.path }) case 'analysis': - return await this.buildAnalysisStepInfo(step, index.flow.steps, index.design) + return stepInfo(buildAnalysisInfo(step)) case 'checklist': return stepInfo({ path: step.resources.checklist.path?.path }) case 'config': @@ -887,7 +663,6 @@ function createEmptyBuckets(): StepFileBuckets { function addEccLikeResources( resources: StepFileBuckets, - root: string, directory: string, design: string, topModule: string, @@ -989,7 +764,6 @@ function addEccLikeResources( ) resources.subflow.path = createFile(join(directory, 'subflow.json'), 'subflow') resources.checklist.path = createFile(join(directory, 'checklist.json'), 'checklist') - addEccConfigResources(resources, root, stepName) } function addYosysResources( @@ -1052,54 +826,6 @@ function addYosysResources( resources.checklist.path = createFile(join(directory, 'checklist.json'), 'checklist') } -function addEccConfigResources( - resources: StepFileBuckets, - root: string, - stepName: string, -): void { - resources.config.dir = createFile(join(root, 'config'), 'config') - resources.config.flow = createFile(join(root, 'config', 'flow_ecc.json'), 'config') - resources.config.db = createFile(join(root, 'config', 'db_ecc.json'), 'config') - resources.config.cts = createFile(join(root, 'config', 'cts_ecc.json'), 'config') - resources.config.drc = createFile(join(root, 'config', 'drc_ecc.json'), 'config') - resources.config.floorplan = createFile( - join(root, 'config', 'floorplan_ecc.json'), - 'config', - ) - resources.config.routing = createFile(join(root, 'config', 'route_ecc.json'), 'config') - resources.config.rcx = createFile(join(root, 'config', 'rcx_ecc.json'), 'config') - resources.config.sta = createFile(join(root, 'config', 'sta_ecc.json'), 'config') - resources.config.filler = createFile(join(root, 'config', 'filler_ecc.json'), 'config') - const stepConfig = configResourceForEccStep(resources.config, stepName) - if (stepConfig) resources.config.config = stepConfig -} - -function configResourceForEccStep( - config: StepFileBuckets['config'], - stepName: string, -): WorkspaceResourceFile | undefined { - switch (stepName.toLowerCase()) { - case 'floorplan': - return config.floorplan - case 'cts': - return config.cts - case 'route': - return config.routing - case 'drc': - return config.drc - case 'filler': - return config.filler - case 'rcx': - return config.rcx - case 'sta': - return config.sta - case 'db': - return config.db - default: - return undefined - } -} - function isFrontendTool(tool: string): boolean { return tool === 'fe' || tool === 'slang' || tool === 'verilator' } @@ -1178,64 +904,6 @@ function addUnknownResources( resources.checklist.path = createFile(join(directory, 'checklist.json'), 'checklist') } -/** Yosys LEC publishes no layout; its key artifact is the equivalence result JSON. */ -function addLecResources( - resources: StepFileBuckets, - directory: string, - design: string, - stepName: string, -): void { - addUnknownResources(resources, directory, stepName) - resources.output.result = createFile( - join(directory, 'output', `${design}_${stepName}_result.json`), - 'output', - ) -} - -/** ECC publishes step netlists as output/_.v.gz (sizer stem underscored). */ -function stepOutputVerilogPath(step: WorkspaceStepResource, design: string): string { - const stem = - step.tool.toLowerCase() === 'sizer' - ? step.name.trim().split(/\s+/).join('_').toLowerCase() - : step.name - return join(step.directory, 'output', `${design}_${stem}.v.gz`) -} - -/** Current gate input of a LEC step: the nearest preceding physical step's verilog. */ -function lecExpectedGatePath( - step: WorkspaceStepResource, - steps: WorkspaceStepResource[], - design: string, -): string | null { - const stepIndex = steps.findIndex( - (candidate) => candidate.name.toLowerCase() === step.name.toLowerCase(), - ) - for (let index = stepIndex - 1; index >= 0; index -= 1) { - const candidate = steps[index]! - if (candidate.tool.toLowerCase() === 'yosys_lec') continue - return stepOutputVerilogPath(candidate, design) - } - return null -} - -/** Current golden input of a LEC step, mirroring the ECC flow chaining. */ -function lecExpectedGoldenPath( - step: WorkspaceStepResource, - steps: WorkspaceStepResource[], - design: string, -): string | null { - const explicit = step.info?.golden_verilog - if (typeof explicit === 'string' && explicit.trim()) return explicit.trim() - const synthesis = steps.find( - (candidate) => candidate.name.trim().toLowerCase() === 'synthesis', - ) - if (!synthesis) return null - if (step.name.trim().toLowerCase() === 'lec') { - return join(synthesis.directory, 'output', `${design}_Synthesis_golden.v`) - } - return stepOutputVerilogPath(synthesis, design) -} - function collectFiles(resources: StepFileBuckets): WorkspaceResourceFile[] { return Object.values(resources).flatMap((bucket) => collectBucketFiles(bucket)) } @@ -1265,6 +933,10 @@ function readFlowStep(value: unknown): FlowStepInput | null { tool: typeof value.tool === 'string' ? value.tool : 'unknown', state: typeof value.state === 'string' ? value.state : '', runtime: typeof value.runtime === 'string' ? value.runtime : '', + ...(typeof value['peak memory (mb)'] === 'number' && + Number.isFinite(value['peak memory (mb)']) + ? { peakMemoryMb: value['peak memory (mb)'] } + : {}), info: isRecord(value.info) ? value.info : {}, } } @@ -1334,9 +1006,7 @@ function analysisFiles(step: WorkspaceStepResource): WorkspaceResourceFile[] { function buildConfigInfo(step: WorkspaceStepResource): Record { const tool = step.tool.toLowerCase() - if (tool === 'yosys') return {} - if (tool === 'dreamplace') return { config: step.resources.config.dreamplace?.path } - return { config: step.resources.config.config?.path } + return isFrontendTool(tool) ? { config: step.resources.config.flow?.path } : {} } function stepInfo(info: Record): StepInfoBuildResult { @@ -1349,10 +1019,7 @@ function stripPngExtension(filename: string): string { function configFiles(step: WorkspaceStepResource): WorkspaceResourceFile[] { const tool = step.tool.toLowerCase() - if (tool === 'yosys') return [] - if (tool === 'dreamplace') - return existingResourceRefs([step.resources.config.dreamplace]) - return existingResourceRefs([step.resources.config.config]) + return isFrontendTool(tool) ? existingResourceRefs([step.resources.config.flow]) : [] } function existingResourceRefs( diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceService.test.ts index d66c8f304..2471f8ba5 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceService.test.ts @@ -1,18 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { - appendFile, - mkdir, - mkdtemp, - readFile, - readdir, - rename, - rm, - symlink, - writeFile, -} from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import type { DesktopProjectFileChangedEvent } from '@ecos-studio/shared' import { WorkspaceService } from './workspaceService' const tempDirectories: string[] = [] @@ -67,24 +56,6 @@ function createWorkspaceService( } } -async function waitForProjectFileEvent( - listener: ReturnType, - event: Partial, -): Promise { - await vi.waitFor( - () => { - expect(listener).toHaveBeenCalledWith(expect.objectContaining(event)) - }, - { timeout: 3000 }, - ) -} - -async function delay(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms) - }) -} - describe('WorkspaceService', () => { afterEach(async () => { await Promise.all( @@ -129,23 +100,23 @@ describe('WorkspaceService', () => { it('reads UTF-8 project text in bounded sequential chunks', async () => { const directory = await createTempDir('ecos-workspace-service-chunk-') - const filePath = join(directory, 'place_dreamplace', 'log', 'place.log') - await mkdir(join(directory, 'place_dreamplace', 'log'), { recursive: true }) + const filePath = join(directory, 'fixFanout_ecc', 'log', 'fixFanout.log') + await mkdir(join(directory, 'fixFanout_ecc', 'log'), { recursive: true }) await writeFile(filePath, 'ab中cd', 'utf8') const { service } = createWorkspaceService(directory, filePath) const first = await service.readOptionalProjectTextFileChunk( - '/workspace/place_dreamplace/log/place.log', + '/workspace/fixFanout_ecc/log/fixFanout.log', 0, 4, ) const second = await service.readOptionalProjectTextFileChunk( - '/workspace/place_dreamplace/log/place.log', + '/workspace/fixFanout_ecc/log/fixFanout.log', first?.nextOffsetBytes ?? 0, 4, ) const third = await service.readOptionalProjectTextFileChunk( - '/workspace/place_dreamplace/log/place.log', + '/workspace/fixFanout_ecc/log/fixFanout.log', second?.nextOffsetBytes ?? 0, 4, ) @@ -227,53 +198,6 @@ describe('WorkspaceService', () => { }) }) - it('reads appended text updates from a byte offset', async () => { - const directory = await createTempDir('ecos-workspace-service-update-') - const filePath = join(directory, 'Route_openroad', 'log', 'Route.log') - await mkdir(join(directory, 'Route_openroad', 'log'), { recursive: true }) - await writeFile(filePath, 'alpha\nbeta', 'utf8') - - const { service } = createWorkspaceService(directory, filePath) - const offset = Buffer.byteLength('alpha') - - await expect( - service.readOptionalProjectTextFileUpdate( - '/workspace/Route_openroad/log/Route.log', - offset, - 32, - ), - ).resolves.toMatchObject({ - content: '\nbeta', - fromOffsetBytes: offset, - nextOffsetBytes: Buffer.byteLength('alpha\nbeta'), - sizeBytes: Buffer.byteLength('alpha\nbeta'), - reset: false, - truncated: false, - }) - }) - - it('resets text updates when the unread range exceeds the bounded tail window', async () => { - const directory = await createTempDir('ecos-workspace-service-update-reset-') - const filePath = join(directory, 'Route_openroad', 'log', 'Route.log') - await mkdir(join(directory, 'Route_openroad', 'log'), { recursive: true }) - await writeFile(filePath, '0123456789abcdefghijklmnopqrstuvwxyz', 'utf8') - - const { service } = createWorkspaceService(directory, filePath) - - await expect( - service.readOptionalProjectTextFileUpdate( - '/workspace/Route_openroad/log/Route.log', - 0, - 10, - ), - ).resolves.toMatchObject({ - content: 'qrstuvwxyz', - nextOffsetBytes: Buffer.byteLength('0123456789abcdefghijklmnopqrstuvwxyz'), - reset: true, - truncated: true, - }) - }) - it('returns null for tail reads when the project-scoped file is absent', async () => { const directory = await createTempDir('ecos-workspace-service-tail-missing-') const filePath = join(directory, 'Synthesis_yosys', 'log', 'Synthesis.log') @@ -749,464 +673,21 @@ describe('WorkspaceService', () => { expect(runtimeMutationGuard.isWorkspaceRuntimeActive).toHaveBeenCalledWith(directory) }) - it('watches a project-scoped file through the validated canonical path', async () => { - const directory = await createTempDir('ecos-workspace-service-watch-') - const filePath = join(directory, 'flow.json') - await writeFile(filePath, '{"steps":[]}', 'utf8') - - const { projectScopeProvider, service } = createWorkspaceService(directory, filePath) - - const listener = vi.fn() - const subscriptionId = await service.watchProjectFile( - '/workspace/home/flow.json', - listener, - ) - - expect(subscriptionId).toMatch(/^project-file-watch-/) - expect(projectScopeProvider.requestProjectPathAccess).toHaveBeenCalledWith( - '/workspace/home/flow.json', - ) - - await service.unwatchProjectFile(subscriptionId) - }) - - it('emits change events for an existing watched file', async () => { - const directory = await createTempDir('ecos-workspace-service-watch-change-') - const filePath = join(directory, 'flow.json') - await writeFile(filePath, '{"steps":[]}', 'utf8') - - const { service } = createWorkspaceService(directory, filePath) - const listener = vi.fn() - const subscriptionId = await service.watchProjectFile( - '/workspace/home/flow.json', - listener, - ) - - try { - await writeFile(join(directory, 'unrelated.log'), 'noise', 'utf8') - await delay(100) - expect(listener).not.toHaveBeenCalled() - - await writeFile(filePath, '{"steps":[{"state":"ongoing"}]}', 'utf8') - await waitForProjectFileEvent(listener, { - subscriptionId, - path: filePath, - eventType: 'change', - }) - - listener.mockClear() - await appendFile(filePath, '\nmore log-like content', 'utf8') - await waitForProjectFileEvent(listener, { - subscriptionId, - path: filePath, - eventType: 'change', - }) - } finally { - await service.unwatchProjectFile(subscriptionId) - } - }) - - it('emits when a missing watched file is created later', async () => { - const directory = await createTempDir('ecos-workspace-service-watch-missing-') - const filePath = join(directory, 'CTS_ecc', 'log', 'CTS.log') - await mkdir(join(directory, 'CTS_ecc', 'log'), { recursive: true }) - - const { projectScopeProvider, service } = createWorkspaceService(directory, filePath) - - const listener = vi.fn() - const subscriptionId = await service.watchProjectFile( - '/workspace/CTS_ecc/log/CTS.log', - listener, - ) - - try { - expect(projectScopeProvider.requestProjectPathAccess).toHaveBeenCalledWith( - '/workspace/CTS_ecc/log/CTS.log', - ) - - await writeFile(filePath, 'created after watch', 'utf8') - await waitForProjectFileEvent(listener, { - subscriptionId, - path: filePath, - eventType: 'change', - }) - } finally { - await service.unwatchProjectFile(subscriptionId) - } - }) - - it('falls back to the project root when parent directories do not exist yet', async () => { - const directory = await createTempDir('ecos-workspace-service-watch-root-fallback-') - const filePath = join(directory, 'legalization_dreamplace', 'log', 'legalization.log') - const { projectScopeProvider, service } = createWorkspaceService(directory, filePath) - - const listener = vi.fn() - const subscriptionId = await service.watchProjectFile( - '/workspace/legalization_dreamplace/log/legalization.log', - listener, - ) - - try { - expect(projectScopeProvider.requestProjectPathAccess).toHaveBeenCalledWith( - '/workspace/legalization_dreamplace/log/legalization.log', - ) - expect(projectScopeProvider.getProjectRoot).toHaveBeenCalledTimes(1) - - await mkdir(join(directory, 'legalization_dreamplace', 'log'), { recursive: true }) - await writeFile(filePath, 'created under missing parents', 'utf8') - await waitForProjectFileEvent(listener, { - subscriptionId, - path: filePath, - eventType: 'change', - }) - } finally { - await service.unwatchProjectFile(subscriptionId) - } - }) - - it('emits when the watched file is replaced by rename', async () => { - const directory = await createTempDir('ecos-workspace-service-watch-replace-') - const filePath = join(directory, 'flow.json') - const replacementPath = join(directory, 'flow.json.tmp') - await writeFile(filePath, '{"steps":[]}', 'utf8') - - const { service } = createWorkspaceService(directory, filePath) - const listener = vi.fn() - const subscriptionId = await service.watchProjectFile( - '/workspace/home/flow.json', - listener, - ) - - try { - await writeFile(replacementPath, '{"steps":[{"state":"complete"}]}', 'utf8') - await rename(replacementPath, filePath) - - await vi.waitFor( - () => { - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - subscriptionId, - path: filePath, - }), - ) - const events = listener.mock.calls.map(([event]) => event.eventType) - expect( - events.some((eventType) => eventType === 'change' || eventType === 'rename'), - ).toBe(true) - }, - { timeout: 3000 }, - ) - } finally { - await service.unwatchProjectFile(subscriptionId) - } - }) - - it('does not emit after unwatching a project file', async () => { - const directory = await createTempDir('ecos-workspace-service-watch-unwatch-') - const filePath = join(directory, 'flow.json') - await writeFile(filePath, '{"steps":[]}', 'utf8') - - const { service } = createWorkspaceService(directory, filePath) - const listener = vi.fn() - const subscriptionId = await service.watchProjectFile( - '/workspace/home/flow.json', - listener, - ) - - await service.unwatchProjectFile(subscriptionId) - await writeFile(filePath, '{"steps":[{"state":"ongoing"}]}', 'utf8') - await delay(150) - - expect(listener).not.toHaveBeenCalled() - }) -}) - -describe('editWorkspaceParameters', () => { - it('refuses to edit parameters through a symlinked config file', async () => { - const directory = await createTempDir('ecos-workspace-service-') - const homeDir = join(directory, 'home') - await mkdir(homeDir, { recursive: true }) - const externalPath = join(directory, 'external.toml') - await writeFile(externalPath, '[params]\ndesign = "gcd"\n', 'utf8') - await symlink(externalPath, join(homeDir, 'params.toml')) - - const { service } = createWorkspaceService(directory, externalPath) - - await expect( - service.editWorkspaceParameters(directory, [{ json_path: ['design'], value: 'x' }]), - ).rejects.toThrow(/symlink/i) - await expect(readFile(externalPath, 'utf8')).resolves.toBe( - '[params]\ndesign = "gcd"\n', - ) - }) - - it('refuses to edit parameters in a nested workspace under the active root', async () => { - const directory = await createTempDir('ecos-workspace-service-') - const nested = join(directory, 'archive', 'other') - await mkdir(join(nested, 'home'), { recursive: true }) - const tomlPath = join(nested, 'home', 'params.toml') - await writeFile(tomlPath, '[params]\ndesign = "gcd"\n', 'utf8') - - const { service } = createWorkspaceService(directory, tomlPath) - await expect( - service.editWorkspaceParameters(nested, [{ json_path: ['design'], value: 'x' }]), - ).rejects.toThrow(/not the active workspace/) - await expect(readFile(tomlPath, 'utf8')).resolves.toBe('[params]\ndesign = "gcd"\n') - }) - - it('edits parameters in a real params.toml file', async () => { - const directory = await createTempDir('ecos-workspace-service-') - const homeDir = join(directory, 'home') - await mkdir(homeDir, { recursive: true }) - const tomlPath = join(homeDir, 'params.toml') - await writeFile(tomlPath, '[params]\ndesign = "gcd"\n', 'utf8') - - const { service } = createWorkspaceService(directory, tomlPath) - const result = await service.editWorkspaceParameters(directory, [ - { json_path: ['design'], value: 'updated' }, - ]) - - expect(result.format).toBe('toml') - await expect(readFile(tomlPath, 'utf8')).resolves.toContain('design = "updated"') - }) -}) - -describe('applyWorkspaceParameterWrites', () => { - function createApplyService(rootPath: string): WorkspaceService { - const projectScopeProvider = createProjectScopeProvider(rootPath, rootPath) - projectScopeProvider.requestWritableProjectPathAccess = vi.fn( - async (path: string) => path, - ) - return new WorkspaceService({ - projectScopeProvider, - replacementJournalDirectory: join(rootPath, '.workspace-replacement-journals'), - }) - } - - it('rolls back the parameter file when a later step-config write fails', async () => { - const directory = await createTempDir('ecos-workspace-service-apply-rollback-') - await mkdir(join(directory, 'home'), { recursive: true }) - await mkdir(join(directory, 'config'), { recursive: true }) - const tomlPath = join(directory, 'home', 'params.toml') - const original = '[params]\ndesign = "gcd"\nmax_fanout = 20\n' - await writeFile(tomlPath, original, 'utf8') - await writeFile( - join(directory, 'config', 'dreamplace_ecc.json'), - '{\n "other_key": 1\n}\n', - 'utf8', - ) - - const service = createApplyService(directory) - await expect( - service.applyWorkspaceParameterWrites(directory, [ - { - file: 'home/params.toml', - json_path: ['max_fanout'], - knob_id: 'cts.max_fanout', - surface: 'parameters', - value: 64, - }, - { - file: 'config/dreamplace_ecc.json', - json_path: ['density_weight'], - knob_id: 'place.density_weight', - surface: 'step_config', - value: 0.1, - }, - ]), - ).rejects.toThrow(/does not exist/) - - await expect(readFile(tomlPath, 'utf8')).resolves.toBe(original) - }) - - it('applies parameter and step-config writes together', async () => { - const directory = await createTempDir('ecos-workspace-service-apply-ok-') - await mkdir(join(directory, 'home'), { recursive: true }) - await mkdir(join(directory, 'config'), { recursive: true }) - const tomlPath = join(directory, 'home', 'params.toml') - const stepPath = join(directory, 'config', 'dreamplace_ecc.json') - await writeFile(tomlPath, '[params]\ndesign = "gcd"\nmax_fanout = 20\n', 'utf8') - await writeFile(stepPath, '{\n "density_weight": 0.2\n}\n', 'utf8') - - const service = createApplyService(directory) - await service.applyWorkspaceParameterWrites(directory, [ - { - file: 'home/params.toml', - json_path: ['max_fanout'], - knob_id: 'cts.max_fanout', - surface: 'parameters', - value: 64, - }, - { - file: 'config/dreamplace_ecc.json', - json_path: ['density_weight'], - knob_id: 'place.density_weight', - surface: 'step_config', - value: 0.1, - }, - ]) - - await expect(readFile(tomlPath, 'utf8')).resolves.toContain('max_fanout = 64') - await expect(readFile(stepPath, 'utf8')).resolves.toContain('"density_weight": 0.1') - }) - - it('serializes step-config editor saves behind the parameter write queue', async () => { - const directory = await createTempDir('ecos-workspace-service-step-config-queue-') + it('blocks direct configuration writes for an idle descriptor Workspace', async () => { + const directory = await createTempDir('ecos-workspace-service-domain-write-') await mkdir(join(directory, 'home'), { recursive: true }) - await mkdir(join(directory, 'config'), { recursive: true }) - const stepPath = join(directory, 'config', 'dreamplace_ecc.json') - await writeFile(stepPath, '{\n "density_weight": 0.2\n}\n', 'utf8') - - const { enqueueParameterWrite, workspaceParameterWriteQueueKey } = - await import('./workspaceParametersFile') - const queueKey = await workspaceParameterWriteQueueKey(directory) - let release!: () => void - const gate = new Promise((resolveGate) => { - release = resolveGate - }) - const hold = enqueueParameterWrite(queueKey, async () => { - await gate - }) - - const service = createApplyService(directory) - const editorContent = '{\n "density_weight": 0.8,\n "extra": true\n}\n' - const editor = service.writeProjectTextFile(stepPath, editorContent) - - let editorDone = false - void editor.then(() => { - editorDone = true - }) - await delay(50) - expect(editorDone).toBe(false) - await expect(readFile(stepPath, 'utf8')).resolves.toBe( - '{\n "density_weight": 0.2\n}\n', - ) - - release() - await hold - await editor - await expect(readFile(stepPath, 'utf8')).resolves.toBe(editorContent) - }) - - it('lets a later agent step-config RMW observe a queued editor save', async () => { - const directory = await createTempDir('ecos-workspace-service-step-config-overlap-') - await mkdir(join(directory, 'home'), { recursive: true }) - await mkdir(join(directory, 'config'), { recursive: true }) - const tomlPath = join(directory, 'home', 'params.toml') - const stepPath = join(directory, 'config', 'dreamplace_ecc.json') - await writeFile(tomlPath, '[params]\ndesign = "gcd"\nmax_fanout = 20\n', 'utf8') - await writeFile(stepPath, '{\n "density_weight": 0.2\n}\n', 'utf8') - - const { enqueueParameterWrite, workspaceParameterWriteQueueKey } = - await import('./workspaceParametersFile') - const queueKey = await workspaceParameterWriteQueueKey(directory) - let release!: () => void - const gate = new Promise((resolveGate) => { - release = resolveGate - }) - const hold = enqueueParameterWrite(queueKey, async () => { - await gate - }) - - const service = createApplyService(directory) - const editorContent = '{\n "density_weight": 0.8,\n "extra": true\n}\n' - const editor = service.writeProjectTextFile(stepPath, editorContent) - await delay(20) - const agent = service.applyWorkspaceParameterWrites(directory, [ - { - file: 'config/dreamplace_ecc.json', - json_path: ['density_weight'], - knob_id: 'place.density_weight', - surface: 'step_config', - value: 0.1, - }, - ]) - - release() - await hold - await Promise.all([agent, editor]) - - const finalDocument = JSON.parse(await readFile(stepPath, 'utf8')) as { - density_weight: number - extra?: boolean + await writeFile(join(directory, 'home', 'workspace.toml'), 'format = 1\n') + const filePath = join(directory, 'home', 'parameters.json') + const runtimeMutationGuard = { + isWorkspaceRuntimeActive: vi.fn().mockReturnValue(false), } - // Without the shared queue the agent can read 0.2, the editor can land - // `extra`, and the agent rename then drops it. Serialized, the agent - // RMW sees the editor document and keeps unknown leaves. - expect(finalDocument.extra).toBe(true) - expect(finalDocument.density_weight).toBe(0.1) - }) - - it('refuses a queued config-editor save after the active workspace changes', async () => { - const workspaceA = await createTempDir('ecos-workspace-service-editor-root-a-') - const workspaceB = await createTempDir('ecos-workspace-service-editor-root-b-') - await mkdir(join(workspaceA, 'home'), { recursive: true }) - await mkdir(join(workspaceA, 'config'), { recursive: true }) - const stepPath = join(workspaceA, 'config', 'dreamplace_ecc.json') - const original = '{\n "density_weight": 0.2\n}\n' - await writeFile(stepPath, original, 'utf8') - - const projectScopeProvider = createProjectScopeProvider(workspaceA, workspaceA) - projectScopeProvider.requestWritableProjectPathAccess = vi.fn( - async (path: string) => path, - ) - let activeRoot = workspaceA - projectScopeProvider.getProjectRoot = vi.fn(async () => activeRoot) - const service = new WorkspaceService({ - projectScopeProvider, - replacementJournalDirectory: join(workspaceA, '.workspace-replacement-journals'), - }) - - const { enqueueParameterWrite, workspaceParameterWriteQueueKey } = - await import('./workspaceParametersFile') - const queueKey = await workspaceParameterWriteQueueKey(workspaceA) - let release!: () => void - const gate = new Promise((resolveGate) => { - release = resolveGate - }) - const hold = enqueueParameterWrite(queueKey, async () => { - await gate + const { service } = createWorkspaceService(directory, filePath, { + runtimeMutationGuard, }) - const editor = service.writeProjectTextFile( - stepPath, - '{\n "density_weight": 0.8\n}\n', - ) - await delay(20) - activeRoot = workspaceB - release() - await hold - await expect(editor).rejects.toThrow(/active workspace/) - await expect(readFile(stepPath, 'utf8')).resolves.toBe(original) - }) -}) - -describe('hasWorkspaceConfigShadow', () => { - it('refuses to probe paths outside the project scope', async () => { - const directory = await createTempDir('ecos-workspace-service-') - const { projectScopeProvider, service } = createWorkspaceService(directory, directory) - projectScopeProvider.requestProjectPathAccess = vi - .fn() - .mockRejectedValue( - new Error('Refusing to grant access outside current project root'), - ) - - await expect(service.hasWorkspaceConfigShadow('/etc')).rejects.toThrow( - /outside current project root/, - ) - }) - - it('probes the shadow pair for in-scope workspaces', async () => { - const directory = await createTempDir('ecos-workspace-service-') - const workspace = join(directory, 'ws') - await mkdir(join(workspace, 'home'), { recursive: true }) - await writeFile(join(workspace, 'home', 'params.toml'), '[params]\n', 'utf8') - await writeFile(join(workspace, 'home', 'parameters.json'), '{}', 'utf8') - const { projectScopeProvider, service } = createWorkspaceService(directory, directory) - - await expect(service.hasWorkspaceConfigShadow(workspace)).resolves.toBe(true) - expect(projectScopeProvider.requestProjectPathAccess).toHaveBeenCalledWith( - join(workspace, 'home', 'params.toml'), - ) + await expect( + service.writeProjectTextFile('/workspace/home/parameters.json', '{}'), + ).rejects.toThrow('must be changed through an ECC configuration command') + expect(runtimeMutationGuard.isWorkspaceRuntimeActive).not.toHaveBeenCalled() }) }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceService.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceService.ts index dfcb55b8f..67cbe9d01 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceService.ts @@ -1,56 +1,38 @@ import { randomUUID } from 'node:crypto' import { - lstat, mkdir, open, readFile, readdir, - realpath, rename, rm, stat, writeFile, } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' -import { watch, type FSWatcher } from 'chokidar' -import { - desktopAgentParameterWriteFiles, - hasSafeJsonPath, - type DesktopAgentWorkspaceParameterWrite, - type DesktopProjectFileChangedEvent, - type DesktopProjectFileChangeEventType, - type DesktopProjectDirectoryEntry, - type DesktopProjectTextFileChunk, - type DesktopProjectTextFileTail, - type DesktopProjectTextFileUpdate, - type ScannedPdkDirectory, - type ScannedRtlDirectory, - type WorkspaceDesignFileAddResult, - type WorkspaceDesignFileEntry, - type WorkspaceDirectoryReplacement, +import type { + DesktopProjectDirectoryEntry, + DesktopProjectTextFileChunk, + DesktopProjectTextFileTail, + HdlModuleDiscoveryRequest, + HdlModuleDiscoveryResult, + ScannedPdkDirectory, + ScannedRtlDirectory, + WorkspaceDirectoryReplacement, } from '@ecos-studio/shared' -import { LogTailService } from './logTailService' -import { isPathWithinRoot, isSameOrAncestorPath } from './pathScope' +import { isPathWithinRoot } from './pathScope' import { scanRtlDirectory as scanRtlDirectoryFiles } from './rtlDirectoryScanner' +import { discoverHdlModules as discoverHdlModulesFromSources } from './hdlModuleDiscovery' import { addWorkspaceDesignFiles, getWorkspaceFilelistPath, listWorkspaceDesignFiles, removeWorkspaceDesignFile, } from './designFileService' -import { - applyQueuedWorkspaceParameterWrites, - editWorkspaceParameters as editWorkspaceParametersFile, - enqueueParameterWrite, - hasWorkspaceConfigShadow as hasWorkspaceConfigShadowFile, - locateWorkspaceParametersFile, - parseWorkspaceParametersText, - readWorkspaceConfigContained, - WORKSPACE_CONFIG_BASENAME, - workspaceParameterWriteQueueKey, - writeTextAtomically, - type PreparedStepConfigWrite, -} from './workspaceParametersFile' +import type { + WorkspaceDesignFileAddResult, + WorkspaceDesignFileEntry, +} from '@ecos-studio/shared' export interface ProjectScopeProvider { approvePendingExternalReadRoots?( @@ -104,8 +86,10 @@ interface DirectoryReplacementJournalRecord { } const UTF8_MAX_BYTES_PER_CODE_UNIT = 4 -export const WORKSPACE_RUNTIME_MUTATION_BLOCKED_MESSAGE = +const WORKSPACE_RUNTIME_MUTATION_BLOCKED_MESSAGE = 'Cannot save workspace configuration while the workspace flow is running. Wait for it to finish before editing parameters or step config.' +const WORKSPACE_CONFIGURATION_WRITE_BLOCKED_MESSAGE = + 'Backend Workspace configuration must be changed through an ECC configuration command.' const WORKSPACE_REPLACEMENT_BLOCKED_MESSAGE = 'Cannot replace a workspace while its flow is running. Wait for it to finish before deleting or replacing the workspace.' @@ -181,10 +165,6 @@ function isSamePath(path: string, otherPath: string): boolean { return relative(path, otherPath) === '' } -function shouldIgnoreWatchPath(path: string, targetPath: string): boolean { - return !isSameOrAncestorPath(path, targetPath) -} - function normalizeRelativePathForMatch(path: string): string { return path.replace(/\\/g, '/') } @@ -234,38 +214,23 @@ async function readManifestReplacementReferences( } } -function isRuntimeProtectedProjectPath( - canonicalPath: string, - projectRoot: string, -): boolean { - const relativePath = normalizeRelativePathForMatch(relative(projectRoot, canonicalPath)) - return ( - relativePath === 'home/params.toml' || - relativePath === 'home/parameters.json' || - (relativePath.startsWith('config/') && relativePath.endsWith('.json')) - ) -} - -async function findProjectFileWatchDirectory( - path: string, - rootPath: string, -): Promise { - let candidate = dirname(path) - - while (candidate && isPathWithinRoot(candidate, rootPath)) { - try { - const candidateStats = await stat(candidate) - if (candidateStats.isDirectory()) return candidate - } catch (error) { - if (!isNodeErrorWithCode(error, 'ENOENT')) { - throw error - } - } - - candidate = dirname(candidate) +function protectedWorkspaceRoot(canonicalPath: string): string | null { + const parent = dirname(canonicalPath) + const directory = basename(parent).toLowerCase() + const filename = basename(canonicalPath).toLowerCase() + if ( + directory === 'home' && + [ + 'workspace.toml', + 'params.toml', + 'parameters.json', + 'pdk.json', + 'flow.json', + ].includes(filename) + ) { + return dirname(parent) } - - return rootPath + return directory === 'config' && filename.endsWith('.json') ? dirname(parent) : null } async function pathExists(path: string): Promise { @@ -295,85 +260,26 @@ async function createUniqueReplacementBackupPath(targetPath: string): Promise { - await new Promise((resolve, reject) => { - const cleanup = () => { - watcher.off('ready', onReady) - watcher.off('error', onError) - } - const onReady = () => { - cleanup() - resolve() - } - const onError = (error: unknown) => { - cleanup() - reject(error) - } - - watcher.once('ready', onReady) - watcher.once('error', onError) - }) -} - export class WorkspaceService { private readonly projectScopeProvider: ProjectScopeProvider private readonly replacementJournalDirectory: string private readonly runtimeMutationGuard?: RuntimeMutationGuard - private readonly logTailService: LogTailService private readonly directoryReplacements = new Map() - private readonly projectFileWatchers = new Map Promise }>() - private nextProjectFileWatchId = 1 constructor(options: WorkspaceServiceOptions) { this.projectScopeProvider = options.projectScopeProvider this.replacementJournalDirectory = options.replacementJournalDirectory this.runtimeMutationGuard = options.runtimeMutationGuard - this.logTailService = new LogTailService({ - projectScopeProvider: this.projectScopeProvider, - textReader: this, - }) } async isProjectDirectory(path: string): Promise { return await this.projectScopeProvider.isProjectDirectory(path) } + async getProjectRoot(): Promise { + return await this.projectScopeProvider.getProjectRoot() + } + async pathExists(path: string): Promise { return await pathExists(resolve(path)) } @@ -442,8 +348,6 @@ export class WorkspaceService { } async clearProjectRoot(): Promise { - // Per-window scope only. File/log subscriptions are tracked by the IPC layer - // and cleaned up for the calling window (or on sender destroy). await this.projectScopeProvider.clearProjectRoot() } @@ -468,219 +372,6 @@ export class WorkspaceService { } } - /** True when a workspace home/ holds both the canonical TOML and the - * legacy JSON: the JSON is inert and the user should delete it. */ - async hasWorkspaceConfigShadow(workspacePath: string): Promise { - // Advisory probe, but still scope-checked like the parameter read it - // rides on: one access check on the TOML candidate covers its JSON - // sibling (scope is per-root, not per-file). - await this.projectScopeProvider.requestProjectPathAccess( - join(workspacePath, 'home', WORKSPACE_CONFIG_BASENAME), - ) - return await hasWorkspaceConfigShadowFile(workspacePath) - } - - /** - * Read a workspace's persisted parameters (home/params.toml preferred, - * home/parameters.json fallback) for callers that only know the workspace - * directory — e.g. wizard prefill before the workspace is opened. - */ - async readWorkspaceParameters( - workspacePath: string, - ): Promise | null> { - const location = await locateWorkspaceParametersFile(workspacePath) - if (!location) return null - try { - const canonicalPath = await this.projectScopeProvider.requestProjectPathAccess( - location.path, - ) - const raw = await readWorkspaceConfigContained(location.path, canonicalPath) - return parseWorkspaceParametersText(raw, location.format, workspacePath) - } catch (error) { - if (isNodeErrorWithCode(error, 'ENOENT')) { - return null - } - - throw error - } - } - - /** - * Apply existing-path-only parameter edits (agent surface) to the - * workspace configuration on disk. The path vocabulary is interpreted in - * the on-disk file's format by the shared helper. - */ - async editWorkspaceParameters( - workspacePath: string, - edits: { json_path: (string | number)[]; value: unknown }[], - ): Promise<{ format: 'toml' | 'json'; path: string }> { - const location = await locateWorkspaceParametersFile(workspacePath) - if (!location) { - throw new Error(`Workspace parameters file not found under: ${workspacePath}`) - } - const targetStats = await lstat(location.path) - if (targetStats.isSymbolicLink()) { - // A symlinked config path escapes the runtime mutation guard's - // spelled-path protection and makes the write target ambiguous — - // refuse it, matching ECC's own refusal to write through symlinks. - throw new Error( - `Refusing to edit workspace parameters through a symlink: ${location.path}`, - ) - } - const authorizingRoot = await this.projectScopeProvider.getProjectRoot() - const [canonicalWorkspace, canonicalRoot] = await Promise.all([ - realpath(workspacePath), - realpath(authorizingRoot), - ]) - if (canonicalWorkspace !== canonicalRoot) { - throw new Error( - 'Refusing to edit workspace parameters: the target is not the active workspace', - ) - } - const canonicalPath = - await this.projectScopeProvider.requestWritableProjectPathAccess(location.path) - await this.assertCanWriteProjectTextFile(canonicalPath) - return await editWorkspaceParametersFile( - workspacePath, - edits, - { - format: location.format, - path: canonicalPath, - spelledPath: location.path, - }, - // Re-checked inside the serialized operation: the authorization was - // issued against THIS root, so an active-root change (or a flow - // starting while the edit queued behind another writer) blocks it. - async () => { - const activeRoot = await this.projectScopeProvider.getProjectRoot() - const [expected, active, target] = await Promise.all([ - realpath(authorizingRoot), - realpath(activeRoot), - realpath(workspacePath), - ]) - if (expected !== active || target !== active) { - throw new Error( - 'Refusing to edit workspace parameters: the active workspace ' + - 'changed before the edit completed', - ) - } - await this.assertCanWriteProjectTextFile(canonicalPath) - }, - ) - } - - /** - * Apply Agent parameter writes (workspace config + step configs) through - * the serialized atomic parameter queue. Rollback restores only the - * revision this operation produced. - */ - async applyWorkspaceParameterWrites( - workspacePath: string, - writes: DesktopAgentWorkspaceParameterWrite[], - ): Promise { - const authorizingRoot = await this.projectScopeProvider.getProjectRoot() - const [canonicalWorkspace, canonicalRoot] = await Promise.all([ - realpath(workspacePath), - realpath(authorizingRoot), - ]) - if (canonicalWorkspace !== canonicalRoot) { - throw new Error( - 'Refusing to apply workspace parameter writes: the target is not the active workspace', - ) - } - - const parameterEdits: { json_path: (string | number)[]; value: unknown }[] = [] - const stepConfigWrites: PreparedStepConfigWrite[] = [] - const seenStepFiles = new Set() - for (const write of writes) { - if ( - !(desktopAgentParameterWriteFiles as readonly string[]).includes(write.file) || - !hasSafeJsonPath(write.json_path) - ) { - throw new Error( - `Parameter path ${JSON.stringify(write.json_path)} is not allowed in ${write.file}.`, - ) - } - if (write.file === 'home/params.toml' || write.file === 'home/parameters.json') { - parameterEdits.push({ json_path: write.json_path, value: write.value }) - continue - } - const spelledPath = join(workspacePath, write.file) - const targetStats = await lstat(spelledPath) - if (targetStats.isSymbolicLink()) { - throw new Error(`Refusing to edit step config through a symlink: ${spelledPath}`) - } - const canonicalPath = - await this.projectScopeProvider.requestWritableProjectPathAccess(spelledPath) - await this.assertCanWriteProjectTextFile(canonicalPath) - if (!seenStepFiles.has(write.file)) { - seenStepFiles.add(write.file) - stepConfigWrites.push({ - canonicalPath, - edits: writes - .filter((item) => item.file === write.file) - .map((item) => ({ json_path: item.json_path, value: item.value })), - spelledPath, - }) - } - } - - let authorizedLocation: - | { - format: 'toml' | 'json' - path: string - spelledPath: string - } - | undefined - if (parameterEdits.length > 0) { - const location = await locateWorkspaceParametersFile(workspacePath) - if (!location) { - throw new Error(`Workspace parameters file not found under: ${workspacePath}`) - } - const targetStats = await lstat(location.path) - if (targetStats.isSymbolicLink()) { - throw new Error( - `Refusing to edit workspace parameters through a symlink: ${location.path}`, - ) - } - const canonicalPath = - await this.projectScopeProvider.requestWritableProjectPathAccess(location.path) - await this.assertCanWriteProjectTextFile(canonicalPath) - authorizedLocation = { - format: location.format, - path: canonicalPath, - spelledPath: location.path, - } - } - - await applyQueuedWorkspaceParameterWrites( - workspacePath, - parameterEdits, - stepConfigWrites, - authorizedLocation, - async () => { - const activeRoot = await this.projectScopeProvider.getProjectRoot() - const [expected, active, target] = await Promise.all([ - realpath(authorizingRoot), - realpath(activeRoot), - realpath(workspacePath), - ]) - if (expected !== active || target !== active) { - throw new Error( - 'Refusing to apply workspace parameter writes: the active workspace ' + - 'changed before the write completed', - ) - } - if (authorizedLocation) { - await this.assertCanWriteProjectTextFile(authorizedLocation.path) - } - for (const step of stepConfigWrites) { - await this.assertCanWriteProjectTextFile(step.canonicalPath) - } - }, - ) - } - async readProjectTextFileTail(path: string, maxChars: number): Promise { const result = await this.readOptionalProjectTextFileTail(path, maxChars) return result?.content ?? null @@ -719,54 +410,6 @@ export class WorkspaceService { } } - async readOptionalProjectTextFileUpdate( - path: string, - fromOffsetBytes: number, - maxChars: number, - ): Promise { - const canonicalPath = await this.projectScopeProvider.requestProjectPathAccess(path) - const boundedMaxChars = boundedTextCharCount(maxChars) - const readBytes = boundedMaxChars * UTF8_MAX_BYTES_PER_CODE_UNIT - - let handle: Awaited> | null = null - try { - handle = await open(canonicalPath, 'r') - const fileStats = await handle.stat() - const normalizedOffset = Math.max(0, Math.floor(fromOffsetBytes)) - const fileWasTruncated = normalizedOffset > fileStats.size - const unreadBytes = Math.max(0, fileStats.size - normalizedOffset) - const tooMuchUnread = unreadBytes > readBytes - const start = - fileWasTruncated || tooMuchUnread - ? Math.max(0, fileStats.size - readBytes) - : normalizedOffset - const length = fileStats.size - start - const buffer = Buffer.alloc(length) - const result = - length > 0 ? await handle.read(buffer, 0, length, start) : { bytesRead: 0 } - const raw = buffer.subarray(0, result.bytesRead).toString('utf8') - const decodedTooLong = raw.length > boundedMaxChars - const truncated = fileWasTruncated || tooMuchUnread || decodedTooLong - - return { - content: truncated ? raw.slice(-boundedMaxChars) : raw, - fromOffsetBytes: start, - nextOffsetBytes: fileStats.size, - sizeBytes: fileStats.size, - reset: fileWasTruncated || tooMuchUnread || decodedTooLong, - truncated, - } - } catch (error) { - if (isNodeErrorWithCode(error, 'ENOENT')) { - return null - } - - throw error - } finally { - await handle?.close() - } - } - /** * Reads one bounded, UTF-8-safe chunk without materializing a complete NFS * log in Electron main or sending an unbounded IPC payload to the renderer. @@ -818,59 +461,16 @@ export class WorkspaceService { } } - async subscribeProjectLogTail( - path: string, - options: { - maxInitialChars?: number - maxChunkChars?: number - pollIntervalMs?: number - } = {}, - listener: (event: import('@ecos-studio/shared').DesktopProjectLogTailEvent) => void, - ): Promise { - return await this.logTailService.subscribeProjectLogTail(path, options, listener) - } - - async unsubscribeProjectLogTail(subscriptionId: string): Promise { - await this.logTailService.unsubscribeProjectLogTail(subscriptionId) - } - async readProjectBinaryFile(path: string): Promise { const canonicalPath = await this.projectScopeProvider.requestProjectPathAccess(path) return new Uint8Array(await readFile(canonicalPath)) } async writeProjectTextFile(path: string, content: string): Promise { - const authorizingRoot = await this.projectScopeProvider.getProjectRoot() const canonicalPath = await this.projectScopeProvider.requestWritableProjectPathAccess(path) - await this.assertCanWriteProjectTextFile(canonicalPath, authorizingRoot) - if (!isRuntimeProtectedProjectPath(canonicalPath, authorizingRoot)) { - await writeFile(canonicalPath, content, 'utf8') - return - } - // Step-config and workspace-parameter files share the agent RMW queue: - // an editor save that lands between an agent read and rename would - // otherwise be clobbered, and CAS rollback only runs on failure. - await enqueueParameterWrite( - await workspaceParameterWriteQueueKey(authorizingRoot), - async () => { - const activeRoot = await this.projectScopeProvider.getProjectRoot() - const [expected, active] = await Promise.all([ - realpath(authorizingRoot), - realpath(activeRoot), - ]) - if (expected !== active) { - throw new Error( - 'Refusing to write project text file: the active workspace ' + - 'changed before the write completed', - ) - } - await this.assertCanWriteProjectTextFile(canonicalPath, authorizingRoot) - await writeTextAtomically(canonicalPath, content, { - authorizedParent: dirname(canonicalPath), - }) - }, - ) + await this.assertCanWriteProjectTextFile(canonicalPath) + await writeFile(canonicalPath, content, 'utf8') } async listProjectDirectory(path: string): Promise { @@ -1231,99 +831,6 @@ export class WorkspaceService { await rm(journalPath, { force: true }) } - async watchProjectFile( - path: string, - listener: (event: DesktopProjectFileChangedEvent) => void, - ): Promise { - const canonicalPath = await this.projectScopeProvider.requestProjectPathAccess(path) - const projectRoot = await this.projectScopeProvider.getProjectRoot() - const watchDirectory = await findProjectFileWatchDirectory(canonicalPath, projectRoot) - const subscriptionId = `project-file-watch-${this.nextProjectFileWatchId++}` - let closed = false - let pendingRawEmitTimer: ReturnType | null = null - let pendingRawEventType: DesktopProjectFileChangeEventType = 'change' - - const clearPendingRawEmit = () => { - if (!pendingRawEmitTimer) return - clearTimeout(pendingRawEmitTimer) - pendingRawEmitTimer = null - } - - const emit = (eventType: DesktopProjectFileChangeEventType) => { - if (closed) return - listener({ - subscriptionId, - path: canonicalPath, - eventType, - }) - } - - const scheduleRawFallbackEmit = (eventType: DesktopProjectFileChangeEventType) => { - pendingRawEventType = eventType - if (pendingRawEmitTimer) return - pendingRawEmitTimer = setTimeout(() => { - pendingRawEmitTimer = null - emit(pendingRawEventType) - }, 50) - } - - const watcher = watch(watchDirectory, { - ignored: (path) => shouldIgnoreWatchPath(path, canonicalPath), - ignoreInitial: true, - persistent: false, - }) - - watcher.on('all', (eventType, changedPath) => { - if ( - eventType !== 'add' && - eventType !== 'addDir' && - eventType !== 'change' && - eventType !== 'unlink' && - eventType !== 'unlinkDir' - ) { - return - } - if (!isSamePath(changedPath, canonicalPath)) return - - clearPendingRawEmit() - emit(mapChokidarEventType(eventType)) - }) - watcher.on('raw', (rawEventType, rawPath, details) => { - if (rawEventType !== 'change' && rawEventType !== 'rename') return - if (typeof rawPath !== 'string' || !rawPath) return - const changedPath = getRawEventPath(rawPath, details, watchDirectory, canonicalPath) - if (!isSamePath(changedPath, canonicalPath)) return - - scheduleRawFallbackEmit(rawEventType === 'rename' ? 'rename' : 'change') - }) - watcher.on('error', () => { - emit('error') - }) - - try { - await waitForWatcherReady(watcher) - } catch (error) { - await watcher.close() - throw error - } - - this.projectFileWatchers.set(subscriptionId, { - close: async () => { - closed = true - clearPendingRawEmit() - await watcher.close() - }, - }) - return subscriptionId - } - - async unwatchProjectFile(subscriptionId: string): Promise { - const record = this.projectFileWatchers.get(subscriptionId) - if (!record) return - await record.close() - this.projectFileWatchers.delete(subscriptionId) - } - async scanPdkDirectory(path: string): Promise { return await this.projectScopeProvider.scanPdkDirectory(path) } @@ -1332,6 +839,12 @@ export class WorkspaceService { return await scanRtlDirectoryFiles(path) } + async discoverHdlModules( + request: HdlModuleDiscoveryRequest, + ): Promise { + return await discoverHdlModulesFromSources(request) + } + async listDesignFiles(): Promise { const projectRoot = await this.projectScopeProvider.getProjectRoot() return await listWorkspaceDesignFiles(projectRoot) @@ -1359,26 +872,21 @@ export class WorkspaceService { return await removeWorkspaceDesignFile(projectRoot, filelistEntry) } - private async closeAllProjectFileWatchers(): Promise { - await Promise.all( - [...this.projectFileWatchers.values()].map(async (record) => { - await record.close() - }), - ) - this.projectFileWatchers.clear() - } + private async assertCanWriteProjectTextFile(canonicalPath: string): Promise { + const workspaceRoot = protectedWorkspaceRoot(canonicalPath) + if (!workspaceRoot) return - private async assertCanWriteProjectTextFile( - canonicalPath: string, - authorizingRoot?: string, - ): Promise { - if (!this.runtimeMutationGuard) return - - const projectRoot = - authorizingRoot ?? (await this.projectScopeProvider.getProjectRoot()) - if (!isRuntimeProtectedProjectPath(canonicalPath, projectRoot)) return + const relativePath = normalizeRelativePathForMatch( + relative(workspaceRoot, canonicalPath), + ) + if ( + relativePath === 'home/workspace.toml' || + (await pathExists(join(workspaceRoot, 'home', 'workspace.toml'))) + ) { + throw new Error(WORKSPACE_CONFIGURATION_WRITE_BLOCKED_MESSAGE) + } - if (await this.runtimeMutationGuard.isWorkspaceRuntimeActive(projectRoot)) { + if (await this.runtimeMutationGuard?.isWorkspaceRuntimeActive(workspaceRoot)) { throw new Error(WORKSPACE_RUNTIME_MUTATION_BLOCKED_MESSAGE) } } diff --git a/ecos/gui/apps/desktop-electron/package.json b/ecos/gui/apps/desktop-electron/package.json index 15f21d016..fd9826f69 100644 --- a/ecos/gui/apps/desktop-electron/package.json +++ b/ecos/gui/apps/desktop-electron/package.json @@ -34,7 +34,6 @@ }, "dependencies": { "chokidar": "^4.0.3", - "node-pty": "^1.1.0", - "smol-toml": "^1.8.0" + "node-pty": "^1.1.0" } } diff --git a/ecos/gui/apps/renderer/package.json b/ecos/gui/apps/renderer/package.json index c3a9817b8..38ac2dd63 100644 --- a/ecos/gui/apps/renderer/package.json +++ b/ecos/gui/apps/renderer/package.json @@ -23,11 +23,8 @@ "markdown-it": "^14.1.0", "monaco-editor": "0.55.1", "pinia": "^3.0.4", - "primeicons": "^7.0.0", "primevue": "^4.4.0", - "rbush": "^4.0.1", "remixicon": "^4.8.0", - "uipro-cli": "^2.1.1", "vue": "^3.4.21", "vue-router": "^4.6.4" }, @@ -35,7 +32,6 @@ "@tailwindcss/vite": "^4.1.18", "@types/markdown-it": "^14.1.2", "@types/node": "^25.0.6", - "@types/rbush": "^4.0.0", "@vitejs/plugin-vue": "^6.0.3", "@vue/test-utils": "^2.4.11", "happy-dom": "^20.11.1", diff --git a/ecos/gui/apps/renderer/src/App.agentWorkspaceSetup.test.ts b/ecos/gui/apps/renderer/src/App.agentWorkspaceSetup.test.ts deleted file mode 100644 index d563baa41..000000000 --- a/ecos/gui/apps/renderer/src/App.agentWorkspaceSetup.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest' -import source from './App.vue?raw' - -describe('agent workspace creation', () => { - it('persists the frozen contract and returns its workspace for execution tracking', () => { - expect(source).toContain('workspace_setup_contract.v2.json') - expect(source).toContain('api.workspace.writeProjectTextFile') - expect(source).toContain('return { created: true, workspacePath }') - expect(source).toContain('ownerSessionId,') - expect(source).not.toContain('void runAllFlow()') - expect(source).not.toContain('agentShell.expandWorkspaceChat()') - }) - - it('returns the workspace creation failure reason to the chat host', () => { - expect(source).toContain('lastWorkspaceCreationError.value') - expect(source).toContain('created: false') - }) - - it('keeps the managed project context when opening the new workspace home', () => { - expect(source).toContain("path: '/workspace/home'") - expect(source).toContain('projectRoot: contract.project_context.project_root') - expect(source).toContain('projectName: contract.project_context.project_name') - }) - - it('hosts the flow-scoped step configuration editor in a top-level dialog', () => { - expect(source).toContain('@step-config="showStepConfigDialog = true"') - expect(source).toContain(':visible="showStepConfigDialog"') - expect(source).toContain('@update:visible="updateStepConfigDialogVisibility"') - expect(source).toContain('') - }) - - it('does not auto-open Edit/Config after agent workspace creation', () => { - const createStart = source.indexOf('async function createWorkspaceFromAgent') - const createEnd = source.indexOf('provide(agentWorkspaceSetupKey', createStart) - const createSource = source.slice(createStart, createEnd) - expect(createSource).not.toContain('requestOpenStepConfigAfterCreate') - }) -}) diff --git a/ecos/gui/apps/renderer/src/App.design-report-export.test.ts b/ecos/gui/apps/renderer/src/App.design-report-export.test.ts deleted file mode 100644 index 30e09194a..000000000 --- a/ecos/gui/apps/renderer/src/App.design-report-export.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest' -import appSource from './App.vue?raw' - -describe('App design report export wiring', () => { - it('restricts design report export actions to the workspace route', () => { - expect(appSource).toContain('useDesignReportExport') - expect(appSource).toContain('DesignReportExportDialog') - expect(appSource).toContain('openDesignReportExport') - expect(appSource).not.toContain('design-report-export-enabled') - expect(appSource).toMatch( - /exportDesignSummary: \(\) => \{[\s\S]*if \(isWorkspaceRoute\.value\)[\s\S]*openDesignReportExport\(\)/, - ) - expect(appSource).toMatch( - /appMenuActionIds\.exportDesignSummary,[\s\S]*workspaceRoute/, - ) - }) - - it('mounts DesignReportExportDialog with full handlers and bindings', () => { - expect(appSource).toContain(' { - expect(appSource).toContain(' { - it('does not let a failed zoom setting restore block app startup', () => { - const restoreStart = appSource.indexOf( - 'if (desktopApi.value) {', - appSource.indexOf('onMounted(async () =>'), - ) - const restoreEnd = appSource.indexOf('themeStore.initTheme()', restoreStart) - const restoreSource = appSource.slice(restoreStart, restoreEnd) - - expect(restoreSource).toContain('try {') - expect(restoreSource).toContain('catch (error)') - expect(restoreSource).toContain('Failed to restore UI zoom setting') - }) - - it('does not let zoom persistence failure reject after applying the factor', () => { - const zoomStart = appSource.indexOf('async function setZoomFactor') - const zoomEnd = appSource.indexOf('async function adjustZoom', zoomStart) - const zoomSource = appSource.slice(zoomStart, zoomEnd) - - expect(zoomSource).toContain('await api.window.setZoomFactor(factor)') - expect(zoomSource).toContain('await api.settings.set(zoomSettingKey, factor)') - expect(zoomSource).toContain('Failed to persist UI zoom setting') - }) - - it('consumes second-instance openWorkspace query through the shared launch helper', () => { - expect(appSource).toContain('consumeOpenWorkspaceLaunchQuery') - expect(appSource).toContain('route.query.openWorkspace') - expect(appSource).toContain("await router.replace('/workspace')") - expect(appSource).toContain('delete nextQuery.openWorkspace') - }) - - it('restricts signoff export actions to the workspace route', () => { - expect(appSource).not.toContain('signoff-package-export-enabled') - expect(appSource).toContain('exportSignoffPackage,') - expect(appSource).toContain('appMenuActionIds.exportSignoffPackage') - expect(appSource).toContain( - 'if (isWorkspaceRoute.value) return exportSignoffPackage()', - ) - }) - - it('opens the shared workspace wizard with current workspace data from the File menu', () => { - expect(appSource).toContain(':initial-config="workspaceWizardInitialConfig"') - expect(appSource).toContain(':title="workspaceWizardTitle"') - expect(appSource).toContain( - "return reconfigureWorkspacePath.value ? 'Update Workspace' : 'New Workspace'", - ) - expect(appSource).toContain('reconfigureWorkspace: openWorkspaceReconfigureWizard') - expect(appSource).toContain('buildReconfigureWizardInitialConfig') - expect(appSource).toContain('replaceExistingWorkspace: true') - expect(appSource).toContain('keepReplacementBackup') - expect(appSource).toContain('lockWorkspaceDirectory: true') - expect(appSource).toContain('readOptionalProjectTextFile') - expect(appSource).toContain('registerProjectReadRoot') - expect(appSource).toContain('resolveProjectRouteContextForWorkspace') - - const openStart = appSource.indexOf('async function openWorkspaceReconfigureWizard') - const openEnd = appSource.indexOf( - 'async function buildReconfigureWizardInitialConfig', - openStart, - ) - const openSource = appSource.slice(openStart, openEnd) - expect(openSource).toContain('if (projectContext)') - expect(openSource).toContain( - 'await api.workspace.registerProjectReadRoot(projectContext.projectRoot)', - ) - expect(openSource).not.toContain('parentLocalPath') - }) - - it('keeps standalone workspace updates outside project management', () => { - expect(appSource).toContain('standaloneWorkspace: !resolvedProjectContext') - expect(appSource).toContain('project_context: resolvedProjectContext') - expect(appSource).toContain(': undefined,') - expect(appSource).not.toContain( - 'queryString(route.query.projectRoot) || parentLocalPath(workspacePath)', - ) - }) - - it('asks whether to keep the old workspace backup before running update workspace', () => { - expect(appSource).toContain('showWorkspaceUpdateBackupDialog') - expect(appSource).toContain('pendingWorkspaceUpdateConfig') - expect(appSource).toContain('confirmWorkspaceUpdateBackup') - expect(appSource).toContain('runWorkspaceUpdate(true)') - expect(appSource).toContain('runWorkspaceUpdate(false)') - expect(appSource).toContain('Backup Original') - expect(appSource).toContain('Do Not Backup') - }) - - it('keeps workspace replacement limited to explicit reconfiguration', () => { - expect(appSource).not.toContain('registerHomeWorkspaceRerun') - expect(appSource).not.toContain('rebuildCurrentWorkspaceForHomeRerun') - }) - - it('prefers the current workspace origin files when building reconfigure defaults', () => { - expect(appSource).toContain('scanWorkspaceOriginDesignInputs') - const rtlStart = appSource.indexOf('const rtlList =') - const rtlEnd = appSource.indexOf('const originDef =', rtlStart) - const rtlSource = appSource.slice(rtlStart, rtlEnd) - expect(rtlSource.indexOf('...originInputs.rtlFiles')).toBeLessThan( - rtlSource.indexOf('...stringList(dbInput?.rtl_paths)'), - ) - expect(rtlSource.indexOf('...originInputs.filelists')).toBeLessThan( - rtlSource.indexOf('optionalString(dbInput?.filelist)'), - ) - - const defStart = appSource.indexOf('const originDef =') - const defEnd = appSource.indexOf('const originVerilog =', defStart) - const defSource = appSource.slice(defStart, defEnd) - expect(defSource.indexOf('...originInputs.defFiles')).toBeLessThan( - defSource.indexOf('optionalString(dbInput?.def_path)'), - ) - - const verilogStart = appSource.indexOf('const originVerilog =') - const verilogEnd = appSource.indexOf('const sdc =', verilogStart) - const verilogSource = appSource.slice(verilogStart, verilogEnd) - expect(verilogSource.indexOf('...originInputs.verilogFiles')).toBeLessThan( - verilogSource.indexOf('optionalString(dbInput?.verilog_path)'), - ) - - const sdcStart = appSource.indexOf('const sdc =') - const sdcEnd = appSource.indexOf('return {', sdcStart) - const sdcSource = appSource.slice(sdcStart, sdcEnd) - expect(sdcSource.indexOf('...originInputs.sdcFiles')).toBeLessThan( - sdcSource.indexOf('optionalString(dbInput?.sdc_path)'), - ) - expect(appSource).toContain("'origin/filelist'") - expect(appSource).toContain("fileName === 'filelist'") - expect(appSource).toContain("hasAnySuffix(filePath, ['.def', '.def.gz'])") - expect(appSource).toContain( - "hasAnySuffix(filePath, ['.v', '.v.gz', '.sv', '.sv.gz', '.vg', '.vg.gz'])", - ) - const existsStart = appSource.indexOf('async function workspaceTextFileExists') - const existsEnd = appSource.indexOf('function optionalRecord', existsStart) - const existsSource = appSource.slice(existsStart, existsEnd) - expect(existsSource).toContain('catch') - expect(existsSource).toContain('return false') - }) - - it('keeps cancel local to the wizard instead of navigating away from the workspace', () => { - expect(appSource).toContain('@close="handleWizardClose"') - expect(appSource).toContain('function handleWizardClose()') - expect(appSource).toContain('resetWorkspaceWizard()') - }) - - it('routes prefill number conversion through the lossless bigint guard', () => { - expect(appSource).toContain("from '@/utils/numbers'") - const optionalNumberStart = appSource.indexOf('function optionalNumber') - const optionalNumberEnd = appSource.indexOf( - 'function normalizeDieAreaMode', - optionalNumberStart, - ) - expect(appSource.slice(optionalNumberStart, optionalNumberEnd)).toContain( - 'losslessOptionalNumber(', - ) - const numberListStart = appSource.indexOf('function numberList') - const numberListEnd = appSource.indexOf( - 'function normalizeLocalPath', - numberListStart, - ) - expect(appSource.slice(numberListStart, numberListEnd)).toContain( - 'losslessNumberList(', - ) - }) - - it('falls back to flat die_width/die_height keys when prefilling reconfigure defaults', () => { - const normalizeStart = appSource.indexOf('function normalizeWorkspaceParameters') - const normalizeEnd = appSource.indexOf( - 'function normalizeWorkspaceFlowConfig', - normalizeStart, - ) - const normalizeSource = appSource.slice(normalizeStart, normalizeEnd) - - const widthLine = normalizeSource.indexOf('die_width: optionalNumber(') - const heightLine = normalizeSource.indexOf('die_height: optionalNumber(') - expect(widthLine).toBeGreaterThan(-1) - expect(heightLine).toBeGreaterThan(-1) - expect(normalizeSource.indexOf('dieSize[0]', widthLine)).toBeLessThan( - normalizeSource.indexOf('parametersJson?.die_width', widthLine), - ) - expect(normalizeSource.indexOf('dieSize[1]', heightLine)).toBeLessThan( - normalizeSource.indexOf('parametersJson?.die_height', heightLine), - ) - expect(normalizeSource).toContain('parametersJson?.die_area') - expect(normalizeSource).toContain('hasCanonicalDieSize') - expect(normalizeSource).toContain( - "hasCanonicalDieSize || hasDieSize ? 'width_height'", - ) - expect(normalizeSource).toContain('scalarMarginFromCore(coreMargin') - }) - - it('prefers canonical die_area dimensions when inferring reconfigure die_area_mode', () => { - const normalizeStart = appSource.indexOf('function normalizeWorkspaceParameters') - const normalizeEnd = appSource.indexOf( - 'function normalizeWorkspaceFlowConfig', - normalizeStart, - ) - const normalizeSource = appSource.slice(normalizeStart, normalizeEnd) - expect(normalizeSource).toContain( - "hasCanonicalDieSize || hasDieSize ? 'width_height'", - ) - }) - - it('records project-managed workspaces into project.json after wizard create and reconfigure', () => { - expect(appSource).toContain('registerProjectManagedWorkspace') - expect(appSource).toContain('syncProjectManagedWorkspace') - expect(appSource).toContain('projectContextFromWorkspaceConfig') - expect(appSource).toContain('await syncProjectManagedWorkspace(config)') - - const updateStart = appSource.indexOf('async function runWorkspaceUpdate') - const updateSync = appSource.indexOf( - 'await syncProjectManagedWorkspace(config, normalizeLocalPath(targetReconfigurePath))', - updateStart, - ) - expect(updateSync).toBeGreaterThan(updateStart) - }) - - it('opens Edit/Config after a successful new workspace create', () => { - expect(appSource).toContain('requestOpenStepConfigAfterCreate') - expect(appSource).toContain('usePendingOpenStepConfigAfterCreate') - expect(appSource).toContain('showStepConfigDialog.value = true') - - const createStart = appSource.indexOf('const handleWizardCreate') - const createEnd = appSource.indexOf( - 'function cancelWorkspaceUpdateBackup', - createStart, - ) - const createSource = appSource.slice(createStart, createEnd) - expect(createSource).toContain('if (!success) return') - expect(createSource).toContain('requestOpenStepConfigAfterCreate()') - expect(createSource.indexOf('requestOpenStepConfigAfterCreate()')).toBeGreaterThan( - createSource.indexOf('await syncProjectManagedWorkspace(config)'), - ) - expect(createSource.indexOf("router.push('/workspace')")).toBeGreaterThan( - createSource.indexOf('requestOpenStepConfigAfterCreate()'), - ) - - const updateStart = appSource.indexOf('async function runWorkspaceUpdate') - const updateEnd = appSource.indexOf( - 'async function syncProjectManagedWorkspace', - updateStart, - ) - const updateSource = appSource.slice(updateStart, updateEnd) - expect(updateSource).not.toContain('requestOpenStepConfigAfterCreate') - }) -}) diff --git a/ecos/gui/apps/renderer/src/App.step-config-dialog.test.ts b/ecos/gui/apps/renderer/src/App.step-config-dialog.test.ts deleted file mode 100644 index 459196bf6..000000000 --- a/ecos/gui/apps/renderer/src/App.step-config-dialog.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' -import appSource from './App.vue?raw' -import stepDashboardSource from './components/StepDashboard.vue?raw' - -describe('Step configuration dialog sizing', () => { - it('fills the maximized window instead of stopping at the normal-mode height', () => { - const ruleStart = appSource.indexOf('.step-config-dialog {') - const ruleEnd = appSource.indexOf('}', ruleStart) - expect(appSource.slice(ruleStart, ruleEnd)).toContain('height: min(72vh, 720px)') - - // PrimeVue marks the maximized dialog root with p-dialog-maximized; the inner - // area must stretch to the bottom of the window (above the footer). - expect(appSource).toContain('.p-dialog-maximized .step-config-dialog') - const maximizedStart = appSource.indexOf('.p-dialog-maximized .step-config-dialog') - const maximizedEnd = appSource.indexOf('}', maximizedStart) - expect(appSource.slice(maximizedStart, maximizedEnd)).toContain('height: 100%') - }) - - it('offers and fills on maximize in the Step Dashboard configuration dialog too', () => { - const dialogStart = stepDashboardSource.indexOf( - 'v-model:visible="showStepConfiguration"', - ) - const dialogEnd = stepDashboardSource.indexOf('', dialogStart) - const dialogSource = stepDashboardSource.slice(dialogStart, dialogEnd) - expect(dialogSource).toContain('maximizable') - - const maximizedStart = stepDashboardSource.indexOf( - '.p-dialog-maximized .step-config-dialog', - ) - expect(maximizedStart).toBeGreaterThan(-1) - const maximizedEnd = stepDashboardSource.indexOf('}', maximizedStart) - expect(stepDashboardSource.slice(maximizedStart, maximizedEnd)).toContain( - 'height: 100%', - ) - }) -}) diff --git a/ecos/gui/apps/renderer/src/App.vue b/ecos/gui/apps/renderer/src/App.vue index 902983dfb..b84d97d66 100644 --- a/ecos/gui/apps/renderer/src/App.vue +++ b/ecos/gui/apps/renderer/src/App.vue @@ -6,6 +6,9 @@ @@ -68,7 +71,11 @@ >

Update Workspace

Backup Original Workspace?

-

Keep a copy of the current workspace before replacing it.

+

+ Updating replaces the current Flow state, engineering results, Artifacts, + logs, and user files. Keep a complete Project-managed backup for later + inspection or recovery, or choose permanent replacement without a backup. +