From e999d18294524306dd5a956b59b6589ef60eef58 Mon Sep 17 00:00:00 2001 From: KoEkko <2251930460@qq.com> Date: Sun, 30 Aug 2026 16:19:10 +0800 Subject: [PATCH 001/116] refactor(gui): centralize backend workspace read models --- ecos/docs/gui-backend-refactor-standard.md | 379 ++++ .../backend-qor-project-comparison-v1.md | 273 +++ ecos/docs/specs/backend-workspace-core-v1.md | 261 +++ ecos/gui/README.md | 1 + .../desktop-electron/electron/main/index.ts | 17 + .../electron/main/registerIpc.test.ts | 116 ++ .../electron/main/registerIpc.ts | 204 +- .../electron/preload/index.test.ts | 56 + .../electron/preload/index.ts | 30 + .../backendProjectComparisonService.test.ts | 200 ++ .../backendProjectComparisonService.ts | 520 +++++ .../services/backendWorkspaceService.test.ts | 299 +++ .../services/backendWorkspaceService.ts | 576 ++++++ .../services}/projectAnalysisSnapshot.ts | 54 +- .../electron/services/qorAnalysis.test.ts | 115 ++ .../electron/services/qorAnalysis.ts} | 182 +- .../services/workspaceQorAnalysis.test.ts | 91 + .../electron/services/workspaceQorAnalysis.ts | 297 +++ .../services/workspaceResourceService.ts | 25 +- .../electron/services/workspaceService.ts | 4 + .../src/components/ChecklistTable.vue | 2 +- .../renderer/src/components/LeftSidebar.vue | 4 +- .../components/ProjectQorScoreChart.test.ts | 2 +- .../src/components/ProjectQorScoreChart.vue | 2 +- .../ProjectStepAnalysisPanel.test.ts | 28 +- .../components/ProjectStepAnalysisPanel.vue | 7 +- .../src/components/StepDashboard.test.ts | 4 +- .../renderer/src/components/StepDashboard.vue | 8 +- .../src/components/ThumbnailGallery.vue | 2 +- .../WorkspaceStepConfigDialog.test.ts | 2 +- .../components/WorkspaceStepConfigDialog.vue | 4 +- .../components/home/qorComparisonData.test.ts | 41 +- .../src/components/home/qorComparisonData.ts | 15 +- .../components/projectStepAnalysis.fixture.ts | 112 +- .../components/projectStepAnalysis.test.ts | 475 ----- .../src/components/projectStepAnalysis.ts | 175 +- .../src/components/workbench/FlowLogPanel.vue | 2 +- .../components/workbench/FlowRunControl.vue | 4 +- .../composables/backendFlowProjection.test.ts | 38 + .../src/composables/backendFlowProjection.ts | 111 + ... => useBackendFlowLogs.live-watch.test.ts} | 61 +- .../composables/useBackendFlowLogs.test.ts | 22 + .../src/composables/useBackendFlowLogs.ts | 459 +++++ .../src/composables/useBackendFlowStages.ts | 160 ++ .../src/composables/useBackendWorkspaceQor.ts | 128 ++ .../src/composables/useDesktopRuntime.test.ts | 2 + .../useFlowStages.live-watch.test.ts | 606 ------ .../renderer/src/composables/useFlowStages.ts | 26 +- .../composables/useHomeData.flow-log.test.ts | 22 - .../renderer/src/composables/useHomeData.ts | 1795 ----------------- .../composables/useHomeQorComparison.test.ts | 334 --- .../src/composables/useHomeQorComparison.ts | 378 ---- .../composables/useParameters.runtime.test.ts | 12 +- .../renderer/src/composables/useParameters.ts | 37 +- .../src/composables/usePdkManager.test.ts | 2 + .../src/composables/useStepConfigInfo.test.ts | 2 +- .../src/composables/useStepConfigInfo.ts | 2 +- .../composables/useSubflow.runtime.test.ts | 2 +- .../renderer/src/composables/useSubflow.ts | 2 +- .../src/composables/useVersion.test.ts | 2 + .../backendProjectComparisonSession.test.ts | 98 + .../stores/backendProjectComparisonSession.ts | 154 ++ .../stores/backendWorkspaceSession.test.ts | 114 ++ .../src/stores/backendWorkspaceSession.ts | 112 + .../src/utils/projectAnalysisAvailability.ts | 18 + .../src/utils/projectAnalysisSnapshot.test.ts | 228 --- .../src/utils/projectManagement.test.ts | 527 +---- .../renderer/src/utils/projectManagement.ts | 224 +- .../src/utils/projectQorTrend.test.ts | 932 --------- .../views/HomeView.layout-fullscreen.test.ts | 16 +- ecos/gui/apps/renderer/src/views/HomeView.vue | 75 +- .../ProjectsView.project-management.test.ts | 40 +- .../apps/renderer/src/views/ProjectsView.vue | 79 +- .../views/WorkspaceView.sidePanels.test.ts | 2 +- .../apps/renderer/src/views/WorkspaceView.vue | 4 +- .../src/views/WorkspaceViewWrapper.vue | 14 +- .../views/homeViewFlowLogSelection.test.ts | 2 +- .../src/views/homeViewFlowLogSelection.ts | 2 +- .../ProjectAnalysisPanel.vue | 11 +- .../projectDashboard.fixture.ts | 8 +- .../project-management/projectDashboard.ts | 50 +- .../projectWorkspaceAnalysisData.test.ts | 53 - .../projectWorkspaceAnalysisData.ts | 157 -- .../shared/src/constants/ipcChannels.ts | 8 + .../src/contracts/backendProjectComparison.ts | 550 +++++ .../shared/src/contracts/backendWorkspace.ts | 149 ++ .../shared/src/contracts/desktopApi.ts | 4 + ecos/gui/packages/shared/src/index.ts | 64 + .../shared/src/types/workspaceResources.ts | 1 + 89 files changed, 6394 insertions(+), 6094 deletions(-) create mode 100644 ecos/docs/gui-backend-refactor-standard.md create mode 100644 ecos/docs/specs/backend-qor-project-comparison-v1.md create mode 100644 ecos/docs/specs/backend-workspace-core-v1.md create mode 100644 ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.test.ts create mode 100644 ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.ts create mode 100644 ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.test.ts create mode 100644 ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.ts rename ecos/gui/apps/{renderer/src/utils => desktop-electron/electron/services}/projectAnalysisSnapshot.ts (67%) create mode 100644 ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.test.ts rename ecos/gui/apps/{renderer/src/utils/projectQorTrend.ts => desktop-electron/electron/services/qorAnalysis.ts} (95%) create mode 100644 ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts create mode 100644 ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts create mode 100644 ecos/gui/apps/renderer/src/composables/backendFlowProjection.test.ts create mode 100644 ecos/gui/apps/renderer/src/composables/backendFlowProjection.ts rename ecos/gui/apps/renderer/src/composables/{useHomeData.live-watch.test.ts => useBackendFlowLogs.live-watch.test.ts} (84%) create mode 100644 ecos/gui/apps/renderer/src/composables/useBackendFlowLogs.test.ts create mode 100644 ecos/gui/apps/renderer/src/composables/useBackendFlowLogs.ts create mode 100644 ecos/gui/apps/renderer/src/composables/useBackendFlowStages.ts create mode 100644 ecos/gui/apps/renderer/src/composables/useBackendWorkspaceQor.ts delete mode 100644 ecos/gui/apps/renderer/src/composables/useFlowStages.live-watch.test.ts delete mode 100644 ecos/gui/apps/renderer/src/composables/useHomeData.flow-log.test.ts delete mode 100644 ecos/gui/apps/renderer/src/composables/useHomeData.ts delete mode 100644 ecos/gui/apps/renderer/src/composables/useHomeQorComparison.test.ts delete mode 100644 ecos/gui/apps/renderer/src/composables/useHomeQorComparison.ts create mode 100644 ecos/gui/apps/renderer/src/stores/backendProjectComparisonSession.test.ts create mode 100644 ecos/gui/apps/renderer/src/stores/backendProjectComparisonSession.ts create mode 100644 ecos/gui/apps/renderer/src/stores/backendWorkspaceSession.test.ts create mode 100644 ecos/gui/apps/renderer/src/stores/backendWorkspaceSession.ts create mode 100644 ecos/gui/apps/renderer/src/utils/projectAnalysisAvailability.ts delete mode 100644 ecos/gui/apps/renderer/src/utils/projectAnalysisSnapshot.test.ts delete mode 100644 ecos/gui/apps/renderer/src/utils/projectQorTrend.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/project-management/projectWorkspaceAnalysisData.ts create mode 100644 ecos/gui/packages/shared/src/contracts/backendProjectComparison.ts create mode 100644 ecos/gui/packages/shared/src/contracts/backendWorkspace.ts diff --git a/ecos/docs/gui-backend-refactor-standard.md b/ecos/docs/gui-backend-refactor-standard.md new file mode 100644 index 000000000..23b104ffa --- /dev/null +++ b/ecos/docs/gui-backend-refactor-standard.md @@ -0,0 +1,379 @@ +# Backend GUI 重构标准 + +状态:设计已确认,尚未开始实现 + +确认日期:2026-08-30 + +适用范围:`ecos/gui` + +## 目标 + +本轮重构解决 Backend GUI 中工程数据、业务状态和 Vue 视图相互耦合的问题。完成后,Electron 负责读取和解释 Backend 工程事实,Renderer 只维护当前查询投影、瞬态执行投影和 UI 状态。 + +这是一轮行为保持重构。业务规则调整、页面改版和执行协议改造另行处理。 + +实施分为两份 spec: + +- [Backend Workspace Core v1](./specs/backend-workspace-core-v1.md) +- [Backend QoR 与 Project Comparison v1](./specs/backend-qor-project-comparison-v1.md) + +## 范围 + +本轮包含: + +- Backend Workspace Overview Core。 +- Backend Project Comparison,包括 Dashboard 和跨 Workspace Step Analysis。 +- Electron 内部统一 QoR Analysis。 +- Backend Flow Log 从 `useHomeData` 中拆分。 +- 所有消费 Flow、Checklist、QoR 和 baseline comparison 的 Backend 页面。 + +本轮不包含: + +- Frontend Workspace、ECC-FE 或 Frontend RPC。 +- Run、Rerun、Cancel 等 Operation command 和 lifecycle。 +- Agent、Resource Manager、Project CRUD 和 Workspace 创建、替换、派生。 +- Projects 页视觉重设计。 +- Chip Viewer、Signoff 和完整 Step Analysis 迁移。 +- ECC RPC、Workspace 输出格式或 Artifact Manifest 变更。 +- 事件总线、Command Bus、Repository、依赖注入框架和新的持久化数据库。 + +## 架构 + +```text +Vue 页面 + -> Renderer Backend Session + -> typed preload bridge + -> Electron Backend Query module + -> WorkspaceResourceService + -> ProjectScopeService + -> qorAnalysis + -> bounded filesystem reads + -> existing ECC snapshot/event adapter +``` + +按业务模块组织代码,不建立全局 `domain/application/infrastructure` 目录模板。模块内部先保持扁平,只有实际复杂度增长后再拆子目录。 + +`packages/shared` 只保存跨进程 typed contract,不承载 Backend 领域实现。领域计算在 Electron 内部执行,Renderer 不重复解释工程事实。 + +## 状态所有权 + +| 状态 | 所有者 | +| ---------------------------------------------------------- | ----------------------------------- | +| Flow、Checklist、QoR、baseline comparison 等已提交工程事实 | Electron Backend module | +| Operation 生命周期和实时进度 | 现有 ECC Runtime / Electron tracker | +| 当前 Workspace 的 ReadModel 和请求状态 | `backendWorkspaceSession` | +| 当前 Project Comparison 的 ReadModel 和请求状态 | `backendProjectComparisonSession` | +| tab、modal、filter、sort、selection、zoom | Vue 页面 | +| 文件、路径、IPC、进程和订阅 | Electron 内部实现 | + +一个业务事实只能有一个权威解释位置。Pinia 是 Renderer 状态容器,不是领域层。 + +## 模块 interface + +Backend Workspace Query 使用当前 IPC sender window 已注册的 Workspace scope,不接受路径或 runtime handle: + +```ts +backendWorkspace.getOverview(); +backendWorkspace.refreshOverview(); +backendWorkspace.onInvalidated(listener); +``` + +Electron 根据 window scope 的 canonical root 懒创建 Query Context。成功结果包含 `workspaceContextId` 和 context-local `generation`;二者不进入 ReadModel。 + +Project Comparison 需要显式选择 Project,因为 Projects 页面可以在多个 Project 之间切换: + +```ts +backendProjectComparison.selectProject({ projectRootLocator }); +backendProjectComparison.getComparison({ projectComparisonContextId }); +backendProjectComparison.refreshComparison({ projectComparisonContextId }); +``` + +`projectRootLocator` 只用于建立并授权 Context。后续查询和缓存使用不透明的 `projectComparisonContextId`。 + +不按 Widget 或数据文件拆出 `getQor()`、`getChecklist()`、`getHomeCards()` 等浅 interface。 + +## 查询结果 + +查询使用 section 级部分成功: + +```ts +type ReadSection = + | { status: "ready"; data: T; issues: [] } + | { status: "partial"; data: T; issues: ReadIssue[] } + | { status: "unavailable"; issues: ReadIssue[] } + | { status: "error"; issues: ReadIssue[] }; +``` + +Workspace Context 无效、路径未授权和顶层读取失败会使整个 query 失败。单个 Flow、Checklist、QoR 或 Workspace 分析缺失时,只影响对应 section 或 Workspace entry。 + +`ReadIssue` 返回稳定机器码和可选诊断信息。Renderer 负责本地化文案、tone 和未知 code fallback,不能解析错误文本决定业务行为。 + +## Workspace Overview Core + +```ts +type WorkspaceOverviewCore = { + identity: WorkspaceOverviewIdentity; + configuration: ReadSection; + flow: ReadSection; + checklist: ReadSection; + qor: ReadSection; + baselineComparison: ReadSection; +}; + +type WorkspaceOverviewIdentity = { + projectId?: string; + projectName?: string; + workspaceId?: string; + workspaceName: string; + baselineWorkspaceId?: string; + displayPath?: string; +}; + +type WorkspaceFlowSummary = { + steps: FlowStepSummary[]; +}; + +type FlowStepSummary = { + stepId: string; + order: number; + name: string; + state: FlowStepState; + toolId?: string; + runtimeSeconds?: number; + peakMemoryMb?: number; +}; + +type WorkspaceChecklistSummary = { + findings: ChecklistFinding[]; +}; + +type WorkspaceQorSummary = { + score: QorScore; + metrics: MetricValue[]; + steps: QorStepSummary[]; +}; + +type WorkspaceBaselineComparison = { + baselineWorkspaceId: string; + baselineWorkspaceName: string; + baselineScore: QorScore; + deltas: MetricComparison[]; + status: "baseline" | "comparable" | "not-comparable"; +}; +``` + +Configuration 只返回 Home 和通用 Workspace header 使用的规范字段。完整 `parameters.json`、表单 schema、RTL/filelist、PDK 本地路径和工具私有参数继续走独立配置能力。 + +## Project Comparison + +```ts +type BackendProjectComparison = { + identity: ProjectComparisonIdentity; + workspaces: ReadSection<{ items: ProjectWorkspaceSummary[] }>; + recommendation: ReadSection; + risks: ReadSection<{ items: ProjectRisk[] }>; + stepComparisons: ReadSection<{ steps: ProjectStepComparison[] }>; + timingTriage: ReadSection; +}; + +type ProjectComparisonIdentity = { + projectId: string; + projectName: string; + designName: string; + baselineWorkspaceId?: string; +}; + +type ProjectWorkspaceSummary = { + workspaceId: string; + workspaceName: string; + createdAt: string; + flow: FlowProgressSummary; + qor: WorkspaceQorSummary; + signoffReadiness: SignoffReadiness; + dataQuality: AnalysisDataQuality; + issues: ReadIssue[]; +}; + +type ProjectRecommendation = { + workspaceId: string; + score: QorScore; + reasons: RecommendationReason[]; +}; + +type ProjectStepComparison = { + stepId: string; + order: number; + name: string; + workspaces: ProjectStepWorkspaceResult[]; +}; +``` + +单个 Workspace 不可用时保留其他 Workspace 结果。recommendation 只能从达到最低数据质量的 Workspace 中选择;baseline 不可用时返回明确 issue,不能改选其他 Workspace。 + +## QoR Analysis + +Workspace Overview 和 Project Comparison 共用 Electron 内部 `qorAnalysis` 深模块: + +```text +backendWorkspace -> qorAnalysis +backendProjectComparison -> qorAnalysis +``` + +该模块不读取文件,也不知道 IPC、窗口、Vue 或本地路径。调用方提供已经授权、读取和分类的逻辑输入。 + +QoR Analysis 负责: + +- 指标解析、规范单位和 polarity。 +- score、gate 和当前阈值 60。 +- improvement、regression、unchanged 和 not-comparable。 +- 单 baseline comparison。 +- Project recommendation、risk、regression 和数据质量。 +- 跨 Workspace Step verdict 和 timing triage。 + +Renderer 只负责精度、单位缩放、label、颜色、图表、排序和本地化。 + +## Flow 与 Checklist 规则 + +Home、Tech Library 和 Configure 是 Renderer Navigation Item,不是 Flow Step。 + +Flow Step 使用 Workspace 内开放且不透明的 `stepId`,顺序由 `order` 明确返回。Renderer 不能根据 `stepId` 推导目录、Artifact 路径或工具;未知步骤使用通用展示 fallback。 + +当 committed Flow Step 已成功,而 `category: flow` 的 Checklist Finding 仍为 failed 时,Flow Step committed state 是有效状态的权威来源。归一化后的 finding 返回 pass,并保留 reconciliation evidence。Artifact、configuration、provenance 和 report 等 finding 不受该规则影响。 + +## Artifact 与渲染数据 + +ReadModel 直接包含页面正常渲染需要的结构化领域事实和有界趋势数据。原始、大型或需要专用查看器的内容使用不透明 `ArtifactRef`,不暴露本地路径。 + +```ts +type ArtifactRef = { + id: string; + kind: ArtifactKind; + name: string; + availability: "available" | "missing" | "stale"; + sizeBytes?: number; +}; +``` + +日志继续走 tail、chunk 和 runtime event;图片走受限 URL 或 bytes;DEF、GDS 和 geometry 交给 Chip Viewer。Renderer 不下载报告原文后重新解析工程事实。 + +## Renderer Session + +`backendWorkspaceSession` 和 `backendProjectComparisonSession` 是两个独立 Pinia store。单个 window 各自只保存一个前台 Context,不建立 session map、LRU 或 normalized entity store。 + +同一 Context 刷新时保留最后 committed ReadModel: + +```ts +type QueryProjectionState = + | { status: "idle"; data: null } + | { status: "loading"; data: null } + | { status: "ready"; data: T } + | { status: "refreshing"; data: T } + | { status: "stale"; data: T; issue: QueryIssue } + | { status: "error"; data: null; issue: QueryIssue }; +``` + +切换 Context 时立即清空旧数据。旧 generation 或旧 request sequence 的结果静默丢弃。 + +Runtime Event 不写入 ReadModel。一个纯 projection 组合 committed Flow 与现有瞬态 Operation 状态,供 Sidebar、Run Control 和 Home 使用。 + +## 缓存与失效 + +Electron 是唯一查询缓存所有者。每个 window Context 只保留 generation、ReadModel cache 和同 generation in-flight Promise 合并。 + +第一阶段不增加 TTL、LRU、持久化、内容 hash、跨窗口共享、stale-while-revalidate 或通用 cache framework。 + +受管事实提交后,Electron 必须先失效,再发送模块专用 typed IPC 通知: + +```text +事实提交 + -> generation/cache 失效 + -> backendWorkspace.invalidated 或 backendProjectComparison.invalidated + -> Renderer refresh +``` + +失效来源包括 Step commit、Operation 终态、配置写入、设计文件变更、Workspace replacement、baseline 选择和 Project manifest 变更。 + +Project Comparison Context 记录上次 Query 使用的 Workspace root 集合。某个 Workspace 提交后,Electron 只失效依赖该 Workspace 的活动 Context,不扫描所有 Project。 + +首批不保证任意外部文件修改实时刷新。ECC Runtime 和 GUI 受管变更自动刷新,外部修改通过手动 refresh 读取。 + +## 资源读取 + +第一阶段复用 `WorkspaceResourceService` 做内部资源发现。一次未缓存 Query 只建立一次 Resource Index;各 section 复用同一索引,不重复递归扫描。 + +原始 JSON 和 ECC payload 在 Electron trust boundary 使用显式 parser 和 type guard 校验。首批不引入 Zod 等运行时 schema 依赖。 + +所有文件读取必须有界。超限只影响对应 section 或 Workspace entry,不能回退为无界读取。 + +## 性能 + +首批不预设毫秒阈值,也不引入 Worker。先记录: + +- Resource Index 构建次数。 +- 文件数量和读取总字节数。 +- decode、parse 和 normalize 耗时。 +- Query 总耗时和 IPC payload 大小。 +- 相同并发请求的实际执行次数。 +- Electron event-loop 延迟。 + +硬性约束是一次 cache miss 只建一次索引、同 generation 同 query 只执行一次、不做无界读取、不把大型 Artifact 放进 ReadModel。只有代表性数据证明主线程解析阻塞时,才评估 Worker。 + +## 与现有执行链路的关系 + +首批保留 `waiting_for_gui_sync`、`gui_sync_degraded` 和 `operation.ack_step_rendered`。删除旧 Core refresh task 后,`backendWorkspaceSession` 注册一个 Overview refresh task;section 失败不能阻止 ACK。 + +这只是行为保持约束,不代表 GUI render ACK 属于目标查询架构。Execution Lifecycle 后续单独评审。 + +`useWorkspace` 的打开、关闭、runtime readiness 和 Frontend 依赖保持不变。Backend 页面不再从 `resourceVersions`、路径或 raw runtime payload 推导 committed 工程事实。 + +## 迁移切片 + +### 切片 A:Backend Workspace 基础数据流 + +- 建立 shared contract、Electron Query、IPC adapter 和 Renderer session。 +- 迁移 identity、configuration、flow 和 checklist。 +- 迁移所有 Backend Core 消费者,不只迁移 Home。 +- 拆出 `useBackendFlowLogs`,只保留日志职责。 +- 删除 `useHomeData.ts`、无消费者资产逻辑和 Backend `useFlowStages` 旧路径。 +- Frontend 对 `useFlowStages` 和 `useWorkspace` 的使用不变。 + +### 切片 B:统一 QoR 与 Project Comparison + +- 建立 Electron `qorAnalysis`。 +- 迁移 Workspace QoR 和单 baseline comparison。 +- 建立 Project Comparison Query、Context 和 Renderer session。 +- 同时迁移 Project Dashboard 和跨 Workspace Step Analysis。 +- 删除 `useHomeQorComparison` 和 Renderer QoR 领域计算。 +- 删除旧 `projectQorTrend` 生产路径,不保留 fallback 或双算。 + +每个 section 切换后,在同一切片删除旧 parser、cache、watch 和兼容分支。不使用生产 feature flag,不长期保留新旧两条事实来源。 + +## 测试与验收 + +主要测试面是模块 interface,不是内部缓存和 parser 调用顺序。 + +至少覆盖: + +- 完整、未运行和部分完成的 Workspace。 +- section 缺失、损坏和超限。 +- baseline 缺失、不可用、相同 Workspace 和正常比较。 +- 未知 Step、状态、metric 和单位。 +- Flow 与 Checklist reconciliation。 +- 单 Workspace 失败时 Project Comparison 部分成功。 +- 同 generation 请求合并和旧 generation 丢弃。 +- 同 Context refresh 保留旧 projection,切换 Context 立即清空。 +- Electron 先失效再通知 Renderer。 +- Project Comparison 跨窗口依赖失效。 +- GUI ACK 行为保持。 + +旧 composable 内部 ref、cache 和 watch 测试随旧实现删除。真实用户行为测试改为通过新 interface 驱动;纯 parser 保留少量边界测试;IPC、Pinia session 和 Vue 页面只保留必要集成与交互检查。 + +验收条件: + +- 当前 Backend GUI 可见行为保持。 +- Frontend Workspace 文件和行为不变。 +- Renderer 不解析 Backend 原始报告或状态别名。 +- 同一工程事实只有一个生产解释路径。 +- 已迁移旧代码全部删除。 +- 不增加全量扫描工作量。 +- 不引入 Worker、事件总线、通用缓存或新状态库。 diff --git a/ecos/docs/specs/backend-qor-project-comparison-v1.md b/ecos/docs/specs/backend-qor-project-comparison-v1.md new file mode 100644 index 000000000..49a2441e0 --- /dev/null +++ b/ecos/docs/specs/backend-qor-project-comparison-v1.md @@ -0,0 +1,273 @@ +# Backend QoR 与 Project Comparison v1 + +状态:已实现(2026-08-30) + +## 实现结果 + +Workspace Home 和 Project Comparison 共用 Electron 中的 `qorAnalysis`。score、阈值、gate、polarity、metric verdict、baseline delta、推荐、风险和 timing triage 都在这一层产生。Renderer 只把这些结论整理成表格、图表和页面文案。 + +Project 页面通过 `backendProjectComparisonSession` 选择 Project Context。查询按 Workspace 部分成功,记录实际依赖的 Workspace root;受管提交只失效相关 Context。旧的 `projectQorTrend`、`projectAnalysisSnapshot` 和 Renderer 原始 QoR 批量读取模块已经删除。 + +来源:[Backend GUI 重构标准](../gui-backend-refactor-standard.md) + +前置:[Backend Workspace Core v1](./backend-workspace-core-v1.md) + +## 问题 + +当前 Workspace Home、Project Dashboard 和跨 Workspace Step Analysis 都在 Renderer 中解释 QoR 数据。`useHomeQorComparison`、`projectQorTrend`、`projectAnalysisSnapshot` 和相关页面工具分别处理指标方向、score、gate、baseline delta、推荐和风险。 + +这些计算依赖页面读取到的原始报告和 Workspace 快照。同一指标可能在不同页面得到不同结论,Renderer 也因此同时承担文件编排、工程规则和图表展示。只迁移 Home 会留下两套 QoR 生产路径,后续修改 polarity、阈值或数据质量规则时仍然无法确认哪个结果权威。 + +## 方案 + +在 Electron 内建立纯 `qorAnalysis` 深模块。Workspace Overview 和新的 Backend Project Comparison Query 共用该模块,Renderer 只接收工程结论并生成展示 projection。 + +本 spec 同时迁移 Workspace QoR、单 baseline comparison、Project Dashboard 和跨 Workspace Step Analysis。迁移完成后删除旧 Renderer QoR 领域计算和 `projectQorTrend` 生产路径,不保留 fallback 或双算。 + +## 业务场景 + +1. 工程师在 Workspace Home 查看当前 QoR score、gate、指标和各 Flow Step 的工程结果。 +2. 工程师选择 baseline Workspace 后,可以看到当前 Workspace 相对 baseline 的 improvement、regression、unchanged 或 not-comparable。 +3. 当前 Workspace 就是 baseline 时,页面得到明确的 baseline 状态,而不是把 delta 当作零改进。 +4. baseline 缺失、未完成或数据不可比较时,当前 Workspace QoR 仍可显示,并附带稳定 issue。 +5. 工程师在 Project Dashboard 查看各 Workspace 的 Flow 进度、QoR、Signoff readiness 和数据质量。 +6. 工程师可以看到基于明确工程规则生成的推荐 Workspace 及推荐理由。 +7. 单个 Workspace 数据损坏时,Project Comparison 保留其他 Workspace 的结果,不重新选择 baseline 掩盖问题。 +8. 工程师在跨 Workspace Step Analysis 中按相同 `stepId` 比较各 Workspace 结果。 +9. 工程师可以看到 timing triage、风险和 regression;结论由 Electron 生成,页面只负责排序、筛选和展示。 +10. 一个 Workspace 在另一个窗口完成受管提交后,所有依赖该 Workspace 的活动 Project Comparison Context 自动失效。 +11. Project 切换后,旧 Project Comparison 数据立即清空,旧请求不能覆盖新 Project。 +12. 未知 metric、单位或 Step 不导致整个比较失败;无法可靠比较的内容返回 not-comparable 或对应 issue。 + +## 功能要求 + +### QP-01 `qorAnalysis` 领域边界 + +Electron 内部建立一个 `qorAnalysis` 模块: + +```text +backendWorkspace -> qorAnalysis +backendProjectComparison -> qorAnalysis +``` + +该模块接收已经授权、读取和分类的逻辑输入。它不能读取文件,也不能依赖 IPC、Electron window、Vue、Pinia 或本地路径。 + +首版不建立共享 domain package,不增加只有一个实现的 interface 或 factory。 + +### QP-02 工程规则所有权 + +`qorAnalysis` 是以下事实的唯一生产解释位置: + +- 指标解析、规范单位和 polarity。 +- score、gate 和当前阈值 60。 +- improvement、regression、unchanged 和 not-comparable。 +- Workspace 与单 baseline 的 delta。 +- Project recommendation、risk、regression 和数据质量。 +- 跨 Workspace Step verdict。 +- timing triage。 + +Renderer 只负责精度、单位缩放、label、颜色、图表布局、排序、筛选和本地化。 + +### QP-03 Workspace QoR + +在 `WorkspaceOverviewCore` 中加入: + +```ts +type WorkspaceOverviewCore = { + // Backend Workspace Core v1 fields + qor: ReadSection; + baselineComparison: ReadSection; +}; + +type WorkspaceQorSummary = { + score: QorScore; + metrics: MetricValue[]; + steps: QorStepSummary[]; +}; + +type WorkspaceBaselineComparison = { + baselineWorkspaceId: string; + baselineWorkspaceName: string; + baselineScore: QorScore; + deltas: MetricComparison[]; + status: "baseline" | "comparable" | "not-comparable"; +}; +``` + +QoR 与 baselineComparison 独立使用 `ReadSection`。baseline 不可用不能使当前 Workspace 的 QoR 失败。 + +### QP-04 Project Comparison Context + +Projects 页面可以选择多个 Project,因此 Preload 暴露显式 Context API: + +```ts +backendProjectComparison.selectProject({ projectRootLocator }); +backendProjectComparison.getComparison({ projectComparisonContextId }); +backendProjectComparison.refreshComparison({ projectComparisonContextId }); +backendProjectComparison.onInvalidated(listener); +``` + +`projectRootLocator` 只用于首次选择、授权和建立 Context。后续查询使用不透明 `projectComparisonContextId`,不能反复传入 Project 路径。 + +Project 切换或 Context 销毁后,旧 generation 的查询结果不能提交。 + +### QP-05 Project Comparison ReadModel + +查询返回: + +```ts +type BackendProjectComparison = { + identity: ProjectComparisonIdentity; + workspaces: ReadSection<{ items: ProjectWorkspaceSummary[] }>; + recommendation: ReadSection; + risks: ReadSection<{ items: ProjectRisk[] }>; + stepComparisons: ReadSection<{ steps: ProjectStepComparison[] }>; + timingTriage: ReadSection; +}; + +type ProjectComparisonIdentity = { + projectId: string; + projectName: string; + designName: string; + baselineWorkspaceId?: string; +}; + +type ProjectWorkspaceSummary = { + workspaceId: string; + workspaceName: string; + createdAt: string; + flow: FlowProgressSummary; + qor: WorkspaceQorSummary; + signoffReadiness: SignoffReadiness; + dataQuality: AnalysisDataQuality; + issues: ReadIssue[]; +}; + +type ProjectRecommendation = { + workspaceId: string; + score: QorScore; + reasons: RecommendationReason[]; +}; + +type ProjectStepComparison = { + stepId: string; + order: number; + name: string; + workspaces: ProjectStepWorkspaceResult[]; +}; +``` + +ReadModel 只包含 Dashboard 和跨 Workspace Step Analysis 正常渲染所需的有界结构化事实。大型报告或 Artifact 使用不透明 `ArtifactRef`。 + +### QP-06 部分成功与数据质量 + +单个 Workspace 完全无法读取或分析时,`workspaces` section 返回 partial,issue 标识失败的 Workspace;只有能够构造完整 `ProjectWorkspaceSummary` 的 Workspace 才进入 `items`。单项存在非致命数据问题时,保留该 entry,并把问题放入它的 `issues`。其他 Workspace 继续参与结果生成。 + +Recommendation 只能从达到最低数据质量的 Workspace 中选择。若没有合格 Workspace,recommendation section 返回 unavailable,不能用数据不完整的 Workspace 填充结果;若仍能推荐但部分候选不可用,则返回 partial 和对应 issues。 + +配置的 baseline 不可用时返回明确 issue,不能自动改选其他 Workspace。 + +### QP-07 Step Comparison + +跨 Workspace Step 使用开放且不透明的 `stepId` 对齐,并使用显式 `order` 排序。Renderer 不能解析 ID 推导阶段、工具或路径。 + +某 Workspace 缺少该 Step 时保留该 Workspace 的缺失状态,不能从相邻 Step 推测结果。无法比较的 metric 返回 not-comparable 及原因。 + +### QP-08 Renderer Session 与 Projection + +新增独立 Pinia store:`backendProjectComparisonSession`。它不与 `backendWorkspaceSession` 合并,也不建立 Project session map。 + +Session 使用与 Workspace Query 相同的 idle、loading、ready、refreshing、stale 和 error 状态。同一 Context 刷新保留最后 committed 数据;Project Context 切换立即清空。 + +Renderer 可以对领域结果格式化、分组、排序和筛选,但不能重新计算 score、gate、polarity、推荐、风险或 verdict。 + +### QP-09 缓存与跨窗口失效 + +Electron 是唯一 Query cache 所有者。每个 Project Comparison Context 保存 generation、一个 ReadModel cache、同 generation in-flight Promise,以及最近一次 Query 实际依赖的 Workspace root 集合。 + +Workspace 发生受管提交时: + +```text +Workspace 事实提交 + -> 失效该 Workspace 的 Backend Workspace Context + -> 查找依赖该 Workspace 的活动 Project Comparison Context + -> 失效这些 Context + -> 发送各模块 typed invalidation 通知 +``` + +不扫描所有 Project,不引入全局事件总线,也不跨窗口共享 ReadModel。 + +### QP-10 资源读取与性能 + +每个 Workspace 的一次未缓存分析只建立一次 Resource Index,并在该 Workspace 的 Flow、QoR、Signoff 和 Step 分析之间复用。 + +所有文件读取和 IPC payload 必须有界。单个 Workspace 超限只影响该 Workspace entry 或相关 section。 + +首版记录每个 Workspace 的输入规模、读取字节、分析阶段耗时、总 Query 耗时、IPC payload 和 event-loop 延迟。只有测量证明纯计算阻塞 Electron main 时,才把 `qorAnalysis` 内部计算移入 Worker;对外 contract 不变。 + +## 实施决定 + +- Workspace Overview 与 Project Comparison 共用同一个 Electron `qorAnalysis`,不复制公式。 +- `packages/shared` 只保存 ReadModel 与 IPC contract,不承载领域实现。 +- Project Comparison 只包含 Dashboard 与跨 Workspace Step Analysis,不扩展到 Project CRUD、Workspace 创建或页面重设计。 +- 现有页面结构和可见行为保持;图表组件可以继续使用,只替换其数据来源。 +- 迁移按业务消费者完成,不在 Renderer 保留临时 QoR 兼容层。 + +## 删除范围 + +切片完成时必须删除: + +- `useHomeQorComparison.ts` 及仅服务该实现的 cache、watch 和测试。 +- `projectQorTrend.ts` 的生产路径及其 Renderer 领域计算。 +- `projectAnalysisSnapshot`、`projectManagement` 和页面 helper 中重复的 score、gate、polarity、delta、推荐和风险计算。 +- Project Dashboard 与 Project Step Analysis 对旧 QoR 类型和计算结果的依赖。 +- 只断言旧 Renderer QoR helper 内部算法拆分的测试。 + +仍被展示组件使用的纯格式化函数可以保留,但其输入必须是 Electron 返回的领域结论,且不能产生新的工程判断。 + +## 测试决定 + +主要测试 `qorAnalysis` 公开输入输出、Electron Query 行为和用户可见页面结果。 + +至少覆盖: + +- score 阈值 60 两侧及边界值。 +- 正向、反向和未知 polarity 指标。 +- improvement、regression、unchanged 和 not-comparable。 +- baseline 缺失、不可用、等于当前 Workspace 和正常比较。 +- 完整、部分完成、未运行和损坏的 Workspace。 +- Recommendation 排除低数据质量 Workspace。 +- 配置 baseline 不可用时不自动改选。 +- 单 Workspace 失败时 Project Comparison 部分成功。 +- 未知 Step、metric 和单位。 +- 跨 Workspace Step 对齐、缺失和排序。 +- timing triage 与 risk 的稳定领域结论。 +- 同 generation 请求合并和旧 generation 丢弃。 +- Workspace 跨窗口提交只失效实际依赖它的 Project Context。 +- Project Context refresh 保留旧数据,切换 Project 清空旧数据。 +- Home、Project Dashboard 与 Step Analysis 对同一 fixture 显示一致结论。 +- Renderer 只做展示 projection,不重新计算工程结论。 + +使用一组最小脱敏 fixture 覆盖 Workspace Overview 和 Project Comparison。测试断言稳定业务结果,不固定 Electron 内部文件结构或函数拆分。 + +## 验收条件 + +- [x] Workspace Home 的 QoR 与 baseline comparison 可见行为保持。 +- [x] Project Dashboard 的 Workspace 汇总、推荐和风险可见行为保持。 +- [x] 跨 Workspace Step Analysis 可见行为保持。 +- [x] 同一输入在三个消费面得到一致的 score、gate、polarity 和比较结论。 +- [x] 单 Workspace 失败不会使整个 Project Comparison 失败。 +- [x] baseline 不可用时不会静默改选。 +- [x] Renderer 不读取原始 QoR 报告,也不生成工程结论。 +- [x] `projectQorTrend` 和 `useHomeQorComparison` 的旧生产路径全部删除。 +- [x] Project Comparison 跨窗口依赖失效通过测试。 +- [x] 没有新增 Worker、事件总线、共享领域 package 或新状态库。 + +## 不在范围内 + +- Project CRUD、Workspace 创建、替换和派生。 +- Projects 页面与分析页面的视觉重设计。 +- 完整 Step Analysis、Chip Viewer 和 Signoff 页面迁移。 +- Run、Rerun、Cancel 和 Operation lifecycle 重构。 +- ECC RPC、Workspace 输出格式和 Artifact Manifest 修改。 +- QoR 阈值或现有业务规则调整。本 spec 只迁移当前行为。 diff --git a/ecos/docs/specs/backend-workspace-core-v1.md b/ecos/docs/specs/backend-workspace-core-v1.md new file mode 100644 index 000000000..ec324f61a --- /dev/null +++ b/ecos/docs/specs/backend-workspace-core-v1.md @@ -0,0 +1,261 @@ +# Backend Workspace Core v1 + +状态:已实现(2026-08-30) + +## 实现结果 + +Backend Workspace 现在通过 `backendWorkspace` 查询返回 identity、配置摘要、Flow、Checklist、QoR 和 baseline comparison。Electron 按窗口保存 Context、generation、单份缓存和同代请求;Renderer 的 Pinia Session 只保存当前页面投影。 + +Home、Sidebar、Run Control 和 Step Dashboard 已改用同一份 Flow ReadModel。日志单独留在 `useBackendFlowLogs`,继续走 tail、chunk 和 Runtime Event。旧的 `useHomeData`、Backend `useFlowStages` 消费路径和资源版本刷新逻辑已经删除,Frontend Workspace 仍使用原来的 `useFlowStages`。 + +来源:[Backend GUI 重构标准](../gui-backend-refactor-standard.md) + +## 问题 + +Backend Workspace 的身份、配置摘要、Flow 和 Checklist 目前由多个 Renderer composable 分别读取、解析和缓存。页面还会监听 `resourceVersions` 和 Runtime Event,再自行决定何时重新扫描文件。结果是同一份工程事实存在多条解释路径,Vue 视图变化、文件读取和业务状态相互牵连。 + +`useHomeData` 同时承担 Checklist、日志和已无消费者的布局资产逻辑,`useFlowStages` 又被 Home、Sidebar、Run Control 和 Step 页面共同使用。修改一个数据来源时,开发者很难判断会影响哪些页面,也无法在 Electron 边界统一校验输入和控制读取规模。 + +## 方案 + +建立 Backend Workspace 业务查询模块。Electron 根据当前 IPC sender window 已注册的 Workspace scope,返回一次场景级 `WorkspaceOverviewCore` 查询。Renderer 通过单个 `backendWorkspaceSession` 保存查询投影,页面只消费该投影。 + +首版迁移 Workspace identity、configuration summary、Flow 和 Checklist。Flow 日志从 `useHomeData` 拆为独立的 `useBackendFlowLogs`,继续使用已有的 tail、chunk 和 Runtime Event 能力,不进入普通 ReadModel。 + +旧 Backend 数据路径在消费者切换后直接删除,不保留 feature flag、fallback 或双重计算。Frontend Workspace 对 `useWorkspace` 和 `useFlowStages` 的现有依赖保持不变。 + +## 业务场景 + +1. 芯片后端工程师打开 Backend Workspace 后,可以看到正确的 Project、Workspace 和 baseline 身份信息。 +2. 工程师可以看到 Home 与 Workspace header 所需的配置摘要,但 GUI 不暴露或复制完整 `parameters.json`。 +3. 工程师可以看到按明确顺序排列的 Flow Step、已提交状态、工具、运行时间和峰值内存。 +4. 工程师可以看到 Checklist Finding;某一类数据缺失时,其他可用内容仍然显示。 +5. 工程师在同一 Workspace 手动刷新时,继续看到最后一次成功数据和刷新状态,不会先得到一张空白页面。 +6. 工程师切换 Workspace 时,旧 Workspace 数据立即清空,不会短暂显示到新 Workspace 页面。 +7. 受 GUI 或 Runtime 管理的事实提交后,当前页面自动刷新;任意外部文件修改首版通过手动刷新读取。 +8. Flow 正在运行时,Sidebar、Run Control 和 Home 可以看到瞬态进度,但瞬态事件不会改写已提交 ReadModel。 +9. 某个 section 的文件缺失、损坏或超限时,该 section 显示可恢复的问题,其他 section 不受影响。 +10. 未知 Step 和未知状态使用通用展示,不导致页面失败,也不通过字符串猜测目录或工具。 +11. 工程师查看 Flow 日志时,仍可使用现有日志选择、tail 和分块读取能力。 +12. Runtime 等待 GUI 完成 Step 渲染时,Overview 的单个 section 失败不会阻止 `operation.ack_step_rendered`。 + +## 功能要求 + +### BW-01 查询边界 + +Preload 暴露一个 Backend 专用 typed API: + +```ts +backendWorkspace.getOverview(); +backendWorkspace.refreshOverview(); +backendWorkspace.onInvalidated(listener); +``` + +查询不接受 Workspace 路径、runtime handle 或 Widget 名称。Electron 从 IPC sender window 的已授权 scope 解析当前 Workspace。 + +不增加 `getQor()`、`getChecklist()`、`getHomeCards()` 等按 Widget 或文件拆分的接口。 + +### BW-02 Workspace Query Context + +Electron 根据 window scope 的 canonical Workspace root 懒创建 Context。成功响应在 envelope 中返回不透明的 `workspaceContextId` 和 context-local `generation`,二者不写入 ReadModel。 + +Workspace 切换或窗口销毁后,旧 Context 不能继续提交结果。旧 generation 的异步结果应静默丢弃。 + +### BW-03 section 级结果 + +各业务 section 使用统一结果类型: + +```ts +type ReadSection = + | { status: "ready"; data: T; issues: [] } + | { status: "partial"; data: T; issues: ReadIssue[] } + | { status: "unavailable"; issues: ReadIssue[] } + | { status: "error"; issues: ReadIssue[] }; +``` + +Workspace Context 无效、路径未授权和顶层读取失败会使整个 query 失败。单个配置、Flow 或 Checklist 输入缺失,只影响对应 section。 + +`ReadIssue` 包含稳定 `code` 和可选诊断信息。Renderer 根据 `code` 选择本地化文案和 tone,不能解析错误文本决定业务行为。 + +### BW-04 Workspace Overview Core + +首版 ReadModel 只包含以下字段: + +```ts +type WorkspaceOverviewCore = { + identity: WorkspaceOverviewIdentity; + configuration: ReadSection; + flow: ReadSection; + checklist: ReadSection; +}; + +type WorkspaceOverviewIdentity = { + projectId?: string; + projectName?: string; + workspaceId?: string; + workspaceName: string; + baselineWorkspaceId?: string; + displayPath?: string; +}; + +type WorkspaceFlowSummary = { + steps: FlowStepSummary[]; +}; + +type FlowStepSummary = { + stepId: string; + order: number; + name: string; + state: FlowStepState; + toolId?: string; + runtimeSeconds?: number; + peakMemoryMb?: number; +}; + +type WorkspaceChecklistSummary = { + findings: ChecklistFinding[]; +}; +``` + +QoR 和 `baselineComparison` 在 [Backend QoR 与 Project Comparison v1](./backend-qor-project-comparison-v1.md) 加入同一 Overview contract。 + +### BW-05 配置摘要 + +Configuration 只返回 Home 与通用 Workspace header 正常展示所需的规范字段。完整参数编辑继续使用现有配置能力。 + +以下内容不能进入 Overview: + +- 完整 `parameters.json` 或表单 schema。 +- RTL/filelist 原文。 +- PDK 本地路径。 +- 工具私有参数和未归一化 ECC payload。 + +### BW-06 Flow 规则 + +Home、Tech Library 和 Configure 是 Renderer Navigation Item,不是 Flow Step。 + +`stepId` 在 Workspace 内保持开放且不透明,顺序由 `order` 返回。Renderer 不能从 `stepId` 推导目录、Artifact 路径或工具类型。 + +ReadModel 中的 Flow 是已提交事实。现有 Operation tracker 继续拥有运行中、取消中和实时进度等瞬态状态。页面通过一个纯 projection 合并两者,不能反向修改 ReadModel。 + +### BW-07 Checklist 协调 + +Checklist Finding 按其业务类别保持权威。只有以下冲突需要归一化: + +- committed Flow Step 已成功; +- 同一 Step 的 `category: flow` Finding 仍为 failed。 + +此时归一化后的 Finding 返回 pass,并保留 reconciliation evidence。Artifact、configuration、provenance 和 report 等类别不受该规则影响。 + +### BW-08 Artifact 与日志 + +正常页面渲染需要的有界结构化事实直接进入 ReadModel。大型或需要专用查看器的内容只返回不透明 `ArtifactRef`,不能返回本地路径。 + +日志保留现有 tail、chunk 和 Runtime Event 传输。新 `useBackendFlowLogs` 只负责日志,不读取 Checklist、Flow 或布局资产。 + +### BW-09 Renderer Session + +新增单个 Pinia store:`backendWorkspaceSession`。每个 window 只保存当前前台 Context,不增加 session map、LRU 或 normalized entity store。 + +状态至少覆盖: + +```ts +type QueryProjectionState = + | { status: "idle"; data: null } + | { status: "loading"; data: null } + | { status: "ready"; data: T } + | { status: "refreshing"; data: T } + | { status: "stale"; data: T; issue: QueryIssue } + | { status: "error"; data: null; issue: QueryIssue }; +``` + +同一 Context 刷新时保留最后 committed 数据。Context 切换时立即清空。store 使用 request sequence 和 generation 拒绝过期响应。 + +### BW-10 缓存与失效 + +Electron 是唯一查询缓存所有者。每个 window Context 只保存 generation、一个 ReadModel cache 和同 generation 的 in-flight Promise。 + +受管事实提交时必须遵循: + +```text +事实提交 + -> generation/cache 失效 + -> backendWorkspace.invalidated + -> backendWorkspaceSession 刷新 +``` + +首版不增加 TTL、LRU、持久化、内容 hash、跨窗口共享、stale-while-revalidate 或通用事件总线。 + +### BW-11 资源读取与性能 + +Electron 内部复用现有 `WorkspaceResourceService`。一次未缓存 Query 只建立一次 Resource Index,各 section 复用该索引。 + +原始 JSON 和 ECC payload 使用显式 parser 与 type guard 校验。所有读取必须有界;超限只影响对应 section。 + +首版记录文件数量、总读取字节、decode/parse/normalize 耗时、Query 总耗时、IPC payload、请求合并次数和 event-loop 延迟。没有主线程阻塞证据前不引入 Worker。 + +### BW-12 现有执行链路 + +保留 `waiting_for_gui_sync`、`gui_sync_degraded` 和 `operation.ack_step_rendered`。`backendWorkspaceSession` 替换旧 Core refresh task 后,Overview section 失败不能阻止 ACK。 + +`useWorkspace` 的打开、关闭和 Runtime readiness 行为不变。Backend 页面不再从 `resourceVersions`、路径或 raw Runtime Event 推导已提交工程事实。 + +## 实施决定 + +- 按业务模块组织 shared contract、Electron Query、IPC adapter 和 Renderer session,不建立全局 Clean Architecture 目录。 +- `packages/shared` 只保存跨进程 contract。Electron 拥有 parser、归一化和业务规则。 +- 只从大型 `registerIpc.ts` 中抽出 Backend Workspace adapter,不做全局 IPC 重构。 +- 迁移全部 Backend Core 消费者,包括 Home、Sidebar、Run Control、Step Dashboard 和 Workspace shell 中的对应读取点。 +- Frontend Workspace 文件与行为不变。共享文件只能做保持兼容所需的最小修改。 +- 每个消费面切换后,在同一切片删除对应旧 parser、cache、watch 和测试。 + +## 删除范围 + +切片完成时必须删除: + +- `useHomeData.ts` 及其旧缓存、watch 和仅服务旧实现的测试。 +- `useHomeData` 中无消费者的布局资产逻辑。 +- Backend 消费者对 `useFlowStages` 的依赖及对应旧路径。 +- Backend Core 对 `resourceVersions` 的刷新依赖。 +- 仅断言旧 composable 内部 ref、cache 或 watch 顺序的测试。 + +本 spec 不删除 `useFlowStages.ts`,因为 Frontend Workspace 仍在使用它;只删除 Backend 消费路径。本 spec 不授权改写 Frontend 数据流。 + +## 测试决定 + +主要测试模块公开行为,不固定内部 helper、缓存对象或 parser 调用顺序。 + +至少覆盖: + +- 完整、未运行和部分完成的 Workspace。 +- identity 可用但 configuration、Flow 或 Checklist 缺失的部分成功。 +- 文件损坏、未知字段、未知 Step、未知状态和读取超限。 +- Flow 与 Checklist reconciliation 及 evidence 保留。 +- 同 generation 并发请求只执行一次。 +- generation 变化后旧请求不能提交。 +- 同 Context refresh 保留旧数据;Context 切换清空旧数据。 +- Electron 先失效再通知 Renderer。 +- Runtime 瞬态 projection 不修改 committed ReadModel。 +- Overview section 失败仍完成现有 GUI ACK。 +- Flow Log 的选择、tail、chunk 和 Runtime Event 行为保持。 +- Frontend Workspace 相关回归测试保持通过。 + +## 验收条件 + +- [x] 当前 Backend Workspace 的 identity、配置摘要、Flow、Checklist 和日志可见行为保持。 +- [x] Renderer 不读取或解析这些业务事实的原始文件和 ECC payload。 +- [x] 同一业务事实只有 Electron 中的一条生产解释路径。 +- [x] 一次 cache miss 只构建一次 Resource Index。 +- [x] 同 generation 的相同并发查询只执行一次。 +- [x] section 失败不会清空其他成功 section,也不会阻止 GUI ACK。 +- [x] 已迁移 Backend 旧代码和旧测试全部删除。 +- [x] Frontend Workspace 文件和行为不变。 +- [x] 没有新增 Worker、事件总线、通用缓存框架或状态库。 + +## 不在范围内 + +- Workspace QoR、baseline comparison 和 Project Comparison,见下一份 spec。 +- Run、Rerun、Cancel、恢复和 Operation lifecycle 重构。 +- 完整参数编辑模型、Workspace 创建和 Project CRUD。 +- Chip Viewer、Signoff 和完整 Step Analysis 数据迁移。 +- ECC RPC、Workspace 输出格式和 Artifact Manifest 修改。 diff --git a/ecos/gui/README.md b/ecos/gui/README.md index df1c62295..8b1173b8d 100644 --- a/ecos/gui/README.md +++ b/ecos/gui/README.md @@ -146,6 +146,7 @@ which files changed. - [ECOS package README](../README.md) — overall quick start and release notes for ECOS Studio - [ECOS Studio user guide](../docs/user-guide.md) — product usage +- [Backend GUI refactor standard](../docs/gui-backend-refactor-standard.md) — confirmed data-flow and domain-module rules - [Repository root README](../../README.md) — monorepo overview - [ECC development](../../ecc/docs/development.md), [ECC architecture](../../ecc/docs/architecture.md) — ECC toolchain docs diff --git a/ecos/gui/apps/desktop-electron/electron/main/index.ts b/ecos/gui/apps/desktop-electron/electron/main/index.ts index 8f129636b..28adc6709 100644 --- a/ecos/gui/apps/desktop-electron/electron/main/index.ts +++ b/ecos/gui/apps/desktop-electron/electron/main/index.ts @@ -10,6 +10,8 @@ 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 { BackendProjectComparisonService } from '../services/backendProjectComparisonService' import { prepareDesktopLogs } from '../services/desktopLogPaths' import { createEccRuntimeEnv, resolveEccExecutable } from '../services/eccRpc/runtimeEnv' import { EccRpcRuntimeService } from '../services/eccRpc/runtimeService' @@ -59,6 +61,8 @@ let workspaceReplacementRecovery: Promise | null = null let projectScopeService: ProjectScopeService | null = null let services: { appInfoService: AppInfoService + backendWorkspaceService: BackendWorkspaceService + backendProjectComparisonService: BackendProjectComparisonService codexDependencyService: CodexDependencyService eccRuntimeService: EccRpcRuntimeService frontendRpcRuntimeService: FrontendRpcRuntimeService @@ -213,6 +217,13 @@ function getDesktopServices() { workspaceService, ) const projectManagementReadService = new ProjectManagementReadService() + const backendProjectComparisonService = new BackendProjectComparisonService( + projectManagementReadService, + ) + const backendWorkspaceService = new BackendWorkspaceService({ + projectManagementReadService, + workspaceResourceService, + }) const shellService = new ShellPtyService({ env: runtimeEnv, envProvider: runtimeEnvProvider, @@ -251,6 +262,8 @@ function getDesktopServices() { services = { appInfoService, + backendWorkspaceService, + backendProjectComparisonService, frontendRpcRuntimeService, chipViewerService, codexDependencyService, @@ -291,6 +304,8 @@ async function ensureDesktopBridgeReady(): Promise { registerIpc(undefined, { agentRuntimeService: agentRuntimeService ?? undefined, appInfoService: desktopServices.appInfoService, + backendWorkspaceService: desktopServices.backendWorkspaceService, + backendProjectComparisonService: desktopServices.backendProjectComparisonService, codexDependencyService: desktopServices.codexDependencyService, createWindow: async (options) => { await launchWindow({ @@ -327,6 +342,8 @@ async function launchWindow( bindWindowEvents(mainWindow) mainWindow.on('closed', () => { workspaceWindowRegistry.unregisterByWindow(mainWindow as WorkspaceWindowLike) + services?.backendWorkspaceService.clearWindow(windowId) + services?.backendProjectComparisonService.disposeWindow(windowId) projectScopeService?.clearWindow(windowId) clearWindowMenuState(windowId) }) 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 8a1fe02aa..1dc4b9f2d 100644 --- a/ecos/gui/apps/desktop-electron/electron/main/registerIpc.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/main/registerIpc.test.ts @@ -112,9 +112,26 @@ function registerHandlers( listProjectEntries: vi.fn(), readWorkspaceTexts: vi.fn(), }, + backendWorkspaceService: { + clearWindow: vi.fn(), + getOverview: vi.fn(), + invalidateWindow: vi.fn(), + onInvalidated: vi.fn(), + refreshOverview: vi.fn(), + }, + backendProjectComparisonService: { + disposeWindow: vi.fn(), + getComparison: vi.fn(), + refreshComparison: vi.fn(), + selectProject: vi.fn(), + invalidateProject: vi.fn(), + invalidateWorkspace: vi.fn(), + onInvalidated: vi.fn(), + }, workspaceService: { approvePendingExternalReadRoots: vi.fn(), clearProjectRoot: vi.fn(), + getProjectRoot: vi.fn().mockResolvedValue('/work/demo'), isProjectDirectory: vi.fn(), listPendingExternalReadRoots: vi.fn(), readProjectBinaryFile: vi.fn(), @@ -138,6 +155,12 @@ function registerHandlers( 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(), @@ -1926,6 +1949,54 @@ describe('registerIpc', () => { }) }) + 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) + services.backendWorkspaceService.refreshOverview.mockResolvedValue(result) + + await expect( + handlers.get(desktopApiIpcChannels.backendWorkspaceGetOverview)?.(event), + ).resolves.toEqual(result) + await expect( + handlers.get(desktopApiIpcChannels.backendWorkspaceRefreshOverview)?.(event), + ).resolves.toEqual(result) + + expect(services.backendWorkspaceService.getOverview).toHaveBeenCalledTimes(1) + 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('runs ECC flow steps through the runtime service', async () => { const { handlers, services } = registerHandlers() const event = { sender: { id: 'web-contents' } } @@ -2246,6 +2317,51 @@ 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 handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + { 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: { state: 'Success' }, + sequence: 3, + timestamp: 1, + type: 'step.completed', + 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() diff --git a/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts b/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts index 57eebcd9c..45ef57d22 100644 --- a/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts +++ b/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts @@ -167,12 +167,49 @@ export interface DesktopBridgeServices { request: DesktopProjectManagementWorkspaceTextsRequest, ): Promise } + backendWorkspaceService: { + clearWindow(windowId: number): void + getOverview(): Promise + invalidateWindow(windowId: number): void + onInvalidated( + listener: ( + event: import('../services/backendWorkspaceService').BackendWorkspaceInvalidation, + ) => void, + ): () => void + refreshOverview(): Promise< + import('@ecos-studio/shared').BackendWorkspaceOverviewResult + > + } + backendProjectComparisonService: { + disposeWindow(windowId: number): void + getComparison( + windowId: number, + contextId: string, + ): Promise + refreshComparison( + windowId: number, + contextId: string, + ): Promise + selectProject( + windowId: number, + request: { projectRootLocator: string }, + ): Promise + invalidateProject(projectRoot: string): void + invalidateWorkspace(workspaceRoot: string): void + onInvalidated( + listener: ( + windowId: number, + event: import('@ecos-studio/shared').BackendProjectComparisonInvalidatedEvent, + ) => void, + ): () => void + } workspaceService: { approvePendingExternalReadRoots?( expectedProjectRoot: string, expectedRoots: string[], ): Promise clearProjectRoot(): Promise + getProjectRoot(): Promise isProjectDirectory(path: string): Promise readProjectBinaryFile(path: string): Promise readOptionalProjectTextFile(path: string): Promise @@ -217,6 +254,9 @@ 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 @@ -691,6 +731,27 @@ export function registerIpc( target.handle(channel, wrapIpcHandler(channel, handler)) } + 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, + ) + }) + const projectFileWatchSubscriptions = new Map< string, { @@ -768,6 +829,32 @@ 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 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 + return [ + 'step.completed', + 'operation.completed', + 'operation.failed', + 'operation.cancelled', + 'operation.rerun_prepared', + ].includes(payload.event.type) + } + const agentSessionKey = (providerId: string, sessionId: string): string => `${providerId}:${sessionId}` @@ -834,6 +921,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 @@ -844,6 +935,9 @@ export function registerIpc( ...payload, workspaceDirectory: normalizedDirectory, } + if (designTool === 'backend' && runtimeEventCommitsWorkspaceFacts(payload)) { + invalidateBackendWorkspaceForSender(subscription.sender) + } if (designTool === 'backend') sendEccEventToSender(subscription.sender, scopedPayload) sendDesignRuntimeEventToSender(subscription.sender, designTool, scopedPayload) @@ -884,6 +978,14 @@ export function registerIpc( if (workspaceHandle) { const subscription = workspaceHandleSubscriptions.get(workspaceHandle) if (subscription && subscription.designTool === designTool) { + if (designTool === 'backend' && runtimeEventCommitsWorkspaceFacts(payload)) { + invalidateBackendWorkspaceForSender(subscription.sender) + const directory = + readWorkspaceDirectoryFromEvent(payload) ?? + subscription.directories.values().next().value + if (directory) + services.backendProjectComparisonService.invalidateWorkspace(directory) + } if (designTool === 'backend') sendEccEventToSender(subscription.sender, payload) sendDesignRuntimeEventToSender(subscription.sender, designTool, payload) return @@ -1296,7 +1398,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') { @@ -1305,11 +1407,55 @@ 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( + 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.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.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.projectManagementReadManifest, async (_event, projectRoot) => { @@ -1381,6 +1527,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 @@ -1441,6 +1590,9 @@ export function registerIpc( } } await services.workspaceService.clearProjectRoot() + if (typeof sender.id === 'number') { + services.backendWorkspaceService.clearWindow(sender.id) + } }) handle( @@ -1563,11 +1715,13 @@ export function registerIpc( handle( desktopApiIpcChannels.workspaceWriteProjectTextFile, - async (_event, path, content) => { + async (event, path, content) => { await services.workspaceService.writeProjectTextFile( path as string, content as string, ) + invalidateBackendWorkspaceForSender(event.sender) + services.backendProjectComparisonService.invalidateWorkspace(path as string) }, ) @@ -1603,31 +1757,43 @@ 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 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 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 services.workspaceService.retainProjectDirectoryReplacement(replacementId) + invalidateBackendWorkspaceForSender(event.sender) + services.backendProjectComparisonService.invalidateWorkspace(replacement.targetPath) }, ) @@ -1643,14 +1809,24 @@ export function registerIpc( 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) => { + 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) + async (event, filelistEntry) => { + const result = await services.workspaceService.removeDesignFile( + filelistEntry as string, + ) + const workspaceRoot = await services.workspaceService.getProjectRoot() + invalidateBackendWorkspaceForSender(event.sender) + services.backendProjectComparisonService.invalidateWorkspace(workspaceRoot) + return result }, ) @@ -1712,6 +1888,14 @@ export function registerIpc( return await services.workspaceResourceService.getIndex() }) + handle(desktopApiIpcChannels.backendWorkspaceGetOverview, async () => { + return await services.backendWorkspaceService.getOverview() + }) + + handle(desktopApiIpcChannels.backendWorkspaceRefreshOverview, async () => { + return await services.backendWorkspaceService.refreshOverview() + }) + handle(desktopApiIpcChannels.workspaceResourcesReadHome, async () => { return await services.workspaceResourceService.readHome() }) 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..36ed21777 100644 --- a/ecos/gui/apps/desktop-electron/electron/preload/index.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/preload/index.test.ts @@ -33,6 +33,17 @@ async function loadDesktopBridge() { app: { getVersions(): Promise } + backendWorkspace: { + getOverview(): Promise + refreshOverview(): Promise + onInvalidated(listener: (event: unknown) => void): () => void + } + backendProjectComparison: { + selectProject(request: unknown): Promise + getComparison(request: unknown): Promise + refreshComparison(request: unknown): Promise + onInvalidated(listener: (event: unknown) => void): () => void + } ecc: { events: { onEvent(listener: (event: unknown) => void): () => void @@ -107,6 +118,11 @@ describe('preload desktop bridge contract', () => { runStep: expect.any(Function), }), }), + backendProjectComparison: expect.objectContaining({ + selectProject: expect.any(Function), + getComparison: expect.any(Function), + refreshComparison: expect.any(Function), + }), workspace: expect.objectContaining({ readProjectTextFile: expect.any(Function), }), @@ -190,6 +206,46 @@ describe('preload desktop bridge contract', () => { ) }) + it('routes Backend Workspace queries and invalidation through typed channels', async () => { + const bridge = await loadDesktopBridge() + const overview = { + generation: 0, + overview: { identity: { workspaceName: 'Workspace A' } }, + workspaceContextId: 'workspace-context-1', + } + ipcRenderer.invoke.mockResolvedValue(overview) + + await expect(bridge.backendWorkspace.getOverview()).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.backendWorkspaceRefreshOverview, + ) + expect(ipcRenderer.on).toHaveBeenCalledWith( + desktopApiEventChannels.backendWorkspaceInvalidated, + eventListener, + ) + expect(listener).toHaveBeenCalledWith({ + generation: 1, + workspaceContextId: 'workspace-context-1', + }) + expect(ipcRenderer.removeListener).toHaveBeenCalledWith( + desktopApiEventChannels.backendWorkspaceInvalidated, + eventListener, + ) + }) + it('routes ECC flow calls through the shared IPC channel constant', async () => { const bridge = await loadDesktopBridge() ipcRenderer.invoke.mockResolvedValueOnce({ diff --git a/ecos/gui/apps/desktop-electron/electron/preload/index.ts b/ecos/gui/apps/desktop-electron/electron/preload/index.ts index 31f8c6f3d..e333f1f75 100644 --- a/ecos/gui/apps/desktop-electron/electron/preload/index.ts +++ b/ecos/gui/apps/desktop-electron/electron/preload/index.ts @@ -129,6 +129,36 @@ const desktopApi: DesktopApi = { mutate: (request: ProjectManifestMutationRequest) => invokeDesktop(desktopApiIpcChannels.projectManifestMutate, request), }, + backendWorkspace: { + getOverview: () => invokeDesktop(desktopApiIpcChannels.backendWorkspaceGetOverview), + refreshOverview: () => + invokeDesktop(desktopApiIpcChannels.backendWorkspaceRefreshOverview), + onInvalidated: (listener) => + subscribeToDesktopEvent( + desktopApiEventChannels.backendWorkspaceInvalidated, + (_event, payload: unknown) => { + listener(payload as Parameters[0]) + }, + ), + }, + backendProjectComparison: { + selectProject: (request) => + invokeDesktop(desktopApiIpcChannels.backendProjectComparisonSelectProject, request), + getComparison: (request) => + invokeDesktop(desktopApiIpcChannels.backendProjectComparisonGetComparison, request), + refreshComparison: (request) => + invokeDesktop( + desktopApiIpcChannels.backendProjectComparisonRefreshComparison, + request, + ), + onInvalidated: (listener) => + subscribeToDesktopEvent( + desktopApiEventChannels.backendProjectComparisonInvalidated, + (_event, payload: unknown) => { + listener(payload as Parameters[0]) + }, + ), + }, projectManagement: { readManifest: (projectRoot) => invokeDesktop(desktopApiIpcChannels.projectManagementReadManifest, projectRoot), 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..91d223a8e --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ProjectManifest } from '@ecos-studio/shared' +import { BackendProjectComparisonService } from './backendProjectComparisonService' + +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 serviceFixture() { + const project = manifest() + const readManifest = vi.fn().mockResolvedValue(JSON.stringify(project)) + const readWorkspaceTexts = vi.fn().mockImplementation(async ({ workspacePath }) => ({ + texts: { + 'home/flow.json': JSON.stringify({ + steps: [{ name: 'Route', state: 'Success' }], + }), + 'analysis/Route/qor_metrics.json': JSON.stringify({ + metrics: [ + { name: 'wire length', value: workspacePath.endsWith('ws_1') ? 120 : 100 }, + ], + }), + }, + unavailablePaths: [], + })) + return { + project, + readManifest, + readWorkspaceTexts, + service: new BackendProjectComparisonService({ + readManifest, + readWorkspaceTexts, + resolveProjectRoot: async (path) => path, + }), + } +} + +describe('BackendProjectComparisonService', () => { + it('selects an opaque context and coalesces comparison reads in one generation', async () => { + const { service, readWorkspaceTexts } = 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(readWorkspaceTexts).toHaveBeenCalledTimes(2) + }) + + it('keeps readable workspaces when one workspace analysis fails', async () => { + const { service, readWorkspaceTexts } = serviceFixture() + readWorkspaceTexts.mockRejectedValueOnce(new Error('broken workspace')) + 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) + }) + + it('rejects a manifest that redirects the selected Project root', async () => { + const { service, readManifest } = serviceFixture() + readManifest.mockResolvedValue(JSON.stringify(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 { service, readWorkspaceTexts } = serviceFixture() + readWorkspaceTexts.mockResolvedValue({ + texts: { + 'home/flow.json': JSON.stringify({ + steps: [{ name: 'CustomSignoff', state: 'Success' }], + }), + }, + unavailablePaths: [], + }) + 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( + JSON.stringify({ + ...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(JSON.stringify(first.project)) + .mockResolvedValueOnce(JSON.stringify(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 }]) + }) +}) 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..b8d3614a0 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.ts @@ -0,0 +1,520 @@ +import { randomUUID } from 'node:crypto' +import { realpath } from 'node:fs/promises' +import { resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { + parseProjectManifest, + projectManagementWorkspaceSummaryPaths, + type BackendProjectComparison, + type BackendProjectComparisonInvalidatedEvent, + type BackendProjectComparisonQueryResult, + type BackendProjectComparisonSelectResult, + type DesktopProjectManagementWorkspaceTextsResult, + type ProjectAnalysisSnapshot, + type ProjectManifest, + type ProjectQorMetricRecord, + type ProjectQorTrendSummary, + type ProjectQorTrendWorkspaceSummary, + type ProjectRecommendation, + type ProjectStepComparison, + type ReadIssue, + type ReadSection, +} from '@ecos-studio/shared' +import { buildProjectAnalysisSnapshot } from './projectAnalysisSnapshot' +import { buildProjectQorTrendSummary } from './qorAnalysis' +import { projectQorInputForWorkspace } from './workspaceQorAnalysis' +import { electronLogger } from './logger' +import { isPathWithinRoot } from './pathScope' + +interface ProjectComparisonReader { + resolveProjectRoot?(projectRoot: string): Promise + readManifest(projectRoot: string): Promise + readWorkspaceTexts(request: { + projectRoot: string + workspacePath: string + paths: string[] + }): Promise +} + +interface ProjectComparisonContext { + id: string + windowId: number + projectRoot: string + generation: number + dependencies: Set + cache: BackendProjectComparisonQueryResult | null + inFlight: Promise | null + coalescedRequests: number +} + +type InvalidationListener = ( + windowId: number, + event: BackendProjectComparisonInvalidatedEvent, +) => void + +const FLOW_STEPS = projectManagementWorkspaceSummaryPaths.length + ? ([ + 'Synth', + 'Floor', + 'Fanout', + 'Place', + 'CTS', + 'Legal', + 'Route', + 'DRC', + 'LVS', + 'Filler', + 'RCX', + 'STA', + 'Harden', + ] as const) + : [] + +export class BackendProjectComparisonService { + private readonly contextsByWindow = new Map() + private readonly listeners = new Set() + + constructor(private readonly reader: ProjectComparisonReader) {} + + async selectProject( + windowId: number, + request: { projectRootLocator: string }, + ): Promise { + try { + const projectRoot = await (this.reader.resolveProjectRoot ?? realpath)( + request.projectRootLocator, + ) + const manifest = await this.readManifest(projectRoot) + const context: ProjectComparisonContext = { + id: randomUUID(), + windowId, + projectRoot, + generation: 0, + dependencies: new Set( + manifest.workspaces.map((workspace) => resolve(workspace.workspace_path)), + ), + cache: null, + inFlight: null, + coalescedRequests: 0, + } + this.contextsByWindow.set(windowId, context) + return { + ok: true, + projectComparisonContextId: context.id, + generation: context.generation, + } + } catch (error) { + 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.generation += 1 + context.cache = null + context.inFlight = null + return this.getComparison(windowId, projectComparisonContextId) + } + + 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 + 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 + 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) + } + + disposeWindow(windowId: number): void { + this.contextsByWindow.delete(windowId) + } + + 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 async readManifest(projectRoot: string): Promise { + const content = await this.reader.readManifest(projectRoot) + if (!content) throw new Error('Project manifest does not exist.') + const manifest = parseProjectManifest(content) + 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) + let readBytes = 0 + let unavailableFileCount = 0 + const readStartedAt = performance.now() + const entries = await mapWithConcurrency( + manifest.workspaces, + 2, + async (workspace) => { + try { + const { texts, unavailablePaths } = await this.reader.readWorkspaceTexts({ + projectRoot: context.projectRoot, + workspacePath: workspace.workspace_path, + paths: [...projectManagementWorkspaceSummaryPaths], + }) + readBytes += Object.values(texts).reduce( + (total, text) => total + (text ? Buffer.byteLength(text) : 0), + 0, + ) + unavailableFileCount += unavailablePaths.length + const input = projectQorInputForWorkspace( + manifest, + workspace.workspace_id, + texts, + ) + return input ? { input } : { issue: workspaceIssue(workspace.workspace_id) } + } catch (error) { + return { issue: workspaceIssue(workspace.workspace_id, error) } + } + }, + ) + if (!this.isCurrent(context, generation)) { + return { ok: false, code: 'unknown-context' } + } + + context.dependencies = new Set( + manifest.workspaces.map((workspace) => resolve(workspace.workspace_path)), + ) + const inputs = entries.flatMap((entry) => (entry.input ? [entry.input] : [])) + const issues = entries.flatMap((entry) => (entry.issue ? [entry.issue] : [])) + 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 = publicTrend( + buildProjectQorTrendSummary(inputs, { + baselineWorkspaceId: manifest.qor_baseline?.workspace_id ?? null, + }), + ) + const snapshots = inputs.map((input) => + publicSnapshot(buildProjectAnalysisSnapshot(input, FLOW_STEPS)), + ) + const flowStates = Object.fromEntries( + inputs.map((input) => [input.workspaceId, input.stepStatuses]), + ) + const stepComparisons = buildStepComparisons(manifest, inputs, snapshots) + 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 } + : {}), + }, + trend: section(trend, issues), + workspaceSnapshots: section({ items: snapshots, flowStates }, issues), + stepComparisons: section({ steps: stepComparisons }, issues), + recommendation: recommendation + ? section(recommendation, issues) + : { + status: 'unavailable', + issues: [...issues, { code: 'NO_ELIGIBLE_WORKSPACE' }], + }, + risks: section({ items: trend.risks }, issues), + timingTriage: section({ 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: + manifest.workspaces.length * projectManagementWorkspaceSummaryPaths.length, + 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 buildStepComparisons( + manifest: ProjectManifest, + inputs: ReturnType[], + snapshots: ProjectAnalysisSnapshot[], +): ProjectStepComparison[] { + const availableInputs = inputs.filter((input): input is NonNullable => + Boolean(input), + ) + const stepIds = new Set(FLOW_STEPS) + for (const input of availableInputs) { + for (const stepId of Object.keys(input.stepStatuses)) stepIds.add(stepId) + } + for (const workspace of manifest.workspaces) { + stepIds.add(workspace.start_step) + stepIds.add(workspace.end_step) + if (workspace.branch_from?.source_step) stepIds.add(workspace.branch_from.source_step) + } + const knownOrder = new Map(FLOW_STEPS.map((step, order) => [step, order])) + 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) => { + const input = availableInputs.find( + (candidate) => candidate.workspaceId === workspace.workspace_id, + ) + const snapshot = snapshots.find( + (candidate) => candidate.workspaceId === workspace.workspace_id, + ) + const stepSnapshot = snapshot?.steps[stepId as keyof typeof snapshot.steps] + return { + workspaceId: workspace.workspace_id, + status: (input?.stepStatuses[stepId] ?? + 'missing') as ProjectStepComparison['workspaces'][number]['status'], + metrics: stepSnapshot?.metrics ?? [], + } + }), + })) + .sort((left, right) => left.order - right.order) +} + +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, + }, + } +} + +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 section(data: T, issues: ReadIssue[]): ReadSection { + return issues.length + ? { status: 'partial', data, issues } + : { status: 'ready', data, issues: [] } +} + +function workspaceIssue(workspaceId: string, error?: unknown): ReadIssue { + return { + code: 'WORKSPACE_ANALYSIS_FAILED', + detail: error instanceof Error ? `${workspaceId}: ${error.message}` : workspaceId, + } +} + +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 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), + } +} + +async function mapWithConcurrency( + values: readonly T[], + concurrency: number, + mapper: (value: T) => Promise, +): Promise { + const results: R[] = [] + let next = 0 + await Promise.all( + Array.from({ length: Math.min(concurrency, values.length) }, async () => { + while (next < values.length) { + const index = next++ + results[index] = await mapper(values[index]!) + } + }), + ) + return results +} 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..72ec5d0f3 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.test.ts @@ -0,0 +1,299 @@ +import type { WorkspaceResourceIndex } from '@ecos-studio/shared' +import { describe, expect, it, vi } from 'vitest' +import { BackendWorkspaceService } from './backendWorkspaceService' +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 [MHz]': 200, + 'Max fanout': 24, + PDK: 'ics55', + 'Top module': 'gcd_top', + }, + pdk: 'ics55', + root: '/project/ws-a', + status: 'available', + topModule: 'gcd_top', + } +} + +describe('BackendWorkspaceService', () => { + it('returns window-scoped identity and configuration from one resource index', async () => { + const getIndex = vi.fn().mockResolvedValue(resourceIndex()) + const readManifest = vi.fn().mockResolvedValue( + JSON.stringify({ + 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 service = new BackendWorkspaceService({ + projectManagementReadService: { readManifest }, + workspaceResourceService: { getIndex }, + }) + + const result = await runWithWindowScope(41, () => service.getOverview()) + + expect(result).toMatchObject({ + generation: 0, + overview: { + configuration: { + data: { + clock: 'clk', + 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(getIndex).toHaveBeenCalledTimes(1) + 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({ + workspaceResourceService: { getIndex: vi.fn().mockResolvedValue(index) }, + }) + + 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('reports a damaged Flow section instead of a ready empty flow', async () => { + const index = resourceIndex() + index.flow.steps = [] + index.messages = [`Failed to parse workspace JSON: ${index.home.flowJson.path}`] + const service = new BackendWorkspaceService({ + workspaceResourceService: { getIndex: vi.fn().mockResolvedValue(index) }, + }) + + const result = await runWithWindowScope(42, () => service.getOverview()) + + expect(result.overview.flow).toMatchObject({ + status: 'error', + issues: [{ code: 'WORKSPACE_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 service = new BackendWorkspaceService({ + readWorkspaceTextFile: vi.fn().mockResolvedValue( + JSON.stringify({ + checklist: [finding('flow-ready', 'flow'), finding('layout', 'artifact')], + }), + ), + workspaceResourceService: { getIndex: vi.fn().mockResolvedValue(index) }, + }) + + 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 resolveIndex!: (value: WorkspaceResourceIndex) => void + const getIndex = vi + .fn() + .mockReturnValueOnce( + new Promise((resolve) => { + resolveIndex = resolve + }), + ) + .mockResolvedValue(resourceIndex()) + const service = new BackendWorkspaceService({ + workspaceResourceService: { getIndex }, + }) + + const first = runWithWindowScope(44, () => service.getOverview()) + const second = runWithWindowScope(44, () => service.getOverview()) + expect(getIndex).toHaveBeenCalledTimes(1) + + resolveIndex(resourceIndex()) + const [firstResult, secondResult] = await Promise.all([first, second]) + expect(secondResult).toEqual(firstResult) + await runWithWindowScope(44, () => service.getOverview()) + expect(getIndex).toHaveBeenCalledTimes(1) + + const refreshed = await runWithWindowScope(44, () => service.refreshOverview()) + expect(refreshed.generation).toBe(1) + expect(getIndex).toHaveBeenCalledTimes(2) + }) + + it('invalidates before notifying subscribers with the next generation', async () => { + const service = new BackendWorkspaceService({ + workspaceResourceService: { + getIndex: vi.fn().mockResolvedValue(resourceIndex()), + }, + }) + 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) + }) +}) 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..5648c1654 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.ts @@ -0,0 +1,576 @@ +import { randomUUID } from 'node:crypto' +import { open } from 'node:fs/promises' +import { dirname, relative, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { + parseProjectManifest, + parseRuntimeSeconds, + projectManagementWorkspaceSummaryPaths, + type BackendWorkspaceOverviewResult, + type ChecklistFinding, + type FlowStepState, + type ProjectManifest, + type ReadIssue, + type ReadSection, + type WorkspaceConfigurationSummary, + type WorkspaceChecklistSummary, + type WorkspaceFlowSummary, + type WorkspaceOverviewCore, + type WorkspaceOverviewIdentity, + type WorkspaceBaselineComparison, + type WorkspaceQorSummary, + type WorkspaceResourceIndex, +} from '@ecos-studio/shared' +import { requireWindowScopeId } from './windowScopeContext' +import { analyzeWorkspaceQor, type WorkspaceAnalysisTexts } from './workspaceQorAnalysis' +import { electronLogger } from './logger' + +interface BackendWorkspaceServiceOptions { + workspaceResourceService: { + getIndex(): Promise + } + projectManagementReadService?: { + readManifest(projectRoot: string): Promise + readWorkspaceTexts?(request: { + projectRoot: string + workspacePath: string + paths: string[] + }): Promise<{ + texts: Record + unavailablePaths: string[] + }> + } + readWorkspaceTextFile?: (path: string) => Promise +} + +interface WorkspaceContext { + id: string + generation: number + cache?: BackendWorkspaceOverviewResult + inFlight?: Promise + coalescedRequests: number +} + +export interface BackendWorkspaceInvalidation { + windowId: number + workspaceContextId: string + generation: number +} + +const NOT_MIGRATED_ISSUE: ReadIssue = { code: 'BACKEND_SECTION_NOT_MIGRATED' } +const CHECKLIST_MAX_BYTES = 512 * 1024 + +function unavailable(): ReadSection { + return { status: 'unavailable', issues: [NOT_MIGRATED_ISSUE] } +} + +function stringValue(record: Record | null, key: string): string { + const value = record?.[key] + return typeof value === 'string' ? value : '' +} + +function finiteNumber(value: unknown): number | null { + const number = typeof value === 'number' ? value : Number(value) + return Number.isFinite(number) ? number : null +} + +function recordValue(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +async function readBoundedText(path: string): Promise { + let handle: Awaited> | null = null + try { + handle = await open(path, 'r') + const buffer = Buffer.alloc(CHECKLIST_MAX_BYTES + 1) + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0) + if (bytesRead > CHECKLIST_MAX_BYTES) { + throw new Error(`Checklist exceeds ${CHECKLIST_MAX_BYTES} bytes`) + } + return buffer.subarray(0, bytesRead).toString('utf8') + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ) { + return null + } + throw error + } finally { + await handle?.close() + } +} + +function pathsEqual(left: string, right: string): boolean { + return relative(resolve(left), resolve(right)) === '' +} + +function configurationSection( + index: WorkspaceResourceIndex, +): ReadSection { + if (!index.parameters) { + return { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_CONFIGURATION_UNAVAILABLE' }], + } + } + + const die = recordValue(index.parameters.Die) + return { + status: 'ready', + data: { + pdk: stringValue(index.parameters, 'PDK'), + design: stringValue(index.parameters, 'Design'), + topModule: stringValue(index.parameters, 'Top module'), + dieArea: finiteNumber(die?.Area), + maxFanout: finiteNumber(index.parameters['Max fanout']), + clock: stringValue(index.parameters, 'Clock'), + frequencyMaxMhz: finiteNumber(index.parameters['Frequency max [MHz]']), + }, + issues: [], + } +} + +function normalizeFlowState(value: string): FlowStepState { + switch (value.trim().toLowerCase()) { + case 'success': + case 'succeeded': + case 'completed': + case 'complete': + return 'succeeded' + case 'ongoing': + case 'running': + return 'running' + case 'incomplete': + return 'failed' + case 'invalid': + case 'failed': + case 'failure': + case 'error': + return 'failed' + case 'pending': + return 'not-started' + case 'skipped': + return 'skipped' + case 'cancelled': + case 'canceled': + return 'cancelled' + default: + return value.trim() ? 'unknown' : 'not-started' + } +} + +function flowSection(index: WorkspaceResourceIndex): ReadSection { + if (!index.home.flowJson.exists) { + return { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_FLOW_UNAVAILABLE' }], + } + } + const parseIssue = index.messages.find((message) => + message.includes(index.home.flowJson.path), + ) + if (parseIssue) { + return { + status: 'error', + issues: [{ code: 'WORKSPACE_FLOW_INVALID', detail: parseIssue }], + } + } + return { + status: 'ready', + data: { + steps: index.flow.steps.map((step, order) => { + const runtimeSeconds = parseRuntimeSeconds(step.runtime) + const peakMemoryMb = finiteNumber( + step.peakMemoryMb ?? step.info['peak memory (mb)'], + ) + return { + stepId: step.name, + order, + name: step.name, + state: normalizeFlowState(step.state), + ...(step.tool ? { toolId: step.tool } : {}), + ...(runtimeSeconds === null ? {} : { runtimeSeconds }), + ...(peakMemoryMb === null ? {} : { peakMemoryMb }), + } + }), + }, + issues: [], + } +} + +function checklistFinding(value: unknown): ChecklistFinding | null { + const item = recordValue(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 = recordValue(item.source) + if (!source || !Array.isArray(item.evidence)) return null + const evidence = item.evidence.filter( + (entry): entry is Record => recordValue(entry) !== 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, + } +} + +function reconcileChecklistFinding( + 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', + }, + ], + } +} + +function identityFromManifest( + index: WorkspaceResourceIndex, + manifest: ProjectManifest | null, +): WorkspaceOverviewIdentity { + const workspace = manifest?.workspaces.find((candidate) => + pathsEqual(candidate.workspace_path, index.root), + ) + return { + ...(manifest ? { projectId: manifest.project_id, projectName: manifest.name } : {}), + ...(workspace + ? { workspaceId: workspace.workspace_id, workspaceName: workspace.name } + : { + workspaceName: index.root.split(/[\\/]/).filter(Boolean).pop() ?? 'Workspace', + }), + ...(manifest?.qor_baseline?.workspace_id + ? { baselineWorkspaceId: manifest.qor_baseline.workspace_id } + : {}), + } +} + +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((result) => { + const current = this.contexts.get(windowId) + if (current === context && current.generation === generation) { + current.cache = result + } + 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() + } + + 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 { + this.contexts.delete(windowId) + } + + private contextForWindow(windowId: number): WorkspaceContext { + const existing = this.contexts.get(windowId) + if (existing) return existing + const context = { id: randomUUID(), generation: 0, coalescedRequests: 0 } + 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 index = await this.options.workspaceResourceService.getIndex() + const [manifest, checklist] = await Promise.all([ + this.readManifest(index.root), + this.readChecklist(index), + ]) + const qor = await this.readQor(index, manifest) + const readMs = performance.now() - readStartedAt + const normalizeStartedAt = performance.now() + const overview: WorkspaceOverviewCore = { + identity: identityFromManifest(index, manifest), + configuration: configurationSection(index), + flow: flowSection(index), + checklist, + qor: qor.qor, + baselineComparison: qor.baselineComparison, + } + const result = { + workspaceContextId: context.id, + generation, + overview, + } + const files = workspaceIndexFiles(index) + electronLogger.debug('[backend-workspace] query metrics', { + coalescedRequests: context.coalescedRequests, + eventLoopDelayMs: roundMs(await eventLoopDelay), + fileCount: files.length, + indexedBytes: files.reduce((total, file) => total + (file.sizeBytes ?? 0), 0), + ipcPayloadBytes: Buffer.byteLength(JSON.stringify(result)), + normalizeMs: roundMs(performance.now() - normalizeStartedAt), + readMs: roundMs(readMs), + totalMs: roundMs(performance.now() - startedAt), + }) + context.coalescedRequests = 0 + return result + } + + private async readManifest(workspaceRoot: string): Promise { + if (!this.options.projectManagementReadService) return null + try { + const content = await this.options.projectManagementReadService.readManifest( + dirname(workspaceRoot), + ) + return content ? parseProjectManifest(content) : null + } catch { + return null + } + } + + private async readChecklist( + index: WorkspaceResourceIndex, + ): Promise> { + if (!index.home.checklistJson.exists) { + return { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_CHECKLIST_UNAVAILABLE' }], + } + } + try { + const content = await (this.options.readWorkspaceTextFile ?? readBoundedText)( + index.home.checklistJson.path, + ) + if (!content) { + return { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_CHECKLIST_UNAVAILABLE' }], + } + } + const root = recordValue(JSON.parse(content)) + 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( + index.flow.steps + .filter((step) => normalizeFlowState(step.state) === 'succeeded') + .flatMap((step) => [step.name.trim().toLowerCase()]), + ) + const data = { + findings: findings + .filter((finding): finding is ChecklistFinding => finding !== null) + .map((finding) => reconcileChecklistFinding(finding, successfulSteps)), + } + return invalidCount + ? { + status: 'partial', + data, + issues: [ + { + code: 'WORKSPACE_CHECKLIST_ITEM_INVALID', + detail: `${invalidCount} invalid checklist item(s)`, + }, + ], + } + : { status: 'ready', data, issues: [] } + } catch (error) { + return { + status: 'error', + issues: [ + { + code: 'WORKSPACE_CHECKLIST_READ_FAILED', + detail: error instanceof Error ? error.message : String(error), + }, + ], + } + } + } + + private async readQor( + index: WorkspaceResourceIndex, + manifest: ProjectManifest | null, + ): Promise<{ + qor: ReadSection + baselineComparison: ReadSection + }> { + const reader = this.options.projectManagementReadService?.readWorkspaceTexts + const currentWorkspace = manifest?.workspaces.find((workspace) => + pathsEqual(workspace.workspace_path, index.root), + ) + if (!manifest || !currentWorkspace || !reader) { + return { qor: unavailable(), baselineComparison: unavailable() } + } + + const baselineWorkspaceId = manifest.qor_baseline?.workspace_id + const requestedIds = [ + currentWorkspace.workspace_id, + ...(baselineWorkspaceId && baselineWorkspaceId !== currentWorkspace.workspace_id + ? [baselineWorkspaceId] + : []), + ] + const textsByWorkspaceId: 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 + } + try { + const result = await reader({ + projectRoot: dirname(index.root), + workspacePath: workspace.workspace_path, + paths: [...projectManagementWorkspaceSummaryPaths], + }) + textsByWorkspaceId[workspaceId] = result.texts + } catch { + 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, + textsByWorkspaceId, + ) + if (baselineWorkspaceId && failedIds.has(baselineWorkspaceId)) { + result.baselineComparison = { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_BASELINE_UNAVAILABLE' }], + } + } + return result + } +} + +function workspaceIndexFiles(index: WorkspaceResourceIndex) { + const files = new Map() + const visit = (value: unknown): void => { + if (!value || typeof value !== 'object') return + if ('path' in value && typeof value.path === 'string' && 'exists' in value) { + files.set(value.path, value as { path: string; sizeBytes?: number }) + return + } + for (const nested of Object.values(value)) visit(nested) + } + visit(index.home) + visit(index.flow.steps) + if (index.tech) visit(index.tech) + return [...files.values()] +} + +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/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/qorAnalysis.test.ts b/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.test.ts new file mode 100644 index 000000000..780e2b5a6 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { + buildProjectQorTrendSummary, + normalizeQorMetrics, + QOR_SCORE_THRESHOLD, +} 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('owns the score threshold and metric polarity in Electron', () => { + 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(QOR_SCORE_THRESHOLD).toBe(60) + 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 95% rename from ecos/gui/apps/renderer/src/utils/projectQorTrend.ts rename to ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts index 85c996a3c..69f2c9df6 100644 --- a/ecos/gui/apps/renderer/src/utils/projectQorTrend.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts @@ -1,8 +1,15 @@ import type { - FlowStep, - ProjectStepStatus, - ProjectWorkspaceStatus, -} from './projectManagement' + ProjectManifestFlowStep as FlowStep, + ProjectManifestWorkspaceStatus as ProjectWorkspaceStatus, +} from '@ecos-studio/shared' + +type ProjectStepStatus = + | 'success' + | 'reused' + | 'skipped' + | 'unstart' + | 'running' + | 'failed' export type QorDimension = | 'timing' @@ -24,7 +31,7 @@ export type QorGateStatus = 'pass' | 'blocked' | 'incomplete' | 'unavailable' export interface ProjectQorWorkspaceInput { workspaceId: string workspaceName: string - workspacePath: string + workspaceKey: string createdAt: string status: ProjectWorkspaceStatus branchFrom: { @@ -35,19 +42,19 @@ export interface ProjectQorWorkspaceInput { stepSummaryTexts?: Partial> stepHotspotTexts?: Partial> staTimingIssuesText?: string | null - stepStatuses: Partial> + stepStatuses: Record } 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 @@ -64,6 +71,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 { @@ -194,7 +211,7 @@ export interface ProjectQorDataQuality { export interface ProjectQorTrendWorkspaceSummary { workspaceId: string workspaceName: string - workspacePath: string + workspaceKey: string status: QorStatus overallScore: number | null gateStatus: QorGateStatus @@ -219,6 +236,7 @@ export interface ProjectQorTrendSummary { trendPoints: ProjectQorTrendPoint[] baselineWorkspaceId: string | null baselineLabel: string + scoreThreshold: number regressions: ProjectQorRegression[] improvements: ProjectQorDelta[] risks: ProjectQorRisk[] @@ -1098,7 +1116,7 @@ export function normalizeQorMetrics(input: QorStepMetricInput): ProjectQorMetric return [ { workspaceId: input.workspaceId, - workspacePath: input.workspacePath, + workspaceKey: input.workspaceKey, step: input.step, metricName, displayName: @@ -1116,11 +1134,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) && @@ -1152,11 +1190,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, @@ -1184,6 +1231,7 @@ export function buildProjectQorTrendSummary( baselineLabel: baselineWorkspace ? baselineWorkspace.workspaceName || baselineWorkspace.workspaceId : 'Sequential workspace baseline', + scoreThreshold: QOR_SCORE_THRESHOLD, regressions, improvements, risks, @@ -1360,7 +1408,7 @@ export function buildProjectQorTrendReport( workspaces: summary.workspaces.map((workspace) => ({ workspace_id: workspace.workspaceId, workspace_name: workspace.workspaceName, - workspace_path: workspace.workspacePath, + workspace_path: workspace.workspaceKey, status: workspace.status, overall_score: workspace.overallScore, gate_status: workspace.gateStatus, @@ -1618,7 +1666,7 @@ function buildWorkspaceSummary( const records = QOR_FLOW_STEPS.flatMap((step) => normalizeQorMetrics({ workspaceId: workspace.workspaceId, - workspacePath: workspace.workspacePath, + workspaceKey: workspace.workspaceKey, step, text: workspace.stepMetricTexts[step], }), @@ -1677,7 +1725,7 @@ function buildWorkspaceSummary( return { workspaceId: workspace.workspaceId, workspaceName: workspace.workspaceName, - workspacePath: workspace.workspacePath, + workspaceKey: workspace.workspaceKey, status: workspaceStatus(workspace.status, overallScore, effectiveGateStatus), overallScore, gateStatus: effectiveGateStatus, @@ -2787,6 +2835,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, @@ -2950,7 +3098,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], }) 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..f7a44fe11 --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts @@ -0,0 +1,91 @@ +import type { ProjectManifest } from '@ecos-studio/shared' +import { describe, expect, it } from 'vitest' +import { analyzeWorkspaceQor } 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', + }) +} + +describe('analyzeWorkspaceQor', () => { + it('builds current QoR and the selected baseline comparison', () => { + const workspace = (id: string, name: string) => ({ + 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}`, + }) + 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: { 'route_ecc/analysis/qor_metrics.json': metricText(5200) }, + current: { 'route_ecc/analysis/qor_metrics.json': metricText(5000) }, + }) + + expect(result.qor).toMatchObject({ + data: { metrics: [{ id: 'route_wirelength', value: 5000 }] }, + status: 'ready', + }) + expect(result.baselineComparison).toMatchObject({ + data: { + baselineWorkspaceId: 'baseline', + deltas: [ + { + baselineValue: 5200, + currentValue: 5000, + metricId: 'route_wirelength', + verdict: 'improvement', + }, + ], + status: 'comparable', + }, + status: 'ready', + }) + }) +}) 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..bdb372c1a --- /dev/null +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts @@ -0,0 +1,297 @@ +import { + projectManagementStaTimingIssuesPath, + projectManagementWorkspaceStepAnalysisSpecs, + type MetricComparison, + type MetricValue, + type ProjectManifest, + type ProjectManifestFlowStep, + type QorScore, + type ReadSection, + type WorkspaceBaselineComparison, + type WorkspaceQorSummary, +} from '@ecos-studio/shared' +import { + buildProjectQorTrendSummary, + buildProjectQorWorkspaceComparison, + QOR_SCORE_THRESHOLD, + type ProjectQorMetricRecord, + type ProjectQorWorkspaceInput, +} from './qorAnalysis' + +export type WorkspaceAnalysisTexts = Record + +const FLOW_STEPS = projectManagementWorkspaceStepAnalysisSpecs.map((spec) => spec.step) + +const FLOW_STEP_ALIASES: Record = { + synthesis: 'Synth', + synth: 'Synth', + floorplan: 'Floor', + floor: 'Floor', + fixfanout: 'Fanout', + fanout: 'Fanout', + place: 'Place', + placement: 'Place', + cts: 'CTS', + legalization: 'Legal', + legal: 'Legal', + route: 'Route', + routing: 'Route', + drc: 'DRC', + lvs: 'LVS', + filler: 'Filler', + rcx: 'RCX', + sta: 'STA', + harden: 'Harden', +} + +type ProjectStepStatus = NonNullable< + ProjectQorWorkspaceInput['stepStatuses'][ProjectManifestFlowStep] +> + +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 flowState(value: unknown): ProjectStepStatus | undefined { + if (typeof value !== 'string') return undefined + switch (value.trim().toLowerCase()) { + case 'success': + case 'succeeded': + case 'completed': + return 'success' + 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 + } +} + +function flowStates(text: string | null | undefined): Record { + if (!text) return {} + try { + const parsed = JSON.parse(text) as { steps?: unknown[] } + if (!Array.isArray(parsed.steps)) return {} + return Object.fromEntries( + parsed.steps.flatMap((raw) => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [] + const stepRecord = raw as Record + const step = flowStep(stepRecord.name) + const state = flowState(stepRecord.state) + return step && state ? [[step, state]] : [] + }), + ) + } catch { + return {} + } +} + +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.some((state) => state === 'success' || state === 'reused')) { + return 'success' + } + return manifestStatus +} + +export function projectQorInputForWorkspace( + manifest: ProjectManifest, + workspaceId: string, + texts: WorkspaceAnalysisTexts, +): ProjectQorWorkspaceInput | null { + const workspace = manifest.workspaces.find( + (candidate) => candidate.workspace_id === workspaceId, + ) + if (!workspace) return null + const statuses = flowStates(texts['home/flow.json']) + return { + branchFrom: workspace.branch_from, + createdAt: workspace.created_at, + staTimingIssuesText: texts[projectManagementStaTimingIssuesPath] ?? null, + status: workspaceStatus(workspace.status, statuses), + stepHotspotTexts: Object.fromEntries( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ + spec.step, + texts[spec.hotspotsPath] ?? null, + ]), + ), + stepMetricTexts: Object.fromEntries( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ + spec.step, + texts[spec.metricsPath] ?? null, + ]), + ), + stepStatuses: statuses, + stepSummaryTexts: Object.fromEntries( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ + spec.step, + texts[spec.summaryPath] ?? null, + ]), + ), + workspaceId, + workspaceName: workspace.name || workspaceId, + workspaceKey: workspaceId, + } +} + +function qorScore(record: { + overallScore: number | null + gateStatus: QorScore['gate'] +}): QorScore { + return { + value: record.overallScore, + gate: record.gateStatus, + threshold: QOR_SCORE_THRESHOLD, + } +} + +function metricValue(record: ProjectQorMetricRecord): MetricValue { + return { + id: record.metricName, + name: record.displayName, + stepId: record.step, + value: record.value, + ...(record.unit ? { unit: record.unit } : {}), + polarity: record.polarity, + } +} + +function workspaceQor( + record: ReturnType['workspaces'][number], +): WorkspaceQorSummary { + const metrics = (record.comparisonRecords ?? record.records).map(metricValue) + return { + score: qorScore(record), + metrics, + steps: FLOW_STEPS.flatMap((step, order) => { + const stepMetrics = metrics.filter((metric) => metric.stepId === step) + return stepMetrics.length + ? [{ stepId: step, order, name: step, metrics: stepMetrics }] + : [] + }), + } +} + +function comparisonDelta( + metric: ReturnType['metrics'][number], +): MetricComparison { + return { + metricId: metric.metricName, + name: metric.displayName, + stepId: metric.step, + currentValue: metric.currentValue, + baselineValue: metric.baselineValue, + absoluteDelta: metric.absoluteDelta, + relativeDeltaPct: metric.relativeDeltaPct, + ...(metric.unit ? { unit: metric.unit } : {}), + polarity: metric.polarity, + verdict: !metric.isDirectional + ? 'not-comparable' + : metric.state === 'neutral' + ? 'unchanged' + : metric.state, + } +} + +export function analyzeWorkspaceQor( + manifest: ProjectManifest, + currentWorkspaceId: string, + textsByWorkspaceId: Record, +): { + qor: ReadSection + baselineComparison: ReadSection +} { + const currentInput = projectQorInputForWorkspace( + manifest, + currentWorkspaceId, + textsByWorkspaceId[currentWorkspaceId] ?? {}, + ) + if (!currentInput) { + const unavailable = { + status: 'unavailable' as const, + issues: [{ code: 'WORKSPACE_QOR_UNAVAILABLE' }], + } + return { qor: unavailable, baselineComparison: unavailable } + } + const baselineWorkspaceId = manifest.qor_baseline?.workspace_id + const baselineInput = baselineWorkspaceId + ? projectQorInputForWorkspace( + manifest, + baselineWorkspaceId, + textsByWorkspaceId[baselineWorkspaceId] ?? {}, + ) + : null + const trend = buildProjectQorTrendSummary( + baselineInput && baselineInput.workspaceId !== currentInput.workspaceId + ? [baselineInput, currentInput] + : [currentInput], + { baselineWorkspaceId: baselineWorkspaceId ?? null }, + ) + const current = trend.workspaces.find( + (workspace) => workspace.workspaceId === currentWorkspaceId, + ) + const qor: ReadSection = current + ? { status: 'ready', data: workspaceQor(current), issues: [] } + : { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_QOR_UNAVAILABLE' }], + } + + if (!baselineWorkspaceId || !baselineInput) { + return { + qor, + baselineComparison: { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_BASELINE_UNAVAILABLE' }], + }, + } + } + const comparison = buildProjectQorWorkspaceComparison(trend, currentWorkspaceId) + return { + qor, + baselineComparison: { + status: 'ready', + data: { + baselineWorkspaceId, + baselineWorkspaceName: + comparison.baselineWorkspaceName ?? baselineInput.workspaceName, + baselineScore: { + value: comparison.baselineScore, + gate: + trend.workspaces.find( + (workspace) => workspace.workspaceId === baselineWorkspaceId, + )?.gateStatus ?? 'unavailable', + threshold: QOR_SCORE_THRESHOLD, + }, + deltas: comparison.metrics.map(comparisonDelta), + status: comparison.isBaselineWorkspace + ? 'baseline' + : comparison.available + ? 'comparable' + : 'not-comparable', + }, + issues: [], + }, + } +} diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.ts index ae7e08058..e1b6ba273 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceResourceService.ts @@ -1,4 +1,4 @@ -import { readdir, readFile, stat } from 'node:fs/promises' +import { open, readdir, stat } from 'node:fs/promises' import { join, relative } from 'node:path' import type { WorkspaceResourceFile, @@ -16,6 +16,8 @@ type WorkspaceResourceFileKind = WorkspaceResourceFile['kind'] type ResourceBucketName = keyof WorkspaceStepResource['resources'] type StepFileBuckets = WorkspaceStepResource['resources'] +const WORKSPACE_INDEX_JSON_MAX_BYTES = 4 * 1024 * 1024 + interface WorkspaceResourceServiceOptions { projectScopeProvider: Pick< ProjectScopeProvider, @@ -28,6 +30,7 @@ interface FlowStepInput { tool: string state: string runtime: string + peakMemoryMb?: number info: Record } @@ -239,6 +242,7 @@ export class WorkspaceResourceService { tool, state: step.state, runtime: step.runtime, + ...(step.peakMemoryMb === undefined ? {} : { peakMemoryMb: step.peakMemoryMb }), directory, info: step.info, resources, @@ -442,7 +446,20 @@ export class WorkspaceResourceService { private async readJsonOrNull(path: string): Promise | null> { try { const canonicalPath = await this.projectScopeProvider.requestProjectPathAccess(path) - const raw = await readFile(canonicalPath, 'utf8') + 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) { @@ -974,6 +991,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 : {}, } } diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceService.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceService.ts index 329cef92a..8d9750dd1 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceService.ts @@ -357,6 +357,10 @@ export class WorkspaceService { return await this.projectScopeProvider.isProjectDirectory(path) } + async getProjectRoot(): Promise { + return await this.projectScopeProvider.getProjectRoot() + } + async pathExists(path: string): Promise { return await pathExists(resolve(path)) } diff --git a/ecos/gui/apps/renderer/src/components/ChecklistTable.vue b/ecos/gui/apps/renderer/src/components/ChecklistTable.vue index 412b22667..fb9d99d5f 100644 --- a/ecos/gui/apps/renderer/src/components/ChecklistTable.vue +++ b/ecos/gui/apps/renderer/src/components/ChecklistTable.vue @@ -51,7 +51,7 @@ diff --git a/ecos/gui/apps/renderer/src/composables/runtimeStepRenderSync.test.ts b/ecos/gui/apps/renderer/src/composables/runtimeStepRenderSync.test.ts index f67827cbe..132f0468c 100644 --- a/ecos/gui/apps/renderer/src/composables/runtimeStepRenderSync.test.ts +++ b/ecos/gui/apps/renderer/src/composables/runtimeStepRenderSync.test.ts @@ -56,7 +56,7 @@ describe('runtimeStepRenderSync', () => { unregisterSecond() }) - it('keeps the ACK path available when one optional view refresh fails', async () => { + it('keeps event refresh available when one optional view refresh fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const unregister = registerRuntimeStepRenderTask(async () => { throw new Error('NFS preview unavailable') diff --git a/ecos/gui/apps/renderer/src/composables/runtimeStepRenderSync.ts b/ecos/gui/apps/renderer/src/composables/runtimeStepRenderSync.ts index c57defd4c..1c6351966 100644 --- a/ecos/gui/apps/renderer/src/composables/runtimeStepRenderSync.ts +++ b/ecos/gui/apps/renderer/src/composables/runtimeStepRenderSync.ts @@ -22,8 +22,7 @@ const renderTasks = new Set() /** * Components that own step-derived data register their bounded refresh here. - * The workspace event coordinator awaits these tasks before it releases the - * ECC gate, so no next step races a slow NFS-backed view refresh. + * Runtime events trigger these tasks without delaying ECC execution. */ export function registerRuntimeStepRenderTask(task: RuntimeStepRenderTask): () => void { renderTasks.add(task) diff --git a/ecos/gui/apps/renderer/src/composables/useDesktopRuntime.test.ts b/ecos/gui/apps/renderer/src/composables/useDesktopRuntime.test.ts index 0ca11a547..5637d2c3d 100644 --- a/ecos/gui/apps/renderer/src/composables/useDesktopRuntime.test.ts +++ b/ecos/gui/apps/renderer/src/composables/useDesktopRuntime.test.ts @@ -57,6 +57,23 @@ const desktopBridge = { dreamplace: 'unknown', }), }, + productCommands: { + execute: async () => ({ accepted: false, operationId: '', state: '' }), + }, + workspaceCreationModel: { + get: async () => ({ + context: {}, + controls: { + flowBoundaries: true, + manualPdkFiles: true, + mpc: true, + pdkVersion: true, + }, + discovery: {}, + parameters: [], + pdkInstallations: [], + }), + }, window: { minimize: async () => undefined, toggleMaximize: async () => undefined, @@ -213,22 +230,15 @@ const desktopBridge = { events: { onEvent: () => () => undefined, }, - flow: { - run: async (request) => ({ rerun: Boolean(request.rerun) }), - runStep: async (request) => ({ state: 'Success', step: request.step }), - }, rpc: { hello: async () => ({ capabilities: [], eccVersion: 'unknown', version: 1 }), ping: async () => ({ ok: true }), shutdown: async () => ({ ok: true }), }, workspace: { + describeSpec: async () => ({}), + validateSpec: async () => ({ issues: [] }), close: async () => ({ ok: true }), - create: async (request) => ({ - directory: request.directory, - workspaceHandle: 'workspace-handle-1', - }), - exportSignoff: async (request) => ({ outputPath: request.outputPath }), inspectSignoff: async () => ({ groups: [], risks: [], status: 'ready' as const }), home: async () => ({ path: '' }), info: async (request) => ({ id: request.id, info: {}, step: request.step }), @@ -237,13 +247,6 @@ const desktopBridge = { workspaceHandle: 'workspace-handle-1', }), refreshConfig: async () => ({ directory: '', refreshed: true }), - resetFlow: async () => ({ directory: '' }), - syncConfig: async (request) => ({ - configPath: request.configPath, - directory: '', - parametersChanged: false, - refreshed: true, - }), }, }, shell: { diff --git a/ecos/gui/apps/renderer/src/composables/useFlowRunner.test.ts b/ecos/gui/apps/renderer/src/composables/useFlowRunner.test.ts index f4c4ecb4c..29b9d1190 100644 --- a/ecos/gui/apps/renderer/src/composables/useFlowRunner.test.ts +++ b/ecos/gui/apps/renderer/src/composables/useFlowRunner.test.ts @@ -37,6 +37,7 @@ const { value: { sessionId: 'session-1', workspaceId: 'workspace-demo', + workspaceRevision: 1, state: undefined as string | undefined, }, }, @@ -111,6 +112,7 @@ describe('useFlowRunner desktop and design-tool routing', () => { workspaceSession.value = { sessionId: 'session-1', workspaceId: 'workspace-demo', + workspaceRevision: 1, state: undefined, } resourceVersions.value = { @@ -161,11 +163,13 @@ describe('useFlowRunner desktop and design-tool routing', () => { }) expect(startFlowOperationApi).toHaveBeenCalledWith({ + expectedWorkspaceRevision: 1, idempotencyKey: expect.any(String), rerun: true, workspaceHandle: 'workspace-demo', }) expect(startStepOperationApi).toHaveBeenCalledWith({ + expectedWorkspaceRevision: 1, idempotencyKey: expect.any(String), rerun: true, resetDependents: false, @@ -242,6 +246,7 @@ describe('useFlowRunner desktop and design-tool routing', () => { workspaceSession.value = { sessionId: 'session-1', workspaceId: '', + workspaceRevision: 1, state: 'loading', } diff --git a/ecos/gui/apps/renderer/src/composables/useFlowRunner.ts b/ecos/gui/apps/renderer/src/composables/useFlowRunner.ts index 5981b23d5..309129d60 100644 --- a/ecos/gui/apps/renderer/src/composables/useFlowRunner.ts +++ b/ecos/gui/apps/renderer/src/composables/useFlowRunner.ts @@ -89,6 +89,14 @@ export function useFlowRunner() { const error = ref(null) const lastRunResult = ref(null) + function currentWorkspaceRevision(): number { + const revision = workspaceSession.value.workspaceRevision + if (!Number.isInteger(revision)) { + throw new Error('The current Workspace revision is unavailable.') + } + return revision! + } + /** * 获取当前步骤(从动态路由参数获取) */ @@ -256,6 +264,7 @@ export function useFlowRunner() { } const operation = await startStepOperationApi({ + expectedWorkspaceRevision: currentWorkspaceRevision(), idempotencyKey: crypto.randomUUID(), rerun: Boolean(options.rerun), resetDependents: Boolean(options.resetDependents), @@ -379,6 +388,7 @@ export function useFlowRunner() { } const operation = await startFlowOperationApi({ + expectedWorkspaceRevision: currentWorkspaceRevision(), idempotencyKey: crypto.randomUUID(), rerun: Boolean(options.rerun), workspaceHandle: requestScope.workspaceHandle, diff --git a/ecos/gui/apps/renderer/src/composables/usePdkManager.test.ts b/ecos/gui/apps/renderer/src/composables/usePdkManager.test.ts index 8965ebe5f..1b16473c8 100644 --- a/ecos/gui/apps/renderer/src/composables/usePdkManager.test.ts +++ b/ecos/gui/apps/renderer/src/composables/usePdkManager.test.ts @@ -107,6 +107,23 @@ const desktopBridge = { dreamplace: 'unknown', }), }, + productCommands: { + execute: async () => ({ accepted: false, operationId: '', state: '' }), + }, + workspaceCreationModel: { + get: async () => ({ + context: {}, + controls: { + flowBoundaries: true, + manualPdkFiles: true, + mpc: true, + pdkVersion: true, + }, + discovery: {}, + parameters: [], + pdkInstallations: [], + }), + }, window: { minimize: async () => undefined, toggleMaximize: async () => undefined, @@ -251,22 +268,15 @@ const desktopBridge = { events: { onEvent: () => () => undefined, }, - flow: { - run: async (request) => ({ rerun: Boolean(request.rerun) }), - runStep: async (request) => ({ state: 'Success', step: request.step }), - }, rpc: { hello: async () => ({ capabilities: [], eccVersion: 'unknown', version: 1 }), ping: async () => ({ ok: true }), shutdown: async () => ({ ok: true }), }, workspace: { + describeSpec: async () => ({}), + validateSpec: async () => ({ issues: [] }), close: async () => ({ ok: true }), - create: async (request) => ({ - directory: request.directory, - workspaceHandle: 'workspace-handle-1', - }), - exportSignoff: async (request) => ({ outputPath: request.outputPath }), inspectSignoff: async () => ({ groups: [], risks: [], status: 'ready' as const }), home: async () => ({ path: '' }), info: async (request) => ({ id: request.id, info: {}, step: request.step }), @@ -275,13 +285,6 @@ const desktopBridge = { workspaceHandle: 'workspace-handle-1', }), refreshConfig: async () => ({ directory: '', refreshed: true }), - resetFlow: async () => ({ directory: '' }), - syncConfig: async (request) => ({ - configPath: request.configPath, - directory: '', - parametersChanged: false, - refreshed: true, - }), }, }, shell: { diff --git a/ecos/gui/apps/renderer/src/composables/useSignoffPackageExport.test.ts b/ecos/gui/apps/renderer/src/composables/useSignoffPackageExport.test.ts index de74a5a8d..605db0dd3 100644 --- a/ecos/gui/apps/renderer/src/composables/useSignoffPackageExport.test.ts +++ b/ecos/gui/apps/renderer/src/composables/useSignoffPackageExport.test.ts @@ -116,7 +116,11 @@ function createApi() { workspace: { writeProjectTextFile }, workspaceResources: { readFlow, readParameters, readHome }, dialog: { saveFile }, - ecc: { workspace: { exportSignoff, inspectSignoff } }, + ecc: { workspace: { inspectSignoff } }, + productCommands: { + execute: (request: { payload: { outputPath: string } }) => + exportSignoff(request.payload), + }, } as unknown as DesktopApi return { diff --git a/ecos/gui/apps/renderer/src/composables/useSignoffPackageExport.ts b/ecos/gui/apps/renderer/src/composables/useSignoffPackageExport.ts index edab323a4..7e6108ed8 100644 --- a/ecos/gui/apps/renderer/src/composables/useSignoffPackageExport.ts +++ b/ecos/gui/apps/renderer/src/composables/useSignoffPackageExport.ts @@ -289,17 +289,20 @@ export function useSignoffPackageExport({ } }) - const result = await api.ecc.workspace.exportSignoff({ - additionalFiles, - outputPath, - workspaceHandle: workspace.workspaceHandle, + const result = await api.productCommands.execute({ + command: 'workspace.exportSignoff', + payload: { + additionalFiles, + outputPath, + workspaceHandle: workspace.workspaceHandle, + }, }) if (!isActiveWorkspace(workspace.workspacePath, workspace.workspaceHandle)) return showToast({ severity: 'success', summary: 'Signoff Package Exported', - detail: `Saved package and design summaries to ${result.outputPath}`, + detail: `Saved package and design summaries to ${'outputPath' in result ? result.outputPath : outputPath}`, }) } catch (error) { if (!isActiveWorkspace(workspace.workspacePath, workspace.workspaceHandle)) return diff --git a/ecos/gui/apps/renderer/src/composables/useStepConfigInfo.ts b/ecos/gui/apps/renderer/src/composables/useStepConfigInfo.ts index aa6bfa1f7..c97cd4058 100644 --- a/ecos/gui/apps/renderer/src/composables/useStepConfigInfo.ts +++ b/ecos/gui/apps/renderer/src/composables/useStepConfigInfo.ts @@ -462,11 +462,19 @@ export function useStepConfigInfo(stepOverride?: StepEnum | Ref ({ accepted: false, operationId: '', state: '' }), + }, + workspaceCreationModel: { + get: async () => ({ + context: {}, + controls: { + flowBoundaries: true, + manualPdkFiles: true, + mpc: true, + pdkVersion: true, + }, + discovery: {}, + parameters: [], + pdkInstallations: [], + }), + }, window: { minimize: async () => undefined, toggleMaximize: async () => undefined, @@ -184,22 +201,15 @@ function createDesktopBridge(getVersions: DesktopApi['app']['getVersions']) { events: { onEvent: () => () => undefined, }, - flow: { - run: async (request) => ({ rerun: Boolean(request.rerun) }), - runStep: async (request) => ({ state: 'Success', step: request.step }), - }, rpc: { hello: async () => ({ capabilities: [], eccVersion: 'unknown', version: 1 }), ping: async () => ({ ok: true }), shutdown: async () => ({ ok: true }), }, workspace: { + describeSpec: async () => ({}), + validateSpec: async () => ({ issues: [] }), close: async () => ({ ok: true }), - create: async (request) => ({ - directory: request.directory, - workspaceHandle: 'workspace-handle-1', - }), - exportSignoff: async (request) => ({ outputPath: request.outputPath }), inspectSignoff: async () => ({ groups: [], risks: [], status: 'ready' as const }), home: async () => ({ path: '' }), info: async (request) => ({ id: request.id, info: {}, step: request.step }), @@ -208,13 +218,6 @@ function createDesktopBridge(getVersions: DesktopApi['app']['getVersions']) { workspaceHandle: 'workspace-handle-1', }), refreshConfig: async () => ({ directory: '', refreshed: true }), - resetFlow: async () => ({ directory: '' }), - syncConfig: async (request) => ({ - configPath: request.configPath, - directory: '', - parametersChanged: false, - refreshed: true, - }), }, }, shell: { diff --git a/ecos/gui/apps/renderer/src/composables/useWorkspace.test.ts b/ecos/gui/apps/renderer/src/composables/useWorkspace.test.ts index cff5f876d..a93769f9c 100644 --- a/ecos/gui/apps/renderer/src/composables/useWorkspace.test.ts +++ b/ecos/gui/apps/renderer/src/composables/useWorkspace.test.ts @@ -6,6 +6,7 @@ const { createRuntimeEventClientMock, closeWorkspaceApiMock, createWorkspaceApiMock, + updateWorkspaceApiMock, loadWorkspaceApiMock, clearMessagesMock, readWorkspaceFlowResourceApiMock, @@ -27,6 +28,7 @@ const { createRuntimeEventClientMock: vi.fn(), closeWorkspaceApiMock: vi.fn(), createWorkspaceApiMock: vi.fn(), + updateWorkspaceApiMock: vi.fn(), loadWorkspaceApiMock: vi.fn(), clearMessagesMock: vi.fn(), readWorkspaceFlowResourceApiMock: vi.fn(), @@ -68,6 +70,7 @@ vi.mock('@/api', () => ({ closeWorkspaceApi: closeWorkspaceApiMock, loadWorkspaceApi: loadWorkspaceApiMock, createWorkspaceApi: createWorkspaceApiMock, + updateWorkspaceApi: updateWorkspaceApiMock, waitForRuntimeReady: waitForRuntimeReadyMock, })) @@ -232,6 +235,12 @@ describe('useWorkspace openProject', () => { closeWorkspaceApiMock.mockReset() closeWorkspaceApiMock.mockResolvedValue({ ok: true }) createWorkspaceApiMock.mockReset() + updateWorkspaceApiMock.mockReset() + updateWorkspaceApiMock.mockResolvedValue({ + directory: '/work/existing', + workspaceId: 'workspace-1', + workspaceRevision: 2, + }) loadWorkspaceApiMock.mockReset() clearMessagesMock.mockReset() readWorkspaceFlowResourceApiMock.mockReset() @@ -2595,12 +2604,10 @@ describe('useWorkspace openProject', () => { expect(createWorkspaceApiMock).toHaveBeenCalledWith( expect.objectContaining({ - parameters: expect.objectContaining({ - MPC: expect.objectContaining({ - resource_id: 'mpc:mpc-frame', - design: { index: 0, design_name: 'frame' }, - core_template: { minimum_area: 100, maximum_area: 500 }, - }), + mpc: expect.objectContaining({ + resource_id: 'mpc:mpc-frame', + design: { index: 0, design_name: 'frame' }, + core_template: { minimum_area: 100, maximum_area: 500 }, }), }), ) @@ -2768,7 +2775,7 @@ describe('useWorkspace openProject', () => { expect(setDesktopWindowTitleMock).toHaveBeenCalledWith('ws_0002') }) - it('creates an external PDK JSON from manual PDK resources', async () => { + it('forwards manual PDK file bindings without a PDK override blob', async () => { const workspace = useWorkspace() createWorkspaceApiMock.mockResolvedValueOnce({ response: 'success', @@ -2804,17 +2811,56 @@ describe('useWorkspace openProject', () => { expect(createWorkspaceApiMock).toHaveBeenCalledWith( expect.objectContaining({ - pdk_json: { - name: 'local-pdk', - root: '/pdks/local-pdk', - tech: '/pdks/local-pdk/tech.lef', - lefs: ['/pdks/local-pdk/stdcells.lef'], - libs: ['/pdks/local-pdk/stdcells.lib'], + pdk_config_mode: 'manual', + pdk_config: { + mode: 'manual', + tech_lef: ['/pdks/local-pdk/tech.lef'], + cell_lef: ['/pdks/local-pdk/stdcells.lef'], + liberty: ['/pdks/local-pdk/stdcells.lib'], }, }), ) }) + it('updates the active backend workspace without replacing its directory', async () => { + const workspace = useWorkspace() + const lifecycle = useWorkspaceLifecycle() + workspace.currentProject.value = { + id: '/work/existing', + name: 'existing', + path: '/work/existing', + designTool: 'backend', + lastOpened: new Date(), + } + const session = lifecycle.beginSession({ projectRoot: '/work/existing' }) + lifecycle.activateSession(session.sessionId, { + projectRoot: '/work/existing', + workspaceId: 'workspace-handle-1', + workspaceRevision: 1, + }) + + await expect( + workspace.newProject({ + directory: '/work/existing', + pdk: 'ics55', + pdk_root: '/pdks/ics55', + parameters: { design: 'gcd', top_module: 'gcd', clock: 'clk' }, + origin_def: '', + origin_verilog: '/work/gcd.v', + rtl_list: ['/work/gcd.v'], + replaceExistingWorkspace: true, + }), + ).resolves.toBe(true) + + expect(updateWorkspaceApiMock).toHaveBeenCalledWith( + expect.objectContaining({ directory: '/work/existing' }), + 'workspace-handle-1', + 1, + ) + expect(createWorkspaceApiMock).not.toHaveBeenCalled() + expect(desktopApi.workspace.prepareProjectDirectoryReplacement).not.toHaveBeenCalled() + }) + it('closes a freshly created workspace handle when local activation fails', async () => { const workspace = useWorkspace() vi.mocked(desktopApi.workspace.registerProjectRoot).mockResolvedValueOnce('') diff --git a/ecos/gui/apps/renderer/src/composables/useWorkspace.ts b/ecos/gui/apps/renderer/src/composables/useWorkspace.ts index 600cb9f69..4a41b7a00 100644 --- a/ecos/gui/apps/renderer/src/composables/useWorkspace.ts +++ b/ecos/gui/apps/renderer/src/composables/useWorkspace.ts @@ -12,6 +12,7 @@ import { closeWorkspaceApi, loadWorkspaceApi, createWorkspaceApi, + updateWorkspaceApi, waitForRuntimeReady, } from '../api' import * as runtimeEventApi from '../api/runtimeEvents' @@ -102,6 +103,75 @@ function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined } +function backendWorkspaceOptions(config: WorkspaceConfig, selectedPath: string) { + const frontendParams = config.parameters || {} + const pdkName = config.pdk || 'ics55' + const toNumber = (value: unknown, fallback: number) => { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback + } + const dieAreaMode = + frontendParams.die_area_mode === 'width_height' + ? 'width_height' + : 'utilitization_margin' + const dieArea = + dieAreaMode === 'width_height' + ? { + mode: dieAreaMode, + width: toNumber(frontendParams.die_width, 100), + height: toNumber(frontendParams.die_height, 100), + } + : { + mode: dieAreaMode, + utilitization: toNumber( + frontendParams.utilitization ?? frontendParams.core_utilization, + 0.6, + ), + margin: toNumber(frontendParams.margin, 0), + } + const parameters = { + ...Object.fromEntries( + Object.entries(frontendParams).filter(([key]) => key.includes('.')), + ), + Design: frontendParams.design || selectedPath.split('/').pop() || 'New_Chip_Design', + 'Top module': frontendParams.top_module || 'top', + Clock: frontendParams.clock || 'clk', + 'Die Area': dieArea, + 'Frequency max [MHz]': toNumber(frontendParams.frequency_max, 100), + 'Max fanout': toNumber(frontendParams.max_fanout, 20), + 'Target density': toNumber(frontendParams.target_density, 0.2), + 'Target overflow': toNumber(frontendParams.target_overflow, 0.1), + PDK: pdkName, + Core: { + Utilitization: + dieAreaMode === 'utilitization_margin' + ? toNumber(frontendParams.utilitization ?? frontendParams.core_utilization, 0.6) + : toNumber(frontendParams.core_utilization, 0.5), + }, + } + return { + directory: selectedPath, + designTool: 'backend' as const, + pdk: pdkName, + pdk_root: config.pdk_root || '', + pdk_installation_id: config.pdk_installation_id, + pdk_requirement: config.pdk_requirement, + parameters, + origin_def: config.origin_def, + origin_verilog: config.origin_verilog, + rtl_list: config.rtl_list || [], + filelist: config.filelist, + mpc: config.mpc as unknown as Record | null, + design_input_mode: config.design_input_mode, + sdc: config.sdc, + flow_config: config.flow_config, + pdk_config_mode: config.pdk_config_mode, + pdk_config: config.pdk_config, + pdk_json: config.pdk_json, + project_context: config.project_context, + } +} + function workspaceHandleFromResponseData( data: { directory?: string; workspace_handle?: string; workspaceHandle?: string }, fallback?: string, @@ -117,7 +187,7 @@ function workspaceRuntimeIdFromResponseData( return workspaceHandleFromResponseData(data, fallback) } -function scheduleStepRenderedAck(options: { +function scheduleStepRefresh(options: { eventId: string operationId: string workspaceHandle: string @@ -125,58 +195,15 @@ function scheduleStepRenderedAck(options: { stepCommitId?: string workspaceRevision?: number }): void { - const ackKey = `${options.workspaceHandle}\u001f${options.operationId}\u001f${options.stepCommitId ?? options.eventId}` - if (pendingStepRenderedAcks.has(ackKey)) return - const acknowledge = async () => { - await getOptionalDesktopApi()?.ecc.runtime?.acknowledgeStepRendered({ - eventId: options.eventId, - operationId: options.operationId, - workspaceHandle: options.workspaceHandle, - ...(options.stepCommitId ? { stepCommitId: options.stepCommitId } : {}), - ...(typeof options.workspaceRevision === 'number' - ? { workspaceRevision: options.workspaceRevision } - : {}), - }) - } - if (acknowledgedStepRenderedAcks.has(ackKey)) { - void acknowledge().catch((error) => { - console.warn('Failed to repeat an ECC step render acknowledgement:', error) - }) - return - } - const nextFrame = () => - new Promise((resolve) => { - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => resolve()) - return - } - setTimeout(resolve, 0) - }) - - const acknowledgement = finishRuntimeStepRender({ + void finishRuntimeStepRender({ eventId: options.eventId, operationId: options.operationId, step: options.step, stepCommitId: options.stepCommitId ?? options.eventId, workspaceRevision: options.workspaceRevision, + }).catch((error) => { + console.warn('Failed to refresh data after an ECC step commit:', error) }) - .then(nextFrame) - .then(async () => { - await acknowledge() - acknowledgedStepRenderedAcks.add(ackKey) - if (acknowledgedStepRenderedAcks.size > 512) { - acknowledgedStepRenderedAcks.delete( - acknowledgedStepRenderedAcks.values().next().value!, - ) - } - }) - .catch((error) => { - console.warn('Failed to acknowledge rendered ECC step:', error) - }) - .finally(() => { - pendingStepRenderedAcks.delete(ackKey) - }) - pendingStepRenderedAcks.set(ackKey, acknowledgement) } // Runtime event connection(workspace 级别,跟随 workspace 生命周期) @@ -185,8 +212,6 @@ const runtimeEvents = ref([]) const notificationStore = useNotificationStore() const handledRefreshRuntimeEvents = new Set() const handledRuntimeProtocolEvents = new Set() -const pendingStepRenderedAcks = new Map>() -const acknowledgedStepRenderedAcks = new Set() let unregisterRuntimeEventCleanup: (() => void) | null = null const workspaceLifecycle = useWorkspaceLifecycle() @@ -625,6 +650,7 @@ export function useWorkspace() { workspaceLifecycle.activateSession(session.sessionId, { workspaceId, projectRoot: canonicalProjectRoot, + workspaceRevision: response.data.workspaceRevision, }) connectRuntimeEvents(workspaceId, restoredDesignTool, session.sessionId) } else { @@ -884,6 +910,7 @@ export function useWorkspace() { workspaceLifecycle.activateSession(activeSession.sessionId, { workspaceId, projectRoot: canonicalProjectRoot, + workspaceRevision: response.data.workspaceRevision, }) candidateWorkspaceCommitted = true connectRuntimeEvents(workspaceId, requestedDesignTool, activeSession.sessionId) @@ -897,13 +924,10 @@ export function useWorkspace() { workspaceHandle: workspaceId, }) - // 检查是否有活跃的operations(运行中、排队中或等待GUI同步) + // 检查是否有活跃的 operations。 const hasActiveOperations = snapshot.operations?.some( (op: import('@ecos-studio/shared').EccRuntimeOperation) => - op.state === 'running' || - op.state === 'queued' || - op.state === 'waiting_for_gui_sync' || - op.state === 'paused_for_gui_recovery', + op.state === 'running' || op.state === 'queued', ) if (hasActiveOperations) { @@ -1045,6 +1069,49 @@ export function useWorkspace() { } selectedPath = normalizePath(selectedPath) + const updatesCurrentBackendWorkspace = Boolean( + config?.replaceExistingWorkspace && + (config.designTool ?? 'backend') === 'backend' && + currentProject.value && + normalizePath(currentProject.value.path) === selectedPath && + workspaceLifecycle.session.value.workspaceId, + ) + if (updatesCurrentBackendWorkspace) { + existedBeforeCreate = true + if (!(await ensureApiReady({ keepLoading: true }))) { + lastWorkspaceCreationError.value = + 'The desktop runtime is unavailable. Restart the application and try again.' + return false + } + runtimeBackendTitle.value = 'Updating your workspace' + runtimeBackendSubtitle.value = 'Committing the revised engineering specification' + runtimeBackendConnecting.value = true + const expectedWorkspaceRevision = + workspaceLifecycle.session.value.workspaceRevision + if (!Number.isInteger(expectedWorkspaceRevision)) { + throw new Error('The current Workspace revision is unavailable.') + } + const updated = await updateWorkspaceApi( + backendWorkspaceOptions(config!, selectedPath), + workspaceLifecycle.session.value.workspaceId, + expectedWorkspaceRevision!, + ) + if ( + 'workspaceRevision' in updated && + typeof updated.workspaceRevision === 'number' + ) { + workspaceLifecycle.updateWorkspaceRevision(updated.workspaceRevision) + } + workspaceLifecycle.invalidate('all', { reason: 'workspace-updated' }) + runtimeBackendConnecting.value = false + showToast({ + severity: 'success', + summary: 'Workspace Updated', + detail: 'The engineering specification was committed.', + life: 4000, + }) + return true + } const createAffinity = await resolveWorkspaceWindowAffinity(selectedPath) if (createAffinity.action === 'focused') { lastWorkspaceCreationError.value = @@ -1202,87 +1269,9 @@ export function useWorkspace() { ), }) } else { - const pdkName = creationConfig?.pdk || 'ics55' - const toNumber = (value: unknown, fallback: number) => { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : fallback - } - const dieAreaMode = - frontendParams.die_area_mode === 'width_height' - ? 'width_height' - : 'utilitization_margin' - const dieArea = - dieAreaMode === 'width_height' - ? { - mode: dieAreaMode, - width: toNumber(frontendParams.die_width, 100), - height: toNumber(frontendParams.die_height, 100), - } - : { - mode: dieAreaMode, - utilitization: toNumber( - frontendParams.utilitization ?? frontendParams.core_utilization, - 0.6, - ), - margin: toNumber(frontendParams.margin, 0), - } - const backendParameters = { - Design: - frontendParams.design || selectedPath.split('/').pop() || 'New_Chip_Design', - 'Top module': frontendParams.top_module || 'top', - Clock: frontendParams.clock || 'clk', - 'Die Area': dieArea, - 'Frequency max [MHz]': toNumber(frontendParams.frequency_max, 100), - 'Max fanout': toNumber(frontendParams.max_fanout, 20), - 'Target density': toNumber(frontendParams.target_density, 0.2), - 'Target overflow': toNumber(frontendParams.target_overflow, 0.1), - PDK: pdkName, - Core: { - Utilitization: - dieAreaMode === 'utilitization_margin' - ? toNumber( - frontendParams.utilitization ?? frontendParams.core_utilization, - 0.6, - ) - : toNumber(frontendParams.core_utilization, 0.5), - }, - ...(creationConfig?.mpc ? { MPC: creationConfig.mpc } : {}), - } - - const resolvedPdkRoot = creationConfig?.pdk_root || '' - const manualPdkConfig = creationConfig?.pdk_config - const pdkJson = - creationConfig?.pdk_config_mode === 'manual' || - manualPdkConfig?.mode === 'manual' - ? { - name: pdkName, - root: resolvedPdkRoot, - tech: manualPdkConfig?.tech_lef[0] ?? '', - lefs: manualPdkConfig?.cell_lef ?? [], - libs: manualPdkConfig?.liberty ?? [], - } - : creationConfig?.pdk_json - - response = await createWorkspaceApi({ - directory: selectedPath, - designTool: 'backend', - pdk: pdkName, - pdk_root: resolvedPdkRoot, - pdk_installation_id: creationConfig?.pdk_installation_id, - pdk_requirement: creationConfig?.pdk_requirement, - parameters: backendParameters, - origin_def: creationConfig?.origin_def, - origin_verilog: creationConfig?.origin_verilog, - rtl_list: creationConfig?.rtl_list || [], - filelist: creationConfig?.filelist, - design_input_mode: creationConfig?.design_input_mode, - sdc: creationConfig?.sdc, - flow_config: creationConfig?.flow_config, - pdk_config_mode: creationConfig?.pdk_config_mode, - pdk_config: creationConfig?.pdk_config, - pdk_json: pdkJson, - project_context: creationConfig?.project_context, - }) + response = await createWorkspaceApi( + backendWorkspaceOptions(creationConfig!, selectedPath), + ) } if (response.response === 'success') { candidateWorkspaceHandle = workspaceRuntimeIdFromResponseData( @@ -1357,6 +1346,7 @@ export function useWorkspace() { workspaceLifecycle.activateSession(session.sessionId, { workspaceId, projectRoot: canonicalProjectRoot, + workspaceRevision: response.data.workspaceRevision, }) candidateWorkspaceCommitted = true workspaceLifecycle.invalidate(['home', 'flow', 'parameters'], { @@ -1667,6 +1657,9 @@ export function useWorkspace() { const step = asString(response.data?.step) ?? '' const stepCommitId = asString(response.data?.stepCommitId) const workspaceRevision = asNumber(response.data?.workspaceRevision) + if (workspaceRevision !== undefined) { + workspaceLifecycle.updateWorkspaceRevision(workspaceRevision, sessionId) + } const runtimeEventKey = runtimeEventId ? [ workspaceHandle ?? '', @@ -1686,24 +1679,7 @@ export function useWorkspace() { ) } } - if (duplicate) { - if ( - eventType === 'step.completed' && - runtimeEventId && - operationId && - workspaceHandle - ) { - scheduleStepRenderedAck({ - eventId: runtimeEventId, - operationId, - step, - stepCommitId, - workspaceRevision, - workspaceHandle, - }) - } - return - } + if (duplicate) return runtimeEvents.value.push(response) if (runtimeEvents.value.length > 200) { runtimeEvents.value.splice(0, runtimeEvents.value.length - 200) @@ -1726,7 +1702,7 @@ export function useWorkspace() { operationId && workspaceHandle ) { - scheduleStepRenderedAck({ + scheduleStepRefresh({ eventId: runtimeEventId, operationId, step, diff --git a/ecos/gui/apps/renderer/src/composables/useWorkspaceCreationModel.ts b/ecos/gui/apps/renderer/src/composables/useWorkspaceCreationModel.ts new file mode 100644 index 000000000..51b1fdd9a --- /dev/null +++ b/ecos/gui/apps/renderer/src/composables/useWorkspaceCreationModel.ts @@ -0,0 +1,80 @@ +import { computed, ref } from 'vue' +import { getDesktopApi } from '@/platform/desktop' +import type { DesignTool, WorkspaceCreationModel } from '@ecos-studio/shared' + +const WIZARD_PARAMETER_IDS = new Set([ + 'design.frequency_mhz', + 'floorplan.mode', + 'floorplan.die_width', + 'floorplan.die_height', + 'floorplan.core_util', + 'synth.max_fanout', +]) + +export function useWorkspaceCreationModel(options: { + designTool: () => DesignTool | undefined + flowId: () => string + inputMode: () => 'rtl' | 'postSynthesis' + mpc: () => Record | null + pdk: () => { + familyId: string + mode: 'default' | 'manual' + version?: string | null + } | null + projectPresetParameters: () => Record +}) { + const model = ref(null) + const values = ref>({}) + const explicitIds = new Set() + let generation = 0 + + const parameters = computed(() => + (model.value?.parameters ?? []).filter( + (parameter) => !WIZARD_PARAMETER_IDS.has(parameter.definition.id), + ), + ) + + function explicitValues(): Record { + return Object.fromEntries( + [...explicitIds].map((parameterId) => [parameterId, values.value[parameterId]]), + ) + } + + async function refresh(): Promise { + if (options.designTool() === 'frontend') return + const requestGeneration = ++generation + try { + const next = await getDesktopApi().workspaceCreationModel.get({ + explicitParameters: explicitValues(), + flowId: options.flowId(), + inputMode: options.inputMode(), + mpc: options.mpc(), + pdk: options.pdk(), + projectPresetParameters: options.projectPresetParameters(), + }) + if (requestGeneration !== generation) return + model.value = next + for (const parameter of next.parameters) { + if ( + values.value[parameter.definition.id] === undefined && + parameter.value !== undefined + ) { + values.value[parameter.definition.id] = parameter.value + } + } + } catch { + if (requestGeneration === generation) model.value = null + } + } + + function setValue(parameterId: string, value: unknown): void { + values.value[parameterId] = value + explicitIds.add(parameterId) + const parameter = model.value?.parameters.find( + (candidate) => candidate.definition.id === parameterId, + ) + if (parameter) parameter.state = 'explicit' + } + + return { explicitValues, parameters, refresh, setValue, values } +} diff --git a/ecos/gui/apps/renderer/src/composables/useWorkspaceLifecycle.ts b/ecos/gui/apps/renderer/src/composables/useWorkspaceLifecycle.ts index dc0b3de0d..17553252f 100644 --- a/ecos/gui/apps/renderer/src/composables/useWorkspaceLifecycle.ts +++ b/ecos/gui/apps/renderer/src/composables/useWorkspaceLifecycle.ts @@ -30,11 +30,13 @@ export interface WorkspaceSession { projectRoot: string state: WorkspaceSessionState resourceVersions: WorkspaceResourceVersions + workspaceRevision?: number } export interface WorkspaceSessionInput { workspaceId?: string projectRoot?: string + workspaceRevision?: number } export interface WorkspaceCleanupOptions { @@ -95,6 +97,7 @@ function createSession( projectRoot: input.projectRoot ?? '', state, resourceVersions: resourceVersions.value, + workspaceRevision: input.workspaceRevision, } } @@ -172,6 +175,7 @@ export function useWorkspaceLifecycle() { updateCurrentSession({ workspaceId: input.workspaceId ?? session.value.workspaceId, projectRoot: input.projectRoot ?? session.value.projectRoot, + workspaceRevision: input.workspaceRevision ?? session.value.workspaceRevision, state: 'active', }) } @@ -194,6 +198,11 @@ export function useWorkspaceLifecycle() { return Boolean(sessionId) && session.value.sessionId === sessionId } + function updateWorkspaceRevision(revision: number, sessionId?: string): void { + if (sessionId && !isCurrentSession(sessionId)) return + updateCurrentSession({ workspaceRevision: revision }) + } + async function runForSession( sessionId: string, operation: () => T | Promise, @@ -258,6 +267,7 @@ export function useWorkspaceLifecycle() { return { session, currentSessionId, + updateWorkspaceRevision, resourceVersions, beginSession, setSessionLoading, diff --git a/ecos/gui/packages/shared/src/constants/ipcChannels.ts b/ecos/gui/packages/shared/src/constants/ipcChannels.ts index 03f4ca364..5d8b060d1 100644 --- a/ecos/gui/packages/shared/src/constants/ipcChannels.ts +++ b/ecos/gui/packages/shared/src/constants/ipcChannels.ts @@ -114,25 +114,22 @@ export const desktopApiIpcChannels = { eccRpcHello: 'ecc:rpc-hello', eccRpcPing: 'ecc:rpc-ping', eccRpcShutdown: 'ecc:rpc-shutdown', - eccWorkspaceCreate: 'ecc:workspace-create', eccWorkspaceOpen: 'ecc:workspace-open', eccWorkspaceClose: 'ecc:workspace-close', eccWorkspaceHome: 'ecc:workspace-home', eccWorkspaceInfo: 'ecc:workspace-info', eccWorkspaceRefreshConfig: 'ecc:workspace-refresh-config', - eccWorkspaceSyncConfig: 'ecc:workspace-sync-config', - eccWorkspaceResetFlow: 'ecc:workspace-reset-flow', - eccWorkspaceExportSignoff: 'ecc:workspace-export-signoff', eccWorkspaceInspectSignoff: 'ecc:workspace-inspect-signoff', - eccFlowRun: 'ecc:flow-run', - eccFlowRunStep: 'ecc:flow-run-step', - eccRuntimeStartFlow: 'ecc:runtime-start-flow', - eccRuntimeStartStep: 'ecc:runtime-start-step', + eccWorkspaceDescribeSpec: 'ecc:workspace-describe-spec', + eccWorkspaceValidateSpec: 'ecc:workspace-validate-spec', eccRuntimeOperationStatus: 'ecc:runtime-operation-status', eccRuntimeWaitForOperation: 'ecc:runtime-wait-operation', - eccRuntimeOperationCancel: 'ecc:runtime-operation-cancel', - eccRuntimeAcknowledgeStepRendered: 'ecc:runtime-acknowledge-step-rendered', eccRuntimeSnapshot: 'ecc:runtime-snapshot', + eccRuntimeEngineeringSnapshot: 'ecc:runtime-engineering-snapshot', + eccRuntimeReadArtifactChunk: 'ecc:runtime-read-artifact-chunk', + eccRuntimeOpenArtifact: 'ecc:runtime-open-artifact', + productCommandExecute: 'product-command:execute', + workspaceCreationModelGet: 'workspace-creation-model:get', agentStart: 'agent:start', agentStartSession: 'agent:start-session', agentSendMessage: 'agent:send-message', diff --git a/ecos/gui/packages/shared/src/contracts/designRuntime.ts b/ecos/gui/packages/shared/src/contracts/designRuntime.ts index 05a73054d..fb733d2a9 100644 --- a/ecos/gui/packages/shared/src/contracts/designRuntime.ts +++ b/ecos/gui/packages/shared/src/contracts/designRuntime.ts @@ -38,6 +38,7 @@ export interface DesignRuntimeWorkspaceOpenRequest extends DesignRuntimeTargetRe } export interface DesignRuntimeWorkspaceHandleRequest extends DesignRuntimeTargetRequest { + expectedWorkspaceRevision?: number workspaceHandle: string } diff --git a/ecos/gui/packages/shared/src/contracts/desktopApi.ts b/ecos/gui/packages/shared/src/contracts/desktopApi.ts index f6710f38a..b8f6575e0 100644 --- a/ecos/gui/packages/shared/src/contracts/desktopApi.ts +++ b/ecos/gui/packages/shared/src/contracts/desktopApi.ts @@ -1,4 +1,6 @@ import type { DesignRuntimeApi } from './designRuntime.ts' +import type { ProductCommandApi } from './productCommands.ts' +import type { WorkspaceCreationModelApi } from './workspaceCreationModel.ts' import type { BackendWorkspaceApi } from './backendWorkspace.ts' import type { BackendProjectComparisonApi } from './backendProjectComparison.ts' import type { @@ -217,6 +219,8 @@ export interface DesktopApi { app: { getVersions(): Promise } + productCommands: ProductCommandApi + workspaceCreationModel: WorkspaceCreationModelApi window: { minimize(): Promise toggleMaximize(): Promise diff --git a/ecos/gui/packages/shared/src/contracts/eccRuntime.ts b/ecos/gui/packages/shared/src/contracts/eccRuntime.ts index 49c98f94e..cb5c1d419 100644 --- a/ecos/gui/packages/shared/src/contracts/eccRuntime.ts +++ b/ecos/gui/packages/shared/src/contracts/eccRuntime.ts @@ -1,6 +1,7 @@ import type { DesktopEventUnsubscribe } from './desktopEvents.ts' export interface EccRpcHelloResult { + adapterVersion?: number capabilities: string[] eccVersion: string version: number @@ -27,15 +28,25 @@ export interface EccRpcShutdownResult { import type { PdkRequirement } from './pdkInventory.ts' export interface EccWorkspaceCreateRequest { + commandId?: string directory: string + designInputMode?: 'rtl' | 'post_synthesis' filelist?: string + mpc?: Record | null flowConfig?: Record originDef?: string originVerilog?: string parameters?: Record pdk?: string + pdkConfig?: { + cell_lef?: string[] + liberty?: string[] + tech_lef?: string[] + } + pdkConfigMode?: 'default' | 'manual' pdkJson?: unknown pdkRoot?: string + pdkVersion?: string | null pdkInstallationId?: string pdkRequirement?: PdkRequirement projectId?: string @@ -46,10 +57,43 @@ export interface EccWorkspaceCreateRequest { export interface EccWorkspaceOpenRequest { directory: string + workspaceBindings?: Record +} + +export interface EccWorkspaceSpecValidationRequest { + workspaceSpec: Record + workspaceBindings: Record +} + +export interface EccWorkspaceSpecValidationResult { + issues: Array<{ + code: string + details: Record + path: string + severity: 'error' | 'warning' + }> + resolvedWorkspaceSpec?: Record +} + +export interface EccWorkspaceUpdateRequest + extends EccWorkspaceMutationRequest, EccWorkspaceSpecValidationRequest { + commandId: string +} + +export interface EccWorkspaceUpdateResult { + directory: string + executionReadiness?: { ready: boolean; code?: string } + workspaceId: string + workspaceRevision: number } export interface EccWorkspaceHandleRequest { workspaceHandle: string + expectedWorkspaceRevision?: number +} + +export interface EccWorkspaceMutationRequest extends EccWorkspaceHandleRequest { + expectedWorkspaceRevision: number } export interface EccWorkspaceInfoRequest extends EccWorkspaceHandleRequest { @@ -118,7 +162,9 @@ export interface EccSignoffReviewRisk { export interface EccWorkspaceOpenResult { directory: string + workspaceId?: string workspaceHandle: string + workspaceRevision?: number } export type EccWorkspaceCreateResult = EccWorkspaceOpenResult @@ -147,10 +193,12 @@ export interface EccWorkspaceSyncConfigResult { directory: string parametersChanged: boolean refreshed: boolean + workspaceRevision?: number } export interface EccWorkspaceResetFlowResult { directory: string + workspaceRevision?: number } export interface EccWorkspaceExportSignoffResult { @@ -193,7 +241,7 @@ export interface EccLayoutEditApplyResult { revision: number } -export interface EccLayoutEditSaveRequest extends EccWorkspaceHandleRequest { +export interface EccLayoutEditSaveRequest extends EccWorkspaceMutationRequest { editSessionId: string expectedRevision: number } @@ -210,6 +258,7 @@ export interface EccLayoutEditSaveResult { geometryRevision: number revision: number saved: boolean + workspaceRevision?: number } export interface EccLayoutEditDiscardRequest extends EccWorkspaceHandleRequest { @@ -243,17 +292,13 @@ export type EccRuntimeOperationKind = 'flow' | 'step' export type EccRuntimeOperationState = | 'queued' | 'running' - | 'waiting_for_gui_sync' - | 'paused_for_gui_recovery' - | 'gui_sync_degraded' | 'succeeded' | 'failed' | 'cancelled' + | 'interrupted' export type EccRuntimeInterruptibility = 'safe' | 'deferred' | 'forbidden' export interface EccRuntimeOperation { - awaitingEventId: string | null - awaitingStepCommitId?: string | null cancelRequested?: boolean createdAt: number currentStep: string @@ -271,21 +316,13 @@ export interface EccRuntimeOperation { step: string safeToStop?: boolean workspaceRevision?: number - renderSyncState?: - | 'idle' - | 'waiting_for_gui_sync' - | 'paused_for_gui_recovery' - | 'gui_sync_degraded' - | 'timed_out' - renderRetryCount?: number - lastRenderAckAt?: number | null shutdownBarrier?: boolean updatedAt: number workspaceId: string deduplicated?: boolean } -export interface EccRuntimeStartFlowRequest extends EccWorkspaceHandleRequest { +export interface EccRuntimeStartFlowRequest extends EccWorkspaceMutationRequest { idempotencyKey: string rerun?: boolean } @@ -300,12 +337,6 @@ export interface EccRuntimeOperationRequest extends EccWorkspaceHandleRequest { operationId: string } -export interface EccRuntimeStepRenderedAckRequest extends EccRuntimeOperationRequest { - eventId: string - stepCommitId?: string - workspaceRevision?: number -} - export interface EccRuntimeStepSnapshot { name: string peakMemory: number @@ -324,6 +355,47 @@ export interface EccWorkspaceRuntimeSnapshot extends EccWorkspaceHandleRequest { runtimeInstanceId?: string } +export interface EccArtifactRef { + artifactId: string + availability: 'available' | 'missing' | 'stale' + kind: string + name: string + sha256?: string + sizeBytes?: number + stepId?: string +} + +export interface EccEngineeringSnapshot { + artifacts: EccArtifactRef[] + checklist: Record + flow: Record + metrics: unknown[] + parameters: Record + qorAssessment: Record + schemaVersion: 1 + signoffAssessment: EccWorkspaceInspectSignoffResult + workspaceId: string + workspaceRevision: number +} + +export interface EccArtifactReadRequest extends EccWorkspaceHandleRequest { + artifactId: string + length: number + offset: number +} + +export interface EccArtifactChunk { + data: Uint8Array + eof: boolean + nextOffset: number + sizeBytes: number +} + +export interface EccArtifactOpenRequest extends EccWorkspaceHandleRequest { + artifactId: string + viewer: 'system' +} + export interface EccRuntimeProtocolPayload { eventId: string kind?: EccRuntimeOperationKind @@ -335,19 +407,11 @@ export interface EccRuntimeProtocolPayload { sequence: number timestamp: number type: - | 'operation.queued' - | 'operation.started' - | 'operation.completed' - | 'operation.failed' - | 'operation.cancelled' - | 'operation.cancel_requested' - | 'operation.gui_sync_paused' - | 'operation.gui_sync_degraded' - | 'operation.rerun_prepared' - | 'step.started' - | 'step.log' - | 'step.completed' - | 'subflow.stage' + | 'operation.changed' + | 'execution.progress' + | 'workspace.committed' + | 'artifact.changed' + workspaceRevision?: number workspaceId: string rerun?: boolean } @@ -459,37 +523,24 @@ export interface EccRuntimeApi { events: { onEvent(listener: (event: EccRuntimeEvent) => void): DesktopEventUnsubscribe } - flow: { - run(request: EccFlowRunRequest): Promise - runStep(request: EccFlowRunStepRequest): Promise - } rpc: { hello(): Promise ping(): Promise shutdown(): Promise } runtime?: { - acknowledgeStepRendered(request: EccRuntimeStepRenderedAckRequest): Promise<{ - accepted: boolean - duplicate: boolean - eventId: string - operationId: string - }> - cancel( - request: EccRuntimeOperationRequest, - ): Promise<{ accepted: boolean; operationId: string; state: string }> + engineeringSnapshot( + request: EccWorkspaceHandleRequest, + ): Promise + openArtifact(request: EccArtifactOpenRequest): Promise<{ opened: boolean }> + readArtifactChunk(request: EccArtifactReadRequest): Promise snapshot(request: EccWorkspaceHandleRequest): Promise - startFlow(request: EccRuntimeStartFlowRequest): Promise - startStep(request: EccRuntimeStartStepRequest): Promise status(request: EccRuntimeOperationRequest): Promise waitForOperation(request: EccRuntimeOperationRequest): Promise } workspace: { close(request: EccWorkspaceHandleRequest): Promise - create(request: EccWorkspaceCreateRequest): Promise - exportSignoff( - request: EccWorkspaceExportSignoffRequest, - ): Promise + describeSpec(): Promise> inspectSignoff( request: EccWorkspaceHandleRequest, ): Promise @@ -499,9 +550,8 @@ export interface EccRuntimeApi { refreshConfig( request: EccWorkspaceHandleRequest, ): Promise - resetFlow(request: EccWorkspaceHandleRequest): Promise - syncConfig( - request: EccWorkspaceSyncConfigRequest, - ): Promise + validateSpec( + request: EccWorkspaceSpecValidationRequest, + ): Promise } } diff --git a/ecos/gui/packages/shared/src/contracts/productCommands.ts b/ecos/gui/packages/shared/src/contracts/productCommands.ts new file mode 100644 index 000000000..d4f4f610b --- /dev/null +++ b/ecos/gui/packages/shared/src/contracts/productCommands.ts @@ -0,0 +1,54 @@ +import type { + EccRuntimeOperation, + EccRuntimeStartFlowRequest, + EccRuntimeStartStepRequest, + EccWorkspaceCreateRequest, + EccWorkspaceCreateResult, + EccWorkspaceExportSignoffRequest, + EccWorkspaceExportSignoffResult, + EccWorkspaceMutationRequest, + EccWorkspaceResetFlowResult, + EccWorkspaceSyncConfigRequest, + EccWorkspaceSyncConfigResult, + EccWorkspaceUpdateResult, +} from './eccRuntime.ts' + +export type ProductCommandRequest = + | { + command: 'workspace.create' + payload: EccWorkspaceCreateRequest & { commandId: string } + } + | { command: 'workspace.run'; payload: EccRuntimeStartFlowRequest } + | { command: 'workspace.runStep'; payload: EccRuntimeStartStepRequest } + | { + command: 'workspace.update' + payload: { + draft: EccWorkspaceCreateRequest + commandId: string + expectedWorkspaceRevision: number + workspaceHandle: string + } + } + | { + command: 'workspace.cancel' + payload: { workspaceHandle: string; operationId: string } + } + | { command: 'workspace.reset'; payload: EccWorkspaceMutationRequest } + | { + command: 'workspace.syncConfig' + payload: EccWorkspaceSyncConfigRequest & EccWorkspaceMutationRequest + } + | { command: 'workspace.exportSignoff'; payload: EccWorkspaceExportSignoffRequest } + +export type ProductCommandResult = + | EccWorkspaceCreateResult + | EccWorkspaceUpdateResult + | EccWorkspaceResetFlowResult + | EccWorkspaceSyncConfigResult + | EccWorkspaceExportSignoffResult + | EccRuntimeOperation + | { accepted: boolean; operationId: string; state: string } + +export interface ProductCommandApi { + execute(request: ProductCommandRequest): Promise +} diff --git a/ecos/gui/packages/shared/src/contracts/workspaceCreationModel.ts b/ecos/gui/packages/shared/src/contracts/workspaceCreationModel.ts new file mode 100644 index 000000000..b39dc825e --- /dev/null +++ b/ecos/gui/packages/shared/src/contracts/workspaceCreationModel.ts @@ -0,0 +1,39 @@ +import type { PdkInstallationSnapshot } from './pdkInventory.ts' + +export interface WorkspaceCreationModelRequest { + explicitParameters?: Record + flowId?: string + inputMode?: 'rtl' | 'postSynthesis' + mpc?: Record | null + pdk?: { + familyId: string + mode: 'default' | 'manual' + version?: string | null + } | null + projectPresetParameters?: Record +} + +export interface WorkspaceCreationParameter { + definition: Record & { id: string } + state: 'explicit' | 'defaulted' | 'inapplicable' + source?: 'catalogDefault' | 'projectPreset' | 'user' + value?: unknown + inapplicableReason?: string +} + +export interface WorkspaceCreationModel { + controls: { + flowBoundaries: true + manualPdkFiles: true + mpc: true + pdkVersion: true + } + discovery: Record + context: Pick + parameters: WorkspaceCreationParameter[] + pdkInstallations: PdkInstallationSnapshot[] +} + +export interface WorkspaceCreationModelApi { + get(request?: WorkspaceCreationModelRequest): Promise +} diff --git a/ecos/gui/packages/shared/src/index.ts b/ecos/gui/packages/shared/src/index.ts index 92a591d06..79cb493c1 100644 --- a/ecos/gui/packages/shared/src/index.ts +++ b/ecos/gui/packages/shared/src/index.ts @@ -4,6 +4,17 @@ export { type DesktopApiEventChannel, type DesktopApiIpcChannel, } from './constants/ipcChannels.ts' +export type { + ProductCommandApi, + ProductCommandRequest, + ProductCommandResult, +} from './contracts/productCommands.ts' +export type { + WorkspaceCreationModel, + WorkspaceCreationModelApi, + WorkspaceCreationModelRequest, + WorkspaceCreationParameter, +} from './contracts/workspaceCreationModel.ts' export type { BackendWorkspaceApi, BackendWorkspaceInvalidatedEvent, @@ -123,6 +134,11 @@ export type { EccLayoutEditDiscardResult, EccLayoutEditSaveRequest, EccLayoutEditSaveResult, + EccArtifactChunk, + EccArtifactOpenRequest, + EccArtifactReadRequest, + EccArtifactRef, + EccEngineeringSnapshot, EccRpcHelloResult, EccRpcPingResult, EccRpcShutdownResult, @@ -138,7 +154,6 @@ export type { EccRuntimeProtocolPayload, EccRuntimeStartFlowRequest, EccRuntimeStartStepRequest, - EccRuntimeStepRenderedAckRequest, EccRuntimeStepSnapshot, EccSignoffReviewDetail, EccSignoffReviewDetailKind, @@ -162,6 +177,10 @@ export type { EccWorkspaceResetFlowResult, EccWorkspaceSyncConfigRequest, EccWorkspaceSyncConfigResult, + EccWorkspaceSpecValidationRequest, + EccWorkspaceSpecValidationResult, + EccWorkspaceUpdateRequest, + EccWorkspaceUpdateResult, EccWorkspaceRuntimeSnapshot, } from './contracts/eccRuntime.ts' export type { diff --git a/ecos/gui/packages/shared/src/utils/projectManagementSummary.test.ts b/ecos/gui/packages/shared/src/utils/projectManagementSummary.test.ts index ca7ae0a81..3b69f7707 100644 --- a/ecos/gui/packages/shared/src/utils/projectManagementSummary.test.ts +++ b/ecos/gui/packages/shared/src/utils/projectManagementSummary.test.ts @@ -20,11 +20,14 @@ describe('projectManagementWorkspaceSummaryPaths', () => { summaryPath: 'lvs_ecc/analysis/qor_summary.json', }) expect(projectManagementWorkspaceSummaryPaths).toContain('home/flow.json') + expect(projectManagementWorkspaceSummaryPaths).toContain( + 'home/engineering-snapshot.json', + ) expect(projectManagementWorkspaceSummaryPaths).toContain( projectManagementStaTimingIssuesPath, ) expect(projectManagementWorkspaceSummaryPaths).toHaveLength( - 2 + projectManagementWorkspaceStepAnalysisSpecs.length * 3, + 3 + projectManagementWorkspaceStepAnalysisSpecs.length * 3, ) expect(new Set(projectManagementWorkspaceSummaryPaths).size).toBe( projectManagementWorkspaceSummaryPaths.length, diff --git a/ecos/gui/packages/shared/src/utils/projectManagementSummary.ts b/ecos/gui/packages/shared/src/utils/projectManagementSummary.ts index cfcf36ebd..6e77b3a8d 100644 --- a/ecos/gui/packages/shared/src/utils/projectManagementSummary.ts +++ b/ecos/gui/packages/shared/src/utils/projectManagementSummary.ts @@ -83,6 +83,7 @@ export const projectManagementStaTimingIssuesPath = 'sta_ecc/analysis/sta_timing_issues.json' export const projectManagementWorkspaceSummaryPaths = [ + 'home/engineering-snapshot.json', 'home/flow.json', ...projectManagementWorkspaceStepAnalysisSpecs.flatMap((spec) => [ spec.metricsPath, diff --git a/ecos/runtime-adapter/.gitignore b/ecos/runtime-adapter/.gitignore new file mode 100644 index 000000000..43ae0e2a6 --- /dev/null +++ b/ecos/runtime-adapter/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/__init__.py b/ecos/runtime-adapter/ecos_runtime_adapter/__init__.py new file mode 100644 index 000000000..6777b640b --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/__init__.py @@ -0,0 +1 @@ +"""Private ECC runtime support for long-lived sidecar sessions.""" diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/errors.py b/ecos/runtime-adapter/ecos_runtime_adapter/errors.py new file mode 100644 index 000000000..64ace25c2 --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/errors.py @@ -0,0 +1,6 @@ +class RuntimeApiError(RuntimeError): + def __init__(self, code: str, message: str, data: dict | None = None): + super().__init__(message) + self.code = code + self.message = message + self.data = data or {} diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/events.py b/ecos/runtime-adapter/ecos_runtime_adapter/events.py new file mode 100644 index 000000000..aa59bba27 --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/events.py @@ -0,0 +1,68 @@ +import os +import sys +from contextlib import contextmanager, suppress + +from chipcompiler.utility.log import stdio_redirect_lock + + +@contextmanager +def redirect_stdout_to_stderr(): + acquired = stdio_redirect_lock.acquire(blocking=False) + if not acquired: + # A tool already owns fd 1/2. Its protocol writer uses a duplicated fd, + # so the RPC can proceed without installing a competing redirect. + yield + return + try: + with _redirect_stdout_to_stderr(): + yield + finally: + stdio_redirect_lock.release() + + +@contextmanager +def _redirect_stdout_to_stderr(): + saved_stdout = sys.stdout + saved_stderr = sys.stderr + saved_stdout_fd = None + saved_stderr_fd = None + + with suppress(Exception): + sys.stdout.flush() + with suppress(Exception): + sys.stderr.flush() + + try: + saved_stdout_fd = os.dup(1) + saved_stderr_fd = os.dup(2) + os.dup2(2, 1) + sys.stdout = sys.stderr + except OSError: + with suppress(Exception): + if saved_stdout_fd is not None: + os.close(saved_stdout_fd) + if saved_stderr_fd is not None: + os.close(saved_stderr_fd) + sys.stdout = sys.stderr + try: + yield + finally: + sys.stdout = saved_stdout + sys.stderr = saved_stderr + return + + try: + yield + finally: + with suppress(Exception): + sys.stdout.flush() + with suppress(Exception): + sys.stderr.flush() + try: + os.dup2(saved_stdout_fd, 1) + os.dup2(saved_stderr_fd, 2) + finally: + os.close(saved_stdout_fd) + os.close(saved_stderr_fd) + sys.stdout = saved_stdout + sys.stderr = saved_stderr diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/flow_observer.py b/ecos/runtime-adapter/ecos_runtime_adapter/flow_observer.py new file mode 100644 index 000000000..c43c7d6a4 --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/flow_observer.py @@ -0,0 +1,77 @@ +from collections.abc import Callable +from typing import Any + + +class RuntimeFlowObserver: + def __init__( + self, + manager: Any, + operation_id: str, + snapshot_committer: Callable[[Any, Any, str | None], int] | None = None, + ): + self._manager = manager + self._operation_id = operation_id + self._snapshot_committer = snapshot_committer + self._committed_revision: int | None = None + + @property + def runtime_operation(self) -> dict[str, Any]: + return { + "schema": 1, + "operation_id": self._operation_id, + "runtime_instance_id": self._manager._runtime_instance_id, + } + + def on_step_started(self, workspace_step: Any) -> None: + self._manager.step_started(self._operation_id, workspace_step) + + def raise_if_cancelled(self) -> None: + self._manager.raise_if_cancel_requested(self._operation_id) + + def on_rerun_prepared( + self, + *, + affected_steps: list[str], + scope: str, + target_step: str = "", + ) -> None: + self._manager.rerun_prepared( + self._operation_id, + affected_steps=affected_steps, + scope=scope, + target_step=target_step, + ) + + def on_step_completed( + self, + workspace_step: Any, + state: Any, + error: str | None = None, + ) -> None: + if self._committed_revision is None and self._snapshot_committer is not None: + self.commit_step(workspace_step, state, error) + self._manager.step_completed( + self._operation_id, + workspace_step, + state, + error, + self._committed_revision, + ) + self._committed_revision = None + + def on_step_diagnostic( + self, + workspace_step: Any, + diagnostic: dict[str, Any], + ) -> None: + self._manager.step_diagnostic(self._operation_id, workspace_step, diagnostic) + + def commit_step(self, workspace_step: Any, state: Any, error: str | None = None) -> None: + if self._snapshot_committer is not None: + self._committed_revision = self._snapshot_committer(workspace_step, state, error) + + def on_subflow_stage(self, workspace_step: Any, subflow_step: dict[str, Any]) -> None: + self._manager.subflow_stage(self._operation_id, workspace_step, subflow_step) + + def on_step_skipped(self, workspace_step: Any) -> None: + self._manager.step_skipped(self._operation_id, workspace_step) diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/methods.py b/ecos/runtime-adapter/ecos_runtime_adapter/methods.py new file mode 100644 index 000000000..c686511ae --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/methods.py @@ -0,0 +1,231 @@ +from dataclasses import dataclass +from typing import Any, Final, Generic, TypeVar + +from ecos_runtime_adapter.requests import ( + DbEnsureRequest, + DbReleaseRequest, + EmptyRequest, + FloorplanEditInspectRequest, + FloorplanEditRunAutoRequest, + FloorplanEditValidateRequest, + FlowRunRequest, + FlowRunStepRequest, + LayoutEditApplyRequest, + LayoutEditBeginRequest, + LayoutEditDiscardRequest, + LayoutEditSaveRequest, + OperationIdRequest, + OperationStartFlowRequest, + OperationStartStepRequest, + WorkspaceCloseRequest, + WorkspaceCreateV1Request, + WorkspaceExportSignoffRequest, + WorkspaceIdRequest, + WorkspaceInfoRequest, + WorkspaceInspectSignoffRequest, + WorkspaceMutationRequest, + WorkspaceOpenV1Request, + WorkspaceRecoverInterruptedRequest, + WorkspaceSpecValidateRequest, + WorkspaceSyncConfigRequest, + WorkspaceUpdateRequest, +) + +RequestT = TypeVar("RequestT") + + +@dataclass(frozen=True) +class RuntimeMethodSpec(Generic[RequestT]): + method_name: str + request_model: type[RequestT] + handler_name: str + + +RUNTIME_METHODS: Final[tuple[RuntimeMethodSpec[Any], ...]] = ( + RuntimeMethodSpec( + method_name="workspace_spec.describe", + request_model=EmptyRequest, + handler_name="describe_workspace_spec", + ), + RuntimeMethodSpec( + method_name="workspace_spec.validate", + request_model=WorkspaceSpecValidateRequest, + handler_name="validate_workspace_spec", + ), + RuntimeMethodSpec( + method_name="workspace.create", + request_model=WorkspaceCreateV1Request, + handler_name="create_workspace", + ), + RuntimeMethodSpec( + method_name="workspace.open", + request_model=WorkspaceOpenV1Request, + handler_name="open_workspace", + ), + RuntimeMethodSpec( + method_name="workspace.update", + request_model=WorkspaceUpdateRequest, + handler_name="update_workspace", + ), + RuntimeMethodSpec( + method_name="workspace.close", + request_model=WorkspaceCloseRequest, + handler_name="close_workspace", + ), + RuntimeMethodSpec( + method_name="workspace.home", + request_model=WorkspaceIdRequest, + handler_name="workspace_home", + ), + RuntimeMethodSpec( + method_name="workspace.info", + request_model=WorkspaceInfoRequest, + handler_name="workspace_info", + ), + RuntimeMethodSpec( + method_name="workspace.refresh_config", + request_model=WorkspaceIdRequest, + handler_name="refresh_config", + ), + RuntimeMethodSpec( + method_name="workspace.sync_config", + request_model=WorkspaceSyncConfigRequest, + handler_name="sync_config", + ), + RuntimeMethodSpec( + method_name="workspace.reset_flow", + request_model=WorkspaceMutationRequest, + handler_name="reset_flow", + ), + RuntimeMethodSpec( + method_name="workspace.export_signoff", + request_model=WorkspaceExportSignoffRequest, + handler_name="export_signoff", + ), + RuntimeMethodSpec( + method_name="workspace.inspect_signoff", + request_model=WorkspaceInspectSignoffRequest, + handler_name="inspect_signoff", + ), + RuntimeMethodSpec( + method_name="flow.run", + request_model=FlowRunRequest, + handler_name="flow_run", + ), + RuntimeMethodSpec( + method_name="flow.run_step", + request_model=FlowRunStepRequest, + handler_name="flow_run_step", + ), + RuntimeMethodSpec( + method_name="operation.start_flow", + request_model=OperationStartFlowRequest, + handler_name="start_flow_operation", + ), + RuntimeMethodSpec( + method_name="operation.start_step", + request_model=OperationStartStepRequest, + handler_name="start_step_operation", + ), + RuntimeMethodSpec( + method_name="operation.status", + request_model=OperationIdRequest, + handler_name="operation_status", + ), + RuntimeMethodSpec( + method_name="operation.cancel", + request_model=OperationIdRequest, + handler_name="cancel_operation", + ), + RuntimeMethodSpec( + method_name="workspace.snapshot", + request_model=WorkspaceIdRequest, + handler_name="workspace_snapshot", + ), + RuntimeMethodSpec( + method_name="workspace.engineering_snapshot", + request_model=WorkspaceIdRequest, + handler_name="engineering_snapshot", + ), + RuntimeMethodSpec( + method_name="workspace.recover_interrupted", + request_model=WorkspaceRecoverInterruptedRequest, + handler_name="recover_interrupted", + ), +) + + +PERSISTENT_DB_METHODS: Final[tuple[RuntimeMethodSpec[Any], ...]] = ( + RuntimeMethodSpec( + method_name="db.ensure", + request_model=DbEnsureRequest, + handler_name="db_ensure", + ), + RuntimeMethodSpec( + method_name="db.release", + request_model=DbReleaseRequest, + handler_name="db_release", + ), + RuntimeMethodSpec( + method_name="layout.edit.begin", + request_model=LayoutEditBeginRequest, + handler_name="layout_edit_begin", + ), + RuntimeMethodSpec( + method_name="layout.edit.apply", + request_model=LayoutEditApplyRequest, + handler_name="layout_edit_apply", + ), + RuntimeMethodSpec( + method_name="layout.edit.save", + request_model=LayoutEditSaveRequest, + handler_name="layout_edit_save", + ), + RuntimeMethodSpec( + method_name="layout.edit.discard", + request_model=LayoutEditDiscardRequest, + handler_name="layout_edit_discard", + ), + RuntimeMethodSpec( + method_name="floorplan.edit.inspect", + request_model=FloorplanEditInspectRequest, + handler_name="floorplan_edit_inspect", + ), + RuntimeMethodSpec( + method_name="floorplan.edit.run_auto", + request_model=FloorplanEditRunAutoRequest, + handler_name="floorplan_edit_run_auto", + ), + RuntimeMethodSpec( + method_name="floorplan.edit.validate", + request_model=FloorplanEditValidateRequest, + handler_name="floorplan_edit_validate", + ), +) + + +def runtime_methods(*, persistent_db_enabled: bool = False) -> tuple[RuntimeMethodSpec[Any], ...]: + if persistent_db_enabled: + return (*RUNTIME_METHODS, *PERSISTENT_DB_METHODS) + return RUNTIME_METHODS + + +def runtime_method_names(*, persistent_db_enabled: bool = False) -> tuple[str, ...]: + return tuple( + spec.method_name for spec in runtime_methods(persistent_db_enabled=persistent_db_enabled) + ) + + +def persistent_db_method_names() -> tuple[str, ...]: + return tuple(spec.method_name for spec in PERSISTENT_DB_METHODS) + + +def runtime_method_by_name( + method_name: str, + *, + persistent_db_enabled: bool = False, +) -> RuntimeMethodSpec[Any] | None: + for spec in runtime_methods(persistent_db_enabled=persistent_db_enabled): + if spec.method_name == method_name: + return spec + return None diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/operation_diagnostics.py b/ecos/runtime-adapter/ecos_runtime_adapter/operation_diagnostics.py new file mode 100644 index 000000000..13b073db6 --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/operation_diagnostics.py @@ -0,0 +1,17 @@ +from typing import Any + + +def tool_error_payload( + workspace_step: Any, + message: str, + diagnostic: dict[str, Any] | None = None, +) -> dict[str, Any]: + log_file = str(getattr(getattr(workspace_step, "log", None), "file", "") or "") + return { + "code": "tool_failed", + "message": message, + "step": str(getattr(workspace_step, "name", "")), + "tool": str(getattr(workspace_step, "tool", "")), + "logFile": log_file, + **(diagnostic or {}), + } diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/operations.py b/ecos/runtime-adapter/ecos_runtime_adapter/operations.py new file mode 100644 index 000000000..93737a1fb --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/operations.py @@ -0,0 +1,735 @@ +from __future__ import annotations + +import hashlib +import json +import threading +import time +from collections.abc import Callable +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from ecos_runtime_adapter.flow_observer import RuntimeFlowObserver +from ecos_runtime_adapter.operation_diagnostics import tool_error_payload + +_LOG_POLL_INTERVAL_SECONDS = 0.25 +_MAX_LOG_CHUNK_BYTES = 16 * 1024 +_MAX_FINAL_LOG_BYTES = 64 * 1024 +_TERMINAL_OPERATION_STATES = frozenset({"succeeded", "failed", "cancelled", "interrupted"}) + + +@dataclass +class _StepLogTail: + """A bounded worker-side reader for one active step log.""" + + operation_id: str + path: Path + step: str + tool: str + cursor: int + stopped: threading.Event = field(default_factory=threading.Event) + thread: threading.Thread | None = None + + +class RuntimeOperationConflict(RuntimeError): + """A workspace already owns a non-terminal runtime operation.""" + + +class RuntimeOperationCancelled(RuntimeError): + """Cancellation was accepted at a safe step boundary.""" + + +class RuntimeOperationIdempotencyConflict(RuntimeError): + """A command ID was reused with different immutable input.""" + + +@dataclass +class RuntimeOperation: + operation_id: str + run_session_id: str + runtime_instance_id: str + workspace_id: str + kind: str + origin: str + rerun: bool + step: str = "" + idempotency_key: str = "" + state: str = "queued" + current_step: str = "" + current_tool: str = "" + error: dict[str, Any] | None = None + result: dict[str, Any] | None = None + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + sequence: int = 0 + workspace_revision: int = 0 + cancel_requested: bool = False + interruptibility: str = "deferred" + + +class RuntimeOperationManager: + """Owns asynchronous GUI operations and their exactly-once event stream.""" + + def __init__(self, publisher: Callable[[dict[str, Any]], None] | None = None): + self._publisher = publisher + self._lock = threading.RLock() + self._operations: dict[str, RuntimeOperation] = {} + self._active_by_workspace: dict[str, str] = {} + self._idempotency: dict[tuple[str, str], tuple[str, str]] = {} + self._step_log_tails: dict[str, _StepLogTail] = {} + self._runtime_instance_id = uuid4().hex + self._workspace_sequences: dict[str, int] = {} + self._ledger_paths: dict[str, Path] = {} + self._loaded_ledgers: set[Path] = set() + + def set_publisher(self, publisher: Callable[[dict[str, Any]], None] | None) -> None: + with self._lock: + self._publisher = publisher + + def start( + self, + *, + workspace_id: str, + kind: str, + origin: str, + rerun: bool, + step: str, + idempotency_key: str, + runner: Callable[[RuntimeFlowObserver], dict[str, Any]], + workspace_revision: int = 0, + command_input: dict[str, Any] | None = None, + snapshot_committer: Callable[[Any, Any, str | None], int] | None = None, + ledger_path: str | Path | None = None, + precondition: Callable[[], None] | None = None, + ) -> dict[str, Any]: + fingerprint = _command_fingerprint( + kind=kind, + origin=origin, + rerun=rerun, + step=step, + workspace_revision=workspace_revision, + command_input=command_input, + ) + with self._lock: + if ledger_path is not None: + self._load_workspace_ledger_locked(workspace_id, Path(ledger_path)) + if idempotency_key: + known = self._idempotency.get((workspace_id, idempotency_key)) + if known is not None: + known_id, known_fingerprint = known + if known_fingerprint != fingerprint: + raise RuntimeOperationIdempotencyConflict( + f"command id reused with different input: {idempotency_key}" + ) + return { + **self._operation_payload(self._operations[known_id]), + "deduplicated": True, + } + + if precondition is not None: + precondition() + active_id = self._active_by_workspace.get(workspace_id) + if active_id is not None: + active = self._operations[active_id] + raise RuntimeOperationConflict( + f"workspace already has an active operation: {active.operation_id}" + ) + + operation = RuntimeOperation( + operation_id=f"operation-{uuid4().hex}", + run_session_id=uuid4().hex, + runtime_instance_id=self._runtime_instance_id, + workspace_id=workspace_id, + kind=kind, + origin=origin, + rerun=rerun, + step=step, + idempotency_key=idempotency_key, + workspace_revision=workspace_revision, + ) + self._operations[operation.operation_id] = operation + self._active_by_workspace[workspace_id] = operation.operation_id + if idempotency_key: + self._idempotency[(workspace_id, idempotency_key)] = ( + operation.operation_id, + fingerprint, + ) + self._persist_workspace_ledger_locked(workspace_id) + queued_event = self._new_event_locked(operation, "operation.queued", {}) + + self._publish(queued_event) + thread = threading.Thread( + target=self._run, + args=(operation.operation_id, runner, snapshot_committer), + name=f"ecc-runtime-{operation.operation_id}", + daemon=True, + ) + thread.start() + return self.operation_status(operation.operation_id) + + def operation_status(self, operation_id: str) -> dict[str, Any]: + with self._lock: + operation = self._operations.get(operation_id) + if operation is None: + raise KeyError(operation_id) + return self._operation_payload(operation) + + def load_workspace_ledger(self, workspace_id: str, ledger_path: str | Path) -> list[str]: + with self._lock: + return self._load_workspace_ledger_locked(workspace_id, Path(ledger_path)) + + def is_active(self, operation_id: str) -> bool: + with self._lock: + operation = self._operations.get(operation_id) + return operation is not None and operation.state not in _TERMINAL_OPERATION_STATES + + def has_active_workspace(self, workspace_id: str) -> bool: + with self._lock: + return workspace_id in self._active_by_workspace + + def workspace_snapshot(self, workspace_id: str) -> dict[str, Any]: + with self._lock: + operations = [ + self._operation_payload(operation) + for operation in self._operations.values() + if operation.workspace_id == workspace_id + ] + return { + "workspaceId": workspace_id, + "runtimeInstanceId": self._runtime_instance_id, + "lastEventId": ( + f"{self._runtime_instance_id}:{workspace_id}:" + f"{self._workspace_sequences.get(workspace_id, 0)}" + ), + "operations": operations, + } + + def request_cancel(self, operation_id: str) -> dict[str, Any]: + with self._lock: + operation = self._operations.get(operation_id) + if operation is None: + raise KeyError(operation_id) + if operation.state in _TERMINAL_OPERATION_STATES: + return {"accepted": False, "operationId": operation_id, "state": operation.state} + operation.cancel_requested = True + operation.updated_at = time.time() + self._persist_workspace_ledger_locked(operation.workspace_id) + event = self._new_event_locked(operation, "operation.cancel_requested", {}) + self._publish(event) + return {"accepted": True, "operationId": operation_id, "state": operation.state} + + def raise_if_cancel_requested(self, operation_id: str) -> None: + with self._lock: + operation = self._operations[operation_id] + if operation.cancel_requested: + raise RuntimeOperationCancelled("operation cancelled at a step boundary") + + def shutdown_barrier(self) -> dict[str, Any] | None: + with self._lock: + for operation_id in self._active_by_workspace.values(): + operation = self._operations[operation_id] + return { + "operationId": operation.operation_id, + "workspaceId": operation.workspace_id, + "state": operation.state, + "step": operation.current_step, + "interruptibility": operation.interruptibility, + "safeToStop": operation.interruptibility == "safe", + "cancelRequested": operation.cancel_requested, + } + return None + + def _run( + self, + operation_id: str, + runner: Callable[[RuntimeFlowObserver], dict[str, Any]], + snapshot_committer: Callable[[Any, Any, str | None], int] | None, + ) -> None: + with self._lock: + operation = self._operations[operation_id] + operation.state = "running" + operation.updated_at = time.time() + self._persist_workspace_ledger_locked(operation.workspace_id) + started_event = self._new_event_locked(operation, "operation.started", {}) + observer = RuntimeFlowObserver(self, operation_id, snapshot_committer) + try: + self._publish(started_event) + try: + result = runner(observer) + with self._lock: + operation = self._operations[operation_id] + if operation.cancel_requested: + raise RuntimeOperationCancelled("operation cancelled at a step boundary") + operation.state = "succeeded" + operation.result = result + operation.updated_at = time.time() + event = self._new_event_locked( + operation, + "operation.completed", + {"result": result}, + ) + self._persist_workspace_ledger_locked(operation.workspace_id) + except RuntimeOperationCancelled as exc: + with self._lock: + operation = self._operations[operation_id] + if operation.error is not None: + operation.state = "failed" + event_type = "operation.failed" + else: + operation.state = "cancelled" + operation.error = { + "message": str(exc), + "code": "cancelled", + } + event_type = "operation.cancelled" + operation.updated_at = time.time() + event = self._new_event_locked( + operation, + event_type, + {"error": operation.error}, + ) + self._persist_workspace_ledger_locked(operation.workspace_id) + except Exception as exc: + with self._lock: + operation = self._operations[operation_id] + if operation.cancel_requested and operation.error is None: + operation.state = "cancelled" + operation.error = {"message": str(exc), "code": "cancelled"} + event_type = "operation.cancelled" + else: + operation.state = "failed" + operation.error = operation.error or { + "message": str(exc), + "code": "command_failed", + } + event_type = "operation.failed" + operation.updated_at = time.time() + payload = {"error": operation.error} + if operation.error: + payload.update( + { + key: operation.error[key] + for key in ("step", "tool", "logFile") + if key in operation.error + } + ) + event = self._new_event_locked(operation, event_type, payload) + self._persist_workspace_ledger_locked(operation.workspace_id) + self._publish(event) + finally: + try: + self._stop_step_log_tail(operation_id) + finally: + with self._lock: + self._active_by_workspace.pop( + self._operations[operation_id].workspace_id, + None, + ) + + def step_started(self, operation_id: str, workspace_step: Any) -> None: + self._stop_step_log_tail(operation_id) + with self._lock: + operation = self._operations[operation_id] + operation.current_step = str(getattr(workspace_step, "name", "")) + operation.current_tool = str(getattr(workspace_step, "tool", "")) + operation.updated_at = time.time() + self._persist_workspace_ledger_locked(operation.workspace_id) + event = self._new_event_locked( + operation, + "step.started", + { + "step": operation.current_step, + "tool": operation.current_tool, + "state": "Ongoing", + }, + ) + log_tail = _step_log_tail_for( + operation_id, + getattr(workspace_step, "log", None), + operation.current_step, + operation.current_tool, + ) + if log_tail is not None: + self._step_log_tails[operation_id] = log_tail + self._publish(event) + if log_tail is not None: + thread = threading.Thread( + target=self._tail_step_log, + args=(log_tail,), + name=f"ecc-runtime-log-{operation_id}", + daemon=True, + ) + log_tail.thread = thread + thread.start() + + def rerun_prepared( + self, + operation_id: str, + *, + affected_steps: list[str], + scope: str, + target_step: str = "", + ) -> None: + """Publish the idempotent GUI reset boundary before a rerun starts.""" + with self._lock: + operation = self._operations[operation_id] + operation.updated_at = time.time() + event = self._new_event_locked( + operation, + "operation.rerun_prepared", + { + "affectedSteps": affected_steps, + "scope": scope, + "targetStep": target_step, + }, + ) + self._publish(event) + + def step_completed( + self, + operation_id: str, + workspace_step: Any, + state: Any, + error: str | None = None, + workspace_revision: int | None = None, + ) -> None: + self._stop_step_log_tail(operation_id) + state_value = str(getattr(state, "value", state)) + final_log = _read_final_log(getattr(workspace_step, "log", None)) + with self._lock: + operation = self._operations[operation_id] + operation.current_step = str(getattr(workspace_step, "name", "")) + operation.current_tool = str(getattr(workspace_step, "tool", "")) + operation.updated_at = time.time() + payload: dict[str, Any] = { + "finalLog": final_log, + "step": operation.current_step, + "tool": operation.current_tool, + "state": state_value, + } + if error: + operation.error = operation.error or tool_error_payload(workspace_step, error) + payload["error"] = operation.error + payload["logFile"] = operation.error["logFile"] + event = self._new_event_locked(operation, "step.completed", payload) + if workspace_revision is None: + operation.workspace_revision += 1 + else: + operation.workspace_revision = workspace_revision + step_commit_id = f"{operation.operation_id}:step:{operation.workspace_revision}" + payload["stepCommitId"] = step_commit_id + payload["workspaceRevision"] = operation.workspace_revision + self._persist_workspace_ledger_locked(operation.workspace_id) + self._publish(event) + + def step_diagnostic( + self, + operation_id: str, + workspace_step: Any, + diagnostic: dict[str, Any], + ) -> None: + with self._lock: + operation = self._operations[operation_id] + operation.error = tool_error_payload( + workspace_step, + str(diagnostic.get("message", "tool failed")), + diagnostic, + ) + + def subflow_stage( + self, + operation_id: str, + workspace_step: Any, + subflow_step: dict[str, Any], + ) -> None: + """Publish a saved inner-flow state without waiting for a render ACK.""" + with self._lock: + operation = self._operations[operation_id] + step = str(getattr(workspace_step, "name", "")) + tool = str(getattr(workspace_step, "tool", "")) + event = self._new_event_locked( + operation, + "subflow.stage", + { + "peakMemory": subflow_step.get("peak memory (mb)", 0), + "runtime": str(subflow_step.get("runtime", "")), + "state": str(subflow_step.get("state", "Unstart")), + "step": step, + "subflowStep": str(subflow_step.get("name", "")), + "tool": tool, + }, + ) + self._publish(event) + + def step_skipped(self, operation_id: str, workspace_step: Any) -> None: + self._stop_step_log_tail(operation_id) + with self._lock: + operation = self._operations[operation_id] + operation.current_step = str(getattr(workspace_step, "name", "")) + operation.current_tool = str(getattr(workspace_step, "tool", "")) + operation.updated_at = time.time() + event = self._new_event_locked( + operation, + "step.completed", + { + "step": operation.current_step, + "tool": operation.current_tool, + "state": "Skipped", + }, + ) + self._publish(event) + + def _tail_step_log(self, log_tail: _StepLogTail) -> None: + while not log_tail.stopped.is_set(): + self._publish_step_log_delta(log_tail) + log_tail.stopped.wait(_LOG_POLL_INTERVAL_SECONDS) + + def _publish_step_log_delta(self, log_tail: _StepLogTail) -> None: + try: + size = log_tail.path.stat().st_size + if size < log_tail.cursor: + # A rerun may truncate or replace a log file. The renderer treats + # this as a new bounded stream for the same step attempt. + log_tail.cursor = 0 + if size <= log_tail.cursor: + return + with log_tail.path.open("rb") as log_file: + log_file.seek(log_tail.cursor) + chunk = log_file.read(_MAX_LOG_CHUNK_BYTES) + except OSError: + return + + if not chunk: + return + log_tail.cursor += len(chunk) + text = chunk.decode("utf-8", errors="replace") + with self._lock: + if self._step_log_tails.get(log_tail.operation_id) is not log_tail: + return + operation = self._operations.get(log_tail.operation_id) + if operation is None: + return + event = self._new_event_locked( + operation, + "step.log", + { + "chunk": text, + "cursor": log_tail.cursor, + "step": log_tail.step, + "tool": log_tail.tool, + }, + ) + self._publish(event) + + def _stop_step_log_tail(self, operation_id: str) -> None: + with self._lock: + log_tail = self._step_log_tails.pop(operation_id, None) + if log_tail is None: + return + log_tail.stopped.set() + if log_tail.thread is not None and log_tail.thread is not threading.current_thread(): + log_tail.thread.join(timeout=_LOG_POLL_INTERVAL_SECONDS + 0.25) + + def _new_event_locked( + self, + operation: RuntimeOperation, + event_type: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + sequence = self._workspace_sequences.get(operation.workspace_id, 0) + 1 + self._workspace_sequences[operation.workspace_id] = sequence + operation.sequence = sequence + return { + "eventId": f"{self._runtime_instance_id}:{operation.operation_id}:{sequence}", + "runtimeInstanceId": self._runtime_instance_id, + "runSessionId": operation.run_session_id, + "sequence": sequence, + "type": event_type, + "workspaceId": operation.workspace_id, + "operationId": operation.operation_id, + "origin": operation.origin, + "kind": operation.kind, + "rerun": operation.rerun, + "timestamp": time.time(), + "payload": payload, + } + + @staticmethod + def _operation_payload(operation: RuntimeOperation) -> dict[str, Any]: + return { + "operationId": operation.operation_id, + "runSessionId": operation.run_session_id, + "runtimeInstanceId": operation.runtime_instance_id, + "workspaceId": operation.workspace_id, + "kind": operation.kind, + "origin": operation.origin, + "rerun": operation.rerun, + "step": operation.step, + "state": operation.state, + "currentStep": operation.current_step, + "currentTool": operation.current_tool, + "error": operation.error, + "result": operation.result, + "workspaceRevision": operation.workspace_revision, + "cancelRequested": operation.cancel_requested, + "interruptibility": operation.interruptibility, + "safeToStop": operation.interruptibility == "safe", + "shutdownBarrier": operation.state not in _TERMINAL_OPERATION_STATES, + "createdAt": operation.created_at, + "updatedAt": operation.updated_at, + } + + def _publish(self, event: dict[str, Any]) -> None: + publisher = self._publisher + if publisher is not None: + publisher(event) + + def _load_workspace_ledger_locked(self, workspace_id: str, ledger_path: Path) -> list[str]: + ledger_path = ledger_path.resolve() + self._ledger_paths[workspace_id] = ledger_path + if ledger_path in self._loaded_ledgers: + return [] + self._loaded_ledgers.add(ledger_path) + try: + payload = json.loads(ledger_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + except (OSError, json.JSONDecodeError) as exc: + raise OSError(f"invalid Runtime Operation ledger: {ledger_path}") from exc + if payload.get("schemaVersion") != 1 or not isinstance(payload.get("commands"), dict): + raise OSError(f"invalid Runtime Operation ledger: {ledger_path}") + recovered = [] + events = [] + for command_id, record in payload["commands"].items(): + if not isinstance(record, dict) or not isinstance(record.get("operation"), dict): + continue + operation = _operation_from_record(record["operation"]) + if operation.workspace_id != workspace_id: + continue + self._operations[operation.operation_id] = operation + self._idempotency[(workspace_id, command_id)] = ( + operation.operation_id, + str(record.get("fingerprint", "")), + ) + if operation.state not in _TERMINAL_OPERATION_STATES: + operation.state = "interrupted" + operation.error = { + "code": "interrupted", + "message": "Runtime Adapter exited before confirming a terminal outcome.", + } + operation.updated_at = time.time() + recovered.append(operation.operation_id) + events.append( + self._new_event_locked( + operation, + "operation.interrupted", + {"error": operation.error, "state": "interrupted"}, + ) + ) + if recovered: + self._persist_workspace_ledger_locked(workspace_id) + for event in events: + self._publish(event) + return recovered + + def _persist_workspace_ledger_locked(self, workspace_id: str) -> None: + path = self._ledger_paths.get(workspace_id) + if path is None: + return + from chipcompiler.utility import json_write + + commands = {} + for (candidate_workspace_id, command_id), ( + operation_id, + fingerprint, + ) in self._idempotency.items(): + if candidate_workspace_id != workspace_id: + continue + operation = self._operations.get(operation_id) + if operation is None: + continue + commands[command_id] = { + "fingerprint": fingerprint, + "operation": asdict(operation), + } + path.parent.mkdir(parents=True, exist_ok=True) + if not json_write(path, {"schemaVersion": 1, "commands": commands}): + raise OSError(f"failed to persist Runtime Operation ledger: {path}") + + +def _read_final_log(log: Any) -> str: + path = getattr(log, "file", None) + if not path: + return "" + try: + with Path(path).open("rb") as log_file: + log_file.seek(0, 2) + size = log_file.tell() + log_file.seek(max(0, size - _MAX_FINAL_LOG_BYTES)) + return log_file.read().decode("utf-8", errors="replace") + except OSError: + return "" + + +def _step_log_tail_for( + operation_id: str, + log: Any, + step: str, + tool: str, +) -> _StepLogTail | None: + path = getattr(log, "file", None) + if not path: + return None + log_path = Path(path) + try: + cursor = log_path.stat().st_size + except OSError: + cursor = 0 + return _StepLogTail( + operation_id=operation_id, + path=log_path, + step=step, + tool=tool, + cursor=cursor, + ) + + +def _command_fingerprint( + *, + kind: str, + origin: str, + rerun: bool, + step: str, + workspace_revision: int, + command_input: dict[str, Any] | None = None, +) -> str: + encoded = json.dumps( + { + "kind": kind, + "origin": origin, + "rerun": rerun, + "step": step, + "workspaceRevision": workspace_revision, + "commandInput": command_input or {}, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _operation_from_record(record: dict[str, Any]) -> RuntimeOperation: + required = { + "operation_id", + "run_session_id", + "runtime_instance_id", + "workspace_id", + "kind", + "origin", + "rerun", + } + if not required <= record.keys(): + raise OSError("invalid Runtime Operation record") + fields = RuntimeOperation.__dataclass_fields__ + return RuntimeOperation(**{key: value for key, value in record.items() if key in fields}) diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/recovery.py b/ecos/runtime-adapter/ecos_runtime_adapter/recovery.py new file mode 100644 index 000000000..28c16b6af --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/recovery.py @@ -0,0 +1,54 @@ +from copy import deepcopy +from pathlib import Path +from typing import Any + +from chipcompiler.data import StateEnum, Workspace +from chipcompiler.utility import json_write + +from ecos_runtime_adapter.operations import RuntimeOperationManager + + +def recover_interrupted_operation( + workspace: Workspace, + operations: RuntimeOperationManager, + operation_id: str = "", +) -> dict[str, list[dict[str, str]]]: + flow_data = deepcopy(workspace.flow.data) + recovered = [] + for step in flow_data.get("steps", []): + marker = step.get("info", {}).get("runtime_operation") + if ( + step.get("state") != "Ongoing" + or not isinstance(marker, dict) + or marker.get("schema") != 1 + or not isinstance(marker.get("operation_id"), str) + or not marker["operation_id"] + or not isinstance(marker.get("runtime_instance_id"), str) + or not marker["runtime_instance_id"] + or not isinstance(marker.get("started_at"), (int, float)) + or (operation_id and marker.get("operation_id") != operation_id) + or operations.is_active(str(marker["operation_id"])) + ): + continue + log_file = _step_log_file(workspace.directory, step) + step["state"] = StateEnum.Imcomplete.value + step["info"].pop("runtime_operation", None) + recovered.append( + { + "step": str(step.get("name", "")), + "tool": str(step.get("tool", "")), + "operationId": str(marker["operation_id"]), + "logFile": log_file, + } + ) + if recovered: + if not json_write(workspace.flow.path, flow_data): + raise OSError(f"failed to save recovered flow state: {workspace.flow.path}") + workspace.flow.data = flow_data + return {"recovered": recovered} + + +def _step_log_file(directory: str | Path, step: dict[str, Any]) -> str: + name = str(step.get("name", "")) + tool = str(step.get("tool", "")) + return str(Path(directory) / f"{name}_{tool}" / "log" / f"{name}.log") diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/requests.py b/ecos/runtime-adapter/ecos_runtime_adapter/requests.py new file mode 100644 index 000000000..9d345bf69 --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/requests.py @@ -0,0 +1,285 @@ +from dataclasses import MISSING, dataclass, fields +from typing import Any + + +@dataclass(frozen=True) +class EmptyRequest: + pass + + +@dataclass(frozen=True) +class WorkspaceCreateRequest: + directory: str + pdk: str = "" + pdk_root: str = "" + pdk_json: Any = None + parameters: dict[str, Any] | None = None + origin_def: str = "" + origin_verilog: str = "" + filelist: str = "" + rtl_list: list[str] | None = None + sdc: str = "" + flow_config: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class WorkspaceOpenRequest: + directory: str + + +@dataclass(frozen=True) +class WorkspaceOpenV1Request: + directory: str + workspace_bindings: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class WorkspaceSpecValidateRequest: + workspace_spec: dict[str, Any] + workspace_bindings: dict[str, Any] + + +@dataclass(frozen=True) +class WorkspaceCreateV1Request: + command_id: str + target_directory: str + workspace_spec: dict[str, Any] + workspace_bindings: dict[str, Any] + + +@dataclass(frozen=True) +class WorkspaceUpdateRequest: + command_id: str + workspace_id: str + expected_workspace_revision: int + workspace_spec: dict[str, Any] + workspace_bindings: dict[str, Any] + + +@dataclass(frozen=True) +class WorkspaceIdRequest: + workspace_id: str + + +@dataclass(frozen=True) +class WorkspaceRecoverInterruptedRequest: + workspace_id: str + operation_id: str = "" + + +@dataclass(frozen=True) +class WorkspaceCloseRequest: + workspace_id: str + + +@dataclass(frozen=True) +class WorkspaceSyncConfigRequest: + workspace_id: str + config_path: str + expected_workspace_revision: int + + +@dataclass(frozen=True) +class WorkspaceExportSignoffRequest: + workspace_id: str + output_path: str + additional_files: list[dict[str, str]] | None = None + + +@dataclass(frozen=True) +class WorkspaceInspectSignoffRequest: + workspace_id: str + + +@dataclass(frozen=True) +class WorkspaceInfoRequest: + workspace_id: str + step: str + info_id: str + + +@dataclass(frozen=True) +class FlowRunRequest: + workspace_id: str + expected_workspace_revision: int = 1 + rerun: bool = False + + +@dataclass(frozen=True) +class FlowRunStepRequest: + workspace_id: str + step: str + expected_workspace_revision: int = 1 + rerun: bool = False + + +@dataclass(frozen=True) +class OperationStartFlowRequest: + workspace_id: str + expected_workspace_revision: int = 1 + rerun: bool = False + origin: str = "gui" + idempotency_key: str = "" + + +@dataclass(frozen=True) +class OperationStartStepRequest: + workspace_id: str + step: str + expected_workspace_revision: int = 1 + rerun: bool = False + reset_dependents: bool = False + origin: str = "gui" + idempotency_key: str = "" + + +@dataclass(frozen=True) +class OperationIdRequest: + operation_id: str + + +@dataclass(frozen=True) +class DbEnsureRequest: + workspace_id: str + step: str = "" + + +@dataclass(frozen=True) +class DbReleaseRequest: + workspace_id: str + + +@dataclass(frozen=True) +class LayoutEditBeginRequest: + workspace_id: str + step: str + expected_source_fingerprint: str = "" + + +@dataclass(frozen=True) +class LayoutEditApplyRequest: + edit_session_id: str + command_id: str + base_revision: int + operation: dict[str, Any] + + +@dataclass(frozen=True) +class LayoutEditSaveRequest: + edit_session_id: str + expected_revision: int + expected_workspace_revision: int = 1 + + +@dataclass(frozen=True) +class WorkspaceMutationRequest: + workspace_id: str + expected_workspace_revision: int = 1 + + +@dataclass(frozen=True) +class LayoutEditDiscardRequest: + edit_session_id: str + + +@dataclass(frozen=True) +class FloorplanEditInspectRequest: + edit_session_id: str + + +@dataclass(frozen=True) +class FloorplanEditRunAutoRequest: + edit_session_id: str + command_id: str + base_revision: int + request: dict[str, Any] + + +@dataclass(frozen=True) +class FloorplanEditValidateRequest: + edit_session_id: str + scope: str = "all" + + +class RequestValidationError(ValueError): + def __init__(self, reason: str): + super().__init__(reason) + self.reason = reason + + +FIELD_ALIASES = { + "flowConfig": "flow_config", + "pdkRoot": "pdk_root", + "pdkJson": "pdk_json", + "originDef": "origin_def", + "originVerilog": "origin_verilog", + "paramJson": "parameters", + "rtlList": "rtl_list", + "workspaceId": "workspace_id", + "operationId": "operation_id", + "eventId": "event_id", + "stepCommitId": "step_commit_id", + "workspaceRevision": "workspace_revision", + "expectedWorkspaceRevision": "expected_workspace_revision", + "idempotencyKey": "idempotency_key", + "resetDependents": "reset_dependents", + "configPath": "config_path", + "outputPath": "output_path", + "infoId": "info_id", + "editSessionId": "edit_session_id", + "commandId": "command_id", + "baseRevision": "base_revision", + "expectedRevision": "expected_revision", + "expectedSourceFingerprint": "expected_source_fingerprint", + "id": "info_id", + "additionalFiles": "additional_files", + "workspaceSpec": "workspace_spec", + "workspaceBindings": "workspace_bindings", + "targetDirectory": "target_directory", +} + + +def parse_request_model(model: type, params: object): + if not isinstance(params, dict): + raise RequestValidationError("params must be an object") + + normalized = _normalize_fields(params) + model_fields = {field.name: field for field in fields(model)} + for key in normalized: + if key not in model_fields: + raise RequestValidationError(f"unknown field: {key}") + + values: dict[str, Any] = {} + for field in fields(model): + required = field.name == "expected_workspace_revision" or ( + field.default is MISSING and field.default_factory is MISSING + ) + + if field.name in normalized: + values[field.name] = normalized[field.name] + elif not required: + values[field.name] = field.default + else: + raise RequestValidationError(f"missing required field: {field.name}") + + if required and _is_missing(values[field.name]): + raise RequestValidationError(f"missing required field: {field.name}") + + if field.name in {"rerun", "reset_dependents"} and not isinstance(values[field.name], bool): + raise RequestValidationError(f"{field.name} must be a boolean") + + return model(**values) + + +def _normalize_fields(params: dict) -> dict[str, Any]: + normalized: dict[str, Any] = {} + for key, value in params.items(): + normalized_key = FIELD_ALIASES.get(str(key), str(key)) + if normalized_key in normalized: + raise RequestValidationError(f"duplicate field: {normalized_key}") + normalized[normalized_key] = value + return normalized + + +def _is_missing(value: object) -> bool: + return value is None or (isinstance(value, str) and not value.strip()) diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/rpc_dispatch.py b/ecos/runtime-adapter/ecos_runtime_adapter/rpc_dispatch.py new file mode 100644 index 000000000..de7c4469a --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/rpc_dispatch.py @@ -0,0 +1,40 @@ +from collections.abc import Callable +from functools import wraps +from typing import Any + +from jsonrpcserver import Success, dispatch +from oslash.either import Left, Right + +from ecos_runtime_adapter.events import redirect_stdout_to_stderr + +JsonRpcHandler = Callable[..., Any] + + +class RpcDispatcher: + def __init__(self): + self._methods: dict[str, JsonRpcHandler] = {} + + def add_method(self, name: str, handler: JsonRpcHandler) -> None: + self._methods[name] = self._wrap_handler(handler) + + def method(self, name: str) -> Callable[[JsonRpcHandler], JsonRpcHandler]: + def decorator(handler: JsonRpcHandler) -> JsonRpcHandler: + self.add_method(name, handler) + return handler + + return decorator + + def dispatch(self, payload: bytes | str) -> str: + request_text = payload.decode("utf-8") if isinstance(payload, bytes) else payload + return dispatch(request_text, methods=self._methods) + + def _wrap_handler(self, handler: JsonRpcHandler) -> JsonRpcHandler: + @wraps(handler) + def wrapped(*args: Any, **kwargs: Any): + with redirect_stdout_to_stderr(): + result = handler(*args, **kwargs) + if isinstance(result, Left | Right): + return result + return Success(result) + + return wrapped diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/server.py b/ecos/runtime-adapter/ecos_runtime_adapter/server.py new file mode 100644 index 000000000..b0b4dd30e --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/server.py @@ -0,0 +1,174 @@ +from collections.abc import Callable + +import chipcompiler +from jsonrpcserver import Error + +from ecos_runtime_adapter import methods +from ecos_runtime_adapter.errors import RuntimeApiError +from ecos_runtime_adapter.requests import RequestValidationError, parse_request_model +from ecos_runtime_adapter.rpc_dispatch import RpcDispatcher +from ecos_runtime_adapter.workspace_api import WorkspaceRuntimeApi + +PROTOCOL_VERSION = 1 +ADAPTER_VERSION = 1 +BASE_CAPABILITIES = ( + "rpc.hello", + "rpc.ping", + "rpc.shutdown", + "runtime.v2", + "operation.events", + "runtime.adapter.v1", + "workspace-spec.v1", + "engineering-snapshot.v1", +) + +ERROR_CODES = { + "workspace_session_not_found": -32010, + "command_failed": -32020, + "invalid_request": -32602, +} + + +class RuntimeServer: + def __init__( + self, + api: WorkspaceRuntimeApi | None = None, + *, + persistent_db_enabled: bool = False, + ): + self.persistent_db_enabled = persistent_db_enabled + self.dispatcher = RpcDispatcher() + self.api = api or WorkspaceRuntimeApi(persistent_db_enabled=persistent_db_enabled) + self.should_exit = False + self._notification_sink: Callable[[str, dict], None] | None = None + set_event_publisher = getattr(self.api, "set_event_publisher", None) + if callable(set_event_publisher): + set_event_publisher(self._publish_runtime_event) + self._register_base_methods() + self._register_runtime_methods() + + @property + def capabilities(self) -> tuple[str, ...]: + return ( + *BASE_CAPABILITIES, + *methods.runtime_method_names( + persistent_db_enabled=self.persistent_db_enabled, + ), + ) + + def dispatch(self, payload: bytes | str) -> str: + return self.dispatcher.dispatch(payload) + + def set_notification_sink(self, sink: Callable[[str, dict], None] | None) -> None: + self._notification_sink = sink + + def _publish_runtime_event(self, event: dict) -> None: + sink = self._notification_sink + if sink is not None: + sink("runtime.event", _project_runtime_event(event)) + + def _register_base_methods(self) -> None: + self.dispatcher.add_method("rpc.hello", self._hello) + self.dispatcher.add_method("rpc.ping", self._ping) + self.dispatcher.add_method("rpc.shutdown", self._shutdown) + + def _hello(self, version: int): + if version != PROTOCOL_VERSION: + return Error( + -32001, + "unsupported_version", + {"supportedVersion": PROTOCOL_VERSION, "requestedVersion": version}, + ) + return { + "version": PROTOCOL_VERSION, + "adapterVersion": ADAPTER_VERSION, + "eccVersion": getattr(chipcompiler, "__version__", "unknown"), + "capabilities": list(self.capabilities), + } + + def _ping(self) -> dict: + return {"ok": True} + + def _shutdown(self) -> dict: + operations = getattr(self.api, "operations", None) + shutdown_barrier = getattr(operations, "shutdown_barrier", None) + barrier = shutdown_barrier() if callable(shutdown_barrier) else None + if barrier is not None: + return {"ok": False, "deferred": True, "shutdownBarrier": barrier} + self.should_exit = True + sessions = getattr(self.api, "sessions", None) + if sessions is not None and hasattr(sessions, "close_all"): + sessions.close_all() + return {"ok": True} + + def _register_runtime_methods(self) -> None: + for spec in methods.runtime_methods( + persistent_db_enabled=self.persistent_db_enabled, + ): + api_method = getattr(self.api, spec.handler_name, None) + if not callable(api_method): + raise TypeError( + f"runtime method {spec.method_name} handler {spec.handler_name} is not callable" + ) + self.dispatcher.add_method( + spec.method_name, + self._runtime_method_handler(spec, api_method), + ) + + def _runtime_method_handler(self, spec, api_method): + def handler(**params): + try: + request = parse_request_model(spec.request_model, params) + except RequestValidationError as exc: + return Error( + -32602, + "invalid_request", + {"message": exc.reason}, + ) + + try: + return api_method(request) + except RuntimeApiError as exc: + return Error( + ERROR_CODES.get(exc.code, -32000), + exc.code, + {"message": exc.message, **exc.data}, + ) + except Exception as exc: + return Error( + ERROR_CODES["command_failed"], + "command_failed", + {"message": str(exc)}, + ) + + return handler + + +def _project_runtime_event(event: dict) -> dict: + source_type = str(event.get("type", "")) + payload = {**event.get("payload", {}), "sourceType": source_type} + if source_type == "step.completed": + event_type = "workspace.committed" + elif source_type in {"step.started", "step.log", "subflow.stage", "operation.rerun_prepared"}: + event_type = "execution.progress" + else: + event_type = "operation.changed" + state = { + "operation.queued": "queued", + "operation.started": "running", + "operation.completed": "succeeded", + "operation.failed": "failed", + "operation.cancelled": "cancelled", + }.get(source_type) + if state: + payload["state"] = state + return { + **event, + "type": event_type, + "payload": payload, + **( + {"workspaceRevision": payload["workspaceRevision"]} + if event_type == "workspace.committed" and "workspaceRevision" in payload + else {} + ), + } diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/sessions.py b/ecos/runtime-adapter/ecos_runtime_adapter/sessions.py new file mode 100644 index 000000000..ae15065c3 --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/sessions.py @@ -0,0 +1,199 @@ +import shutil +import threading +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +@dataclass +class WorkspaceSession: + workspace_id: str + workspace_revision: int + directory: Path + workspace: Any + execution_readiness: dict[str, Any] = field(default_factory=lambda: {"ready": True}) + workspace_bindings: dict[str, Any] | None = None + db_handle: Any = None + layout_edit_session: "LayoutEditSession | None" = None + mutation_lock: threading.Lock = field(default_factory=threading.Lock) + + +@dataclass +class LayoutEditSession: + edit_session_id: str + workspace_id: str + step_name: str + workspace_step: Any + db_handle: Any + source_kind: str + source_paths: tuple[Path, ...] + source_fingerprint: str + geometry_output_dir: Path + revision: int = 0 + geometry_revision: int = 0 + dirty: bool = False + command_results: dict[str, dict[str, Any]] = field(default_factory=dict) + floorplan_plan: dict[str, Any] = field(default_factory=dict) + pdn_plan: dict[str, Any] = field(default_factory=dict) + config_patch: dict[str, Any] = field(default_factory=dict) + parameters_patch: dict[str, Any] = field(default_factory=dict) + requires_verilog: bool = False + used_floorplan_editor: bool = False + validation_diagnostics: list[dict[str, Any]] = field(default_factory=list) + + +class WorkspaceSessionNotFound(KeyError): + pass + + +def _close_db_handle(db_handle: Any) -> None: + close = getattr(db_handle, "close", None) + if callable(close): + close() + + +class WorkspaceSessionRegistry: + def __init__(self, db_releaser: Callable[[Any], None] | None = _close_db_handle): + self._next_id = 1 + self._sessions: dict[str, WorkspaceSession] = {} + self._sessions_by_directory: dict[Path, str] = {} + self._db_releaser = db_releaser + self._lock = threading.Lock() + + def create_session( + self, + directory: str | Path, + *, + workspace: Any, + workspace_id: str | None = None, + workspace_revision: int = 0, + execution_readiness: dict[str, Any] | None = None, + workspace_bindings: dict[str, Any] | None = None, + ) -> WorkspaceSession: + resolved_directory = Path(directory).resolve() + with self._lock: + existing_id = self._sessions_by_directory.get(resolved_directory) + if existing_id is not None: + self._remove_session(existing_id) + return self._create_session( + resolved_directory, + workspace=workspace, + workspace_id=workspace_id, + workspace_revision=workspace_revision, + execution_readiness=execution_readiness, + workspace_bindings=workspace_bindings, + ) + + def open_session( + self, + directory: str | Path, + *, + workspace: Any, + workspace_id: str | None = None, + workspace_revision: int = 0, + execution_readiness: dict[str, Any] | None = None, + workspace_bindings: dict[str, Any] | None = None, + ) -> WorkspaceSession: + resolved_directory = Path(directory).resolve() + with self._lock: + existing_id = self._sessions_by_directory.get(resolved_directory) + if existing_id is not None: + return self._sessions[existing_id] + + return self._create_session( + resolved_directory, + workspace=workspace, + workspace_id=workspace_id, + workspace_revision=workspace_revision, + execution_readiness=execution_readiness, + workspace_bindings=workspace_bindings, + ) + + def get_session(self, workspace_id: str) -> WorkspaceSession: + try: + return self._sessions[workspace_id] + except KeyError as exc: + raise WorkspaceSessionNotFound(workspace_id) from exc + + def close_session(self, workspace_id: str) -> None: + with self._lock: + self._remove_session(workspace_id) + + def close_all(self) -> None: + with self._lock: + for session in self._sessions.values(): + self._release_session_db(session) + self._release_layout_edit_session(session) + self._sessions.clear() + self._sessions_by_directory.clear() + + def release_session_db(self, session: WorkspaceSession) -> bool: + return self._release_session_db(session) + + def _create_session( + self, + directory: Path, + *, + workspace: Any, + workspace_id: str | None, + workspace_revision: int, + execution_readiness: dict[str, Any] | None, + workspace_bindings: dict[str, Any] | None, + ) -> WorkspaceSession: + if workspace_id is None: + workspace_id = f"workspace-{self._next_id}" + self._next_id += 1 + session = WorkspaceSession( + workspace_id=workspace_id, + workspace_revision=workspace_revision, + directory=directory, + workspace=workspace, + execution_readiness=execution_readiness or {"ready": True}, + workspace_bindings=workspace_bindings, + ) + self._sessions[workspace_id] = session + self._sessions_by_directory[directory] = workspace_id + return session + + def _remove_session(self, workspace_id: str) -> None: + session = self._sessions.pop(workspace_id, None) + if session is None: + raise WorkspaceSessionNotFound(workspace_id) + self._release_session_db(session) + self._release_layout_edit_session(session) + self._sessions_by_directory.pop(session.directory, None) + + def _release_session_db(self, session: WorkspaceSession) -> bool: + db_handle = session.db_handle + if db_handle is None: + return False + + session.db_handle = None + if self._db_releaser is not None: + self._db_releaser(db_handle) + return True + + def release_layout_edit_session(self, session: WorkspaceSession) -> bool: + return self._release_layout_edit_session(session) + + def _release_layout_edit_session(self, session: WorkspaceSession) -> bool: + layout_edit_session = session.layout_edit_session + if layout_edit_session is None: + return False + + session.layout_edit_session = None + _reset_layout_edit_geometry_session(layout_edit_session.db_handle) + if self._db_releaser is not None: + self._db_releaser(layout_edit_session.db_handle) + shutil.rmtree(layout_edit_session.geometry_output_dir.parent, ignore_errors=True) + return True + + +def _reset_layout_edit_geometry_session(db_handle: Any) -> None: + module = getattr(db_handle, "engine", None) + if module is None: + module = getattr(db_handle, "ecc_module", None) + reset_geometry_session = getattr(module, "reset_geometry_session", None) + if callable(reset_geometry_session): + reset_geometry_session() diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/stdio_server.py b/ecos/runtime-adapter/ecos_runtime_adapter/stdio_server.py new file mode 100644 index 000000000..6c35e9be3 --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/stdio_server.py @@ -0,0 +1,120 @@ +import os +import queue +import sys +import threading +from typing import BinaryIO + +from ecos_runtime_adapter.server import RuntimeServer +from ecos_runtime_adapter.transport import ( + ContentLengthDecoder, + TransportError, + encode_content_length_frame, +) + +_STOP = object() + + +class _ProtocolWriter: + """Serialize protocol output through an fd immune to tool stdio redirects.""" + + def __init__(self, output_stream: BinaryIO): + self._output_stream = output_stream + self._output_fd: int | None = None + try: + self._output_fd = os.dup(output_stream.fileno()) + except (AttributeError, OSError): + # BytesIO is used by unit tests and has no file descriptor. + self._output_fd = None + self._messages: queue.Queue[bytes | object] = queue.Queue() + self._thread = threading.Thread(target=self._write_loop, name="ecc-rpc-writer", daemon=True) + self._thread.start() + + def send_response(self, response: str) -> None: + self._messages.put(encode_content_length_frame(response)) + + def send_notification(self, method: str, params: dict) -> None: + import json + + payload = json.dumps( + {"jsonrpc": "2.0", "method": method, "params": params}, + separators=(",", ":"), + ) + self._messages.put(encode_content_length_frame(payload)) + + def close(self) -> None: + self._messages.put(_STOP) + self._thread.join() + if self._output_fd is not None: + os.close(self._output_fd) + self._output_fd = None + + def _write_loop(self) -> None: + while True: + message = self._messages.get() + if message is _STOP: + return + assert isinstance(message, bytes) + if self._output_fd is None: + self._output_stream.write(message) + self._output_stream.flush() + continue + _write_all(self._output_fd, message) + + +def _write_all(fd: int, data: bytes) -> None: + view = memoryview(data) + while view: + written = os.write(fd, view) + view = view[written:] + + +def run_stdio_server( + input_stream: BinaryIO, + output_stream: BinaryIO, + *, + server: RuntimeServer | None = None, + persistent_db_enabled: bool = False, +) -> int: + runtime_server = server or RuntimeServer(persistent_db_enabled=persistent_db_enabled) + decoder = ContentLengthDecoder() + writer = _ProtocolWriter(output_stream) + runtime_server.set_notification_sink(writer.send_notification) + + try: + while not runtime_server.should_exit: + chunk = _read_chunk(input_stream) + if not chunk: + break + try: + messages = decoder.feed(chunk) + except TransportError as exc: + print(f"transport error: {exc}", file=sys.stderr) + return 1 + + for message in messages: + response = runtime_server.dispatch(message) + if runtime_server.should_exit and not response: + break + if response: + writer.send_response(response) + if runtime_server.should_exit: + break + return 0 + finally: + runtime_server.set_notification_sink(None) + writer.close() + + +def _read_chunk(input_stream: BinaryIO) -> bytes: + read1 = getattr(input_stream, "read1", None) + if read1 is not None: + return read1(8192) + return input_stream.read(8192) + + +def main(*, persistent_db_enabled: bool = False) -> int: + return run_stdio_server( + sys.stdin.buffer, + sys.stdout.buffer, + persistent_db_enabled=persistent_db_enabled, + ) diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/transport.py b/ecos/runtime-adapter/ecos_runtime_adapter/transport.py new file mode 100644 index 000000000..666a99484 --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/transport.py @@ -0,0 +1,61 @@ +HEADER_SEPARATOR = b"\r\n\r\n" +DEFAULT_MAX_PAYLOAD_SIZE = 16 * 1024 * 1024 + + +class TransportError(ValueError): + """Raised when stdio framing is malformed before JSON-RPC dispatch.""" + + +def encode_content_length_frame(payload: bytes | str) -> bytes: + payload_bytes = payload.encode("utf-8") if isinstance(payload, str) else payload + return b"Content-Length: %d\r\n\r\n" % len(payload_bytes) + payload_bytes + + +class ContentLengthDecoder: + def __init__(self, *, max_payload_size: int = DEFAULT_MAX_PAYLOAD_SIZE): + self._buffer = bytearray() + self._max_payload_size = max_payload_size + + def feed(self, data: bytes) -> list[bytes]: + self._buffer.extend(data) + messages: list[bytes] = [] + + while True: + header_end = self._buffer.find(HEADER_SEPARATOR) + if header_end < 0: + return messages + + header = bytes(self._buffer[:header_end]).decode("ascii", errors="replace") + content_length = self._parse_content_length(header) + payload_start = header_end + len(HEADER_SEPARATOR) + payload_end = payload_start + content_length + if len(self._buffer) < payload_end: + return messages + + messages.append(bytes(self._buffer[payload_start:payload_end])) + del self._buffer[:payload_end] + + def _parse_content_length(self, header: str) -> int: + length_values: list[str] = [] + for line in header.split("\r\n"): + if not line: + continue + name, separator, value = line.partition(":") + if not separator: + raise TransportError("malformed header line before Content-Length") + if name.lower() == "content-length": + length_values.append(value.strip()) + + if len(length_values) != 1: + raise TransportError("exactly one Content-Length header is required") + + try: + content_length = int(length_values[0]) + except ValueError as exc: + raise TransportError("Content-Length must be an integer") from exc + + if content_length < 0: + raise TransportError("Content-Length must be non-negative") + if content_length > self._max_payload_size: + raise TransportError("Content-Length exceeds maximum payload size") + return content_length diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/workspace_api.py b/ecos/runtime-adapter/ecos_runtime_adapter/workspace_api.py new file mode 100644 index 000000000..6d695d9db --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/workspace_api.py @@ -0,0 +1,2098 @@ +import hashlib +import inspect +import json +import os +import shutil +import tempfile +import threading +from collections.abc import Callable +from copy import copy, deepcopy +from dataclasses import replace +from pathlib import Path +from typing import Any, TypeVar + +from chipcompiler.engine.workspace_flow import ( + build_flow_for_workspace, +) +from chipcompiler.engine.workspace_flow import ( + init_db_engine_for_workspace_step as _init_db_engine_for_workspace_step, +) +from chipcompiler.engine.workspace_flow import ( + success_state as _success_state, +) +from chipcompiler.engine.workspace_flow import ( + workspace_step_from_flow as _workspace_step_from_flow, +) +from chipcompiler.utility.path import path_is_within, stringify_paths + +from ecos_runtime_adapter.errors import RuntimeApiError +from ecos_runtime_adapter.operations import ( + RuntimeOperationConflict, + RuntimeOperationIdempotencyConflict, + RuntimeOperationManager, +) +from ecos_runtime_adapter.requests import ( + DbEnsureRequest, + DbReleaseRequest, + FloorplanEditInspectRequest, + FloorplanEditRunAutoRequest, + FloorplanEditValidateRequest, + FlowRunRequest, + FlowRunStepRequest, + LayoutEditApplyRequest, + LayoutEditBeginRequest, + LayoutEditDiscardRequest, + LayoutEditSaveRequest, + OperationIdRequest, + OperationStartFlowRequest, + OperationStartStepRequest, + WorkspaceExportSignoffRequest, + WorkspaceIdRequest, + WorkspaceInfoRequest, + WorkspaceInspectSignoffRequest, + WorkspaceMutationRequest, + WorkspaceRecoverInterruptedRequest, + WorkspaceSyncConfigRequest, +) +from ecos_runtime_adapter.sessions import ( + LayoutEditSession, + WorkspaceSession, + WorkspaceSessionRegistry, +) +from ecos_runtime_adapter.workspace_spec_api import WorkspaceSpecRuntimeMixin + +_T = TypeVar("_T") + + +class WorkspaceRuntimeApi(WorkspaceSpecRuntimeMixin): + def __init__( + self, + sessions: WorkspaceSessionRegistry | None = None, + *, + persistent_db_enabled: bool = False, + event_publisher: Callable[[dict[str, Any]], None] | None = None, + ): + self.persistent_db_enabled = persistent_db_enabled + self.sessions = sessions or WorkspaceSessionRegistry(db_releaser=_close_db_handle) + self._next_layout_edit_id = 1 + self._layout_edit_sessions: dict[str, LayoutEditSession] = {} + # ecc_tools_bin currently owns one process-global GeometryEditSession. + # Keep its native DB and derived GeometryStore exclusive until that + # session is reset, so a second workspace cannot replace the store + # underneath an active editor. + self._layout_edit_lock = threading.RLock() + self.operations = RuntimeOperationManager(event_publisher) + + def set_event_publisher(self, publisher: Callable[[dict[str, Any]], None] | None) -> None: + self.operations.set_publisher(publisher) + + def recover_interrupted(self, request: WorkspaceRecoverInterruptedRequest) -> dict: + from ecos_runtime_adapter.recovery import recover_interrupted_operation + + return self._with_session_mutation_lock( + request.workspace_id, + lambda session: recover_interrupted_operation( + session.workspace, + self.operations, + request.operation_id, + ), + ) + + def workspace_home(self, request: WorkspaceIdRequest) -> dict: + session = self._get_session(request.workspace_id) + path = Path(session.workspace.home.path).resolve() + if not path.exists(): + raise RuntimeApiError("command_failed", f"get home failed : {path}") + return {"path": str(path)} + + def workspace_info(self, request: WorkspaceInfoRequest) -> dict: + session = self._get_session(request.workspace_id) + workspace_step = _workspace_step_from_flow(session.workspace, request.step) + if workspace_step is None: + raise RuntimeApiError("command_failed", f"step not found: {request.step}") + + import chipcompiler.tools as tools_api + + info = tools_api.get_step_info( + workspace=session.workspace, + step=workspace_step, + id=request.info_id, + ) + return { + "step": request.step, + "id": request.info_id, + "info": stringify_paths(info or {}), + } + + def refresh_config(self, request: WorkspaceIdRequest) -> dict: + def refresh(session: WorkspaceSession) -> dict: + self._release_session_db(session) + self._refresh_workspace_config(session.workspace) + return {"directory": str(session.directory), "refreshed": True} + + return self._with_session_mutation_lock(request.workspace_id, refresh) + + def sync_config(self, request: WorkspaceSyncConfigRequest) -> dict: + def sync(session: WorkspaceSession) -> dict: + self._validate_workspace_revision( + session, + request.expected_workspace_revision, + ) + config_path = Path(request.config_path).resolve() + config_dir = session.directory / "config" + if not path_is_within(config_path, config_dir): + raise RuntimeApiError( + "invalid_request", + f"config path outside workspace config directory : {config_path}", + ) + + import chipcompiler.data as data_api + + parameters_changed = data_api.sync_workspace_config_to_parameters( + session.workspace, + config_path, + ) + refreshed = False + if parameters_changed: + self._release_session_db(session) + self._refresh_workspace_config(session.workspace) + self._commit_workspace_snapshot(session, "workspace.sync_config") + refreshed = True + return { + "directory": str(session.directory), + "configPath": str(config_path), + "parametersChanged": bool(parameters_changed), + "refreshed": refreshed, + "workspaceRevision": session.workspace_revision, + } + + return self._with_session_mutation_lock(request.workspace_id, sync) + + def reset_flow(self, request: WorkspaceMutationRequest) -> dict: + def reset(session: WorkspaceSession) -> dict: + self._validate_workspace_revision( + session, + request.expected_workspace_revision, + ) + self._release_session_db(session) + engine_flow = build_flow_for_workspace(session.workspace) + self._prepare_workspace_for_rerun(session.workspace, engine_flow) + revision = self._commit_workspace_snapshot(session, "workspace.reset") + return { + "directory": str(session.directory), + "workspaceRevision": revision, + } + + return self._with_session_mutation_lock(request.workspace_id, reset) + + def export_signoff(self, request: WorkspaceExportSignoffRequest) -> dict: + def export(session: WorkspaceSession) -> dict: + from chipcompiler.engine.signoff_export import ( + SignoffExportError, + export_signoff_package_archive, + inspect_signoff_package, + ) + + assessment = inspect_signoff_package(session.workspace) + if assessment.get("status") == "blocked": + raise RuntimeApiError( + "signoff_blocked", + "current ECC Signoff Assessment blocks export", + {"assessment": assessment}, + ) + try: + output_path = export_signoff_package_archive( + session.workspace, + request.output_path, + request.additional_files, + ) + except SignoffExportError as exc: + raise RuntimeApiError("command_failed", str(exc)) from exc + return {"outputPath": output_path} + + return self._with_session_mutation_lock(request.workspace_id, export) + + def inspect_signoff(self, request: WorkspaceInspectSignoffRequest) -> dict: + def inspect(session: WorkspaceSession) -> dict: + from chipcompiler.engine.signoff_export import inspect_signoff_package + + return inspect_signoff_package(session.workspace) + + return self._with_session_mutation_lock(request.workspace_id, inspect) + + def close_workspace(self, request: WorkspaceIdRequest) -> dict: + def close(session: WorkspaceSession) -> dict: + self._discard_layout_edit_session(session) + self.sessions.close_session(session.workspace_id) + return {"ok": True} + + return self._with_session_mutation_lock(request.workspace_id, close) + + def flow_run(self, request: FlowRunRequest) -> dict: + return self._flow_run(request) + + def _flow_run( + self, + request: FlowRunRequest, + *, + observer=None, + preserve_user_inputs: bool = False, + ) -> dict: + def run(session: WorkspaceSession) -> dict: + self._ensure_execution_ready(session) + self._validate_workspace_revision( + session, + request.expected_workspace_revision, + ) + should_capture = self._should_capture_session_db(session) + previous_db = session.db_handle if should_capture else None + if request.rerun and should_capture: + self._release_session_db(session) + previous_db = None + + engine_flow = self._build_flow_for_session( + session, + attach_session_db=should_capture and not request.rerun, + ) + if request.rerun: + affected_steps = list(getattr(engine_flow, "workspace_steps", [])) + self._prepare_workspace_for_rerun( + session.workspace, + engine_flow, + preserve_user_inputs=preserve_user_inputs, + ) + self._notify_rerun_prepared( + observer, + affected_steps, + scope="flow", + ) + try: + from chipcompiler.engine import ExecutionPlan, execute + + execution = execute( + engine_flow, + ExecutionPlan(intent="rerun" if request.rerun else "run"), + event_sink=observer, + ) + finally: + if should_capture: + self._capture_flow_db( + session, + engine_flow, + previous_handle=previous_db, + ) + else: + self._close_transient_flow_db(engine_flow) + if not execution.succeeded: + raise RuntimeApiError( + "command_failed", + f"run flow failed : {session.directory}", + {"rerun": request.rerun}, + ) + return {"rerun": request.rerun} + + return self._with_session_mutation_lock(request.workspace_id, run) + + def flow_run_step(self, request: FlowRunStepRequest) -> dict: + return self._flow_run_step(request) + + def _flow_run_step( + self, + request: FlowRunStepRequest, + *, + observer=None, + reset_dependents: bool = False, + ) -> dict: + def run_step(session: WorkspaceSession) -> dict: + self._ensure_execution_ready(session) + self._validate_workspace_revision( + session, + request.expected_workspace_revision, + ) + should_capture = self._should_capture_session_db(session) + previous_db = session.db_handle if should_capture else None + if request.rerun and should_capture: + self._release_session_db(session) + previous_db = None + + engine_flow = self._build_flow_for_session( + session, + attach_session_db=should_capture and not request.rerun, + ) + if request.rerun: + if session.layout_edit_session is not None: + raise RuntimeApiError( + "layout_edit_active", + "close the rendered layout before rerunning this step", + ) + self._refresh_workspace_config(session.workspace) + + workspace_step = engine_flow.get_workspace_step(request.step) + if workspace_step is None: + raise RuntimeApiError("command_failed", f"step not found: {request.step}") + if request.rerun: + affected_steps = self._rerun_affected_steps( + engine_flow, + workspace_step, + reset_dependents=reset_dependents, + ) + self._prepare_steps_for_rerun( + session.workspace, + engine_flow, + affected_steps, + ) + self._notify_rerun_prepared( + observer, + affected_steps, + scope="step", + target_step=workspace_step.name, + ) + + try: + step_already_succeeded = not request.rerun and engine_flow.check_state( + name=workspace_step.name, + tool=workspace_step.tool, + state=_success_state(), + ) + if not step_already_succeeded: + _init_db_engine_for_workspace_step(engine_flow, workspace_step) + from chipcompiler.engine import ExecutionPlan, execute + + execution = execute( + engine_flow, + ExecutionPlan( + intent="rerun" if request.rerun else "run", + step_id=workspace_step.name, + ), + event_sink=observer, + ) + finally: + if should_capture: + self._capture_flow_db( + session, + engine_flow, + previous_handle=previous_db, + ) + else: + self._close_transient_flow_db(engine_flow) + + state_value = execution.state + result = {"step": request.step, "state": state_value} + if state_value != "Success": + raise RuntimeApiError( + "command_failed", + f"run step {request.step} failed with state {state_value}", + result, + ) + return result + + return self._with_session_mutation_lock(request.workspace_id, run_step) + + def start_flow_operation(self, request: OperationStartFlowRequest) -> dict: + self._require_gui_operation_origin(request.origin) + session = self._get_session(request.workspace_id) + try: + with session.mutation_lock: + + def validate_start() -> None: + self._ensure_execution_ready(session) + self._validate_workspace_revision( + session, + request.expected_workspace_revision, + ) + + return self.operations.start( + workspace_id=request.workspace_id, + kind="flow", + origin=request.origin, + rerun=request.rerun, + step="", + idempotency_key=request.idempotency_key, + workspace_revision=request.expected_workspace_revision, + ledger_path=session.directory / "home" / "runtime-commands.json", + precondition=validate_start, + snapshot_committer=lambda step, state, error: self._commit_step_snapshot( + session, + step, + state, + error, + ), + runner=lambda observer: self._flow_run( + FlowRunRequest( + workspace_id=request.workspace_id, + expected_workspace_revision=request.expected_workspace_revision, + rerun=request.rerun, + ), + observer=observer, + preserve_user_inputs=request.rerun, + ), + ) + except RuntimeOperationIdempotencyConflict as exc: + raise RuntimeApiError("idempotency_conflict", str(exc)) from exc + except RuntimeOperationConflict as exc: + raise RuntimeApiError("operation_conflict", str(exc)) from exc + + def start_step_operation(self, request: OperationStartStepRequest) -> dict: + self._require_gui_operation_origin(request.origin) + session = self._get_session(request.workspace_id) + try: + with session.mutation_lock: + + def validate_start() -> None: + self._ensure_execution_ready(session) + self._validate_workspace_revision( + session, + request.expected_workspace_revision, + ) + + return self.operations.start( + workspace_id=request.workspace_id, + kind="step", + origin=request.origin, + rerun=request.rerun, + step=request.step, + idempotency_key=request.idempotency_key, + workspace_revision=request.expected_workspace_revision, + command_input={"resetDependents": request.reset_dependents}, + ledger_path=session.directory / "home" / "runtime-commands.json", + precondition=validate_start, + snapshot_committer=lambda step, state, error: self._commit_step_snapshot( + session, + step, + state, + error, + ), + runner=lambda observer: self._flow_run_step( + FlowRunStepRequest( + workspace_id=request.workspace_id, + step=request.step, + expected_workspace_revision=request.expected_workspace_revision, + rerun=request.rerun, + ), + observer=observer, + reset_dependents=request.reset_dependents, + ), + ) + except RuntimeOperationIdempotencyConflict as exc: + raise RuntimeApiError("idempotency_conflict", str(exc)) from exc + except RuntimeOperationConflict as exc: + raise RuntimeApiError("operation_conflict", str(exc)) from exc + + def operation_status(self, request: OperationIdRequest) -> dict: + try: + return self.operations.operation_status(request.operation_id) + except KeyError as exc: + raise RuntimeApiError( + "invalid_request", + f"operation not found: {request.operation_id}", + ) from exc + + def cancel_operation(self, request: OperationIdRequest) -> dict: + try: + return self.operations.request_cancel(request.operation_id) + except KeyError as exc: + raise RuntimeApiError( + "invalid_request", + f"operation not found: {request.operation_id}", + ) from exc + + def workspace_snapshot(self, request: WorkspaceIdRequest) -> dict: + session = self._get_session(request.workspace_id) + flow_data = getattr(getattr(session.workspace, "flow", None), "data", {}) + raw_steps = flow_data.get("steps", []) if isinstance(flow_data, dict) else [] + steps = [ + { + "name": str(step.get("name", "")), + "tool": str(step.get("tool", "")), + "state": str(step.get("state", "Unstart")), + "runtime": str(step.get("runtime", "")), + "peakMemory": step.get("peak memory (mb)", 0), + } + for step in raw_steps + if isinstance(step, dict) + ] + from chipcompiler.data.parameter import ( + parameters_have_chip_identity, + reload_parameter, + ) + + parameters = getattr(session.workspace, "parameters", None) + parameters_data = getattr(parameters, "data", {}) or {} + if not parameters_have_chip_identity(parameters_data): + parameter_path = getattr(parameters, "path", None) + if parameter_path: + session.workspace.parameters = reload_parameter(parameter_path, parameters) + parameters_data = session.workspace.parameters.data or {} + + home_data = deepcopy(getattr(session.workspace.home, "data", {}) or {}) + if not str(home_data.get("parameters", "")).strip(): + parameter_path = getattr(session.workspace.parameters, "path", None) + if parameter_path is None: + parameter_path = Path(session.directory) / "home" / "parameters.json" + home_data["parameters"] = str(parameter_path) + + return { + **self.operations.workspace_snapshot(request.workspace_id), + "engineeringSnapshot": self._read_engineering_snapshot(session), + "directory": str(session.directory), + "flow": {"steps": steps}, + "home": stringify_paths(home_data), + "parameters": stringify_paths(deepcopy(parameters_data)), + } + + def engineering_snapshot(self, request: WorkspaceIdRequest) -> dict: + return self._read_engineering_snapshot(self._get_session(request.workspace_id)) + + def db_ensure(self, request: DbEnsureRequest) -> dict: + self._require_persistent_db() + + def ensure(session: WorkspaceSession) -> dict: + db_handle = session.db_handle + if _db_handle_is_initialized(db_handle): + return _db_ensure_result( + workspace_id=session.workspace_id, + step=request.step, + active=True, + reused=True, + ) + + engine_flow = build_flow_for_workspace(session.workspace) + if db_handle is not None: + engine_flow.engine_db = db_handle + + if request.step: + workspace_step = engine_flow.get_workspace_step(request.step) + if workspace_step is None: + raise RuntimeApiError( + "command_failed", + f"step not found: {request.step}", + ) + initialized = _init_db_engine_for_workspace_step(engine_flow, workspace_step) + else: + initialized = engine_flow.init_db_engine() + + flow_db = getattr(engine_flow, "engine_db", None) + active = bool(initialized and _db_handle_is_initialized(flow_db)) + session.db_handle = flow_db if active else None + return _db_ensure_result( + workspace_id=session.workspace_id, + step=request.step, + active=active, + reused=False, + ) + + return self._with_session_mutation_lock(request.workspace_id, ensure) + + def db_release(self, request: DbReleaseRequest) -> dict: + self._require_persistent_db() + + def release(session: WorkspaceSession) -> dict: + released = self._release_session_db(session) + return {"workspaceId": session.workspace_id, "released": released} + + return self._with_session_mutation_lock(request.workspace_id, release) + + def layout_edit_begin(self, request: LayoutEditBeginRequest) -> dict: + self._require_persistent_db() + + def begin(session: WorkspaceSession) -> dict: + with self._layout_edit_lock: + active_session = session.layout_edit_session + if active_session is not None: + if active_session.step_name != request.step: + raise RuntimeApiError( + "layout_edit_active", + "layout edit session already active for step: " + f"{active_session.step_name}", + {"editSessionId": active_session.edit_session_id}, + ) + if ( + request.expected_source_fingerprint + and request.expected_source_fingerprint != active_session.source_fingerprint + ): + raise RuntimeApiError( + "source_changed", + "layout edit source fingerprint does not match", + { + "expectedSourceFingerprint": request.expected_source_fingerprint, + "actualSourceFingerprint": active_session.source_fingerprint, + }, + ) + return _layout_edit_begin_result(active_session, reused=True) + + active_session = next(iter(self._layout_edit_sessions.values()), None) + if active_session is not None: + raise RuntimeApiError( + "layout_edit_active", + "layout edit session already active for another workspace", + { + "editSessionId": active_session.edit_session_id, + "workspaceId": active_session.workspace_id, + }, + ) + + engine_flow = build_flow_for_workspace(session.workspace) + workspace_step = engine_flow.get_workspace_step(request.step) + if workspace_step is None: + raise RuntimeApiError("command_failed", f"step not found: {request.step}") + + source_kind, source_paths = _layout_edit_source(workspace_step) + source_fingerprint = _artifact_fingerprint(source_paths) + if ( + request.expected_source_fingerprint + and request.expected_source_fingerprint != source_fingerprint + ): + raise RuntimeApiError( + "source_changed", + "layout edit source fingerprint does not match", + { + "expectedSourceFingerprint": request.expected_source_fingerprint, + "actualSourceFingerprint": source_fingerprint, + }, + ) + + edit_step = _layout_edit_workspace_step( + workspace_step, + source_kind=source_kind, + source_paths=source_paths, + ) + initialized = _init_db_engine_for_workspace_step(engine_flow, edit_step) + db_handle = getattr(engine_flow, "engine_db", None) + if not initialized or not _db_handle_is_initialized(db_handle): + _close_db_handle(db_handle) + raise RuntimeApiError( + "command_failed", + f"failed to initialize layout edit DB for step: {request.step}", + ) + + module = _db_engine_module(db_handle) + initialize_geometry = getattr(module, "initialize_geometry_session", None) + if not callable(initialize_geometry) or not initialize_geometry(): + _close_db_handle(db_handle) + raise RuntimeApiError( + "command_failed", + f"failed to initialize layout geometry for step: {request.step}", + ) + + edit_session_id = self._new_layout_edit_id() + geometry_output_dir = ( + Path(tempfile.mkdtemp(prefix=f"ecc-{edit_session_id}-geometry-")) / "geometry-0" + ) + try: + _write_layout_edit_geometry_snapshot(module, geometry_output_dir) + except Exception: + _close_db_handle(db_handle) + shutil.rmtree(geometry_output_dir.parent, ignore_errors=True) + raise + + edit_session = LayoutEditSession( + edit_session_id=edit_session_id, + workspace_id=session.workspace_id, + step_name=request.step, + workspace_step=workspace_step, + db_handle=db_handle, + source_kind=source_kind, + source_paths=source_paths, + source_fingerprint=source_fingerprint, + geometry_output_dir=geometry_output_dir, + ) + session.layout_edit_session = edit_session + self._layout_edit_sessions[edit_session.edit_session_id] = edit_session + return _layout_edit_begin_result(edit_session, reused=False) + + return self._with_session_mutation_lock(request.workspace_id, begin) + + def layout_edit_apply(self, request: LayoutEditApplyRequest) -> dict: + def apply(session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: + return _apply_layout_edit_operation( + edit_session, + command_id=request.command_id, + base_revision=request.base_revision, + operation=request.operation, + ) + + return self._with_layout_edit_session_mutation_lock(request.edit_session_id, apply) + + def floorplan_edit_inspect(self, request: FloorplanEditInspectRequest) -> dict: + def inspect(_session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: + module = _db_engine_module(edit_session.db_handle) + module_state = _floorplan_editor_inspect(module) + return { + "editSessionId": edit_session.edit_session_id, + "revision": edit_session.revision, + "geometryRevision": edit_session.geometry_revision, + "geometryManifestPath": str(edit_session.geometry_output_dir / "geometry.manifest"), + "dirty": edit_session.dirty, + "floorplanPlan": deepcopy(edit_session.floorplan_plan), + "pdnPlan": deepcopy(edit_session.pdn_plan), + "diagnostics": deepcopy(edit_session.validation_diagnostics), + "state": module_state, + } + + return self._with_layout_edit_session_mutation_lock(request.edit_session_id, inspect) + + def floorplan_edit_run_auto(self, request: FloorplanEditRunAutoRequest) -> dict: + def run_auto(_session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: + if not isinstance(request.request, dict): + raise RuntimeApiError("invalid_request", "request must be an object") + return _apply_layout_edit_operation( + edit_session, + command_id=request.command_id, + base_revision=request.base_revision, + operation={"kind": "run_auto", "request": request.request}, + ) + + return self._with_layout_edit_session_mutation_lock(request.edit_session_id, run_auto) + + def floorplan_edit_validate(self, request: FloorplanEditValidateRequest) -> dict: + def validate(_session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: + scope = request.scope.strip() if isinstance(request.scope, str) else "" + if not scope: + raise RuntimeApiError("invalid_request", "scope must be a non-empty string") + module = _db_engine_module(edit_session.db_handle) + result = _floorplan_editor_validate(module, scope) + edit_session.validation_diagnostics = _floorplan_diagnostics(result) + return { + "editSessionId": edit_session.edit_session_id, + "revision": edit_session.revision, + "scope": scope, + "valid": _floorplan_validation_ok(result), + "diagnostics": deepcopy(edit_session.validation_diagnostics), + } + + return self._with_layout_edit_session_mutation_lock(request.edit_session_id, validate) + + def layout_edit_save(self, request: LayoutEditSaveRequest) -> dict: + def save(session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: + self._validate_workspace_revision( + session, + request.expected_workspace_revision, + ) + _validate_layout_edit_revision(request.expected_revision, "expected_revision") + if request.expected_revision != edit_session.revision: + raise RuntimeApiError( + "version_conflict", + "layout edit revision does not match", + { + "expectedRevision": request.expected_revision, + "actualRevision": edit_session.revision, + }, + ) + if not edit_session.dirty: + return { + **_layout_edit_save_result(edit_session, saved=False), + "workspaceRevision": session.workspace_revision, + } + + current_fingerprint = _artifact_fingerprint(edit_session.source_paths) + if current_fingerprint != edit_session.source_fingerprint: + raise RuntimeApiError( + "source_changed", + "layout edit source changed after the session began", + { + "expectedSourceFingerprint": edit_session.source_fingerprint, + "actualSourceFingerprint": current_fingerprint, + }, + ) + + module = _db_engine_module(edit_session.db_handle) + if edit_session.used_floorplan_editor: + validation = _floorplan_editor_validate(module, "all") + edit_session.validation_diagnostics = _floorplan_diagnostics(validation) + if not _floorplan_validation_ok(validation): + raise RuntimeApiError( + "floorplan_validation_failed", + "floorplan edit validation failed", + {"diagnostics": deepcopy(edit_session.validation_diagnostics)}, + ) + _merge_floorplan_export_intent( + edit_session, _floorplan_editor_export_intent(module) + ) + + artifacts = _publish_layout_edit_artifacts(edit_session, session.workspace) + output_db = _path_or_none( + _workspace_step_output_value(edit_session.workspace_step, "db") + ) + if output_db is None: + raise RuntimeApiError("command_failed", "layout edit output DB is missing") + edit_session.source_kind = "db" + edit_session.source_paths = (output_db,) + edit_session.source_fingerprint = _artifact_fingerprint(edit_session.source_paths) + edit_session.dirty = False + revision = self._commit_workspace_snapshot(session, "layout.saved") + return { + **_layout_edit_save_result(edit_session, saved=True, artifacts=artifacts), + "workspaceRevision": revision, + } + + return self._with_layout_edit_session_mutation_lock(request.edit_session_id, save) + + def layout_edit_discard(self, request: LayoutEditDiscardRequest) -> dict: + def discard(session: WorkspaceSession, edit_session: LayoutEditSession) -> dict: + dirty = edit_session.dirty + self._discard_layout_edit_session(session) + return { + "editSessionId": request.edit_session_id, + "discarded": True, + "dirty": dirty, + } + + return self._with_layout_edit_session_mutation_lock(request.edit_session_id, discard) + + def _require_persistent_db(self) -> None: + if not self.persistent_db_enabled: + raise RuntimeApiError("command_failed", "persistent_db_disabled") + + @staticmethod + def _require_gui_operation_origin(origin: str) -> None: + if origin != "gui": + raise RuntimeApiError("invalid_request", "operation origin must be gui") + + def _new_layout_edit_id(self) -> str: + edit_session_id = f"layout-edit-{self._next_layout_edit_id}" + self._next_layout_edit_id += 1 + return edit_session_id + + def _with_layout_edit_session_mutation_lock( + self, + edit_session_id: str, + operation: Callable[[WorkspaceSession, LayoutEditSession], _T], + ) -> _T: + edit_session = self._layout_edit_sessions.get(edit_session_id) + if edit_session is None: + raise RuntimeApiError( + "layout_edit_session_not_found", + f"layout edit session not found: {edit_session_id}", + ) + + def run(session: WorkspaceSession) -> _T: + if session.layout_edit_session is not edit_session: + self._layout_edit_sessions.pop(edit_session_id, None) + raise RuntimeApiError( + "layout_edit_session_not_found", + f"layout edit session not found: {edit_session_id}", + ) + return operation(session, edit_session) + + return self._with_session_mutation_lock(edit_session.workspace_id, run) + + def _discard_layout_edit_session(self, session: WorkspaceSession) -> bool: + with self._layout_edit_lock: + edit_session = session.layout_edit_session + if edit_session is None: + return False + self._layout_edit_sessions.pop(edit_session.edit_session_id, None) + return self.sessions.release_layout_edit_session(session) + + def _release_session_db(self, session: WorkspaceSession) -> bool: + return self.sessions.release_session_db(session) + + def _should_capture_session_db(self, session: WorkspaceSession) -> bool: + return self.persistent_db_enabled and _db_handle_is_initialized(session.db_handle) + + def _build_flow_for_session( + self, + session: WorkspaceSession, + *, + attach_session_db: bool, + ): + engine_flow = build_flow_for_workspace(session.workspace) + if attach_session_db: + engine_flow.engine_db = session.db_handle + return engine_flow + + def _capture_flow_db( + self, + session: WorkspaceSession, + engine_flow, + *, + previous_handle, + ) -> None: + flow_db = getattr(engine_flow, "engine_db", None) + if _db_handle_is_initialized(flow_db): + session.db_handle = flow_db + if previous_handle is not None and previous_handle is not flow_db: + _close_db_handle(previous_handle) + return + + session.db_handle = None + if previous_handle is not None: + _close_db_handle(previous_handle) + + def _close_transient_flow_db(self, engine_flow) -> None: + _close_db_handle(getattr(engine_flow, "engine_db", None)) + + def _with_session_mutation_lock( + self, + workspace_id: str, + operation: Callable[[WorkspaceSession], _T], + ) -> _T: + session = self._get_session(workspace_id) + with session.mutation_lock: + return operation(session) + + def _refresh_workspace_config(self, workspace) -> None: + import chipcompiler.data as data_api + + data_api.refresh_workspace_config(workspace) + + def _prepare_workspace_for_rerun( + self, + workspace, + engine_flow, + *, + preserve_user_inputs: bool = False, + ) -> None: + import chipcompiler.data as data_api + + data_api.prepare_workspace_for_rerun( + workspace, + engine_flow, + preserve_user_inputs=preserve_user_inputs, + ) + + @staticmethod + def _rerun_affected_steps(engine_flow, workspace_step, *, reset_dependents: bool): + if not reset_dependents: + return [workspace_step] + workspace_steps = list(getattr(engine_flow, "workspace_steps", [])) + try: + start_index = workspace_steps.index(workspace_step) + except ValueError: + return [workspace_step] + return workspace_steps[start_index:] + + @staticmethod + def _notify_rerun_prepared( + observer, + workspace_steps, + *, + scope: str, + target_step: str = "", + ) -> None: + callback = getattr(observer, "on_rerun_prepared", None) + if callback is None: + return + callback( + affected_steps=[str(getattr(step, "name", "")) for step in workspace_steps], + scope=scope, + target_step=target_step, + ) + + @staticmethod + def _prepare_step_for_rerun(workspace, engine_flow, workspace_step) -> None: + WorkspaceRuntimeApi._prepare_steps_for_rerun( + workspace, + engine_flow, + [workspace_step], + ) + + @staticmethod + def _prepare_steps_for_rerun(workspace, engine_flow, workspace_steps) -> None: + workspace_root = Path(workspace.directory).resolve() + unique_steps = [] + known_step_keys = set() + for workspace_step in workspace_steps: + key = ( + str(getattr(workspace_step, "name", "")), + str(getattr(workspace_step, "tool", "")), + ) + if key in known_step_keys: + continue + known_step_keys.add(key) + unique_steps.append(workspace_step) + + artifact_directories = [] + known_directories = set() + for workspace_step in unique_steps: + for directory in WorkspaceRuntimeApi._step_artifact_dirs(workspace_step): + resolved = WorkspaceRuntimeApi._validate_step_artifact_dir( + workspace_root, + directory, + workspace_step.name, + ) + if resolved in known_directories: + continue + known_directories.add(resolved) + artifact_directories.append((workspace_step.name, directory)) + + for step_name, directory in artifact_directories: + WorkspaceRuntimeApi._clear_step_artifact_dir( + workspace_root, + directory, + step_name, + ) + + updated_record = False + for workspace_step in unique_steps: + record = engine_flow.get_step(workspace_step.name, workspace_step.tool) + if record is None: + continue + record.update( + { + "state": "Unstart", + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + ) + updated_record = True + if updated_record: + engine_flow.save() + + for workspace_step in unique_steps: + WorkspaceRuntimeApi._reset_step_subflow(workspace_step) + WorkspaceRuntimeApi._reset_step_checklist(workspace_step) + + @staticmethod + def _reset_step_subflow(workspace_step) -> None: + from chipcompiler.utility import json_read, json_write + + subflow = getattr(workspace_step, "subflow", None) + path = getattr(subflow, "path", None) + if not path: + return + subflow_path = Path(path) + data = json_read(subflow_path) + steps = data.get("steps", []) if isinstance(data, dict) else [] + if not isinstance(steps, list): + return + for step in steps: + if not isinstance(step, dict): + continue + step.update( + { + "state": "Unstart", + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + ) + json_write(subflow_path, {"path": str(subflow_path), "steps": steps}) + subflow.steps = steps + + @staticmethod + def _reset_step_checklist(workspace_step) -> None: + from chipcompiler.data import Checklist + + checklist = getattr(workspace_step, "checklist", None) + path = getattr(checklist, "path", None) + if not path: + return + checklist_path = Path(path) + Checklist(checklist_path).replace([]) + checklist.checklist = [] + + @staticmethod + def _step_artifact_dirs(step) -> tuple[Path, ...]: + directories: list[Path] = [] + for field in ("output", "data", "feature", "analysis", "report", "log"): + value = getattr(step, field, {}) + directory = value.get("dir") if isinstance(value, dict) else getattr(value, "dir", None) + if directory: + directories.append(Path(directory)) + return tuple(dict.fromkeys(directories)) + + @staticmethod + def _clear_step_artifact_dir( + workspace_root: Path, + directory: Path, + step_name: str, + ) -> None: + WorkspaceRuntimeApi._validate_step_artifact_dir(workspace_root, directory, step_name) + if directory.exists(): + if not directory.is_dir(): + raise RuntimeApiError( + "command_failed", + f"step artifact is not a directory: {step_name}", + ) + shutil.rmtree(directory) + directory.mkdir(parents=True, exist_ok=True) + + @staticmethod + def _validate_step_artifact_dir( + workspace_root: Path, + directory: Path, + step_name: str, + ) -> Path: + resolved = directory.resolve() + if ( + resolved == workspace_root + or not path_is_within(resolved, workspace_root) + or directory.is_symlink() + ): + raise RuntimeApiError( + "command_failed", + f"step artifact escapes workspace: {step_name}", + ) + if directory.exists() and not directory.is_dir(): + raise RuntimeApiError( + "command_failed", + f"step artifact is not a directory: {step_name}", + ) + return resolved + + +def _layout_edit_begin_result(edit_session: LayoutEditSession, *, reused: bool) -> dict: + return { + "editSessionId": edit_session.edit_session_id, + "workspaceId": edit_session.workspace_id, + "step": edit_session.step_name, + "source": edit_session.source_kind, + "sourceFingerprint": edit_session.source_fingerprint, + "geometryManifestPath": str(edit_session.geometry_output_dir / "geometry.manifest"), + "revision": edit_session.revision, + "geometryRevision": edit_session.geometry_revision, + "dirty": edit_session.dirty, + "reused": reused, + } + + +def _layout_edit_save_result( + edit_session: LayoutEditSession, + *, + saved: bool, + artifacts: dict[str, str] | None = None, +) -> dict: + if artifacts is None: + artifacts = _layout_edit_published_artifacts(edit_session.workspace_step) + return { + "editSessionId": edit_session.edit_session_id, + "revision": edit_session.revision, + "geometryRevision": edit_session.geometry_revision, + "dirty": edit_session.dirty, + "saved": saved, + "sourceFingerprint": edit_session.source_fingerprint, + "artifacts": artifacts, + } + + +def _layout_edit_published_artifacts(workspace_step) -> dict: + geometry_dir = _path_or_none(_workspace_step_output_value(workspace_step, "geometry")) + geometry_manifest = _path_or_none( + _workspace_step_output_value(workspace_step, "geometry_manifest") + ) + if geometry_manifest is None and geometry_dir is not None: + geometry_manifest = geometry_dir / "geometry.manifest" + return { + "defPath": _path_text(_workspace_step_output_value(workspace_step, "def")), + "dbPath": _path_text(_workspace_step_output_value(workspace_step, "db")), + "gdsPath": _path_text(_workspace_step_output_value(workspace_step, "gds")), + "geometryManifestPath": str(geometry_manifest) if geometry_manifest else "", + } + + +def _layout_edit_source(workspace_step) -> tuple[str, tuple[Path, ...]]: + output_db = _path_or_none(_workspace_step_output_value(workspace_step, "db")) + if output_db is not None and output_db.is_dir(): + return "db", (output_db,) + + output_def = _existing_layout_def_path(_workspace_step_output_value(workspace_step, "def")) + if output_def is not None: + return "def", (output_def,) + + raise RuntimeApiError( + "command_failed", + f"layout edit source missing for step: {getattr(workspace_step, 'name', '')}", + ) + + +def _layout_edit_workspace_step( + workspace_step, + *, + source_kind: str, + source_paths: tuple[Path, ...], +): + edit_input = copy(getattr(workspace_step, "input", {})) + output_def = _existing_layout_def_path(_workspace_step_output_value(workspace_step, "def")) + if isinstance(edit_input, dict): + edit_input["db"] = source_paths[0] if source_kind == "db" else None + edit_input["def"] = output_def + edit_step = copy(workspace_step) + edit_step.input = edit_input + return edit_step + + edit_input.db = source_paths[0] if source_kind == "db" else None + edit_input.def_ = output_def + return replace(workspace_step, input=edit_input) + + +_FLOORPLAN_EDITOR_OPERATION_KINDS = frozenset( + { + "set_floorplan_outline", + "replace_tracks", + "upsert_io_pin_port", + "upsert_blockage", + "delete_blockage", + "upsert_instance_halo", + "delete_instance_halo", + "pdn.plan.patch", + "pdn.manual_segment.upsert", + "pdn.manual_segment.delete", + "pdn.manual_via.upsert", + "pdn.manual_via.delete", + "run_auto", + } +) + + +def _apply_layout_edit_operation( + edit_session: LayoutEditSession, + *, + command_id: object, + base_revision: object, + operation: object, +) -> dict: + if not isinstance(command_id, str) or not command_id.strip(): + raise RuntimeApiError("invalid_request", "missing required field: command_id") + + previous_result = edit_session.command_results.get(command_id) + if previous_result is not None: + return previous_result + + _validate_layout_edit_revision(base_revision, "base_revision") + if base_revision != edit_session.revision: + raise RuntimeApiError( + "version_conflict", + "layout edit revision does not match", + { + "expectedRevision": base_revision, + "actualRevision": edit_session.revision, + }, + ) + if not isinstance(operation, dict): + raise RuntimeApiError("invalid_request", "operation must be an object") + + kind = operation.get("kind") + if kind == "place_instance": + result = _apply_layout_edit_place_instance(edit_session, command_id, operation) + else: + result = _apply_floorplan_editor_operation(edit_session, command_id, operation) + edit_session.command_results[command_id] = result + return result + + +def _apply_layout_edit_place_instance( + edit_session: LayoutEditSession, + command_id: str, + operation: dict[str, Any], +) -> dict: + placement = _layout_edit_place_instance_operation(operation) + module = _db_engine_module(edit_session.db_handle) + place_instance = getattr(module, "place_instance", None) + if not callable(place_instance): + raise RuntimeApiError("command_failed", "place_instance is unavailable") + + accepted = place_instance( + inst_name=placement["inst_name"], + llx=placement["llx"], + lly=placement["lly"], + orient=placement["orient"], + cellmaster=placement["cellmaster"], + source=placement["source"], + placement_status=placement["placement_status"], + create_if_missing=placement["create_if_missing"], + ) + if not accepted: + raise RuntimeApiError( + "placement_rejected", + "place_instance rejected the requested placement", + {"instanceName": placement["inst_name"]}, + ) + + geometry_delta = _sync_layout_edit_instance_geometry(module, placement["inst_name"]) + geometry_manifest_path = _advance_layout_edit_geometry_snapshot(edit_session, module) + edit_session.revision += 1 + edit_session.geometry_revision += 1 + edit_session.dirty = True + if placement["create_if_missing"]: + edit_session.requires_verilog = True + return { + "editSessionId": edit_session.edit_session_id, + "commandId": command_id, + "revision": edit_session.revision, + "geometryRevision": edit_session.geometry_revision, + "geometryManifestPath": str(geometry_manifest_path), + "dirty": True, + "operation": { + "kind": "place_instance", + "instanceName": placement["inst_name"], + "origin": {"x": placement["llx"], "y": placement["lly"]}, + "orient": placement["orient"], + }, + "geometryDelta": geometry_delta, + } + + +def _apply_floorplan_editor_operation( + edit_session: LayoutEditSession, + command_id: str, + operation: dict[str, Any], +) -> dict: + kind = operation.get("kind") + if not isinstance(kind, str) or kind not in _FLOORPLAN_EDITOR_OPERATION_KINDS: + raise RuntimeApiError("invalid_request", "unsupported layout edit operation") + + module = _db_engine_module(edit_session.db_handle) + editor_result = _floorplan_editor_apply(module, operation) + if not _floorplan_editor_accepted(editor_result): + diagnostics = _floorplan_diagnostics(editor_result) + raise RuntimeApiError( + "floorplan_rejected", + "floorplan editor rejected the requested operation", + {"operationKind": kind, "diagnostics": diagnostics}, + ) + + model_patch = _floorplan_model_patch(editor_result) + _merge_floorplan_model_patch(edit_session, model_patch) + diagnostics = _floorplan_diagnostics(editor_result) + edit_session.validation_diagnostics = diagnostics + changed = bool(editor_result.get("changed", True)) + geometry_delta = _floorplan_geometry_delta(editor_result) + geometry_manifest_path = edit_session.geometry_output_dir / "geometry.manifest" + if changed: + geometry_manifest_path = _advance_layout_edit_geometry_snapshot(edit_session, module) + edit_session.revision += 1 + edit_session.geometry_revision += 1 + edit_session.dirty = True + edit_session.used_floorplan_editor = True + if _floorplan_bool(editor_result, "instancesChanged", "instances_changed") or _floorplan_bool( + model_patch, + "instancesChanged", + "instances_changed", + ): + edit_session.requires_verilog = True + + return { + "editSessionId": edit_session.edit_session_id, + "commandId": command_id, + "revision": edit_session.revision, + "geometryRevision": edit_session.geometry_revision, + "geometryManifestPath": str(geometry_manifest_path), + "dirty": edit_session.dirty, + "operation": {"kind": kind}, + "affectedRefs": _floorplan_affected_refs(editor_result), + "geometryDelta": geometry_delta, + "modelPatch": model_patch, + "diagnostics": diagnostics, + "changed": changed, + } + + +def _advance_layout_edit_geometry_snapshot(edit_session: LayoutEditSession, module) -> Path: + next_geometry_output_dir = ( + edit_session.geometry_output_dir.parent / f"geometry-{edit_session.geometry_revision + 1}" + ) + geometry_manifest_path = _write_layout_edit_geometry_snapshot(module, next_geometry_output_dir) + previous_geometry_output_dir = edit_session.geometry_output_dir + edit_session.geometry_output_dir = next_geometry_output_dir + shutil.rmtree(previous_geometry_output_dir, ignore_errors=True) + return geometry_manifest_path + + +def _floorplan_editor_apply(module, operation: dict[str, Any]) -> dict[str, Any]: + apply = getattr(module, "floorplan_editor_apply", None) + if not callable(apply): + apply = getattr(module, "floorplan_edit_apply", None) + if not callable(apply): + raise RuntimeApiError("command_failed", "floorplan editor is unavailable") + payload = deepcopy(operation) + result = _call_floorplan_editor_apply(apply, payload) + if not isinstance(result, dict): + raise RuntimeApiError("command_failed", "floorplan editor returned an invalid result") + return result + + +def _call_floorplan_editor_apply(apply, payload: dict[str, Any]): + try: + signature = inspect.signature(apply) + except (TypeError, ValueError): + return apply(request=payload) + + parameters = signature.parameters.values() + accepts_keyword_request = "request" in signature.parameters or any( + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters + ) + if accepts_keyword_request: + return apply(request=payload) + return apply(payload) + + +def _floorplan_editor_inspect(module) -> dict[str, Any]: + inspect = getattr(module, "floorplan_editor_inspect", None) + if not callable(inspect): + inspect = getattr(module, "floorplan_edit_inspect", None) + if not callable(inspect): + return {} + result = inspect() + return deepcopy(result) if isinstance(result, dict) else {} + + +def _floorplan_editor_validate(module, scope: str) -> dict[str, Any]: + validate = getattr(module, "floorplan_editor_validate", None) + if not callable(validate): + validate = getattr(module, "floorplan_validate", None) + if not callable(validate): + return {"ok": True, "diagnostics": []} + try: + result = validate(scope=scope) + except TypeError: + result = validate(scope) + if not isinstance(result, dict): + raise RuntimeApiError("command_failed", "floorplan validation returned an invalid result") + return result + + +def _floorplan_editor_export_intent(module) -> dict[str, Any]: + export_intent = getattr(module, "floorplan_editor_export_intent", None) + if not callable(export_intent): + export_intent = getattr(module, "floorplan_export_intent", None) + if not callable(export_intent): + raise RuntimeApiError("command_failed", "floorplan editor export is unavailable") + result = export_intent() + if not isinstance(result, dict): + raise RuntimeApiError( + "command_failed", "floorplan editor export returned an invalid result" + ) + if not _floorplan_editor_accepted(result): + raise RuntimeApiError( + "command_failed", + "floorplan editor failed to export edit intent", + {"diagnostics": _floorplan_diagnostics(result)}, + ) + return result + + +def _floorplan_editor_accepted(result: dict[str, Any]) -> bool: + return bool(result.get("accepted", result.get("ok", False))) + + +def _floorplan_validation_ok(result: dict[str, Any]) -> bool: + return bool(result.get("ok", result.get("accepted", False))) + + +def _floorplan_diagnostics(result: dict[str, Any]) -> list[dict[str, Any]]: + raw = result.get("diagnostics", []) + if not isinstance(raw, list): + return [] + diagnostics = [] + for item in raw: + if isinstance(item, dict): + diagnostics.append(deepcopy(item)) + elif isinstance(item, str): + diagnostics.append({"message": item}) + return diagnostics + + +def _floorplan_affected_refs(result: dict[str, Any]) -> list[Any]: + refs = result.get("affectedRefs", result.get("affected_refs", [])) + return deepcopy(refs) if isinstance(refs, list) else [] + + +def _floorplan_geometry_delta(result: dict[str, Any]) -> dict[str, Any]: + delta = result.get("geometryDelta", result.get("geometry_delta", {})) + if not isinstance(delta, dict): + delta = {} + return { + "ok": bool(delta.get("ok", True)), + "snapshotRequired": bool( + delta.get( + "snapshotRequired", + delta.get( + "snapshot_required", + result.get("snapshotRequired", result.get("snapshot_required", False)), + ), + ) + ), + "updatedShapeCount": _geometry_delta_count(delta, "updatedShapeCount"), + "insertedShapeCount": _geometry_delta_count(delta, "insertedShapeCount"), + "deletedShapeCount": _geometry_delta_count(delta, "deletedShapeCount"), + "missingShapeCount": _geometry_delta_count(delta, "missingShapeCount"), + "events": deepcopy(delta.get("events", [])) + if isinstance(delta.get("events", []), list) + else [], + } + + +def _floorplan_model_patch(result: dict[str, Any]) -> dict[str, Any]: + patch = result.get("modelPatch", result.get("model_patch", {})) + if patch is None: + return {} + if not isinstance(patch, dict): + raise RuntimeApiError("command_failed", "floorplan editor returned an invalid model patch") + return deepcopy(patch) + + +def _floorplan_bool(data: dict[str, Any], *keys: str) -> bool: + return any(bool(data.get(key, False)) for key in keys) + + +def _merge_floorplan_model_patch( + edit_session: LayoutEditSession, model_patch: dict[str, Any] +) -> None: + floorplan_plan = _floorplan_patch_mapping(model_patch, "floorplanPlan", "floorplan_plan") + pdn_plan = _floorplan_patch_mapping(model_patch, "pdnPlan", "pdn_plan") + config_patch = _floorplan_patch_mapping(model_patch, "configPatch", "config_patch") + parameters_patch = _floorplan_patch_mapping( + model_patch, + "parametersPatch", + "parameters_patch", + ) + _deep_merge(edit_session.floorplan_plan, floorplan_plan) + _deep_merge(edit_session.pdn_plan, pdn_plan) + _deep_merge(edit_session.config_patch, config_patch) + _deep_merge(edit_session.parameters_patch, parameters_patch) + + +def _merge_floorplan_export_intent(edit_session: LayoutEditSession, intent: dict[str, Any]) -> None: + nested_intent = intent.get("intent") + if isinstance(nested_intent, dict): + intent = nested_intent + _merge_floorplan_model_patch(edit_session, intent) + if _floorplan_bool(intent, "requiresVerilog", "requires_verilog"): + edit_session.requires_verilog = True + + +def _floorplan_patch_mapping(data: dict[str, Any], *keys: str) -> dict[str, Any]: + present = [key for key in keys if key in data] + if len(present) > 1: + raise RuntimeApiError("command_failed", f"duplicate floorplan model patch field: {keys[0]}") + if not present: + return {} + value = data[present[0]] + if not isinstance(value, dict): + raise RuntimeApiError( + "command_failed", f"floorplan model patch field must be an object: {keys[0]}" + ) + return deepcopy(value) + + +def _deep_merge(target: dict[str, Any], patch: dict[str, Any]) -> None: + for key, value in patch.items(): + current = target.get(key) + if isinstance(current, dict) and isinstance(value, dict): + _deep_merge(current, value) + else: + target[key] = deepcopy(value) + + +def _layout_edit_place_instance_operation(operation: object) -> dict[str, Any]: + if not isinstance(operation, dict): + raise RuntimeApiError("invalid_request", "operation must be an object") + if operation.get("kind") != "place_instance": + raise RuntimeApiError("invalid_request", "unsupported layout edit operation") + + inst_name = _layout_edit_text(operation, "inst_name", "instName") + cellmaster = _layout_edit_optional_text(operation, "cellmaster", "cellMaster") + source = _layout_edit_optional_text(operation, "source") + placement_status = ( + _layout_edit_optional_text( + operation, + "placement_status", + "placementStatus", + ) + or "preserve" + ) + create_if_missing = _layout_edit_optional_bool( + operation, + "create_if_missing", + "createIfMissing", + default=False, + ) + orient = _layout_edit_optional_text(operation, "orient") + if not orient and (placement_status != "preserve" or create_if_missing): + raise RuntimeApiError("invalid_request", "missing required operation field: orient") + return { + "inst_name": inst_name, + "llx": _layout_edit_integer(operation, "llx"), + "lly": _layout_edit_integer(operation, "lly"), + "orient": orient, + "cellmaster": cellmaster, + "source": source, + "placement_status": placement_status, + "create_if_missing": create_if_missing, + } + + +def _layout_edit_text(operation: dict, *keys: str) -> str: + value = _layout_edit_value(operation, *keys) + if not isinstance(value, str) or not value.strip(): + raise RuntimeApiError("invalid_request", f"missing required operation field: {keys[0]}") + return value + + +def _layout_edit_optional_text(operation: dict, *keys: str) -> str: + value = _layout_edit_value(operation, *keys, default="") + if not isinstance(value, str): + raise RuntimeApiError("invalid_request", f"operation field must be a string: {keys[0]}") + return value + + +def _layout_edit_integer(operation: dict, *keys: str) -> int: + value = _layout_edit_value(operation, *keys) + if isinstance(value, bool) or not isinstance(value, int): + raise RuntimeApiError("invalid_request", f"operation field must be an integer: {keys[0]}") + return value + + +def _layout_edit_optional_bool( + operation: dict, + *keys: str, + default: bool, +) -> bool: + value = _layout_edit_value(operation, *keys, default=default) + if not isinstance(value, bool): + raise RuntimeApiError("invalid_request", f"operation field must be a boolean: {keys[0]}") + return value + + +def _layout_edit_value(operation: dict, *keys: str, default: object = None): + present = [key for key in keys if key in operation] + if len(present) > 1: + raise RuntimeApiError("invalid_request", f"duplicate operation field: {keys[0]}") + return operation[present[0]] if present else default + + +def _validate_layout_edit_revision(value: object, field_name: str) -> None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise RuntimeApiError("invalid_request", f"{field_name} must be a non-negative integer") + + +def _sync_layout_edit_instance_geometry(module, inst_name: str) -> dict: + sync_instance = getattr(module, "sync_instance_geometry", None) + if not callable(sync_instance): + return { + "ok": False, + "snapshotRequired": True, + "updatedShapeCount": 0, + "insertedShapeCount": 0, + "deletedShapeCount": 0, + "missingShapeCount": 0, + "events": [], + } + + result = sync_instance(inst_name) + if not isinstance(result, dict): + return { + "ok": False, + "snapshotRequired": True, + "updatedShapeCount": 0, + "insertedShapeCount": 0, + "deletedShapeCount": 0, + "missingShapeCount": 0, + "events": [], + } + return { + "ok": bool(result.get("ok", False)), + "snapshotRequired": bool(result.get("snapshotRequired", False)), + "updatedShapeCount": _geometry_delta_count(result, "updatedShapeCount"), + "insertedShapeCount": _geometry_delta_count(result, "insertedShapeCount"), + "deletedShapeCount": _geometry_delta_count(result, "deletedShapeCount"), + "missingShapeCount": _geometry_delta_count(result, "missingShapeCount"), + "events": result.get("events", []) if isinstance(result.get("events", []), list) else [], + } + + +def _write_layout_edit_geometry_snapshot(module, output_dir: Path) -> Path: + save_snapshot = getattr(module, "geometry_session_snapshot_save", None) + if not callable(save_snapshot): + save_snapshot = getattr(module, "geometry_snapshot_save", None) + if not callable(save_snapshot) or not save_snapshot(output_dir=str(output_dir)): + raise RuntimeApiError("command_failed", "failed to write layout edit geometry snapshot") + manifest = output_dir / "geometry.manifest" + if not manifest.is_file(): + raise RuntimeApiError("command_failed", "layout edit geometry manifest is missing") + return manifest + + +def _geometry_delta_count(delta: dict, key: str) -> int: + value = delta.get(key, 0) + return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0 + + +def _publish_layout_edit_artifacts(edit_session: LayoutEditSession, workspace) -> dict[str, str]: + targets = _layout_edit_publish_targets(edit_session.workspace_step) + staged_workspace_data = _layout_edit_workspace_staging(edit_session, workspace) + targets.update(staged_workspace_data["targets"]) + stage_parent = _layout_edit_stage_parent(targets.values()) + stage_root = Path( + tempfile.mkdtemp(prefix=f".layout-edit-{edit_session.edit_session_id}-", dir=stage_parent) + ) + staged = {key: stage_root / key / target.name for key, target in targets.items()} + try: + module = _db_engine_module(edit_session.db_handle) + for staged_path in staged.values(): + staged_path.parent.mkdir(parents=True, exist_ok=True) + module.def_save(def_path=str(staged["def"])) + module.save_data(path=str(staged["db"])) + module.gds_save(output_path=str(staged["gds"])) + if not module.geometry_snapshot_save(output_dir=str(staged["geometry"])): + raise RuntimeApiError("command_failed", "failed to export layout geometry snapshot") + _stage_layout_edit_workspace_json(staged, staged_workspace_data) + _stage_layout_edit_verilog(module, staged, edit_session) + _validate_layout_edit_staging(staged, targets) + _validate_layout_edit_workspace_staging(staged, staged_workspace_data) + _publish_staged_layout_edit_artifacts(stage_root, staged, targets) + except RuntimeApiError: + raise + except Exception as exc: + raise RuntimeApiError("command_failed", f"failed to publish layout edit: {exc}") from exc + finally: + shutil.rmtree(stage_root, ignore_errors=True) + + _apply_layout_edit_workspace_staging(workspace, staged_workspace_data) + artifacts = _layout_edit_published_artifacts(edit_session.workspace_step) + artifacts.update(staged_workspace_data["artifacts"]) + return artifacts + + +def _layout_edit_workspace_staging( + edit_session: LayoutEditSession, + workspace, +) -> dict[str, Any]: + targets: dict[str, Path] = {} + json_data: dict[str, dict[str, Any]] = {} + artifacts: dict[str, str] = {} + result: dict[str, Any] = { + "targets": targets, + "json": json_data, + "artifacts": artifacts, + "parametersData": None, + "flowData": None, + } + + if edit_session.used_floorplan_editor: + config_target = _floorplan_config_target(workspace) + config_data = _read_layout_edit_json(config_target) + _deep_merge(config_data, edit_session.config_patch) + config_data["FloorplanPlan"] = deepcopy(edit_session.floorplan_plan) + config_data["PdnPlan"] = deepcopy(edit_session.pdn_plan) + targets["config"] = config_target + json_data["config"] = config_data + artifacts["configPath"] = str(config_target) + + if edit_session.parameters_patch: + parameter_target = _workspace_path(workspace, "parameters", "path") + if parameter_target is None: + raise RuntimeApiError("command_failed", "workspace parameters path is missing") + parameter_data = deepcopy(getattr(getattr(workspace, "parameters", None), "data", {}) or {}) + if not isinstance(parameter_data, dict): + raise RuntimeApiError("command_failed", "workspace parameters are invalid") + _deep_merge(parameter_data, edit_session.parameters_patch) + targets["parameters"] = parameter_target + json_data["parameters"] = parameter_data + artifacts["parametersPath"] = str(parameter_target) + result["parametersData"] = parameter_data + + if edit_session.requires_verilog: + verilog_target = _path_or_none( + _workspace_step_output_value(edit_session.workspace_step, "verilog") + ) + if verilog_target is None: + raise RuntimeApiError("command_failed", "layout edit output Verilog is missing") + targets["verilog"] = verilog_target + artifacts["verilogPath"] = str(verilog_target) + + flow_target = _workspace_path(workspace, "flow", "path") + flow_data = deepcopy(getattr(getattr(workspace, "flow", None), "data", {}) or {}) + if ( + flow_target is not None + and isinstance(flow_data, dict) + and _mark_placement_and_later_stale(flow_data, edit_session.step_name) + ): + targets["flow"] = flow_target + json_data["flow"] = flow_data + artifacts["flowPath"] = str(flow_target) + result["flowData"] = flow_data + + return result + + +def _floorplan_config_target(workspace) -> Path: + config = getattr(workspace, "config", {}) + if isinstance(config, dict): + config_target = _path_or_none(config.get("Floorplan")) + if config_target is not None: + return config_target + workspace_directory = _path_or_none(getattr(workspace, "directory", None)) + if workspace_directory is None: + raise RuntimeApiError("command_failed", "workspace directory is missing") + return workspace_directory / "config" / "floorplan_ecc.json" + + +def _workspace_path(workspace, owner_name: str, path_name: str) -> Path | None: + owner = getattr(workspace, owner_name, None) + return _path_or_none(getattr(owner, path_name, None)) + + +def _read_layout_edit_json(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeApiError("command_failed", f"failed to read JSON artifact: {path}") from exc + if not isinstance(data, dict): + raise RuntimeApiError("command_failed", f"JSON artifact must contain an object: {path}") + return data + + +def _stage_layout_edit_workspace_json( + staged: dict[str, Path], + workspace_staging: dict[str, Any], +) -> None: + for key, data in workspace_staging["json"].items(): + staged_path = staged[key] + staged_path.parent.mkdir(parents=True, exist_ok=True) + staged_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _stage_layout_edit_verilog( + module, staged: dict[str, Path], edit_session: LayoutEditSession +) -> None: + if "verilog" not in staged: + return + save_verilog = getattr(module, "verilog_save", None) + if not callable(save_verilog): + raise RuntimeApiError("command_failed", "verilog_save is unavailable") + staged["verilog"].parent.mkdir(parents=True, exist_ok=True) + save_verilog(output_verilog=str(staged["verilog"])) + + +def _validate_layout_edit_workspace_staging( + staged: dict[str, Path], + workspace_staging: dict[str, Any], +) -> None: + for key in workspace_staging["json"]: + if not staged[key].is_file(): + raise RuntimeApiError("command_failed", f"layout edit staged {key} is missing") + _read_layout_edit_json(staged[key]) + if "verilog" in staged and not staged["verilog"].is_file(): + raise RuntimeApiError("command_failed", "layout edit staged Verilog is missing") + + +def _apply_layout_edit_workspace_staging(workspace, workspace_staging: dict[str, Any]) -> None: + parameters_data = workspace_staging["parametersData"] + if parameters_data is not None: + workspace.parameters.data = parameters_data + flow_data = workspace_staging["flowData"] + if flow_data is not None: + workspace.flow.data = flow_data + + +def _mark_placement_and_later_stale(flow_data: dict[str, Any], step_name: str) -> bool: + if step_name != "Floorplan": + return False + steps = flow_data.get("steps") + if not isinstance(steps, list): + return False + placement_index = next( + ( + index + for index, step in enumerate(steps) + if isinstance(step, dict) and step.get("name") == "place" + ), + None, + ) + if placement_index is None: + return False + changed = False + for step in steps[placement_index:]: + if not isinstance(step, dict): + continue + desired = { + "state": "Unstart", + "runtime": "", + "peak memory (mb)": 0, + } + for key, value in desired.items(): + if step.get(key) != value: + step[key] = value + changed = True + return changed + + +def _layout_edit_publish_targets(workspace_step) -> dict[str, Path]: + targets = { + key: _path_or_none(_workspace_step_output_value(workspace_step, key)) + for key in ("def", "db", "gds", "geometry") + } + missing = [key for key, path in targets.items() if path is None] + if missing: + raise RuntimeApiError( + "command_failed", + f"layout edit output paths missing: {', '.join(missing)}", + ) + return targets + + +def _layout_edit_stage_parent(paths) -> Path: + parents = [str(path.parent) for path in paths] + common_parent = Path(os.path.commonpath(parents)) + common_parent.mkdir(parents=True, exist_ok=True) + return common_parent + + +def _validate_layout_edit_staging(staged: dict[str, Path], targets: dict[str, Path]) -> None: + manifest = _geometry_manifest_path(staged["geometry"], targets["geometry"], targets) + missing = [] + if not staged["def"].is_file(): + missing.append("def") + if not staged["gds"].is_file(): + missing.append("gds") + if not staged["db"].is_dir() or not any(staged["db"].iterdir()): + missing.append("db") + if not manifest.is_file(): + missing.append("geometry_manifest") + if missing: + raise RuntimeApiError( + "command_failed", + f"layout edit staging validation failed: {', '.join(missing)}", + ) + + +def _geometry_manifest_path( + geometry_dir: Path, + target_geometry_dir: Path, + targets: dict[str, Path], +) -> Path: + target_manifest = targets.get("geometry_manifest") + if target_manifest is None: + return geometry_dir / "geometry.manifest" + try: + return geometry_dir / target_manifest.relative_to(target_geometry_dir) + except ValueError: + return geometry_dir / "geometry.manifest" + + +def _publish_staged_layout_edit_artifacts( + stage_root: Path, + staged: dict[str, Path], + targets: dict[str, Path], +) -> None: + artifact_keys = tuple(targets) + backup_dir = stage_root / "backup" + backup_dir.mkdir() + journal_path = stage_root / "publish.journal" + journal_path.write_text( + json.dumps({"state": "prepared", "artifacts": artifact_keys}), + encoding="utf-8", + ) + backups: list[str] = [] + published: list[str] = [] + try: + for key in artifact_keys: + target = targets[key] + if target.exists() or target.is_symlink(): + os.replace(target, backup_dir / key) + backups.append(key) + journal_path.write_text( + json.dumps({"state": "backed_up", "artifacts": backups}), + encoding="utf-8", + ) + for key in artifact_keys: + target = targets[key] + target.parent.mkdir(parents=True, exist_ok=True) + os.replace(staged[key], target) + published.append(key) + journal_path.write_text( + json.dumps({"state": "published", "artifacts": published}), + encoding="utf-8", + ) + except Exception: + for key in reversed(published): + _remove_layout_edit_path(targets[key]) + for key in reversed(backups): + os.replace(backup_dir / key, targets[key]) + raise + + +def _remove_layout_edit_path(path: Path) -> None: + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink(missing_ok=True) + + +def _db_engine_module(db_handle): + module = getattr(db_handle, "engine", None) + if module is None: + module = getattr(db_handle, "ecc_module", None) + if module is None: + raise RuntimeApiError("command_failed", "layout edit DB module is unavailable") + return module + + +def _path_or_none(value: object) -> Path | None: + if value is None or value == "": + return None + return Path(value) + + +def _workspace_step_output_value(workspace_step, key: str): + output = getattr(workspace_step, "output", {}) + if isinstance(output, dict): + return output.get(key) + return getattr(output, "def_" if key == "def" else key, None) + + +def _path_text(value: object) -> str: + path = _path_or_none(value) + return str(path) if path is not None else "" + + +def _existing_layout_def_path(value: object) -> Path | None: + path = _path_or_none(value) + if path is None: + return None + candidates = (path, Path(f"{path}.gz")) + for candidate in candidates: + if candidate.is_file(): + return candidate + return None + + +def _artifact_fingerprint(paths: tuple[Path, ...]) -> str: + digest = hashlib.sha256() + for path in paths: + resolved_path = path.resolve() + digest.update(str(resolved_path).encode("utf-8")) + if resolved_path.is_dir(): + for item in sorted(resolved_path.rglob("*")): + if not item.is_file(): + continue + stat = item.stat() + digest.update(str(item.relative_to(resolved_path)).encode("utf-8")) + digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("ascii")) + elif resolved_path.is_file(): + stat = resolved_path.stat() + digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("ascii")) + else: + digest.update(b"missing") + return digest.hexdigest() + + +def _db_ensure_result( + *, + workspace_id: str, + step: str, + active: bool, + reused: bool, +) -> dict: + return { + "workspaceId": workspace_id, + "enabled": True, + "active": active, + "reused": reused, + "step": step, + } + + +def _db_handle_is_initialized(db_handle) -> bool: + return db_handle is not None and db_handle.has_init() + + +def _close_db_handle(db_handle) -> None: + close = getattr(db_handle, "close", None) + if callable(close): + close() diff --git a/ecos/runtime-adapter/ecos_runtime_adapter/workspace_spec_api.py b/ecos/runtime-adapter/ecos_runtime_adapter/workspace_spec_api.py new file mode 100644 index 000000000..989ee78cd --- /dev/null +++ b/ecos/runtime-adapter/ecos_runtime_adapter/workspace_spec_api.py @@ -0,0 +1,369 @@ +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +from ecos_runtime_adapter.errors import RuntimeApiError +from ecos_runtime_adapter.requests import ( + WorkspaceCreateRequest, + WorkspaceCreateV1Request, + WorkspaceOpenRequest, + WorkspaceOpenV1Request, + WorkspaceSpecValidateRequest, + WorkspaceUpdateRequest, +) +from ecos_runtime_adapter.sessions import ( + WorkspaceSession, + WorkspaceSessionNotFound, +) + + +class WorkspaceSpecRuntimeMixin: + def describe_workspace_spec(self, _request) -> dict: + from chipcompiler.engine import describe_workspace_spec + + return describe_workspace_spec() + + def validate_workspace_spec(self, request: WorkspaceSpecValidateRequest) -> dict: + from chipcompiler.engine import validate_workspace_spec + + return validate_workspace_spec(request.workspace_spec, request.workspace_bindings) + + def create_workspace( + self, + request: WorkspaceCreateRequest | WorkspaceCreateV1Request, + ) -> dict: + if isinstance(request, WorkspaceCreateV1Request): + return self._create_workspace_v1(request) + if not request.directory: + raise RuntimeApiError("invalid_request", "missing required field: directory") + + temp_filelist_dir = None + input_filelist = request.filelist + if not input_filelist: + rtl_paths = _normalize_rtl_list(request.rtl_list or []) + if rtl_paths: + temp_filelist_dir = tempfile.TemporaryDirectory(prefix="ecc-workspace-filelist-") + input_filelist = _write_filelist(temp_filelist_dir.name, rtl_paths) + + import chipcompiler.data as data_api + + pdk_json, pdk_json_temp_path = _materialize_inline_pdk_json(request.pdk_json) + try: + workspace = data_api.create_workspace( + directory=request.directory, + pdk=request.pdk, + parameters=request.parameters or {}, + origin_def=request.origin_def, + origin_verilog=request.origin_verilog, + input_filelist=input_filelist, + pdk_root=request.pdk_root, + pdk_json=pdk_json, + sdc=request.sdc, + flow_config=request.flow_config, + ) + finally: + if pdk_json_temp_path is not None: + pdk_json_temp_path.unlink(missing_ok=True) + if temp_filelist_dir is not None: + temp_filelist_dir.cleanup() + if workspace is None: + raise RuntimeApiError( + "command_failed", + f"create workspace failed : {os.path.abspath(request.directory)}", + ) + + from chipcompiler.engine.workspace_flow import build_flow_for_workspace + + build_flow_for_workspace(workspace) + snapshot = self._create_engineering_snapshot(workspace) + session = self.sessions.create_session( + workspace.directory, + workspace=workspace, + workspace_id=snapshot["workspaceId"], + workspace_revision=snapshot["workspaceRevision"], + ) + return _workspace_session_result(session) + + def _create_workspace_v1(self, request: WorkspaceCreateV1Request) -> dict: + from chipcompiler.engine import WorkspaceLifecycleError, create_workspace_from_spec + + try: + workspace = create_workspace_from_spec( + request.target_directory, + request.workspace_spec, + request.workspace_bindings, + request.command_id, + ) + except WorkspaceLifecycleError as exc: + raise RuntimeApiError(exc.code, str(exc), exc.details) from exc + snapshot = self._read_engineering_snapshot(workspace) + session = self.sessions.create_session( + workspace.directory, + workspace=workspace, + workspace_id=snapshot["workspaceId"], + workspace_revision=snapshot["workspaceRevision"], + execution_readiness={"ready": True}, + workspace_bindings=request.workspace_bindings, + ) + return _workspace_session_result(session) + + def open_workspace( + self, + request: WorkspaceOpenRequest | WorkspaceOpenV1Request, + ) -> dict: + workspace = self._load_workspace(request.directory) + snapshot = self._ensure_engineering_snapshot(workspace) + bindings = ( + request.workspace_bindings if isinstance(request, WorkspaceOpenV1Request) else None + ) + if isinstance(request, WorkspaceOpenV1Request): + from chipcompiler.engine import assess_execution_readiness + + readiness = assess_execution_readiness(workspace.directory, bindings) + if readiness.get("ready") is True: + from chipcompiler.engine import apply_workspace_bindings + + apply_workspace_bindings(workspace, bindings) + else: + readiness = {"ready": True} + + from chipcompiler.engine.workspace_flow import build_flow_for_workspace + + build_flow_for_workspace(workspace, create_step_workspaces=False) + session = self.sessions.open_session( + workspace.directory, + workspace=workspace, + workspace_id=snapshot["workspaceId"], + workspace_revision=snapshot["workspaceRevision"], + execution_readiness=readiness, + workspace_bindings=bindings, + ) + session.execution_readiness = readiness + session.workspace_bindings = bindings + self.operations.load_workspace_ledger( + session.workspace_id, + session.directory / "home" / "runtime-commands.json", + ) + return _workspace_session_result(session) + + def update_workspace(self, request: WorkspaceUpdateRequest) -> dict: + from chipcompiler.engine import WorkspaceLifecycleError, update_workspace_from_spec + + session = self._get_session(request.workspace_id) + with session.mutation_lock: + self._validate_workspace_revision( + session, + request.expected_workspace_revision, + ) + if self.operations.has_active_workspace(session.workspace_id): + raise RuntimeApiError( + "operation_conflict", + "Workspace has an active Operation", + ) + self._release_session_db(session) + try: + workspace = update_workspace_from_spec( + session.directory, + request.expected_workspace_revision, + request.workspace_spec, + request.workspace_bindings, + request.command_id, + ) + except WorkspaceLifecycleError as exc: + raise RuntimeApiError(exc.code, str(exc), exc.details) from exc + snapshot = self._read_engineering_snapshot(workspace) + session.workspace = workspace + session.workspace_revision = snapshot["workspaceRevision"] + session.workspace_bindings = request.workspace_bindings + session.execution_readiness = {"ready": True} + return _workspace_session_result(session) + + def _load_workspace(self, directory: str): + if not directory: + raise RuntimeApiError("invalid_request", "missing required field: directory") + if not _looks_like_old_workspace(directory): + raise RuntimeApiError( + "invalid_request", + f"invalid workspace directory: {directory}", + ) + + import chipcompiler.data as data_api + + workspace = data_api.load_workspace(directory=directory) + if workspace is None: + raise RuntimeApiError( + "command_failed", + f"load workspace failed : {directory}", + ) + return workspace + + def _get_session(self, workspace_id: str) -> WorkspaceSession: + try: + return self.sessions.get_session(workspace_id) + except WorkspaceSessionNotFound as exc: + raise RuntimeApiError( + "workspace_session_not_found", + f"workspace session not found: {workspace_id}", + ) from exc + + @staticmethod + def _validate_workspace_revision( + session: WorkspaceSession, + expected_workspace_revision: int, + ) -> None: + if expected_workspace_revision == session.workspace_revision: + return + raise RuntimeApiError( + "revision_conflict", + "Workspace Revision does not match", + { + "expectedRevision": expected_workspace_revision, + "actualRevision": session.workspace_revision, + }, + ) + + @staticmethod + def _ensure_execution_ready(session: WorkspaceSession) -> None: + readiness = session.execution_readiness + if readiness.get("ready") is True: + return + code = str(readiness.get("code") or "pdk_binding_missing") + raise RuntimeApiError(code, "Workspace is not ready for execution", readiness) + + @staticmethod + def _ensure_engineering_snapshot(workspace) -> dict: + from chipcompiler.engine.snapshot import ( + EngineeringSnapshotError, + ensure_engineering_snapshot, + ) + + try: + return ensure_engineering_snapshot(workspace) + except EngineeringSnapshotError as exc: + raise RuntimeApiError("workspace_migration_required", str(exc)) from exc + + @staticmethod + def _create_engineering_snapshot(workspace) -> dict: + from chipcompiler.engine.snapshot import ( + EngineeringSnapshotError, + create_engineering_snapshot, + ) + + try: + return create_engineering_snapshot(workspace) + except EngineeringSnapshotError as exc: + raise RuntimeApiError("engineering_snapshot_commit_failed", str(exc)) from exc + + @staticmethod + def _read_engineering_snapshot(owner) -> dict: + from chipcompiler.engine.snapshot import ( + EngineeringSnapshotError, + read_engineering_snapshot, + ) + + try: + return read_engineering_snapshot(getattr(owner, "workspace", owner)) + except EngineeringSnapshotError as exc: + raise RuntimeApiError("engineering_snapshot_unavailable", str(exc)) from exc + + @staticmethod + def _commit_step_snapshot( + session: WorkspaceSession, + workspace_step, + state, + error: str | None, + ) -> int: + state_value = str(getattr(state, "value", state)).lower() + try: + return WorkspaceSpecRuntimeMixin._commit_workspace_snapshot( + session, + f"flow_step.{state_value}", + ) + except RuntimeApiError as exc: + raise RuntimeApiError( + "engineering_snapshot_commit_failed", + str(exc), + { + "step": str(getattr(workspace_step, "name", "")), + "error": error or "", + }, + ) from exc + + @staticmethod + def _commit_workspace_snapshot(session: WorkspaceSession, cause: str) -> int: + from chipcompiler.engine.snapshot import ( + EngineeringSnapshotError, + commit_engineering_snapshot, + ) + + try: + snapshot = commit_engineering_snapshot( + session.workspace, + workspace_id=session.workspace_id, + cause=cause, + ) + except EngineeringSnapshotError as exc: + raise RuntimeApiError("engineering_snapshot_commit_failed", str(exc)) from exc + session.workspace_revision = snapshot["workspaceRevision"] + return session.workspace_revision + + +def _workspace_session_result(session: WorkspaceSession) -> dict: + return { + "workspaceId": session.workspace_id, + "workspaceRevision": session.workspace_revision, + "directory": str(session.directory), + **( + {"executionReadiness": session.execution_readiness} + if session.workspace_bindings is not None + or session.execution_readiness.get("ready") is not True + else {} + ), + } + + +def _normalize_rtl_list(rtl_list: list[str]) -> list[str]: + result: list[str] = [] + seen = set() + for item in rtl_list: + path = str(item).strip() + if not path or path in seen: + continue + seen.add(path) + result.append(path) + return result + + +def _write_filelist(directory: str, rtl_paths: list[str]) -> str: + os.makedirs(directory, exist_ok=True) + filelist_path = os.path.join(directory, "filelist") + with open(filelist_path, "w", encoding="utf-8") as filelist: + for path in rtl_paths: + filelist.write(f'"{path}"\n' if any(ch.isspace() for ch in path) else f"{path}\n") + return filelist_path + + +def _materialize_inline_pdk_json(pdk_json: Any) -> tuple[Any, Path | None]: + if not isinstance(pdk_json, dict): + return pdk_json, None + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + prefix="ecc-pdk-", + suffix=".json", + delete=False, + ) as pdk_file: + json.dump(pdk_json, pdk_file) + return pdk_file.name, Path(pdk_file.name) + + +def _looks_like_old_workspace(directory: str) -> bool: + if not os.path.isdir(directory): + return False + home = os.path.join(directory, "home") + return all( + os.path.isfile(os.path.join(home, filename)) + for filename in ("parameters.json", "home.json") + ) diff --git a/ecos/runtime-adapter/main.py b/ecos/runtime-adapter/main.py new file mode 100644 index 000000000..8df157247 --- /dev/null +++ b/ecos/runtime-adapter/main.py @@ -0,0 +1,17 @@ +import argparse + +from ecos_runtime_adapter.stdio_server import main as run_stdio_adapter + + +def main() -> int: + parser = argparse.ArgumentParser(prog="ecos-ecc-runtime-adapter") + parser.add_argument("--stdio", action="store_true") + parser.add_argument("--persistent-db", action="store_true") + args = parser.parse_args() + if not args.stdio: + parser.error("--stdio is required") + return run_stdio_adapter(persistent_db_enabled=args.persistent_db) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ecos/runtime-adapter/test/conftest.py b/ecos/runtime-adapter/test/conftest.py new file mode 100644 index 000000000..70b191a6e --- /dev/null +++ b/ecos/runtime-adapter/test/conftest.py @@ -0,0 +1,19 @@ +import importlib.util +from pathlib import Path + +import pytest + + +def _ecc_test_helpers(): + path = Path(__file__).parents[3] / "ecc" / "test" / "conftest.py" + spec = importlib.util.spec_from_file_location("ecc_test_helpers", path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load ECC test helpers from {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def minimal_ics55_pdk_factory(): + return _ecc_test_helpers().create_minimal_ics55_pdk diff --git a/ecos/runtime-adapter/test/test_events.py b/ecos/runtime-adapter/test/test_events.py new file mode 100644 index 000000000..589976fb2 --- /dev/null +++ b/ecos/runtime-adapter/test/test_events.py @@ -0,0 +1,155 @@ +import os +import sys +import threading +from contextlib import nullcontext + +import pytest +from chipcompiler.utility.log import capture_stdio_to_file +from ecos_runtime_adapter.events import redirect_stdout_to_stderr + + +def test_redirect_stdout_to_stderr_restores_fd_1_and_fd_2(tmp_path): + stdout_target = tmp_path / "stdout.txt" + stderr_target = tmp_path / "stderr.txt" + redirected_target = tmp_path / "redirected.txt" + + saved_stdout_fd = os.dup(1) + saved_stderr_fd = os.dup(2) + with open(stdout_target, "wb") as stdout_file, open(stderr_target, "wb") as stderr_file: + os.dup2(stdout_file.fileno(), 1) + os.dup2(stderr_file.fileno(), 2) + try: + with redirect_stdout_to_stderr(), open(redirected_target, "wb") as redirected_file: + os.dup2(redirected_file.fileno(), 2) + os.write(1, b"captured stdout\n") + os.write(2, b"captured stderr\n") + + os.write(1, b"restored stdout\n") + os.write(2, b"restored stderr\n") + finally: + os.dup2(saved_stdout_fd, 1) + os.dup2(saved_stderr_fd, 2) + os.close(saved_stdout_fd) + os.close(saved_stderr_fd) + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + + assert stdout_target.read_text() == "restored stdout\n" + assert stderr_target.read_text() == "captured stdout\nrestored stderr\n" + assert redirected_target.read_text() == "captured stderr\n" + + +@pytest.mark.parametrize("error", [None, RuntimeError("failed"), SystemExit(0)]) +def test_capture_stdio_to_file_restores_fd_1_and_fd_2(tmp_path, error): + original_fds = (os.dup(1), os.dup(2)) + stdout_target = tmp_path / "stdout.txt" + stderr_target = tmp_path / "stderr.txt" + step_log = tmp_path / "step.log" + with open(stdout_target, "wb") as stdout_file, open(stderr_target, "wb") as stderr_file: + os.dup2(stdout_file.fileno(), 1) + os.dup2(stderr_file.fileno(), 2) + try: + with ( + pytest.raises(type(error)) if error else nullcontext(), + capture_stdio_to_file(str(step_log)), + ): + os.write(1, b"step stdout\n") + os.write(2, b"step stderr\n") + if error: + raise error + os.write(1, b"restored stdout\n") + os.write(2, b"restored stderr\n") + finally: + os.dup2(original_fds[0], 1) + os.dup2(original_fds[1], 2) + os.close(original_fds[0]) + os.close(original_fds[1]) + + assert step_log.read_text() == "step stdout\nstep stderr\n" + assert stdout_target.read_text() == "restored stdout\n" + assert stderr_target.read_text() == "restored stderr\n" + + +def test_capture_stdio_to_file_serializes_process_fds(tmp_path): + first_entered = threading.Event() + second_attempted = threading.Event() + second_entered = threading.Event() + release_first = threading.Event() + + def capture_first(): + with capture_stdio_to_file(str(tmp_path / "first.log")): + os.write(1, b"first\n") + first_entered.set() + release_first.wait(timeout=2) + + def capture_second(): + first_entered.wait(timeout=2) + second_attempted.set() + with capture_stdio_to_file(str(tmp_path / "second.log")): + second_entered.set() + os.write(1, b"second\n") + + first = threading.Thread(target=capture_first) + second = threading.Thread(target=capture_second) + first.start() + second.start() + try: + assert first_entered.wait(timeout=2) + assert second_attempted.wait(timeout=2) + assert not second_entered.wait(timeout=0.05) + finally: + release_first.set() + first.join(timeout=2) + second.join(timeout=2) + + assert not first.is_alive() + assert not second.is_alive() + assert second_entered.is_set() + assert (tmp_path / "first.log").read_text() == "first\n" + assert (tmp_path / "second.log").read_text() == "second\n" + + +def test_rpc_redirect_does_not_restore_step_capture_after_capture_exits(tmp_path): + original_fds = (os.dup(1), os.dup(2)) + stdout_target = tmp_path / "stdout.txt" + stderr_target = tmp_path / "stderr.txt" + step_log = tmp_path / "step.log" + capture_entered = threading.Event() + release_capture = threading.Event() + capture_exited = threading.Event() + + def capture_step(): + with capture_stdio_to_file(str(step_log)): + capture_entered.set() + release_capture.wait(timeout=2) + capture_exited.set() + + def dispatch_rpc(): + capture_entered.wait(timeout=2) + with redirect_stdout_to_stderr(): + release_capture.set() + capture_exited.wait(timeout=2) + + with open(stdout_target, "wb") as stdout_file, open(stderr_target, "wb") as stderr_file: + os.dup2(stdout_file.fileno(), 1) + os.dup2(stderr_file.fileno(), 2) + first = threading.Thread(target=capture_step) + second = threading.Thread(target=dispatch_rpc) + try: + first.start() + second.start() + first.join(timeout=2) + second.join(timeout=2) + assert not first.is_alive() + assert not second.is_alive() + os.write(1, b"after capture\n") + finally: + os.dup2(original_fds[0], 1) + os.dup2(original_fds[1], 2) + os.close(original_fds[0]) + os.close(original_fds[1]) + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + + assert stdout_target.read_text() == "after capture\n" + assert "after capture" not in step_log.read_text() diff --git a/ecos/runtime-adapter/test/test_layout_edit.py b/ecos/runtime-adapter/test/test_layout_edit.py new file mode 100644 index 000000000..af95e46f7 --- /dev/null +++ b/ecos/runtime-adapter/test/test_layout_edit.py @@ -0,0 +1,620 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +from ecos_runtime_adapter.requests import ( + FloorplanEditInspectRequest, + FloorplanEditRunAutoRequest, + FloorplanEditValidateRequest, + LayoutEditApplyRequest, + LayoutEditBeginRequest, + LayoutEditDiscardRequest, + LayoutEditSaveRequest, +) +from ecos_runtime_adapter.sessions import WorkspaceSessionRegistry +from ecos_runtime_adapter.workspace_api import RuntimeApiError, WorkspaceRuntimeApi + + +class FakeLayoutModule: + def __init__(self): + self.initialize_calls = 0 + self.reset_calls = 0 + self.place_calls = [] + self.sync_calls = [] + self.export_calls = [] + self.session_snapshot_calls = [] + self.editor_calls = [] + self.validation_result = {"ok": True, "diagnostics": []} + self.export_intent = {"ok": True} + + def initialize_geometry_session(self): + self.initialize_calls += 1 + return True + + def reset_geometry_session(self): + self.reset_calls += 1 + return True + + def place_instance(self, **kwargs): + self.place_calls.append(kwargs) + return True + + def sync_instance_geometry(self, inst_name): + self.sync_calls.append(inst_name) + return { + "ok": True, + "snapshotRequired": False, + "updatedShapeCount": 1, + "insertedShapeCount": 0, + "deletedShapeCount": 0, + "missingShapeCount": 0, + "events": [{"shapeId": 11, "op": "update"}], + } + + def def_save(self, def_path): + self.export_calls.append("def") + Path(def_path).write_text("new def", encoding="utf-8") + + def save_data(self, path): + self.export_calls.append("db") + db_path = Path(path) + db_path.mkdir(parents=True) + (db_path / "metadata.idb").write_text("new db", encoding="utf-8") + return True + + def gds_save(self, output_path): + self.export_calls.append("gds") + Path(output_path).write_text("new gds", encoding="utf-8") + + def geometry_snapshot_save(self, output_dir): + self.export_calls.append("geometry") + geometry_dir = Path(output_dir) + geometry_dir.mkdir(parents=True) + (geometry_dir / "geometry.manifest").write_text("new geometry", encoding="utf-8") + return True + + def geometry_session_snapshot_save(self, output_dir): + self.session_snapshot_calls.append(Path(output_dir)) + geometry_dir = Path(output_dir) + geometry_dir.mkdir(parents=True) + (geometry_dir / "geometry.manifest").write_text("session geometry", encoding="utf-8") + return True + + def floorplan_editor_apply(self, request): + self.editor_calls.append(request) + return { + "accepted": True, + "changed": True, + "affectedRefs": [{"kind": "blockage", "id": "blockage-1"}], + "geometryDelta": { + "ok": True, + "snapshotRequired": True, + "updatedShapeCount": 0, + "insertedShapeCount": 1, + "deletedShapeCount": 0, + "missingShapeCount": 0, + "events": [{"shapeId": 31, "op": "insert"}], + }, + "modelPatch": { + "floorplanPlan": {"placement_blockages": [{"id": "blockage-1"}]}, + "pdnPlan": {"manual_segments": [{"id": "segment-1"}]}, + "configPatch": {"editor": {"enabled": True}}, + "parametersPatch": {"Floorplan": {"edited": True}}, + }, + "diagnostics": [{"severity": "warning", "message": "preview"}], + } + + def floorplan_editor_validate(self, scope): + assert scope + return self.validation_result + + def floorplan_editor_export_intent(self): + return self.export_intent + + def floorplan_editor_inspect(self): + return {"ownerCount": 3} + + def verilog_save(self, output_verilog): + self.export_calls.append("verilog") + Path(output_verilog).write_text("module gcd; endmodule\n", encoding="utf-8") + + +class FakeEngineDb: + def __init__(self, module): + self.ecc_module = module + self.initialized = False + self.created_for = [] + self.close_calls = 0 + + @property + def engine(self): + return self.ecc_module + + def has_init(self): + return self.initialized + + def create_db_engine(self, step): + self.created_for.append(step) + self.initialized = True + return True + + def close(self): + self.close_calls += 1 + self.initialized = False + + +class FakeFlow: + def __init__(self, workspace, step, module): + self.workspace = workspace + self.workspace_step = step + self.engine_db = FakeEngineDb(module) + + def get_workspace_step(self, name): + return self.workspace_step if name == self.workspace_step.name else None + + +def _make_layout_workspace(tmp_path, *, with_db=False, with_editor_workspace=False): + workspace_dir = tmp_path / "workspace" + output_dir = workspace_dir / "Floorplan_ecc" / "output" + output_dir.mkdir(parents=True) + output_def = output_dir / "gcd_Floorplan.def.gz" + output_def.write_text("old def", encoding="utf-8") + output_db = output_dir / "gcd_Floorplan_db" + if with_db: + output_db.mkdir() + (output_db / "metadata.idb").write_text("old db", encoding="utf-8") + step = SimpleNamespace( + name="Floorplan", + input={"def": None, "verilog": None, "db": None}, + output={ + "def": output_def, + "db": output_db, + "gds": output_dir / "gcd_Floorplan.gds", + "geometry": output_dir / "geometry", + "geometry_manifest": output_dir / "geometry" / "geometry.manifest", + "verilog": output_dir / "gcd_Floorplan.v", + }, + ) + workspace = SimpleNamespace(directory=workspace_dir) + if with_editor_workspace: + config_path = workspace_dir / "config" / "floorplan_ecc.json" + config_path.parent.mkdir() + config_path.write_text('{"legacy": true}\n', encoding="utf-8") + parameters_path = workspace_dir / "parameters.json" + parameters_path.write_text('{"Floorplan": {"edited": false}}\n', encoding="utf-8") + flow_path = workspace_dir / "flow.json" + flow_data = { + "steps": [ + {"name": "Floorplan", "state": "Success", "runtime": "1s"}, + {"name": "place", "state": "Success", "runtime": "2s"}, + {"name": "route", "state": "Success", "runtime": "3s"}, + ] + } + flow_path.write_text(json.dumps(flow_data), encoding="utf-8") + workspace.config = {"Floorplan": config_path} + workspace.parameters = SimpleNamespace( + path=parameters_path, + data={"Floorplan": {"edited": False}}, + ) + workspace.flow = SimpleNamespace(path=flow_path, data=flow_data) + return workspace, step + + +def _open_api(monkeypatch, tmp_path, *, with_db=False, with_editor_workspace=False): + from chipcompiler.engine.snapshot import create_engineering_snapshot + + workspace, step = _make_layout_workspace( + tmp_path, + with_db=with_db, + with_editor_workspace=with_editor_workspace, + ) + module = FakeLayoutModule() + flow_calls = [] + + def build_flow(_workspace): + flow = FakeFlow(_workspace, step, module) + flow_calls.append(flow) + return flow + + monkeypatch.setattr("ecos_runtime_adapter.workspace_api.build_flow_for_workspace", build_flow) + registry = WorkspaceSessionRegistry() + snapshot = create_engineering_snapshot(workspace) + session = registry.open_session( + workspace.directory, + workspace=workspace, + workspace_id=snapshot["workspaceId"], + workspace_revision=snapshot["workspaceRevision"], + ) + api = WorkspaceRuntimeApi(sessions=registry, persistent_db_enabled=True) + return api, session, step, module, flow_calls + + +def _begin(api, workspace_id, **kwargs): + return api.layout_edit_begin( + LayoutEditBeginRequest(workspace_id=workspace_id, step="Floorplan", **kwargs) + ) + + +def _apply(api, edit_session_id, *, revision=0, command_id="move-1"): + return api.layout_edit_apply( + LayoutEditApplyRequest( + edit_session_id=edit_session_id, + command_id=command_id, + base_revision=revision, + operation={ + "kind": "place_instance", + "instName": "u_sram_0", + "llx": 1200, + "lly": 3400, + "orient": "N", + "cellmaster": "", + "source": "", + "placementStatus": "preserve", + "createIfMissing": False, + }, + ) + ) + + +def test_layout_edit_begin_loads_output_def_when_output_db_is_absent(monkeypatch, tmp_path): + api, session, step, module, flow_calls = _open_api(monkeypatch, tmp_path) + + result = _begin(api, session.workspace_id) + + assert result["source"] == "def" + assert result["dirty"] is False + assert Path(result["geometryManifestPath"]).is_file() + assert module.initialize_calls == 1 + loaded_step = flow_calls[0].engine_db.created_for[0] + assert loaded_step is not step + assert loaded_step.input["db"] is None + assert loaded_step.input["def"] == step.output["def"] + + +def test_layout_edit_begin_prefers_selected_step_output_db(monkeypatch, tmp_path): + api, session, step, _module, flow_calls = _open_api(monkeypatch, tmp_path, with_db=True) + + result = _begin(api, session.workspace_id) + + assert result["source"] == "db" + loaded_step = flow_calls[0].engine_db.created_for[0] + assert loaded_step.input["db"] == step.output["db"] + + +def test_layout_edit_begin_rejects_another_workspace_until_active_session_is_discarded( + monkeypatch, + tmp_path, +): + api, first_session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path) + second_workspace = SimpleNamespace(directory=tmp_path / "second-workspace") + second_session = api.sessions.open_session( + second_workspace.directory, + workspace=second_workspace, + ) + + first = _begin(api, first_session.workspace_id) + + with pytest.raises(RuntimeApiError) as exc_info: + _begin(api, second_session.workspace_id) + + assert exc_info.value.code == "layout_edit_active" + assert exc_info.value.data == { + "editSessionId": first["editSessionId"], + "workspaceId": first_session.workspace_id, + } + assert module.initialize_calls == 1 + + api.layout_edit_discard(LayoutEditDiscardRequest(first["editSessionId"])) + second = _begin(api, second_session.workspace_id) + + assert second["workspaceId"] == second_session.workspace_id + assert module.initialize_calls == 2 + + +def test_layout_edit_apply_calls_place_instance_without_persisting_artifacts(monkeypatch, tmp_path): + api, session, step, module, _flow_calls = _open_api(monkeypatch, tmp_path) + begin = _begin(api, session.workspace_id) + original_def = step.output["def"].read_text(encoding="utf-8") + + result = _apply(api, begin["editSessionId"]) + + assert result["revision"] == 1 + assert result["dirty"] is True + assert result["geometryDelta"]["events"] == [{"shapeId": 11, "op": "update"}] + assert module.place_calls == [ + { + "inst_name": "u_sram_0", + "llx": 1200, + "lly": 3400, + "orient": "N", + "cellmaster": "", + "source": "", + "placement_status": "preserve", + "create_if_missing": False, + } + ] + assert module.sync_calls == ["u_sram_0"] + assert module.export_calls == [] + assert len(module.session_snapshot_calls) == 2 + assert Path(result["geometryManifestPath"]).is_file() + assert step.output["def"].read_text(encoding="utf-8") == original_def + assert not step.output["db"].exists() + assert not step.output["gds"].exists() + assert not step.output["geometry"].exists() + + +def test_layout_edit_save_publishes_staged_outputs_only_after_explicit_save(monkeypatch, tmp_path): + api, session, step, module, _flow_calls = _open_api(monkeypatch, tmp_path) + begin = _begin(api, session.workspace_id) + apply = _apply(api, begin["editSessionId"]) + + saved = api.layout_edit_save( + LayoutEditSaveRequest( + edit_session_id=begin["editSessionId"], + expected_revision=apply["revision"], + expected_workspace_revision=session.workspace_revision, + ) + ) + + assert saved["saved"] is True + assert saved["dirty"] is False + assert saved["artifacts"] == { + "defPath": str(step.output["def"]), + "dbPath": str(step.output["db"]), + "gdsPath": str(step.output["gds"]), + "geometryManifestPath": str(step.output["geometry_manifest"]), + } + assert module.export_calls == ["def", "db", "gds", "geometry"] + assert step.output["def"].read_text(encoding="utf-8") == "new def" + assert (step.output["db"] / "metadata.idb").read_text(encoding="utf-8") == "new db" + assert step.output["gds"].read_text(encoding="utf-8") == "new gds" + geometry_manifest = step.output["geometry"] / "geometry.manifest" + assert geometry_manifest.read_text(encoding="utf-8") == "new geometry" + assert not list(step.output["def"].parent.glob(".layout-edit-*")) + + +def test_layout_edit_discard_drops_in_memory_db_without_publishing(monkeypatch, tmp_path): + api, session, step, module, flow_calls = _open_api(monkeypatch, tmp_path) + begin = _begin(api, session.workspace_id) + _apply(api, begin["editSessionId"]) + geometry_root = Path(begin["geometryManifestPath"]).parent.parent + + result = api.layout_edit_discard(LayoutEditDiscardRequest(begin["editSessionId"])) + + assert result == { + "editSessionId": begin["editSessionId"], + "discarded": True, + "dirty": True, + } + assert flow_calls[0].engine_db.close_calls == 1 + assert module.reset_calls == 1 + assert not geometry_root.exists() + assert step.output["def"].read_text(encoding="utf-8") == "old def" + assert module.export_calls == [] + with pytest.raises(RuntimeApiError, match="layout edit session not found"): + _apply(api, begin["editSessionId"]) + + +def test_layout_edit_save_rejects_external_source_change(monkeypatch, tmp_path): + api, session, step, module, _flow_calls = _open_api(monkeypatch, tmp_path) + begin = _begin(api, session.workspace_id) + apply = _apply(api, begin["editSessionId"]) + step.output["def"].write_text("external change", encoding="utf-8") + + with pytest.raises(RuntimeApiError) as exc_info: + api.layout_edit_save( + LayoutEditSaveRequest( + edit_session_id=begin["editSessionId"], + expected_revision=apply["revision"], + expected_workspace_revision=session.workspace_revision, + ) + ) + + assert exc_info.value.code == "source_changed" + assert module.export_calls == [] + assert step.output["def"].read_text(encoding="utf-8") == "external change" + + +def test_layout_edit_save_rolls_back_when_publish_fails(monkeypatch, tmp_path): + api, session, step, _module, _flow_calls = _open_api(monkeypatch, tmp_path, with_db=True) + step.output["gds"].write_text("old gds", encoding="utf-8") + step.output["geometry"].mkdir() + (step.output["geometry"] / "geometry.manifest").write_text( + "old geometry", + encoding="utf-8", + ) + begin = _begin(api, session.workspace_id) + apply = _apply(api, begin["editSessionId"]) + real_replace = __import__("os").replace + + def fail_gds_publish(source, destination): + if Path(source).name == step.output["gds"].name and Path(destination) == step.output["gds"]: + raise OSError("simulated publish failure") + real_replace(source, destination) + + monkeypatch.setattr("ecos_runtime_adapter.workspace_api.os.replace", fail_gds_publish) + + with pytest.raises(RuntimeApiError, match="simulated publish failure"): + api.layout_edit_save( + LayoutEditSaveRequest( + edit_session_id=begin["editSessionId"], + expected_revision=apply["revision"], + expected_workspace_revision=session.workspace_revision, + ) + ) + + assert step.output["def"].read_text(encoding="utf-8") == "old def" + assert (step.output["db"] / "metadata.idb").read_text(encoding="utf-8") == "old db" + assert step.output["gds"].read_text(encoding="utf-8") == "old gds" + geometry_manifest = step.output["geometry"] / "geometry.manifest" + assert geometry_manifest.read_text(encoding="utf-8") == "old geometry" + + +def test_layout_edit_apply_rejects_stale_revision_before_mutation(monkeypatch, tmp_path): + api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path) + begin = _begin(api, session.workspace_id) + _apply(api, begin["editSessionId"]) + + with pytest.raises(RuntimeApiError) as exc_info: + _apply(api, begin["editSessionId"], revision=0, command_id="move-2") + + assert exc_info.value.code == "version_conflict" + assert len(module.place_calls) == 1 + + +def test_layout_edit_apply_replays_completed_command_before_revision_validation( + monkeypatch, + tmp_path, +): + api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path) + begin = _begin(api, session.workspace_id) + first = _apply(api, begin["editSessionId"]) + + replay = _apply(api, begin["editSessionId"], revision=0) + + assert replay == first + assert len(module.place_calls) == 1 + + +def test_layout_edit_apply_allows_preserve_orientation_for_existing_instance( + monkeypatch, + tmp_path, +): + api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path) + begin = _begin(api, session.workspace_id) + + result = api.layout_edit_apply( + LayoutEditApplyRequest( + edit_session_id=begin["editSessionId"], + command_id="move-with-preserved-orient", + base_revision=0, + operation={ + "kind": "place_instance", + "instName": "u_sram_0", + "llx": 1200, + "lly": 3400, + "orient": "", + "placementStatus": "preserve", + "createIfMissing": False, + }, + ) + ) + + assert result["revision"] == 1 + assert module.place_calls[0]["orient"] == "" + + +def test_floorplan_editor_apply_inspect_validate_and_save_publish_editor_artifacts( + monkeypatch, + tmp_path, +): + api, session, step, module, _flow_calls = _open_api( + monkeypatch, + tmp_path, + with_editor_workspace=True, + ) + begin = _begin(api, session.workspace_id) + + applied = api.layout_edit_apply( + LayoutEditApplyRequest( + edit_session_id=begin["editSessionId"], + command_id="blockage-1", + base_revision=0, + operation={"kind": "upsert_blockage", "id": "blockage-1"}, + ) + ) + + assert applied["revision"] == 1 + assert applied["affectedRefs"] == [{"kind": "blockage", "id": "blockage-1"}] + assert applied["modelPatch"]["floorplanPlan"]["placement_blockages"] == [{"id": "blockage-1"}] + assert module.editor_calls == [{"kind": "upsert_blockage", "id": "blockage-1"}] + config_path = session.workspace.config["Floorplan"] + assert config_path.read_text(encoding="utf-8") == '{"legacy": true}\n' + + inspected = api.floorplan_edit_inspect( + FloorplanEditInspectRequest(edit_session_id=begin["editSessionId"]) + ) + assert inspected["state"] == {"ownerCount": 3} + assert inspected["floorplanPlan"]["placement_blockages"] == [{"id": "blockage-1"}] + + validated = api.floorplan_edit_validate( + FloorplanEditValidateRequest(edit_session_id=begin["editSessionId"], scope="pdn") + ) + assert validated["valid"] is True + + module.export_intent = { + "ok": True, + "floorplanPlan": {"outline": {"die": [0, 0, 100, 100]}}, + "pdnPlan": {"manual_vias": [{"id": "via-1"}]}, + "parametersPatch": {"PDN": {"edited": True}}, + "requiresVerilog": True, + } + saved = api.layout_edit_save( + LayoutEditSaveRequest( + edit_session_id=begin["editSessionId"], + expected_revision=applied["revision"], + expected_workspace_revision=session.workspace_revision, + ) + ) + + assert saved["saved"] is True + assert saved["artifacts"]["configPath"] == str(config_path) + assert saved["artifacts"]["parametersPath"] == str(session.workspace.parameters.path) + assert saved["artifacts"]["verilogPath"] == str(step.output["verilog"]) + assert saved["artifacts"]["flowPath"] == str(session.workspace.flow.path) + assert module.export_calls == ["def", "db", "gds", "geometry", "verilog"] + saved_config = json.loads(config_path.read_text(encoding="utf-8")) + assert saved_config["FloorplanPlan"]["outline"] == {"die": [0, 0, 100, 100]} + assert saved_config["PdnPlan"]["manual_vias"] == [{"id": "via-1"}] + assert saved_config["editor"] == {"enabled": True} + saved_parameters = json.loads(session.workspace.parameters.path.read_text(encoding="utf-8")) + assert saved_parameters["Floorplan"]["edited"] is True + assert saved_parameters["PDN"] == {"edited": True} + assert step.output["verilog"].is_file() + stale_steps = session.workspace.flow.data["steps"][1:] + assert [item["state"] for item in stale_steps] == ["Unstart", "Unstart"] + assert [item["runtime"] for item in stale_steps] == ["", ""] + + +def test_floorplan_editor_run_auto_is_idempotent_and_save_rejects_invalid_result( + monkeypatch, + tmp_path, +): + api, session, _step, module, _flow_calls = _open_api(monkeypatch, tmp_path) + begin = _begin(api, session.workspace_id) + + first = api.floorplan_edit_run_auto( + FloorplanEditRunAutoRequest( + edit_session_id=begin["editSessionId"], + command_id="auto-1", + base_revision=0, + request={"mode": "macro"}, + ) + ) + replay = api.floorplan_edit_run_auto( + FloorplanEditRunAutoRequest( + edit_session_id=begin["editSessionId"], + command_id="auto-1", + base_revision=0, + request={"mode": "macro"}, + ) + ) + + assert replay == first + assert module.editor_calls == [{"kind": "run_auto", "request": {"mode": "macro"}}] + module.validation_result = { + "ok": False, + "diagnostics": [{"severity": "error", "message": "bad outline"}], + } + with pytest.raises(RuntimeApiError) as exc_info: + api.layout_edit_save( + LayoutEditSaveRequest( + edit_session_id=begin["editSessionId"], + expected_revision=first["revision"], + expected_workspace_revision=session.workspace_revision, + ) + ) + + assert exc_info.value.code == "floorplan_validation_failed" + assert module.export_calls == [] diff --git a/ecos/runtime-adapter/test/test_methods.py b/ecos/runtime-adapter/test/test_methods.py new file mode 100644 index 000000000..03dae04f8 --- /dev/null +++ b/ecos/runtime-adapter/test/test_methods.py @@ -0,0 +1,157 @@ +from dataclasses import is_dataclass + +import ecos_runtime_adapter.requests as requests +from ecos_runtime_adapter.requests import ( + DbEnsureRequest, + DbReleaseRequest, + FloorplanEditInspectRequest, + FloorplanEditRunAutoRequest, + FloorplanEditValidateRequest, + WorkspaceOpenV1Request, +) +from ecos_runtime_adapter.server import BASE_CAPABILITIES, RuntimeServer + + +def test_runtime_method_registry_contains_current_methods_once(): + from ecos_runtime_adapter.methods import RUNTIME_METHODS, runtime_method_names + + expected_methods = ( + "workspace_spec.describe", + "workspace_spec.validate", + "workspace.create", + "workspace.open", + "workspace.update", + "workspace.close", + "workspace.home", + "workspace.info", + "workspace.refresh_config", + "workspace.sync_config", + "workspace.reset_flow", + "workspace.export_signoff", + "workspace.inspect_signoff", + "flow.run", + "flow.run_step", + "operation.start_flow", + "operation.start_step", + "operation.status", + "operation.cancel", + "workspace.snapshot", + "workspace.engineering_snapshot", + "workspace.recover_interrupted", + ) + + assert runtime_method_names() == expected_methods + assert len(runtime_method_names()) == len(set(runtime_method_names())) + assert len(RUNTIME_METHODS) == len(expected_methods) + + +def test_persistent_db_method_registry_is_separate_and_opt_in(): + from ecos_runtime_adapter.methods import ( + PERSISTENT_DB_METHODS, + persistent_db_method_names, + runtime_method_names, + ) + + expected_methods = ( + "db.ensure", + "db.release", + "layout.edit.begin", + "layout.edit.apply", + "layout.edit.save", + "layout.edit.discard", + "floorplan.edit.inspect", + "floorplan.edit.run_auto", + "floorplan.edit.validate", + ) + + assert persistent_db_method_names() == expected_methods + enabled_methods = runtime_method_names(persistent_db_enabled=True) + assert enabled_methods[-len(expected_methods) :] == expected_methods + assert "db.ensure" not in runtime_method_names() + assert len(PERSISTENT_DB_METHODS) == len(expected_methods) + + +def test_runtime_method_registry_entries_are_typed(): + from ecos_runtime_adapter.methods import runtime_methods + + for spec in runtime_methods(persistent_db_enabled=True): + assert spec.method_name + assert isinstance(spec.request_model, type) + assert is_dataclass(spec.request_model) + assert spec.handler_name + + +def test_runtime_method_lookup_returns_spec(): + from ecos_runtime_adapter.methods import runtime_method_by_name + + spec = runtime_method_by_name("workspace.open") + + assert spec is not None + assert spec.request_model is WorkspaceOpenV1Request + assert spec.handler_name == "open_workspace" + export_spec = runtime_method_by_name("workspace.export_signoff") + assert export_spec is not None + assert export_spec.request_model is requests.WorkspaceExportSignoffRequest + assert export_spec.handler_name == "export_signoff" + inspect_spec = runtime_method_by_name("workspace.inspect_signoff") + assert inspect_spec is not None + assert inspect_spec.request_model is requests.WorkspaceInspectSignoffRequest + assert inspect_spec.handler_name == "inspect_signoff" + assert runtime_method_by_name("db.ensure") is None + + +def test_persistent_db_method_lookup_requires_enabled_capability(): + from ecos_runtime_adapter.methods import runtime_method_by_name + + ensure_spec = runtime_method_by_name("db.ensure", persistent_db_enabled=True) + release_spec = runtime_method_by_name("db.release", persistent_db_enabled=True) + + assert ensure_spec is not None + assert ensure_spec.request_model is DbEnsureRequest + assert ensure_spec.handler_name == "db_ensure" + assert release_spec is not None + assert release_spec.request_model is DbReleaseRequest + assert release_spec.handler_name == "db_release" + inspect_spec = runtime_method_by_name("floorplan.edit.inspect", persistent_db_enabled=True) + assert inspect_spec is not None + assert inspect_spec.request_model is FloorplanEditInspectRequest + auto_spec = runtime_method_by_name("floorplan.edit.run_auto", persistent_db_enabled=True) + assert auto_spec is not None + assert auto_spec.request_model is FloorplanEditRunAutoRequest + validate_spec = runtime_method_by_name("floorplan.edit.validate", persistent_db_enabled=True) + assert validate_spec is not None + assert validate_spec.request_model is FloorplanEditValidateRequest + + +def test_default_server_capabilities_are_generated_from_runtime_registry(): + from ecos_runtime_adapter.methods import runtime_method_names + + server = RuntimeServer() + + assert server.capabilities == (*BASE_CAPABILITIES, *runtime_method_names()) + + +def test_persistent_db_server_capabilities_include_db_methods(): + from ecos_runtime_adapter.methods import runtime_method_names + + server = RuntimeServer(persistent_db_enabled=True) + + assert server.capabilities == ( + *BASE_CAPABILITIES, + *runtime_method_names(persistent_db_enabled=True), + ) + assert "db.ensure" in server.capabilities + assert "db.release" in server.capabilities + assert "layout.edit.begin" in server.capabilities + assert "layout.edit.save" in server.capabilities + assert "floorplan.edit.inspect" in server.capabilities + + +def test_requests_module_does_not_own_runtime_method_table(): + assert not hasattr(requests, "REQUEST_MODELS") + + +def test_server_module_does_not_own_runtime_method_table(): + import ecos_runtime_adapter.server as server + + assert not hasattr(server, "RUNTIME_METHODS") diff --git a/ecos/runtime-adapter/test/test_operations.py b/ecos/runtime-adapter/test/test_operations.py new file mode 100644 index 000000000..e76491ac7 --- /dev/null +++ b/ecos/runtime-adapter/test/test_operations.py @@ -0,0 +1,516 @@ +import threading +from pathlib import Path +from types import SimpleNamespace + +import pytest +from chipcompiler.data import StateEnum +from ecos_runtime_adapter.operations import ( + RuntimeOperationIdempotencyConflict, + RuntimeOperationManager, +) + + +def test_successful_steps_complete_without_renderer_ack(): + events = [] + manager = RuntimeOperationManager(events.append) + synthesis = SimpleNamespace(name="Synthesis", tool="yosys", log=SimpleNamespace(file="")) + floorplan = SimpleNamespace(name="Floorplan", tool="ecc", log=SimpleNamespace(file="")) + + def runner(observer): + observer.on_step_started(synthesis) + observer.on_step_completed(synthesis, StateEnum.Success) + observer.on_step_started(floorplan) + observer.on_step_completed(floorplan, StateEnum.Success) + return {"rerun": False} + + started = manager.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="request-1", + runner=runner, + ) + + status = _wait_for_terminal(manager, started["operationId"]) + assert status["state"] == "succeeded" + completed = [event for event in events if event["type"] == "step.completed"] + assert [event["payload"]["workspaceRevision"] for event in completed] == [1, 2] + assert events[-1]["type"] == "operation.completed" + + +def test_subflow_stage_is_emitted_for_the_active_workspace_step(): + events = [] + released = threading.Event() + manager = RuntimeOperationManager(events.append) + step = SimpleNamespace(name="Floorplan", tool="ecc", log=SimpleNamespace(file="")) + + def runner(observer): + observer.on_step_started(step) + observer.on_subflow_stage( + step, + { + "name": "init floorplan", + "state": "Ongoing", + "runtime": "0:0:1", + "peak memory (mb)": 12.5, + }, + ) + assert released.wait(timeout=1) + return {"rerun": False} + + started = manager.start( + workspace_id="workspace-1", + kind="step", + origin="gui", + rerun=True, + step="Floorplan", + idempotency_key="subflow-stage", + runner=runner, + ) + + event = _wait_for_event(events, "subflow.stage") + assert event["operationId"] == started["operationId"] + assert event["payload"] == { + "peakMemory": 12.5, + "runtime": "0:0:1", + "state": "Ongoing", + "step": "Floorplan", + "subflowStep": "init floorplan", + "tool": "ecc", + } + released.set() + assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded" + + +def test_start_requests_are_idempotent(): + events = [] + release = threading.Event() + manager = RuntimeOperationManager(events.append) + + def runner(_observer): + assert release.wait(timeout=1) + return {"rerun": False} + + first = manager.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="request-1", + runner=runner, + ) + duplicate = manager.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="request-1", + runner=runner, + ) + + assert duplicate["operationId"] == first["operationId"] + assert duplicate["deduplicated"] is True + release.set() + assert _wait_for_terminal(manager, first["operationId"])["state"] == "succeeded" + + +def test_reusing_a_command_id_with_different_parameters_conflicts(): + release = threading.Event() + manager = RuntimeOperationManager() + first = manager.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="request-1", + runner=lambda _observer: release.wait(timeout=1) or {}, + ) + + try: + with pytest.raises(RuntimeOperationIdempotencyConflict): + manager.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=True, + step="", + idempotency_key="request-1", + runner=lambda _observer: {}, + ) + finally: + release.set() + _wait_for_terminal(manager, first["operationId"]) + + +def test_command_fingerprint_includes_command_specific_input(): + release = threading.Event() + manager = RuntimeOperationManager() + first = manager.start( + workspace_id="workspace-1", + kind="step", + origin="gui", + rerun=True, + step="Place", + idempotency_key="request-1", + command_input={"resetDependents": False}, + runner=lambda _observer: release.wait(timeout=1) or {}, + ) + + try: + with pytest.raises(RuntimeOperationIdempotencyConflict): + manager.start( + workspace_id="workspace-1", + kind="step", + origin="gui", + rerun=True, + step="Place", + idempotency_key="request-1", + command_input={"resetDependents": True}, + runner=lambda _observer: {}, + ) + finally: + release.set() + _wait_for_terminal(manager, first["operationId"]) + + +def test_operation_ledger_survives_restart_and_marks_unknown_active_work_interrupted( + tmp_path, +): + ledger = Path(tmp_path) / "runtime-commands.json" + release = threading.Event() + first = RuntimeOperationManager() + started = first.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="request-1", + runner=lambda _observer: release.wait(timeout=1) or {}, + ledger_path=ledger, + ) + _wait_for_state(first, started["operationId"], "running") + + restarted = RuntimeOperationManager() + recovered = restarted.load_workspace_ledger("workspace-1", ledger) + + assert recovered == [started["operationId"]] + assert restarted.operation_status(started["operationId"])["state"] == "interrupted" + duplicate = restarted.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="request-1", + runner=lambda _observer: {}, + ledger_path=ledger, + ) + assert duplicate["operationId"] == started["operationId"] + assert duplicate["deduplicated"] is True + release.set() + + +def test_event_identity_is_unique_across_sidecar_operation_managers(): + first_events = [] + second_events = [] + first = RuntimeOperationManager(first_events.append) + second = RuntimeOperationManager(second_events.append) + + first.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="first", + runner=lambda _observer: {"rerun": False}, + ) + second.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=True, + step="", + idempotency_key="second", + runner=lambda _observer: {"rerun": True}, + ) + + first_queued = _wait_for_event(first_events, "operation.queued") + second_queued = _wait_for_event(second_events, "operation.queued") + assert first_queued["sequence"] == second_queued["sequence"] == 1 + assert first_queued["eventId"] != second_queued["eventId"] + assert first_queued["runtimeInstanceId"] != second_queued["runtimeInstanceId"] + assert first_queued["operationId"] != second_queued["operationId"] + assert first_queued["runSessionId"] != second_queued["runSessionId"] + + +def test_rerun_prepared_event_carries_the_affected_steps_once(): + events = [] + manager = RuntimeOperationManager(events.append) + + def runner(observer): + observer.on_rerun_prepared( + scope="step", + target_step="Floorplan", + affected_steps=["Floorplan", "route"], + ) + return {"rerun": True} + + first = manager.start( + workspace_id="workspace-1", + kind="step", + origin="gui", + rerun=True, + step="Floorplan", + idempotency_key="rerun-prepared", + runner=runner, + ) + duplicate = manager.start( + workspace_id="workspace-1", + kind="step", + origin="gui", + rerun=True, + step="Floorplan", + idempotency_key="rerun-prepared", + runner=runner, + ) + + assert duplicate["operationId"] == first["operationId"] + prepared = _wait_for_event(events, "operation.rerun_prepared") + assert prepared["payload"] == { + "affectedSteps": ["Floorplan", "route"], + "scope": "step", + "targetStep": "Floorplan", + } + assert len([event for event in events if event["type"] == "operation.rerun_prepared"]) == 1 + + +def test_active_operation_reports_a_shutdown_barrier_and_safe_boundary(): + release = threading.Event() + manager = RuntimeOperationManager() + started = manager.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="request-1", + runner=lambda _observer: (release.wait(timeout=1), {"rerun": False})[1], + ) + + barrier = manager.shutdown_barrier() + status = manager.operation_status(started["operationId"]) + + assert barrier is not None + assert barrier["operationId"] == started["operationId"] + assert barrier["interruptibility"] == "deferred" + assert status["shutdownBarrier"] is True + assert status["safeToStop"] is False + release.set() + + +def test_step_log_events_stream_only_new_log_bytes_and_keep_final_tail(tmp_path): + events = [] + step_started = threading.Event() + complete_step = threading.Event() + manager = RuntimeOperationManager(events.append) + log_file = tmp_path / "Synthesis.log" + log_file.write_text("previous run\n", encoding="utf-8") + step = SimpleNamespace( + name="Synthesis", + tool="yosys", + log=SimpleNamespace(file=str(log_file)), + ) + + def runner(observer): + observer.on_step_started(step) + step_started.set() + assert complete_step.wait(timeout=2) + observer.on_step_completed(step, StateEnum.Success) + return {"rerun": False} + + started = manager.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="request-log-stream", + runner=runner, + ) + assert step_started.wait(timeout=1) + with log_file.open("a", encoding="utf-8") as handle: + handle.write("live line one\nlive line two\n") + + step_log = _wait_for_event(events, "step.log") + assert step_log["payload"]["chunk"] == "live line one\nlive line two\n" + assert step_log["payload"]["cursor"] == log_file.stat().st_size + + complete_step.set() + step_complete = _wait_for_event(events, "step.completed") + assert step_complete["payload"]["finalLog"] == ("previous run\nlive line one\nlive line two\n") + assert _wait_for_terminal(manager, started["operationId"])["state"] == "succeeded" + + +def test_step_error_survives_generic_runner_error_and_releases_workspace(tmp_path): + events = [] + manager = RuntimeOperationManager(events.append) + log_file = tmp_path / "place.log" + log_file.write_text("traceback\n", encoding="utf-8") + step = SimpleNamespace( + name="place", + tool="dreamplace", + log=SimpleNamespace(file=log_file), + ) + + def runner(observer): + observer.on_step_started(step) + observer.on_step_diagnostic( + step, + { + "code": "tool_failed", + "exitCode": 9, + "message": "movable utilization is 100.0%", + }, + ) + observer.on_step_completed(step, StateEnum.Imcomplete, "movable utilization is 100.0%") + raise RuntimeError("run step place failed with state Imcomplete") + + started = manager.start( + workspace_id="workspace-1", + kind="step", + origin="gui", + rerun=False, + step="place", + idempotency_key="failed-place", + runner=runner, + ) + + status = _wait_for_terminal(manager, started["operationId"]) + assert status["error"] == { + "code": "tool_failed", + "exitCode": 9, + "message": "movable utilization is 100.0%", + "step": "place", + "tool": "dreamplace", + "logFile": str(log_file), + } + assert _wait_for_event(events, "operation.failed")["payload"]["error"] == status["error"] + second = manager.start( + workspace_id="workspace-1", + kind="step", + origin="gui", + rerun=True, + step="place", + idempotency_key="retry-place", + runner=lambda _observer: {"state": "Success"}, + ) + assert _wait_for_terminal(manager, second["operationId"])["state"] == "succeeded" + + +def test_cancel_does_not_replace_a_specific_tool_error(tmp_path): + manager = RuntimeOperationManager() + log_file = tmp_path / "place.log" + step = SimpleNamespace( + name="place", + tool="dreamplace", + log=SimpleNamespace(file=log_file), + ) + step_failed = threading.Event() + release_runner = threading.Event() + + def runner(observer): + observer.on_step_started(step) + observer.on_step_completed(step, StateEnum.Imcomplete, "utilization is larger than 0.99") + step_failed.set() + assert release_runner.wait(timeout=2) + raise RuntimeError("run step place failed with state Incomplete") + + started = manager.start( + workspace_id="workspace-1", + kind="step", + origin="gui", + rerun=False, + step="place", + idempotency_key="cancelled-failed-place", + runner=runner, + ) + assert step_failed.wait(timeout=1) + assert manager.request_cancel(started["operationId"])["accepted"] is True + release_runner.set() + + status = _wait_for_terminal(manager, started["operationId"]) + assert status["state"] == "failed" + assert status["error"] == { + "code": "tool_failed", + "message": "utilization is larger than 0.99", + "step": "place", + "tool": "dreamplace", + "logFile": str(log_file), + } + + +def test_cancel_stops_at_the_next_step_boundary(): + manager = RuntimeOperationManager() + first_step_finished = threading.Event() + continue_runner = threading.Event() + executed = [] + + def runner(observer): + observer.raise_if_cancelled() + executed.append("synthesis") + first_step_finished.set() + assert continue_runner.wait(timeout=2) + observer.raise_if_cancelled() + executed.append("floorplan") + return {"state": "Success"} + + started = manager.start( + workspace_id="workspace-1", + kind="flow", + origin="gui", + rerun=False, + step="", + idempotency_key="cancel-at-boundary", + runner=runner, + ) + assert first_step_finished.wait(timeout=1) + assert manager.request_cancel(started["operationId"])["accepted"] is True + continue_runner.set() + + status = _wait_for_terminal(manager, started["operationId"]) + assert status["state"] == "cancelled" + assert executed == ["synthesis"] + + +def _wait_for_event(events: list[dict], event_type: str) -> dict: + for _ in range(200): + for event in events: + if event["type"] == event_type: + return event + threading.Event().wait(0.01) + raise AssertionError(f"event not received: {event_type}") + + +def _wait_for_terminal(manager: RuntimeOperationManager, operation_id: str) -> dict: + for _ in range(100): + status = manager.operation_status(operation_id) + if status["state"] in {"succeeded", "failed", "cancelled"}: + return status + threading.Event().wait(0.01) + return manager.operation_status(operation_id) + + +def _wait_for_state(manager: RuntimeOperationManager, operation_id: str, expected: str) -> dict: + for _ in range(100): + status = manager.operation_status(operation_id) + if status["state"] == expected: + return status + threading.Event().wait(0.01) + return manager.operation_status(operation_id) diff --git a/ecos/runtime-adapter/test/test_requests.py b/ecos/runtime-adapter/test/test_requests.py new file mode 100644 index 000000000..86c502223 --- /dev/null +++ b/ecos/runtime-adapter/test/test_requests.py @@ -0,0 +1,354 @@ +from dataclasses import is_dataclass + +import pytest +from ecos_runtime_adapter.methods import runtime_method_by_name +from ecos_runtime_adapter.requests import ( + DbEnsureRequest, + DbReleaseRequest, + FloorplanEditInspectRequest, + FloorplanEditRunAutoRequest, + FloorplanEditValidateRequest, + FlowRunRequest, + FlowRunStepRequest, + LayoutEditApplyRequest, + LayoutEditBeginRequest, + LayoutEditDiscardRequest, + LayoutEditSaveRequest, + OperationStartStepRequest, + RequestValidationError, + WorkspaceCloseRequest, + WorkspaceCreateV1Request, + WorkspaceExportSignoffRequest, + WorkspaceIdRequest, + WorkspaceInfoRequest, + WorkspaceInspectSignoffRequest, + WorkspaceMutationRequest, + WorkspaceOpenV1Request, + WorkspaceSyncConfigRequest, + parse_request_model, +) + + +def _parse_runtime_request(method: str, params: object, *, persistent_db_enabled=False): + spec = runtime_method_by_name(method, persistent_db_enabled=persistent_db_enabled) + assert spec is not None + return parse_request_model(spec.request_model, params) + + +def test_workspace_create_maps_workspace_spec_v1_camel_case_fields(): + workspace_spec = {"schemaVersion": 1} + workspace_bindings = {"inputs": {}, "pdk": {}} + request = _parse_runtime_request( + "workspace.create", + { + "commandId": "create-1", + "targetDirectory": "/work/ws", + "workspaceSpec": workspace_spec, + "workspaceBindings": workspace_bindings, + }, + ) + + assert isinstance(request, WorkspaceCreateV1Request) + assert is_dataclass(request) + assert request.command_id == "create-1" + assert request.target_directory == "/work/ws" + assert request.workspace_spec == workspace_spec + assert request.workspace_bindings == workspace_bindings + + +@pytest.mark.parametrize( + ("method", "params", "request_type"), + [ + ("workspace.open", {"directory": "/work/ws"}, WorkspaceOpenV1Request), + ("workspace.close", {"workspaceId": "ws-1"}, WorkspaceCloseRequest), + ("workspace.home", {"workspaceId": "ws-1"}, WorkspaceIdRequest), + ("workspace.refresh_config", {"workspaceId": "ws-1"}, WorkspaceIdRequest), + ( + "workspace.reset_flow", + {"workspaceId": "ws-1", "expectedWorkspaceRevision": 7}, + WorkspaceMutationRequest, + ), + ( + "workspace.export_signoff", + {"workspaceId": "ws-1", "outputPath": "/exports/custom.tar.gz"}, + WorkspaceExportSignoffRequest, + ), + ( + "workspace.inspect_signoff", + {"workspaceId": "ws-1"}, + WorkspaceInspectSignoffRequest, + ), + ( + "workspace.sync_config", + { + "workspaceId": "ws-1", + "configPath": "/work/ws/config/route.json", + "expectedWorkspaceRevision": 7, + }, + WorkspaceSyncConfigRequest, + ), + ( + "workspace.info", + {"workspaceId": "ws-1", "step": "Synthesis", "id": "layout"}, + WorkspaceInfoRequest, + ), + ( + "flow.run", + {"workspaceId": "ws-1", "expectedWorkspaceRevision": 7, "rerun": True}, + FlowRunRequest, + ), + ( + "flow.run_step", + { + "workspaceId": "ws-1", + "expectedWorkspaceRevision": 7, + "step": "Synthesis", + "rerun": True, + }, + FlowRunStepRequest, + ), + ( + "operation.start_step", + { + "workspaceId": "ws-1", + "expectedWorkspaceRevision": 7, + "step": "Synthesis", + "resetDependents": True, + }, + OperationStartStepRequest, + ), + ], +) +def test_first_slice_payloads_parse_to_typed_request_models(method, params, request_type): + request = _parse_runtime_request(method, params) + + assert isinstance(request, request_type) + assert is_dataclass(request) + + +@pytest.mark.parametrize( + ("method", "params", "request_type"), + [ + ( + "db.ensure", + {"workspaceId": "ws-1", "step": "Floorplan"}, + DbEnsureRequest, + ), + ("db.ensure", {"workspaceId": "ws-1"}, DbEnsureRequest), + ("db.release", {"workspaceId": "ws-1"}, DbReleaseRequest), + ( + "layout.edit.begin", + {"workspaceId": "ws-1", "step": "Floorplan"}, + LayoutEditBeginRequest, + ), + ( + "layout.edit.apply", + { + "editSessionId": "layout-edit-1", + "commandId": "move-1", + "baseRevision": 0, + "operation": {"kind": "place_instance"}, + }, + LayoutEditApplyRequest, + ), + ( + "layout.edit.save", + { + "editSessionId": "layout-edit-1", + "expectedRevision": 1, + "expectedWorkspaceRevision": 7, + }, + LayoutEditSaveRequest, + ), + ( + "layout.edit.discard", + {"editSessionId": "layout-edit-1"}, + LayoutEditDiscardRequest, + ), + ( + "floorplan.edit.inspect", + {"editSessionId": "layout-edit-1"}, + FloorplanEditInspectRequest, + ), + ( + "floorplan.edit.run_auto", + { + "editSessionId": "layout-edit-1", + "commandId": "auto-1", + "baseRevision": 1, + "request": {"mode": "macro"}, + }, + FloorplanEditRunAutoRequest, + ), + ( + "floorplan.edit.validate", + {"editSessionId": "layout-edit-1", "scope": "pdn"}, + FloorplanEditValidateRequest, + ), + ], +) +def test_persistent_db_payloads_parse_to_typed_request_models(method, params, request_type): + request = _parse_runtime_request(method, params, persistent_db_enabled=True) + + assert isinstance(request, request_type) + assert is_dataclass(request) + if hasattr(request, "workspace_id"): + assert request.workspace_id == "ws-1" + else: + assert request.edit_session_id == "layout-edit-1" + + +def test_db_ensure_step_is_optional(): + request = _parse_runtime_request( + "db.ensure", + {"workspaceId": "ws-1"}, + persistent_db_enabled=True, + ) + + assert isinstance(request, DbEnsureRequest) + assert request.step == "" + + +def test_layout_edit_begin_accepts_source_fingerprint_alias(): + request = _parse_runtime_request( + "layout.edit.begin", + { + "workspaceId": "ws-1", + "step": "Floorplan", + "expectedSourceFingerprint": "abc123", + }, + persistent_db_enabled=True, + ) + + assert isinstance(request, LayoutEditBeginRequest) + assert request.expected_source_fingerprint == "abc123" + + +def test_missing_required_field_reports_field_name(): + with pytest.raises(RequestValidationError) as exc_info: + _parse_runtime_request("flow.run_step", {"workspaceId": "ws-1"}) + + assert exc_info.value.reason == "missing required field: step" + + +def test_unknown_fields_are_rejected(): + with pytest.raises(RequestValidationError) as exc_info: + _parse_runtime_request("workspace.open", {"directory": "/work/ws", "extra": True}) + + assert exc_info.value.reason == "unknown field: extra" + + +def test_db_method_unknown_fields_are_rejected(): + with pytest.raises(RequestValidationError) as exc_info: + _parse_runtime_request( + "db.ensure", + {"workspaceId": "ws-1", "extra": True}, + persistent_db_enabled=True, + ) + + assert exc_info.value.reason == "unknown field: extra" + + +def test_db_method_blank_workspace_id_is_rejected(): + with pytest.raises(RequestValidationError) as exc_info: + _parse_runtime_request( + "db.release", + {"workspaceId": " "}, + persistent_db_enabled=True, + ) + + assert exc_info.value.reason == "missing required field: workspace_id" + + +def test_params_must_be_an_object(): + with pytest.raises(RequestValidationError, match="params must be an object"): + _parse_runtime_request("workspace.open", None) + + +def test_workspace_info_accepts_info_id_alias(): + request = _parse_runtime_request( + "workspace.info", + {"workspaceId": "ws-1", "step": "Synthesis", "infoId": "timing"}, + ) + + assert isinstance(request, WorkspaceInfoRequest) + assert request.info_id == "timing" + + +def test_workspace_export_signoff_preserves_exact_output_path(): + request = _parse_runtime_request( + "workspace.export_signoff", + { + "workspaceId": "ws-1", + "outputPath": "/exports/custom.tar.gz ", + }, + ) + + assert isinstance(request, WorkspaceExportSignoffRequest) + assert request.output_path == "/exports/custom.tar.gz " + + +@pytest.mark.parametrize( + ("method", "params"), + [ + ( + "flow.run", + {"workspaceId": "ws-1", "expectedWorkspaceRevision": 7, "rerun": "false"}, + ), + ( + "flow.run_step", + { + "workspaceId": "ws-1", + "expectedWorkspaceRevision": 7, + "step": "Synthesis", + "rerun": "true", + }, + ), + ], +) +def test_rerun_must_be_boolean(method, params): + with pytest.raises(RequestValidationError) as exc_info: + _parse_runtime_request(method, params) + + assert exc_info.value.reason == "rerun must be a boolean" + + +@pytest.mark.parametrize( + ("method", "params"), + [ + ( + "operation.start_step", + { + "workspaceId": "ws-1", + "expectedWorkspaceRevision": 7, + "step": "Synthesis", + "resetDependents": 1, + }, + ), + ], +) +def test_reset_dependents_must_be_boolean(method, params): + with pytest.raises(RequestValidationError) as exc_info: + _parse_runtime_request(method, params) + + assert exc_info.value.reason == "reset_dependents must be a boolean" + + +def test_direct_flow_run_step_rejects_gui_only_reset_dependents_field(): + with pytest.raises(RequestValidationError) as exc_info: + _parse_runtime_request( + "flow.run_step", + { + "workspaceId": "ws-1", + "expectedWorkspaceRevision": 7, + "step": "Synthesis", + "resetDependents": True, + }, + ) + + assert exc_info.value.reason == "unknown field: reset_dependents" + + +def test_unknown_runtime_method_has_no_request_model(): + assert runtime_method_by_name("workspace.signoff") is None + assert runtime_method_by_name("db.ensure") is None diff --git a/ecos/runtime-adapter/test/test_rpc_dispatch.py b/ecos/runtime-adapter/test/test_rpc_dispatch.py new file mode 100644 index 000000000..ee1db9590 --- /dev/null +++ b/ecos/runtime-adapter/test/test_rpc_dispatch.py @@ -0,0 +1,71 @@ +import json + +from ecos_runtime_adapter.rpc_dispatch import RpcDispatcher + + +def _dispatch(dispatcher: RpcDispatcher, payload: str) -> dict: + return json.loads(dispatcher.dispatch(payload)) + + +def test_dispatch_registered_method_returns_standard_success_response(): + dispatcher = RpcDispatcher() + dispatcher.add_method("rpc.ping", lambda: {"ok": True}) + + response = _dispatch( + dispatcher, + '{"jsonrpc":"2.0","method":"rpc.ping","id":1}', + ) + + assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 1} + + +def test_unknown_method_returns_standard_method_not_found_error(): + dispatcher = RpcDispatcher() + + response = _dispatch( + dispatcher, + '{"jsonrpc":"2.0","method":"missing","id":"req-1"}', + ) + + assert response["jsonrpc"] == "2.0" + assert response["id"] == "req-1" + assert response["error"]["code"] == -32601 + assert response["error"]["message"] == "Method not found" + + +def test_invalid_params_return_standard_invalid_params_error(): + dispatcher = RpcDispatcher() + + def needs_name(name: str) -> dict: + return {"name": name} + + dispatcher.add_method("needsName", needs_name) + + response = _dispatch( + dispatcher, + '{"jsonrpc":"2.0","method":"needsName","params":{"missing":"x"},"id":2}', + ) + + assert response["id"] == 2 + assert response["error"]["code"] == -32602 + assert response["error"]["message"] == "Invalid params" + + +def test_parse_error_uses_json_rpc_parse_error_shape(): + dispatcher = RpcDispatcher() + + response = _dispatch(dispatcher, "{") + + assert response["id"] is None + assert response["error"]["code"] == -32700 + assert response["error"]["message"] == "Parse error" + + +def test_custom_non_json_rpc_envelope_is_rejected(): + dispatcher = RpcDispatcher() + dispatcher.add_method("rpc.ping", lambda: {"ok": True}) + + response = _dispatch(dispatcher, '{"type":"request","method":"rpc.ping","id":1}') + + assert response["id"] is None + assert response["error"]["code"] == -32600 diff --git a/ecos/runtime-adapter/test/test_server.py b/ecos/runtime-adapter/test/test_server.py new file mode 100644 index 000000000..49a2ccc8b --- /dev/null +++ b/ecos/runtime-adapter/test/test_server.py @@ -0,0 +1,468 @@ +import json + +import pytest +from ecos_runtime_adapter.methods import RUNTIME_METHODS, runtime_methods +from ecos_runtime_adapter.requests import ( + DbEnsureRequest, + DbReleaseRequest, + WorkspaceExportSignoffRequest, + WorkspaceInspectSignoffRequest, + WorkspaceOpenV1Request, +) +from ecos_runtime_adapter.server import RuntimeServer +from ecos_runtime_adapter.workspace_api import RuntimeApiError + + +def _dispatch(server: RuntimeServer, payload: str) -> dict: + return json.loads(server.dispatch(payload)) + + +class CompleteFakeApi: + def describe_workspace_spec(self, _request): + raise AssertionError("unexpected describe_workspace_spec call") + + def validate_workspace_spec(self, _request): + raise AssertionError("unexpected validate_workspace_spec call") + + def create_workspace(self, _request): + raise AssertionError("unexpected create_workspace call") + + def open_workspace(self, _request): + raise AssertionError("unexpected open_workspace call") + + def update_workspace(self, _request): + raise AssertionError("unexpected update_workspace call") + + def close_workspace(self, _request): + raise AssertionError("unexpected close_workspace call") + + def workspace_home(self, _request): + raise AssertionError("unexpected workspace_home call") + + def workspace_info(self, _request): + raise AssertionError("unexpected workspace_info call") + + def refresh_config(self, _request): + raise AssertionError("unexpected refresh_config call") + + def sync_config(self, _request): + raise AssertionError("unexpected sync_config call") + + def reset_flow(self, _request): + raise AssertionError("unexpected reset_flow call") + + def export_signoff(self, _request): + raise AssertionError("unexpected export_signoff call") + + def inspect_signoff(self, _request): + raise AssertionError("unexpected inspect_signoff call") + + def flow_run(self, _request): + raise AssertionError("unexpected flow_run call") + + def flow_run_step(self, _request): + raise AssertionError("unexpected flow_run_step call") + + def start_flow_operation(self, _request): + raise AssertionError("unexpected start_flow_operation call") + + def start_step_operation(self, _request): + raise AssertionError("unexpected start_step_operation call") + + def operation_status(self, _request): + raise AssertionError("unexpected operation_status call") + + def cancel_operation(self, _request): + raise AssertionError("unexpected cancel_operation call") + + def workspace_snapshot(self, _request): + raise AssertionError("unexpected workspace_snapshot call") + + def engineering_snapshot(self, _request): + raise AssertionError("unexpected engineering_snapshot call") + + def recover_interrupted(self, _request): + raise AssertionError("unexpected recover_interrupted call") + + def db_ensure(self, _request): + raise AssertionError("unexpected db_ensure call") + + def db_release(self, _request): + raise AssertionError("unexpected db_release call") + + def layout_edit_begin(self, _request): + raise AssertionError("unexpected layout_edit_begin call") + + def layout_edit_apply(self, _request): + raise AssertionError("unexpected layout_edit_apply call") + + def layout_edit_save(self, _request): + raise AssertionError("unexpected layout_edit_save call") + + def layout_edit_discard(self, _request): + raise AssertionError("unexpected layout_edit_discard call") + + def floorplan_edit_inspect(self, _request): + raise AssertionError("unexpected floorplan_edit_inspect call") + + def floorplan_edit_run_auto(self, _request): + raise AssertionError("unexpected floorplan_edit_run_auto call") + + def floorplan_edit_validate(self, _request): + raise AssertionError("unexpected floorplan_edit_validate call") + + +def test_rpc_hello_returns_version_and_capabilities(): + server = RuntimeServer() + + response = _dispatch( + server, + '{"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello"}', + ) + + assert response["id"] == "hello" + assert response["result"]["adapterVersion"] == 1 + assert "runtime.adapter.v1" in response["result"]["capabilities"] + assert response["result"]["version"] == 1 + assert response["result"]["eccVersion"] + assert "rpc.ping" in response["result"]["capabilities"] + assert "rpc.shutdown" in response["result"]["capabilities"] + assert "runtime.v2" in response["result"]["capabilities"] + assert "operation.events" in response["result"]["capabilities"] + assert "workspace.snapshot" in response["result"]["capabilities"] + assert "db.ensure" not in response["result"]["capabilities"] + assert "db.release" not in response["result"]["capabilities"] + + +def test_runtime_events_are_projected_to_the_four_v1_event_types(): + server = RuntimeServer() + events = [] + server.set_notification_sink(lambda _method, event: events.append(event)) + + base = { + "eventId": "event-1", + "operationId": "operation-1", + "origin": "gui", + "payload": {"step": "place", "workspaceRevision": 2}, + "sequence": 1, + "timestamp": 1, + "workspaceId": "workspace-1", + } + for event_type in ( + "operation.started", + "step.started", + "step.completed", + ): + server._publish_runtime_event({**base, "type": event_type}) + + assert [event["type"] for event in events] == [ + "operation.changed", + "execution.progress", + "workspace.committed", + ] + assert events[-1]["workspaceRevision"] == 2 + + +def test_rpc_hello_reports_persistent_db_capabilities_when_enabled(): + server = RuntimeServer(persistent_db_enabled=True) + + response = _dispatch( + server, + '{"jsonrpc":"2.0","method":"rpc.hello","params":{"version":1},"id":"hello"}', + ) + + assert "db.ensure" in response["result"]["capabilities"] + assert "db.release" in response["result"]["capabilities"] + + +def test_rpc_hello_rejects_incompatible_version(): + server = RuntimeServer() + + response = _dispatch( + server, + '{"jsonrpc":"2.0","method":"rpc.hello","params":{"version":2},"id":1}', + ) + + assert response["id"] == 1 + assert response["error"]["code"] == -32001 + assert response["error"]["message"] == "unsupported_version" + + +def test_rpc_ping_returns_correlated_result(): + server = RuntimeServer() + + response = _dispatch(server, '{"jsonrpc":"2.0","method":"rpc.ping","id":"p"}') + + assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": "p"} + + +def test_rpc_shutdown_marks_server_for_graceful_exit(): + server = RuntimeServer() + + response = _dispatch(server, '{"jsonrpc":"2.0","method":"rpc.shutdown","id":3}') + + assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 3} + assert server.should_exit + + +def test_rpc_shutdown_releases_runtime_sessions(): + class FakeSessions: + def __init__(self): + self.closed = False + + def close_all(self): + self.closed = True + + class FakeApi(CompleteFakeApi): + sessions = FakeSessions() + + api = FakeApi() + server = RuntimeServer(api=api) + + response = _dispatch(server, '{"jsonrpc":"2.0","method":"rpc.shutdown","id":3}') + + assert response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 3} + assert api.sessions.closed + + +def test_unknown_method_keeps_request_id(): + server = RuntimeServer() + + response = _dispatch(server, '{"jsonrpc":"2.0","method":"missing","id":"req"}') + + assert response["id"] == "req" + assert response["error"]["code"] == -32601 + + +def test_workspace_method_dispatches_typed_request_to_runtime_api(): + class FakeApi(CompleteFakeApi): + def open_workspace(self, request): + assert isinstance(request, WorkspaceOpenV1Request) + return {"workspaceId": "workspace-1", "directory": request.directory} + + server = RuntimeServer(api=FakeApi()) + + response = _dispatch( + server, + '{"jsonrpc":"2.0","method":"workspace.open","params":{"directory":"/ws"},"id":4}', + ) + + assert response == { + "jsonrpc": "2.0", + "result": {"workspaceId": "workspace-1", "directory": "/ws"}, + "id": 4, + } + + +def test_workspace_export_signoff_dispatches_exact_output_path(): + class FakeApi(CompleteFakeApi): + def export_signoff(self, request): + assert isinstance(request, WorkspaceExportSignoffRequest) + return {"outputPath": request.output_path} + + server = RuntimeServer(api=FakeApi()) + + response = _dispatch( + server, + ( + '{"jsonrpc":"2.0","method":"workspace.export_signoff",' + '"params":{"workspaceId":"workspace-1",' + '"outputPath":"/exports/custom.tar.gz "},"id":5}' + ), + ) + + assert response == { + "jsonrpc": "2.0", + "result": {"outputPath": "/exports/custom.tar.gz "}, + "id": 5, + } + + +def test_workspace_inspect_signoff_dispatches_typed_request(): + class FakeApi(CompleteFakeApi): + def inspect_signoff(self, request): + assert isinstance(request, WorkspaceInspectSignoffRequest) + return {"status": "ready", "groups": [], "risks": []} + + server = RuntimeServer(api=FakeApi()) + + response = _dispatch( + server, + ( + '{"jsonrpc":"2.0","method":"workspace.inspect_signoff",' + '"params":{"workspaceId":"workspace-1"},"id":6}' + ), + ) + + assert response == { + "jsonrpc": "2.0", + "result": {"status": "ready", "groups": [], "risks": []}, + "id": 6, + } + + +def test_persistent_db_methods_dispatch_typed_requests_to_runtime_api(): + seen = [] + + class FakeApi(CompleteFakeApi): + def db_ensure(self, request): + seen.append(request) + assert isinstance(request, DbEnsureRequest) + return { + "workspaceId": request.workspace_id, + "enabled": True, + "active": True, + "reused": False, + "step": request.step, + } + + def db_release(self, request): + seen.append(request) + assert isinstance(request, DbReleaseRequest) + return {"workspaceId": request.workspace_id, "released": True} + + server = RuntimeServer(api=FakeApi(), persistent_db_enabled=True) + + ensure_response = _dispatch( + server, + ( + '{"jsonrpc":"2.0","method":"db.ensure",' + '"params":{"workspaceId":"workspace-1","step":"Floorplan"},"id":8}' + ), + ) + release_response = _dispatch( + server, + ('{"jsonrpc":"2.0","method":"db.release","params":{"workspaceId":"workspace-1"},"id":9}'), + ) + + assert ensure_response["result"] == { + "workspaceId": "workspace-1", + "enabled": True, + "active": True, + "reused": False, + "step": "Floorplan", + } + assert release_response["result"] == { + "workspaceId": "workspace-1", + "released": True, + } + assert [type(request) for request in seen] == [DbEnsureRequest, DbReleaseRequest] + + +def test_persistent_db_methods_are_not_registered_by_default(): + server = RuntimeServer() + + response = _dispatch( + server, + ('{"jsonrpc":"2.0","method":"db.ensure","params":{"workspaceId":"workspace-1"},"id":10}'), + ) + + assert response["id"] == 10 + assert response["error"]["code"] == -32601 + + +def test_request_validation_errors_map_to_json_rpc_invalid_params(): + server = RuntimeServer() + + response = _dispatch( + server, + '{"jsonrpc":"2.0","method":"workspace.home","params":{"directory":"/ws"},"id":5}', + ) + + assert response["id"] == 5 + assert response["error"]["code"] == -32602 + assert response["error"]["message"] == "invalid_request" + assert response["error"]["data"]["message"] == "unknown field: directory" + + +def test_workspace_session_errors_map_to_json_rpc_runtime_error(): + class FakeApi(CompleteFakeApi): + def workspace_home(self, _request): + raise RuntimeApiError( + "workspace_session_not_found", + "workspace session not found: missing", + ) + + server = RuntimeServer(api=FakeApi()) + + response = _dispatch( + server, + '{"jsonrpc":"2.0","method":"workspace.home","params":{"workspaceId":"missing"},"id":6}', + ) + + assert response["id"] == 6 + assert response["error"]["code"] == -32010 + assert response["error"]["message"] == "workspace_session_not_found" + + +def test_workspace_api_user_exceptions_map_to_command_failed(): + class FakeApi(CompleteFakeApi): + def open_workspace(self, _request): + raise ValueError("PDK tech LEF is missing") + + server = RuntimeServer(api=FakeApi()) + + response = _dispatch( + server, + '{"jsonrpc":"2.0","method":"workspace.open","params":{"directory":"/ws"},"id":7}', + ) + + assert response["id"] == 7 + assert response["error"]["code"] == -32020 + assert response["error"]["message"] == "command_failed" + assert response["error"]["data"]["message"] == "PDK tech LEF is missing" + + +@pytest.mark.parametrize( + "method", + [spec.method_name for spec in RUNTIME_METHODS], +) +def test_first_slice_methods_are_registered(method): + server = RuntimeServer() + + response = _dispatch(server, f'{{"jsonrpc":"2.0","method":"{method}","id":1}}') + + assert response.get("error", {}).get("code") != -32601 + + +@pytest.mark.parametrize( + "method", + [spec.method_name for spec in runtime_methods(persistent_db_enabled=True)], +) +def test_enabled_persistent_db_runtime_methods_are_registered(method): + server = RuntimeServer(api=CompleteFakeApi(), persistent_db_enabled=True) + + response = _dispatch(server, f'{{"jsonrpc":"2.0","method":"{method}","id":1}}') + + assert response.get("error", {}).get("code") != -32601 + + +def test_runtime_server_fails_when_registered_api_handler_is_missing(monkeypatch): + from ecos_runtime_adapter import methods + + missing_spec = methods.RuntimeMethodSpec( + method_name="workspace.missing_handler", + request_model=WorkspaceOpenV1Request, + handler_name="missing_handler", + ) + monkeypatch.setattr(methods, "RUNTIME_METHODS", (missing_spec,)) + + with pytest.raises(TypeError, match="missing_handler"): + RuntimeServer() + + +def test_runtime_server_fails_when_registered_api_handler_is_not_callable(monkeypatch): + from ecos_runtime_adapter import methods + + class FakeApi: + open_workspace = object() + + spec = methods.RuntimeMethodSpec( + method_name="workspace.open", + request_model=WorkspaceOpenV1Request, + handler_name="open_workspace", + ) + monkeypatch.setattr(methods, "RUNTIME_METHODS", (spec,)) + + with pytest.raises(TypeError, match="open_workspace"): + RuntimeServer(api=FakeApi()) diff --git a/ecos/runtime-adapter/test/test_sessions.py b/ecos/runtime-adapter/test/test_sessions.py new file mode 100644 index 000000000..75e1c18e4 --- /dev/null +++ b/ecos/runtime-adapter/test/test_sessions.py @@ -0,0 +1,100 @@ +from pathlib import Path + +import pytest +from ecos_runtime_adapter.sessions import WorkspaceSessionNotFound, WorkspaceSessionRegistry + + +def test_create_session_returns_workspace_id_and_resolved_directory(tmp_path): + registry = WorkspaceSessionRegistry() + workspace = object() + + session = registry.create_session(tmp_path / "ws", workspace=workspace) + + assert session.workspace_id.startswith("workspace-") + assert session.directory == (tmp_path / "ws").resolve() + assert session.workspace is workspace + assert session.db_handle is None + + +def test_open_reuses_existing_session_for_same_directory(tmp_path): + registry = WorkspaceSessionRegistry() + + first = registry.open_session(tmp_path / "ws", workspace="first") + second = registry.open_session(Path(tmp_path / "ws"), workspace="second") + + assert second.workspace_id == first.workspace_id + assert second.workspace == "first" + + +def test_create_replaces_existing_session_for_same_directory(tmp_path): + released = [] + registry = WorkspaceSessionRegistry(db_releaser=released.append) + + first = registry.open_session(tmp_path / "ws", workspace="first") + db_handle = object() + first.db_handle = db_handle + second = registry.create_session(Path(tmp_path / "ws"), workspace="second") + + assert second.workspace_id != first.workspace_id + assert second.directory == first.directory + assert second.workspace == "second" + assert first.db_handle is None + assert released == [db_handle] + assert registry.get_session(second.workspace_id) is second + with pytest.raises(WorkspaceSessionNotFound, match=first.workspace_id): + registry.get_session(first.workspace_id) + assert registry.open_session(tmp_path / "ws", workspace="third") is second + + +def test_get_session_rejects_unknown_and_closed_workspace_id(tmp_path): + registry = WorkspaceSessionRegistry() + session = registry.open_session(tmp_path / "ws", workspace=object()) + + registry.close_session(session.workspace_id) + + with pytest.raises(WorkspaceSessionNotFound, match=session.workspace_id): + registry.get_session(session.workspace_id) + with pytest.raises(WorkspaceSessionNotFound, match="missing"): + registry.get_session("missing") + + +def test_close_session_releases_active_db_handle_once(tmp_path): + released = [] + registry = WorkspaceSessionRegistry(db_releaser=released.append) + session = registry.open_session(tmp_path / "ws", workspace=object()) + db_handle = object() + session.db_handle = db_handle + + registry.close_session(session.workspace_id) + + assert session.db_handle is None + assert released == [db_handle] + + +def test_close_all_releases_all_active_db_handles_and_clears_directory_map(tmp_path): + released = [] + registry = WorkspaceSessionRegistry(db_releaser=released.append) + first = registry.open_session(tmp_path / "first", workspace=object()) + second = registry.open_session(tmp_path / "second", workspace=object()) + first_db = object() + second_db = object() + first.db_handle = first_db + second.db_handle = second_db + + registry.close_all() + + assert first.db_handle is None + assert second.db_handle is None + assert released == [first_db, second_db] + with pytest.raises(WorkspaceSessionNotFound, match=first.workspace_id): + registry.get_session(first.workspace_id) + reopened = registry.open_session(tmp_path / "first", workspace="new") + assert reopened.workspace == "new" + + +def test_per_session_lock_serializes_mutating_commands(tmp_path): + registry = WorkspaceSessionRegistry() + session = registry.open_session(tmp_path / "ws", workspace=object()) + + with session.mutation_lock: + assert not session.mutation_lock.acquire(blocking=False) diff --git a/ecos/runtime-adapter/test/test_signoff_export.py b/ecos/runtime-adapter/test/test_signoff_export.py new file mode 100644 index 000000000..3056afbbf --- /dev/null +++ b/ecos/runtime-adapter/test/test_signoff_export.py @@ -0,0 +1,406 @@ +import json +import queue +import threading +from pathlib import Path +from types import SimpleNamespace + +import pytest +from chipcompiler.engine import signoff_export +from chipcompiler.engine.signoff_export import SignoffExportError +from ecos_runtime_adapter.requests import ( + WorkspaceExportSignoffRequest, + WorkspaceInspectSignoffRequest, +) +from ecos_runtime_adapter.sessions import WorkspaceSessionRegistry +from ecos_runtime_adapter.workspace_api import RuntimeApiError, WorkspaceRuntimeApi + + +def test_workspace_export_signoff_returns_exact_output_path(monkeypatch, tmp_path): + workspace = SimpleNamespace(directory=tmp_path / "workspace") + sessions = WorkspaceSessionRegistry() + session = sessions.open_session(workspace.directory, workspace=workspace) + output_path = tmp_path / "exports" / "custom name.tar.gz" + calls = [] + + def fake_export(active_workspace, requested_output, additional_files=None): + calls.append((active_workspace, requested_output)) + return str(output_path) + + monkeypatch.setattr( + "chipcompiler.engine.signoff_export.export_signoff_package_archive", + fake_export, + ) + monkeypatch.setattr( + "chipcompiler.engine.signoff_export.inspect_signoff_package", + lambda _workspace: {"status": "ready", "groups": [], "risks": []}, + ) + api = WorkspaceRuntimeApi(sessions=sessions) + + result = api.export_signoff( + WorkspaceExportSignoffRequest( + workspace_id=session.workspace_id, + output_path=str(output_path), + ) + ) + + assert result == {"outputPath": str(output_path)} + assert calls == [(workspace, str(output_path))] + + +def test_workspace_export_signoff_waits_for_session_mutation_lock(monkeypatch, tmp_path): + workspace = SimpleNamespace(directory=tmp_path / "workspace") + sessions = WorkspaceSessionRegistry() + session = sessions.open_session(workspace.directory, workspace=workspace) + output_path = tmp_path / "export.tar.gz" + entered = threading.Event() + results = queue.Queue() + + def fake_export(_workspace, requested_output, additional_files=None): + entered.set() + return requested_output + + monkeypatch.setattr( + "chipcompiler.engine.signoff_export.export_signoff_package_archive", + fake_export, + ) + monkeypatch.setattr( + "chipcompiler.engine.signoff_export.inspect_signoff_package", + lambda _workspace: {"status": "ready", "groups": [], "risks": []}, + ) + api = WorkspaceRuntimeApi(sessions=sessions) + + def run_export(): + try: + results.put( + api.export_signoff( + WorkspaceExportSignoffRequest( + workspace_id=session.workspace_id, + output_path=str(output_path), + ) + ) + ) + except BaseException as error: # pragma: no cover - re-raised below + results.put(error) + + with session.mutation_lock: + worker = threading.Thread(target=run_export) + worker.start() + assert not entered.wait(0.1) + assert worker.is_alive() + + worker.join(timeout=2) + assert not worker.is_alive() + result = results.get_nowait() + if isinstance(result, BaseException): + raise result + assert result == {"outputPath": str(output_path)} + assert entered.is_set() + + +def test_workspace_export_signoff_rejects_current_blocked_assessment(monkeypatch, tmp_path): + workspace = SimpleNamespace(directory=tmp_path / "workspace") + sessions = WorkspaceSessionRegistry() + session = sessions.open_session(workspace.directory, workspace=workspace) + monkeypatch.setattr( + "chipcompiler.engine.signoff_export.inspect_signoff_package", + lambda _workspace: {"status": "blocked", "groups": [], "risks": [{"id": "drc"}]}, + ) + monkeypatch.setattr( + "chipcompiler.engine.signoff_export.export_signoff_package_archive", + lambda *_args, **_kwargs: pytest.fail("blocked assessment must prevent export"), + ) + + with pytest.raises(RuntimeApiError) as exc_info: + WorkspaceRuntimeApi(sessions=sessions).export_signoff( + WorkspaceExportSignoffRequest( + workspace_id=session.workspace_id, + output_path=str(tmp_path / "blocked.tar.gz"), + ) + ) + + assert exc_info.value.code == "signoff_blocked" + assert exc_info.value.data["assessment"]["status"] == "blocked" + + +def test_inspect_signoff_package_reads_current_home_checklist(monkeypatch, tmp_path): + workspace_dir = tmp_path / "workspace" + checklist_path = workspace_dir / "home" / "checklist.json" + checklist_path.parent.mkdir(parents=True) + checklist_path.write_text( + json.dumps( + { + "schema_version": 3, + "kind": "signoff_checklist", + "status": "blocked", + "summary": {"passed": 1, "blocked": 1, "attention": 1, "unavailable": 0}, + "checklist": [ + { + "id": "quality.drc.clean", + "step": "drc", + "category": "quality_gate", + "owner": "qor", + "policy": "block", + "state": "failed", + "blocked": True, + "title": "Final DRC clean", + "summary": "drc_count=2 (required == 0)", + "source": { + "kind": "qor_gate", + "path": "drc_ecc/analysis/qor_summary.json", + "gate_id": "qor.drc.clean", + }, + "evidence": [{"kind": "feature", "path": "drc_ecc/feature/drc.step.json"}], + }, + { + "id": "report.optional.image", + "step": "workspace", + "category": "report", + "owner": "checklist", + "policy": "warn", + "state": "warning", + "blocked": False, + "title": "Optional image", + "summary": "Optional image is missing.", + "source": {"kind": "package", "path": "filler_ecc/output/gcd_filler.png"}, + "evidence": [], + }, + { + "id": "provenance.initial.rtl", + "step": "workspace", + "category": "provenance", + "owner": "checklist", + "policy": "block", + "state": "pass", + "blocked": False, + "title": "Initial RTL", + "summary": "Current output is present and non-empty.", + "source": {"kind": "provenance", "path": "origin/gcd.v"}, + "evidence": [], + }, + ], + } + ), + encoding="utf-8", + ) + + class FakeFlow: + def __init__(self, workspace): + assert workspace.directory == workspace_dir + + def collect_signoff_package(self, options): + assert options.archive is False + assert options.materialize is False + return SimpleNamespace() + + monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow) + + review = signoff_export.inspect_signoff_package(SimpleNamespace(directory=workspace_dir)) + + assert review["status"] == "blocked" + assert [group["id"] for group in review["groups"]] == [ + "initial", + "config", + "harden", + "final_design", + "sta", + "spef", + "reports", + ] + drc_group = next(group for group in review["groups"] if group["id"] == "final_design") + assert drc_group == { + "id": "final_design", + "label": "Final Design", + "status": "blocked", + "available": 0, + "expected": 2, + "summary": "1 blocking checklist requirements", + } + reports_group = next(group for group in review["groups"] if group["id"] == "reports") + assert reports_group["status"] == "ready" + assert [risk["severity"] for risk in review["risks"]] == ["blocked", "warning"] + blocked_risk = next(risk for risk in review["risks"] if risk["severity"] == "blocked") + assert blocked_risk["details"] == [ + { + "kind": "quality_gate", + "label": "Final DRC clean", + "location": "drc_ecc/analysis/qor_summary.json", + "reason": "drc_count=2 (required == 0)", + "owner": "qor", + "policy": "block", + "state": "failed", + "evidence": [{"kind": "feature", "path": "drc_ecc/feature/drc.step.json"}], + } + ] + + +def test_inspect_signoff_package_blocks_when_current_checklist_is_unavailable( + monkeypatch, tmp_path +): + workspace_dir = tmp_path / "workspace" + (workspace_dir / "home").mkdir(parents=True) + + class FakeFlow: + def __init__(self, workspace): + assert workspace.directory == workspace_dir + + def collect_signoff_package(self, options): + return SimpleNamespace() + + monkeypatch.setattr(signoff_export, "EngineFlow", FakeFlow) + + review = signoff_export.inspect_signoff_package(SimpleNamespace(directory=workspace_dir)) + + assert review["status"] == "blocked" + assert review["risks"][0]["title"] == "Signoff checklist unavailable" + + +def test_workspace_inspect_signoff_waits_for_session_mutation_lock(monkeypatch, tmp_path): + workspace = SimpleNamespace(directory=tmp_path / "workspace") + sessions = WorkspaceSessionRegistry() + session = sessions.open_session(workspace.directory, workspace=workspace) + entered = threading.Event() + results = queue.Queue() + + def fake_inspect(active_workspace): + assert active_workspace is workspace + entered.set() + return {"status": "ready", "groups": [], "risks": []} + + monkeypatch.setattr(signoff_export, "inspect_signoff_package", fake_inspect) + api = WorkspaceRuntimeApi(sessions=sessions) + + def run_inspection(): + try: + results.put( + api.inspect_signoff( + WorkspaceInspectSignoffRequest(workspace_id=session.workspace_id) + ) + ) + except BaseException as error: # pragma: no cover - re-raised below + results.put(error) + + with session.mutation_lock: + worker = threading.Thread(target=run_inspection) + worker.start() + assert not entered.wait(0.1) + assert worker.is_alive() + + worker.join(timeout=2) + assert not worker.is_alive() + result = results.get_nowait() + if isinstance(result, BaseException): + raise result + assert result == {"status": "ready", "groups": [], "risks": []} + + +def test_export_signoff_package_archive_collects_temporarily_and_replaces_atomically( + monkeypatch, + tmp_path, +): + from chipcompiler.engine.signoff_export import export_signoff_package_archive + + output_path = tmp_path / "nested" / "chosen.tar.gz" + captured_output_dirs = [] + + class FakeFlow: + def __init__(self, workspace): + assert workspace == "workspace" + + def collect_signoff_package(self, options): + captured_output_dirs.append(options.output_dir) + package_dir = Path(options.output_dir) / "design_signoff_package" + package_dir.mkdir(parents=True, exist_ok=True) + (package_dir / "dummy.txt").write_text("archive") + return SimpleNamespace( + ok=True, + package_dir=str(package_dir), + missing_required=[], + ) + + monkeypatch.setattr( + "chipcompiler.engine.signoff_export.EngineFlow", + FakeFlow, + ) + + result = export_signoff_package_archive("workspace", str(output_path)) + + assert result == str(output_path.resolve()) + import tarfile + + with tarfile.open(output_path, "r:gz") as tar: + assert tar.extractfile("design_signoff_package/dummy.txt").read() == b"archive" + assert captured_output_dirs + assert not Path(captured_output_dirs[0]).exists() + assert not list(output_path.parent.glob(f".{output_path.name}.*")) + + +def test_export_signoff_package_archive_preserves_existing_target_on_incomplete_result( + monkeypatch, + tmp_path, +): + from chipcompiler.engine.signoff_export import export_signoff_package_archive + + output_path = tmp_path / "existing.tar.gz" + output_path.write_bytes(b"old") + + class FakeFlow: + def __init__(self, workspace): + pass + + def collect_signoff_package(self, options): + return SimpleNamespace( + ok=False, + package_dir=None, + missing_required=["harden/design.gds", "harden/design.lef"], + ) + + monkeypatch.setattr( + "chipcompiler.engine.signoff_export.EngineFlow", + FakeFlow, + ) + + with pytest.raises(SignoffExportError) as exc_info: + export_signoff_package_archive("workspace", str(output_path)) + + assert "harden/design.gds" in str(exc_info.value) + assert "harden/design.lef" in str(exc_info.value) + assert output_path.read_bytes() == b"old" + + +def test_export_signoff_package_archive_replaces_symlink_entry_not_target( + monkeypatch, + tmp_path, +): + from chipcompiler.engine.signoff_export import export_signoff_package_archive + + target = tmp_path / "target.tar.gz" + target.write_bytes(b"target") + output_path = tmp_path / "chosen.tar.gz" + try: + output_path.symlink_to(target.name) + except (OSError, NotImplementedError): + pytest.skip("symlinks are unavailable") + + class FakeFlow: + def __init__(self, workspace): + pass + + def collect_signoff_package(self, options): + package_dir = Path(options.output_dir) / "design_signoff_package" + package_dir.mkdir(parents=True, exist_ok=True) + (package_dir / "dummy.txt").write_text("new") + return SimpleNamespace(ok=True, package_dir=str(package_dir), missing_required=[]) + + monkeypatch.setattr( + "chipcompiler.engine.signoff_export.EngineFlow", + FakeFlow, + ) + + export_signoff_package_archive("workspace", str(output_path)) + + assert not output_path.is_symlink() + import tarfile + + with tarfile.open(output_path, "r:gz") as tar: + assert tar.extractfile("design_signoff_package/dummy.txt").read() == b"new" + assert target.read_bytes() == b"target" diff --git a/ecos/runtime-adapter/test/test_stdio_server.py b/ecos/runtime-adapter/test/test_stdio_server.py new file mode 100644 index 000000000..34408edbb --- /dev/null +++ b/ecos/runtime-adapter/test/test_stdio_server.py @@ -0,0 +1,247 @@ +import io +import json +import os +import select +import subprocess +import sys +from pathlib import Path + +from chipcompiler.data import create_workspace +from ecos_runtime_adapter.server import RuntimeServer +from ecos_runtime_adapter.stdio_server import run_stdio_server +from ecos_runtime_adapter.transport import ContentLengthDecoder, encode_content_length_frame + + +def _request(method: str, request_id, params: dict | None = None) -> bytes: + payload = {"jsonrpc": "2.0", "method": method, "id": request_id} + if params is not None: + payload["params"] = params + return encode_content_length_frame(json.dumps(payload, separators=(",", ":"))) + + +def _notification(method: str, params: dict | None = None) -> bytes: + payload = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + return encode_content_length_frame(json.dumps(payload, separators=(",", ":"))) + + +def _decode_output(output: bytes) -> list[dict]: + decoder = ContentLengthDecoder() + return [json.loads(message) for message in decoder.feed(output)] + + +def _read_subprocess_response(process: subprocess.Popen) -> dict: + assert process.stdout is not None + assert select.select([process.stdout], [], [], 5)[0] + length_line = process.stdout.readline() + assert length_line.startswith(b"Content-Length: ") + blank_line = process.stdout.readline() + assert blank_line == b"\r\n" + length = int(length_line.removeprefix(b"Content-Length: ").strip()) + payload = process.stdout.read(length) + return json.loads(payload) + + +def _write_subprocess_request(process: subprocess.Popen, method: str, request_id, params=None): + assert process.stdin is not None + process.stdin.write(_request(method, request_id, params)) + process.stdin.flush() + + +def _stdio_command(*, persistent_db: bool = False) -> list[str]: + enabled = "True" if persistent_db else "False" + return [ + sys.executable, + "-c", + ( + "from ecos_runtime_adapter.stdio_server import main; " + f"raise SystemExit(main(persistent_db_enabled={enabled}))" + ), + ] + + +def _create_real_workspace(tmp_path: Path, minimal_ics55_pdk_factory) -> Path: + pdk_root = minimal_ics55_pdk_factory(tmp_path / "ics55") + rtl_path = tmp_path / "gcd.v" + rtl_path.write_text("module gcd(input clk, output y); assign y = clk; endmodule\n") + workspace_dir = tmp_path / "workspace" + create_workspace( + directory=workspace_dir, + origin_def="", + origin_verilog=rtl_path, + pdk="ics55", + pdk_root=pdk_root, + parameters={ + "PDK": "ics55", + "Design": "gcd", + "Top module": "gcd", + "Clock": "clk", + "Frequency max [MHz]": 100, + }, + ) + return workspace_dir + + +def test_stdio_server_writes_only_content_length_framed_responses(): + stdin = io.BytesIO( + _request("rpc.hello", 1, {"version": 1}) + + _request("rpc.ping", 2) + + _request("rpc.shutdown", 3) + ) + stdout = io.BytesIO() + + rc = run_stdio_server(stdin, stdout, server=RuntimeServer()) + + raw = stdout.getvalue() + assert rc == 0 + assert raw.startswith(b"Content-Length: ") + assert raw.count(b"Content-Length: ") == 3 + responses = _decode_output(raw) + assert [response["id"] for response in responses] == [1, 2, 3] + assert responses[0]["result"]["version"] == 1 + assert responses[1]["result"] == {"ok": True} + assert responses[2]["result"] == {"ok": True} + + +def test_stdio_server_does_not_write_response_for_notification(): + stdin = io.BytesIO(_notification("rpc.ping")) + stdout = io.BytesIO() + + rc = run_stdio_server(stdin, stdout, server=RuntimeServer()) + + assert rc == 0 + assert stdout.getvalue() == b"" + + +def test_stdio_server_stops_after_shutdown_notification_in_buffer(): + stdin = io.BytesIO(_notification("rpc.shutdown") + _request("rpc.ping", 1)) + stdout = io.BytesIO() + + rc = run_stdio_server(stdin, stdout, server=RuntimeServer()) + + assert rc == 0 + assert stdout.getvalue() == b"" + + +def test_stdio_server_redirects_print_noise_away_from_protocol_stdout(capfd): + server = RuntimeServer() + server.dispatcher.add_method("test.noisyPrint", lambda: print("tool output") or {"ok": True}) + stdin = io.BytesIO(_request("test.noisyPrint", 1)) + stdout = io.BytesIO() + + rc = run_stdio_server(stdin, stdout, server=server) + + captured = capfd.readouterr() + assert rc == 0 + assert captured.out == "" + assert "tool output" in captured.err + assert _decode_output(stdout.getvalue())[0]["result"] == {"ok": True} + + +def test_stdio_server_redirects_fd_stdout_noise_away_from_protocol_stdout(capfd): + server = RuntimeServer() + + def noisy_fd(): + os.write(1, b"tool output\n") + return {"ok": True} + + server.dispatcher.add_method("test.noisyFd", noisy_fd) + stdin = io.BytesIO(_request("test.noisyFd", 1)) + stdout = io.BytesIO() + + rc = run_stdio_server(stdin, stdout, server=server) + + captured = capfd.readouterr() + assert rc == 0 + assert captured.out == "" + assert "tool output" in captured.err + assert _decode_output(stdout.getvalue())[0]["result"] == {"ok": True} + + +def test_rpc_stdio_subprocess_smoke(): + stdin = ( + _request("rpc.hello", 1, {"version": 1}) + + _request("rpc.ping", 2) + + _request( + "rpc.shutdown", + 3, + ) + ) + + completed = subprocess.run( + _stdio_command(), + input=stdin, + cwd=os.getcwd(), + capture_output=True, + check=False, + ) + + assert completed.returncode == 0 + responses = _decode_output(completed.stdout) + assert [response["id"] for response in responses] == [1, 2, 3] + assert responses[1]["result"] == {"ok": True} + + +def test_rpc_stdio_subprocess_persistent_db_smoke(): + stdin = _request("rpc.hello", 1, {"version": 1}) + _request("rpc.shutdown", 2) + + completed = subprocess.run( + _stdio_command(persistent_db=True), + input=stdin, + cwd=os.getcwd(), + capture_output=True, + check=False, + ) + + assert completed.returncode == 0 + responses = _decode_output(completed.stdout) + assert [response["id"] for response in responses] == [1, 2] + assert "db.ensure" in responses[0]["result"]["capabilities"] + assert "db.release" in responses[0]["result"]["capabilities"] + + +def test_rpc_stdio_subprocess_workspace_open_home_smoke(tmp_path, minimal_ics55_pdk_factory): + ws = _create_real_workspace(tmp_path, minimal_ics55_pdk_factory) + process = subprocess.Popen( + _stdio_command(), + cwd=os.getcwd(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + try: + _write_subprocess_request(process, "workspace.open", 1, {"directory": str(ws)}) + open_response = _read_subprocess_response(process) + workspace_id = open_response["result"]["workspaceId"] + + _write_subprocess_request( + process, + "workspace.home", + 2, + {"workspaceId": workspace_id}, + ) + home_response = _read_subprocess_response(process) + + _write_subprocess_request(process, "rpc.shutdown", 3) + shutdown_response = _read_subprocess_response(process) + stderr = process.communicate(timeout=5)[1] + finally: + if process.poll() is None: + process.kill() + process.communicate() + + assert process.returncode == 0, stderr.decode("utf-8", errors="replace") + assert open_response["result"] == { + "workspaceId": workspace_id, + "workspaceRevision": 1, + "directory": str(ws.resolve()), + "executionReadiness": {"ready": False, "code": "pdk_binding_missing"}, + } + assert home_response == { + "jsonrpc": "2.0", + "result": {"path": str(ws.resolve() / "home" / "home.json")}, + "id": 2, + } + assert shutdown_response == {"jsonrpc": "2.0", "result": {"ok": True}, "id": 3} diff --git a/ecos/runtime-adapter/test/test_transport.py b/ecos/runtime-adapter/test/test_transport.py new file mode 100644 index 000000000..f9620ccc1 --- /dev/null +++ b/ecos/runtime-adapter/test/test_transport.py @@ -0,0 +1,53 @@ +import pytest +from ecos_runtime_adapter.transport import ( + ContentLengthDecoder, + TransportError, + encode_content_length_frame, +) + + +def test_encodes_and_decodes_one_content_length_frame(): + frame = encode_content_length_frame(b'{"jsonrpc":"2.0","id":1}') + + assert frame.startswith(b"Content-Length: 24\r\n\r\n") + decoder = ContentLengthDecoder() + assert decoder.feed(frame) == [b'{"jsonrpc":"2.0","id":1}'] + + +def test_decodes_multiple_frames_from_one_buffer(): + frame = encode_content_length_frame(b'{"id":1}') + encode_content_length_frame(b'{"id":2}') + + decoder = ContentLengthDecoder() + + assert decoder.feed(frame) == [b'{"id":1}', b'{"id":2}'] + + +def test_buffers_partial_header_and_payload_until_complete(): + frame = encode_content_length_frame(b'{"id":1}') + decoder = ContentLengthDecoder() + + assert decoder.feed(frame[:5]) == [] + assert decoder.feed(frame[5:20]) == [] + assert decoder.feed(frame[20:-1]) == [] + assert decoder.feed(frame[-1:]) == [b'{"id":1}'] + + +def test_malformed_content_length_header_is_transport_error(): + decoder = ContentLengthDecoder() + + with pytest.raises(TransportError, match="Content-Length"): + decoder.feed(b"Content-Length: nope\r\n\r\n{}") + + +def test_missing_content_length_header_is_transport_error(): + decoder = ContentLengthDecoder() + + with pytest.raises(TransportError, match="Content-Length"): + decoder.feed(b"X-Length: 2\r\n\r\n{}") + + +def test_oversize_payload_is_transport_error(): + decoder = ContentLengthDecoder(max_payload_size=4) + + with pytest.raises(TransportError, match="exceeds"): + decoder.feed(encode_content_length_frame(b"12345")) diff --git a/ecos/runtime-adapter/test/test_workspace_api.py b/ecos/runtime-adapter/test/test_workspace_api.py new file mode 100644 index 000000000..06af7f377 --- /dev/null +++ b/ecos/runtime-adapter/test/test_workspace_api.py @@ -0,0 +1,1715 @@ +import json +import queue +import threading +from pathlib import Path +from types import SimpleNamespace + +import pytest +from chipcompiler.data import StateEnum +from ecos_runtime_adapter.requests import ( + DbEnsureRequest, + DbReleaseRequest, + FlowRunRequest, + FlowRunStepRequest, + OperationIdRequest, + OperationStartFlowRequest, + WorkspaceCreateRequest, + WorkspaceIdRequest, + WorkspaceInfoRequest, + WorkspaceMutationRequest, + WorkspaceOpenRequest, + WorkspaceRecoverInterruptedRequest, + WorkspaceSyncConfigRequest, +) +from ecos_runtime_adapter.sessions import WorkspaceSessionRegistry +from ecos_runtime_adapter.workspace_api import RuntimeApiError, WorkspaceRuntimeApi + + +class DummyEngineDB: + def __init__(self, flow): + self.flow = flow + self.initialized = False + self.close_calls = 0 + + def has_init(self): + return self.initialized + + def create_db_engine(self, step): + self.flow.init_db_engine_calls += 1 + self.flow.init_db_engine_steps.append(None if step is None else step.name) + self.flow.init_db_engine_inputs.append( + ( + None if step is None else getattr(step, "input_def", ""), + None if step is None else getattr(step, "input_verilog", ""), + ) + ) + self.flow.call_order.append(("init_db_engine",)) + self.initialized = self.flow.next_init_success + return self.initialized + + def close(self): + if not self.initialized: + return + self.close_calls += 1 + self.initialized = False + + +class DummyFlow: + instances = [] + next_run_states = [] + next_init_success = True + successful_steps = set() + workspace_step_specs = None + + def __init__(self, workspace): + self.workspace = workspace + self.added_steps = [] + self.created = False + self.prepared_for_rerun = False + self.run_steps_calls = [] + self.run_calls = [] + self.flow_init_db_engine_calls = 0 + self.init_db_engine_calls = 0 + self.init_db_engine_steps = [] + self.init_db_engine_inputs = [] + self.call_order = [] + specs = self.workspace_step_specs or ( + {"name": "Synthesis", "tool": "yosys"}, + {"name": "Floorplan", "tool": "ecc"}, + ) + self.workspace_steps = [SimpleNamespace(**spec) for spec in specs] + self.completed_steps = set() + self.engine_db = DummyEngineDB(self) + DummyFlow.instances.append(self) + + def has_init(self): + return False + + def add_step(self, step, tool, state): + self.added_steps.append((step, tool, state)) + self.workspace.flow.data.setdefault("steps", []).append( + {"name": step, "tool": tool, "state": state} + ) + + def create_step_workspaces(self): + self.created = True + + def run_steps(self, *, rerun=False): + self.run_steps_calls.append(rerun) + success = True + for workspace_step in self.workspace_steps: + self.init_db_engine() + state = self.run_step(workspace_step, rerun=rerun) + if state != StateEnum.Success: + success = False + break + return success + + def init_db_engine(self): + self.flow_init_db_engine_calls += 1 + self.call_order.append(("flow_init_db_engine",)) + if self.engine_db is None: + self.engine_db = DummyEngineDB(self) + workspace_step = self.workspace_steps[0] + for candidate in self.workspace_steps: + if candidate.name not in self.completed_steps: + workspace_step = candidate + break + return self.engine_db.create_db_engine(workspace_step) + + def run_step(self, workspace_step, *, rerun=False): + name = workspace_step if isinstance(workspace_step, str) else workspace_step.name + self.run_calls.append((name, rerun)) + self.call_order.append(("run_step", name, rerun)) + state = DummyFlow.next_run_states.pop(0) if DummyFlow.next_run_states else StateEnum.Success + if state == StateEnum.Success: + self.completed_steps.add(name) + workspace_step_object = self.get_workspace_step(name) + if getattr(workspace_step_object, "tool", "") == "sizer": + if self.engine_db is not None: + self.engine_db.close() + self.engine_db = None + return state + + def get_workspace_step(self, name): + for step in self.workspace_steps: + if step.name == name: + return step + return None + + def get_step(self, name, tool): + for step in self.workspace.flow.data.get("steps", []): + if step.get("name") == name and step.get("tool") == tool: + return step + return None + + def save(self): + return True + + def check_state(self, name, tool, state): + return getattr(state, "value", state) == StateEnum.Success.value and name in ( + self.successful_steps + ) + + +def _workspace(directory: Path): + design = SimpleNamespace( + name="gcd", + top_module="gcd", + origin_def="", + origin_verilog=directory / "origin" / "gcd.v", + input_filelist="", + ) + return SimpleNamespace( + directory=directory.resolve(), + design=design, + flow=SimpleNamespace(path=directory / "home" / "flow.json", data={"steps": []}), + home=SimpleNamespace(path=directory / "home" / "home.json"), + ) + + +def _install_runtime_mocks(monkeypatch, tmp_path, *, create_workspace_files=True): + capture = { + "create_kwargs": None, + "input_filelist_lines": [], + "loaded": [], + "workspace_entries_when_create_called": [], + } + DummyFlow.instances = [] + DummyFlow.next_run_states = [] + DummyFlow.next_init_success = True + DummyFlow.successful_steps = set() + DummyFlow.workspace_step_specs = None + + def fake_create_workspace(**kwargs): + capture["create_kwargs"] = kwargs + input_filelist = kwargs.get("input_filelist") + if input_filelist and Path(input_filelist).exists(): + capture["input_filelist_lines"] = ( + Path(input_filelist).read_text(encoding="utf-8").splitlines() + ) + workspace_dir = Path(kwargs["directory"]) + if workspace_dir.is_dir(): + capture["workspace_entries_when_create_called"] = sorted( + path.name for path in workspace_dir.iterdir() + ) + return _workspace(Path(kwargs["directory"])) + + def fake_load_workspace(directory): + capture["loaded"].append(directory) + return _workspace(Path(directory)) + + monkeypatch.setattr("chipcompiler.data.create_workspace", fake_create_workspace) + monkeypatch.setattr("chipcompiler.data.load_workspace", fake_load_workspace) + monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", lambda workspace: None) + monkeypatch.setattr( + "chipcompiler.data.prepare_workspace_for_rerun", lambda ws, flow, **_kwargs: None + ) + monkeypatch.setattr("chipcompiler.engine.EngineFlow", DummyFlow) + monkeypatch.setattr( + "chipcompiler.rtl2gds.build_rtl2gds_flow", + lambda: [("Synthesis", "yosys", "Unstart")], + ) + + ws = tmp_path / "workspace" + if create_workspace_files: + (ws / "home").mkdir(parents=True) + (ws / "home" / "parameters.json").write_text("{}") + (ws / "home" / "flow.json").write_text(json.dumps({"steps": []})) + (ws / "home" / "home.json").write_text("{}") + return capture, ws + + +def _assert_call_waits_for_session_lock(api, workspace_id, call, entered): + session = api.sessions.get_session(workspace_id) + result_queue = queue.Queue() + + def run_call(): + try: + result_queue.put(("result", call())) + except BaseException as exc: # pragma: no cover - re-raised in test thread + result_queue.put(("error", exc)) + + with session.mutation_lock: + worker = threading.Thread(target=run_call) + worker.start() + assert not entered.wait(0.1) + assert worker.is_alive() + + worker.join(timeout=2) + assert not worker.is_alive() + kind, payload = result_queue.get_nowait() + if kind == "error": + raise payload + assert entered.is_set() + return payload + + +def test_create_workspace_returns_plain_runtime_result_and_session(monkeypatch, tmp_path): + capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + + result = api.create_workspace( + WorkspaceCreateRequest( + directory=str(ws), + pdk="ics55", + pdk_root="/pdk", + pdk_json={"name": "ics55"}, + parameters={"Design": "gcd"}, + rtl_list=["a.v"], + sdc="/constraints/top.sdc", + ) + ) + + assert set(result) == {"workspaceId", "workspaceRevision", "directory"} + assert result["directory"] == str(ws.resolve()) + assert result["workspaceId"].startswith("workspace-") + assert result["workspaceRevision"] == 1 + assert isinstance(capture["create_kwargs"]["pdk_json"], str) + assert capture["create_kwargs"]["sdc"] == "/constraints/top.sdc" + assert DummyFlow.instances[0].created + assert api.sessions.get_session(result["workspaceId"]).directory == ws.resolve() + + +def test_create_workspace_forwards_dynamic_flow_config(monkeypatch, tmp_path): + capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + flow_config = { + "start_step": "Synthesis", + "end_step": "Harden", + "steps": ["Synthesis", "RCX", "sta", "Harden"], + } + + WorkspaceRuntimeApi().create_workspace( + WorkspaceCreateRequest(directory=str(ws), flow_config=flow_config) + ) + + assert capture["create_kwargs"]["flow_config"] == flow_config + + +def test_create_workspace_writes_rtl_list_filelist_outside_workspace( + monkeypatch, + tmp_path, +): + capture, ws = _install_runtime_mocks( + monkeypatch, + tmp_path, + create_workspace_files=False, + ) + project = tmp_path / "project" + project.mkdir() + rtl_paths = [str(project / "a.v"), str(project / "b.v")] + api = WorkspaceRuntimeApi() + + api.create_workspace( + WorkspaceCreateRequest( + directory=str(ws), + pdk="ics55", + parameters={"Design": "gcd"}, + rtl_list=rtl_paths, + ) + ) + + input_filelist = Path(capture["create_kwargs"]["input_filelist"]) + assert input_filelist.name == "filelist" + assert not input_filelist.is_relative_to(ws) + assert capture["input_filelist_lines"] == rtl_paths + assert capture["workspace_entries_when_create_called"] == [] + assert not (ws / "filelist").exists() + + +def test_create_workspace_materializes_inline_pdk_json_before_data_api(monkeypatch, tmp_path): + pdk_json = {"name": "ics55", "lef": ["tech.lef"]} + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + seen = {} + + def create_workspace(**kwargs): + pdk_json_path = Path(kwargs["pdk_json"]) + seen["pdk_json"] = json.loads(pdk_json_path.read_text(encoding="utf-8")) + return _workspace(Path(kwargs["directory"])) + + monkeypatch.setattr("chipcompiler.data.create_workspace", create_workspace) + api = WorkspaceRuntimeApi() + + api.create_workspace( + WorkspaceCreateRequest( + directory=str(ws), + pdk="ics55", + pdk_json=pdk_json, + ) + ) + + assert seen["pdk_json"] == pdk_json + + +def test_create_workspace_with_inline_pdk_json_uses_real_data_api(monkeypatch, tmp_path): + pdk_root = tmp_path / "pdk" + tech = pdk_root / "tech.lef" + lef = pdk_root / "stdcell.lef" + liberty = pdk_root / "stdcell.lib" + for path in (tech, lef, liberty): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("VERSION 5.8 ;\n") + + workspace_dir = tmp_path / "workspace" + monkeypatch.setattr( + "chipcompiler.engine.workspace_flow.build_flow_for_workspace", + lambda _workspace: SimpleNamespace(), + ) + + api = WorkspaceRuntimeApi() + result = api.create_workspace( + WorkspaceCreateRequest( + directory=str(workspace_dir), + pdk="ics55", + pdk_json={ + "name": "ics55", + "root": str(pdk_root), + "tech": str(tech), + "lefs": [str(lef)], + "libs": [str(liberty)], + }, + parameters={ + "Design": "gcd", + "Top module": "gcd", + "Clock": "clk", + }, + ) + ) + + assert result["directory"] == str(workspace_dir.resolve()) + pdk_config_path = workspace_dir / "home" / "pdk.json" + assert pdk_config_path.is_file() + parameters = json.loads((workspace_dir / "home" / "parameters.json").read_text()) + assert parameters["PDK Config"] == str(pdk_config_path.resolve()) + fixfanout = json.loads((workspace_dir / "config" / "fixfanout_ecc.json").read_text()) + session = api.sessions.get_session(result["workspaceId"]) + assert session.workspace.pdk.tech == tech + assert session.workspace.pdk.lefs == [lef] + assert session.workspace.pdk.libs == [liberty] + assert session.workspace.pdk.buffers + assert fixfanout["insert_buffer"] == session.workspace.pdk.buffers[0] + assert session.directory == workspace_dir.resolve() + + +def test_open_workspace_loads_without_creating_step_workspaces(monkeypatch, tmp_path): + capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + + result = api.open_workspace(WorkspaceOpenRequest(directory=str(ws))) + + assert result == { + "workspaceId": result["workspaceId"], + "workspaceRevision": 1, + "directory": str(ws.resolve()), + } + assert capture["loaded"] == [str(ws)] + assert not DummyFlow.instances[0].created + + +def test_open_workspace_persists_identity_across_restart_and_directory_move( + monkeypatch, + tmp_path, +): + _capture, workspace_dir = _install_runtime_mocks(monkeypatch, tmp_path) + first = WorkspaceRuntimeApi().open_workspace(WorkspaceOpenRequest(directory=str(workspace_dir))) + + moved_dir = tmp_path / "moved-workspace" + workspace_dir.rename(moved_dir) + other_dir = tmp_path / "other-workspace" + (other_dir / "home").mkdir(parents=True) + for name, contents in { + "parameters.json": "{}", + "flow.json": '{"steps": []}', + "home.json": "{}", + }.items(): + (other_dir / "home" / name).write_text(contents, encoding="utf-8") + + restarted = WorkspaceRuntimeApi() + restarted.open_workspace(WorkspaceOpenRequest(directory=str(other_dir))) + reopened = restarted.open_workspace(WorkspaceOpenRequest(directory=str(moved_dir))) + + assert first["workspaceRevision"] == reopened["workspaceRevision"] == 1 + assert reopened["workspaceId"] == first["workspaceId"] + persisted = json.loads( + (moved_dir / "home" / "engineering-snapshot.json").read_text(encoding="utf-8") + ) + assert persisted["workspaceId"] == first["workspaceId"] + assert persisted["workspaceRevision"] == 1 + + +def test_engineering_snapshot_recovers_committed_step_without_runtime_events( + monkeypatch, + tmp_path, +): + _capture, workspace_dir = _install_runtime_mocks(monkeypatch, tmp_path) + published_events = [] + api = WorkspaceRuntimeApi(event_publisher=published_events.append) + opened = api.open_workspace(WorkspaceOpenRequest(directory=str(workspace_dir))) + workspace_id = opened["workspaceId"] + session = api.sessions.get_session(workspace_id) + step = SimpleNamespace(name="Synthesis", tool="yosys", log=SimpleNamespace(file="")) + + def execute(_request, *, observer=None, preserve_user_inputs=False): + assert preserve_user_inputs is False + session.workspace.flow.data = { + "steps": [{"name": "Synthesis", "tool": "yosys", "state": "Success"}], + } + observer.on_step_completed(step, StateEnum.Success) + return {"rerun": False} + + monkeypatch.setattr(api, "_flow_run", execute) + started = api.start_flow_operation( + OperationStartFlowRequest( + workspace_id=workspace_id, + idempotency_key="run-1", + ) + ) + for _ in range(100): + status = api.operation_status(OperationIdRequest(operation_id=started["operationId"])) + if status["state"] == "succeeded": + break + threading.Event().wait(0.01) + + published_events.clear() + snapshot = api.engineering_snapshot(WorkspaceIdRequest(workspace_id=workspace_id)) + assert snapshot["workspaceRevision"] == 2 + assert snapshot["flow"]["steps"] == [{"name": "Synthesis", "tool": "yosys", "state": "Success"}] + assert status["workspaceRevision"] == 2 + + duplicate = api.start_flow_operation( + OperationStartFlowRequest( + workspace_id=workspace_id, + expected_workspace_revision=1, + idempotency_key="run-1", + ) + ) + assert duplicate["operationId"] == started["operationId"] + assert duplicate["deduplicated"] is True + + reopened_api = WorkspaceRuntimeApi() + reopened = reopened_api.open_workspace(WorkspaceOpenRequest(directory=str(workspace_dir))) + assert reopened["workspaceRevision"] == 2 + assert ( + reopened_api.engineering_snapshot(WorkspaceIdRequest(workspace_id=reopened["workspaceId"])) + == snapshot + ) + + +def test_stale_revision_rejects_run_before_creating_operation(monkeypatch, tmp_path): + _capture, workspace_dir = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + opened = api.open_workspace(WorkspaceOpenRequest(directory=str(workspace_dir))) + + with pytest.raises(RuntimeApiError) as exc_info: + api.start_flow_operation( + OperationStartFlowRequest( + workspace_id=opened["workspaceId"], + expected_workspace_revision=0, + idempotency_key="stale-run", + ) + ) + + assert exc_info.value.code == "revision_conflict" + assert exc_info.value.data == {"expectedRevision": 0, "actualRevision": 1} + assert api.operations.workspace_snapshot(opened["workspaceId"])["operations"] == [] + + +def test_recover_interrupted_is_marker_scoped_and_idempotent(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + session = api.sessions.get_session(workspace_id) + session.workspace.flow.data = { + "steps": [ + { + "name": "place", + "tool": "dreamplace", + "state": "Ongoing", + "info": { + "runtime_operation": { + "schema": 1, + "operation_id": "operation-1", + "runtime_instance_id": "runtime-old", + "started_at": 1.0, + } + }, + }, + {"name": "route", "tool": "ecc", "state": "Ongoing", "info": {}}, + { + "name": "Floorplan", + "tool": "ecc", + "state": "Success", + "info": { + "runtime_operation": { + "schema": 1, + "operation_id": "operation-2", + } + }, + }, + { + "name": "CTS", + "tool": "ecc", + "state": "Ongoing", + "info": { + "runtime_operation": { + "schema": 1, + "operation_id": "operation-partial", + } + }, + }, + { + "name": "STA", + "tool": "ecc", + "state": "Ongoing", + "info": { + "runtime_operation": { + "schema": 1, + "operation_id": "operation-active", + "runtime_instance_id": "runtime-current", + "started_at": 2.0, + } + }, + }, + { + "name": "DRC", + "tool": "ecc", + "state": "Ongoing", + "info": { + "runtime_operation": { + "schema": 1, + "operation_id": "operation-previous", + "runtime_instance_id": "runtime-old", + "started_at": 3.0, + } + }, + }, + ] + } + mismatch = api.recover_interrupted( + WorkspaceRecoverInterruptedRequest(workspace_id, "operation-other") + ) + assert mismatch == {"recovered": []} + + result = api.recover_interrupted( + WorkspaceRecoverInterruptedRequest(workspace_id, "operation-1") + ) + assert result == { + "recovered": [ + { + "step": "place", + "tool": "dreamplace", + "operationId": "operation-1", + "logFile": str(ws / "place_dreamplace" / "log" / "place.log"), + } + ] + } + assert session.workspace.flow.data["steps"][0]["state"] == StateEnum.Imcomplete.value + assert session.workspace.flow.data["steps"][0]["info"] == {} + assert session.workspace.flow.data["steps"][1]["state"] == "Ongoing" + assert session.workspace.flow.data["steps"][2]["state"] == "Success" + monkeypatch.setattr( + api.operations, + "is_active", + lambda operation_id: operation_id == "operation-active", + ) + previous = api.recover_interrupted(WorkspaceRecoverInterruptedRequest(workspace_id)) + assert previous == { + "recovered": [ + { + "step": "DRC", + "tool": "ecc", + "operationId": "operation-previous", + "logFile": str(ws / "DRC_ecc" / "log" / "DRC.log"), + } + ] + } + assert session.workspace.flow.data["steps"][3]["state"] == "Ongoing" + assert session.workspace.flow.data["steps"][4]["state"] == "Ongoing" + assert session.workspace.flow.data["steps"][5]["state"] == StateEnum.Imcomplete.value + assert api.recover_interrupted( + WorkspaceRecoverInterruptedRequest(workspace_id, "operation-1") + ) == {"recovered": []} + + +def test_create_workspace_replaces_existing_same_directory_session(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + + opened = api.open_workspace(WorkspaceOpenRequest(directory=str(ws))) + opened_session = api.sessions.get_session(opened["workspaceId"]) + opened_session.db_handle = object() + created = api.create_workspace(WorkspaceCreateRequest(directory=str(ws))) + + assert created["workspaceId"] != opened["workspaceId"] + assert opened_session.db_handle is None + with pytest.raises(RuntimeApiError, match="workspace session not found"): + api.workspace_home(WorkspaceIdRequest(workspace_id=opened["workspaceId"])) + created_session = api.sessions.get_session(created["workspaceId"]) + assert created_session.workspace is not opened_session.workspace + + +def test_open_workspace_reuses_existing_same_directory_session(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + + first = api.open_workspace(WorkspaceOpenRequest(directory=str(ws))) + first_session = api.sessions.get_session(first["workspaceId"]) + second = api.open_workspace(WorkspaceOpenRequest(directory=str(ws))) + + assert second["workspaceId"] == first["workspaceId"] + assert api.sessions.get_session(second["workspaceId"]).workspace is first_session.workspace + + +def test_workspace_home_and_info_use_session_id(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + monkeypatch.setattr( + "chipcompiler.tools.get_step_info", + lambda workspace, step, id: {"path": Path(workspace.directory) / "layout.png"}, + ) + api = WorkspaceRuntimeApi() + opened = api.open_workspace(WorkspaceOpenRequest(directory=str(ws))) + workspace_id = opened["workspaceId"] + + home = api.workspace_home(WorkspaceIdRequest(workspace_id=workspace_id)) + info = api.workspace_info( + WorkspaceInfoRequest(workspace_id=workspace_id, step="Synthesis", info_id="layout") + ) + + assert home == {"path": str(ws.resolve() / "home" / "home.json")} + assert info == { + "step": "Synthesis", + "id": "layout", + "info": {"path": str(ws.resolve() / "layout.png")}, + } + + +def test_refresh_sync_and_reset_flow_use_session(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + refreshed = [] + synced = [] + prepared = [] + + monkeypatch.setattr( + "chipcompiler.data.refresh_workspace_config", + lambda workspace: refreshed.append(workspace.directory), + ) + monkeypatch.setattr( + "chipcompiler.data.sync_workspace_config_to_parameters", + lambda workspace, path: synced.append((workspace.directory, path)) or True, + ) + monkeypatch.setattr( + "chipcompiler.data.prepare_workspace_for_rerun", + lambda workspace, flow, **_kwargs: prepared.append((workspace.directory, flow)), + ) + config_dir = ws / "config" + config_dir.mkdir() + config_path = config_dir / "route.json" + config_path.write_text("{}") + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + refresh = api.refresh_config(WorkspaceIdRequest(workspace_id=workspace_id)) + sync = api.sync_config( + WorkspaceSyncConfigRequest( + workspace_id=workspace_id, + config_path=str(config_path), + expected_workspace_revision=1, + ) + ) + reset = api.reset_flow( + WorkspaceMutationRequest( + workspace_id=workspace_id, + expected_workspace_revision=2, + ) + ) + + assert refresh == {"directory": str(ws.resolve()), "refreshed": True} + assert sync == { + "directory": str(ws.resolve()), + "configPath": str(config_path.resolve()), + "parametersChanged": True, + "refreshed": True, + "workspaceRevision": 2, + } + assert reset == {"directory": str(ws.resolve()), "workspaceRevision": 3} + assert refreshed == [ws.resolve(), ws.resolve()] + assert synced == [(ws.resolve(), config_path.resolve())] + assert prepared == [(ws.resolve(), DummyFlow.instances[-1])] + + +def test_refresh_config_releases_active_session_db(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + db_handle = api.sessions.get_session(workspace_id).db_handle + + result = api.refresh_config(WorkspaceIdRequest(workspace_id=workspace_id)) + + assert result == {"directory": str(ws.resolve()), "refreshed": True} + assert db_handle.close_calls == 1 + assert api.sessions.get_session(workspace_id).db_handle is None + + +def test_sync_config_releases_active_session_db_only_when_parameters_change( + monkeypatch, + tmp_path, +): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + config_dir = ws / "config" + config_dir.mkdir() + config_path = config_dir / "route.json" + config_path.write_text("{}") + changed = [False, True] + + monkeypatch.setattr( + "chipcompiler.data.sync_workspace_config_to_parameters", + lambda _workspace, _path: changed.pop(0), + ) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + db_handle = api.sessions.get_session(workspace_id).db_handle + + unchanged = api.sync_config( + WorkspaceSyncConfigRequest( + workspace_id=workspace_id, + config_path=str(config_path), + expected_workspace_revision=1, + ) + ) + assert unchanged["parametersChanged"] is False + assert unchanged["refreshed"] is False + assert db_handle.close_calls == 0 + + changed_result = api.sync_config( + WorkspaceSyncConfigRequest( + workspace_id=workspace_id, + config_path=str(config_path), + expected_workspace_revision=1, + ) + ) + + assert changed_result["parametersChanged"] is True + assert changed_result["refreshed"] is True + assert changed_result["workspaceRevision"] == 2 + assert db_handle.close_calls == 1 + assert api.sessions.get_session(workspace_id).db_handle is None + + with pytest.raises(RuntimeApiError, match="Workspace Revision does not match"): + api.sync_config( + WorkspaceSyncConfigRequest( + workspace_id=workspace_id, + config_path=str(config_path), + expected_workspace_revision=1, + ) + ) + + +def test_reset_flow_releases_active_session_db_before_prepare(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + prepared = [] + + def prepare(workspace, flow, **_kwargs): + prepared.append((workspace.directory, flow)) + assert api.sessions.get_session(workspace_id).db_handle is None + + monkeypatch.setattr("chipcompiler.data.prepare_workspace_for_rerun", prepare) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + db_handle = api.sessions.get_session(workspace_id).db_handle + + result = api.reset_flow( + WorkspaceMutationRequest( + workspace_id=workspace_id, + expected_workspace_revision=1, + ) + ) + + assert result == {"directory": str(ws.resolve()), "workspaceRevision": 2} + assert db_handle.close_calls == 1 + assert prepared == [(ws.resolve(), DummyFlow.instances[-1])] + + +def test_refresh_config_waits_for_session_mutation_lock(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + entered = threading.Event() + + def refresh_config(_workspace): + entered.set() + + monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", refresh_config) + + _assert_call_waits_for_session_lock( + api=api, + workspace_id=workspace_id, + call=lambda: api.refresh_config(WorkspaceIdRequest(workspace_id=workspace_id)), + entered=entered, + ) + + +def test_sync_config_waits_for_session_mutation_lock(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + config_dir = ws / "config" + config_dir.mkdir() + config_path = config_dir / "route.json" + config_path.write_text("{}") + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + entered = threading.Event() + + def sync_config(_workspace, _path): + entered.set() + return False + + monkeypatch.setattr("chipcompiler.data.sync_workspace_config_to_parameters", sync_config) + + _assert_call_waits_for_session_lock( + api=api, + workspace_id=workspace_id, + call=lambda: api.sync_config( + WorkspaceSyncConfigRequest( + workspace_id=workspace_id, + config_path=str(config_path), + expected_workspace_revision=1, + ) + ), + entered=entered, + ) + + +def test_reset_flow_waits_for_session_mutation_lock(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + entered = threading.Event() + + def build_flow(_workspace): + entered.set() + return SimpleNamespace() + + monkeypatch.setattr("ecos_runtime_adapter.workspace_api.build_flow_for_workspace", build_flow) + monkeypatch.setattr( + "chipcompiler.data.prepare_workspace_for_rerun", lambda _ws, _flow, **_kwargs: None + ) + + _assert_call_waits_for_session_lock( + api=api, + workspace_id=workspace_id, + call=lambda: api.reset_flow( + WorkspaceMutationRequest( + workspace_id=workspace_id, + expected_workspace_revision=1, + ) + ), + entered=entered, + ) + + +def test_unknown_session_returns_structured_runtime_error(): + api = WorkspaceRuntimeApi() + + with pytest.raises(RuntimeApiError) as exc_info: + api.workspace_home(WorkspaceIdRequest(workspace_id="missing")) + + assert exc_info.value.code == "workspace_session_not_found" + + +def test_db_ensure_rejects_disabled_runtime_api(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + with pytest.raises(RuntimeApiError) as exc_info: + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id)) + + assert exc_info.value.code == "command_failed" + assert exc_info.value.message == "persistent_db_disabled" + + +def test_db_ensure_initializes_requested_step_and_stores_session_db(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + result = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + + flow = DummyFlow.instances[-1] + session = api.sessions.get_session(workspace_id) + assert result == { + "workspaceId": workspace_id, + "enabled": True, + "active": True, + "reused": False, + "step": "Floorplan", + } + assert flow.init_db_engine_steps == ["Floorplan"] + assert session.db_handle is flow.engine_db + assert session.db_handle.has_init() + + +def test_db_ensure_without_step_uses_flow_selection_rule(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + result = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id)) + + flow = DummyFlow.instances[-1] + assert result == { + "workspaceId": workspace_id, + "enabled": True, + "active": True, + "reused": False, + "step": "", + } + assert flow.flow_init_db_engine_calls == 1 + assert flow.init_db_engine_steps == ["Synthesis"] + assert api.sessions.get_session(workspace_id).db_handle is flow.engine_db + + +def test_db_ensure_reuses_initialized_session_db(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + first = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + db_handle = api.sessions.get_session(workspace_id).db_handle + + second = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + + assert first["reused"] is False + assert second == { + "workspaceId": workspace_id, + "enabled": True, + "active": True, + "reused": True, + "step": "Floorplan", + } + assert api.sessions.get_session(workspace_id).db_handle is db_handle + + +def test_db_ensure_unknown_step_returns_runtime_error(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + with pytest.raises(RuntimeApiError) as exc_info: + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Missing")) + + assert exc_info.value.code == "command_failed" + assert exc_info.value.message == "step not found: Missing" + assert api.sessions.get_session(workspace_id).db_handle is None + + +def test_db_ensure_does_not_store_uninitialized_db(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + DummyFlow.next_init_success = False + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + result = api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + + assert result == { + "workspaceId": workspace_id, + "enabled": True, + "active": False, + "reused": False, + "step": "Floorplan", + } + assert api.sessions.get_session(workspace_id).db_handle is None + + +def test_db_release_closes_and_clears_active_session_db(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + db_handle = api.sessions.get_session(workspace_id).db_handle + + result = api.db_release(DbReleaseRequest(workspace_id=workspace_id)) + + assert result == {"workspaceId": workspace_id, "released": True} + assert db_handle.close_calls == 1 + assert api.sessions.get_session(workspace_id).db_handle is None + + +def test_db_release_is_idempotent_when_no_db_is_active(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + result = api.db_release(DbReleaseRequest(workspace_id=workspace_id)) + + assert result == {"workspaceId": workspace_id, "released": False} + + +def test_db_release_closes_db_with_injected_session_registry(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi( + sessions=WorkspaceSessionRegistry(), + persistent_db_enabled=True, + ) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + db_handle = api.sessions.get_session(workspace_id).db_handle + + result = api.db_release(DbReleaseRequest(workspace_id=workspace_id)) + + assert result == {"workspaceId": workspace_id, "released": True} + assert db_handle.close_calls == 1 + assert api.sessions.get_session(workspace_id).db_handle is None + + +def test_runtime_modules_do_not_import_typer_or_click(): + for path in Path("chipcompiler/runtime").glob("*.py"): + source = path.read_text() + assert "import typer" not in source + assert "import click" not in source + + +def test_flow_run_uses_run_steps_and_prepare_on_rerun(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + prepared = [] + monkeypatch.setattr( + "chipcompiler.data.prepare_workspace_for_rerun", + lambda workspace, flow, **kwargs: prepared.append((workspace.directory, flow, kwargs)), + ) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=True)) + + flow = DummyFlow.instances[-1] + assert result == {"rerun": True} + assert prepared == [(ws.resolve(), flow, {"preserve_user_inputs": False})] + assert flow.run_steps_calls == [True] + + +def test_gui_flow_operation_rerun_preserves_current_user_inputs(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + captured = {} + + def fake_flow_run(request, *, observer=None, preserve_user_inputs=False): + captured.update( + request=request, + observer=observer, + preserve_user_inputs=preserve_user_inputs, + ) + return {"rerun": request.rerun} + + def fake_start(**kwargs): + return kwargs["runner"](None) + + monkeypatch.setattr(api, "_flow_run", fake_flow_run) + monkeypatch.setattr(api.operations, "start", fake_start) + + result = api.start_flow_operation( + OperationStartFlowRequest( + workspace_id=workspace_id, + origin="gui", + rerun=True, + idempotency_key="gui-rerun", + ) + ) + + assert result == {"rerun": True} + assert captured["request"].workspace_id == workspace_id + assert captured["request"].rerun is True + assert captured["observer"] is None + assert captured["preserve_user_inputs"] is True + + +def test_flow_run_without_active_session_db_closes_transient_db( + monkeypatch, + tmp_path, +): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False)) + + flow = DummyFlow.instances[-1] + assert result == {"rerun": False} + assert not flow.engine_db.has_init() + assert flow.engine_db.close_calls == 1 + assert api.sessions.get_session(workspace_id).db_handle is None + + +def test_flow_run_with_active_session_db_injects_and_captures_final_db( + monkeypatch, + tmp_path, +): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + db_handle = api.sessions.get_session(workspace_id).db_handle + + result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False)) + + flow = DummyFlow.instances[-1] + assert result == {"rerun": False} + assert flow.engine_db is db_handle + assert api.sessions.get_session(workspace_id).db_handle is db_handle + assert db_handle.close_calls == 0 + + +def test_flow_run_rerun_releases_stale_db_and_captures_new_db(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + prepared = [] + + def prepare(workspace, flow, **_kwargs): + prepared.append((workspace.directory, flow)) + assert api.sessions.get_session(workspace_id).db_handle is None + + monkeypatch.setattr("chipcompiler.data.prepare_workspace_for_rerun", prepare) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + stale_db = api.sessions.get_session(workspace_id).db_handle + + result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=True)) + + flow = DummyFlow.instances[-1] + assert result == {"rerun": True} + assert stale_db.close_calls == 1 + assert prepared == [(ws.resolve(), flow)] + assert api.sessions.get_session(workspace_id).db_handle is flow.engine_db + assert flow.engine_db is not stale_db + assert flow.engine_db.has_init() + + +def test_flow_run_sizer_boundary_captures_post_sizer_db(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + sizer_def = str(ws / "Timing optimization_sizer" / "output" / "sizer.def") + sizer_verilog = str(ws / "Timing optimization_sizer" / "output" / "sizer.v") + DummyFlow.workspace_step_specs = ( + {"name": "Floorplan", "tool": "ecc", "input_def": "origin.def"}, + { + "name": "Timing optimization", + "tool": "sizer", + "input_def": "floorplan.def", + "input_verilog": "floorplan.v", + "output": {"def": sizer_def, "verilog": sizer_verilog}, + }, + { + "name": "Legalization", + "tool": "ecc", + "input_def": sizer_def, + "input_verilog": sizer_verilog, + }, + ) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + pre_sizer_db = api.sessions.get_session(workspace_id).db_handle + + result = api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False)) + + flow = DummyFlow.instances[-1] + post_sizer_db = api.sessions.get_session(workspace_id).db_handle + assert result == {"rerun": False} + assert pre_sizer_db.close_calls == 1 + assert post_sizer_db is flow.engine_db + assert post_sizer_db is not pre_sizer_db + assert post_sizer_db.has_init() + assert flow.init_db_engine_steps[-1] == "Legalization" + assert flow.init_db_engine_inputs[-1] == (sizer_def, sizer_verilog) + + +def test_flow_run_sizer_boundary_failure_captures_post_sizer_db( + monkeypatch, + tmp_path, +): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + sizer_def = str(ws / "Timing optimization_sizer" / "output" / "sizer.def") + sizer_verilog = str(ws / "Timing optimization_sizer" / "output" / "sizer.v") + DummyFlow.workspace_step_specs = ( + {"name": "Floorplan", "tool": "ecc", "input_def": "origin.def"}, + { + "name": "Timing optimization", + "tool": "sizer", + "input_def": "floorplan.def", + "input_verilog": "floorplan.v", + "output": {"def": sizer_def, "verilog": sizer_verilog}, + }, + { + "name": "Legalization", + "tool": "ecc", + "input_def": sizer_def, + "input_verilog": sizer_verilog, + }, + ) + DummyFlow.next_run_states = [ + StateEnum.Success, + StateEnum.Success, + StateEnum.Imcomplete, + ] + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + pre_sizer_db = api.sessions.get_session(workspace_id).db_handle + + with pytest.raises(RuntimeApiError) as exc_info: + api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False)) + + flow = DummyFlow.instances[-1] + post_sizer_db = api.sessions.get_session(workspace_id).db_handle + assert exc_info.value.code == "command_failed" + assert pre_sizer_db.close_calls == 1 + assert post_sizer_db is flow.engine_db + assert post_sizer_db is not pre_sizer_db + assert post_sizer_db.has_init() + assert flow.init_db_engine_steps[-1] == "Legalization" + + +def test_flow_run_sizer_boundary_exception_captures_post_sizer_db( + monkeypatch, + tmp_path, +): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + DummyFlow.workspace_step_specs = ( + {"name": "Floorplan", "tool": "ecc"}, + {"name": "Timing optimization", "tool": "sizer"}, + {"name": "Legalization", "tool": "ecc"}, + ) + + def run_steps_raises_after_post_sizer_db(self, *, rerun=False): + del rerun + self.engine_db.close() + self.engine_db = DummyEngineDB(self) + self.engine_db.create_db_engine(self.workspace_steps[-1]) + raise ValueError("post-sizer failure") + + monkeypatch.setattr(DummyFlow, "run_steps", run_steps_raises_after_post_sizer_db) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + pre_sizer_db = api.sessions.get_session(workspace_id).db_handle + + with pytest.raises(ValueError, match="post-sizer failure"): + api.flow_run(FlowRunRequest(workspace_id=workspace_id, rerun=False)) + + flow = DummyFlow.instances[-1] + post_sizer_db = api.sessions.get_session(workspace_id).db_handle + assert pre_sizer_db.close_calls == 1 + assert post_sizer_db is flow.engine_db + assert post_sizer_db is not pre_sizer_db + assert post_sizer_db.has_init() + + +def test_flow_run_step_initializes_db_before_direct_step(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + result = api.flow_run_step( + FlowRunStepRequest(workspace_id=workspace_id, step="Synthesis", rerun=False) + ) + + flow = DummyFlow.instances[-1] + assert result == {"step": "Synthesis", "state": "Success"} + assert flow.init_db_engine_steps == ["Synthesis"] + assert flow.call_order == [ + ("init_db_engine",), + ("run_step", "Synthesis", False), + ] + assert flow.run_steps_calls == [] + assert not flow.engine_db.has_init() + assert flow.engine_db.close_calls == 1 + assert api.sessions.get_session(workspace_id).db_handle is None + + +def test_flow_run_step_with_active_session_db_injects_and_captures_final_db( + monkeypatch, + tmp_path, +): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + db_handle = api.sessions.get_session(workspace_id).db_handle + + result = api.flow_run_step( + FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=False) + ) + + flow = DummyFlow.instances[-1] + assert result == {"step": "Floorplan", "state": "Success"} + assert flow.engine_db is db_handle + assert api.sessions.get_session(workspace_id).db_handle is db_handle + assert db_handle.close_calls == 0 + + +def test_flow_run_step_successful_sizer_releases_active_session_db( + monkeypatch, + tmp_path, +): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + DummyFlow.workspace_step_specs = ( + {"name": "Floorplan", "tool": "ecc"}, + {"name": "Timing optimization", "tool": "sizer"}, + ) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + pre_sizer_db = api.sessions.get_session(workspace_id).db_handle + + result = api.flow_run_step( + FlowRunStepRequest( + workspace_id=workspace_id, + step="Timing optimization", + rerun=False, + ) + ) + + flow = DummyFlow.instances[-1] + assert result == {"step": "Timing optimization", "state": "Success"} + assert flow.engine_db is None + assert pre_sizer_db.close_calls == 1 + assert api.sessions.get_session(workspace_id).db_handle is None + + +def test_flow_run_step_sizer_exception_clears_closed_session_db( + monkeypatch, + tmp_path, +): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + DummyFlow.workspace_step_specs = ( + {"name": "Floorplan", "tool": "ecc"}, + {"name": "Timing optimization", "tool": "sizer"}, + ) + + def run_step_raises_after_sizer_boundary(self, workspace_step, *, rerun=False): + del workspace_step, rerun + self.engine_db.close() + self.engine_db = None + raise ValueError("sizer failure") + + monkeypatch.setattr(DummyFlow, "run_step", run_step_raises_after_sizer_boundary) + api = WorkspaceRuntimeApi(persistent_db_enabled=True) + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.db_ensure(DbEnsureRequest(workspace_id=workspace_id, step="Floorplan")) + pre_sizer_db = api.sessions.get_session(workspace_id).db_handle + + with pytest.raises(ValueError, match="sizer failure"): + api.flow_run_step( + FlowRunStepRequest( + workspace_id=workspace_id, + step="Timing optimization", + rerun=False, + ) + ) + + assert pre_sizer_db.close_calls == 1 + assert api.sessions.get_session(workspace_id).db_handle is None + + +def test_flow_run_step_rerun_refreshes_before_db_init(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + refreshed = [] + + def refresh_config(workspace): + refreshed.append(workspace.directory) + DummyFlow.instances[-1].call_order.append(("refresh_config", workspace.directory)) + + monkeypatch.setattr("chipcompiler.data.refresh_workspace_config", refresh_config) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + result = api.flow_run_step( + FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True) + ) + + flow = DummyFlow.instances[-1] + assert result == {"step": "Floorplan", "state": "Success"} + assert refreshed == [ws.resolve()] + assert flow.call_order == [ + ("refresh_config", ws.resolve()), + ("init_db_engine",), + ("run_step", "Floorplan", True), + ] + + +def test_flow_run_step_rerun_clears_step_artifacts_and_resets_step_state(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + step_dir = ws / "Floorplan_ecc" + artifact_dirs = [ + step_dir / "output", + step_dir / "data", + step_dir / "feature", + step_dir / "analysis", + step_dir / "report", + step_dir / "log", + ] + for directory in artifact_dirs: + (directory / "nested").mkdir(parents=True) + (directory / "nested" / "stale").write_text("stale") + script_dir = step_dir / "script" + script_dir.mkdir() + (script_dir / "keep.tcl").write_text("keep") + + subflow_path = step_dir / "subflow.json" + subflow_path.write_text( + json.dumps( + { + "path": str(subflow_path), + "steps": [ + { + "name": "load data", + "state": "Success", + "runtime": "0:00:03", + "peak memory (mb)": 24, + "info": {"instances": 12}, + } + ], + } + ) + ) + checklist_path = step_dir / "checklist.json" + checklist_path.write_text( + json.dumps( + { + "path": str(checklist_path), + "checklist": [{"item": "stale", "state": "Success"}], + } + ) + ) + DummyFlow.workspace_step_specs = ( + { + "name": "Floorplan", + "tool": "ecc", + "output": {"dir": step_dir / "output"}, + "data": {"dir": step_dir / "data"}, + "feature": {"dir": step_dir / "feature"}, + "analysis": {"dir": step_dir / "analysis"}, + "report": {"dir": step_dir / "report"}, + "log": {"dir": step_dir / "log"}, + "subflow": SimpleNamespace(path=subflow_path, steps=[]), + "checklist": SimpleNamespace(path=checklist_path, checklist=[]), + }, + ) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + session = api.sessions.get_session(workspace_id) + session.workspace.flow.data = { + "steps": [ + { + "name": "Floorplan", + "tool": "ecc", + "state": "Success", + "runtime": "0:00:04", + "peak memory (mb)": 30, + } + ] + } + + result = api.flow_run_step( + FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True) + ) + + assert result == {"step": "Floorplan", "state": "Success"} + assert all(list(directory.iterdir()) == [] for directory in artifact_dirs) + assert (script_dir / "keep.tcl").read_text() == "keep" + flow_record = next( + record + for record in session.workspace.flow.data["steps"] + if record["name"] == "Floorplan" and record["tool"] == "ecc" + ) + assert flow_record == { + "name": "Floorplan", + "tool": "ecc", + "state": "Unstart", + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + assert json.loads(subflow_path.read_text()) == { + "path": str(subflow_path), + "steps": [ + { + "name": "load data", + "state": "Unstart", + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + ], + } + checklist = json.loads(checklist_path.read_text()) + assert checklist["schema_version"] == 3 + assert checklist["kind"] == "signoff_checklist" + assert checklist["checker_revision"] == "signoff-v1" + assert checklist["status"] == "ready" + assert checklist["summary"] == { + "passed": 0, + "blocked": 0, + "attention": 0, + "unavailable": 0, + } + assert checklist["checklist"] == [] + + +def test_flow_run_step_gui_rerun_resets_target_and_downstream_subflows(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + + def step_spec(name, tool): + step_dir = ws / f"{name}_{tool}" + artifact_dir = step_dir / "output" + (artifact_dir / "nested").mkdir(parents=True) + (artifact_dir / "nested" / "stale").write_text(name) + subflow_path = step_dir / "subflow.json" + subflow_path.write_text( + json.dumps( + { + "path": str(subflow_path), + "steps": [ + { + "name": "load data", + "state": "Success", + "runtime": "0:00:01", + "peak memory (mb)": 10, + "info": {"stale": name}, + }, + { + "name": "run tool", + "state": "Success", + "runtime": "0:00:02", + "peak memory (mb)": 20, + "info": {"stale": name}, + }, + ], + } + ) + ) + checklist_path = step_dir / "checklist.json" + checklist_path.write_text(json.dumps({"checklist": [{"item": name}]})) + return { + "name": name, + "tool": tool, + "output": {"dir": artifact_dir}, + "subflow": SimpleNamespace(path=subflow_path, steps=[]), + "checklist": SimpleNamespace(path=checklist_path, checklist=[]), + } + + synthesis = step_spec("Synthesis", "yosys") + floorplan = step_spec("Floorplan", "ecc") + route = step_spec("route", "ecc") + DummyFlow.workspace_step_specs = (synthesis, floorplan, route) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + session = api.sessions.get_session(workspace_id) + session.workspace.flow.data = { + "steps": [ + { + "name": spec["name"], + "tool": spec["tool"], + "state": "Success", + "runtime": "0:00:04", + "peak memory (mb)": 30, + "info": {"stale": spec["name"]}, + } + for spec in (synthesis, floorplan, route) + ] + } + + result = api._flow_run_step( + FlowRunStepRequest( + workspace_id=workspace_id, + step="Floorplan", + rerun=True, + ), + reset_dependents=True, + ) + + assert result == {"step": "Floorplan", "state": "Success"} + assert (ws / "Synthesis_yosys" / "output" / "nested" / "stale").read_text() == "Synthesis" + for spec in (floorplan, route): + assert list(spec["output"]["dir"].iterdir()) == [] + checklist = json.loads(spec["checklist"].path.read_text()) + assert checklist["schema_version"] == 3 + assert checklist["kind"] == "signoff_checklist" + assert checklist["checker_revision"] == "signoff-v1" + assert checklist["status"] == "ready" + assert checklist["summary"] == { + "passed": 0, + "blocked": 0, + "attention": 0, + "unavailable": 0, + } + assert checklist["checklist"] == [] + reset_subflow = json.loads(spec["subflow"].path.read_text()) + assert all( + step["state"] == "Unstart" + and step["runtime"] == "" + and step["peak memory (mb)"] == 0 + and step["info"] == {} + for step in reset_subflow["steps"] + ) + + records = {record["name"]: record for record in session.workspace.flow.data["steps"]} + assert any( + record["name"] == "Synthesis" and record["state"] == "Success" + for record in session.workspace.flow.data["steps"] + ) + for name in ("Floorplan", "route"): + assert records[name] == { + "name": name, + "tool": "ecc", + "state": "Unstart", + "runtime": "", + "peak memory (mb)": 0, + "info": {}, + } + + +def test_flow_run_step_rerun_rejects_an_open_layout_edit(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + api.sessions.get_session(workspace_id).layout_edit_session = object() + + with pytest.raises(RuntimeApiError) as exc_info: + api.flow_run_step( + FlowRunStepRequest(workspace_id=workspace_id, step="Floorplan", rerun=True) + ) + + assert exc_info.value.code == "layout_edit_active" + assert "close the rendered layout" in exc_info.value.message + assert DummyFlow.instances[-1].run_calls == [] + + +def test_flow_run_step_skips_successful_step_without_db_init(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + DummyFlow.successful_steps = {"Synthesis"} + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + result = api.flow_run_step( + FlowRunStepRequest(workspace_id=workspace_id, step="Synthesis", rerun=False) + ) + + flow = DummyFlow.instances[-1] + assert result == {"step": "Synthesis", "state": "Success"} + assert flow.init_db_engine_calls == 0 + assert flow.call_order == [("run_step", "Synthesis", False)] + + +def test_flow_run_step_unknown_step_returns_runtime_error(monkeypatch, tmp_path): + _capture, ws = _install_runtime_mocks(monkeypatch, tmp_path) + api = WorkspaceRuntimeApi() + workspace_id = api.open_workspace(WorkspaceOpenRequest(directory=str(ws)))["workspaceId"] + + with pytest.raises(RuntimeApiError) as exc_info: + api.flow_run_step( + FlowRunStepRequest(workspace_id=workspace_id, step="Missing", rerun=False) + ) + + assert exc_info.value.code == "command_failed" + assert "step not found" in exc_info.value.message diff --git a/ecos/scripts/ecc-runtime-adapter-wrapper.sh b/ecos/scripts/ecc-runtime-adapter-wrapper.sh new file mode 100755 index 000000000..965a57596 --- /dev/null +++ b/ecos/scripts/ecc-runtime-adapter-wrapper.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +exec "$REPO_ROOT/ecc/.venv/bin/python" \ + "$REPO_ROOT/ecos/runtime-adapter/main.py" "$@" From 0388a6e854c60249551c6eab2536f07b095dd564 Mon Sep 17 00:00:00 2001 From: KoEkko <2251930460@qq.com> Date: Tue, 1 Sep 2026 22:46:21 +0800 Subject: [PATCH 005/116] fix(runtime): retain PDK bindings across reconnects --- .../services/eccRpc/workspaceRuntime.test.ts | 14 ++++++++-- .../services/eccRpc/workspaceRuntime.ts | 3 +++ .../eccRpc/workspaceRuntimeCommands.ts | 27 ++++++++++++++++--- .../services/eccRpc/workspaceSessions.ts | 11 ++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) 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 db74058ca..7d0ab9980 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 @@ -1425,11 +1425,18 @@ describe('EccWorkspaceRuntime', () => { it('handshakes and reopens retained sessions when the sidecar returns a new client', async () => { const { client, service, sidecar } = createService() + const workspaceBindings = { + inputs: {}, + pdk: { root: '/pdks/ics55', version: '1.10.102' }, + } 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 workspace = await service.openWorkspace({ + directory: '/work/demo', + workspaceBindings, + }) const replacementClient = new FakeRpcClient() replacementClient.responses.push( { capabilities: [], eccVersion: '0.1.0', version: 1 }, @@ -1448,7 +1455,10 @@ describe('EccWorkspaceRuntime', () => { 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 }, 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 6e976c40f..619a88d51 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.ts @@ -649,6 +649,9 @@ 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, 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 8eb609426..9b54a2fbf 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntimeCommands.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntimeCommands.ts @@ -73,14 +73,18 @@ export class WorkspaceRuntimeCommands { createWorkspace(request: EccWorkspaceCreateRequest): Promise { return this.context.enqueue('workspace.create', undefined, async () => { const client = await this.context.ensureStarted() + const payload = workspaceSpecCreatePayload(request) as { + workspaceBindings: Record + } const response = await client.call( 'workspace.create', - workspaceSpecCreatePayload(request), + payload, ) const session = this.context.sessions.activate( response.directory, response.workspaceId, response.workspaceRevision ?? 1, + payload.workspaceBindings, ) return { directory: session.directory, @@ -96,8 +100,20 @@ export class WorkspaceRuntimeCommands { await migrateWorkspaceConfigFilenames(request.directory) if (this.context.lazyWorkspaceOpen) { const existing = this.context.sessions.findByDirectory(request.directory) - const session = - existing ?? this.context.sessions.activate(request.directory, null) + 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, workspaceHandle: session.workspaceHandle } } const client = await this.context.ensureStarted() @@ -111,6 +127,7 @@ export class WorkspaceRuntimeCommands { response.directory, response.workspaceId, response.workspaceRevision ?? 1, + request.workspaceBindings, ) return { directory: session.directory, @@ -158,6 +175,10 @@ export class WorkspaceRuntimeCommands { request.workspaceHandle, result.workspaceRevision, ) + this.context.sessions.updateBindings( + request.workspaceHandle, + request.workspaceBindings, + ) return result } 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 3dd2eadb6..9e315c425 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSessions.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSessions.ts @@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto' export interface WorkspaceSessionRecord { directory: string eccWorkspaceId: string | null + workspaceBindings?: Record workspaceHandle: string workspaceRevision: number } @@ -43,10 +44,12 @@ export class WorkspaceSessionRegistry { directory: string, eccWorkspaceId: string | null, workspaceRevision = 0, + workspaceBindings?: Record, ): WorkspaceSessionRecord { const session = { directory, eccWorkspaceId, + ...(workspaceBindings ? { workspaceBindings } : {}), workspaceHandle: this.idProvider(), workspaceRevision, } @@ -92,6 +95,14 @@ export class WorkspaceSessionRegistry { 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, From e0649d8d4119c260e6dc6f4ba849e36761559e68 Mon Sep 17 00:00:00 2001 From: KoEkko <2251930460@qq.com> Date: Tue, 1 Sep 2026 23:25:01 +0800 Subject: [PATCH 006/116] fix(runtime): restore STA state after sidecar reconnect --- ecc | 2 +- .../composables/useBackendFlowStages.test.ts | 26 +++++++++++++++++++ .../src/composables/useBackendFlowStages.ts | 7 +++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/ecc b/ecc index cc76f4d03..51351032e 160000 --- a/ecc +++ b/ecc @@ -1 +1 @@ -Subproject commit cc76f4d033584713438d0d68b6975c0101eb5d3a +Subproject commit 51351032ea2619e85a7ebe24ac10089e38c7c1f6 diff --git a/ecos/gui/apps/renderer/src/composables/useBackendFlowStages.test.ts b/ecos/gui/apps/renderer/src/composables/useBackendFlowStages.test.ts index 557a408a0..a340a9b66 100644 --- a/ecos/gui/apps/renderer/src/composables/useBackendFlowStages.test.ts +++ b/ecos/gui/apps/renderer/src/composables/useBackendFlowStages.test.ts @@ -81,4 +81,30 @@ describe('useBackendFlowStages runtime projection', () => { flow.dynamicFlowStages.value.filter((step) => step.state === 'Ongoing'), ).toEqual([expect.objectContaining({ label: 'Place' })]) }) + + it('does not restore optimistic running state after the operation fails', () => { + const flow = useBackendFlowStages() + flow.setFirstRunStepOngoing() + state.runtimeEvents!.value.push( + { + data: { + runtimeProtocolType: 'step.started', + state: 'running', + step: 'Synthesis', + }, + }, + { + data: { + step: 'Synthesis', + type: 'error', + }, + }, + ) + + expect(flow.dynamicFlowStages.value[0]).toMatchObject({ + label: 'Synthesis', + state: 'Incomplete', + }) + expect(flow.hasOngoingRunStage.value).toBe(false) + }) }) diff --git a/ecos/gui/apps/renderer/src/composables/useBackendFlowStages.ts b/ecos/gui/apps/renderer/src/composables/useBackendFlowStages.ts index 99424a11b..0f4230af2 100644 --- a/ecos/gui/apps/renderer/src/composables/useBackendFlowStages.ts +++ b/ecos/gui/apps/renderer/src/composables/useBackendFlowStages.ts @@ -32,6 +32,7 @@ const setupStages: BackendFlowStage[] = Object.values(STEP_METADATA) const optimisticRun = ref<{ projectPath: string resetAll: boolean + runtimeEventCursor: unknown stepPath: string } | null>(null) @@ -78,6 +79,10 @@ export function useBackendFlowStages() { ) { return steps } + const latestRuntimeEvent = runtimeEvents.value[runtimeEvents.value.length - 1] + if (latestRuntimeEvent && latestRuntimeEvent !== request.runtimeEventCursor) { + return steps + } if (steps.some((step) => step.state === 'running')) return steps const next = steps.map((step) => ({ ...step, @@ -129,6 +134,7 @@ export function useBackendFlowStages() { optimisticRun.value = { projectPath: normalizedPath(currentProject.value?.path ?? ''), resetAll: Boolean(options.resetAll), + runtimeEventCursor: runtimeEvents.value[runtimeEvents.value.length - 1] ?? null, stepPath: '', } } @@ -137,6 +143,7 @@ export function useBackendFlowStages() { optimisticRun.value = { projectPath: normalizedPath(currentProject.value?.path ?? ''), resetAll: false, + runtimeEventCursor: runtimeEvents.value[runtimeEvents.value.length - 1] ?? null, stepPath, } } From 3a9ad62b8e880412b391e1393b6dfa5772e775cb Mon Sep 17 00:00:00 2001 From: KoEkko <2251930460@qq.com> Date: Tue, 1 Sep 2026 23:59:41 +0800 Subject: [PATCH 007/116] fix(qor): project workspace metrics from snapshots --- .../services/workspaceQorAnalysis.test.ts | 78 +++++-- .../electron/services/workspaceQorAnalysis.ts | 190 +++++++++++++++--- 2 files changed, 224 insertions(+), 44 deletions(-) diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts index a71da0778..e5537de3c 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts @@ -46,23 +46,26 @@ const engineeringSnapshot = JSON.stringify({ signoffAssessment: { status: 'ready' }, }) +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('builds current QoR and the selected baseline comparison', () => { - const workspace = (id: string, name: string) => ({ - 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}`, - }) const manifest = { project_id: 'project-1', name: 'demo', @@ -113,4 +116,49 @@ describe('analyzeWorkspaceQor', () => { result.qor.status === 'ready' ? result.qor.data.score.value : null, ) }) + + it('restores step metrics directly from an authoritative snapshot', () => { + const metric = JSON.parse(metricText(5000)).metrics[0] + const snapshot = JSON.stringify({ + metrics: [metric], + qorAssessment: { + metrics: [metric], + score: { gate: 'pass', threshold: 60, value: 73.5 }, + steps: [ + { + name: 'route', + order: 6, + status: 'pass', + stepId: 'route', + summaryMetricCount: 1, + }, + ], + }, + signoffAssessment: { status: 'ready' }, + }) + 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: { 'home/engineering-snapshot.json': snapshot }, + }) + + expect(result.qor).toMatchObject({ + data: { + metrics: [{ id: 'route_wirelength', stepId: 'Route', value: 5000 }], + score: { value: 73.5 }, + }, + status: 'ready', + }) + expect( + result.qor.status === 'ready' + ? result.qor.data.steps.find((step) => step.stepId === 'Route') + : null, + ).toMatchObject({ status: 'pass', stepId: 'Route', summaryMetricCount: 1 }) + }) }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts index 22ca3a1ff..6f456aa70 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts @@ -49,10 +49,55 @@ type ProjectStepStatus = NonNullable< ProjectQorWorkspaceInput['stepStatuses'][ProjectManifestFlowStep] > -function authoritativeAssessment( - text: string | null | undefined, -): ProjectQorWorkspaceInput['authoritativeAssessment'] { - if (!text) return null +interface WorkspaceQorInput extends ProjectQorWorkspaceInput { + snapshotQor: WorkspaceQorSummary | null +} + +interface SnapshotQorProjection { + assessment: ProjectQorWorkspaceInput['authoritativeAssessment'] + qor: WorkspaceQorSummary | null + stepMetricTexts: Partial> + stepSummaryTexts: Partial> +} + +function snapshotMetric( + value: unknown, + stepId: ProjectManifestFlowStep, +): MetricValue | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const metric = value as Record + const id = typeof metric.id === 'string' ? metric.id : '' + const name = typeof metric.display_name === 'string' ? metric.display_name : id + const number = metric.value + const polarity = metric.direction + 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'], + } +} + +function snapshotQorProjection(text: string | null | undefined): SnapshotQorProjection { + const empty: SnapshotQorProjection = { + assessment: null, + qor: null, + stepMetricTexts: {}, + stepSummaryTexts: {}, + } + if (!text) return empty try { const snapshot = JSON.parse(text) as Record const qor = snapshot.qorAssessment as Record | undefined @@ -69,9 +114,9 @@ function authoritativeAssessment( !Number.isFinite(threshold) || !['ready', 'attention', 'blocked'].includes(String(signoffStatus)) ) { - return null + return empty } - return { + const assessment = { gateStatus: gate as NonNullable< ProjectQorWorkspaceInput['authoritativeAssessment'] >['gateStatus'], @@ -81,8 +126,87 @@ function authoritativeAssessment( ProjectQorWorkspaceInput['authoritativeAssessment'] >['signoffStatus'], } + if (!Array.isArray(qor?.metrics) || !Array.isArray(qor.steps)) { + return { ...empty, assessment } + } + + const metrics: MetricValue[] = [] + const steps: WorkspaceQorSummary['steps'] = [] + const stepMetricTexts: Partial> = {} + const stepSummaryTexts: Partial> = {} + let offset = 0 + for (const rawStep of qor.steps) { + if (!rawStep || typeof rawStep !== 'object' || Array.isArray(rawStep)) { + return { ...empty, assessment } + } + const stepRecord = rawStep as Record + const count = stepRecord.summaryMetricCount + const order = stepRecord.order + if ( + !Number.isInteger(count) || + (count as number) < 0 || + !Number.isInteger(order) || + (order as number) < 0 + ) { + return { ...empty, assessment } + } + const nextOffset = offset + (count as number) + if (nextOffset > qor.metrics.length) return { ...empty, assessment } + const step = flowStep(stepRecord.stepId ?? stepRecord.name) + if ((!step || !FLOW_STEPS.includes(step as ProjectManifestFlowStep)) && count) { + return { ...empty, assessment } + } + if (step && FLOW_STEPS.includes(step as ProjectManifestFlowStep)) { + const canonicalStep = step as ProjectManifestFlowStep + if (stepMetricTexts[canonicalStep]) return { ...empty, assessment } + const stepMetrics = qor.metrics + .slice(offset, nextOffset) + .map((metric) => snapshotMetric(metric, canonicalStep)) + if (stepMetrics.some((metric) => metric === null)) { + return { ...empty, assessment } + } + const status = stepRecord.status + if (!['pass', 'blocked', 'incomplete', 'unavailable'].includes(String(status))) { + return { ...empty, assessment } + } + metrics.push(...(stepMetrics as MetricValue[])) + steps.push({ + stepId: canonicalStep, + order: order as number, + name: canonicalStep, + status: status as WorkspaceQorSummary['steps'][number]['status'], + summaryMetricCount: count as number, + metrics: stepMetrics as MetricValue[], + }) + stepMetricTexts[canonicalStep] = JSON.stringify({ + schema_version: 3, + metrics: qor.metrics.slice(offset, nextOffset), + }) + stepSummaryTexts[canonicalStep] = JSON.stringify({ + schema_version: 4, + quality_status: stepRecord.status, + }) + } + offset = nextOffset + } + return offset === qor.metrics.length + ? { + assessment, + qor: { + score: { + value: assessment.score, + gate: assessment.gateStatus, + threshold: assessment.scoreThreshold, + }, + metrics, + steps, + }, + stepMetricTexts, + stepSummaryTexts, + } + : { ...empty, assessment } } catch { - return null + return empty } } @@ -157,39 +281,45 @@ export function projectQorInputForWorkspace( manifest: ProjectManifest, workspaceId: string, texts: WorkspaceAnalysisTexts, -): ProjectQorWorkspaceInput | null { +): WorkspaceQorInput | null { const workspace = manifest.workspaces.find( (candidate) => candidate.workspace_id === workspaceId, ) if (!workspace) return null const statuses = flowStates(texts['home/flow.json']) + const snapshot = snapshotQorProjection(texts['home/engineering-snapshot.json']) return { branchFrom: workspace.branch_from, createdAt: workspace.created_at, staTimingIssuesText: texts[projectManagementStaTimingIssuesPath] ?? null, status: workspaceStatus(workspace.status, statuses), - authoritativeAssessment: authoritativeAssessment( - texts['home/engineering-snapshot.json'], - ), + authoritativeAssessment: snapshot.assessment, + snapshotQor: snapshot.qor, stepHotspotTexts: Object.fromEntries( projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ spec.step, texts[spec.hotspotsPath] ?? null, ]), ), - stepMetricTexts: Object.fromEntries( - projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ - spec.step, - texts[spec.metricsPath] ?? null, - ]), - ), + stepMetricTexts: { + ...Object.fromEntries( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ + spec.step, + texts[spec.metricsPath] ?? null, + ]), + ), + ...snapshot.stepMetricTexts, + }, stepStatuses: statuses, - stepSummaryTexts: Object.fromEntries( - projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ - spec.step, - texts[spec.summaryPath] ?? null, - ]), - ), + stepSummaryTexts: { + ...Object.fromEntries( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ + spec.step, + texts[spec.summaryPath] ?? null, + ]), + ), + ...snapshot.stepSummaryTexts, + }, workspaceId, workspaceName: workspace.name || workspaceId, workspaceKey: workspaceId, @@ -304,12 +434,14 @@ export function analyzeWorkspaceQor( const current = trend.workspaces.find( (workspace) => workspace.workspaceId === currentWorkspaceId, ) - const qor: ReadSection = current - ? { status: 'ready', data: workspaceQor(current, currentInput), issues: [] } - : { - status: 'unavailable', - issues: [{ code: 'WORKSPACE_QOR_UNAVAILABLE' }], - } + const qor: ReadSection = currentInput.snapshotQor + ? { status: 'ready', data: currentInput.snapshotQor, issues: [] } + : current + ? { status: 'ready', data: workspaceQor(current, currentInput), issues: [] } + : { + status: 'unavailable', + issues: [{ code: 'WORKSPACE_QOR_UNAVAILABLE' }], + } if (!baselineWorkspaceId || !baselineInput) { return { From f5bfb85c25bdab302ceeede72b6f429a8c92032d Mon Sep 17 00:00:00 2001 From: KoEkko <2251930460@qq.com> Date: Wed, 2 Sep 2026 10:09:33 +0800 Subject: [PATCH 008/116] refactor(gui): remove legacy backend paths --- .../desktop-electron/electron/main/index.ts | 6 +- .../electron/main/registerIpc.test.ts | 181 +++-- .../electron/main/registerIpc.ts | 91 --- .../electron/preload/index.test.ts | 25 - .../electron/preload/index.ts | 28 - .../backendProjectComparisonService.test.ts | 87 ++- .../backendProjectComparisonService.ts | 24 +- .../services/backendWorkspaceService.test.ts | 15 +- .../services/backendWorkspaceService.ts | 34 +- .../services/eccRpc/runtimeService.test.ts | 36 +- .../services/eccRpc/runtimeService.ts | 18 +- .../services/eccRpc/workspaceRuntime.test.ts | 112 +--- .../services/eccRpc/workspaceRuntime.ts | 36 +- .../eccRpc/workspaceRuntimeCommands.ts | 13 +- .../eccRpc/workspaceSnapshotCache.test.ts | 74 -- .../services/eccRpc/workspaceSnapshotCache.ts | 54 -- .../eccRpc/workspaceSnapshotLoader.test.ts | 44 -- .../eccRpc/workspaceSnapshotLoader.ts | 53 +- .../eccRpc/workspaceSpecAdapter.test.ts | 174 ----- .../services/eccRpc/workspaceSpecAdapter.ts | 329 --------- .../services/productCommandService.ts | 26 +- .../projectManagementReadService.test.ts | 15 +- .../electron/services/qorAnalysis.ts | 450 ------------- .../electron/services/workspacePdkBindings.ts | 34 +- .../services/workspaceQorAnalysis.test.ts | 88 ++- .../electron/services/workspaceQorAnalysis.ts | 496 ++++++-------- ecos/gui/apps/renderer/package.json | 4 - .../src/App.agentWorkspaceSetup.test.ts | 40 -- .../src/App.design-report-export.test.ts | 39 -- .../src/App.reconfigure-workspace.test.ts | 189 ------ .../src/App.step-config-dialog.test.ts | 36 - ecos/gui/apps/renderer/src/api/index.ts | 4 +- .../src/api/workspace.desktop-ipc.test.ts | 79 ++- ecos/gui/apps/renderer/src/api/workspace.ts | 382 ++++++----- .../image-preview/ImagePreviewContainer.vue | 68 -- .../ImagePreviewController.test.ts | 62 -- .../image-preview/ImagePreviewController.ts | 481 ------------- .../src/applications/image-preview/index.ts | 7 - .../image-preview/rulerDrawing.ts | 180 ----- .../src/applications/image-preview/types.ts | 11 - .../components/AIChatPanel.contract.test.ts | 286 -------- .../AgentExecutionContractPanel.test.ts | 44 -- .../AgentSessionContractPanels.test.ts | 12 - .../AgentWorkspaceSetupPanel.test.ts | 62 -- .../src/components/ChatInspectorPanel.test.ts | 28 - .../src/components/ChecklistTable.vue | 286 -------- .../DesignFilesManageDialog.test.ts | 22 - .../DesignReportExportDialog.test.ts | 40 -- .../components/DrawingArea.chipViewer.test.ts | 35 - .../DrawingArea.lifecycleGuard.test.ts | 28 - .../DrawingArea.loadingState.test.ts | 56 -- .../components/DrawingArea.runtime.test.ts | 24 - .../DrawingArea.tileGenerationWarning.test.ts | 19 - .../renderer/src/components/DrawingArea.vue | 371 ---------- .../DrawingToolbar.chipViewer.test.ts | 19 - .../DrawingToolbar.previewMode.test.ts | 24 - .../src/components/DrawingToolbar.vue | 174 ----- .../src/components/ECOSTerminal.test.ts | 352 ---------- .../src/components/FlowLogStepChooser.test.ts | 500 -------------- .../src/components/FlowLogStepChooser.vue | 189 ------ .../src/components/FlowLogStepList.vue | 262 -------- .../components/FrontendLeftSidebar.test.ts | 32 - .../FrontendProjectWizard.catalog.test.ts | 65 -- .../components/FrontendSourceEditor.test.ts | 26 - .../src/components/LeftSidebar.test.ts | 32 - .../renderer/src/components/MapsGallery.vue | 231 ------- .../src/components/MonacoLogViewer.test.ts | 74 -- .../src/components/NewProjectWizard.test.ts | 474 ------------- .../PdkResourcePickerDialog.test.ts | 26 - .../src/components/PluginToolsPanel.vue | 302 --------- .../renderer/src/components/RightPanel.vue | 51 -- .../renderer/src/components/RightSidebar.vue | 20 - .../SignoffPackageReviewDialog.test.ts | 42 -- .../src/components/StepConfigPanel.test.ts | 47 -- .../src/components/StepDashboard.test.ts | 228 ------- .../src/components/ThumbnailGallery.vue | 579 ---------------- .../renderer/src/components/TopBar.test.ts | 167 ----- .../renderer/src/components/WelcomePage.vue | 313 --------- .../WorkspaceStepConfigDialog.test.ts | 18 - .../flow-insights/FlowInsightsPanel.test.ts | 37 - .../FrontendDisassemblyViewer.test.ts | 61 -- .../components/home/StatusPieChart.test.ts | 36 - .../step-insights/timingInsights.test.ts | 43 -- .../components/thumbnailGalleryCache.test.ts | 58 -- .../src/components/thumbnailGalleryCache.ts | 24 - .../components/workbench/FlowLogPanel.test.ts | 59 -- .../workbench/FlowRunControl.test.ts | 37 - .../workbench/FlowStatusStrip.test.ts | 45 -- .../workbench/WorkspaceWorkbench.test.ts | 52 -- .../composables/flowLogSegmentPlan.test.ts | 75 --- .../src/composables/flowLogSegmentPlan.ts | 54 -- .../renderer/src/composables/requestIdle.ts | 17 - .../composables/useBackendFlowLogs.test.ts | 22 - .../src/composables/useDesktopRuntime.test.ts | 18 - .../apps/renderer/src/composables/useEDA.ts | 39 -- .../src/composables/usePdkManager.test.ts | 18 - .../composables/useStepDashboardData.test.ts | 79 --- .../src/composables/useVersion.test.ts | 18 - .../src/composables/useWorkspace.test.ts | 53 +- .../renderer/src/composables/useWorkspace.ts | 70 +- .../src/styles/index.opaqueShell.test.ts | 32 - .../src/styles/index.scrollbar.test.ts | 47 -- .../renderer/src/utils/checklistState.test.ts | 29 - .../apps/renderer/src/utils/checklistState.ts | 58 -- .../apps/renderer/src/utils/projectFlow.ts | 15 - .../src/views/ECCView.project-prefill.test.ts | 247 ------- .../src/views/FEView.creation.test.ts | 24 - ...endWorkspaceView.rtl-review-layout.test.ts | 23 - .../FrontendWorkspaceView.sim-layout.test.ts | 71 -- .../FrontendWorkspaceView.typography.test.ts | 32 - .../views/HomeView.current-step-log.test.ts | 21 - .../src/views/HomeView.flow-log.test.ts | 25 - .../views/HomeView.layout-fullscreen.test.ts | 275 -------- .../src/views/HomeView.typography.test.ts | 29 - .../src/views/PluginToolsView.layout.test.ts | 208 ------ .../ProjectsView.project-management.test.ts | 632 ------------------ .../views/TechLibraryView.workbench.test.ts | 25 - .../views/WorkspaceView.sidePanels.test.ts | 55 -- ...orkspaceViewWrapper.backgroundFlow.test.ts | 9 - .../WorkspaceViewWrapper.sidebar.test.ts | 12 - ...kspaceViewWrapper.workspaceRefresh.test.ts | 14 - .../src/views/homeViewFlowLogCopy.test.ts | 111 --- .../renderer/src/views/homeViewFlowLogCopy.ts | 76 --- .../views/homeViewFlowLogSelection.test.ts | 96 --- .../src/views/homeViewFlowLogSelection.ts | 62 -- .../shared/src/constants/ipcChannels.ts | 13 - .../src/contracts/backendProjectComparison.ts | 70 -- .../shared/src/contracts/eccRuntime.ts | 50 +- .../shared/src/contracts/productCommands.ts | 4 +- .../utils/projectManagementSummary.test.ts | 10 +- .../src/utils/projectManagementSummary.ts | 3 +- ecos/gui/pnpm-lock.yaml | 163 ----- 132 files changed, 893 insertions(+), 12248 deletions(-) delete mode 100644 ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotCache.test.ts delete mode 100644 ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotCache.ts delete mode 100644 ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSpecAdapter.test.ts delete mode 100644 ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSpecAdapter.ts delete mode 100644 ecos/gui/apps/renderer/src/App.agentWorkspaceSetup.test.ts delete mode 100644 ecos/gui/apps/renderer/src/App.design-report-export.test.ts delete mode 100644 ecos/gui/apps/renderer/src/App.reconfigure-workspace.test.ts delete mode 100644 ecos/gui/apps/renderer/src/App.step-config-dialog.test.ts delete mode 100644 ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewContainer.vue delete mode 100644 ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewController.test.ts delete mode 100644 ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewController.ts delete mode 100644 ecos/gui/apps/renderer/src/applications/image-preview/index.ts delete mode 100644 ecos/gui/apps/renderer/src/applications/image-preview/rulerDrawing.ts delete mode 100644 ecos/gui/apps/renderer/src/applications/image-preview/types.ts delete mode 100644 ecos/gui/apps/renderer/src/components/AIChatPanel.contract.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/AgentExecutionContractPanel.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/AgentSessionContractPanels.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/AgentWorkspaceSetupPanel.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/ChatInspectorPanel.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/ChecklistTable.vue delete mode 100644 ecos/gui/apps/renderer/src/components/DesignFilesManageDialog.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/DesignReportExportDialog.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/DrawingArea.chipViewer.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/DrawingArea.lifecycleGuard.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/DrawingArea.loadingState.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/DrawingArea.runtime.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/DrawingArea.tileGenerationWarning.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/DrawingArea.vue delete mode 100644 ecos/gui/apps/renderer/src/components/DrawingToolbar.chipViewer.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/DrawingToolbar.previewMode.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/DrawingToolbar.vue delete mode 100644 ecos/gui/apps/renderer/src/components/ECOSTerminal.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/FlowLogStepChooser.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/FlowLogStepChooser.vue delete mode 100644 ecos/gui/apps/renderer/src/components/FlowLogStepList.vue delete mode 100644 ecos/gui/apps/renderer/src/components/FrontendLeftSidebar.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/FrontendProjectWizard.catalog.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/LeftSidebar.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/MapsGallery.vue delete mode 100644 ecos/gui/apps/renderer/src/components/MonacoLogViewer.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/NewProjectWizard.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/PdkResourcePickerDialog.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/PluginToolsPanel.vue delete mode 100644 ecos/gui/apps/renderer/src/components/RightPanel.vue delete mode 100644 ecos/gui/apps/renderer/src/components/RightSidebar.vue delete mode 100644 ecos/gui/apps/renderer/src/components/SignoffPackageReviewDialog.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/StepConfigPanel.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/StepDashboard.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/ThumbnailGallery.vue delete mode 100644 ecos/gui/apps/renderer/src/components/TopBar.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/WelcomePage.vue delete mode 100644 ecos/gui/apps/renderer/src/components/WorkspaceStepConfigDialog.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/flow-insights/FlowInsightsPanel.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/frontend/FrontendDisassemblyViewer.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/home/StatusPieChart.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/thumbnailGalleryCache.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/thumbnailGalleryCache.ts delete mode 100644 ecos/gui/apps/renderer/src/components/workbench/FlowLogPanel.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/workbench/FlowRunControl.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/workbench/FlowStatusStrip.test.ts delete mode 100644 ecos/gui/apps/renderer/src/components/workbench/WorkspaceWorkbench.test.ts delete mode 100644 ecos/gui/apps/renderer/src/composables/flowLogSegmentPlan.test.ts delete mode 100644 ecos/gui/apps/renderer/src/composables/flowLogSegmentPlan.ts delete mode 100644 ecos/gui/apps/renderer/src/composables/requestIdle.ts delete mode 100644 ecos/gui/apps/renderer/src/composables/useBackendFlowLogs.test.ts delete mode 100644 ecos/gui/apps/renderer/src/composables/useEDA.ts delete mode 100644 ecos/gui/apps/renderer/src/styles/index.opaqueShell.test.ts delete mode 100644 ecos/gui/apps/renderer/src/styles/index.scrollbar.test.ts delete mode 100644 ecos/gui/apps/renderer/src/utils/checklistState.test.ts delete mode 100644 ecos/gui/apps/renderer/src/utils/checklistState.ts delete mode 100644 ecos/gui/apps/renderer/src/utils/projectFlow.ts delete mode 100644 ecos/gui/apps/renderer/src/views/ECCView.project-prefill.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/FEView.creation.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/FrontendWorkspaceView.rtl-review-layout.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/FrontendWorkspaceView.sim-layout.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/FrontendWorkspaceView.typography.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/HomeView.current-step-log.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/HomeView.flow-log.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/HomeView.layout-fullscreen.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/HomeView.typography.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/PluginToolsView.layout.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/ProjectsView.project-management.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/TechLibraryView.workbench.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/WorkspaceView.sidePanels.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/WorkspaceViewWrapper.backgroundFlow.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/WorkspaceViewWrapper.sidebar.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/WorkspaceViewWrapper.workspaceRefresh.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/homeViewFlowLogCopy.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/homeViewFlowLogCopy.ts delete mode 100644 ecos/gui/apps/renderer/src/views/homeViewFlowLogSelection.test.ts delete mode 100644 ecos/gui/apps/renderer/src/views/homeViewFlowLogSelection.ts diff --git a/ecos/gui/apps/desktop-electron/electron/main/index.ts b/ecos/gui/apps/desktop-electron/electron/main/index.ts index a7a70e6e4..9f8c4cac6 100644 --- a/ecos/gui/apps/desktop-electron/electron/main/index.ts +++ b/ecos/gui/apps/desktop-electron/electron/main/index.ts @@ -15,7 +15,6 @@ import { BackendProjectComparisonService } from '../services/backendProjectCompa import { prepareDesktopLogs } from '../services/desktopLogPaths' import { createEccRuntimeEnv, resolveEccExecutable } 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 { @@ -180,7 +179,6 @@ function getDesktopServices() { }), lazyWorkspaceOpen: false, openPath: (path) => shell.openPath(path), - snapshotLoader: (directory) => new WorkspaceSnapshotLoader().load(directory), }) installRuntimeQuitGuard({ app, @@ -230,6 +228,10 @@ function getDesktopServices() { const projectManagementReadService = new ProjectManagementReadService() const backendProjectComparisonService = new BackendProjectComparisonService( projectManagementReadService, + { + getByDirectory: (directory) => + eccRuntimeService.engineeringSnapshotForDirectory(directory), + }, ) const backendWorkspaceService = new BackendWorkspaceService({ engineeringSnapshotProvider: { 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 2bcdaeed2..f0301d2cb 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,7 @@ import { desktopApiIpcChannels, desktopMenuEventIds, type EccRuntimeEvent, + type EccWorkspaceCreateRequest, } from '@ecos-studio/shared' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -285,6 +286,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(), @@ -661,7 +696,8 @@ describe('registerIpc', () => { services.eccRuntimeService.workspaceSnapshot.mockResolvedValue({ workspaceRevision: 1, }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: owner }, { directory: contract.target_workspace }, ) @@ -747,7 +783,8 @@ describe('registerIpc', () => { services.eccRuntimeService.workspaceSnapshot.mockResolvedValue({ workspaceRevision: 1, }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: owner }, { directory: contract.target_workspace }, ) @@ -992,17 +1029,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' } } @@ -1017,10 +1043,8 @@ describe('registerIpc', () => { await expect( handlers.get(desktopApiIpcChannels.productCommandExecute)?.(event, { command: 'workspace.create', - payload: { + payload: workspaceCreateRequest({ commandId: 'workspace-create-invalid-binding', - directory: '/tmp/workspace', - pdk: 'ics55', pdkInstallationId: 'pdk-installation:ics55', projectId: 'proj_demo', projectRoot: '/tmp/project', @@ -1029,7 +1053,7 @@ describe('registerIpc', () => { version: null, manualConfig: null, }, - }, + }), }), ).resolves.toEqual({ error: { message: error.message, name: 'Error' }, @@ -1055,12 +1079,9 @@ describe('registerIpc', () => { await expect( handlers.get(desktopApiIpcChannels.productCommandExecute)?.(event, { command: 'workspace.create', - payload: { + payload: workspaceCreateRequest({ commandId: 'workspace-create-missing-requirement', - directory: '/tmp/workspace', - pdk: 'vendor-pdk', - pdkRoot: '/tmp/vendor-pdk', - }, + }), }), ).resolves.toEqual({ error: { @@ -1078,10 +1099,8 @@ describe('registerIpc', () => { it('uses the persisted Project Requirement for workspace creation', async () => { const { handlers, services } = registerHandlers() const event = { sender: { id: 'web-contents' } } - const payload = { + const payload = workspaceCreateRequest({ commandId: 'workspace-create-persisted-requirement', - directory: '/tmp/workspace', - pdk: 'ics55', pdkInstallationId: 'pdk-installation:ics55', projectId: 'proj_demo', projectRoot: '/tmp/project', @@ -1090,7 +1109,18 @@ describe('registerIpc', () => { version: null, manualConfig: null, }, - } + 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' } const persistedRequirement = { familyId: 'ics55', @@ -1156,19 +1186,24 @@ describe('registerIpc', () => { projectRoot: payload.projectRoot, requirement: persistedRequirement, }) - expect(services.eccRuntimeService.createWorkspace).toHaveBeenCalledWith({ - commandId: payload.commandId, - directory: payload.directory, - pdk: payload.pdk, - pdkConfig: { - tech_lef: ['tech.lef'], - cell_lef: ['cells.lef'], - liberty: ['typ.lib'], - }, - pdkConfigMode: 'manual', - pdkRoot: '/canonical/pdk', - pdkVersion: null, - }) + expect(services.eccRuntimeService.createWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + commandId: payload.commandId, + targetDirectory: payload.targetDirectory, + workspaceBindings: { + inputs: {}, + pdk: { + files: { + tech: 'tech.lef', + 'lef-1': 'cells.lef', + 'liberty-1': 'typ.lib', + }, + root: '/canonical/pdk', + }, + }, + workspaceSpec: payload.workspaceSpec, + }), + ) }) it('waits for a runtime operation through the main-process tracker', async () => { @@ -2033,7 +2068,7 @@ describe('registerIpc', () => { workspaceHandle: request.workspaceHandle, }) services.eccRuntimeService.exportSignoff.mockResolvedValue(result) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.(event, { + await openBackendWorkspace(handlers, event, { directory: '/work/demo', }) @@ -2054,7 +2089,7 @@ describe('registerIpc', () => { directory: '/work/demo', workspaceHandle: 'workspace-handle-1', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.(owner, { + await openBackendWorkspace(handlers, owner, { directory: '/work/demo', }) @@ -2117,11 +2152,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' }, ) @@ -2133,7 +2170,8 @@ describe('registerIpc', () => { workspaceDirectory: '/work/demo', }) - expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.eccEvent, { + expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.designRuntimeEvent, { + designTool: 'backend', type: 'runtime.ready', workspaceDirectory: '/work/demo', }) @@ -2164,11 +2202,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' }, ) @@ -2183,7 +2223,10 @@ describe('registerIpc', () => { } listener?.(exited) - expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.eccEvent, exited) + expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.designRuntimeEvent, { + ...exited, + designTool: 'backend', + }) expect(otherSend).not.toHaveBeenCalled() }) @@ -2217,7 +2260,8 @@ describe('registerIpc', () => { directory: '/work/demo/', workspaceHandle: 'workspace-handle-1', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( + await openBackendWorkspace( + handlers, { sender: ownerSender }, { directory: '/work/demo/' }, ) @@ -2228,7 +2272,8 @@ describe('registerIpc', () => { workspaceDirectory: '/work/demo', }) - expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.eccEvent, { + expect(ownerSend).toHaveBeenCalledWith(desktopApiEventChannels.designRuntimeEvent, { + designTool: 'backend', type: 'runtime.ready', workspaceDirectory: '/work/demo', }) @@ -2257,11 +2302,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' }, ) @@ -2273,7 +2320,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', @@ -2299,12 +2347,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', }) @@ -2333,11 +2383,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' }, ) @@ -2352,7 +2404,7 @@ describe('registerIpc', () => { }) expect(ownerSend).toHaveBeenCalledWith( - desktopApiEventChannels.eccEvent, + desktopApiEventChannels.designRuntimeEvent, expect.objectContaining({ type: 'operation.started', workspaceHandle: 'workspace-handle-1', @@ -2374,10 +2426,7 @@ describe('registerIpc', () => { directory: '/work/demo', workspaceHandle: 'workspace-handle-1', }) - await handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.( - { sender }, - { directory: '/work/demo' }, - ) + await openBackendWorkspace(handlers, { sender }, { directory: '/work/demo' }) const listener = services.eccRuntimeService.onEvent.mock.calls[0]?.[0] listener?.({ @@ -2701,7 +2750,7 @@ describe('registerIpc', () => { }) await expect( - handlers.get(desktopApiIpcChannels.eccWorkspaceOpen)?.(event, { + openBackendWorkspace(handlers, event, { directory: '/work/demo', }), ).resolves.toEqual({ @@ -2711,7 +2760,7 @@ 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', }) @@ -2732,17 +2781,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', }) diff --git a/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts b/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts index 15047692b..a538229f7 100644 --- a/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts +++ b/ecos/gui/apps/desktop-electron/electron/main/registerIpc.ts @@ -750,16 +750,6 @@ export function registerIpc( 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 - } - sender.send(desktopApiEventChannels.eccEvent, payload) - } - const sendDesignRuntimeEventToSender = ( sender: IpcMainInvokeEvent['sender'], designTool: DesignTool, @@ -881,8 +871,6 @@ export function registerIpc( if (designTool === 'backend' && runtimeEventCommitsWorkspaceFacts(payload)) { invalidateBackendWorkspaceForSender(subscription.sender) } - if (designTool === 'backend') - sendEccEventToSender(subscription.sender, scopedPayload) sendDesignRuntimeEventToSender(subscription.sender, designTool, scopedPayload) } return deliveredSenders.size @@ -904,7 +892,6 @@ export function registerIpc( if (directory) services.backendProjectComparisonService.invalidateWorkspace(directory) } - if (designTool === 'backend') sendEccEventToSender(subscription.sender, payload) sendDesignRuntimeEventToSender(subscription.sender, designTool, payload) return } @@ -1067,7 +1054,6 @@ export function registerIpc( readyKey(designTool, normalizedDirectory), ) if (pendingReady) { - if (designTool === 'backend') sendEccEventToSender(sender, pendingReady) sendDesignRuntimeEventToSender(sender, designTool, pendingReady) } } @@ -2197,83 +2183,12 @@ export function registerIpc( ) }) - 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.eccWorkspaceOpen, async (event, request) => { - const openRequest = request as EccWorkspaceOpenRequest - const result = await services.eccRuntimeService.openWorkspace( - openRequest.workspaceBindings - ? openRequest - : await prepareWorkspaceOpenBinding(services, openRequest.directory), - ) - 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) - } - } - return result - }) - - handle(desktopApiIpcChannels.eccWorkspaceDescribeSpec, async () => { - return await services.eccRuntimeService.describeWorkspaceSpec() - }) - - handle(desktopApiIpcChannels.eccWorkspaceValidateSpec, async (_event, request) => { - return await services.eccRuntimeService.validateWorkspaceSpec( - request as EccWorkspaceSpecValidationRequest, - ) - }) - - 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.eccWorkspaceInspectSignoff, async (_event, request) => { return await services.eccRuntimeService.inspectSignoff( request as EccWorkspaceHandleRequest, ) }) - 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, @@ -2286,12 +2201,6 @@ export function registerIpc( ) }) - handle(desktopApiIpcChannels.eccRuntimeEngineeringSnapshot, async (_event, request) => { - return await services.eccRuntimeService.engineeringSnapshot( - request as EccWorkspaceHandleRequest, - ) - }) - handle(desktopApiIpcChannels.eccRuntimeReadArtifactChunk, async (_event, request) => { return await services.eccRuntimeService.readArtifactChunk( request as EccArtifactReadRequest, 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 a1752583a..585f705f7 100644 --- a/ecos/gui/apps/desktop-electron/electron/preload/index.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/preload/index.test.ts @@ -106,11 +106,6 @@ describe('preload desktop bridge contract', () => { app: expect.objectContaining({ getVersions: expect.any(Function), }), - ecc: expect.objectContaining({ - events: expect.objectContaining({ - onEvent: expect.any(Function), - }), - }), backendProjectComparison: expect.objectContaining({ selectProject: expect.any(Function), getComparison: expect.any(Function), @@ -425,26 +420,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 f6c087d55..0ab5ecbf1 100644 --- a/ecos/gui/apps/desktop-electron/electron/preload/index.ts +++ b/ecos/gui/apps/desktop-electron/electron/preload/index.ts @@ -7,7 +7,6 @@ import type { DesktopApi, DesignRuntimeEvent, DesktopDirectoryDialogOptions, - EccRuntimeEvent, DesktopFileDialogOptions, DesktopRtlSourceDialogOptions, ChipViewerOpenRequest, @@ -436,46 +435,19 @@ const desktopApi: DesktopApi = { }, }, ecc: { - events: { - onEvent: (listener) => - subscribeToDesktopEvent( - desktopApiEventChannels.eccEvent, - (_event, payload: unknown) => { - listener(payload as EccRuntimeEvent) - }, - ), - }, - rpc: { - hello: () => invokeDesktop(desktopApiIpcChannels.eccRpcHello), - ping: () => invokeDesktop(desktopApiIpcChannels.eccRpcPing), - shutdown: () => invokeDesktop(desktopApiIpcChannels.eccRpcShutdown), - }, runtime: { - engineeringSnapshot: (request) => - invokeDesktop(desktopApiIpcChannels.eccRuntimeEngineeringSnapshot, request), openArtifact: (request) => invokeDesktop(desktopApiIpcChannels.eccRuntimeOpenArtifact, request), readArtifactChunk: (request) => invokeDesktop(desktopApiIpcChannels.eccRuntimeReadArtifactChunk, request), snapshot: (request) => invokeDesktop(desktopApiIpcChannels.eccRuntimeSnapshot, request), - status: (request) => - invokeDesktop(desktopApiIpcChannels.eccRuntimeOperationStatus, request), waitForOperation: (request) => invokeDesktop(desktopApiIpcChannels.eccRuntimeWaitForOperation, request), }, workspace: { - close: (request) => invokeDesktop(desktopApiIpcChannels.eccWorkspaceClose, request), - describeSpec: () => invokeDesktop(desktopApiIpcChannels.eccWorkspaceDescribeSpec), 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), - validateSpec: (request) => - invokeDesktop(desktopApiIpcChannels.eccWorkspaceValidateSpec, request), }, }, agent: { diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.test.ts index 91d223a8e..6180413d1 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import type { ProjectManifest } from '@ecos-studio/shared' +import type { EccEngineeringSnapshot, ProjectManifest } from '@ecos-studio/shared' import { BackendProjectComparisonService } from './backendProjectComparisonService' function manifest(root = '/projects/demo'): ProjectManifest { @@ -52,37 +52,72 @@ function manifest(root = '/projects/demo'): ProjectManifest { } } +function engineeringSnapshot( + workspacePath: string, + step = 'Route', +): EccEngineeringSnapshot { + const value = workspacePath.endsWith('ws_1') ? 120 : 100 + const metric = { + direction: 'lower_is_better', + display_name: 'Wire length', + id: 'wire_length', + value, + } + return { + artifacts: [], + checklist: {}, + flow: { steps: [{ name: step, state: 'Success' }] }, + metrics: [metric], + parameters: {}, + qorAssessment: { + 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 serviceFixture() { const project = manifest() const readManifest = vi.fn().mockResolvedValue(JSON.stringify(project)) - const readWorkspaceTexts = vi.fn().mockImplementation(async ({ workspacePath }) => ({ - texts: { - 'home/flow.json': JSON.stringify({ - steps: [{ name: 'Route', state: 'Success' }], - }), - 'analysis/Route/qor_metrics.json': JSON.stringify({ - metrics: [ - { name: 'wire length', value: workspacePath.endsWith('ws_1') ? 120 : 100 }, - ], - }), - }, + const readWorkspaceTexts = vi.fn().mockResolvedValue({ + texts: {}, unavailablePaths: [], - })) + }) + const getByDirectory = vi + .fn() + .mockImplementation(async (workspacePath) => engineeringSnapshot(workspacePath)) return { + getByDirectory, project, readManifest, readWorkspaceTexts, - service: new BackendProjectComparisonService({ - readManifest, - readWorkspaceTexts, - resolveProjectRoot: async (path) => path, - }), + service: new BackendProjectComparisonService( + { + readManifest, + readWorkspaceTexts, + resolveProjectRoot: async (path) => path, + }, + { getByDirectory }, + ), } } describe('BackendProjectComparisonService', () => { it('selects an opaque context and coalesces comparison reads in one generation', async () => { - const { service, readWorkspaceTexts } = serviceFixture() + const { getByDirectory, service, readWorkspaceTexts } = serviceFixture() const selected = await service.selectProject(11, { projectRootLocator: '/projects/demo', }) @@ -107,6 +142,7 @@ describe('BackendProjectComparisonService', () => { ).toEqual(expect.arrayContaining([expect.objectContaining({ stepId: 'Route' })])) expect(JSON.stringify(left)).not.toContain('/projects/demo/ws_') expect(readWorkspaceTexts).toHaveBeenCalledTimes(2) + expect(getByDirectory).toHaveBeenCalledTimes(2) }) it('keeps readable workspaces when one workspace analysis fails', async () => { @@ -136,15 +172,10 @@ describe('BackendProjectComparisonService', () => { }) it('preserves an unknown Flow Step as an opaque comparison identity', async () => { - const { service, readWorkspaceTexts } = serviceFixture() - readWorkspaceTexts.mockResolvedValue({ - texts: { - 'home/flow.json': JSON.stringify({ - steps: [{ name: 'CustomSignoff', state: 'Success' }], - }), - }, - unavailablePaths: [], - }) + const { getByDirectory, service } = serviceFixture() + getByDirectory.mockImplementation(async (workspacePath) => + engineeringSnapshot(workspacePath, 'CustomSignoff'), + ) const selected = await service.selectProject(11, { projectRootLocator: '/projects/demo', }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.ts b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.ts index b8d3614a0..1e6dc2433 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/backendProjectComparisonService.ts @@ -10,6 +10,7 @@ import { type BackendProjectComparisonQueryResult, type BackendProjectComparisonSelectResult, type DesktopProjectManagementWorkspaceTextsResult, + type EccEngineeringSnapshot, type ProjectAnalysisSnapshot, type ProjectManifest, type ProjectQorMetricRecord, @@ -36,6 +37,10 @@ interface ProjectComparisonReader { }): Promise } +interface EngineeringSnapshotProvider { + getByDirectory(directory: string): Promise +} + interface ProjectComparisonContext { id: string windowId: number @@ -74,7 +79,10 @@ export class BackendProjectComparisonService { private readonly contextsByWindow = new Map() private readonly listeners = new Set() - constructor(private readonly reader: ProjectComparisonReader) {} + constructor( + private readonly reader: ProjectComparisonReader, + private readonly snapshotProvider: EngineeringSnapshotProvider, + ) {} async selectProject( windowId: number, @@ -235,11 +243,14 @@ export class BackendProjectComparisonService { 2, async (workspace) => { try { - const { texts, unavailablePaths } = await this.reader.readWorkspaceTexts({ - projectRoot: context.projectRoot, - workspacePath: workspace.workspace_path, - paths: [...projectManagementWorkspaceSummaryPaths], - }) + const [{ texts, unavailablePaths }, engineeringSnapshot] = await Promise.all([ + this.reader.readWorkspaceTexts({ + projectRoot: context.projectRoot, + workspacePath: workspace.workspace_path, + paths: [...projectManagementWorkspaceSummaryPaths], + }), + this.snapshotProvider.getByDirectory(workspace.workspace_path), + ]) readBytes += Object.values(texts).reduce( (total, text) => total + (text ? Buffer.byteLength(text) : 0), 0, @@ -249,6 +260,7 @@ export class BackendProjectComparisonService { manifest, workspace.workspace_id, texts, + engineeringSnapshot, ) return input ? { input } : { issue: workspaceIssue(workspace.workspace_id) } } catch (error) { diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.test.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.test.ts index 0a6790af9..99d80c060 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.test.ts @@ -53,7 +53,11 @@ function engineeringSnapshot(index = resourceIndex()): EccEngineeringSnapshot { }, metrics: [], parameters: index.parameters ?? {}, - qorAssessment: { score: { gate: 'pass', threshold: 60, value: 73.5 } }, + qorAssessment: { + metrics: [], + score: { gate: 'pass', threshold: 60, value: 73.5 }, + steps: [], + }, schemaVersion: 1, signoffAssessment: { groups: [], risks: [], status: 'ready' }, workspaceId: 'ecc-workspace-a', @@ -102,16 +106,9 @@ describe('BackendWorkspaceService', () => { ], }), ) - const projectManagementReadService = { - readManifest, - async readWorkspaceTexts() { - expect(this).toBe(projectManagementReadService) - return { texts: {}, unavailablePaths: [] } - }, - } const service = new BackendWorkspaceService({ engineeringSnapshotProvider: snapshotProvider(), - projectManagementReadService, + projectManagementReadService: { readManifest }, workspaceResourceService: { getIndex }, }) diff --git a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.ts b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.ts index a6cc21756..5d1c3b73f 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/backendWorkspaceService.ts @@ -5,7 +5,6 @@ import { performance } from 'node:perf_hooks' import { parseProjectManifest, parseRuntimeSeconds, - projectManagementWorkspaceSummaryPaths, type BackendWorkspaceOverviewResult, type ChecklistFinding, type FlowStepState, @@ -24,7 +23,7 @@ import { type WorkspaceResourceIndex, } from '@ecos-studio/shared' import { requireWindowScopeId } from './windowScopeContext' -import { analyzeWorkspaceQor, type WorkspaceAnalysisTexts } from './workspaceQorAnalysis' +import { analyzeWorkspaceQor } from './workspaceQorAnalysis' import { electronLogger } from './logger' import { workspaceDashboardMetrics } from './workspaceDashboardAnalysis' @@ -37,14 +36,6 @@ interface BackendWorkspaceServiceOptions { } projectManagementReadService?: { readManifest(projectRoot: string): Promise - readWorkspaceTexts?(request: { - projectRoot: string - workspacePath: string - paths: string[] - }): Promise<{ - texts: Record - unavailablePaths: string[] - }> } readWorkspaceTextFile?: (path: string) => Promise } @@ -536,14 +527,13 @@ export class BackendWorkspaceService { qor: ReadSection baselineComparison: ReadSection }> { - const reader = this.options.projectManagementReadService const currentWorkspace = manifest?.workspaces.find((workspace) => pathsEqual(workspace.workspace_path, index.root), ) - if (!manifest || !currentWorkspace || !reader?.readWorkspaceTexts) { + const snapshotProvider = this.options.engineeringSnapshotProvider + if (!manifest || !currentWorkspace || !snapshotProvider) { return { qor: unavailable(), baselineComparison: unavailable() } } - const readWorkspaceTexts = reader.readWorkspaceTexts.bind(reader) const baselineWorkspaceId = manifest.qor_baseline?.workspace_id const requestedIds = [ @@ -552,7 +542,7 @@ export class BackendWorkspaceService { ? [baselineWorkspaceId] : []), ] - const textsByWorkspaceId: Record = {} + const snapshotsByWorkspaceId: Record = {} const failedIds = new Set() await Promise.all( requestedIds.map(async (workspaceId) => { @@ -564,19 +554,15 @@ export class BackendWorkspaceService { return } if (workspaceId === currentWorkspace.workspace_id && snapshot) { - textsByWorkspaceId[workspaceId] = { - 'home/engineering-snapshot.json': JSON.stringify(snapshot), - } + snapshotsByWorkspaceId[workspaceId] = snapshot return } try { - const result = await readWorkspaceTexts({ - projectRoot: dirname(index.root), - workspacePath: workspace.workspace_path, - paths: [...projectManagementWorkspaceSummaryPaths], - }) - textsByWorkspaceId[workspaceId] = result.texts + snapshotsByWorkspaceId[workspaceId] = await snapshotProvider.getByDirectory( + workspace.workspace_path, + ) } catch { + snapshotsByWorkspaceId[workspaceId] = null failedIds.add(workspaceId) } }), @@ -597,7 +583,7 @@ export class BackendWorkspaceService { const result = analyzeWorkspaceQor( manifest, currentWorkspace.workspace_id, - textsByWorkspaceId, + snapshotsByWorkspaceId, ) if (baselineWorkspaceId && failedIds.has(baselineWorkspaceId)) { result.baselineComparison = { 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 2925eb829..48e7cac50 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 @@ -231,7 +231,9 @@ describe('EccRpcRuntimeService pool', () => { const workspace = await pool.service.createWorkspace({ commandId: 'workspace-create-new', - directory: '/work/new', + targetDirectory: '/work/new', + workspaceBindings: {}, + workspaceSpec: {}, }) expect(workspace.directory).toBe('/work/new') @@ -382,6 +384,38 @@ describe('EccRpcRuntimeService pool', () => { expect(workspace.workspaceHandle).toEqual(expect.any(String)) }) + 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: [] }, + { + artifacts: [], + checklist: {}, + flow: { steps: [] }, + metrics: [], + parameters: {}, + qorAssessment: {}, + signoffAssessment: { groups: [], risks: [], status: 'ready' }, + workspaceId: 'id-/work/idle', + workspaceRevision: 1, + }, + { closed: true }, + ) + + await expect(query).resolves.toMatchObject({ workspaceId: 'id-/work/idle' }) + expect(client.calls.map((call) => call.method)).toEqual([ + 'rpc.hello', + 'workspace.open', + 'workspace.recover_interrupted', + 'workspace.engineering_snapshot', + 'workspace.close', + ]) + expect(pool.sidecarFor('/work/idle').shutdownCount).toBe(1) + }) + it('reads a bounded artifact chunk by opaque id without accepting a path', async () => { const pool = createPool() const workspaceDirectory = await mkdtemp(join(tmpdir(), 'ecos-artifact-runtime-')) 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 a68c38697..c9d579cea 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/runtimeService.ts @@ -91,9 +91,6 @@ export interface EccRpcRuntimeServiceOptions { ): EccRpcRuntimeSidecar onEvent?: (event: EccRuntimeEvent) => void lazyWorkspaceOpen?: boolean - snapshotLoader?: ( - directory: string, - ) => Promise> openPath?: (path: string) => Promise } @@ -205,8 +202,8 @@ export class EccRpcRuntimeService { } 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() @@ -375,8 +372,14 @@ export class EccRpcRuntimeService { const workspaceHandle = [...this.handleToDirectory].find( ([, candidateDirectory]) => candidateDirectory === key, )?.[0] - if (!workspaceHandle) throw new WorkspaceSessionNotFoundError(directory) - return await this.engineeringSnapshot({ workspaceHandle }) + 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 }) + } } async readArtifactChunk(request: EccArtifactReadRequest): Promise { @@ -456,7 +459,6 @@ export class EccRpcRuntimeService { directory: key, lazyWorkspaceOpen: this.options.lazyWorkspaceOpen, onEvent: (event) => this.emit(event), - snapshotLoader: this.options.snapshotLoader, }) this.runtimes.set(key, runtime) } 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 7d0ab9980..9308905b7 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 @@ -89,7 +89,7 @@ function createService( directory = '/work/demo', options: Pick< ConstructorParameters[0], - 'diagnosticIdleTimeoutMs' | 'lazyWorkspaceOpen' | 'snapshotLoader' + 'diagnosticIdleTimeoutMs' | 'lazyWorkspaceOpen' > = {}, ) { const client = new FakeRpcClient() @@ -179,77 +179,6 @@ describe('EccWorkspaceRuntime', () => { } }) - 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( @@ -772,17 +701,17 @@ describe('EccWorkspaceRuntime', () => { { 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({ commandId: 'workspace-create-flow-range', - directory: '/work/demo', - flowConfig, - sdc: '/constraints/top.sdc', + targetDirectory: '/work/demo', + workspaceBindings: { inputs: { sdc: '/constraints/top.sdc' } }, + workspaceSpec: { + flow: { + flowId: 'harden', + fromStepId: 'Synthesis', + throughStepId: 'Harden', + }, + }, }) expect(client.calls.at(-1)).toEqual({ @@ -801,27 +730,6 @@ describe('EccWorkspaceRuntime', () => { }) }) - 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 service.createWorkspace({ - commandId: 'workspace-create-empty-flow', - directory: '/work/demo', - flowConfig: {}, - }) - - expect(client.calls.at(-1)).toEqual({ - method: 'workspace.create', - params: expect.not.objectContaining({ - flowConfig: expect.anything(), - }), - }) - }) - it('lazy-starts the sidecar, performs rpc.hello, and opens workspaces', async () => { const { client, events, service, sidecar } = createService() client.responses.push( 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 619a88d51..0b759c911 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntime.ts @@ -59,7 +59,6 @@ import { type RuntimeOperationMetadata, } from './workspaceRuntimeCommands' import { WorkspaceSessionRegistry } from './workspaceSessions' -import { WorkspaceSnapshotCache } from './workspaceSnapshotCache' export type { EccRpcRuntimeClient, EccRpcRuntimeSidecar } from './runtimeClient' @@ -77,9 +76,6 @@ export interface EccWorkspaceRuntimeOptions { diagnosticIdleTimeoutMs?: number lazyWorkspaceOpen?: boolean sessions?: WorkspaceSessionRegistry - snapshotLoader?: ( - directory: string, - ) => Promise> } interface InFlightOperation { @@ -114,7 +110,8 @@ 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 commands: WorkspaceRuntimeCommands private queue = Promise.resolve() private ready = false @@ -134,7 +131,7 @@ export class EccWorkspaceRuntime { const snapshot = await client.call< Omit >('workspace.snapshot', { workspaceId }) - this.snapshotCache.set(snapshot) + this.cachedSnapshot = snapshot }, closeSidecar: async () => { await this.shutdown() @@ -342,7 +339,7 @@ export class EccWorkspaceRuntime { request: EccWorkspaceHandleRequest, ): Promise { const result = await this.commands.refreshConfig(request) - this.snapshotCache.clear() + this.cachedSnapshot = null return result } @@ -350,7 +347,7 @@ export class EccWorkspaceRuntime { request: EccWorkspaceSyncConfigRequest, ): Promise { const result = await this.commands.syncConfig(request) - this.snapshotCache.clear() + this.cachedSnapshot = null return result } @@ -358,7 +355,7 @@ export class EccWorkspaceRuntime { request: EccWorkspaceHandleRequest, ): Promise { const result = await this.commands.resetFlow(request) - this.snapshotCache.clear() + this.cachedSnapshot = null return result } @@ -493,26 +490,18 @@ 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 } } @@ -540,7 +529,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 = { @@ -865,9 +854,8 @@ export class EccWorkspaceRuntime { !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() + // must wait for the terminal ECC snapshot. + this.cachedSnapshot = null this.sidecarLifecycle.releaseAfterSuccessfulOperation(protocolEvent.workspaceId) } else if ( isTerminal && 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 9b54a2fbf..885e646e8 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntimeCommands.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceRuntimeCommands.ts @@ -36,7 +36,6 @@ import type { import type { EccRpcRuntimeClient, EccRpcRuntimeSidecar } from './runtimeClient' import { migrateWorkspaceConfigFilenames } from './workspaceConfigMigration' import { WorkspaceSessionRegistry } from './workspaceSessions' -import { workspaceSpecCreatePayload } from './workspaceSpecAdapter' export interface EccWorkspaceSessionResult { directory: string @@ -73,18 +72,14 @@ export class WorkspaceRuntimeCommands { createWorkspace(request: EccWorkspaceCreateRequest): Promise { return this.context.enqueue('workspace.create', undefined, async () => { const client = await this.context.ensureStarted() - const payload = workspaceSpecCreatePayload(request) as { - workspaceBindings: Record - } - const response = await client.call( - 'workspace.create', - payload, - ) + const response = await client.call('workspace.create', { + ...request, + }) const session = this.context.sessions.activate( response.directory, response.workspaceId, response.workspaceRevision ?? 1, - payload.workspaceBindings, + request.workspaceBindings, ) return { directory: session.directory, 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 index 4132482c4..a5007c06c 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.test.ts @@ -21,50 +21,6 @@ describe('WorkspaceSnapshotLoader', () => { } }) - 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('loads the bounded configuration snapshot used for project baseline sync', async () => { const directory = createWorkspace() mkdirSync(join(directory, 'config')) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.ts index d10343bbb..e41b8fec1 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSnapshotLoader.ts @@ -1,16 +1,10 @@ import { readFile, stat } from 'node:fs/promises' import { join } from 'node:path' -import type { - EccWorkspaceRuntimeSnapshot, - EccRuntimeStepSnapshot, -} from '@ecos-studio/shared' import { migrateWorkspaceConfigFilenames } from './workspaceConfigMigration' const MAX_SNAPSHOT_FILE_BYTES = 512 * 1024 -type DetachedWorkspaceSnapshot = Omit - export interface WorkspaceBaselineSnapshot { db: Record parameters: Record @@ -35,53 +29,8 @@ async function readJsonObject(path: string): Promise> { } } -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')), - readJsonObject(join(homeDirectory, 'flow.json')), - readJsonObject(join(homeDirectory, 'parameters.json')), - ]) - 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. - */ + /** Reads only the persisted configuration needed to refresh a project baseline. */ async loadBaselineSnapshot(directory: string): Promise { await migrateWorkspaceConfigFilenames(directory) const [parameters, pdk, db] = await Promise.all([ diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSpecAdapter.test.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSpecAdapter.test.ts deleted file mode 100644 index d1dc1377e..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSpecAdapter.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import type { EccWorkspaceCreateRequest } from '@ecos-studio/shared' - -import { workspaceSpecCreatePayload } from './workspaceSpecAdapter' - -describe('workspaceSpecCreatePayload', () => { - it('matches the shared ECC and CLI WorkspaceSpec fixture', async () => { - const root = resolve( - import.meta.dirname, - '../../../../../../../ecc/test/fixtures/workspace_spec_v1', - ) - const fixture = JSON.parse(await readFile(resolve(root, 'valid.json'), 'utf8')) as { - studioDraft: EccWorkspaceCreateRequest - workspaceBindings: Record - workspaceSpec: Record - } - const draft = { - ...fixture.studioDraft, - directory: resolve(root, fixture.studioDraft.directory), - originVerilog: resolve(root, fixture.studioDraft.originVerilog ?? ''), - pdkRoot: resolve(root, fixture.studioDraft.pdkRoot ?? ''), - } - - expect(workspaceSpecCreatePayload(draft)).toEqual({ - commandId: fixture.studioDraft.commandId, - targetDirectory: draft.directory, - workspaceBindings: { - inputs: { 'rtl-main': resolve(root, 'input/gcd.v') }, - pdk: { root: resolve(root, 'pdk') }, - }, - workspaceSpec: fixture.workspaceSpec, - }) - }) - - it('rejects the shared invalid Studio fixture', async () => { - const root = resolve( - import.meta.dirname, - '../../../../../../../ecc/test/fixtures/workspace_spec_v1', - ) - const fixture = JSON.parse(await readFile(resolve(root, 'invalid.json'), 'utf8')) as { - expectedStudioError: string - studioDraft: EccWorkspaceCreateRequest - } - - expect(() => workspaceSpecCreatePayload(fixture.studioDraft)).toThrow( - fixture.expectedStudioError, - ) - }) - - it('maps the legacy Studio draft to portable WorkspaceSpec and local bindings', () => { - const payload = workspaceSpecCreatePayload({ - commandId: 'workspace-create-1', - designInputMode: 'rtl', - directory: '/project/ws-1', - flowConfig: { - end_step: 'Harden', - start_step: 'Synthesis', - steps: ['Synthesis', 'Floorplan', 'Harden'], - }, - parameters: { - clock: 'clk', - core_utilization: 0.6, - description: 'product-only', - design: 'gcd', - frequency_max: 200, - top_module: 'gcd', - }, - pdk: 'ics55', - pdkConfigMode: 'manual', - pdkConfig: { - cell_lef: ['/pdk/cells.lef'], - liberty: ['/pdk/typ.lib'], - tech_lef: ['/pdk/tech.lef'], - }, - pdkRoot: '/pdk', - pdkVersion: '1.10.100', - rtlList: ['/project/rtl/gcd.v'], - sdc: '/project/gcd.sdc', - }) - - expect(payload).toEqual({ - commandId: 'workspace-create-1', - targetDirectory: '/project/ws-1', - workspaceBindings: { - inputs: { - 'rtl-1': '/project/rtl/gcd.v', - sdc: '/project/gcd.sdc', - }, - pdk: { - files: { - 'lef-1': '/pdk/cells.lef', - 'liberty-1': '/pdk/typ.lib', - tech: '/pdk/tech.lef', - }, - root: '/pdk', - version: '1.10.100', - }, - }, - workspaceSpec: { - design: { clockPort: 'clk', name: 'gcd', topModule: 'gcd' }, - flow: { - flowId: 'harden', - fromStepId: 'Synthesis', - throughStepId: 'Harden', - }, - inputMode: 'rtl', - inputs: [ - { inputId: 'rtl-1', role: 'rtl' }, - { inputId: 'sdc', role: 'sdc' }, - ], - parameters: { - 'design.frequency_mhz': 200, - 'floorplan.core_util': 0.6, - }, - pdk: { - familyId: 'ics55', - files: [ - { fileId: 'tech', role: 'tech' }, - { fileId: 'lef-1', role: 'lef' }, - { fileId: 'liberty-1', role: 'liberty' }, - ], - mode: 'manual', - version: '1.10.100', - }, - schemaVersion: 1, - }, - }) - }) - - it('rejects a legacy engineering field that cannot be mapped losslessly', () => { - expect(() => - workspaceSpecCreatePayload({ - commandId: 'workspace-create-invalid-parameter', - directory: '/project/ws-1', - parameters: { design: 'gcd', mystery_knob: 3, top_module: 'gcd' }, - pdk: 'ics55', - pdkRoot: '/pdk', - rtlList: ['/project/gcd.v'], - }), - ).toThrow('unsupported_legacy_field: parameters.mystery_knob') - }) - - it('maps a legacy inline PDK config instead of silently dropping it', () => { - const payload = workspaceSpecCreatePayload({ - commandId: 'workspace-create-inline-pdk', - directory: '/project/ws-1', - parameters: { design: 'gcd', top_module: 'gcd' }, - pdkJson: { - name: 'custom55', - root: '/pdk', - tech: '/pdk/tech.lef', - lefs: ['/pdk/cells.lef'], - libs: ['/pdk/typ.lib'], - mapping_file: '/pdk/cells.map', - }, - rtlList: ['/project/gcd.v'], - }) as { - workspaceBindings: { pdk: Record } - workspaceSpec: { pdk: Record } - } - - expect(payload.workspaceSpec.pdk).toMatchObject({ - familyId: 'custom55', - mode: 'manual', - files: expect.arrayContaining([{ fileId: 'mapping', role: 'mapping' }]), - }) - expect(payload.workspaceBindings.pdk).toMatchObject({ - root: '/pdk', - files: expect.objectContaining({ mapping: '/pdk/cells.map' }), - }) - }) -}) diff --git a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSpecAdapter.ts b/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSpecAdapter.ts deleted file mode 100644 index 25fc8607d..000000000 --- a/ecos/gui/apps/desktop-electron/electron/services/eccRpc/workspaceSpecAdapter.ts +++ /dev/null @@ -1,329 +0,0 @@ -import type { EccWorkspaceCreateRequest } from '@ecos-studio/shared' - -interface InputRef { - inputId: string - role: 'rtl' | 'filelist' | 'netlist' | 'def' | 'sdc' -} - -const PARAMETER_MAP: Record = { - 'Frequency max [MHz]': 'design.frequency_mhz', - 'Max fanout': 'synth.max_fanout', - 'Target density': 'place.target_density', - 'Target overflow': 'place.target_overflow', - 'Global right padding': 'place.global_right_padding', - 'Cell padding x': 'place.cell_padding_x', - 'Routability opt flag': 'place.routability_opt', - 'Bottom layer': 'route.bottom_layer', - 'Top layer': 'route.top_layer', - 'STA max paths': 'sta.max_paths', - frequency_max: 'design.frequency_mhz', - core_utilization: 'floorplan.core_util', - target_density: 'place.target_density', - target_overflow: 'place.target_overflow', - max_fanout: 'synth.max_fanout', - die_width: 'floorplan.die_width', - die_height: 'floorplan.die_height', -} - -const NON_ENGINEERING_PARAMETER_KEYS = new Set([ - 'design', - 'description', - 'top_module', - 'clock', - 'Design', - 'Top module', - 'Clock', - 'PDK', - 'PDK Root', - 'PDK Config', -]) - -export function workspaceSpecCreatePayload( - request: EccWorkspaceCreateRequest, -): Record { - if (!request.commandId) throw new Error('Workspace commandId is required') - const inputs: InputRef[] = [] - const inputBindings: Record = {} - const inputMode = resolveInputMode(request) - if (request.filelist) - addInput(inputs, inputBindings, 'filelist', 'filelist', request.filelist) - for (const [index, path] of (request.rtlList ?? []).entries()) { - addInput(inputs, inputBindings, `rtl-${index + 1}`, 'rtl', path) - } - if (!request.filelist && !request.rtlList?.length && request.originVerilog) { - addInput( - inputs, - inputBindings, - inputMode === 'rtl' ? 'rtl-main' : 'netlist', - inputMode === 'rtl' ? 'rtl' : 'netlist', - request.originVerilog, - ) - } - if (request.originDef) addInput(inputs, inputBindings, 'def', 'def', request.originDef) - if (request.sdc) addInput(inputs, inputBindings, 'sdc', 'sdc', request.sdc) - - const pdkJson = legacyPdkJson(request.pdkJson) - const techLefs = request.pdkConfig?.tech_lef ?? compactStrings([pdkJson.tech]) - const cellLefs = request.pdkConfig?.cell_lef ?? compactStrings(pdkJson.lefs) - const liberties = request.pdkConfig?.liberty ?? compactStrings(pdkJson.libs) - const mappingFile = stringValue(pdkJson.mapping_file) - const hasManualFiles = Boolean( - techLefs.length || cellLefs.length || liberties.length || mappingFile, - ) - if (request.pdkConfigMode === 'default' && hasManualFiles) { - throw new Error('unsupported_legacy_field: pdkJson') - } - const pdkMode = request.pdkConfigMode ?? (hasManualFiles ? 'manual' : 'default') - const pdkFiles: Array<{ - fileId: string - role: 'tech' | 'lef' | 'liberty' | 'mapping' - }> = [] - const pdkFileBindings: Record = {} - if (pdkMode === 'manual') { - for (const [index, path] of techLefs.entries()) { - addPdkFile( - pdkFiles, - pdkFileBindings, - index ? `tech-${index + 1}` : 'tech', - 'tech', - path, - ) - } - for (const [index, path] of cellLefs.entries()) { - addPdkFile(pdkFiles, pdkFileBindings, `lef-${index + 1}`, 'lef', path) - } - for (const [index, path] of liberties.entries()) { - addPdkFile(pdkFiles, pdkFileBindings, `liberty-${index + 1}`, 'liberty', path) - } - if (mappingFile) { - addPdkFile(pdkFiles, pdkFileBindings, 'mapping', 'mapping', mappingFile) - } - } - - const parameters = canonicalParameters(request.parameters ?? {}) - const flow = canonicalFlow(request.flowConfig) - const version = request.pdkVersion || stringValue(pdkJson.version) || undefined - const mpc = canonicalMpc(request.mpc) - return { - commandId: request.commandId, - targetDirectory: request.directory, - workspaceSpec: { - schemaVersion: 1, - design: { - name: identityString(request.parameters, 'design', 'Design'), - topModule: identityString(request.parameters, 'top_module', 'Top module'), - ...(identityString(request.parameters, 'clock', 'Clock') - ? { clockPort: identityString(request.parameters, 'clock', 'Clock') } - : {}), - }, - inputMode: inputMode === 'post_synthesis' ? 'postSynthesis' : 'rtl', - inputs, - pdk: { - familyId: request.pdk || stringValue(pdkJson.name), - mode: pdkMode, - ...(version ? { version } : {}), - ...(pdkMode === 'manual' ? { files: pdkFiles } : {}), - }, - flow, - ...(mpc ? { mpc: mpc.spec } : {}), - parameters, - }, - workspaceBindings: { - inputs: inputBindings, - pdk: { - root: request.pdkRoot || stringValue(pdkJson.root), - ...(version ? { version } : {}), - ...(pdkMode === 'manual' ? { files: pdkFileBindings } : {}), - }, - ...(mpc ? { mpc: mpc.binding } : {}), - }, - } -} - -function canonicalParameters(input: Record): Record { - const parameters: Record = {} - for (const [key, value] of Object.entries(input)) { - if (NON_ENGINEERING_PARAMETER_KEYS.has(key)) continue - if (key.includes('.')) { - parameters[key] = value - continue - } - const canonical = PARAMETER_MAP[key] - if (canonical) { - parameters[canonical] = value - continue - } - if (key === 'die_area_mode') { - parameters['floorplan.mode'] = - value === 'utilitization_margin' ? 'utilization' : value - continue - } - if (key === 'utilitization') { - parameters['floorplan.core_util'] ??= value - continue - } - if (key === 'margin') { - parameters['floorplan.core_margin'] = [value, value] - continue - } - if (key === 'Core' && isRecord(value)) { - mapLegacyCore(value, parameters) - continue - } - if (key === 'Die' && isRecord(value)) { - mapLegacyDie(value, parameters) - continue - } - if (key === 'Die Area' && isRecord(value)) { - if (value.mode !== undefined) { - parameters['floorplan.mode'] = - value.mode === 'utilitization_margin' ? 'utilization' : value.mode - } - if (value.width !== undefined) parameters['floorplan.die_width'] = value.width - if (value.height !== undefined) parameters['floorplan.die_height'] = value.height - if (value.utilitization !== undefined) - parameters['floorplan.core_util'] = value.utilitization - continue - } - throw new Error(`unsupported_legacy_field: parameters.${key}`) - } - return parameters -} - -function canonicalFlow( - config: Record | undefined, -): Record { - const steps = Array.isArray(config?.steps) - ? config.steps.filter((step): step is string => typeof step === 'string') - : [] - const start = stringValue(config?.start_step) || steps[0] || '' - const end = stringValue(config?.end_step) || steps.at(-1) || '' - const selected = new Set([...steps, start, end]) - const flowId = selected.has('Harden') - ? 'harden' - : selected.has('RCX') || selected.has('sta') - ? 'rcx' - : selected.size === 1 && selected.has('Synthesis') - ? 'syn_sta' - : 'rtl2gds' - return { - flowId, - ...(start && end ? { fromStepId: start, throughStepId: end } : {}), - } -} - -function resolveInputMode(request: EccWorkspaceCreateRequest): 'rtl' | 'post_synthesis' { - if (request.designInputMode) return request.designInputMode - return request.filelist || request.rtlList?.length ? 'rtl' : 'post_synthesis' -} - -function identityString( - parameters: Record | undefined, - currentKey: string, - legacyKey: string, -): string { - return stringValue(parameters?.[currentKey]) || stringValue(parameters?.[legacyKey]) -} - -function addInput( - refs: InputRef[], - bindings: Record, - inputId: string, - role: InputRef['role'], - path: string, -): void { - refs.push({ inputId, role }) - bindings[inputId] = path -} - -function addPdkFile( - refs: Array<{ - fileId: string - role: 'tech' | 'lef' | 'liberty' | 'mapping' - }>, - bindings: Record, - fileId: string, - role: 'tech' | 'lef' | 'liberty' | 'mapping', - path: string, -): void { - refs.push({ fileId, role }) - bindings[fileId] = path -} - -function mapLegacyCore( - value: Record, - parameters: Record, -): void { - if (value.Utilitization !== undefined) - parameters['floorplan.core_util'] = value.Utilitization - if (value['Aspect ratio'] !== undefined) - parameters['floorplan.aspect_ratio'] = value['Aspect ratio'] - if (value.Margin !== undefined) parameters['floorplan.core_margin'] = value.Margin -} - -function mapLegacyDie( - value: Record, - parameters: Record, -): void { - if (!Array.isArray(value.Size)) return - if (value.Size[0] !== undefined) parameters['floorplan.die_width'] = value.Size[0] - if (value.Size[1] !== undefined) parameters['floorplan.die_height'] = value.Size[1] -} - -function stringValue(value: unknown): string { - return typeof value === 'string' ? value.trim() : '' -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function compactStrings(value: unknown): string[] { - return Array.isArray(value) ? value.map(stringValue).filter(Boolean) : [] -} - -function legacyPdkJson(value: unknown): Record { - if (value === undefined || value === null || value === '') return {} - if (!isRecord(value)) throw new Error('unsupported_legacy_field: pdkJson') - const supported = new Set([ - 'name', - 'version', - 'root', - 'tech', - 'lefs', - 'libs', - 'mapping_file', - ]) - for (const [key, field] of Object.entries(value)) { - if (!supported.has(key) && hasLegacyValue(field)) { - throw new Error(`unsupported_legacy_field: pdkJson.${key}`) - } - } - return value -} - -function hasLegacyValue(value: unknown): boolean { - if (value === undefined || value === null || value === '') return false - if (Array.isArray(value)) return value.length > 0 - if (isRecord(value)) return Object.keys(value).length > 0 - return true -} - -function canonicalMpc(value: Record | null | undefined): { - binding: Record - spec: Record -} | null { - if (!value) return null - const design = isRecord(value.design) ? value.design : {} - return { - spec: { - resourceId: stringValue(value.resource_id), - version: stringValue(value.installed_version), - designId: stringValue(design.design_name) || String(design.index ?? ''), - }, - binding: { - template: isRecord(value.core_template) ? value.core_template : {}, - sourcePath: stringValue(value.spec_path) || stringValue(value.path), - }, - } -} diff --git a/ecos/gui/apps/desktop-electron/electron/services/productCommandService.ts b/ecos/gui/apps/desktop-electron/electron/services/productCommandService.ts index c70fe88a8..9e12a4d4d 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/productCommandService.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/productCommandService.ts @@ -10,8 +10,6 @@ import type { ProductCommandRequest, } from '@ecos-studio/shared' -import { workspaceSpecCreatePayload } from './eccRpc/workspaceSpecAdapter' - interface ProductCommandRuntime { cancelOperation(request: EccRuntimeOperationRequest): Promise createWorkspace(request: EccWorkspaceCreateRequest): Promise @@ -54,18 +52,16 @@ export async function executeProductCommand( case 'workspace.runStep': return await context.runtime.startStepOperation(request.payload) case 'workspace.update': { - const draft = await context.prepareCreate(request.payload.draft) - const canonical = workspaceSpecCreatePayload(draft) as { - commandId: string - workspaceBindings: Record - workspaceSpec: Record - } + 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: canonical.workspaceBindings, + workspaceBindings: draft.workspaceBindings, workspaceHandle, - workspaceSpec: canonical.workspaceSpec, + workspaceSpec: draft.workspaceSpec, }) } case 'workspace.cancel': @@ -87,7 +83,9 @@ function readProductCommandRequest(value: unknown): ProductCommandRequest { switch (value.command) { case 'workspace.create': requireString(payload, 'commandId') - requireString(payload, 'directory') + requireString(payload, 'targetDirectory') + requireRecord(payload, 'workspaceBindings') + requireRecord(payload, 'workspaceSpec') break case 'workspace.run': requireString(payload, 'workspaceHandle') @@ -104,6 +102,8 @@ function readProductCommandRequest(value: unknown): ProductCommandRequest { 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.cancel': @@ -135,6 +135,10 @@ function requireString(payload: Record, key: string): void { } } +function requireRecord(payload: Record, key: string): void { + if (!isRecord(payload[key])) throw new Error(`Product Command requires ${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') 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..c5e5a6ab5 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/projectManagementReadService.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/projectManagementReadService.test.ts @@ -75,19 +75,18 @@ describe('ProjectManagementReadService', () => { }, unavailablePaths: [], }) - await expect( - service.readWorkspaceTexts({ - projectRoot, - workspacePath: workspaceRoot, - paths: [...projectManagementWorkspaceSummaryPaths], - }), - ).resolves.toMatchObject({ + const summaries = await service.readWorkspaceTexts({ + projectRoot, + workspacePath: workspaceRoot, + paths: [...projectManagementWorkspaceSummaryPaths], + }) + expect(summaries).toMatchObject({ texts: expect.objectContaining({ - 'home/flow.json': '{"steps":[]}', 'lvs_ecc/analysis/qor_metrics.json': null, }), unavailablePaths: [], }) + expect(summaries.texts).not.toHaveProperty('home/flow.json') }) it('returns project.json text even when root_path does not match the selected directory', async () => { diff --git a/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts b/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts index a369ea49b..55a7a5b67 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/qorAnalysis.ts @@ -251,42 +251,10 @@ export interface ProjectQorTrendSummary { unsupportedModules: ProjectQorUnsupportedModule[] } -export interface ProjectQorScoreMetricDetail { - step: FlowStep - metricName: string - displayName: string - value: number - unit?: string - score: number -} - -export interface ProjectQorScoreDimensionDetail { - dimension: QorDimension - label: string - score: number - configuredWeight: number - effectiveWeight: number - contribution: number - metrics: ProjectQorScoreMetricDetail[] -} - -export interface ProjectQorScoreDetail { - overallScore: number | null - gateStatus: QorGateStatus - dimensions: ProjectQorScoreDimensionDetail[] -} - export interface ProjectQorTrendOptions { baselineWorkspaceId?: string | null } -export interface ProjectQorTrendReportMetadata { - projectId?: string - projectName?: string - projectPath?: string - generatedAt?: string -} - export interface ProjectQorTrendPoint { workspaceId: string label: string @@ -308,40 +276,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 } @@ -988,15 +922,6 @@ const DIMENSION_WEIGHTS: Record = { runtime: 0, } -const DIMENSION_LABELS: Record = { - timing: 'Timing', - power_integrity: 'Power / IR / EM', - routability_physical: 'Routability / Physical', - area_cost: 'Area', - clock_robustness_dfm: 'Clock / DFM', - runtime: 'Runtime', -} - const METRIC_FAIL_VALUES: Record = { drc_count: 10, lvs_count: 10, @@ -1247,381 +1172,6 @@ export function buildProjectQorTrendSummary( } } -export function buildProjectQorScoreDetail( - workspace: ProjectQorTrendWorkspaceSummary, -): ProjectQorScoreDetail { - const dimensions = QOR_DIMENSIONS.flatMap((dimension) => { - const score = workspace.dimensionScores[dimension] - const configuredWeight = DIMENSION_WEIGHTS[dimension] - if (score === undefined || configuredWeight <= 0) return [] - - const metrics = workspace.records.flatMap((record) => { - if ( - !isRecordIncludedInDimensionScore(record, dimension, workspace.areaScoringStep) - ) { - return [] - } - const metricScore = scoreRecord(record) - if (metricScore === null || record.value === null) return [] - return [ - { - step: record.step, - metricName: record.metricName, - displayName: record.displayName, - value: record.value, - unit: record.unit, - score: roundScore(metricScore), - }, - ] - }) - - return [ - { - dimension, - label: DIMENSION_LABELS[dimension], - score, - configuredWeight, - metrics, - }, - ] - }) - return { - overallScore: workspace.overallScore, - gateStatus: workspace.gateStatus, - dimensions: dimensions.map((dimension) => { - const effectiveWeight = dimension.configuredWeight - return { - ...dimension, - effectiveWeight: roundScore(effectiveWeight * 100), - contribution: roundScore(dimension.score * effectiveWeight), - } - }), - } -} - -/** - * 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.workspaceKey, - 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, - }, - area_scoring_step: workspace.areaScoringStep, - dimension_scores: workspace.dimensionScores, - 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[], diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspacePdkBindings.ts b/ecos/gui/apps/desktop-electron/electron/services/workspacePdkBindings.ts index 0078fb472..c8f5d5934 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspacePdkBindings.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspacePdkBindings.ts @@ -68,21 +68,29 @@ export async function prepareWorkspaceCreateBinding( projectRoot: _projectRoot, ...runtimeRequest } = request + const specPdk = isRecord(request.workspaceSpec.pdk) ? request.workspaceSpec.pdk : {} + const bindingPdk = isRecord(request.workspaceBindings.pdk) + ? request.workspaceBindings.pdk + : {} return { ...runtimeRequest, - pdk: requirement.familyId, - pdkRoot: installation.root, - pdkVersion: requirement.version, - ...(requirement.manualConfig - ? { - pdkConfigMode: 'manual' as const, - pdkConfig: { - tech_lef: [requirement.manualConfig.techLef], - cell_lef: requirement.manualConfig.cellLefs, - liberty: requirement.manualConfig.liberty, - }, - } - : {}), + workspaceBindings: { + ...request.workspaceBindings, + pdk: { + ...bindingPdk, + root: installation.root, + ...(requirement.version ? { version: requirement.version } : {}), + ...manualPdkFiles(specPdk, requirement), + }, + }, + workspaceSpec: { + ...request.workspaceSpec, + pdk: { + ...specPdk, + familyId: requirement.familyId, + ...(requirement.version ? { version: requirement.version } : {}), + }, + }, } } diff --git a/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts index e5537de3c..bc3bb74ab 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.test.ts @@ -1,4 +1,4 @@ -import type { ProjectManifest } from '@ecos-studio/shared' +import type { EccEngineeringSnapshot, ProjectManifest } from '@ecos-studio/shared' import { describe, expect, it } from 'vitest' import { buildProjectQorTrendSummary } from './qorAnalysis' import { analyzeWorkspaceQor, projectQorInputForWorkspace } from './workspaceQorAnalysis' @@ -39,12 +39,33 @@ function metricText(value: number): string { }) } -const engineeringSnapshot = JSON.stringify({ - qorAssessment: { - score: { gate: 'pass', threshold: 60, value: 73.5 }, - }, - signoffAssessment: { status: 'ready' }, -}) +function engineeringSnapshot(metricValue: number): EccEngineeringSnapshot { + const metric = JSON.parse(metricText(metricValue)).metrics[0] + return { + artifacts: [], + checklist: {}, + flow: { steps: [{ name: 'Route', state: 'Success' }] }, + metrics: [metric], + parameters: {}, + qorAssessment: { + 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 { @@ -75,14 +96,8 @@ describe('analyzeWorkspaceQor', () => { } as ProjectManifest const result = analyzeWorkspaceQor(manifest, 'current', { - baseline: { - 'home/engineering-snapshot.json': engineeringSnapshot, - 'route_ecc/analysis/qor_metrics.json': metricText(5200), - }, - current: { - 'home/engineering-snapshot.json': engineeringSnapshot, - 'route_ecc/analysis/qor_metrics.json': metricText(5000), - }, + baseline: engineeringSnapshot(5200), + current: engineeringSnapshot(5000), }) expect(result.qor).toMatchObject({ @@ -108,10 +123,14 @@ describe('analyzeWorkspaceQor', () => { status: 'ready', }) - const projectInput = projectQorInputForWorkspace(manifest, 'current', { - 'home/engineering-snapshot.json': engineeringSnapshot, - 'route_ecc/analysis/qor_metrics.json': metricText(5000), - }) + const projectInput = projectQorInputForWorkspace( + manifest, + 'current', + { + 'route_ecc/analysis/qor_metrics.json': metricText(5000), + }, + engineeringSnapshot(5000), + ) expect(buildProjectQorTrendSummary([projectInput!]).workspaces[0]?.overallScore).toBe( result.qor.status === 'ready' ? result.qor.data.score.value : null, ) @@ -119,23 +138,30 @@ describe('analyzeWorkspaceQor', () => { it('restores step metrics directly from an authoritative snapshot', () => { const metric = JSON.parse(metricText(5000)).metrics[0] - const snapshot = JSON.stringify({ + const snapshot: EccEngineeringSnapshot = { + artifacts: [], + checklist: {}, + flow: { steps: [{ name: 'CustomSignoff', state: 'Success' }] }, metrics: [metric], + parameters: {}, qorAssessment: { metrics: [metric], score: { gate: 'pass', threshold: 60, value: 73.5 }, steps: [ { - name: 'route', + name: 'CustomSignoff', order: 6, status: 'pass', - stepId: 'route', + stepId: 'CustomSignoff', summaryMetricCount: 1, }, ], }, - signoffAssessment: { status: 'ready' }, - }) + schemaVersion: 1, + signoffAssessment: { groups: [], risks: [], status: 'ready' }, + workspaceId: 'ecc-current', + workspaceRevision: 1, + } const manifest = { project_id: 'project-1', name: 'demo', @@ -144,21 +170,23 @@ describe('analyzeWorkspaceQor', () => { qor_baseline: null, } as ProjectManifest - const result = analyzeWorkspaceQor(manifest, 'current', { - current: { 'home/engineering-snapshot.json': snapshot }, - }) + const result = analyzeWorkspaceQor(manifest, 'current', { current: snapshot }) expect(result.qor).toMatchObject({ data: { - metrics: [{ id: 'route_wirelength', stepId: 'Route', value: 5000 }], + 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 === 'Route') + ? result.qor.data.steps.find((step) => step.stepId === 'CustomSignoff') : null, - ).toMatchObject({ status: 'pass', stepId: 'Route', summaryMetricCount: 1 }) + ).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 index 6f456aa70..4b2df6879 100644 --- a/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts +++ b/ecos/gui/apps/desktop-electron/electron/services/workspaceQorAnalysis.ts @@ -1,28 +1,19 @@ import { projectManagementStaTimingIssuesPath, projectManagementWorkspaceStepAnalysisSpecs, + type EccEngineeringSnapshot, type MetricComparison, type MetricValue, type ProjectManifest, type ProjectManifestFlowStep, - type QorScore, type ReadSection, type WorkspaceBaselineComparison, type WorkspaceQorSummary, } from '@ecos-studio/shared' -import { - buildProjectQorTrendSummary, - buildProjectQorWorkspaceComparison, - QOR_SCORE_THRESHOLD, - qorSummaryStatus, - type ProjectQorMetricRecord, - type ProjectQorWorkspaceInput, -} from './qorAnalysis' +import type { ProjectQorWorkspaceInput } from './qorAnalysis' export type WorkspaceAnalysisTexts = Record -const FLOW_STEPS = projectManagementWorkspaceStepAnalysisSpecs.map((spec) => spec.step) - const FLOW_STEP_ALIASES: Record = { synthesis: 'Synth', synth: 'Synth', @@ -56,20 +47,32 @@ interface WorkspaceQorInput extends ProjectQorWorkspaceInput { interface SnapshotQorProjection { assessment: ProjectQorWorkspaceInput['authoritativeAssessment'] qor: WorkspaceQorSummary | null - stepMetricTexts: Partial> - stepSummaryTexts: Partial> } -function snapshotMetric( - value: unknown, - stepId: ProjectManifestFlowStep, -): MetricValue | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null - const metric = value as Record +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 : 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 + const polarity = metric.direction ?? metric.polarity if ( !id || typeof number !== 'number' || @@ -90,130 +93,94 @@ function snapshotMetric( } } -function snapshotQorProjection(text: string | null | undefined): SnapshotQorProjection { - const empty: SnapshotQorProjection = { - assessment: null, - qor: null, - stepMetricTexts: {}, - stepSummaryTexts: {}, +function snapshotQorProjection( + snapshot: EccEngineeringSnapshot | null | undefined, +): SnapshotQorProjection { + const empty: SnapshotQorProjection = { assessment: null, qor: null } + if (!snapshot) return empty + const qor = record(snapshot.qorAssessment) + const score = record(qor?.score) + const gate = score?.gate + const value = score?.value + const threshold = score?.threshold + const signoffStatus = snapshot.signoffAssessment.status + if ( + !['pass', 'blocked', 'incomplete', 'unavailable'].includes(String(gate)) || + !(value === null || (typeof value === 'number' && Number.isFinite(value))) || + typeof threshold !== 'number' || + !Number.isFinite(threshold) || + !['ready', 'attention', 'blocked'].includes(signoffStatus) + ) { + return empty } - if (!text) return empty - try { - const snapshot = JSON.parse(text) as Record - const qor = snapshot.qorAssessment as Record | undefined - const score = qor?.score as Record | undefined - const signoff = snapshot.signoffAssessment as Record | undefined - const gate = score?.gate - const value = score?.value - const threshold = score?.threshold - const signoffStatus = signoff?.status + const assessment = { + gateStatus: gate as NonNullable< + ProjectQorWorkspaceInput['authoritativeAssessment'] + >['gateStatus'], + score: value as number | null, + scoreThreshold: threshold, + signoffStatus, + } + const rawMetrics = Array.isArray(qor?.metrics) ? qor.metrics : snapshot.metrics + if (!Array.isArray(rawMetrics) || !Array.isArray(qor?.steps)) { + return { assessment, qor: null } + } + + 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 ( - !['pass', 'blocked', 'incomplete', 'unavailable'].includes(String(gate)) || - !(value === null || (typeof value === 'number' && Number.isFinite(value))) || - typeof threshold !== 'number' || - !Number.isFinite(threshold) || - !['ready', 'attention', 'blocked'].includes(String(signoffStatus)) + !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 empty - } - const assessment = { - gateStatus: gate as NonNullable< - ProjectQorWorkspaceInput['authoritativeAssessment'] - >['gateStatus'], - score: value as number | null, - scoreThreshold: threshold, - signoffStatus: signoffStatus as NonNullable< - ProjectQorWorkspaceInput['authoritativeAssessment'] - >['signoffStatus'], + return { assessment, qor: null } } - if (!Array.isArray(qor?.metrics) || !Array.isArray(qor.steps)) { - return { ...empty, assessment } + const nextOffset = offset + (count as number) + if (nextOffset > rawMetrics.length) return { assessment, qor: null } + const stepMetrics = rawMetrics + .slice(offset, nextOffset) + .map((metric) => snapshotMetric(metric, stepId)) + if (stepMetrics.some((metric) => metric === null)) { + return { assessment, qor: null } } - - const metrics: MetricValue[] = [] - const steps: WorkspaceQorSummary['steps'] = [] - const stepMetricTexts: Partial> = {} - const stepSummaryTexts: Partial> = {} - let offset = 0 - for (const rawStep of qor.steps) { - if (!rawStep || typeof rawStep !== 'object' || Array.isArray(rawStep)) { - return { ...empty, assessment } - } - const stepRecord = rawStep as Record - const count = stepRecord.summaryMetricCount - const order = stepRecord.order - if ( - !Number.isInteger(count) || - (count as number) < 0 || - !Number.isInteger(order) || - (order as number) < 0 - ) { - return { ...empty, assessment } - } - const nextOffset = offset + (count as number) - if (nextOffset > qor.metrics.length) return { ...empty, assessment } - const step = flowStep(stepRecord.stepId ?? stepRecord.name) - if ((!step || !FLOW_STEPS.includes(step as ProjectManifestFlowStep)) && count) { - return { ...empty, assessment } - } - if (step && FLOW_STEPS.includes(step as ProjectManifestFlowStep)) { - const canonicalStep = step as ProjectManifestFlowStep - if (stepMetricTexts[canonicalStep]) return { ...empty, assessment } - const stepMetrics = qor.metrics - .slice(offset, nextOffset) - .map((metric) => snapshotMetric(metric, canonicalStep)) - if (stepMetrics.some((metric) => metric === null)) { - return { ...empty, assessment } - } - const status = stepRecord.status - if (!['pass', 'blocked', 'incomplete', 'unavailable'].includes(String(status))) { - return { ...empty, assessment } - } - metrics.push(...(stepMetrics as MetricValue[])) - steps.push({ - stepId: canonicalStep, - order: order as number, - name: canonicalStep, - status: status as WorkspaceQorSummary['steps'][number]['status'], - summaryMetricCount: count as number, - metrics: stepMetrics as MetricValue[], - }) - stepMetricTexts[canonicalStep] = JSON.stringify({ - schema_version: 3, - metrics: qor.metrics.slice(offset, nextOffset), - }) - stepSummaryTexts[canonicalStep] = JSON.stringify({ - schema_version: 4, - quality_status: stepRecord.status, - }) - } - offset = nextOffset - } - return offset === qor.metrics.length - ? { - assessment, - qor: { - score: { - value: assessment.score, - gate: assessment.gateStatus, - threshold: assessment.scoreThreshold, - }, - metrics, - steps, - }, - stepMetricTexts, - stepSummaryTexts, - } - : { ...empty, assessment } - } catch { - return empty + 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 } + return { + assessment, + qor: { + score: { + value: assessment.score, + gate: assessment.gateStatus, + threshold: assessment.scoreThreshold, + }, + metrics, + steps, + }, } -} - -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 flowState(value: unknown): ProjectStepStatus | undefined { @@ -243,23 +210,17 @@ function flowState(value: unknown): ProjectStepStatus | undefined { } } -function flowStates(text: string | null | undefined): Record { - if (!text) return {} - try { - const parsed = JSON.parse(text) as { steps?: unknown[] } - if (!Array.isArray(parsed.steps)) return {} - return Object.fromEntries( - parsed.steps.flatMap((raw) => { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [] - const stepRecord = raw as Record - const step = flowStep(stepRecord.name) - const state = flowState(stepRecord.state) - return step && state ? [[step, state]] : [] - }), - ) - } catch { - return {} - } +function flowStates(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( @@ -281,13 +242,14 @@ export function projectQorInputForWorkspace( manifest: ProjectManifest, workspaceId: string, texts: WorkspaceAnalysisTexts, + engineeringSnapshot?: EccEngineeringSnapshot | null, ): WorkspaceQorInput | null { const workspace = manifest.workspaces.find( (candidate) => candidate.workspace_id === workspaceId, ) if (!workspace) return null - const statuses = flowStates(texts['home/flow.json']) - const snapshot = snapshotQorProjection(texts['home/engineering-snapshot.json']) + const statuses = flowStates(engineeringSnapshot?.flow) + const snapshot = snapshotQorProjection(engineeringSnapshot) return { branchFrom: workspace.branch_from, createdAt: workspace.created_at, @@ -301,149 +263,94 @@ export function projectQorInputForWorkspace( texts[spec.hotspotsPath] ?? null, ]), ), - stepMetricTexts: { - ...Object.fromEntries( - projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ - spec.step, - texts[spec.metricsPath] ?? null, - ]), - ), - ...snapshot.stepMetricTexts, - }, + stepMetricTexts: Object.fromEntries( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ + spec.step, + texts[spec.metricsPath] ?? null, + ]), + ), stepStatuses: statuses, - stepSummaryTexts: { - ...Object.fromEntries( - projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ - spec.step, - texts[spec.summaryPath] ?? null, - ]), - ), - ...snapshot.stepSummaryTexts, - }, + stepSummaryTexts: Object.fromEntries( + projectManagementWorkspaceStepAnalysisSpecs.map((spec) => [ + spec.step, + texts[spec.summaryPath] ?? null, + ]), + ), workspaceId, workspaceName: workspace.name || workspaceId, workspaceKey: workspaceId, } } -function qorScore( - record: { - overallScore: number | null - gateStatus: QorScore['gate'] - }, - threshold = QOR_SCORE_THRESHOLD, -): QorScore { - return { - value: record.overallScore, - gate: record.gateStatus, - threshold, - } +function metricKey(metric: MetricValue): string { + return `${metric.stepId}:\0${metric.id}` } -function metricValue(record: ProjectQorMetricRecord): MetricValue { +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 { - id: record.metricName, - name: record.displayName, - stepId: record.step, - value: record.value, - ...(record.unit ? { unit: record.unit } : {}), - polarity: record.polarity, - } -} - -function workspaceQor( - record: ReturnType['workspaces'][number], - input: ProjectQorWorkspaceInput, -): WorkspaceQorSummary { - const metrics = (record.comparisonRecords ?? record.records).map(metricValue) - return { - score: qorScore( - record, - input.authoritativeAssessment?.scoreThreshold ?? QOR_SCORE_THRESHOLD, - ), - metrics, - steps: FLOW_STEPS.map((step, order) => { - const stepMetrics = metrics.filter((metric) => metric.stepId === step) - return { - stepId: step, - order, - name: step, - metrics: stepMetrics, - status: qorSummaryStatus(input.stepSummaryTexts?.[step]) ?? 'unavailable', - summaryMetricCount: stepMetrics.length, - } - }), - } -} - -function comparisonDelta( - metric: ReturnType['metrics'][number], -): MetricComparison { - return { - metricId: metric.metricName, - name: metric.displayName, - stepId: metric.step, - currentValue: metric.currentValue, - baselineValue: metric.baselineValue, - absoluteDelta: metric.absoluteDelta, - relativeDeltaPct: metric.relativeDeltaPct, - ...(metric.unit ? { unit: metric.unit } : {}), - polarity: metric.polarity, - verdict: !metric.isDirectional - ? 'not-comparable' - : metric.state === 'neutral' - ? 'unchanged' - : metric.state, + 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, - textsByWorkspaceId: Record, + snapshotsByWorkspaceId: Record, ): { qor: ReadSection baselineComparison: ReadSection } { - const currentInput = projectQorInputForWorkspace( - manifest, - currentWorkspaceId, - textsByWorkspaceId[currentWorkspaceId] ?? {}, + const currentWorkspace = manifest.workspaces.find( + (workspace) => workspace.workspace_id === currentWorkspaceId, ) - if (!currentInput) { - const unavailable = { - status: 'unavailable' as const, - issues: [{ code: 'WORKSPACE_QOR_UNAVAILABLE' }], + 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' }], + }, } - return { qor: unavailable, baselineComparison: unavailable } } + const baselineWorkspaceId = manifest.qor_baseline?.workspace_id - const baselineInput = baselineWorkspaceId - ? projectQorInputForWorkspace( - manifest, - baselineWorkspaceId, - textsByWorkspaceId[baselineWorkspaceId] ?? {}, - ) - : null - const trend = buildProjectQorTrendSummary( - baselineInput && baselineInput.workspaceId !== currentInput.workspaceId - ? [baselineInput, currentInput] - : [currentInput], - { baselineWorkspaceId: baselineWorkspaceId ?? null }, - ) - const current = trend.workspaces.find( - (workspace) => workspace.workspaceId === currentWorkspaceId, + const baselineWorkspace = manifest.workspaces.find( + (workspace) => workspace.workspace_id === baselineWorkspaceId, ) - const qor: ReadSection = currentInput.snapshotQor - ? { status: 'ready', data: currentInput.snapshotQor, issues: [] } - : current - ? { status: 'ready', data: workspaceQor(current, currentInput), issues: [] } - : { - status: 'unavailable', - issues: [{ code: 'WORKSPACE_QOR_UNAVAILABLE' }], - } - - if (!baselineWorkspaceId || !baselineInput) { + const baseline = baselineWorkspaceId + ? snapshotQorProjection(snapshotsByWorkspaceId[baselineWorkspaceId]) + : null + if (!baselineWorkspaceId || !baselineWorkspace || !baseline?.qor) { return { qor, baselineComparison: { @@ -452,29 +359,32 @@ export function analyzeWorkspaceQor( }, } } - const comparison = buildProjectQorWorkspaceComparison(trend, currentWorkspaceId) + + 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: - comparison.baselineWorkspaceName ?? baselineInput.workspaceName, - baselineScore: { - value: comparison.baselineScore, - gate: - trend.workspaces.find( - (workspace) => workspace.workspaceId === baselineWorkspaceId, - )?.gateStatus ?? 'unavailable', - threshold: QOR_SCORE_THRESHOLD, - }, - deltas: comparison.metrics.map(comparisonDelta), - status: comparison.isBaselineWorkspace - ? 'baseline' - : comparison.available - ? 'comparable' - : 'not-comparable', + 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/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('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/api/index.ts b/ecos/gui/apps/renderer/src/api/index.ts index b0f0198c0..b666f5893 100644 --- a/ecos/gui/apps/renderer/src/api/index.ts +++ b/ecos/gui/apps/renderer/src/api/index.ts @@ -6,13 +6,11 @@ export { waitForRuntimeReady, type WaitForRuntimeReadyOptions } from './client' export { closeWorkspaceApi, + backendWorkspaceOptions, loadWorkspaceApi, createWorkspaceApi, updateWorkspaceApi, - type ProjectInfo, type WorkspaceResponse, - type LoadWorkspaceRequest, - type CreateWorkspaceRequest, } from './workspace' export { diff --git a/ecos/gui/apps/renderer/src/api/workspace.desktop-ipc.test.ts b/ecos/gui/apps/renderer/src/api/workspace.desktop-ipc.test.ts index 2c25dd916..e2899f42d 100644 --- a/ecos/gui/apps/renderer/src/api/workspace.desktop-ipc.test.ts +++ b/ecos/gui/apps/renderer/src/api/workspace.desktop-ipc.test.ts @@ -43,8 +43,8 @@ describe('workspace desktop bridge', () => { }, }) - const { createWorkspaceApi } = await import('./workspace') - const options = reactive({ + const { backendWorkspaceOptions, createWorkspaceApi } = await import('./workspace') + const config = reactive({ directory: '/workspace/demo', filelist: '', origin_def: '', @@ -59,14 +59,12 @@ describe('workspace desktop bridge', () => { top_module: 'top', }, pdk: 'ics55', - pdk_json: { - name: 'ics55', - root: '/pdks/ics55', - tech: '/pdks/ics55/tech.lef', - lefs: ['/pdks/ics55/stdcells.lef'], - libs: ['/pdks/ics55/stdcells.lib'], - }, pdk_root: '/pdks/ics55', + pdk_requirement: { + familyId: 'ics55', + manualConfig: null, + version: null, + }, rtl_list: ['/rtl/top.v'], sdc: '/constraints/top.sdc', flow_config: { @@ -76,7 +74,9 @@ describe('workspace desktop bridge', () => { }, }) - await expect(createWorkspaceApi(options)).resolves.toMatchObject({ + await expect( + createWorkspaceApi(backendWorkspaceOptions(config, config.directory)), + ).resolves.toMatchObject({ response: 'success', data: { workspace_handle: 'workspace-handle-1', @@ -86,28 +86,55 @@ describe('workspace desktop bridge', () => { expect.objectContaining({ command: 'workspace.create', payload: expect.objectContaining({ - parameters: expect.objectContaining({ - design: 'demo', + targetDirectory: '/workspace/demo', + workspaceBindings: expect.objectContaining({ + inputs: { + 'rtl-1': '/rtl/top.v', + sdc: '/constraints/top.sdc', + }, + }), + workspaceSpec: expect.objectContaining({ + design: expect.objectContaining({ name: 'demo', topModule: 'top' }), + flow: { + flowId: 'harden', + fromStepId: 'Synthesis', + throughStepId: 'Harden', + }, + parameters: expect.objectContaining({ + 'design.frequency_mhz': 100, + }), }), - rtlList: ['/rtl/top.v'], - flowConfig: { - start_step: 'Synthesis', - end_step: 'Harden', - steps: ['Synthesis', 'RCX', 'sta', 'Harden'], - }, - pdkJson: { - name: 'ics55', - root: '/pdks/ics55', - tech: '/pdks/ics55/tech.lef', - lefs: ['/pdks/ics55/stdcells.lef'], - libs: ['/pdks/ics55/stdcells.lib'], - }, - sdc: '/constraints/top.sdc', }), }), ) }) + it('keeps a synthesis-only Flow range when endpoints are inferred', async () => { + const { backendWorkspaceOptions } = await import('./workspace') + const options = backendWorkspaceOptions( + { + directory: '/workspace/synth', + flow_config: { start_step: '', end_step: '', steps: ['Synthesis'] }, + origin_def: '', + origin_verilog: '/rtl/top.v', + parameters: { design: 'demo', top_module: 'top' }, + pdk: 'ics55', + pdk_root: '/pdks/ics55', + rtl_list: [], + }, + '/workspace/synth', + ) + + expect(options.workspaceSpec).toMatchObject({ + design: { clockPort: 'clk' }, + flow: { + flowId: 'syn_sta', + fromStepId: 'Synthesis', + throughStepId: 'Synthesis', + }, + }) + }) + it('forwards the CPU module independently from the frontend SoC top', async () => { const create = vi.fn(async () => ({ directory: '/workspace/frontend-demo', diff --git a/ecos/gui/apps/renderer/src/api/workspace.ts b/ecos/gui/apps/renderer/src/api/workspace.ts index c0944c45a..036603188 100644 --- a/ecos/gui/apps/renderer/src/api/workspace.ts +++ b/ecos/gui/apps/renderer/src/api/workspace.ts @@ -5,15 +5,10 @@ import { projectIdFromName, type DesignTool, type EccWorkspaceCreateRequest, + type ProjectManifestMpc, + type WorkspaceConfig, } from '@ecos-studio/shared' -// Types for API requests and responses -export interface ProjectInfo { - name: string - path: string - flow?: Record -} - export interface WorkspaceResponse { cmd: CMDEnum response: string @@ -27,93 +22,20 @@ export interface WorkspaceResponse { message: string[] } -export interface LoadWorkspaceRequest { - cmd: CMDEnum.load_workspace - data: { - cpu_filelist?: string - cpu_rtl_files?: string[] - designTool?: DesignTool - directory: string - } -} - -export interface CreateWorkspaceRequest { - cmd: CMDEnum.create_workspace - data: { - pdk: string - pdk_root: string - directory: string - parameters: Record - origin_def: string - origin_verilog: string - filelist: string - rtl_list: string[] - design_input_mode?: string - sdc?: string - flow_config?: Record - pdk_config_mode?: string - pdk_config?: Record - pdk_json?: unknown - project_context?: Record - soc_filelist?: string - testbench?: string - } -} - -/** - * Open an existing project - * @param path - Full path to the project directory - */ -export function loadWorkspaceApi(directory: string, designTool: DesignTool = 'backend') { - return getDesktopApi() - .runtime.workspace.open({ designTool, directory }) - .then((result) => ({ - cmd: CMDEnum.load_workspace, - data: { - designTool, - directory: result.directory, - workspace_handle: result.workspaceHandle, - workspaceHandle: result.workspaceHandle, - workspaceRevision: result.workspaceRevision, - }, - message: [], - response: ResponseEnum.success, - })) as Promise -} - -export function closeWorkspaceApi( - workspaceHandle: string, - designTool: DesignTool = 'backend', -) { - return getDesktopApi().runtime.workspace.close({ designTool, workspaceHandle }) +type BackendWorkspaceCreateOptions = Omit & { + designTool: 'backend' } -/** - * Create a new project - * @param path - Parent directory where the project will be created - * @param name - Name of the new project (optional, defaults to "New_Chip_Design") - * @param options - Additional project configuration options from wizard - */ -export function createWorkspaceApi(options: { +interface FrontendWorkspaceCreateOptions { directory?: string - designTool?: DesignTool + designTool: 'frontend' pdk?: string parameters?: Record origin_def?: string origin_verilog?: string rtl_list?: string[] pdk_root?: string - pdk_installation_id?: string - pdk_requirement?: import('@ecos-studio/shared').PdkRequirement filelist?: string - mpc?: Record | null - design_input_mode?: string - sdc?: string - flow_config?: Record - pdk_config_mode?: string - pdk_config?: Record - pdk_json?: unknown - project_context?: Record cpu_filelist?: string cpu_rtl_files?: string[] cpu_top_module?: string @@ -146,7 +68,194 @@ export function createWorkspaceApi(options: { toolchain_id?: string test_suite_id?: string core_id?: string -}) { +} + +export function backendWorkspaceOptions( + config: WorkspaceConfig, + targetDirectory: string, +): BackendWorkspaceCreateOptions { + const parameters = config.parameters ?? {} + const inputs: Array<{ inputId: string; role: string }> = [] + const inputBindings: Record = {} + const addInput = (inputId: string, role: string, path: string) => { + if (!path) return + inputs.push({ inputId, role }) + inputBindings[inputId] = path + } + if (config.filelist) addInput('filelist', 'filelist', config.filelist) + config.rtl_list.forEach((path, index) => addInput(`rtl-${index + 1}`, 'rtl', path)) + if (!config.filelist && !config.rtl_list.length) { + addInput( + config.design_input_mode === 'post_synthesis' ? 'netlist' : 'rtl-main', + config.design_input_mode === 'post_synthesis' ? 'netlist' : 'rtl', + config.origin_verilog, + ) + } + addInput('def', 'def', config.origin_def) + addInput('sdc', 'sdc', config.sdc ?? '') + + const pdkMode = config.pdk_config_mode === 'manual' ? 'manual' : 'default' + const pdkFiles: Array<{ fileId: string; role: string }> = [] + const pdkFileBindings: Record = {} + const addPdkFiles = (role: string, paths: string[]) => { + paths.forEach((path, index) => { + const fileId = role === 'tech' && index === 0 ? 'tech' : `${role}-${index + 1}` + pdkFiles.push({ fileId, role }) + pdkFileBindings[fileId] = path + }) + } + if (pdkMode === 'manual') { + addPdkFiles('tech', config.pdk_config?.tech_lef ?? []) + addPdkFiles('lef', config.pdk_config?.cell_lef ?? []) + addPdkFiles('liberty', config.pdk_config?.liberty ?? []) + } + + const numberValue = (value: unknown, fallback: number) => { + const number = Number(value) + return Number.isFinite(number) ? number : fallback + } + const fixedDie = parameters.die_area_mode === 'width_height' + const flowSteps = config.flow_config?.steps ?? [] + const startStep = config.flow_config?.start_step || flowSteps[0] + const endStep = config.flow_config?.end_step || flowSteps[flowSteps.length - 1] + const selectedSteps = new Set([...flowSteps, startStep, endStep].filter(Boolean)) + const flowId = selectedSteps.has('Harden') + ? 'harden' + : selectedSteps.has('RCX') || selectedSteps.has('sta') + ? 'rcx' + : selectedSteps.size === 1 && selectedSteps.has('Synthesis') + ? 'syn_sta' + : 'rtl2gds' + const mpc = config.mpc as ProjectManifestMpc | null | undefined + const projectContext = config.project_context + + return { + designTool: 'backend', + targetDirectory, + pdkInstallationId: config.pdk_installation_id, + pdkRequirement: config.pdk_requirement, + projectId: + projectContext?.project_id ?? + projectIdFromName( + projectContext?.project_name || targetDirectory.split('/').pop() || '', + ), + projectRoot: projectContext?.project_root || targetDirectory, + workspaceSpec: { + schemaVersion: 1, + design: { + name: String(parameters.design || targetDirectory.split('/').pop() || ''), + topModule: String(parameters.top_module || 'top'), + clockPort: String(parameters.clock || 'clk'), + }, + inputMode: config.design_input_mode === 'post_synthesis' ? 'postSynthesis' : 'rtl', + inputs, + pdk: { + familyId: config.pdk || 'ics55', + mode: pdkMode, + ...(config.pdk_requirement?.version + ? { version: config.pdk_requirement.version } + : {}), + ...(pdkMode === 'manual' ? { files: pdkFiles } : {}), + }, + flow: { + flowId, + ...(startStep && endStep + ? { + fromStepId: startStep, + throughStepId: endStep, + } + : {}), + }, + ...(mpc + ? { + mpc: { + resourceId: mpc.resource_id, + version: mpc.installed_version, + designId: mpc.design.design_name || String(mpc.design.index), + }, + } + : {}), + parameters: { + ...Object.fromEntries( + Object.entries(parameters).filter(([key]) => key.includes('.')), + ), + 'design.frequency_mhz': numberValue(parameters.frequency_max, 100), + 'floorplan.mode': fixedDie ? 'width_height' : 'utilization', + 'floorplan.core_util': numberValue( + parameters.utilitization ?? parameters.core_utilization, + fixedDie ? 0.5 : 0.6, + ), + ...(fixedDie + ? { + 'floorplan.die_width': numberValue(parameters.die_width, 100), + 'floorplan.die_height': numberValue(parameters.die_height, 100), + } + : { + 'floorplan.core_margin': [ + numberValue(parameters.margin, 0), + numberValue(parameters.margin, 0), + ], + }), + 'synth.max_fanout': numberValue(parameters.max_fanout, 20), + 'place.target_density': numberValue(parameters.target_density, 0.2), + 'place.target_overflow': numberValue(parameters.target_overflow, 0.1), + }, + }, + workspaceBindings: { + inputs: inputBindings, + pdk: { + root: config.pdk_root, + ...(pdkMode === 'manual' ? { files: pdkFileBindings } : {}), + }, + ...(mpc + ? { + mpc: { + template: mpc.core_template, + sourcePath: mpc.spec_path || mpc.path, + }, + } + : {}), + }, + } +} + +/** + * Open an existing project + * @param path - Full path to the project directory + */ +export function loadWorkspaceApi(directory: string, designTool: DesignTool = 'backend') { + return getDesktopApi() + .runtime.workspace.open({ designTool, directory }) + .then((result) => ({ + cmd: CMDEnum.load_workspace, + data: { + designTool, + directory: result.directory, + workspace_handle: result.workspaceHandle, + workspaceHandle: result.workspaceHandle, + workspaceRevision: result.workspaceRevision, + }, + message: [], + response: ResponseEnum.success, + })) as Promise +} + +export function closeWorkspaceApi( + workspaceHandle: string, + designTool: DesignTool = 'backend', +) { + return getDesktopApi().runtime.workspace.close({ designTool, workspaceHandle }) +} + +/** + * Create a new project + * @param path - Parent directory where the project will be created + * @param name - Name of the new project (optional, defaults to "New_Chip_Design") + * @param options - Additional project configuration options from wizard + */ +export function createWorkspaceApi( + options: BackendWorkspaceCreateOptions | FrontendWorkspaceCreateOptions, +) { if (options.designTool === 'frontend') { const payload = toDesktopBridgeData({ cpu_filelist: options.cpu_filelist || '', @@ -209,31 +318,15 @@ export function createWorkspaceApi(options: { })) as Promise } - const data = toDesktopBridgeData({ - directory: options?.directory || '', - pdk: options?.pdk || '', - parameters: options.parameters || {}, - origin_def: options.origin_def || '', - origin_verilog: options.origin_verilog || '', - rtl_list: options.rtl_list || [], - pdk_root: options.pdk_root || '', - pdk_installation_id: options.pdk_installation_id || '', - pdk_requirement: options.pdk_requirement, - filelist: options.filelist || '', - mpc: options.mpc ?? null, - design_input_mode: options.design_input_mode || '', - sdc: options.sdc || '', - flow_config: options.flow_config || {}, - pdk_config_mode: options.pdk_config_mode || '', - pdk_config: options.pdk_config || {}, - pdk_json: options.pdk_json || '', - project_context: options.project_context || {}, - }) - const draft = backendWorkspaceDraft(data) + const { designTool: _designTool, ...draft } = options + const payload = toDesktopBridgeData({ + ...draft, + commandId: crypto.randomUUID(), + }) as unknown as EccWorkspaceCreateRequest return getDesktopApi() .productCommands.execute({ command: 'workspace.create', - payload: { ...draft, commandId: crypto.randomUUID() }, + payload, }) .then((result) => ({ cmd: CMDEnum.create_workspace, @@ -250,87 +343,18 @@ export function createWorkspaceApi(options: { } export function updateWorkspaceApi( - options: Parameters[0], + options: BackendWorkspaceCreateOptions, workspaceHandle: string, expectedWorkspaceRevision: number, ) { - const data = toDesktopBridgeData({ - ...options, - flow_config: options.flow_config ?? {}, - pdk_config: options.pdk_config ?? {}, - project_context: options.project_context ?? {}, - }) + const { designTool: _designTool, ...request } = options return getDesktopApi().productCommands.execute({ command: 'workspace.update', payload: { - draft: backendWorkspaceDraft(data), + draft: toDesktopBridgeData(request) as Omit, commandId: crypto.randomUUID(), expectedWorkspaceRevision, workspaceHandle, }, }) } - -function backendWorkspaceDraft(data: Record): EccWorkspaceCreateRequest { - const directory = String(data.directory ?? '') - return { - ...(data.design_input_mode - ? { - designInputMode: String(data.design_input_mode) as 'rtl' | 'post_synthesis', - } - : {}), - directory, - filelist: String(data.filelist ?? ''), - mpc: (data.mpc as Record | null) ?? null, - flowConfig: (data.flow_config as Record) ?? {}, - originDef: String(data.origin_def ?? ''), - originVerilog: String(data.origin_verilog ?? ''), - parameters: (data.parameters as Record) ?? {}, - pdk: String(data.pdk ?? ''), - pdkConfig: - (data.pdk_config as { - cell_lef?: string[] - liberty?: string[] - tech_lef?: string[] - }) ?? undefined, - ...(data.pdk_config_mode - ? { - pdkConfigMode: String(data.pdk_config_mode) as 'default' | 'manual', - } - : {}), - pdkJson: data.pdk_json ?? null, - pdkRoot: String(data.pdk_root ?? ''), - pdkInstallationId: String(data.pdk_installation_id ?? ''), - pdkRequirement: data.pdk_requirement as - | import('@ecos-studio/shared').PdkRequirement - | undefined, - projectId: projectIdFromContext( - data.project_context as Record, - directory, - ), - projectRoot: projectRootFromContext( - data.project_context as Record, - directory, - ), - rtlList: Array.isArray(data.rtl_list) ? (data.rtl_list as string[]) : [], - sdc: String(data.sdc ?? ''), - } -} - -function projectRootFromContext( - context: Record, - workspaceDirectory: string, -): string { - return String(context.project_root || workspaceDirectory) -} - -function projectIdFromContext( - context: Record, - workspaceDirectory: string, -): string { - if (context.project_id) return String(context.project_id) - const name = String( - context.project_name || workspaceDirectory.split(/[/\\]/).pop() || 'project', - ) - return projectIdFromName(name) -} diff --git a/ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewContainer.vue b/ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewContainer.vue deleted file mode 100644 index e3c0a3f40..000000000 --- a/ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewContainer.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - - - diff --git a/ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewController.test.ts b/ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewController.test.ts deleted file mode 100644 index 9e9e1192a..000000000 --- a/ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewController.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest' -import controllerSource from '@/applications/image-preview/ImagePreviewController.ts?raw' -import containerSource from '@/applications/image-preview/ImagePreviewContainer.vue?raw' -import rulerSource from '@/applications/image-preview/rulerDrawing.ts?raw' -import techPreviewSource from '@/components/TechPreviewCanvas.vue?raw' - -describe('ImagePreviewController', () => { - it('uses Canvas 2D instead of Pixi or WebGL', () => { - expect(controllerSource).toContain("getContext('2d')") - expect(controllerSource).toContain('drawImage') - expect(controllerSource).not.toContain('pixi') - expect(controllerSource).not.toContain('webgl') - expect(controllerSource).not.toContain('webgpu') - }) - - it('keeps the preview viewport API used by DrawingArea', () => { - expect(controllerSource).toContain('setBackgroundImage') - expect(controllerSource).toContain('fitToWorld') - expect(controllerSource).toContain('screenToWorld') - expect(controllerSource).toContain('worldToDisplay') - expect(controllerSource).toContain('setRulerEnabled') - expect(controllerSource).toContain('onWheel') - expect(controllerSource).toContain('setZoomAt') - }) - - it('scales wheel zoom with scroll distance instead of a fixed step per event', () => { - expect(controllerSource).toContain('WHEEL_PIXELS_PER_NOTCH') - expect(controllerSource).toContain('wheelNotches') - expect(controllerSource).toContain('deltaMode') - expect(controllerSource).toContain('Math.pow') - expect(controllerSource).toContain('WHEEL_MAX_NOTCHES_PER_EVENT') - }) -}) - -describe('rulerDrawing', () => { - it('anchors the horizontal ruler to the bottom edge of the canvas', () => { - expect(rulerSource).toContain('const rulerTop = screenHeight - thickness') - expect(rulerSource).toContain( - 'ctx.fillRect(thickness, rulerTop, screenWidth - thickness, thickness)', - ) - expect(rulerSource).not.toMatch( - /fillRect\(thickness,\s*0,\s*screenWidth - thickness,\s*thickness\)/, - ) - }) -}) - -describe('ImagePreviewContainer', () => { - it('initializes synchronously without blocking wheel zoom on the container', () => { - expect(containerSource).toContain('controller.init(containerRef.value)') - expect(containerSource).toContain("emit('ready', controller)") - expect(containerSource).not.toContain('Loading Editor') - expect(containerSource).not.toContain('preventWheel') - }) -}) - -describe('TechPreviewCanvas', () => { - it('renders tech preview geometry with Canvas 2D', () => { - expect(techPreviewSource).toContain("getContext('2d')") - expect(techPreviewSource).toContain('buildTechPreviewRenderGroups') - expect(techPreviewSource).not.toContain('pixi') - }) -}) diff --git a/ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewController.ts b/ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewController.ts deleted file mode 100644 index c29bc05e9..000000000 --- a/ecos/gui/apps/renderer/src/applications/image-preview/ImagePreviewController.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { displayPointFromWorld } from '@/applications/editor/core/editorCoordinates' -import { RULER_THICKNESS } from '@/applications/editor/core/rulerConfig' -import { - darkTheme, - themes, - type EditorTheme, - type ThemeName, -} from '@/applications/editor/core/Theme' -import { colorNumberToCss } from './themeUtils' -import { drawRulers } from './rulerDrawing' -import type { ViewportTransform } from './types' - -export interface ImagePreviewControllerOptions { - worldWidth?: number - worldHeight?: number - theme?: ThemeName -} - -export interface FitToWorldOptions { - worldCenter?: { x: number; y: number } -} - -const DEFAULT_OPTIONS: Required> & { - theme: ThemeName -} = { - worldWidth: 4000, - worldHeight: 4000, - theme: 'dark', -} - -const RESIZE_DEBOUNCE_DELAY = 16 - -/** Zoom change applied per wheel notch (100px of deltaY), kept in sync with toolbar zoomIn/zoomOut. */ -const WHEEL_ZOOM_STEP_PER_NOTCH = 0.1 -const WHEEL_PIXELS_PER_NOTCH = 100 -const WHEEL_LINES_TO_PIXELS = 16 -const WHEEL_MAX_NOTCHES_PER_EVENT = 3 - -export class ImagePreviewController { - readonly canvas: HTMLCanvasElement - - private container: HTMLElement | null = null - private ctx: CanvasRenderingContext2D | null = null - private options: Required> & { - theme: ThemeName - } - private theme: EditorTheme - private transform: ViewportTransform = { x: 0, y: 0, scale: 1 } - private transformListeners = new Set<(t: ViewportTransform) => void>() - private resizeObserver: ResizeObserver | null = null - private resizeDebounceTimer: ReturnType | null = null - private _initialized = false - private _rulerEnabled = true - private backgroundImage: HTMLImageElement | null = null - private currentBlobUrl: string | null = null - private screenWidth = 0 - private screenHeight = 0 - private devicePixelRatio = 1 - private _screenSizeForResize: { w: number; h: number } | null = null - private _resizeUsesCenteredUnderflowRule = false - private dragState: { - pointerId: number - startX: number - startY: number - originX: number - originY: number - } | null = null - private boundPointerDown: (event: PointerEvent) => void - private boundPointerMove: (event: PointerEvent) => void - private boundPointerUp: (event: PointerEvent) => void - private boundWheel: (event: WheelEvent) => void - - constructor(options: ImagePreviewControllerOptions = {}) { - this.options = { - worldWidth: options.worldWidth ?? DEFAULT_OPTIONS.worldWidth, - worldHeight: options.worldHeight ?? DEFAULT_OPTIONS.worldHeight, - theme: options.theme ?? DEFAULT_OPTIONS.theme, - } - this.theme = themes[this.options.theme] || darkTheme - this.canvas = document.createElement('canvas') - this.canvas.style.display = 'block' - this.canvas.style.width = '100%' - this.canvas.style.height = '100%' - this.boundPointerDown = (event) => this.onPointerDown(event) - this.boundPointerMove = (event) => this.onPointerMove(event) - this.boundPointerUp = (event) => this.onPointerUp(event) - this.boundWheel = (event) => this.onWheel(event) - } - - get worldWidth(): number { - return this.options.worldWidth - } - - get worldHeight(): number { - return this.options.worldHeight - } - - get initialized(): boolean { - return this._initialized - } - - init(container: HTMLElement): void { - if (this._initialized) { - console.warn('ImagePreviewController already initialized') - return - } - - this.container = container - container.appendChild(this.canvas) - - const context = this.canvas.getContext('2d') - if (!context) { - throw new Error('Canvas 2D context is unavailable') - } - this.ctx = context - - this.canvas.addEventListener('pointerdown', this.boundPointerDown) - this.canvas.addEventListener('pointermove', this.boundPointerMove) - this.canvas.addEventListener('pointerup', this.boundPointerUp) - this.canvas.addEventListener('pointercancel', this.boundPointerUp) - this.canvas.addEventListener('pointerleave', this.boundPointerUp) - this.canvas.addEventListener('wheel', this.boundWheel, { passive: false }) - - this.resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0] - if (!entry) return - const { width, height } = entry.contentRect - if (this.resizeDebounceTimer) { - clearTimeout(this.resizeDebounceTimer) - } - this.resizeDebounceTimer = setTimeout(() => { - this.handleResize(width, height) - this.resizeDebounceTimer = null - }, RESIZE_DEBOUNCE_DELAY) - }) - this.resizeObserver.observe(container) - - const { width, height } = container.getBoundingClientRect() - this.applyCanvasSize(width, height) - this.alignViewportToRulerOrigin() - this._initialized = true - this.notifyTransformChange() - this.render() - } - - destroy(): void { - if (this.resizeDebounceTimer) { - clearTimeout(this.resizeDebounceTimer) - this.resizeDebounceTimer = null - } - this.resizeObserver?.disconnect() - this.resizeObserver = null - - this.canvas.removeEventListener('pointerdown', this.boundPointerDown) - this.canvas.removeEventListener('pointermove', this.boundPointerMove) - this.canvas.removeEventListener('pointerup', this.boundPointerUp) - this.canvas.removeEventListener('pointercancel', this.boundPointerUp) - this.canvas.removeEventListener('pointerleave', this.boundPointerUp) - this.canvas.removeEventListener('wheel', this.boundWheel) - - this.clearBackground() - this.canvas.remove() - this.container = null - this.ctx = null - this._initialized = false - this._screenSizeForResize = null - this.dragState = null - } - - setTheme(themeName: ThemeName): void { - const nextTheme = themes[themeName] - if (!nextTheme) { - console.warn(`Theme "${themeName}" not found`) - return - } - this.theme = nextTheme - this.options.theme = themeName - this.render() - } - - setRulerEnabled(enabled: boolean): void { - this._rulerEnabled = enabled - this.render() - } - - setPluginEnabled(name: string, enabled: boolean): void { - if (name === 'ruler') { - this.setRulerEnabled(enabled) - } - } - - getTransform(): ViewportTransform { - return { ...this.transform } - } - - getScale(): number { - return this.transform.scale - } - - onTransformChange(cb: (t: ViewportTransform) => void): () => void { - this.transformListeners.add(cb) - return () => this.transformListeners.delete(cb) - } - - screenToWorld(screenX: number, screenY: number): { x: number; y: number } { - const scale = this.transform.scale || 1 - return { - x: (screenX - this.transform.x) / scale, - y: (screenY - this.transform.y) / scale, - } - } - - worldToDisplay(worldX: number, worldY: number): { x: number; y: number } { - return displayPointFromWorld(worldX, worldY, this.options.worldHeight) - } - - setZoom(scale: number): this { - return this.setZoomAt(scale, this.screenWidth / 2, this.screenHeight / 2) - } - - setZoomAt(scale: number, screenX: number, screenY: number): this { - const nextScale = this.clampScale(scale) - const world = this.screenToWorld(screenX, screenY) - this.transform.scale = nextScale - this.transform.x = screenX - world.x * nextScale - this.transform.y = screenY - world.y * nextScale - this._resizeUsesCenteredUnderflowRule = false - this.notifyTransformChange() - this.render() - return this - } - - zoomIn(step = 0.1): this { - return this.setZoom(this.getScale() * (1 + step)) - } - - zoomOut(step = 0.1): this { - return this.setZoom(this.getScale() / (1 + step)) - } - - setWorldBounds(worldWidth: number, worldHeight: number): this { - this.options.worldWidth = worldWidth - this.options.worldHeight = worldHeight - return this - } - - fitToWorld(padding = 40, options?: FitToWorldOptions): this { - this.syncSizeFromDom() - const screenW = this.screenWidth - const screenH = this.screenHeight - const R = RULER_THICKNESS - const drawableW = Math.max(1, screenW - R) - const drawableH = Math.max(1, screenH - R) - const sw = Math.max(1, drawableW - padding * 2) - const sh = Math.max(1, drawableH - padding * 2) - const scale = Math.min(sw / this.options.worldWidth, sh / this.options.worldHeight) - this.transform.scale = scale - this.alignViewportFittedWorldCentered(options) - this.notifyTransformChange() - this.render() - return this - } - - async setBackgroundImage(url: string): Promise { - if (!this.ctx) return - - if (this.currentBlobUrl && this.currentBlobUrl.startsWith('blob:')) { - URL.revokeObjectURL(this.currentBlobUrl) - } - - const img = await this.loadImage(url) - this.backgroundImage = img - this.currentBlobUrl = url.startsWith('blob:') ? url : null - - if (img.width === 0 || img.height === 0) { - throw new Error('Background image has zero dimensions') - } - - this.setWorldBounds(img.width, img.height) - await new Promise((resolve) => requestAnimationFrame(resolve)) - this.fitToWorld(10) - } - - clearBackground(): void { - this.backgroundImage = null - if (this.currentBlobUrl && this.currentBlobUrl.startsWith('blob:')) { - URL.revokeObjectURL(this.currentBlobUrl) - this.currentBlobUrl = null - } - this.render() - } - - private loadImage(url: string): Promise { - return new Promise((resolve, reject) => { - const img = new Image() - img.onload = () => resolve(img) - img.onerror = () => reject(new Error(`Failed to load image: ${url}`)) - img.src = url - }) - } - - private alignViewportFittedWorldCentered(options?: FitToWorldOptions): void { - const ww = this.options.worldWidth - const wh = this.options.worldHeight - const cx = options?.worldCenter?.x ?? ww / 2 - const cy = options?.worldCenter?.y ?? wh / 2 - this.transform.x = this.screenWidth / 2 - cx * this.transform.scale - this.transform.y = this.screenHeight / 2 - cy * this.transform.scale - this.transform.x += RULER_THICKNESS / 2 - this.transform.y -= RULER_THICKNESS / 2 - this._resizeUsesCenteredUnderflowRule = true - this._screenSizeForResize = { w: this.screenWidth, h: this.screenHeight } - } - - alignViewportToRulerOrigin(): this { - const scale = this.transform.scale - const sh = this.screenHeight - const wh = this.options.worldHeight - this.transform.x = RULER_THICKNESS - this.transform.y = sh - RULER_THICKNESS - wh * scale - this._resizeUsesCenteredUnderflowRule = false - this._screenSizeForResize = { w: this.screenWidth, h: this.screenHeight } - return this - } - - private syncSizeFromDom(): void { - if (!this.container) return - const rect = this.container.getBoundingClientRect() - const w = Math.max(1, Math.round(rect.width)) - const h = Math.max(1, Math.round(rect.height)) - if (w !== this.screenWidth || h !== this.screenHeight) { - this.applyCanvasSize(w, h) - } - } - - private applyCanvasSize(width: number, height: number): void { - this.screenWidth = Math.max(1, Math.floor(width)) - this.screenHeight = Math.max(1, Math.floor(height)) - this.devicePixelRatio = window.devicePixelRatio || 1 - this.canvas.width = Math.max(1, Math.floor(this.screenWidth * this.devicePixelRatio)) - this.canvas.height = Math.max( - 1, - Math.floor(this.screenHeight * this.devicePixelRatio), - ) - this.render() - } - - private handleResize(width: number, height: number): void { - const prev = this._screenSizeForResize - this.applyCanvasSize(width, height) - - if (prev) { - const dw = this.screenWidth - prev.w - const dh = this.screenHeight - prev.h - if (this._resizeUsesCenteredUnderflowRule) { - const swPx = this.options.worldWidth * this.transform.scale - const shPx = this.options.worldHeight * this.transform.scale - if (swPx < prev.w) this.transform.x += dw / 2 - else this.transform.x += dw - if (shPx < prev.h) this.transform.y += dh / 2 - else this.transform.y += dh - } else { - this.transform.x += dw - this.transform.y += dh - } - } else { - this.alignViewportToRulerOrigin() - } - - this._screenSizeForResize = { w: this.screenWidth, h: this.screenHeight } - this.notifyTransformChange() - this.render() - } - - private onPointerDown(event: PointerEvent): void { - if (event.button !== 0) return - this.dragState = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: this.transform.x, - originY: this.transform.y, - } - this.canvas.setPointerCapture(event.pointerId) - } - - private onPointerMove(event: PointerEvent): void { - if (!this.dragState || event.pointerId !== this.dragState.pointerId) return - const dx = event.clientX - this.dragState.startX - const dy = event.clientY - this.dragState.startY - this.transform.x = this.dragState.originX + dx - this.transform.y = this.dragState.originY + dy - this._resizeUsesCenteredUnderflowRule = false - this.notifyTransformChange() - this.render() - } - - private onPointerUp(event: PointerEvent): void { - if (!this.dragState || event.pointerId !== this.dragState.pointerId) return - if (this.canvas.hasPointerCapture(event.pointerId)) { - this.canvas.releasePointerCapture(event.pointerId) - } - this.dragState = null - } - - private onWheel(event: WheelEvent): void { - event.preventDefault() - const notches = this.wheelNotches(event) - const factor = Math.pow(1 + WHEEL_ZOOM_STEP_PER_NOTCH, -notches) - this.setZoomAt(this.transform.scale * factor, event.offsetX, event.offsetY) - } - - /** - * Converts a wheel delta into signed notches (positive = scroll down = zoom out) - * so high-resolution wheels and trackpads zoom proportionally to the actual - * scroll distance instead of jumping a full step per event. - */ - private wheelNotches(event: WheelEvent): number { - let delta = event.deltaY - if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) { - delta *= WHEEL_LINES_TO_PIXELS - } else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) { - delta *= Math.max(this.screenHeight, 1) - } - const notches = delta / WHEEL_PIXELS_PER_NOTCH - return Math.max( - -WHEEL_MAX_NOTCHES_PER_EVENT, - Math.min(WHEEL_MAX_NOTCHES_PER_EVENT, notches), - ) - } - - private clampScale(scale: number): number { - const R = RULER_THICKNESS - const drawableW = Math.max(1, this.screenWidth - R) - const drawableH = Math.max(1, this.screenHeight - R) - const fitScale = Math.min( - drawableW / Math.max(this.options.worldWidth, 1), - drawableH / Math.max(this.options.worldHeight, 1), - ) - const min = Math.max(0.0001, fitScale * 0.5) - const max = 100 - return Math.max(min, Math.min(max, scale)) - } - - private notifyTransformChange(): void { - const transform = this.getTransform() - for (const cb of this.transformListeners) { - cb(transform) - } - } - - private render(): void { - const ctx = this.ctx - if (!ctx) return - - ctx.setTransform(this.devicePixelRatio, 0, 0, this.devicePixelRatio, 0, 0) - ctx.clearRect(0, 0, this.screenWidth, this.screenHeight) - ctx.fillStyle = colorNumberToCss(this.theme.backgroundColor) - ctx.fillRect(0, 0, this.screenWidth, this.screenHeight) - - if (this.backgroundImage) { - ctx.save() - ctx.translate(this.transform.x, this.transform.y) - ctx.scale(this.transform.scale, this.transform.scale) - ctx.imageSmoothingEnabled = this.transform.scale < 1 - ctx.drawImage(this.backgroundImage, 0, 0) - ctx.restore() - } - - if (this._rulerEnabled) { - drawRulers( - ctx, - this.screenWidth, - this.screenHeight, - this.options.worldHeight, - this.transform, - this.theme, - ) - } - } -} diff --git a/ecos/gui/apps/renderer/src/applications/image-preview/index.ts b/ecos/gui/apps/renderer/src/applications/image-preview/index.ts deleted file mode 100644 index 3c7d759b1..000000000 --- a/ecos/gui/apps/renderer/src/applications/image-preview/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { ImagePreviewController } from './ImagePreviewController' -export type { - ImagePreviewControllerOptions, - FitToWorldOptions, -} from './ImagePreviewController' -export type { ViewportTransform, ImagePreviewOptions } from './types' -export { default as ImagePreviewContainer } from './ImagePreviewContainer.vue' diff --git a/ecos/gui/apps/renderer/src/applications/image-preview/rulerDrawing.ts b/ecos/gui/apps/renderer/src/applications/image-preview/rulerDrawing.ts deleted file mode 100644 index 78dbed9c7..000000000 --- a/ecos/gui/apps/renderer/src/applications/image-preview/rulerDrawing.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { EditorTheme } from '@/applications/editor/core/Theme' -import { RULER_THICKNESS } from '@/applications/editor/core/rulerConfig' -import type { ViewportTransform } from './types' -import { colorNumberToCss } from './themeUtils' - -const NICE_MULTIPLIERS = [1, 2, 5] - -function calculateTickInterval(scale: number): number { - const targetScreenInterval = 80 - const worldInterval = targetScreenInterval / scale - const mag = 10 ** Math.floor(Math.log10(worldInterval)) - for (const m of NICE_MULTIPLIERS) { - if (m * mag >= worldInterval) return m * mag - } - return 10 * mag -} - -function formatNumber(value: number): string { - const abs = Math.abs(value) - if (abs < 0.01) return '0' - if (abs >= 1_000_000) { - const v = value / 1_000_000 - return (Number.isInteger(v) ? v.toFixed(0) : v.toFixed(1)) + 'M' - } - if (abs >= 10_000) { - const v = value / 1_000 - return (Number.isInteger(v) ? v.toFixed(0) : v.toFixed(1)) + 'K' - } - return value.toFixed(0) -} - -function drawHorizontalRuler( - ctx: CanvasRenderingContext2D, - screenWidth: number, - screenHeight: number, - transform: ViewportTransform, - tickInterval: number, - thickness: number, - theme: EditorTheme, - fontSize: number, -): void { - const backgroundColor = colorNumberToCss(theme.rulerBackground) - const tickColor = colorNumberToCss(theme.rulerTickColor) - const subTickCount = 10 - const subInterval = tickInterval / subTickCount - const rulerTop = screenHeight - thickness - - ctx.fillStyle = backgroundColor - ctx.fillRect(thickness, rulerTop, screenWidth - thickness, thickness) - - const worldStartX = -transform.x / transform.scale - const worldEndX = (screenWidth - transform.x) / transform.scale - const startTick = Math.floor(worldStartX / tickInterval) * tickInterval - - ctx.strokeStyle = tickColor - ctx.lineWidth = 1 - ctx.fillStyle = theme.rulerTextColor - ctx.font = `${fontSize}px JetBrains Mono, Monaco, Consolas, monospace` - ctx.textBaseline = 'top' - - const labelGap = 12 - const charWidth = fontSize * 0.65 - let lastLabelEndX = -Infinity - - for (let worldX = startTick; worldX <= worldEndX; worldX += subInterval) { - const screenX = worldX * transform.scale + transform.x - if (screenX < thickness) continue - - const isMajor = Math.abs(worldX % tickInterval) < 0.01 - const tickHeight = isMajor ? thickness * 0.6 : thickness * 0.3 - - ctx.beginPath() - ctx.moveTo(screenX, rulerTop + thickness - tickHeight) - ctx.lineTo(screenX, rulerTop + thickness) - ctx.stroke() - - if (isMajor && screenX >= lastLabelEndX + labelGap) { - const text = formatNumber(worldX) - ctx.fillText(text, screenX + 2, rulerTop + 2) - lastLabelEndX = screenX + 2 + text.length * charWidth - } - } -} - -function drawVerticalRuler( - ctx: CanvasRenderingContext2D, - screenHeight: number, - worldHeight: number, - transform: ViewportTransform, - tickInterval: number, - thickness: number, - theme: EditorTheme, - fontSize: number, -): void { - const backgroundColor = colorNumberToCss(theme.rulerBackground) - const tickColor = colorNumberToCss(theme.rulerTickColor) - const subTickCount = 10 - const subInterval = tickInterval / subTickCount - - ctx.fillStyle = backgroundColor - ctx.fillRect(0, 0, thickness, screenHeight - thickness) - - ctx.fillStyle = backgroundColor - ctx.fillRect(0, screenHeight - thickness, thickness, thickness) - - const worldStartY = -transform.y / transform.scale - const worldEndY = (screenHeight - transform.y) / transform.scale - const displayMin = worldHeight - worldEndY - const displayMax = worldHeight - worldStartY - const startTick = Math.floor(displayMin / subInterval) * subInterval - - ctx.strokeStyle = tickColor - ctx.lineWidth = 1 - ctx.fillStyle = theme.rulerTextColor - ctx.font = `${fontSize}px JetBrains Mono, Monaco, Consolas, monospace` - - const minLabelScreenInterval = 40 - let lastLabelScreenY = -Infinity - - for (let displayY = startTick; displayY <= displayMax + 1e-6; displayY += subInterval) { - const worldY = worldHeight - displayY - const screenY = worldY * transform.scale + transform.y - if (screenY >= screenHeight - thickness) continue - - const isMajor = - Math.abs(displayY / tickInterval - Math.round(displayY / tickInterval)) < 1e-5 - const tickWidth = isMajor ? thickness * 0.6 : thickness * 0.3 - - ctx.beginPath() - ctx.moveTo(thickness - tickWidth, screenY) - ctx.lineTo(thickness, screenY) - ctx.stroke() - - if (isMajor && Math.abs(screenY - lastLabelScreenY) >= minLabelScreenInterval) { - const text = formatNumber(displayY) - ctx.save() - ctx.translate(thickness - 4, screenY - 2) - ctx.rotate(-Math.PI / 2) - ctx.textAlign = 'left' - ctx.textBaseline = 'bottom' - ctx.fillText(text, 0, 0) - ctx.restore() - lastLabelScreenY = screenY - } - } -} - -export function drawRulers( - ctx: CanvasRenderingContext2D, - screenWidth: number, - screenHeight: number, - worldHeight: number, - transform: ViewportTransform, - theme: EditorTheme, - fontSize = 9, -): void { - const thickness = RULER_THICKNESS - const tickInterval = calculateTickInterval(transform.scale) - - drawHorizontalRuler( - ctx, - screenWidth, - screenHeight, - transform, - tickInterval, - thickness, - theme, - fontSize, - ) - drawVerticalRuler( - ctx, - screenHeight, - worldHeight, - transform, - tickInterval, - thickness, - theme, - fontSize, - ) -} diff --git a/ecos/gui/apps/renderer/src/applications/image-preview/types.ts b/ecos/gui/apps/renderer/src/applications/image-preview/types.ts deleted file mode 100644 index 919d3cc59..000000000 --- a/ecos/gui/apps/renderer/src/applications/image-preview/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface ViewportTransform { - x: number - y: number - scale: number -} - -import type { ThemeName } from '@/applications/editor/core/Theme' - -export interface ImagePreviewOptions { - theme?: ThemeName -} diff --git a/ecos/gui/apps/renderer/src/components/AIChatPanel.contract.test.ts b/ecos/gui/apps/renderer/src/components/AIChatPanel.contract.test.ts deleted file mode 100644 index d2ef27fae..000000000 --- a/ecos/gui/apps/renderer/src/components/AIChatPanel.contract.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, it } from 'vitest' -import source from './AIChatPanel.vue?raw' - -describe('AIChatPanel flow contracts', () => { - it('routes structured choices through their prompt id and compatible option value', () => { - expect(source).toContain("event.type === 'choice'") - expect(source).toContain( - 'messageStore.addChoice(event.choice, event.messageId, event.sessionId)', - ) - expect(source).toContain('candidate.choice?.promptId === promptId') - expect(source).toContain('await sendAgentMessage(option.value, false)') - expect(source).toContain('messageStore.addMessage(choiceSelectionText(option))') - }) - - it('renders Cursor-style centered turns with sticky user cards', () => { - expect(source).toContain('groupMessagesIntoTurns') - expect(source).toContain('conversationTurns') - expect(source).toContain('chat-turn__user') - expect(source).toContain('position: sticky') - expect(source).toContain('v-for="msg in turn.responses"') - expect(source).toContain('turnIndex === conversationTurns.length - 1') - expect(source).toContain('.chat-turn__body') - expect(source).toContain('background: transparent') - expect(source).toContain('margin-inline: auto') - expect(source).toContain('text-align: left') - expect(source).toContain('.chat-turn__user {\n position: sticky') - expect(source).toContain('display: block') - expect(source).not.toContain('border-left: 2px solid') - expect(source).not.toContain('var(--bg-sidebar) 82%') - }) - - it('keeps confirmed run plans above progress and awaiting plans after Q&A', () => { - expect(source).toContain('AgentSessionContractPanels') - expect(source).toContain('mode="committed"') - expect(source).toContain('mode="awaiting"') - expect(source.indexOf('mode="committed"')).toBeLessThan( - source.indexOf('v-for="msg in turn.responses"'), - ) - expect(source.indexOf('v-for="msg in turn.responses"')).toBeLessThan( - source.indexOf('mode="awaiting"'), - ) - expect(source).toContain('activeUi.value.workspaceSetupAnchorTurnId = turnId') - }) - - it('keeps tool activity and streaming updates on the structured message path', () => { - expect(source).toContain("event.type === 'message' || event.type === 'tool'") - expect(source).toContain('messageStore.upsertAgentEvent(event)') - expect(source).toContain('messageStore.finishStreamingMessages(event.sessionId)') - }) - - it('keeps the transcript pinned while tool progress content grows in place', () => { - expect(source).toContain('const stickToBottom = ref(true)') - expect(source).toContain('@scroll.passive="onScrollContainerScroll"') - expect(source).toContain('last?.content.length ?? 0') - expect(source).toContain('bindScrollContentObserver()') - expect(source).toContain('scrollToBottomIfNeeded(force, false)') - }) - - it('keeps the composer open during choices while retaining stop and one-message queue controls', () => { - expect(source).toContain( - 'const composerLocked = computed(() => isInterruptPending.value || !agentSessionId.value)', - ) - expect(source).not.toContain( - 'activeChoice.value && !activeChoice.value.allowFreeText', - ) - expect(source).toContain('if (isRunning.value) {') - expect(source).toContain('queuedMessage.value = message') - expect(source).toContain('watch(isRunning') - expect(source).toContain('void flushQueuedMessage()') - expect(source).toContain('const canSubmit = computed(') - expect(source).toContain(':disabled="!canSubmit"') - expect(source).toContain('isAgentConnecting.value') - expect(source).toContain('!agentSessionId.value') - expect(source).toContain( - 'await agent.interrupt({ providerId: AGENT_PROVIDER_ID, sessionId })', - ) - expect(source).toContain('v-if="isRunning"') - expect(source).toContain('class="stop-btn"') - expect(source).toContain('@click="interruptAgent"') - expect(source).toContain('composer-sr-status') - expect(source).not.toContain('run-status-dot') - expect(source).toContain('@click="cancelQueuedMessage"') - expect(source).toContain("return 'Add a follow-up…'") - expect(source).toContain('Enter a value, or choose above') - expect(source).toContain("activeChoice.value.variant === 'buttons'") - expect(source).toContain( - "if (activeChoice.value) return 'Ask anything, or choose above'", - ) - expect(source).toContain("return 'Ask anything…'") - expect(source).toContain("return 'Connecting…'") - expect(source).toContain("return 'Unavailable'") - expect(source).not.toContain('Connecting to ECOS Agent') - expect(source).not.toContain('ECOS Agent unavailable') - expect(source).not.toContain('Message ECOS Agent') - }) - - it('does not submit while an IME composition is active', () => { - expect(source).toContain('if (e.isComposing) return') - }) - - it('maps validated provider contracts to structured messages', () => { - expect(source).toContain("event.type === 'contract'") - expect(source).toContain('addExecutionContract(event.contract, event.sessionId)') - }) - - it('renders frozen rerun specifications in the same key-value table as workspace setup', () => { - expect(source).toContain("event.contract.presentation === 'workspace_rerun'") - expect(source).toContain('ui.workspaceRerunContract = event.contract') - expect(source).toMatch( - /if \(event\.contract\.presentation === 'workspace_rerun'\) \{[\s\S]*ui\.workspaceRerunContract = event\.contract[\s\S]*ui\.workspaceRerunMessage = event\.text \?\? ''[\s\S]*ui\.lastContractSurface = 'rerun'/, - ) - expect(source).toContain('AgentSessionContractPanels') - expect(source).toContain('workspaceRerunRows:') - expect(source).toContain('workspaceRerunExecutionState') - }) - - it('keeps workspace setup inside chat instead of reopening the native wizard', () => { - expect(source).toContain("event.type === 'workspace_setup'") - expect(source).toContain('AgentSessionContractPanels') - expect(source).toContain('ui.workspaceSetupContract = event.workspaceSetup') - expect(source).not.toContain('openWorkspaceSetup?.(event.workspaceSetup)') - }) - - it('allows empty optional-path answers and executes only after confirmation', () => { - expect(source).not.toContain('if (!message || !agent') - expect(source).toContain("event.type === 'workspace_create'") - expect(source).toContain('isWorkspaceCreationPending') - expect(source).toContain('workspace_create_result:') - expect(source).toContain('createAgentWorkspace(config, contract, ownerSessionId)') - expect(source).toContain('ui.workspaceSetupStartedId === contract.setup_id') - expect(source).toContain('ui.workspaceSetupStartedId = contract.setup_id') - expect(source).toContain('handoff.ownerSessionId') - expect(source).not.toContain('Workspace creation was not completed.') - expect(source).toContain('ui.workspaceSetupMessage = event.text') - expect(source).toContain('scrollWorkspaceSetupIntoView()') - expect(source).not.toContain( - "if (event.text) messageStore.addAssistantMessage(event.text, 'done')", - ) - expect(source).toContain('maybeRunPostCreateFlow') - expect(source).toContain('takePendingPostCreateFlow') - expect(source).toContain('const flowResult = await runAllFlow({ rerun: false })') - expect(source).toContain( - "throw new Error('Flow execution did not complete successfully.')", - ) - expect(source).toMatch( - /const flowResult = await runAllFlow\(\{ rerun: false \}\)[\s\S]*await reportWorkspaceCreationResult\([\s\S]*handoff\.setupId,[\s\S]*'succeeded',[\s\S]*handoff\.ownerSessionId/, - ) - }) - - it('applies parameter updates from the contract instead of a local knob table', () => { - // A second mapping here silently dropped every knob it did not know about. - expect(source).not.toContain('applyParameterPatchToParametersJson') - expect(source).not.toContain("'place.target_density': 'Target density'") - expect(source).toContain( - "invalidateWorkspaceResources(['parameters', 'home', 'step-config', 'flow'])", - ) - expect(source).toContain( - 'applyWorkspaceParameterWrites(workspaceRoot, contract.writes)', - ) - expect(source).toContain('for (const write of fileWrites)') - expect(source).toContain('setJsonPathValue(document, write)') - expect(source).toContain( - 'throw new Error(`Parameter ${write.knob_id} does not exist in ${write.file}.`)', - ) - }) - - it('pushes parameter writes back through ECC so the next run sees them', () => { - expect(source).toContain( - 'syncWorkspaceParameterWrites(workspaceRoot, contract.writes)', - ) - expect(source).toContain('cmd: CMDEnum.sync_config') - expect(source).toContain('cmd: CMDEnum.refresh_config') - // sync_config must run first: refresh_config re-expands parameters.json over - // the step configs and would discard an unsynced step-config edit. - expect(source.indexOf('cmd: CMDEnum.sync_config')).toBeLessThan( - source.indexOf('cmd: CMDEnum.refresh_config'), - ) - expect(source).toContain('assertEccSuccess(') - expect(source).toContain( - "throw new Error('The parameter update targets a workspace that is not open.')", - ) - }) - - it('writes parameter files with the indentation they already use', () => { - expect(source).not.toContain('JSON.stringify(parameters, null, 2)') - expect(source).toContain('JSON.stringify(document, null, detectJsonIndent(raw))') - expect(source).toContain("raw.endsWith('\\n') ? `${serialized}\\n` : serialized") - }) - - it('opens the rerun workspace and restores the source workspace on failure', () => { - expect(source).toContain("event.type === 'workspace_rerun'") - expect(source).toContain('void executeWorkspaceRerun(') - expect(source).toContain( - 'event.text ?? `Rerun ${event.workspaceRerun.rerun_id} accepted.`', - ) - expect(source).toContain('prepareFlowAgentRerun') - expect(source).toContain('event.workspaceRerunToken') - expect(source).toContain( - 'await desktopApi.workspace.bindWindow(contract.source_workspace)', - ) - expect(source).toContain('Restored the source workspace after the rerun failed.') - expect(source).toContain('path: contract.source_workspace') - expect(source).toContain('const restored = await openProject({') - expect(source).toContain( - "throw new Error('The source workspace could not be reopened.')", - ) - expect(source).not.toContain('const sourceOpened =') - expect(source).toContain('prepareRerun({ token })') - expect(source).toContain('workspace_rerun_result:') - expect(source).toContain('await desktopApi.workspace.bindWindow(prepared.directory)') - expect(source).toMatch( - /const opened = await openProject\([\s\S]*const projectContext = await registerAgentRerunWorkspaceInProject\([\s\S]*await router\.push\(\{[\s\S]*projectRoot: projectContext\?\.projectRoot[\s\S]*await nextTick\(\)[\s\S]*invalidateWorkspaceResources\(\['home', 'flow', 'step', 'maps', 'logs', 'parameters'\]\)[\s\S]*await agentFlowProgress\.start\(prepared\.directory\)[\s\S]*await executeRerun/, - ) - expect(source).toContain('executeRerun({ token: prepared.executionToken })') - expect(source).toContain('markAgentWorkspaceRerunHomePrepared(prepared.directory)') - expect(source).not.toContain('requestHomeRunArtifactReset(prepared.directory)') - expect(source).toContain('registerAgentRerunWorkspaceInProject(') - expect(source).toContain('registerProjectManagedWorkspace({') - expect(source).toContain('resolveManagedProjectContext({') - expect(source).toContain( - 'const projectContext = await registerAgentRerunWorkspaceInProject(', - ) - expect(source).toContain('projectRoot: projectContext?.projectRoot') - expect(source).toContain('projectName: projectContext?.projectName') - expect(source).toContain( - "invalidateWorkspaceResources(['flow', 'step', 'maps', 'logs'])", - ) - expect(source).toContain( - "invalidateWorkspaceResources(['home', 'flow', 'step', 'maps', 'logs', 'parameters'])", - ) - expect(source).toContain( - "appendToolProgress('Preparing isolated rerun workspace.', ownerSessionId)", - ) - expect(source).toContain( - "appendToolProgress('Opening isolated rerun workspace.', ownerSessionId)", - ) - expect(source).toContain( - "appendToolProgress('Starting rerun execution.', ownerSessionId)", - ) - expect(source).toContain('finishToolProgress(ownerSessionId)') - expect(source).toContain('`Rerun failed: ${reason}`') - }) - - it('routes live flow progress into the tool timeline instead of plain assistant text', () => { - expect(source).toContain('messageStore.appendToolProgress(message') - expect(source).toContain('messageStore.finishToolProgress()') - expect(source).toContain("message.startsWith('Live flow progress is unavailable')") - }) - - it('starts sessions with projectRoot and known project history', () => { - expect(source).toContain('loadProjectHistory()') - expect(source).toContain('knownProjects') - expect(source).toContain('projectRoot') - expect(source).toContain('route.query.projectRoot') - expect(source).toContain('Create another workspace in this project') - expect(source).toContain('AgentChatTabStrip') - expect(source).toContain('createChatTab') - expect(source).toContain('directory: tab.workspacePath') - }) - - it('prevents replaying stale choice cards after the conversation moves on', () => { - expect(source).toContain('messageStore.dismissOpenChoices()') - expect(source).toContain('activeChoicePromptId') - expect(source).toContain( - ':choice-interactive="msg.choice?.promptId === activeChoicePromptId"', - ) - expect(source).toContain( - 'if (activeChoicePromptId.value && activeChoicePromptId.value !== promptId) return', - ) - }) - - it('shows a quiet pending cue while waiting for the next reply', () => { - expect(source).toContain('showPendingPlaceholder') - expect(source).toContain('class="agent-pending"') - expect(source).toContain('agent-pending__dot') - expect(source).not.toContain('Agent is working…') - expect(source).not.toContain("'Thinking'") - }) - - it('defers GUI-driving actions when the owning tab is not active', () => { - expect(source).toContain('isActiveGuiOwner') - expect(source).toContain('deferGuiAction') - expect(source).toContain('GUI_SWITCH_PROMPT') - expect(source).toContain('flushPendingGuiActionForActiveTab') - }) -}) diff --git a/ecos/gui/apps/renderer/src/components/AgentExecutionContractPanel.test.ts b/ecos/gui/apps/renderer/src/components/AgentExecutionContractPanel.test.ts deleted file mode 100644 index ddbbb4b3e..000000000 --- a/ecos/gui/apps/renderer/src/components/AgentExecutionContractPanel.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import source from './AgentExecutionContractPanel.vue?raw' - -describe('AgentExecutionContractPanel', () => { - it('renders all frozen specification rows in a two-column key-value table', () => { - expect(source).toContain('Key<') - expect(source).toContain('>Value<') - expect(source).toContain('v-for="[key, value] in rows"') - expect(source).toContain('{{ key }}') - expect(source).toContain('{{ value }}') - expect(source).toMatch(/class="[^"]*\bw-full\b[^"]*\btable-fixed\b/) - expect(source).toContain('class="contract-table-shell selectable"') - expect(source).toContain('max-height: min(18rem, 42vh)') - expect(source.indexOf('')).toBeLessThan( - source.indexOf('{{ confirmationText }}'), - ) - }) - - it('embeds the shared confirm and cancel choice and locks it after submission', () => { - expect(source).toContain('AgentChoiceCard') - expect(source).toContain(':answered-option-id="answeredOptionId"') - expect(source).toContain(':disabled="choiceDisabled"') - expect(source).toContain('@select="emit(\'select\', $event)"') - }) - - it('keeps compact confirm cards without a key-value table when fields are empty', () => { - expect(source).toContain('v-if="title"') - expect(source).toContain('v-if="rows.length"') - }) - - it('collapses to a compact receipt after confirmation without a wide status chip', () => { - expect(source).toContain('Boolean(props.answeredOptionId)') - expect(source).toContain('contract-panel--committed') - expect(source).toContain('contract-panel__summary') - expect(source).toContain('contract-panel__title-row') - expect(source).toContain("detailsOpen ? 'Hide details' : 'Details'") - expect(source).not.toContain('contract-state') - expect(source).toContain('v-if="confirmationText"') - expect(source.indexOf('isCommitted')).toBeLessThan( - source.indexOf('v-if="confirmationText"'), - ) - }) -}) diff --git a/ecos/gui/apps/renderer/src/components/AgentSessionContractPanels.test.ts b/ecos/gui/apps/renderer/src/components/AgentSessionContractPanels.test.ts deleted file mode 100644 index e3434f4a0..000000000 --- a/ecos/gui/apps/renderer/src/components/AgentSessionContractPanels.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest' -import source from './AgentSessionContractPanels.vue?raw' - -describe('AgentSessionContractPanels', () => { - it('places setup and execution contracts behind awaiting vs committed visibility', () => { - expect(source).toContain("mode: 'awaiting' | 'committed'") - expect(source).toContain('AgentWorkspaceSetupPanel') - expect(source).toContain('AgentExecutionContractPanel') - expect(source).toContain("props.mode === 'awaiting'") - expect(source).toContain('anchorTurnId === props.turnId') - }) -}) diff --git a/ecos/gui/apps/renderer/src/components/AgentWorkspaceSetupPanel.test.ts b/ecos/gui/apps/renderer/src/components/AgentWorkspaceSetupPanel.test.ts deleted file mode 100644 index f8c3f56bc..000000000 --- a/ecos/gui/apps/renderer/src/components/AgentWorkspaceSetupPanel.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest' -import source from './AgentWorkspaceSetupPanel.vue?raw' - -describe('AgentWorkspaceSetupPanel', () => { - it('creates from the frozen contract without reopening NewProjectWizard', () => { - expect(source).toContain("emit('createWorkspace'") - expect(source).toContain('() => props.createSetupId') - expect(source).toContain('workspaceConfig(contract)') - expect(source).not.toContain('NewProjectWizard') - }) - - it('renders the complete resolved specification in a two-column table', () => { - expect(source).toContain('AgentExecutionContractPanel') - expect(source).toContain(':rows="specRows"') - for (const field of [ - 'Workspace', - 'Flow', - 'RTL', - 'Filelist', - 'SDC', - 'PDK Root', - 'Top Module', - ]) - expect(source).toContain(`['${field}'`) - expect(source).not.toContain(' { - expect(source).toContain("['Workspace Name', workspaceName]") - expect(source).toContain("['Design Name', parameters.design]") - expect(source).toContain("['Project Root', contract.project_context.project_root]") - }) - - it('keeps the specification selectable and permits retrying a failed setup id', () => { - expect(source).toContain('AgentExecutionContractPanel') - expect(source).toContain('if (!setupId) {') - expect(source).toContain("submittedSetupId.value = ''") - }) - - it('renders the confirmation after the resolved specification', () => { - expect(source).toContain('confirmationText?: string') - expect(source).toContain(':confirmation-text="confirmationText"') - expect(source).toContain(':choice="choice"') - expect(source).toContain(':choice-disabled="choiceDisabled"') - }) - - it('collapses committed setups into a short summary with progressive status', () => { - expect(source).toContain(':summary="committedSummary"') - expect(source).toContain( - "return [workspaceName, design, flow].filter(Boolean).join(' · ')", - ) - expect(source).toContain("return 'Running'") - expect(source).toContain("return 'Review'") - expect(source).toContain("return 'Cancelled'") - expect(source).toContain("return 'Confirmed'") - }) - - it('shows a user-facing run-plan title instead of frozen-contract jargon', () => { - expect(source).toContain('displayAgentContractTitle') - expect(source).toContain(':title="displayTitle"') - }) -}) diff --git a/ecos/gui/apps/renderer/src/components/ChatInspectorPanel.test.ts b/ecos/gui/apps/renderer/src/components/ChatInspectorPanel.test.ts deleted file mode 100644 index 34d99135c..000000000 --- a/ecos/gui/apps/renderer/src/components/ChatInspectorPanel.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest' -import source from './ChatInspectorPanel.vue?raw' - -describe('ChatInspectorPanel', () => { - it('renders the workspace Agent chat without configuration or QoR panels', () => { - expect(source).toContain(' { - const tabActionsStart = source.indexOf(' - - diff --git a/ecos/gui/apps/renderer/src/components/TopBar.test.ts b/ecos/gui/apps/renderer/src/components/TopBar.test.ts deleted file mode 100644 index c05eaa003..000000000 --- a/ecos/gui/apps/renderer/src/components/TopBar.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it } from 'vitest' -import topBarSource from './TopBar.vue?raw' - -function getCssDeclaration(selector: string, property: string): string | null { - const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const blockMatch = topBarSource.match( - new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\}`), - ) - if (!blockMatch) return null - - const declarationMatch = blockMatch[1].match(new RegExp(`${property}\\s*:\\s*([^;]+);`)) - return declarationMatch?.[1].trim() ?? null -} - -describe('TopBar drag region layout', () => { - it('moves workspace home navigation into the quick menu above Project Management', () => { - const quickMenuIndex = topBarSource.indexOf('class="quick-dropdown-menu"') - const homeMenuIndex = topBarSource.indexOf('title="Back to Home"', quickMenuIndex) - const projectManagementIndex = topBarSource.indexOf( - 'Back to Project Management', - quickMenuIndex, - ) - - expect(topBarSource).not.toContain('class="home-btn"') - expect(topBarSource).not.toContain('.home-btn') - expect(topBarSource).toContain("router.push({ name: 'ECOS' })") - expect(quickMenuIndex).toBeGreaterThan(-1) - expect(homeMenuIndex).toBeGreaterThan(quickMenuIndex) - expect(homeMenuIndex).toBeLessThan(projectManagementIndex) - }) - - it('uses a dedicated drag spacer instead of making the centered overlay draggable', () => { - expect(topBarSource).toContain('class="topbar-drag-spacer" data-window-drag-region') - expect(topBarSource).not.toContain( - '
', - ) - }) - - it('keeps the centered title overlay pointer-transparent', () => { - expect(topBarSource).toMatch(/\.topbar-center\s*\{[\s\S]*pointer-events:\s*none;/) - }) - - it('keeps dropdown menus above workspace content controls', () => { - const topbarLeftZIndex = Number(getCssDeclaration('.topbar-left', 'z-index')) - - expect(topbarLeftZIndex).toBeGreaterThan(20) - }) - - it('places a workspace quick menu before the theme toggle with a divider', () => { - const menuIndex = topBarSource.indexOf('class="workspace-quick-menu"') - const dividerIndex = topBarSource.indexOf('class="topbar-right-separator"') - const themeIndex = topBarSource.indexOf('class="window-btn theme-btn"') - - expect(menuIndex).toBeGreaterThan(-1) - expect(dividerIndex).toBeGreaterThan(menuIndex) - expect(themeIndex).toBeGreaterThan(dividerIndex) - expect(topBarSource).toContain('ri-more-2-line') - }) - - it('always enables Project Management return from a workspace route', () => { - expect(topBarSource).toContain('isWorkspaceRoute') - expect(topBarSource).toContain('route.query.projectRoot') - expect(topBarSource).not.toContain('hasWorkspaceProjectContext') - expect(topBarSource).not.toContain(':disabled="!hasWorkspaceProjectContext"') - expect(topBarSource).not.toContain('if (!hasWorkspaceProjectContext.value) return') - expect(topBarSource).toContain('goToProjectManagement') - expect(topBarSource).toContain("path: '/projects'") - }) - - it('returns to Project Management with the current workspace focus query', () => { - const goStart = topBarSource.indexOf('const goToProjectManagement') - const goEnd = topBarSource.indexOf('const handleClickOutside', goStart) - const goSource = topBarSource.slice(goStart, goEnd) - - expect(goSource).toContain('workspaceId') - expect(goSource).toContain('projectRoot') - expect(goSource).toContain("path: '/projects'") - expect(goSource).toContain('query') - expect(topBarSource).toContain('route.query.workspaceId') - }) - - it('teleports the workspace quick menu outside the app container clipping area', () => { - expect(topBarSource).toContain('') - expect(topBarSource).toContain(':style="quickMenuStyle"') - expect(topBarSource).toContain('@click.stop') - expect(topBarSource).toContain('updateQuickMenuPosition') - expect(topBarSource).toContain('Back to Project Management') - expect(topBarSource).toMatch(/\.quick-dropdown-menu\s*\{[\s\S]*position:\s*fixed;/) - }) - - it('adds a File menu action for opening a new window above workspace actions', () => { - const newWindowIndex = topBarSource.indexOf("label: 'New Window'") - const newWorkspaceIndex = topBarSource.indexOf("label: 'New Workspace'") - - expect(newWindowIndex).toBeGreaterThan(-1) - expect(newWorkspaceIndex).toBeGreaterThan(newWindowIndex) - expect(topBarSource).toContain('appMenuActionIds.newWindow') - expect(topBarSource).toContain('ri-window-line') - }) - - it('adds Edit > Config for the active workspace flow', () => { - const menuStart = topBarSource.indexOf('const menus = computed') - const menuEnd = topBarSource.indexOf('// ---- 下拉菜单状态 ----', menuStart) - const menuSource = topBarSource.slice(menuStart, menuEnd) - const fileIndex = menuSource.indexOf("label: 'File'") - const editIndex = menuSource.indexOf('...(isWorkspaceRoute.value') - const helpIndex = menuSource.indexOf("label: 'Help'") - - expect(editIndex).toBeGreaterThan(fileIndex) - expect(helpIndex).toBeGreaterThan(editIndex) - expect(topBarSource).toContain("label: 'Config'") - expect(topBarSource).toContain("event: 'step-config'") - expect(topBarSource).toContain('canOpenStepConfig') - expect(topBarSource).toContain("emit('step-config')") - expect(topBarSource).toContain('...(isWorkspaceRoute.value ? [editMenu.value] : [])') - }) - - it('binds File shortcuts for new window, new workspace, and open workspace', () => { - expect(topBarSource).toContain("key === 'n'") - expect(topBarSource).toContain("key === 'o'") - expect(topBarSource).toContain('appMenuActionIds.newWindow') - expect(topBarSource).toContain('appMenuActionIds.newProject') - expect(topBarSource).toContain('appMenuActionIds.openProject') - expect(topBarSource).toContain('isEditableKeyboardTarget') - }) - - it('adds View zoom actions to the topbar menu', () => { - expect(topBarSource).toContain("label: 'View'") - expect(topBarSource).toContain("label: 'Zoom In'") - expect(topBarSource).toContain("label: 'Zoom Out'") - expect(topBarSource).toContain("label: 'Reset Zoom'") - expect(topBarSource).toContain('appMenuActionIds.zoomIn') - expect(topBarSource).toContain('appMenuActionIds.zoomOut') - expect(topBarSource).toContain('appMenuActionIds.zoomReset') - }) - - it('adds a File menu action for reconfiguring the active workspace', () => { - expect(topBarSource).toContain('Update Workspace') - expect(topBarSource).not.toContain('Reconfigure Workspace...') - expect(topBarSource).toContain('ri-settings-3-line') - expect(topBarSource).toContain('appMenuActionIds.reconfigureWorkspace') - expect(topBarSource).toContain('disabled: !props.hasWorkspace') - }) - - it('shows workspace exports only on the workspace route', () => { - const updateIndex = topBarSource.indexOf("label: 'Update Workspace'") - const exportIndex = topBarSource.indexOf("label: 'Export Signoff Package'") - const metricsIndex = topBarSource.indexOf("label: 'Export Design Summary'") - - expect(exportIndex).toBeGreaterThan(updateIndex) - expect(metricsIndex).toBeGreaterThan(exportIndex) - expect(topBarSource).toContain('appMenuActionIds.exportSignoffPackage') - expect(topBarSource).toMatch( - /\.\.\.\(isWorkspaceRoute\.value[\s\S]*label: 'Export Signoff Package'[\s\S]*label: 'Export Design Summary'/, - ) - expect(topBarSource).not.toContain('signoffPackageExportEnabled') - expect(topBarSource).not.toContain('designReportExportEnabled') - expect(topBarSource).toContain('appMenuActionIds.exportDesignSummary') - }) - - it('does not render the workspace Design menu', () => { - expect(topBarSource).not.toContain("label: 'Design'") - expect(topBarSource).not.toContain("action: 'design'") - expect(topBarSource).not.toContain('Manage RTL Files...') - expect(topBarSource).not.toContain('appMenuActionIds.manageDesignFiles') - }) -}) diff --git a/ecos/gui/apps/renderer/src/components/WelcomePage.vue b/ecos/gui/apps/renderer/src/components/WelcomePage.vue deleted file mode 100644 index 7f31a424e..000000000 --- a/ecos/gui/apps/renderer/src/components/WelcomePage.vue +++ /dev/null @@ -1,313 +0,0 @@ - - - - - diff --git a/ecos/gui/apps/renderer/src/components/WorkspaceStepConfigDialog.test.ts b/ecos/gui/apps/renderer/src/components/WorkspaceStepConfigDialog.test.ts deleted file mode 100644 index 9403eb28e..000000000 --- a/ecos/gui/apps/renderer/src/components/WorkspaceStepConfigDialog.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import source from './WorkspaceStepConfigDialog.vue?raw' - -describe('WorkspaceStepConfigDialog', () => { - it('lists only steps from the active workspace flow and edits the selected one', () => { - expect(source).toContain('useBackendFlowStages()') - expect(source).toContain('dynamicFlowStages.value.flatMap') - expect(source).toContain(' { - it('shows a five-tile Data Snapshot grid and opens modules in a dialog', () => { - expect(panelSource).toContain('

Data Snapshot

') - expect(panelSource).toContain('class="data-snapshot-grid"') - expect(panelSource).toContain('class="data-snapshot-tile"') - expect(panelSource).toContain('openModule(cell.id)') - expect(panelSource).toContain(' { - expect(panelSource).toContain('const DATA_SNAPSHOT_ROWS = 4') - expect(panelSource).toContain('const DATA_SNAPSHOT_COLUMNS = 5') - expect(panelSource).toContain('class="data-snapshot-cell"') - expect(panelSource).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))') - expect(panelSource).toContain('grid-template-rows: repeat(4, minmax(0, 1fr))') - expect(panelSource).toContain('snapshot-empty-') - }) -}) - -describe('StepResourcesPanel', () => { - it('does not list individual steps or jump to a step page', () => { - expect(stepResourcesSource).not.toContain('resource-steps') - expect(stepResourcesSource).not.toContain('select-step') - expect(stepResourcesSource).not.toContain('selectStepByKey') - }) -}) diff --git a/ecos/gui/apps/renderer/src/components/frontend/FrontendDisassemblyViewer.test.ts b/ecos/gui/apps/renderer/src/components/frontend/FrontendDisassemblyViewer.test.ts deleted file mode 100644 index 65e23ab68..000000000 --- a/ecos/gui/apps/renderer/src/components/frontend/FrontendDisassemblyViewer.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import viewerSource from './FrontendDisassemblyViewer.vue?raw' - -describe('FrontendDisassemblyViewer Monaco contract', () => { - it('uses the shared read-only disassembly runtime without CodeMirror', () => { - expect(viewerSource).toContain("import('../monacoRuntime')") - expect(viewerSource).toContain('runtime.getMonacoRuntime(editorTheme.value)') - expect(viewerSource).toContain('runtime.MONACO_DISASSEMBLY_LANGUAGE_ID') - expect(viewerSource).toContain('runtime.nextMonacoEditorId()') - expect(viewerSource).toContain('readOnly: true') - expect(viewerSource).toContain('domReadOnly: true') - expect(viewerSource).toContain('automaticLayout: true') - expect(viewerSource).toContain("wordWrap: 'off'") - expect(viewerSource).not.toContain('@codemirror') - }) - - it('owns and disposes a unique disassembly model and PC decorations', () => { - expect(viewerSource).toContain("scheme: 'disassembly'") - expect(viewerSource).toContain('editor.createDecorationsCollection()') - expect(viewerSource).toContain("className: 'monaco-pc-line'") - expect(viewerSource).toContain('pcDecorations?.clear()') - expect(viewerSource).toContain('editor?.dispose()') - expect(viewerSource).toContain('model?.dispose()') - }) - - it('preserves filtered loading, address navigation, and stale-load protection', () => { - expect(viewerSource).toContain('const token = ++loadToken') - expect(viewerSource).toContain('if (token !== loadToken) return') - expect(viewerSource).toContain('stripSourceFromDisassembly(result)') - expect(viewerSource).toContain( - 'findDisassemblyAddressLine(content.value, normalized)', - ) - expect(viewerSource).toContain('editor.setPosition({ lineNumber, column: 1 })') - expect(viewerSource).toContain('editor.revealLineInCenter(lineNumber)') - expect(viewerSource).toContain('setHighlightedLine(null)') - }) - - it('keeps the existing invalid-address and missing-address highlight behavior distinct', () => { - const invalidBranch = viewerSource.slice( - viewerSource.indexOf('if (!normalized) {'), - viewerSource.indexOf('addressInput.value = `0x${normalized}`'), - ) - const missingBranch = viewerSource.slice( - viewerSource.indexOf('if (lineNumber === null) {'), - viewerSource.indexOf('editor.setPosition({ lineNumber, column: 1 })'), - ) - - expect(invalidBranch).toContain('Enter a hexadecimal instruction address.') - expect(invalidBranch).not.toContain('setHighlightedLine(null)') - expect(missingBranch).toContain('setHighlightedLine(null)') - expect(missingBranch).toContain('is not present in this file.') - }) - - it('tracks the shared theme and preserves the close and empty states', () => { - expect(viewerSource).toContain('watch(editorTheme') - expect(viewerSource).toContain('applyTheme?.(theme)') - expect(viewerSource).toContain("emit('close')") - expect(viewerSource).toContain('This case has no disassembly artifact.') - expect(viewerSource).toContain('Loading disassembly') - }) -}) diff --git a/ecos/gui/apps/renderer/src/components/home/StatusPieChart.test.ts b/ecos/gui/apps/renderer/src/components/home/StatusPieChart.test.ts deleted file mode 100644 index 15c21187c..000000000 --- a/ecos/gui/apps/renderer/src/components/home/StatusPieChart.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest' -import statusPieChartSource from './StatusPieChart.vue?raw' - -describe('StatusPieChart', () => { - it('uses an HTML legend instead of clipped ECharts callout labels', () => { - expect(statusPieChartSource).toContain('showLabels?: boolean') - expect(statusPieChartSource).toContain('class="status-pie-legend"') - expect(statusPieChartSource).toContain('v-if="showLabels && slices.length"') - expect(statusPieChartSource).toContain("radius: props.showLabels ? ['48%', '70%']") - expect(statusPieChartSource).toContain('label: { show: false }') - expect(statusPieChartSource).toContain('labelLine: { show: false }') - expect(statusPieChartSource).toContain('slice.color ?? colorForTone(slice.tone)') - }) - - it('keeps unlabeled chart wrappers filling their parent height for snapshot tiles', () => { - expect(statusPieChartSource).toContain( - '.status-pie,\n.status-pie-chart-wrap,\n.status-pie-empty {\n height: 100%;', - ) - expect(statusPieChartSource).toContain( - '.status-pie.has-legend .status-pie-chart-wrap {\n flex: 1 1 auto;\n height: auto;', - ) - }) - - it('renders tooltips outside overflow-clipped chart containers', () => { - expect(statusPieChartSource).toContain("appendTo: 'body'") - expect(statusPieChartSource).toContain('confine: false') - }) - - it('shows a supplied total when a valid empty distribution has no slices', () => { - expect(statusPieChartSource).toContain('v-else-if="centerPrimary"') - expect(statusPieChartSource).toContain('{{ centerPrimary }}') - expect(statusPieChartSource).toContain( - '
No data
', - ) - }) -}) diff --git a/ecos/gui/apps/renderer/src/components/step-insights/timingInsights.test.ts b/ecos/gui/apps/renderer/src/components/step-insights/timingInsights.test.ts index 05ab21e9f..ee40fe508 100644 --- a/ecos/gui/apps/renderer/src/components/step-insights/timingInsights.test.ts +++ b/ecos/gui/apps/renderer/src/components/step-insights/timingInsights.test.ts @@ -1,9 +1,4 @@ import { describe, expect, it } from 'vitest' -import panelSource from './TimingAnalysisPanel.vue?raw' -import dialogSource from './TimingAnalysisDialog.vue?raw' -import tableSource from './TimingCornerTable.vue?raw' -import kpisSource from './TimingKpis.vue?raw' -import staPanelSource from '../flow-insights/StaPanel.vue?raw' import { countClass, formatFrequency, @@ -65,41 +60,3 @@ describe('timing format helpers', () => { ]) }) }) - -describe('TimingAnalysisPanel composition', () => { - it('renders the unified KPI, corner table, chart, critical paths, and run info', () => { - expect(panelSource).toContain(' { - expect(panelSource).toContain('pathsByCorner') - expect(panelSource).toContain('All corners') - expect(panelSource).toContain('initialCorner') - }) - - it('collapses single-corner summaries onto the Worst row and hides attribution', () => { - expect(tableSource).toContain('visibleRows') - expect(tableSource).toContain('props.rows.length === 1') - expect(kpisSource).toContain('attributesCorner') - expect(panelSource).toContain('attributesCorner') - }) - - it('reuses the Data Snapshot dialog chrome and sizing', () => { - expect(dialogSource).toContain('maximizable') - expect(dialogSource).toContain("width: 'min(1080px, calc(100vw - 40px))'") - expect(dialogSource).toContain("height: 'min(72vh, 680px)'") - }) - - it('keeps the home STA panel on the shared composition plus convergence', () => { - expect(staPanelSource).toContain(' { - it('removes only tab info and errors for the selected step', () => { - const tabInfoCache = { - Floorplan_maps: { density: 'old-map' }, - Floorplan_analysis: { timing: 'old-report' }, - place_maps: { density: 'other-step' }, - } - const tabErrorCache = { - Floorplan_maps: 'old maps error', - Floorplan_sta: 'old sta error', - place_maps: 'keep me', - } - const checklistItemsCache = { - Floorplan_checklist_items: [ - { step: 'route', type: 'Route', item: 'x', state: 'Passed' }, - ], - place_checklist_items: [ - { step: 'place', type: 'Density', item: 'y', state: 'Passed' }, - ], - } - - clearStepTabCache(tabInfoCache, tabErrorCache, 'Floorplan', checklistItemsCache) - - expect(tabInfoCache).toEqual({ - place_maps: { density: 'other-step' }, - }) - expect(tabErrorCache).toEqual({ - place_maps: 'keep me', - }) - expect(checklistItemsCache).toEqual({ - place_checklist_items: [ - { step: 'place', type: 'Density', item: 'y', state: 'Passed' }, - ], - }) - }) - - it('does nothing when there is no current step', () => { - const tabInfoCache = { - Floorplan_maps: { density: 'old-map' }, - } - const tabErrorCache = { - Floorplan_maps: 'old maps error', - } - - clearStepTabCache(tabInfoCache, tabErrorCache, undefined) - - expect(tabInfoCache).toEqual({ - Floorplan_maps: { density: 'old-map' }, - }) - expect(tabErrorCache).toEqual({ - Floorplan_maps: 'old maps error', - }) - }) -}) diff --git a/ecos/gui/apps/renderer/src/components/thumbnailGalleryCache.ts b/ecos/gui/apps/renderer/src/components/thumbnailGalleryCache.ts deleted file mode 100644 index 0707c26e1..000000000 --- a/ecos/gui/apps/renderer/src/components/thumbnailGalleryCache.ts +++ /dev/null @@ -1,24 +0,0 @@ -export function clearStepTabCache( - tabInfoCache: Record>, - tabErrorCache: Record, - step: string | undefined, - checklistItemsCache?: Record, -): void { - if (!step) return - - for (const key of Object.keys(tabInfoCache).filter((k) => k.startsWith(`${step}_`))) { - delete tabInfoCache[key] - } - - for (const key of Object.keys(tabErrorCache).filter((k) => k.startsWith(`${step}_`))) { - delete tabErrorCache[key] - } - - if (checklistItemsCache) { - for (const key of Object.keys(checklistItemsCache).filter((k) => - k.startsWith(`${step}_`), - )) { - delete checklistItemsCache[key] - } - } -} diff --git a/ecos/gui/apps/renderer/src/components/workbench/FlowLogPanel.test.ts b/ecos/gui/apps/renderer/src/components/workbench/FlowLogPanel.test.ts deleted file mode 100644 index 6622d4fd6..000000000 --- a/ecos/gui/apps/renderer/src/components/workbench/FlowLogPanel.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import source from './FlowLogPanel.vue?raw' -import titleSource from './flowLogTitle.ts?raw' - -describe('FlowLogPanel embedded controls', () => { - it('keeps the flow log embedded with collapse, copy, and open actions', () => { - expect(source).toContain("{ 'is-collapsed': !expanded }") - expect(source).toContain('copyLog') - expect(source).toContain('ri-fullscreen-line') - expect(source).toContain('ri-arrow-up-s-line') - }) - - it('keeps copy available in the maximizable log dialog', () => { - expect(source).toContain('