From 8688286501fe2554b0e30aaa5e8bf17821d7757c Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Tue, 3 Nov 2026 02:52:58 +0800 Subject: [PATCH 1/4] ci: run affected checks and reduce daemon test overhead Select checks from the complete PR dependency graph and fail closed on missing selected jobs. Preserve full and native coverage while sharing isolated SQLite setup and balancing file workers. Document the selection rules and protect the runner contracts. --- .github/workflows/ci.yml | 39 +- .github/workflows/installer-contracts.yml | 6 + .github/workflows/model-cache-contracts.yml | 2 + .../workflows/sync-provider-model-catalog.yml | 2 +- .../workflows/sync-translation-catalogs.yml | 2 +- .github/workflows/translate-pr-catalogs.yml | 2 +- CONTRIBUTING.md | 4 +- docs/maintenance/ci.md | 42 +- package.json | 3 +- packages/daemon/package.json | 8 +- .../daemon/test/approval-policy-run.test.mjs | 203 ++++---- .../daemon/test/channel-workspace.test.mjs | 433 +++++++++--------- .../test/recovery-discovery-prefix.test.ts | 65 +++ .../test/sqlite-workspace-fixture.test.ts | 111 +++++ .../recovery-discovery-fixture.mjs | 15 +- .../test-support/recovery-discovery-prefix.ts | 36 ++ .../test-support/sqlite-workspace-fixture.ts | 50 ++ packages/daemon/tsconfig.test.json | 15 + scripts/ci/README.md | 145 +++++- scripts/ci/auxiliary-workflows.test.mjs | 86 ++++ scripts/ci/classify-scope.mjs | 99 ++-- scripts/ci/classify-scope.test.mjs | 180 +++++++- scripts/ci/daemon-test-shard.mjs | 2 +- scripts/ci/execution-policy.mjs | 36 +- scripts/ci/execution-policy.test.mjs | 84 +++- scripts/ci/execution-workflow.test.mjs | 160 ++++++- scripts/ci/node-test-shard.mjs | 24 +- ...shard.test.mjs => node-test-shard.test.ts} | 78 +++- scripts/ci/package-script-coverage.test.mjs | 3 +- scripts/ci/platform-lanes.test.mjs | 35 +- scripts/ci/run-packages.mjs | 32 +- scripts/ci/run-packages.test.mjs | 58 +++ scripts/ci/runtime-baseline.test.mjs | 57 ++- ...flow.test.mjs => runtime-workflow.test.ts} | 42 +- scripts/ci/test-shard-placement.test.ts | 58 +++ scripts/ci/test-shard-plan.mjs | 22 +- scripts/ci/test-shard-plan.test.mjs | 15 +- scripts/ci/tsconfig.test.json | 14 + 38 files changed, 1795 insertions(+), 473 deletions(-) create mode 100644 packages/daemon/test/recovery-discovery-prefix.test.ts create mode 100644 packages/daemon/test/sqlite-workspace-fixture.test.ts create mode 100644 packages/daemon/test/test-support/recovery-discovery-prefix.ts create mode 100644 packages/daemon/test/test-support/sqlite-workspace-fixture.ts create mode 100644 packages/daemon/tsconfig.test.json create mode 100644 scripts/ci/auxiliary-workflows.test.mjs rename scripts/ci/{node-test-shard.test.mjs => node-test-shard.test.ts} (68%) create mode 100644 scripts/ci/run-packages.test.mjs rename scripts/ci/{runtime-workflow.test.mjs => runtime-workflow.test.ts} (60%) create mode 100644 scripts/ci/test-shard-placement.test.ts create mode 100644 scripts/ci/tsconfig.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2960d1d8c..24701e838 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,10 @@ jobs: packages: ${{ steps.classify.outputs.packages }} test-packages: ${{ steps.classify.outputs.test-packages }} typecheck-packages: ${{ steps.classify.outputs.typecheck-packages }} + dependency-policy: ${{ steps.classify.outputs.dependency-policy }} + typecheck-selected: ${{ steps.classify.outputs.typecheck-selected }} unit-matrix: ${{ steps.test-plan.outputs.unit-matrix }} + unit-selected: ${{ steps.test-plan.outputs.unit-selected }} daemon: ${{ steps.classify.outputs.daemon }} web: ${{ steps.classify.outputs.web }} steps: @@ -81,6 +84,8 @@ jobs: packages: JSON.stringify(scope.packages), "test-packages": JSON.stringify(scope.testPackages), "typecheck-packages": JSON.stringify(scope.typecheckPackages), + "dependency-policy": String(scope.dependencyPolicy), + "typecheck-selected": String(scope.typecheckPackages.length > 0), daemon: String(scope.daemon), web: String(scope.web), }; @@ -107,6 +112,10 @@ jobs: --timings=.codex-artifacts/ci/timings.json \ --output=.codex-artifacts/ci/test-plan.json)" printf 'unit-matrix=%s\n' "$unit_matrix" >> "$GITHUB_OUTPUT" + UNIT_MATRIX="$unit_matrix" node --input-type=module <<'NODE' >> "$GITHUB_OUTPUT" + const matrix = JSON.parse(process.env.UNIT_MATRIX); + console.log(`unit-selected=${matrix.include.length > 0}`); + NODE - name: Publish the immutable test plan for every shard if: steps.policy.outputs.execution-tier == 'complete' && steps.classify.outputs.mode != 'docs' @@ -145,7 +154,7 @@ jobs: run: node scripts/check-public-repo-safety.mjs - name: Check scanner boundaries, execution policy, classification, and platform CI gates - run: node --test scripts/check-public-repo-safety.test.mjs scripts/ci/*.test.mjs scripts/native-execution-engines/verify-codex.test.mjs + run: node --test scripts/check-public-repo-safety.test.mjs scripts/ci/*.test.mjs scripts/ci/*.test.ts scripts/native-execution-engines/verify-codex.test.mjs # Final file scope cannot exclude secrets introduced and later deleted. - name: Scan Git history for secrets @@ -184,7 +193,7 @@ jobs: dependency-policy: name: Dependency policy and security checks needs: scope - if: needs.scope.outputs.execution-tier == 'complete' && needs.scope.outputs.mode != 'docs' + if: needs.scope.outputs.execution-tier == 'complete' && needs.scope.outputs.dependency-policy == 'true' runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -317,7 +326,7 @@ jobs: typecheck: name: Typecheck selected packages needs: [scope, build] - if: ${{ !cancelled() && needs.scope.outputs.execution-tier == 'complete' && needs.scope.outputs.mode != 'docs' && needs.build.result == 'success' }} + if: ${{ !cancelled() && needs.scope.outputs.execution-tier == 'complete' && needs.scope.outputs.typecheck-selected == 'true' && needs.build.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 20 env: @@ -357,10 +366,13 @@ jobs: - name: Typecheck selected packages without rebuilding dependencies run: node scripts/ci/run-packages.mjs --task=typecheck:ci --packages="$SELECTED_PACKAGES" + - name: Typecheck CI tool tests + run: corepack pnpm typecheck:ci-tests + unit-tests: name: Unit tests and Web checks (${{ matrix.suite }} ${{ matrix.shard }}) needs: [scope, build] - if: ${{ !cancelled() && needs.scope.outputs.execution-tier == 'complete' && needs.scope.outputs.mode != 'docs' && needs.build.result == 'success' }} + if: ${{ !cancelled() && needs.scope.outputs.execution-tier == 'complete' && needs.scope.outputs.unit-selected == 'true' && needs.build.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 25 strategy: @@ -705,20 +717,23 @@ jobs: LINUX_CONTRACT_RESULT: ${{ needs.daemon-linux-contracts.result }} MACOS_NATIVE_RESULT: ${{ needs.restricted-command-macos.result }} DAEMON_SELECTED: ${{ needs.scope.outputs.daemon }} + DEPENDENCY_SELECTED: ${{ needs.scope.outputs.dependency-policy }} + TYPECHECK_SELECTED: ${{ needs.scope.outputs.typecheck-selected }} + UNIT_SELECTED: ${{ needs.scope.outputs.unit-selected }} shell: bash run: | set -euo pipefail node scripts/ci/execution-policy.mjs --check-quality - name: Download the shared test plan for coverage verification - if: needs.scope.outputs.execution-tier == 'complete' && (needs.scope.outputs.daemon == 'true' || contains(needs.scope.outputs.test-packages, '"@ling-stack/core"')) + if: ${{ !cancelled() && needs.scope.result == 'success' && needs.scope.outputs.execution-tier == 'complete' && (needs.scope.outputs.daemon == 'true' || contains(needs.scope.outputs.test-packages, '"@ling-stack/core"')) }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 with: name: ci-test-plan path: .codex-artifacts/ci - - name: Download all successful shard reports - if: needs.scope.outputs.execution-tier == 'complete' && (needs.scope.outputs.daemon == 'true' || contains(needs.scope.outputs.test-packages, '"@ling-stack/core"')) + - name: Download all shard reports + if: ${{ !cancelled() && needs.scope.result == 'success' && needs.scope.outputs.execution-tier == 'complete' && (needs.scope.outputs.daemon == 'true' || contains(needs.scope.outputs.test-packages, '"@ling-stack/core"')) }} uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 with: pattern: ci-test-report-* @@ -726,16 +741,18 @@ jobs: path: .codex-artifacts/ci/reports - name: Verify every planned file passed exactly once and collect timings - if: needs.scope.outputs.execution-tier == 'complete' && (needs.scope.outputs.daemon == 'true' || contains(needs.scope.outputs.test-packages, '"@ling-stack/core"')) + id: test-timings + if: ${{ !cancelled() && needs.scope.result == 'success' && needs.scope.outputs.execution-tier == 'complete' && (needs.scope.outputs.daemon == 'true' || contains(needs.scope.outputs.test-packages, '"@ling-stack/core"')) }} run: >- node scripts/ci/test-shard-plan.mjs --plan=.codex-artifacts/ci/test-plan.json --reports=.codex-artifacts/ci/reports --output=.codex-artifacts/ci/timings.json - # Only validated default-branch runs publish hints. PRs consume them but - # cannot replace the baseline; all shards use the same uploaded plan. + # Timing evidence can survive an unrelated policy failure; the required + # gate above still fails the job. Only test-complete default-branch runs + # publish hints. PRs consume but cannot replace the shared baseline. - name: Save trusted default-branch test timings - if: needs.scope.outputs.execution-tier == 'complete' && needs.scope.outputs.mode != 'docs' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + if: ${{ !cancelled() && steps.test-timings.outcome == 'success' && needs.scope.outputs.execution-tier == 'complete' && needs.scope.outputs.mode != 'docs' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: .codex-artifacts/ci/timings.json diff --git a/.github/workflows/installer-contracts.yml b/.github/workflows/installer-contracts.yml index df09395ab..63faa9c57 100644 --- a/.github/workflows/installer-contracts.yml +++ b/.github/workflows/installer-contracts.yml @@ -3,6 +3,12 @@ name: Native installer contracts on: pull_request: paths: + - package.json + - pnpm-lock.yaml + - pnpm-workspace.yaml + - apps/desktop/package.json + - apps/desktop/build/installer.nsh + - apps/desktop/build/icon.ico - scripts/release/verify-nsis-plugins.mjs - scripts/release/nsis-extractor.mjs - scripts/ci/desktop-bun-entry.test.mjs diff --git a/.github/workflows/model-cache-contracts.yml b/.github/workflows/model-cache-contracts.yml index d65dd33e5..72a91e61a 100644 --- a/.github/workflows/model-cache-contracts.yml +++ b/.github/workflows/model-cache-contracts.yml @@ -5,6 +5,8 @@ on: paths: - scripts/gstack/testsavant-model-*.mjs - scripts/gstack/testsavant-model-pin.json + - scripts/gstack/testsavant-gs-client.mjs + - scripts/gstack/testsavant-client.mjs - .github/workflows/model-cache-contracts.yml workflow_dispatch: diff --git a/.github/workflows/sync-provider-model-catalog.yml b/.github/workflows/sync-provider-model-catalog.yml index 41650a2a9..ad383e365 100644 --- a/.github/workflows/sync-provider-model-catalog.yml +++ b/.github/workflows/sync-provider-model-catalog.yml @@ -28,7 +28,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 with: - node-version: 22.15.0 + node-version: 22.22.1 - name: Check catalog PR synchronization safety run: node --test scripts/ci/provider-model-catalog-pr.test.mjs diff --git a/.github/workflows/sync-translation-catalogs.yml b/.github/workflows/sync-translation-catalogs.yml index 4fb887fe8..c349aca79 100644 --- a/.github/workflows/sync-translation-catalogs.yml +++ b/.github/workflows/sync-translation-catalogs.yml @@ -34,7 +34,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 with: - node-version: 22.15.0 + node-version: 22.22.1 cache: pnpm - name: Check automation PR selection safety diff --git a/.github/workflows/translate-pr-catalogs.yml b/.github/workflows/translate-pr-catalogs.yml index dbecb74b5..b4d44eeba 100644 --- a/.github/workflows/translate-pr-catalogs.yml +++ b/.github/workflows/translate-pr-catalogs.yml @@ -41,7 +41,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 with: - node-version: 22.15.0 + node-version: 22.22.1 cache: pnpm - name: Install locked dependencies diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f4e76f63..aaa6e6900 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,7 +132,9 @@ These repository-wide commands include Desktop checks, and `test` also includes Mobile checks. They require the relevant platform toolchain and test fixtures; use package filters for a scoped change and state which checks ran. The default Web setup above does not establish that all native or browser test fixtures are -available. CI selects and builds the affected package graph separately. +available. CI selects and builds the affected package graph separately; see the +[CI maintenance guide](docs/maintenance/ci.md) and +[CI test execution reference](scripts/ci/README.md). Web changes should also run: diff --git a/docs/maintenance/ci.md b/docs/maintenance/ci.md index 7a87decb8..068380b47 100644 --- a/docs/maintenance/ci.md +++ b/docs/maintenance/ci.md @@ -1,16 +1,16 @@ # CI 运行方式 -本文先说明当前行为,再记录平台分工和耗时优化的后续方案。分类收窄、Draft 分档和严格汇总门已经接入;2026-10-19 将默认分支完整定时回归恢复为每日运行,并补齐本地化包的 CI 入口。 +本文按当前工作区源码说明 CI 行为,再记录尚未实施的平台分工和定时优化方案。分类收窄、Draft 分档、严格汇总门、Core 分片和文件级计时已实现;这不表示当前修改已合并或通过 hosted CI 验收。 ## 当前行为 -本次修改基于 `main@912e3e8e`。PR 工作流在推送修改后应用;每日定时配置只在合入默认分支后生效。该仓库当前公开,标准 GitHub-hosted Linux/macOS runner 的运行分钟免费;并发、单次时长、缓存与制品存储仍有独立限制,不能据此承诺立即开跑或无限存储。 +以下描述以工作区中的分类器、执行器和工作流为准,不以历史提交作为当前基线。PR 工作流在推送修改后应用;定时任务使用默认分支上的工作流。本次文档同步未核验 GitHub 仓库可见性、账户额度或账单,不能据此承诺排队时间或费用。 CI 会先根据整个 Pull Request 的变更路径选择最小安全范围: -- 只有 `README.md`、`docs/`、Issue 模板或已登记维护脚本变更时,运行文档链接、公开仓库安全、CI 边界契约和空白检查;历史密钥扫描在该轻量范围下跳过。维护脚本白名单仅包含 `check-markdown-links.mjs`、`check-public-repo-safety.mjs` 和后者的测试,新增脚本仍保守处理。 -- 工作区源码变更时,[分类器](../../scripts/ci/classify-scope.mjs)会沿 workspace 依赖图扩大到所有直接和间接消费者;仅改测试时选择所属包,并补充已识别的跨包测试辅助文件使用方。 -- 任意 `package.json`、锁文件、非白名单 `.github/`/`scripts/`、补丁、TypeScript/构建配置等变更会进入全量 CI;未知路径也回退全量。维护脚本与源码混合时继续按受影响包及使用方选择。 +- 只有文档、各级 `README.md`、已登记的 PR/Issue 模板、`CODEOWNERS`、`FUNDING.yml` 或维护脚本变更时,不选择产品任务,但仍运行公共检查和汇总门。完整执行档位即使是纯文档范围也扫描 Git 历史;只有 Draft preview 延后历史扫描。维护脚本白名单仅包含 `check-markdown-links.mjs`、`check-public-repo-safety.mjs` 和后者的测试,新增脚本仍保守处理。 +- 工作区源码或包内 TypeScript/构建配置变更时,[分类器](../../scripts/ci/classify-scope.mjs)选择所属包及直接、间接消费者;仅改测试时选择所属包,并补充已识别的跨包测试辅助文件使用方。Web 的目录导入器额外选择 localization 验证,实际依赖图也包含 CLI 对 Web 的依赖;为构建补齐依赖不等于选择这些依赖的测试分片。 +- 包级 `package.json` 只有在 base 与当前包目录、名称、测试/类型检查入口存在性及全部 scripts 内容可核验且不变时才允许收窄;使用新旧依赖边的并集扩大消费者,并选择依赖策略任务。新增、删除、重命名、无法核验或任意 script 增删改均回退全量。根 `package.json`、锁文件、workspace 配置、非白名单 `.github/`/`scripts/`、补丁、根 TypeScript/构建配置及未知路径仍全量;维护脚本与源码混合不豁免源码检查。 - [工作流](../../.github/workflows/ci.yml)由 Pull Request、手动运行和每日定时触发,没有 `main` push 触发。定时为每天 UTC 03:17,即北京时间 11:17;手动和定时运行始终走现有完整 Linux/macOS 矩阵,即使当天没有提交也执行。GitHub 定时任务可能排队或延迟,不保证准点启动。没有启用成功证据去重或每日 Linux 单独分档。 - 新增 `full-ci` 标签立即触发全量;已有标签让后续推送继续全量。无关标签事件不替换必需检查结果,也不取消当前验证;移除标签只影响下一次正常 PR 运行,不立即重跑。 - [执行策略](../../scripts/ci/execution-policy.mjs)独立选择 `preview`/`complete`:无 `full-ci` 的 Draft 推送只跑公共检查,延后依赖安装、构建、类型检查、产品测试、历史密钥扫描及所有 macOS 任务;转正式评审、正式 PR 更新、手动或定时运行使用完整执行档位。 @@ -24,9 +24,13 @@ CI 会先根据整个 Pull Request 的变更路径选择最小安全范围: | 手动运行 | 目标分支上的完整验证 | | 定时运行 | 默认分支每天 UTC 03:17(北京时间 11:17)的完整验证 | -该分支的 Daemon 测试拆为四个 `macos-26` shard,使用稳定的文件分配和独立的 `LING_STACK_HOME`。每个 shard 以 `--test-concurrency=1` 运行,避免测试间共享状态;新增或删除测试文件会自动重新平衡。完整执行档位下选择 Daemon 时,还会运行 Linux restricted-command 拒绝契约和一个真实 macOS helper/Seatbelt smoke 任务。 +Daemon 测试拆为四个 `macos-26` shard,每个 shard 使用两个文件 worker;每个文件单独以 `--test-concurrency=1` 启动,拥有独立的 runtime home、workspace 和 POSIX 进程组。Kimi Host read/terminal、delegation production tool surface 及 channel-workspace 文件在所属 shard 的独占阶段先串行完成,其余文件再进入按估计耗时降序的共享队列。完整执行档位下选择 Daemon 时,仍运行独立的 Linux restricted-command 拒绝契约和真实 macOS helper/Seatbelt smoke。 -Daemon 分片的任务上限为 40 分钟,包含依赖安装及原生准备。运行 `39274142315` 的分片 1 在 25 分钟上限前已连续通过 1,584 个顶层测试,尚有文件未执行;该调整只补足运行预算,未减少测试或修改并发,也不代表性能优化。 +Daemon 分片任务上限为 40 分钟,执行队列上限为 30 分钟;默认文件 watchdog 为五分钟,channel-workspace 和 recovery-discovery-fair-scan 两个文件显式放宽到十分钟。超时或取消会终止文件所属进程并使任务失败,不重试或强制通过。任务预算和本地计时不能证明 hosted CI 性能改善。 + +Core 已接入四个 Linux shard,每个使用两个文件 worker、20 分钟队列上限,属于既有 `unit-tests` 必需矩阵。普通 packages 入口排除 Core 与 Daemon,避免重复执行;只有存在其他被选测试包时才生成该入口。仅改 Core 测试时生成四个 Core 入口,且不生成空 packages 入口;仅改 Daemon 测试时没有 Linux unit 入口。 + +[分片计划](../../scripts/ci/test-shard-plan.mjs)在 scope 阶段统一生成并上传 `ci-test-plan`,各 shard 与新发现的 `test/*.test.mjs`、`test/*.test.ts` 核对完整且恰好一次的分配。计时提示仅影响外层分片和内部队列顺序;缺失或不可用时回退源码大小/类别估算,不减少覆盖。每文件日志及 JSON 计时报告上传为 `ci-test-report-core-*`/`ci-test-report-daemon-*`;汇总验证全部计划文件恰好通过一次后,仅默认分支运行可发布计时缓存。无关策略失败不阻止完整测试报告的计时保存,但仍使质量门失败;PR 不能发布计时基线。这些计时不是跳过测试的成功证据。 完整 Daemon 分片使用 macOS,是为了让 Darwin 专属正向用例实际执行。[平台契约测试](../../scripts/ci/platform-lanes.test.mjs)当前明确保护这一要求,不能只改 `runs-on`,让 Linux 上的跳过被当成原生验证成功。 @@ -36,6 +40,8 @@ Daemon 专用的依赖安全测试由 `dependency-policy` 任务单独运行, 所有任务固定 Node `22.22.1`。构建只执行一次 `build:ci` 拓扑构建,短期 `dist` artifact 供类型检查、单元测试和 Daemon 分片复用;`test:ci` 不隐式重建依赖。本地通过不替代 GitHub 对当前待检提交的干净 runner 验证,完整 CI 成功也不替代 packaged 产品验收。 +公共检查同时发现 `scripts/ci/*.test.mjs` 和 `scripts/ci/*.test.ts`,由固定 Node 直接运行。根 `typecheck` 和被选中的 CI typecheck 任务执行 `typecheck:ci-tests`,使用 `scripts/ci/tsconfig.test.json` 严格检查 TS 测试;运行时类型擦除不能代替类型检查。Daemon 的包级测试命令及两处分片发现也包含 `.test.ts`,其 `typecheck:ci` 同时检查生产代码与 `test/**/*.ts`,包括测试辅助文件。 + Linux 构建任务在选中 CLI 时运行真实 daemon 生命周期和新工作区 doctor 检查;未配置 provider 的新工作区预期返回诊断退出码 4。在选中 Web 时,使用生产构建的 Vite preview 运行六项 Playwright 回归,覆盖权限、内置技能元数据和中文角色。浏览器测试使用模拟 daemon 数据,不包含真实模型调用。当前运行的截图与测试结果上传为 `ci-linux-source-web-smoke` artifact,保留七天。 本地化包的 `typecheck:ci` 和 `test:ci` 已补齐;前者在干净 runner 重新生成未跟踪的 TypeScript 目录文件后检查类型,后者执行翻译目录完整性、占位符检查及本地化单元测试,复用构建阶段的 `dist`。新增[入口覆盖契约](../../scripts/ci/package-script-coverage.test.mjs),要求分类器选中的每个类型检查/测试包都有对应 CI 脚本,防止 `--if-present` 静默跳过漏配包。 @@ -45,12 +51,12 @@ Linux 构建任务在选中 CLI 时运行真实 daemon 生命周期和新工作 | 项目 | 当前覆盖与建议 | | --- | --- | | 本地化类型/测试入口 | 本次修复此前被选中却未执行的漏检,并增加入口覆盖契约 | -| CodeQL / Copilot Autofix | 尚未启用;建议先为 JavaScript/TypeScript 配置 CodeQL 默认扫描,观察首次结果。Autofix 提供修复建议,需要审查与测试,不自动合并 | -| 仓库级密钥扫描/推送保护 | GitHub 开关目前关闭;建议开启。现有完整非文档 CI 会运行 Gitleaks,但它在上传后执行,且 Draft/文档范围跳过,不能代替推送时拦截 | -| 新披露的依赖漏洞 | Dependabot 告警、安全更新及每周版本更新已配置;CI 另测已有安全补丁与许可策略。若增加漏洞严重性阻断,应先核对现存告警、补丁和无上游修复的基线,不能将旧告警直接当作本次改动引入 | +| CodeQL / Copilot Autofix | 2026-10-19 的历史评估记为尚未启用;本次未核验 GitHub 当前设置,不据此判断现在仍关闭。建议核验启用状态及扫描结果;修复建议仍须审查与测试 | +| 仓库级密钥扫描/推送保护 | 2026-10-19 的历史评估记为开关关闭;本次未核验当前设置。现有完整执行档位包括纯文档 PR 都运行 Gitleaks 历史扫描,Draft preview 延后;上传后的扫描不能代替推送时拦截 | +| 新披露的依赖漏洞 | 历史评估记录 Dependabot 告警及安全更新已配置,本次未核验服务端状态;仓库 `.github/dependabot.yml` 仍声明每周版本更新。CI 另测安全补丁与许可策略;新增严重性阻断前须核对现存告警和补丁基线 | | 浏览器与桌面交互 | 选中 Web 时执行六项生产预览 Playwright smoke,使用模拟 daemon 数据并保留截图;Desktop 主要只有类型检查。后续可逐步扩大交互覆盖;不把每日现有矩阵成功视为桌面打包或设备验收 | -本次未变更 GitHub 安全设置、分支保护、Dependabot 更新频率或发布流程。回退方式为撤销本次 CI 修改提交;调度恢复每周,既有 PR 与手动完整验证仍在。 +本次仅同步文档,未变更或核验 GitHub 安全设置、分支保护及服务端状态,也未修改 Dependabot 配置或发布流程。2026-10-19 的每日调度调整及其回退说明属于历史记录,不能据此认为当前应恢复每周运行。 ## 分层方案与范围 @@ -77,7 +83,7 @@ Draft 减少执行不改变改动范围。不能把大 PR 的范围伪装成 `do | main 定时回归(当前) | 每日强制 Linux 与 macOS 完整回归 | 每日完整执行,发布前另行验证 | 不复用历史成功来跳过当天检查 | | `full-ci` 或手动完整运行 | 完整受支持测试矩阵 | 完整平台验证 | 不受 Draft 延后和定时去重影响 | -每周 macOS 回归用于发现平台环境漂移,不替代涉及原生能力的 PR 检查或发布前验证。macOS 专属测试组可以分片;任务数量在清单和耗时测量后决定,不预设必须压成一个任务。 +候选的每周 macOS 回归尚未接入;若未来实施,用于发现平台环境漂移,不替代涉及原生能力的 PR 检查或发布前验证。当前 Daemon 已分为四个 macOS shard,平台拆分后的任务数量仍须依据清单和测量决定。 ### 路径分类收窄 @@ -90,7 +96,8 @@ Draft 减少执行不改变改动范围。不能把大 PR 的范围伪装成 `do | 普通包源码 | 所属包、直接和间接使用方 | 构建继续补齐依赖闭包 | | 测试与共享夹具 | 所属包和夹具使用方 | 无法识别共享关系时扩大范围 | | `native/` 和已登记原生接线 | native、相关 Daemon/Host 测试与平台契约 | 涉及共享启动、权限、运行时契约时扩大;未知 native 路径仍全量 | -| 根配置、workspace、锁文件、补丁、构建配置 | 第一阶段保留全量 | 包级配置的语义细分留到后续,不直接忽略 `package.json` | +| 包内 TypeScript/构建配置、可核验且全部 scripts 不变的包 manifest(已实现) | 所属包及消费者;manifest 另选依赖策略 | manifest 身份或 scripts 变化、无法核验时全量;新旧依赖边并集不能丢失消费者 | +| 根配置、workspace、锁文件、补丁、CI 工作流/脚本 | 保留全量 | 文档及明确登记的维护路径例外;未知路径仍全量 | 分类结果应输出命中文件、理由、受影响包、选中任务和平台要求,供 PR 摘要核对。`macos-required` 不能仅由当前的 `daemon=true` 或单个路径正则替代,需要包含共享契约和调用链的影响传播。 @@ -137,7 +144,7 @@ Linux 每日回归仅在同一提交、同一检查方案已有有效的完整 | --- | --- | --- | | P1:规则与开发阶段分档(已接入) | 分类器、执行策略、CI 工作流、现有 CI 契约测试、本文 | 模板/已登记脚本不误触全量;未知配置仍全量;Draft 不冒充完整通过;转正式和 `full-ci` 标签正确补齐;所有必需任务意外跳过均被拒绝;定时完整矩阵每日运行 | | P2:平台分工 | 测试清单、Daemon 分片执行器、平台契约、工作流 | discovery 与清单守恒;Linux 通用组通过;原有 Darwin 正向/负向用例实际执行;额外 Linux 拒绝与 native smoke 保留;共享变更正确扩大平台范围 | -| P3:耗时与定时优化 | Core 测试执行与耗时记录、必要的 CI 任务、定时选择 | 先测量文件耗时和实际并发;重复数据库初始化仅作为候选原因;若拆 Core 分片,从原 unit job 排除,确保不重复、不漏文件;去重拒绝失败/陈旧证据,周回归仍执行 | +| P3:耗时与定时优化(部分实现) | Core/Daemon 分片执行、文件级计时、必要的 CI 任务、定时选择 | Core 四分片与两 worker、普通入口排除、共享计划及计时报表已实现;hosted 性能收益待验证。每日去重/每周 macOS 尚未实现,仍须满足可信成功证据与平台覆盖规则 | 实施前以分类和汇总契约测试覆盖文档、局部源码、共享契约、未知路径、标签、Draft 转正式、平台新增文件、上游失败和意外跳过。P2 在真实 Linux 与 macOS 上验证同一候选提交的原有覆盖后,才允许减少 macOS 任务。P3 对相同任务集合比较改动前后的累计运行时间和等待时间。 @@ -155,7 +162,8 @@ Linux 每日回归仅在同一提交、同一检查方案已有有效的完整 只检查 CI 规则和分类,不执行产品全套测试: ```bash -node --test scripts/ci/*.test.mjs +corepack pnpm typecheck:ci-tests +node --test --test-concurrency=1 scripts/ci/*.test.mjs scripts/ci/*.test.ts node scripts/ci/classify-scope.mjs --files='["packages/core/src/index.ts"]' node scripts/check-markdown-links.mjs node scripts/check-public-repo-safety.mjs @@ -164,13 +172,13 @@ node scripts/check-public-repo-safety.mjs 下面的命令会真实执行一个 Daemon 分片,不是只打印分配;需要构建产物和对应支持平台: ```bash -node scripts/ci/daemon-test-shard.mjs --shard=0 --shard-count=4 +node scripts/ci/daemon-test-shard.mjs --shard=0 --shard-count=4 --concurrency=2 ``` ## 未决事项 - **平台清单与能力影响映射尚未验证**:这是 P2 的前置条件;未完成时保留现有完整 macOS 范围,不能只切换 runner。 -- **Core 的实际耗时瓶颈尚未 profiling**:这是 P3 的前置条件;不因此阻塞 P1,未测量时不承诺分片或并发能降低费用。 +- **hosted 性能收益尚未验证**:Core 分片、两 worker 和文件级计时已实现;仍须比较同等覆盖下的排队、准备、测试及总时长与累计任务分钟,不能从本地文件计时推导 hosted 加速或费用下降。 - **定时去重尚未实现**:当前按本次要求每天运行完整矩阵;若未来重新提出分层或去重,必须满足平台覆盖与成功基线规则。 以上缺口不阻塞 P1。P1 的本地契约验证不能代替 GitHub runner 实际验收,也不能证明原生平台测试已通过;运行额度仍由账户账单决定。 diff --git a/package.json b/package.json index 2a30bedb7..cb60a4e1e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "check:licenses": "node scripts/check-license-policy.mjs", "check:public": "node scripts/check-public-repo-safety.mjs", "check:dependency-security": "corepack pnpm --filter @ling-stack/mobile exec node --test test/dependency-security-patches.test.mjs test/dependency-security-tooling.test.mjs && corepack pnpm --filter @ling-stack/daemon exec node --test test/dependency-security.test.mjs && node --test scripts/dependency-security-regressions.test.mjs", - "typecheck": "corepack pnpm --workspace-concurrency=1 -r typecheck", + "typecheck": "corepack pnpm typecheck:ci-tests && corepack pnpm --workspace-concurrency=1 -r typecheck", + "typecheck:ci-tests": "tsc -p scripts/ci/tsconfig.test.json", "ling": "node packages/cli/dist/index.js", "local:restart": "node packages/cli/dist/index.js restart", "lint": "corepack pnpm -r lint", diff --git a/packages/daemon/package.json b/packages/daemon/package.json index becbd048e..3055b12e2 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -9,12 +9,12 @@ }, "scripts": { "build:ci": "tsc -p tsconfig.json && node scripts/build-image-sanitizer.mjs", - "typecheck:ci": "tsc -p tsconfig.json --noEmit", - "test:ci": "node --test --test-concurrency=1 test/*.test.mjs", + "typecheck:ci": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json", + "test:ci": "node --test --test-concurrency=1 test/*.test.mjs test/*.test.ts", "build": "corepack pnpm --filter @ling-stack/contracts build && corepack pnpm --filter @ling-stack/plugin-sdk build && corepack pnpm --filter @ling-stack/browser-host build && corepack pnpm --filter @ling-stack/core build && corepack pnpm --filter @ling-stack/runner-pi build && corepack pnpm --filter @ling-stack/providers build && corepack pnpm --filter @ling-stack/remote-protocol build && tsc -p tsconfig.json && node scripts/build-image-sanitizer.mjs", - "typecheck": "corepack pnpm --filter @ling-stack/contracts build && corepack pnpm --filter @ling-stack/plugin-sdk build && corepack pnpm --filter @ling-stack/browser-host build && corepack pnpm --filter @ling-stack/core build && corepack pnpm --filter @ling-stack/runner-pi build && corepack pnpm --filter @ling-stack/providers build && corepack pnpm --filter @ling-stack/remote-protocol build && tsc -p tsconfig.json --noEmit", + "typecheck": "corepack pnpm --filter @ling-stack/contracts build && corepack pnpm --filter @ling-stack/plugin-sdk build && corepack pnpm --filter @ling-stack/browser-host build && corepack pnpm --filter @ling-stack/core build && corepack pnpm --filter @ling-stack/runner-pi build && corepack pnpm --filter @ling-stack/providers build && corepack pnpm --filter @ling-stack/remote-protocol build && corepack pnpm typecheck:ci", "lint": "echo \"lint not configured yet\"", - "test": "corepack pnpm --filter @ling-stack/contracts build && corepack pnpm --filter @ling-stack/plugin-sdk build && corepack pnpm --filter @ling-stack/browser-host build && corepack pnpm --filter @ling-stack/core build && corepack pnpm --filter @ling-stack/runner-pi build && corepack pnpm --filter @ling-stack/providers build && corepack pnpm --filter @ling-stack/remote-protocol build && tsc -p tsconfig.json && node scripts/build-image-sanitizer.mjs && node --test --test-concurrency=1 test/*.test.mjs", + "test": "corepack pnpm --filter @ling-stack/contracts build && corepack pnpm --filter @ling-stack/plugin-sdk build && corepack pnpm --filter @ling-stack/browser-host build && corepack pnpm --filter @ling-stack/core build && corepack pnpm --filter @ling-stack/runner-pi build && corepack pnpm --filter @ling-stack/providers build && corepack pnpm --filter @ling-stack/remote-protocol build && tsc -p tsconfig.json && node scripts/build-image-sanitizer.mjs && corepack pnpm test:ci", "build:engine-supervisor": "node scripts/build-engine-supervisor.mjs", "build:image-sanitizer": "node scripts/build-image-sanitizer.mjs", "test:image-sanitizer": "cargo test --locked --manifest-path native/image-sanitizer/Cargo.toml -- --test-threads=1" diff --git a/packages/daemon/test/approval-policy-run.test.mjs b/packages/daemon/test/approval-policy-run.test.mjs index 3c1937e4d..1ed293657 100644 --- a/packages/daemon/test/approval-policy-run.test.mjs +++ b/packages/daemon/test/approval-policy-run.test.mjs @@ -9,6 +9,7 @@ import { test } from "node:test"; import { deflateSync } from "node:zlib"; import { DEFAULT_WORKSPACE_ID, createSqliteRunStore } from "../../core/dist/index.js"; import { createDaemonServer } from "../dist/index.js"; +import { createSqliteWorkspaceFixture } from "./test-support/sqlite-workspace-fixture.ts"; import { auditIgnoredRunnerCompletion } from "../dist/features/runs/stale-completion.js"; import { ensureGitRepo, ensureGitRepoForChannelPost } from "./git-test-helpers.mjs"; import { ToolFreePiRunner } from "./support/tool-free-pi-runner.mjs"; @@ -48,7 +49,7 @@ function modelVisibleToolOutputs(handled, toolCallId) { } test("health includes the configured default workspace cwd", async () => { - const defaultWorkspaceCwd = await mkdtemp(resolve(tmpdir(), "ling-stack-health-cwd-")); + const defaultWorkspaceCwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-health-cwd-")); const daemon = createDaemonServer({ host: "127.0.0.1", port: 0, @@ -76,7 +77,7 @@ test("health includes the configured default workspace cwd", async () => { }); test("approval API rejects a pending request whose Run has already failed", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-stale-approval-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-stale-approval-api-")); const daemon = await startDaemonForTest(new ThreadResumeRunner()); const store = createSqliteRunStore(cwd); try { @@ -103,7 +104,7 @@ test("approval API rejects a pending request whose Run has already failed", asyn }); test("runtime runner settings api seeds Pi defaults and explicit runner requests fail closed", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-runner-settings-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-runner-settings-")); const runner = new ThreadResumeRunner(); const daemon = await startDaemonForTest(runner); try { @@ -160,7 +161,7 @@ test("runtime runner settings api seeds Pi defaults and explicit runner requests }); test("bash=allow lets the runner continue without creating an approval request", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-policy-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-policy-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_bash_allow", toolName: "bash", @@ -182,7 +183,7 @@ test("bash=allow lets the runner continue without creating an approval request", }); test("Bash approval displays and grants only the model-selected subdirectory", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-approval-effective-cwd-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-approval-effective-cwd-")); await mkdir(resolve(cwd, "project")); const runner = new ApprovalProbeRunner({ toolCallId: "effective-cwd", toolName: "bash", input: { command: "printf scoped", cwd: "project", prefix_rule: ["printf"] } }); @@ -214,7 +215,7 @@ test("Bash approval displays and grants only the model-selected subdirectory", a }); test("risk review upgrades allowed force push into an explicit approval request", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-risk-review-force-push-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-risk-review-force-push-")); execFileSync("git", ["init"], { cwd, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd, stdio: "ignore" }); execFileSync("git", ["config", "user.name", "Test User"], { cwd, stdio: "ignore" }); @@ -267,7 +268,7 @@ test("risk review upgrades allowed force push into an explicit approval request" }); test("runtimeTools.unifiedExec config is authoritative over run request feature flags", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-unified-exec-gate-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-unified-exec-gate-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_bash_request_gate", toolName: "bash", @@ -292,7 +293,7 @@ test("runtimeTools.unifiedExec config is authoritative over run request feature }); test("unified exec bash=allow runs through daemon and records redacted artifacts", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-unified-exec-allow-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-unified-exec-allow-")); const secretValue = "unified-exec-secret-value"; const runner = new UnifiedExecProbeRunner({ toolCallId: "tool_unified_exec_allow", @@ -347,7 +348,7 @@ test("unified exec bash=allow runs through daemon and records redacted artifacts }); test("shell snapshots stay disabled when only the run request asks for them", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-shell-snapshot-gate-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-shell-snapshot-gate-")); const runner = new UnifiedExecProbeRunner({ toolCallId: "tool_shell_snapshot_request_gate", toolName: "bash", @@ -376,7 +377,7 @@ test("shell snapshots stay disabled when only the run request asks for them", as }); test("shell snapshots record unified exec start and end artifacts with redaction", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-shell-snapshot-allow-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-shell-snapshot-allow-")); const secretValue = "shell-snapshot-secret-value"; const runner = new UnifiedExecProbeRunner({ toolCallId: "tool_shell_snapshot_allow", @@ -434,7 +435,7 @@ test("shell snapshots record unified exec start and end artifacts with redaction }); test("unified exec bash=deny returns handled denial and never spawns", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-unified-exec-deny-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-unified-exec-deny-")); const markerPath = resolve(cwd, "should-not-exist.txt"); const runner = new UnifiedExecProbeRunner({ toolCallId: "tool_unified_exec_deny", @@ -466,7 +467,7 @@ test("unified exec bash=deny returns handled denial and never spawns", async () }); test("unified exec bash=ask waits for approval before running", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-unified-exec-ask-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-unified-exec-ask-")); const runner = new UnifiedExecProbeRunner({ toolCallId: "tool_unified_exec_ask", toolName: "bash", @@ -510,7 +511,7 @@ test("unified exec bash=ask waits for approval before running", async () => { }); test("parallel Host commands enter approval waits one at a time", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-parallel-host-approvals-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-parallel-host-approvals-")); const runner = new ConcurrentApprovalProbeRunner([ { toolCallId: "tool_parallel_first", toolName: "bash", input: { command: "printf 'first\\n'" } }, { toolCallId: "tool_parallel_second", toolName: "bash", input: { command: "printf 'second\\n'" } }, @@ -553,7 +554,7 @@ test("parallel Host commands enter approval waits one at a time", async () => { }); test("denying the first parallel Host command does not create a successor approval", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-parallel-host-denial-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-parallel-host-denial-")); const runner = new ConcurrentApprovalProbeRunner([ { toolCallId: "tool_denied_first", toolName: "bash", input: { command: "printf 'first\\n'" } }, { toolCallId: "tool_denied_second", toolName: "bash", input: { command: "printf 'second\\n'" } }, @@ -582,7 +583,7 @@ test("denying the first parallel Host command does not create a successor approv }); test("cancelling a Run during the first parallel Host approval does not admit the queued command", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-parallel-host-cancel-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-parallel-host-cancel-")); const runner = new ConcurrentApprovalProbeRunner([ { toolCallId: "tool_cancel_parallel_first", toolName: "bash", input: { command: "printf 'first\\n'" } }, { toolCallId: "tool_cancel_parallel_second", toolName: "bash", input: { command: "printf 'second\\n'" } }, @@ -620,7 +621,7 @@ test("cancelling a Run during the first parallel Host approval does not admit th // approval wait. Keep their real Host coverage above, all budgets/assertions // below, and the actual SQLite decision/replay path unchanged. test("approval wait does not consume runtime idle or hard timeout budgets", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-approval-timeout-wait-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-approval-timeout-wait-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_approval_timeout_wait", toolName: "read", @@ -680,7 +681,7 @@ test("approval wait does not consume runtime idle or hard timeout budgets", asyn }); test("approval resume restores the hard timeout budget after an externally persisted decision", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-approval-timeout-resume-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-approval-timeout-resume-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_approval_timeout_resume", toolName: "read", @@ -731,7 +732,7 @@ test("approval resume restores the hard timeout budget after an externally persi }); test("slow replay of an external approval decision resets idle time when execution resumes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-approval-slow-replay-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-approval-slow-replay-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_approval_slow_replay", toolName: "read", input: { path: "unused-approval-budget-probe.txt" }, @@ -784,7 +785,7 @@ test("slow replay of an external approval decision resets idle time when executi }); test("unified exec nonzero exit returns a handled failed result", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-unified-exec-nonzero-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-unified-exec-nonzero-")); const runner = new UnifiedExecProbeRunner({ toolCallId: "tool_unified_exec_nonzero", toolName: "bash", @@ -818,7 +819,7 @@ test("unified exec nonzero exit returns a handled failed result", async () => { }); test("unified exec command timeout returns timed_out and does not create approvals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-unified-exec-timeout-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-unified-exec-timeout-")); const runner = new UnifiedExecProbeRunner({ toolCallId: "tool_unified_exec_timeout", toolName: "bash", @@ -845,7 +846,7 @@ test("unified exec command timeout returns timed_out and does not create approva }); test("unified exec rejects PTY requests as handled failures without spawning", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-unified-exec-pty-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-unified-exec-pty-")); const markerPath = resolve(cwd, "pty-should-not-run.txt"); const runner = new UnifiedExecProbeRunner({ toolCallId: "tool_unified_exec_pty", @@ -874,7 +875,7 @@ test("unified exec rejects PTY requests as handled failures without spawning", a }); test("unified exec bounds long output chunks and stream previews", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-unified-exec-long-output-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-unified-exec-long-output-")); const runner = new UnifiedExecProbeRunner({ toolCallId: "tool_unified_exec_long_output", toolName: "bash", @@ -940,7 +941,7 @@ test("unified exec bounds long output chunks and stream previews", async () => { }); test("unified exec run cancellation kills command and leaves the run cancelled", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-unified-exec-cancel-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-unified-exec-cancel-")); const markerPath = resolve(cwd, "after-cancel.txt"); const runner = new UnifiedExecProbeRunner({ toolCallId: "tool_unified_exec_cancel", @@ -991,7 +992,7 @@ for (const [command, profile, actionType] of [ ["echo profile-gated", { requiredForTools: ["bash"] }, "bash"], ["git commit -m profile-gated", { requiredForActions: ["git_commit"] }, "git_commit"], ]) test(`assignee approval profile gates ${actionType} despite workspace bash=allow`, async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-policy-profile-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-policy-profile-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_bash_profile", toolName: "bash", @@ -1047,7 +1048,7 @@ for (const [command, profile, actionType] of [ }); test("session permission mode snapshots the effective run policy without relaxing deny rules", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-session-policy-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-session-policy-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_write_session_policy", toolName: "write", @@ -1092,7 +1093,7 @@ test("session permission mode snapshots the effective run policy without relaxin }); test("smart authorization resolves an asked command through an isolated system reviewer", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-smart-review-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-smart-review-")); const runner = new SmartApprovalProbeRunner("approve"); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -1136,7 +1137,7 @@ test("smart authorization resolves an asked command through an isolated system r }); test("smart authorization preserves the pending human path when the reviewer returns manual", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-smart-review-manual-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-smart-review-manual-")); const runner = new SmartApprovalProbeRunner("manual"); const daemon = await startDaemonForTest(runner); try { @@ -1171,7 +1172,7 @@ test("smart authorization preserves the pending human path when the reviewer ret for (const decision of ["approve", "deny"]) { const decisionVerb = decision === "approve" ? "approves" : "denies"; test(`smart approval review recovers exactly once after daemon restart and ${decisionVerb} safely`, { timeout: 20_000 }, async () => { - const cwd = await mkdtemp(resolve(tmpdir(), `ling-stack-daemon-smart-review-restart-${decision}-`)); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), `ling-stack-daemon-smart-review-restart-${decision}-`)); const outputPath = resolve(cwd, `smart-review-restart-${decision}.txt`); const command = `printf '${decision}\\n' > ${JSON.stringify(outputPath)}`; const firstRunner = new BlockingSmartApprovalProbeRunner({ @@ -1252,7 +1253,7 @@ for (const decision of ["approve", "deny"]) { } test("full access permission mode requires explicit confirmation before creating a run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-session-policy-confirm-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-session-policy-confirm-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_full_access_confirm", toolName: "write", @@ -1277,7 +1278,7 @@ test("full access permission mode requires explicit confirmation before creating }); test("bash=deny records an immediately denied approval request", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-policy-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-policy-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_bash_deny", toolName: "bash", @@ -1307,7 +1308,7 @@ test("bash=deny records an immediately denied approval request", async () => { }); test("bash=ask pauses the run until the approval is resolved", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-policy-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-policy-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_bash_ask", toolName: "bash", @@ -1382,7 +1383,7 @@ test("bash=ask pauses the run until the approval is resolved", async () => { }); test("workspace command rule approves the current Bash request and suppresses matching prompts until revoked", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-command-rule-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-command-rule-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_command_rule_first", toolName: "bash", @@ -1444,7 +1445,7 @@ test("workspace command rule approves the current Bash request and suppresses ma }); test("workspace command rule survives daemon restart without approving an unrelated command", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-command-rule-restart-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-command-rule-restart-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_rule_before_restart", toolName: "bash", @@ -1502,7 +1503,7 @@ test("workspace command rule survives daemon restart without approving an unrela }); test("daemon derives a reusable rule for a bounded compound Bash command without a model hint", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-compound-command-rule-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-compound-command-rule-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_compound_command_rule", toolName: "bash", @@ -1528,7 +1529,7 @@ test("daemon derives a reusable rule for a bounded compound Bash command without }); test("daemon shutdown preserves a pending approval instead of recording user cancellation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-approval-shutdown-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-approval-shutdown-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_bash_shutdown", toolName: "bash", @@ -1604,7 +1605,7 @@ test("daemon shutdown preserves a pending approval instead of recording user can }); test("a real orchestrator hard timeout fails the Run instead of recording cancellation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-hard-timeout-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-hard-timeout-")); const runner = new AbortAwareBlockingRunner(); const stores = new Map(); const storeFactory = (workspaceCwd) => { @@ -1646,7 +1647,7 @@ test("a real orchestrator hard timeout fails the Run instead of recording cancel }); test("approved checkpoint resumes the same Run with one fenced replacement attempt after daemon restart", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-approval-restart-resume-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-approval-restart-resume-")); const command = "printf 'resumed-ok\\n' > resumed-after-restart.txt"; const firstRunner = new ApprovalProbeRunner({ toolCallId: "tool_bash_restart_resume", @@ -1729,7 +1730,7 @@ test("approved checkpoint resumes the same Run with one fenced replacement attem }); test("restart resume fails closed when the exact approved side effect has no safe continuation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-approval-resume-unsupported-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-approval-resume-unsupported-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_write_resume_unsupported", toolName: "write", @@ -1803,7 +1804,7 @@ test("restart resume fails closed when the exact approved side effect has no saf }); test("approval execution actions expose preview and result artifacts", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-action-artifacts-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-action-artifacts-")); const runner = new ApprovalArtifactRunner(); const daemon = await startDaemonForTest(runner); try { @@ -1866,7 +1867,7 @@ test("approval execution actions expose preview and result artifacts", async () }); test("non-approval tool events are exposed as action lifecycle records", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-non-approval-actions-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-non-approval-actions-")); const runner = new NonApprovalToolLifecycleRunner(); const daemon = await startDaemonForTest(runner); try { @@ -1903,7 +1904,7 @@ test("non-approval tool events are exposed as action lifecycle records", async ( }); test("git commit approval actions include scope analysis and git preview artifacts", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-git-action-preview-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-git-action-preview-")); execFileSync("git", ["init"], { cwd, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "ling-test@example.com"], { cwd }); execFileSync("git", ["config", "user.name", "Ling Test"], { cwd }); @@ -1960,7 +1961,7 @@ test("git commit approval actions include scope analysis and git preview artifac }); test("thread evidence and manual resume preserve Ling-owned lineage", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-thread-resume-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-thread-resume-")); const runner = new ThreadResumeRunner(); const daemon = await startDaemonForTest(runner); try { @@ -2024,7 +2025,7 @@ test("thread evidence and manual resume preserve Ling-owned lineage", async () = }); test("write=deny blocks file writes through the run approval chain", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-policy-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-policy-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_write_deny", toolName: "write", @@ -2050,7 +2051,7 @@ test("write=deny blocks file writes through the run approval chain", async () => }); test("write outside allowed paths is denied even when write is allowed", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-policy-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-policy-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_write_outside", toolName: "write", @@ -2080,7 +2081,7 @@ test("write outside allowed paths is denied even when write is allowed", async ( }); test("plan mode denies file writes without offering a pending approval", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-plan-mode-write-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-plan-mode-write-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_plan_write", toolName: "write", @@ -2114,7 +2115,7 @@ test("plan mode denies file writes without offering a pending approval", async ( }); test("plan mode denies mutating shell commands without offering a pending approval", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-plan-mode-bash-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-plan-mode-bash-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_plan_bash", toolName: "bash", @@ -2145,7 +2146,7 @@ test("plan mode denies mutating shell commands without offering a pending approv }); test("patch-shaped write creates approval preview and applies only after approval", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-patch-approval-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-patch-approval-")); await writeFile(resolve(cwd, "src.txt"), "old\nkeep\n", "utf8"); const patchText = `*** Begin Patch *** Update File: src.txt @@ -2211,7 +2212,7 @@ test("patch-shaped write creates approval preview and applies only after approva }); test("patch approval redacts resolved provider secrets from approvals and artifacts", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-patch-secret-redaction-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-patch-secret-redaction-")); await writeFile(resolve(cwd, "src.txt"), "old\n", "utf8"); const secretValue = "resolved-patch-provider-secret"; const patchText = `*** Begin Patch @@ -2290,7 +2291,7 @@ test("patch approval redacts resolved provider secrets from approvals and artifa }); test("patch-shaped edit creates approval preview and applies through the daemon", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-patch-edit-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-patch-edit-")); await writeFile(resolve(cwd, "src.txt"), "old\n", "utf8"); const patchText = `*** Begin Patch *** Update File: src.txt @@ -2331,7 +2332,7 @@ test("patch-shaped edit creates approval preview and applies through the daemon" }); test("denied patch approval leaves files unchanged", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-patch-deny-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-patch-deny-")); await writeFile(resolve(cwd, "src.txt"), "old\n", "utf8"); const patchText = `*** Begin Patch *** Update File: src.txt @@ -2368,7 +2369,7 @@ test("denied patch approval leaves files unchanged", async () => { }); test("approved patch fails closed when target changes after preview", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-patch-conflict-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-patch-conflict-")); await writeFile(resolve(cwd, "src.txt"), "old\nkeep\n", "utf8"); const patchText = `*** Begin Patch *** Update File: src.txt @@ -2421,7 +2422,7 @@ test("approved patch fails closed when target changes after preview", async () = }); test("patch touched paths outside allowed write paths are denied without mutation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-patch-policy-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-patch-policy-")); const patchText = `*** Begin Patch *** Add File: src/out.txt +outside @@ -2454,7 +2455,7 @@ test("patch touched paths outside allowed write paths are denied without mutatio }); test("patches cannot modify .ling-stack internal state", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-patch-internal-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-patch-internal-")); const patchText = `*** Begin Patch *** Add File: .ling-stack/owned.txt +blocked @@ -2481,7 +2482,7 @@ test("patches cannot modify .ling-stack internal state", async () => { }); test("approval listing lazily expires stale pending requests", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-approval-expiry-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-approval-expiry-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_expiry", toolName: "bash", @@ -2548,7 +2549,7 @@ test("approval listing lazily expires stale pending requests", async () => { }); test("provider check persists last check result", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused", toolName: "bash", @@ -2588,7 +2589,7 @@ test("provider check persists last check result", async () => { }); test("secret status reports configured, missing, unavailable, and invalid refs without values", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-secret-status-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-secret-status-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_secret_status", toolName: "bash", @@ -2628,7 +2629,7 @@ test("secret status reports configured, missing, unavailable, and invalid refs w }); test("provider create and update accept keychain refs and reject raw or unknown secret schemes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-secret-ref-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-secret-ref-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_provider_secret_ref", toolName: "bash", @@ -2685,7 +2686,7 @@ test("provider create and update accept keychain refs and reject raw or unknown }); test("provider check and run startup use the injected secret resolver without leaking resolved values", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-secret-resolver-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-secret-resolver-")); const secretValue = "resolved-keychain-secret"; const runner = new SecretEchoRunner(secretValue); const daemon = await startDaemonForTest(runner, { @@ -2725,7 +2726,7 @@ test("provider check and run startup use the injected secret resolver without le }); test("provider local API keys are edited from sqlite without leaking in provider responses", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-local-secret-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-local-secret-")); const openai = await startMockOpenAICompatibleServer(); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_local_secret", @@ -2816,7 +2817,7 @@ test("provider local API keys are edited from sqlite without leaking in provider }); test("provider draft check validates direct API keys without persisting providers", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-draft-check-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-draft-check-")); const openai = await startMockOpenAICompatibleServer(); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_draft_check", @@ -2868,7 +2869,7 @@ test("provider local API keys are edited from sqlite without leaking in provider }); test("provider draft check allows provider-exposed model ids with chat protocols", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-draft-media-mismatch-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-draft-media-mismatch-")); const openai = await startMockOpenAICompatibleServer(); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_mismatch", @@ -2913,7 +2914,7 @@ test("provider local API keys are edited from sqlite without leaking in provider }); test("provider model listing probes the endpoint with the provided API key", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-models-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-models-")); const openai = await startMockOpenAICompatibleServer({ models: [ { id: "cliproxy-gpt-4o", object: "model", owned_by: "cliproxy" }, @@ -2995,7 +2996,7 @@ test("provider model listing probes the endpoint with the provided API key", asy }); test("provider model listing normalizes OpenAI-compatible endpoint paths", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-models-normalize-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-models-normalize-")); const openai = await startMockOpenAICompatibleServer(); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_model_list_normalize", @@ -3024,7 +3025,7 @@ test("provider model listing normalizes OpenAI-compatible endpoint paths", async }); test("provider model import creates and updates supplier model presets", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-model-import-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-model-import-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_model_import", toolName: "bash", @@ -3115,7 +3116,7 @@ test("provider model import creates and updates supplier model presets", async ( }); test("provider manual create and update auto-enable live transport for OpenAI Images", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-manual-openai-image-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-manual-openai-image-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_manual_openai_image", toolName: "bash", @@ -3157,7 +3158,7 @@ test("provider manual create and update auto-enable live transport for OpenAI Im }); test("legacy OpenAI Images media providers are normalized for listing, checks, and runs", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-legacy-openai-image-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-legacy-openai-image-")); const openai = await startMockOpenAICompatibleServer(); process.env.LING_STACK_TEST_OPENAI_IMAGE_KEY = "test-openai-image-key"; const now = "2026-06-30T00:00:00.000Z"; @@ -3220,7 +3221,7 @@ test("legacy OpenAI Images media providers are normalized for listing, checks, a }); test("media provider model listing returns provider-exposed candidates without category filtering", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-media-models-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-media-models-")); const openai = await startMockOpenAICompatibleServer({ models: [ { id: "doubao-seed-2-0-lite-260215", object: "model", owned_by: "volcengine" }, @@ -3285,7 +3286,7 @@ test("media provider model listing returns provider-exposed candidates without c }); test("media provider check runs a real Seedream image probe", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-media-check-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-media-check-")); const seedream = await startMockSeedreamServer(); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_check", @@ -3364,7 +3365,7 @@ test("media provider check runs a real Seedream image probe", async () => { }); test("startup provider refresh does not run live media probes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-startup-media-skip-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-startup-media-skip-")); const seedream = await startMockSeedreamServer(); let daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_startup_media_skip", @@ -3413,7 +3414,7 @@ test("startup provider refresh does not run live media probes", async () => { }); test("media job API creates, lists, and shows queued jobs without running on creation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-jobs-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-jobs-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_job", toolName: "bash", @@ -3457,7 +3458,7 @@ test("media job API creates, lists, and shows queued jobs without running on cre }); test("media production API persists storyboard order independently from canvas placement", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-production-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-production-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_production", toolName: "bash", @@ -3499,7 +3500,7 @@ test("media production API persists storyboard order independently from canvas p }); test("media production project assets validate, persist, serve and protect scene references", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-project-assets-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-project-assets-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_project_assets", toolName: "bash", @@ -3631,7 +3632,7 @@ test("media production project assets validate, persist, serve and protect scene }); test("media production project asset upload rejects a symlinked project directory", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-project-asset-symlink-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-project-asset-symlink-")); const outside = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-project-asset-outside-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_project_asset_symlink", @@ -3667,7 +3668,7 @@ test("media production project asset upload rejects a symlinked project director }); test("media production materializes project images only for the Seedance submission call", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-project-i2v-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-project-i2v-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_project_i2v", toolName: "bash", @@ -3763,7 +3764,7 @@ test("media production materializes project images only for the Seedance submiss }); test("media production rejects corrupt persisted image inputs before creating a paid job", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-corrupt-media-inputs-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-corrupt-media-inputs-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_corrupt_media_inputs", toolName: "bash", @@ -3841,7 +3842,7 @@ test("media production rejects corrupt persisted image inputs before creating a }); test("media production project run validates every legacy target before creating any scene run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-project-run-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-project-run-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_project_run", toolName: "bash", @@ -3950,7 +3951,7 @@ test("media production project run validates every legacy target before creating }); test("media production keeps direct Seedance scenes and ComfyUI workflow scenes in one project", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-mixed-sources-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-mixed-sources-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_mixed_sources", toolName: "bash", @@ -4047,7 +4048,7 @@ test("media production keeps direct Seedance scenes and ComfyUI workflow scenes }); test("ComfyUI workflow polling resumes from the persisted execution handle after daemon restart", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-workflow-resume-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-workflow-resume-")); const comfy = await startMockComfyUiServer({ completeImmediately: false }); const runner = () => new ApprovalProbeRunner({ toolCallId: "tool_unused_media_workflow_resume", toolName: "bash" }); let daemon = await startDaemonForTest(runner(), {}, { defaultWorkspaceCwd: cwd }); @@ -4107,7 +4108,7 @@ test("ComfyUI workflow polling resumes from the persisted execution handle after }); test("media job API blocks static media providers from reporting fake success", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-run-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-run-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_job_run", toolName: "bash", @@ -4140,7 +4141,7 @@ test("media job API blocks static media providers from reporting fake success", }); test("media job live transport fails when the vendor response has no media artifact", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-run-failure-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-run-failure-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_job_run_failure", toolName: "bash", @@ -4182,7 +4183,7 @@ test("media job live transport fails when the vendor response has no media artif }); test("media job live transport calls Seedream image_generate directly and links artifacts", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-live-direct-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-live-direct-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_job_live_direct", toolName: "bash", @@ -4257,7 +4258,7 @@ test("media job live transport calls Seedream image_generate directly and links }); test("legacy media cost approvals do not block direct media job execution", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-legacy-approval-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-legacy-approval-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_job_legacy_approval", toolName: "bash", @@ -4330,7 +4331,7 @@ test("legacy media cost approvals do not block direct media job execution", asyn }); test("media job API rejects chat providers and unsupported media operations", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-jobs-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-jobs-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused_media_job_reject", toolName: "bash", @@ -4371,7 +4372,7 @@ test("media job API rejects chat providers and unsupported media operations", as }); test("media provider presets remain blocked from coding task runs", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-media-run-block-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-media-run-block-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_media_run_block", toolName: "read", @@ -4393,7 +4394,7 @@ test("media provider presets remain blocked from coding task runs", async () => }); test("provider preset run requires successful daemon preflight", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-run-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-run-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_preflight_ready", toolName: "read", @@ -4427,7 +4428,7 @@ test("provider preset run requires successful daemon preflight", async () => { }); test("failed provider checks block daemon preset runs unless skipped", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-provider-run-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-provider-run-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_preflight_failed", toolName: "read", @@ -4466,7 +4467,7 @@ test("failed provider checks block daemon preset runs unless skipped", async () }); test("task run endpoint rejects paused channels before runner start", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-paused-channel-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-paused-channel-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_paused_channel", toolName: "read", @@ -4508,7 +4509,7 @@ test("task run endpoint rejects paused channels before runner start", async () = }); test("same idempotency key returns the original task run without starting a duplicate", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-idempotent-run-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-idempotent-run-")); const runner = new BlockingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -4554,7 +4555,7 @@ test("same idempotency key returns the original task run without starting a dupl }); test("write runs in newly created channels use isolated worktrees and do not conflict", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-bound-path-lock-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-bound-path-lock-")); const runner = new BlockingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -4639,7 +4640,7 @@ test("write runs in newly created channels use isolated worktrees and do not con }); test("queueOnConflict persists a queued run for channels explicitly handed off to Local", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-bound-path-queue-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-bound-path-queue-")); const runner = new BlockingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -4724,7 +4725,7 @@ test("queueOnConflict persists a queued run for channels explicitly handed off t }); test("runtime diagnostics expose the same daemon-owned work unit to web and desktop-style clients", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-runtime-diagnostics-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-runtime-diagnostics-")); const runner = new BlockingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -4795,7 +4796,7 @@ test("runtime diagnostics expose the same daemon-owned work unit to web and desk }); test("work unit cancel cancels a linked active run without late success overwrite", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-work-unit-cancel-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-work-unit-cancel-")); const runner = new BlockingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -4843,7 +4844,7 @@ test("work unit cancel cancels a linked active run without late success overwrit }); test("run cancel cancels pending approvals for a waiting run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-run-cancel-approval-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-run-cancel-approval-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_cancel_waiting_approval", toolName: "bash", @@ -4879,7 +4880,7 @@ test("run cancel cancels pending approvals for a waiting run", async () => { }); test("queued run guidance starts FIFO continuation after the source run finishes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-run-guidance-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-run-guidance-")); const runner = new BlockingRunner({ verifyToolFree: true }); const daemon = await startDaemonForTest(runner); let sse; @@ -4994,7 +4995,7 @@ test("queued run guidance starts FIFO continuation after the source run finishes }); test("interrupt redirect cancels the source run and starts a linked continuation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-run-redirect-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-run-redirect-")); const runner = new BlockingRunner({ verifyToolFree: true }); const daemon = await startDaemonForTest(runner); let sse; @@ -5062,7 +5063,7 @@ test("interrupt redirect cancels the source run and starts a linked continuation }); test("queued run guidance promote cancels the active run without duplicating the intervention", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-run-promote-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-run-promote-")); const runner = new BlockingRunner({ verifyToolFree: true }); const daemon = await startDaemonForTest(runner); let sse; @@ -5130,7 +5131,7 @@ test("queued run guidance promote cancels the active run without duplicating the }); test("work unit retry and reconcile APIs operate on durable supervisor state", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-work-unit-controls-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-work-unit-controls-")); const daemon = await startDaemonForTest(new ApprovalProbeRunner({ toolCallId: "tool_unused", toolName: "read", @@ -5222,7 +5223,7 @@ test("work unit retry and reconcile APIs operate on durable supervisor state", a }); test("cancelled runs are not overwritten when a late runner reports success", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-cancel-race-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-cancel-race-")); const runner = new BlockingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -5283,7 +5284,7 @@ test("cancelled runs are not overwritten when a late runner reports success", as }); test("artifact APIs list metadata and serve content from the artifacts root", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-artifacts-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-artifacts-")); const largeContent = Buffer.alloc(2 * 1024 * 1024 + 17, 0x61); const runner = new ApprovalProbeRunner({ toolCallId: "tool_artifact_read", @@ -5413,7 +5414,7 @@ test("artifact APIs list metadata and serve content from the artifacts root", as }); test("artifact access is workspace-bound, lineage-bound, and range-bounded", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-artifact-boundary-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-artifact-boundary-")); const runner = new ApprovalProbeRunner({ toolCallId: "tool_artifact_boundary", toolName: "read", @@ -5466,7 +5467,7 @@ test("artifact access is workspace-bound, lineage-bound, and range-bounded", asy }); test("daemon extracts artifacts from tool outputs and generated file paths", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-daemon-extract-artifacts-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-daemon-extract-artifacts-")); const runner = new ArtifactExtractionRunner(); const daemon = await startDaemonForTest(runner); try { diff --git a/packages/daemon/test/channel-workspace.test.mjs b/packages/daemon/test/channel-workspace.test.mjs index 2be27cd05..23773cd0c 100644 --- a/packages/daemon/test/channel-workspace.test.mjs +++ b/packages/daemon/test/channel-workspace.test.mjs @@ -19,6 +19,7 @@ import { import { createRequiredParticipantManifestV1, createWorkflowParticipantAuthorityV1 } from "../../contracts/dist/index.js"; import { PiRunnerAdapter } from "../../runner-pi/dist/index.js"; import { createDaemonServer } from "../dist/index.js"; +import { createSqliteWorkspaceFixture } from "./test-support/sqlite-workspace-fixture.ts"; import { buildContextPack, estimateTextTokens, resolveProviderContextBudget } from "../dist/features/channels/context-packs.js"; import { routeChannelMessageReply } from "../dist/features/channels/channel-message-router.js"; import { resolveChannelReplyExecutionTarget } from "../dist/features/channels/channel-reply-execution-target.js"; @@ -241,7 +242,7 @@ test("provider runtime availability mapper records transient failures only", () }); test("channel proposal confirmation claim is durable, first-writer-wins, and recoverable", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-proposal-cas-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-proposal-cas-")); const store = createSqliteRunStore(cwd); const now = "2026-09-19T10:00:00.000Z"; try { @@ -306,7 +307,7 @@ test("channel proposal confirmation claim is durable, first-writer-wins, and rec }); test("an expired proposal confirmation lease can be reclaimed without releasing the old claim", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-proposal-lease-expiry-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-proposal-lease-expiry-")); const store = createSqliteRunStore(cwd); try { const now = "2026-09-19T11:00:00.000Z"; @@ -352,7 +353,7 @@ test("an expired proposal confirmation lease can be reclaimed without releasing }); test("channel branches follow archive restore and delete lifecycle", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-branches-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-branches-")); const stores = []; const daemon = await startDaemonForTest(undefined, { storeFactory: (storeCwd) => { @@ -545,7 +546,7 @@ test("context budget manager resolves provider modes and trims lower priority se }); test("ling agent session view is open when channel has no active work", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-open-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-session-open-")); const store = createSqliteRunStore(cwd); try { const { channel } = createStoreChannelAndMessage(store, cwd, "hello"); @@ -566,7 +567,7 @@ test("ling agent session view is open when channel has no active work", async () }); test("ling agent session view projects draft proposal as task confirmation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-proposal-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-session-proposal-")); const store = createSqliteRunStore(cwd); try { const { channel, message } = createStoreChannelAndMessage(store, cwd, `在 ${cwd} 下创建 README.md`); @@ -597,7 +598,7 @@ test("ling agent session view projects draft proposal as task confirmation", asy }); test("proposed plan APIs hydrate deleted source message state without deleting plans", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-proposed-plan-source-state-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-proposed-plan-source-state-")); const store = createSqliteRunStore(cwd); let thread; let channel; @@ -645,7 +646,7 @@ test("proposed plan APIs hydrate deleted source message state without deleting p }); test("channel team metrics exposes visible evidence total separately from team evidence", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-evidence-summary-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-evidence-summary-")); const store = createSqliteRunStore(cwd); try { const { channel, message } = createStoreChannelAndMessage(store, cwd, "collect evidence"); @@ -776,7 +777,7 @@ test("channel team metrics exposes visible evidence total separately from team e }); test("approval-only workflow owner closes immediately after approval", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-approval-owner-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-workflow-approval-owner-")); const store = createSqliteRunStore(cwd); let workflow; let step; @@ -829,7 +830,7 @@ test("approval-only workflow owner closes immediately after approval", async () }); test("ling agent session view projects active workflow approval wait", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-approval-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-session-approval-")); const store = createSqliteRunStore(cwd); try { const { channel } = createStoreChannelAndMessage(store, cwd, "帮我跑测试"); @@ -892,7 +893,7 @@ test("ling agent session view projects active workflow approval wait", async () }); test("ling agent session view projects terminal workflow final result", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-final-workflow-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-session-final-workflow-")); const store = createSqliteRunStore(cwd); try { const { channel } = createStoreChannelAndMessage(store, cwd, "实现并验证"); @@ -969,7 +970,7 @@ test("ling agent session view projects terminal workflow final result", async () }); test("ling agent session view projects blocked workflow final result with next action", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-blocked-workflow-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-session-blocked-workflow-")); const store = createSqliteRunStore(cwd); try { const { channel } = createStoreChannelAndMessage(store, cwd, "需要写文件"); @@ -1010,7 +1011,7 @@ test("ling agent session view projects blocked workflow final result with next a }); test("ling agent session view projects terminal failed run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-failed-run-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-session-failed-run-")); const store = createSqliteRunStore(cwd); try { const { channel } = createStoreChannelAndMessage(store, cwd, "修复失败任务"); @@ -1044,7 +1045,7 @@ test("ling agent session view projects terminal failed run", async () => { }); test("ling agent session view reports denied approval for terminal run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-denied-approval-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-session-denied-approval-")); const store = createSqliteRunStore(cwd); try { const { channel } = createStoreChannelAndMessage(store, cwd, "创建 README.md"); @@ -1094,7 +1095,7 @@ test("ling agent session view reports denied approval for terminal run", async ( }); test("ling agent coordinator queues active run follow-up before ordinary routing", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-followup-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-followup-")); const stores = new Map(); const daemon = await startDaemonForTest(undefined, { storeFactory: (storeCwd) => { @@ -1145,7 +1146,7 @@ test("ling agent coordinator queues active run follow-up before ordinary routing }); test("active channel reply does not intercept an unmarked direct workflow request", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-active-reply-direct-workflow-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-active-reply-direct-workflow-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -1194,7 +1195,7 @@ test("active channel reply does not intercept an unmarked direct workflow reques }); test("root reply guidance cannot capture a branch turn and branch continuation stays on its thread", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-reply-branch-lineage-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-reply-branch-lineage-")); await ensureGitRepo(cwd); const runner = new AbortableDelayedRunner(); const stores = new Map(); @@ -1290,7 +1291,7 @@ test("root reply guidance cannot capture a branch turn and branch continuation s }); test("topic channel replies keep canonical LingThread identity and isolate guidance by source message lineage", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-topic-reply-lineage-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-topic-reply-lineage-")); const runner = new AbortableDelayedRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -1370,7 +1371,7 @@ test("topic channel replies keep canonical LingThread identity and isolate guida }); test("ling agent coordinator queues attachment-only active run guidance", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-attachment-followup-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-attachment-followup-")); const stores = new Map(); const daemon = await startDaemonForTest(undefined, { storeFactory: (storeCwd) => { @@ -1423,7 +1424,7 @@ test("ling agent coordinator queues attachment-only active run guidance", async }); test("ling agent coordinator queues file-ref-only active run guidance", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-file-ref-followup-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-file-ref-followup-")); await writeFile(resolve(cwd, "README.md"), "# File ref guidance\n", "utf8"); const stores = new Map(); const daemon = await startDaemonForTest(undefined, { @@ -1478,7 +1479,7 @@ test("ling agent coordinator queues file-ref-only active run guidance", async () }); test("ling agent turn envelope merges persisted and explicit text file refs safely", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-turn-file-refs-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-turn-file-refs-")); await writeFile(resolve(cwd, "README.md"), "# Current README\n", "utf8"); const store = createSqliteRunStore(cwd); try { @@ -1512,7 +1513,7 @@ test("ling agent turn envelope merges persisted and explicit text file refs safe }); test("channel input materialization cache upserts and feeds turn envelope", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-input-materialization-cache-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-input-materialization-cache-")); const store = createSqliteRunStore(cwd); try { const channel = store.createChannel({ @@ -1590,7 +1591,7 @@ test("channel input materialization cache upserts and feeds turn envelope", asyn }); test("inline materialization writes image vision cache with injected summarizer", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-vision-inline-cache-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-image-vision-inline-cache-")); const store = createSqliteRunStore(cwd); try { await mkdir(store.paths.attachmentsDir, { recursive: true }); @@ -1662,7 +1663,7 @@ test("inline materialization writes image vision cache with injected summarizer" }); test("inline image materialization reuses same image content after provider timeout", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-vision-content-cache-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-image-vision-content-cache-")); const store = createSqliteRunStore(cwd); try { await mkdir(store.paths.attachmentsDir, { recursive: true }); @@ -1750,7 +1751,7 @@ test("inline image materialization reuses same image content after provider time }); test("retryable image materialization failures are not cached permanently", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-vision-timeout-retry-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-image-vision-timeout-retry-")); const store = createSqliteRunStore(cwd); try { await mkdir(store.paths.attachmentsDir, { recursive: true }); @@ -1818,7 +1819,7 @@ test("retryable image materialization failures are not cached permanently", asyn }); test("teammate direct materialization reads image attachments with injected summarizer", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-image-vision-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-image-vision-")); const store = createSqliteRunStore(cwd); try { await mkdir(store.paths.attachmentsDir, { recursive: true }); @@ -2002,7 +2003,7 @@ test("A11 preview and body fresh native connections consume audited DNS addresse }); test("inline materialization writes public link body cache for explicit link request", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-link-body-inline-cache-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-link-body-inline-cache-")); const store = createSqliteRunStore(cwd); try { const channel = store.createChannel({ @@ -2060,7 +2061,7 @@ test("inline materialization writes public link body cache for explicit link req }); test("cached public link body lets Ling answer without materialization clarify", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-link-body-cached-reply-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-link-body-cached-reply-")); const runner = new ScriptedRunner(["这个页面讲的是 cached public link body。"]); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -2126,7 +2127,7 @@ test("cached public link body lets Ling answer without materialization clarify", }); test("pdf and OOXML extractors materialize bounded attachment text", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-document-extractors-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-document-extractors-")); const store = createSqliteRunStore(cwd); const officeArtifacts = new OfficeArtifactService(); try { @@ -2267,7 +2268,7 @@ test("pdf and OOXML extractors materialize bounded attachment text", async () => }); test("inline materialization writes pdf and docx cache for current-turn attachments", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-document-inline-cache-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-document-inline-cache-")); const store = createSqliteRunStore(cwd); try { await mkdir(store.paths.attachmentsDir, { recursive: true }); @@ -2344,7 +2345,7 @@ test("inline materialization writes pdf and docx cache for current-turn attachme }); test("materialized pdf and docx attachments let Ling answer without materialization clarify", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-document-materialized-reply-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-document-materialized-reply-")); const runner = new ScriptedRunner(["我读到了 PDF 和 DOCX 的正文。"]); const daemon = await startDaemonForTest(runner); try { @@ -2396,7 +2397,7 @@ test("materialized pdf and docx attachments let Ling answer without materializat }); test("materialized image attachment lets Ling answer without materialization clarify", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-materialized-reply-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-image-materialized-reply-")); const runner = new ScriptedRunner(["我读到了截图摘要。"]); const daemon = await startDaemonForTest(runner, { imageVisionSummarizer: async () => ({ @@ -2443,7 +2444,7 @@ test("materialized image attachment lets Ling answer without materialization cla }); test("provider-backed image materialization works without injected summarizer", async (t) => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-provider-backed-image-materialization-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-provider-backed-image-materialization-")); const relay = await startMockOpenAICompatibleServer(t, { responseContent: ({ body }) => { const content = body?.messages?.[0]?.content; @@ -2526,7 +2527,7 @@ test("provider-backed image materialization works without injected summarizer", }); test("ling agent coordinator preserves materialized guidance metadata for active run continuation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-materialized-guidance-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-materialized-guidance-")); await writeFile(resolve(cwd, "README.md"), "# Current README\n", "utf8"); const stores = new Map(); const daemon = await startDaemonForTest(undefined, { @@ -2597,7 +2598,7 @@ test("ling agent coordinator preserves materialized guidance metadata for active }); test("ling agent coordinator treats redirect-like text as queued guidance", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-redirect-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-redirect-")); const stores = new Map(); const daemon = await startDaemonForTest(undefined, { storeFactory: (storeCwd) => { @@ -2647,7 +2648,7 @@ test("ling agent coordinator treats redirect-like text as queued guidance", asyn }); test("channel reply guidance drains FIFO without interrupting and projects every continuation reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-guidance-fifo-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-reply-guidance-fifo-")); await ensureGitRepo(cwd); const runner = new AbortableDelayedRunner(); const stores = new Map(); @@ -2819,7 +2820,7 @@ test("channel reply guidance drains FIFO without interrupting and projects every }); test("channel reply guidance continues draining after the first continuation fails to start", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-guidance-start-failure-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-reply-guidance-start-failure-")); const runner = new AbortableDelayedRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -2901,7 +2902,7 @@ test("channel reply guidance continues draining after the first continuation fai }); test("queued channel source stays hidden while dispatch classification is still in flight", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-classification-visibility-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-reply-classification-visibility-")); await ensureGitRepo(cwd); const runner = new AbortableDelayedRunner(); const stores = new Map(); @@ -2998,7 +2999,7 @@ test("queued channel source stays hidden while dispatch classification is still }); test("unmentioned channel guidance joins the pending Ling reply lane before its run is linked", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-pending-lane-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-reply-pending-lane-")); await ensureGitRepo(cwd); const runner = new AbortableDelayedRunner(); const stores = new Map(); @@ -3104,7 +3105,7 @@ test("unmentioned channel guidance joins the pending Ling reply lane before its }); test("channel messages persist and AI blocker replies do not drop the user message", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-messages-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-messages-")); const daemon = await startDaemonForTest(); try { const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { @@ -3139,7 +3140,7 @@ test("channel messages persist and AI blocker replies do not drop the user messa }); test("known unavailable teammate is accepted and replayed as one typed Channel blocker", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-unavailable-participant-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-unavailable-participant-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -3205,7 +3206,7 @@ test("known unavailable teammate is accepted and replayed as one typed Channel b }); test("unavailable participant produces the same typed blocker over Channel SSE", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-unavailable-participant-sse-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-unavailable-participant-sse-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -3240,7 +3241,7 @@ test("unavailable participant produces the same typed blocker over Channel SSE", }); test("channel discussion uses the mentioned teammate provider preset", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-mentioned-teammate-provider-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-mentioned-teammate-provider-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -3279,7 +3280,7 @@ test("channel discussion uses the mentioned teammate provider preset", async () }); test("an untrusted scripted channel adapter cannot publish Provider availability incidents", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-provider-runtime-incident-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-provider-runtime-incident-")); const rateLimitError = Object.assign(new Error("Too many requests"), { code: "rate_limited" }); const runner = new ScriptedRunner([rateLimitError]); const stores = new Map(); @@ -3325,7 +3326,7 @@ test("an untrusted scripted channel adapter cannot publish Provider availability }); test("channel observer bus stays inert when the feature flag is disabled", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-off-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-observer-bus-off-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -3364,7 +3365,7 @@ test("channel observer bus stays inert when the feature flag is disabled", async }); test("channel observer bus writes bounded relevance evidence without visible observer messages", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-on-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-observer-bus-on-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -3415,7 +3416,7 @@ test("channel observer bus writes bounded relevance evidence without visible obs }); test("channel observer bus skips unavailable observers without blocking the main reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-unavailable-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-observer-bus-unavailable-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -3477,7 +3478,7 @@ test("channel observer bus skips unavailable observers without blocking the main }); test("channel observer bus applies provider-level availability incidents to matching observers", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-provider-unavailable-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-observer-bus-provider-unavailable-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -3537,7 +3538,7 @@ test("channel observer bus applies provider-level availability incidents to matc }); test("channel observer bus ignores expired provider-level availability incidents", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-provider-expired-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-observer-bus-provider-expired-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -3597,7 +3598,7 @@ test("channel observer bus ignores expired provider-level availability incidents }); test("channel discussion injects web search context before runner reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-web-search-context-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-web-search-context-")); const runner = new RecordingRunner(); const webSearchCalls = []; const stores = new Map(); @@ -3711,7 +3712,7 @@ test("channel discussion injects web search context before runner reply", async }); test("channel discussion blocks runner prompt envelope echoes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-prompt-echo-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-prompt-echo-")); const runner = new PromptEchoRunner(); const daemon = await startDaemonForTest(runner); try { @@ -3745,7 +3746,7 @@ test("channel discussion blocks runner prompt envelope echoes", async () => { }); test("channel message stream emits visible deltas into a durable AI reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-message-stream-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-message-stream-")); const runner = new ScriptedRunner(["Streamed channel reply"]); const daemon = await startDaemonForTest(runner); try { @@ -3810,7 +3811,7 @@ test("channel message stream emits visible deltas into a durable AI reply", asyn }); test("conversation stream materializes uploaded attachments into the run prompt", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-conversation-attachment-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-conversation-attachment-")); const runner = new ScriptedRunner(["Attachment received"]); const daemon = await startDaemonForTest(runner); try { @@ -3885,7 +3886,7 @@ test("conversation stream materializes uploaded attachments into the run prompt" }); test("channel message stream hides unbound task proposal blocks without creating approvals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-message-stream-proposal-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-message-stream-proposal-")); const runner = new ScriptedRunner([ `可以开始。\n{"title":"Stream proposal","prompt":"Implement streaming","assigneeTeammateId":"teammate_builtin_engineer","acceptanceCriteria":["reply streams"],"collaborationSteps":[{"teammateId":"teammate_builtin_engineer","action":"coordinated","summary":"Implement"}],"requiredTools":["read"],"riskSummary":"low"}`, ]); @@ -3927,7 +3928,7 @@ test("channel message stream hides unbound task proposal blocks without creating }); test("channel AI reply prompt carries projected segment context for short follow-ups", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-followup-context-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-reply-followup-context-")); const runner = new ScriptedRunner([ "雨下到第三天,城里的钟停了。修表匠阿岚把怀表放进钟芯,世界重新开始走动。", "四百字版本", @@ -3968,7 +3969,7 @@ test("channel AI reply prompt carries projected segment context for short follow }); test("channel AI reply injects Plan Mode instructions for plan threads", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-plan-mode-prompt-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-plan-mode-prompt-")); const runner = new ScriptedRunner(["1. Inspect first."]); const daemon = await startDaemonForTest(runner); try { @@ -4003,7 +4004,7 @@ test("channel AI reply injects Plan Mode instructions for plan threads", async ( }); test("channel no-tools AI reply retries once with compacted channel summary after context-length error", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-context-retry-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-reply-context-retry-")); const runner = new ScriptedRunner([ new Error("context_length exceeded by test provider"), "Retry reply", @@ -4065,7 +4066,7 @@ test("channel no-tools AI reply retries once with compacted channel summary afte }); test("channel hard summary uses the effective workspace provider before dispatching a reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-proactive-summary-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-proactive-summary-")); const runner = new SummaryAwareRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -4250,7 +4251,7 @@ test("channel hard summary uses the effective workspace provider before dispatch }); test("channel hard summary with no completed prefix records the limitation and blocks oversized input", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-no-summary-prefix-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-no-summary-prefix-")); const runner = new SummaryAwareRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -4301,7 +4302,7 @@ test("channel hard summary with no completed prefix records the limitation and b }); test("mentions_only creates a blocked Ling Agent reply when the channel has no runnable provider", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-mentions-only-route-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-mentions-only-route-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -4333,7 +4334,7 @@ test("mentions_only creates a blocked Ling Agent reply when the channel has no r }); test("default_teammate routes root messages through Ling Agent without proposals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-route-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-default-teammate-route-")); const runner = new ScriptedRunner([ `{"title":"Should not persist","prompt":"Create a task","acceptanceCriteria":["never"],"requiredTools":["read"],"riskSummary":"blocked by proposalMode"}Visible default reply`, "这个频道当前有多个 AI 队友,具体数量以频道成员列表为准。", @@ -4408,7 +4409,7 @@ test("default_teammate routes root messages through Ling Agent without proposals }); test("channel intent selector creates visible multi-replies for discussion-only all-teammate requests", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-visible-multi-reply-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-intent-visible-multi-reply-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -4483,7 +4484,7 @@ test("channel intent selector creates visible multi-replies for discussion-only }); test("channel intent selector creates workflow proposals for execution-shaped all-teammate requests", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-workflow-proposal-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-intent-workflow-proposal-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -4560,7 +4561,7 @@ test("channel intent selector creates workflow proposals for execution-shaped al }); test("multi-participant concrete file workflow preserves operation-derived tools", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-multi-participant-file-tools-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-multi-participant-file-tools-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -4646,7 +4647,7 @@ test("multi-participant concrete file workflow preserves operation-derived tools }); test("review-only participant uses an output packet while execution participant keeps Host evidence", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-review-only-participant-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-review-only-participant-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -4705,7 +4706,7 @@ test("review-only participant uses an output packet while execution participant }); test("workflow Host binding rejects a semantic substitute before Completion Gate", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-exact-command-boundary-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-exact-command-boundary-")); const runner = new MismatchedExactCommandRunner(); const daemon = await startDaemonForTest(runner, undefined, withRestrictedCommandReadiness({ channels: { intentSelector: true } })); @@ -4749,7 +4750,7 @@ test("workflow Host binding rejects a semantic substitute before Completion Gate }); test("channel intent selector does not assign specialists outside current channel", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-roster-boundary-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-intent-roster-boundary-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -4796,7 +4797,7 @@ test("channel intent selector does not assign specialists outside current channe }); test("channel intent selector uses attached AI council specialists without copying memberships", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-council-specialists-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-intent-council-specialists-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); const developmentCouncilId = "council_builtin_development"; @@ -4875,7 +4876,7 @@ test("channel intent selector uses attached AI council specialists without copyi }); test("channel intent selector asks one clarify question for underspecified work requests", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-clarify-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-intent-clarify-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -4933,7 +4934,7 @@ test("channel intent selector asks one clarify question for underspecified work }); test("channel intent selector auto-starts explicit direct workflow proposals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-direct-workflow-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-intent-direct-workflow-")); const runner = new WorkflowEvidenceRunner(); const daemon = await startDaemonForTest(runner, {}, { channels: { terminalReports: true } }); try { @@ -5010,7 +5011,7 @@ test("channel intent selector auto-starts explicit direct workflow proposals", a }); test("explicit response language overrides workspace default for workflow proposals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-language-override-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-intent-language-override-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -5050,7 +5051,7 @@ test("explicit response language overrides workspace default for workflow propos }); test("channel response language overrides follow-user inference for workflow proposals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-language-channel-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-intent-language-channel-")); const daemon = await startDaemonForTest(); try { const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { @@ -5078,7 +5079,7 @@ test("channel response language overrides follow-user inference for workflow pro }); test("single-step file creation becomes direct workflow with bounded write tools", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-single-step-file-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -5127,7 +5128,7 @@ test("single-step file creation becomes direct workflow with bounded write tools }); test("explicit command request becomes execute with bounded bash tools", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-command-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-single-command-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -5171,7 +5172,7 @@ test("explicit command request becomes execute with bounded bash tools", async ( }); test("explicit Ling Agent file request becomes direct workflow instead of discussion reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-mentioned-ling-file-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-mentioned-ling-file-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -5215,7 +5216,7 @@ test("explicit Ling Agent file request becomes direct workflow instead of discus }); test("workspace follow-up can create the single file named by the previous Ling Agent reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-followup-file-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-followup-file-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -5287,7 +5288,7 @@ test("workspace follow-up can create the single file named by the previous Ling }); test("thread follow-up can create the single file named by the previous Ling Agent reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-followup-file-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-thread-followup-file-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -5373,7 +5374,7 @@ test("thread follow-up can create the single file named by the previous Ling Age }); test("thread follow-up opens the recent HTML target with the dedicated Host capability", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-followup-browser-open-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-thread-followup-browser-open-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -5449,7 +5450,7 @@ test("thread follow-up opens the recent HTML target with the dedicated Host capa }); test("model-assisted thread follow-up opens recent target from typed operation kind", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-open-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-open-")); let previousReply; const runner = new ScriptedRunner([ () => JSON.stringify({ @@ -5539,7 +5540,7 @@ test("model-assisted thread follow-up opens recent target from typed operation k }); test("model-assisted thread follow-up writes the recent local target without phrase-rule matching", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-file-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-file-")); let previousReply; const content = "\n"; const daemon = await startRealPiChannelFixture(cwd, (request) => { @@ -5639,7 +5640,7 @@ test("model-assisted thread follow-up writes the recent local target without phr }); test("model-assisted create-and-run materializes ordered write and Node operations", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-create-run-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-model-create-run-")); const runner = new ScriptedRunner([ JSON.stringify({ action: "execute", @@ -5713,7 +5714,7 @@ test("model-assisted create-and-run materializes ordered write and Node operatio }); test("model-assisted multi-agent file verification binds an actual QA read when no command is supplied", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-multi-agent-file-verify-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-model-multi-agent-file-verify-")); const daemon = await startRealPiChannelFixture(cwd, (request) => { if (/channel intent selector/i.test(systemText(request))) return { text: JSON.stringify({ action: "execute", @@ -5796,7 +5797,7 @@ test("model-assisted multi-agent file verification binds an actual QA read when }); test("model-assisted natural-language Node work materializes executable evidence policy", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-node-executable-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-model-node-executable-")); const runner = new ScriptedRunner([ JSON.stringify({ action: "execute", @@ -5871,7 +5872,7 @@ test("partial existing proposal lineage becomes one durable blocker without part requiredTools: ["read"], }, ]) { - const cwd = await mkdtemp(resolve(tmpdir(), `ling-stack-channel-partial-proposal-${scenario.name}-`)); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), `ling-stack-channel-partial-proposal-${scenario.name}-`)); const store = createSqliteRunStore(cwd); try { const { channel, message } = createStoreChannelAndMessage(store, cwd, scenario.body); @@ -5924,7 +5925,7 @@ test("partial existing proposal lineage becomes one durable blocker without part }); test("partial existing proposal lineage is blocked consistently over JSON and SSE dispatch", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-partial-proposal-transport-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-partial-proposal-transport-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -6020,7 +6021,7 @@ test("partial existing proposal lineage is blocked consistently over JSON and SS }); test("explicit custom executable participant is preserved ahead of same-role fallback", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-participant-preservation-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-explicit-participant-preservation-")); const command = `node -e "process.stdout.write('proof:7319')"`; const systemPrompt = "PROMISEBOT_BASE_PROFILE_MARKER: return the configured participant behavior."; const appendSystemPrompt = "PROMISEBOT_APPEND_PROFILE_MARKER: preserve the delegated participant profile."; @@ -6183,7 +6184,7 @@ test("explicit custom executable participant is preserved ahead of same-role fal }); test("workflow-definition authority does not require a direct Channel membership row", async (t) => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-definition-authority-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-workflow-definition-authority-")); t.after(() => rm(cwd, { recursive: true, force: true })); const store = createSqliteRunStore(cwd); try { @@ -6229,7 +6230,7 @@ test("workflow-definition authority does not require a direct Channel membership }); test("proposal provider preflight blocks missing workspace default and freezes configured participant bindings", async (t) => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-provider-preflight-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-workflow-provider-preflight-")); t.after(() => rm(cwd, { recursive: true, force: true })); const store = createSqliteRunStore(cwd); try { @@ -6426,7 +6427,7 @@ test("proposal provider preflight blocks missing workspace default and freezes c }); test("explicit executable participant missing a required tool blocks without substitution", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-participant-tool-block-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-explicit-participant-tool-block-")); const command = `node -e "process.stdout.write('proof:7319')"`; const runner = new DelayedCommandEvidenceRunner(command); const stores = new Map(); @@ -6503,7 +6504,7 @@ test("explicit executable participant missing a required tool blocks without sub }); test("distinct explicit same-role participants remain separate workflow requirements", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-same-role-participants-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-explicit-same-role-participants-")); const runner = new DelayedFileCommandRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -6589,7 +6590,7 @@ test("distinct explicit same-role participants remain separate workflow requirem }); test("multi-participant executable request blocks when one explicit participant owns no required operation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-operation-assignment-block-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-explicit-operation-assignment-block-")); const command = `node -e "process.stdout.write('proof:7319')"`; const runner = new DelayedCommandEvidenceRunner(command); const stores = new Map(); @@ -6666,7 +6667,7 @@ test("multi-participant executable request blocks when one explicit participant }); test("single-participant execute acknowledgement stays non-terminal until callback publication settles the source turn", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-participant-convergence-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-single-participant-convergence-")); const command = `node -e "process.stdout.write('proof:7319')"`; const runner = new DelayedCommandEvidenceRunner(command); const stores = new Map(); @@ -6768,7 +6769,7 @@ test("single-participant execute acknowledgement stays non-terminal until callba }); test("model-assisted create-and-run blocks an execute candidate missing its required command", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-create-run-command-clarify-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-model-create-run-command-clarify-")); const runner = new ScriptedRunner([ JSON.stringify({ action: "execute", @@ -6815,7 +6816,7 @@ test("model-assisted create-and-run blocks an execute candidate missing its requ }); test("model-assisted thread follow-up blocks an execute candidate instead of creating empty recent target", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-empty-file-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-empty-file-")); let previousReply; const runner = new ScriptedRunner([ () => JSON.stringify({ @@ -6894,7 +6895,7 @@ test("model-assisted thread follow-up blocks an execute candidate instead of cre }); test("model-assisted local write request blocks an execute candidate when no target validates", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-followup-missing-target-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-model-followup-missing-target-")); const runner = new ScriptedRunner([ JSON.stringify({ action: "execute", @@ -6941,7 +6942,7 @@ test("model-assisted local write request blocks an execute candidate when no tar }); test("workspace follow-up clarifies when the previous Ling Agent reply names multiple files", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-followup-file-ambiguous-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-followup-file-ambiguous-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -7013,7 +7014,7 @@ test("workspace follow-up clarifies when the previous Ling Agent reply names mul }); test("thread follow-up clarifies when the previous Ling Agent reply names multiple files", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-followup-file-ambiguous-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-thread-followup-file-ambiguous-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -7087,7 +7088,7 @@ test("thread follow-up clarifies when the previous Ling Agent reply names multip }); test("text attachment preview can feed a direct README write workflow", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-readme-preview-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-attachment-readme-preview-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7130,7 +7131,7 @@ test("text attachment preview can feed a direct README write workflow", async () }); test("text attachment materialization cache hit avoids rereading attachment file", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-cache-hit-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-attachment-cache-hit-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -7200,7 +7201,7 @@ test("text attachment materialization cache hit avoids rereading attachment file }); test("attachment-dependent README write blocks when attachment content is not materialized", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-readme-blocked-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-attachment-readme-blocked-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7245,7 +7246,7 @@ test("attachment-dependent README write blocks when attachment content is not ma }); test("unreadable screenshot task creates Ling materialization clarify reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-screenshot-materialization-clarify-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-screenshot-materialization-clarify-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7285,7 +7286,7 @@ test("unreadable screenshot task creates Ling materialization clarify reply", as }); test("blocked link body request creates Ling materialization clarify reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-link-materialization-clarify-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-link-materialization-clarify-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7321,7 +7322,7 @@ test("blocked link body request creates Ling materialization clarify reply", asy }); test("Ling Agent fallback creates file proposal when no Docs teammate can run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-ling-agent-fallback-docs-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-ling-agent-fallback-docs-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7371,7 +7372,7 @@ test("Ling Agent fallback creates file proposal when no Docs teammate can run", }); test("Ling Agent fallback edits code proposal when no Engineer teammate can run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-ling-agent-fallback-engineer-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-ling-agent-fallback-engineer-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7416,7 +7417,7 @@ test("Ling Agent fallback edits code proposal when no Engineer teammate can run" }); test("single-step file creation blocks when channel lacks the required write tool", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-channel-readonly-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-single-step-file-channel-readonly-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7462,7 +7463,7 @@ test("single-step file creation blocks when channel lacks the required write too }); test("single-step file edit inherits target path from recent workflow context", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-continuation-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-single-step-file-continuation-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7510,7 +7511,7 @@ test("single-step file edit inherits target path from recent workflow context", }); test("single-step file edit normalizes Users path without leading slash", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-users-path-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-single-step-file-users-path-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7546,7 +7547,7 @@ test("single-step file edit normalizes Users path without leading slash", async }); test("file creation how-to question remains ordinary Ling chat intent", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-howto-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-single-step-howto-")); const daemon = await startDaemonForTest(); try { const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { @@ -7583,7 +7584,7 @@ test("file creation how-to question remains ordinary Ling chat intent", async () }); test("single-step test request becomes direct workflow with bounded bash tools", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-test-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-single-step-test-")); const daemon = await startDaemonForTest(); try { const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { @@ -7618,7 +7619,7 @@ test("single-step test request becomes direct workflow with bounded bash tools", }); test("high-risk push merge request auto-starts when required tools are not denied", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-high-risk-push-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-high-risk-push-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7658,7 +7659,7 @@ test("high-risk push merge request auto-starts when required tools are not denie }); test("high-risk thread follow-up creates a proposal without auto-starting", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-high-risk-push-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-thread-high-risk-push-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -7720,7 +7721,7 @@ test("high-risk thread follow-up creates a proposal without auto-starting", asyn }); test("high-risk push merge request blocks before run when required tool is denied", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-high-risk-push-deny-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-high-risk-push-deny-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -7760,7 +7761,7 @@ test("high-risk push merge request blocks before run when required tool is denie }); test("model-assisted channel intent no_reply falls through to ordinary Ling replies", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-model-no-reply-fallthrough-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-intent-model-no-reply-fallthrough-")); const runner = new ScriptedRunner([ JSON.stringify({ action: "no_reply", reason: "Not an executable task." }), "这个问题应该由 Ling Agent 回复。", @@ -7817,7 +7818,7 @@ test("model-assisted channel intent no_reply falls through to ordinary Ling repl }); test("model-assisted channel intent failure cannot become an ordinary reply or confirmable proposal", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-model-fallback-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-intent-model-fallback-")); const runner = new ScriptedRunner([ "not json", `普通频道回复。{"title":"Unbound proposal","prompt":"Start three agents","acceptanceCriteria":["done"],"requiredTools":["read"]}`, @@ -7871,7 +7872,7 @@ test("model-assisted channel intent failure cannot become an ordinary reply or c }); test("model execution proposal cannot declare command evidence authority", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-command-authority-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-model-command-authority-")); const runner = new ScriptedRunner([ JSON.stringify({ action: "execute", @@ -7926,7 +7927,7 @@ test("model execution proposal cannot declare command evidence authority", async }); test("issue #421 article continuation stays reply while an explicit README save executes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-issue-421-article-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-issue-421-article-")); // file_create needs an absent target; the generic channel test helper seeds a // README.md for its Git baseline, which a real exclusive create must reject. await ensureEmptyGitRepo(cwd); @@ -8045,7 +8046,7 @@ test("issue #421 article continuation stays reply while an explicit README save }); test("issue #421 unrelated snake-game request escapes an old target clarification", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-issue-421-segment-reset-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-issue-421-segment-reset-")); const runner = new ScriptedRunner([ JSON.stringify({ action: "clarify", @@ -8109,7 +8110,7 @@ test("issue #421 unrelated snake-game request escapes an old target clarificatio }); test("default_teammate route suppresses duplicate Ling Agent replies for the same source message", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-idempotent-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-default-teammate-idempotent-")); const now = "2026-06-09T00:00:00.000Z"; const store = createSqliteRunStore(cwd); try { @@ -8185,7 +8186,7 @@ test("default_teammate route suppresses duplicate Ling Agent replies for the sam }); test("default_teammate route treats channel branches and topic replies as separate surfaces", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-surface-route-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-surface-route-")); const now = "2026-06-25T00:00:00.000Z"; const store = createSqliteRunStore(cwd); try { @@ -8324,7 +8325,7 @@ test("default_teammate route treats channel branches and topic replies as separa }); test("Ling Agent replies to greetings, repeated greetings, punctuation, and acknowledgements", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-greeting-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-default-teammate-greeting-")); const runner = new ScriptedRunner([ "你好,我在。可以继续说你的问题。", "我在,继续说。", @@ -8399,7 +8400,7 @@ test("Ling Agent replies to greetings, repeated greetings, punctuation, and ackn }); test("default_teammate creates a blocked Ling Agent reply when the channel has no runnable provider", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-suppressed-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-default-teammate-suppressed-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -8436,7 +8437,7 @@ test("default_teammate creates a blocked Ling Agent reply when the channel has n }); test("explicit mentions keep reply text but cannot create an unbound proposal", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-route-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-explicit-route-")); const runner = new ScriptedRunner([ `Visible explicit reply{"title":"Explicit proposal","prompt":"Create explicit task","acceptanceCriteria":["ok"],"requiredTools":["read"],"riskSummary":"explicit"}`, ]); @@ -8475,7 +8476,7 @@ test("explicit mentions keep reply text but cannot create an unbound proposal", }); test("auto_route is rejected by channel create and update while the feature flag is disabled", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-auto-route-disabled-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-auto-route-disabled-")); let channelStore; const daemon = await startDaemonForTest(undefined, { storeFactory: (storeCwd) => { @@ -8530,7 +8531,7 @@ test("auto_route is rejected by channel create and update while the feature flag }); test("auto_route uses the model selector and records accepted route evidence", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-auto-route-model-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-auto-route-model-")); const stores = new Map(); const runner = new ScriptedRunner([ JSON.stringify({ @@ -8676,7 +8677,7 @@ test("auto_route uses the model selector and records accepted route evidence", a }); test("auto_route falls back to Ling Agent replies on selector failure", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-auto-route-fallback-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-auto-route-fallback-")); const runner = new ScriptedRunner(["not json", "Ling fallback reply 1"]); const daemon = await startDaemonForTest(runner, {}, { channels: { autoRoute: true } }); try { @@ -8752,7 +8753,7 @@ test("auto_route falls back to Ling Agent replies on selector failure", async () }); test("channel AI replies return pending message before runner finishes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-ai-reply-pending-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-ai-reply-pending-")); const runner = new DelayedRunner(); const daemon = await startDaemonForTest(runner); try { @@ -8794,7 +8795,7 @@ test("channel AI replies return pending message before runner finishes", async ( }); test("channel attachment upload validates file types and serves content safely", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachments-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-attachments-")); const daemon = await startDaemonForTest(); try { const channel = await createChannel(daemon.url, cwd); @@ -8835,7 +8836,7 @@ test("channel attachment upload validates file types and serves content safely", }); test("attachment-only channel turn reaches Ling Agent with metadata and preview", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-only-turn-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-attachment-only-turn-")); const runner = new ScriptedRunner(["看到了 brief.txt。你要我总结、写入文件,还是作为当前任务上下文?"]); const daemon = await startDaemonForTest(runner); try { @@ -8881,7 +8882,7 @@ test("attachment-only channel turn reaches Ling Agent with metadata and preview" }); test("channel asset references must stay within the same channel", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reference-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-reference-")); const daemon = await startDaemonForTest(); try { const first = await createChannel(daemon.url, cwd); @@ -8928,7 +8929,7 @@ test("channel asset references must stay within the same channel", async () => { }); test("channel collaboration protocol resolver API patches existing channel fields and reverts to defaults", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-protocol-settings-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-protocol-settings-")); const daemon = await startDaemonForTest(undefined, {}, { channels: { autoRoute: false } }); try { await patchJson(`${daemon.url}/workspace-settings?cwd=${encodeURIComponent(cwd)}`, { @@ -9011,7 +9012,7 @@ test("channel collaboration protocol resolver API patches existing channel field }); test("channel collaboration protocol drafts apply protocol-only overrides with source refs", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-protocol-drafts-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-protocol-drafts-")); const daemon = await startDaemonForTest(); try { const first = await createChannel(daemon.url, cwd); @@ -9122,7 +9123,7 @@ test("channel collaboration protocol drafts apply protocol-only overrides with s }); test("channel collaboration protocol endpoints persist state and reject cross-channel sources", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-protocol-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-protocol-api-")); const daemon = await startDaemonForTest(); try { const first = await createChannel(daemon.url, cwd); @@ -9212,7 +9213,7 @@ test("channel collaboration protocol endpoints persist state and reject cross-ch }); test("concurrent decision supersession accepts one successor, reuses exact retries, and rejects conflicts", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-decision-supersession-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-decision-supersession-api-")); const daemon = await startDaemonForTest(); try { const channel = await createChannel(daemon.url, cwd); @@ -9309,7 +9310,7 @@ test("concurrent decision supersession accepts one successor, reuses exact retri }); test("channel handoff workflows start steps, create tasks, and expose blockers", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflows-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflows-api-")); const daemon = await startDaemonForTest(); try { const first = await createChannel(daemon.url, cwd); @@ -9412,7 +9413,7 @@ test("channel handoff workflows start steps, create tasks, and expose blockers", }); test("manual workflow step start rejects a result after source context changes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-manual-workflow-stale-context-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-manual-workflow-stale-context-")); const runner = new DelayedRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -9506,7 +9507,7 @@ test("manual workflow step start rejects a result after source context changes", }); test("queued workflow runs keep private role memory out of durable snapshots", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-queued-workflow-memory-isolation-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-queued-workflow-memory-isolation-")); const runner = new AbortableDelayedRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -9627,7 +9628,7 @@ test("queued workflow runs keep private role memory out of durable snapshots", a }); test("workflow handoff intents validate, accept, and stay visible as workflow evidence", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-handoff-intents-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-handoff-intents-")); const stores = new Map(); const daemon = await startDaemonForTest(undefined, { storeFactory: (storeCwd) => { @@ -9706,7 +9707,7 @@ test("workflow handoff intents validate, accept, and stay visible as workflow ev }); test("workflow collaboration card endpoints derive compact product cards", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-collaboration-card-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-workflow-collaboration-card-")); const stores = new Map(); const daemon = await startDaemonForTest(undefined, { storeFactory: (storeCwd) => { @@ -9802,7 +9803,7 @@ test("workflow collaboration card endpoints derive compact product cards", async }); test("autoplan-lite workflow compiles durable steps and orchestrates them through the runner", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-orchestrator-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-orchestrator-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -9978,7 +9979,7 @@ test("autoplan-lite workflow compiles durable steps and orchestrates them throug }); test("workflow graph compiles into durable steps and emits standardized packets", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-graph-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-graph-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -10095,7 +10096,7 @@ test("workflow graph compiles into durable steps and emits standardized packets" for (const contextWindowTokens of [1800, 4096]) { test(`workflow dependency compaction preserves the SDK budget gate at ${contextWindowTokens} tokens`, async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-context-compaction-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-context-compaction-")); const runner = new class extends RecordingRunner { providerRequests = 0; @@ -10254,7 +10255,7 @@ test(`workflow dependency compaction preserves the SDK budget gate at ${contextW } test("workflow planning failure follows policy and blocks for user repair", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-planning-failure-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-planning-failure-")); const runner = new SelectiveFailingRunner(["Product Discovery"]); const daemon = await startDaemonForTest(runner); try { @@ -10284,7 +10285,7 @@ test("workflow planning failure follows policy and blocks for user repair", asyn }); test("workflow implementation failure follows policy and fails the workflow", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-implementation-failure-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-implementation-failure-")); const runner = new SelectiveFailingRunner(["Implementation"]); const daemon = await startDaemonForTest(runner); try { @@ -10316,7 +10317,7 @@ test("workflow implementation failure follows policy and fails the workflow", as }); test("workflow post-step gate pauses downstream steps until approval", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-post-gate-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-post-gate-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -10391,7 +10392,7 @@ test("workflow post-step gate pauses downstream steps until approval", async () }); test("workflow post-step gate resumes after daemon restart when approval was granted", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-post-gate-restart-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-post-gate-restart-")); const runner = new RecordingRunner(); const stores = new Map(); let daemon = await startDaemonForTest(runner, { @@ -10463,7 +10464,7 @@ test("workflow post-step gate resumes after daemon restart when approval was gra }); test("workflow run cancellation follows default policy and blocks for user", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-cancellation-policy-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-cancellation-policy-")); const runner = new DelayedRunner(); const daemon = await startDaemonForTest(runner); try { @@ -10504,7 +10505,7 @@ test("workflow run cancellation follows default policy and blocks for user", asy }); test("workflow approval denial follows onApprovalDenied policy and blocks", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-approval-denial-policy-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-approval-denial-policy-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -10546,7 +10547,7 @@ test("workflow approval denial follows onApprovalDenied policy and blocks", asyn }); test("workflow skipped optional step allows downstream steps to start", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-skip-optional-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-skip-optional-")); const runner = new SelectiveFailingRunner(["Product Review"]); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -10607,7 +10608,7 @@ test("workflow skipped optional step allows downstream steps to start", async () }); test("workflow orchestrator starts independent parallel branches within channel budget", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-parallel-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-parallel-")); const runner = new ParallelDelayedRunner(); let cleanupWorkflowId; const stores = new Map(); @@ -10688,7 +10689,7 @@ test("workflow orchestrator starts independent parallel branches within channel }); test("custom workflow template compiles deterministic steps and dependencies", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-custom-template-")); const daemon = await startDaemonForTest(); try { const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { @@ -10753,7 +10754,7 @@ test("custom workflow template compiles deterministic steps and dependencies", a }); test("workflow councilSlotKey resolves through attached AI council specialists", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-council-slot-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-council-slot-")); const daemon = await startDaemonForTest(); try { const channel = await createWorkflowTestChannel(daemon.url, cwd, "Council slot workflow"); @@ -10849,7 +10850,7 @@ test("workflow councilSlotKey resolves through attached AI council specialists", }); test("custom workflow template rejects cycles and unknown runners at compile time", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-invalid-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-custom-template-invalid-")); const daemon = await startDaemonForTest(); try { const channel = await createChannel(daemon.url, cwd); @@ -10894,7 +10895,7 @@ test("custom workflow template rejects cycles and unknown runners at compile tim }); test("custom workflow template unsafe autostart requires explicit confirmation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-autostart-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-custom-template-autostart-")); const daemon = await startDaemonForTest(); try { const channel = await createWorkflowTestChannel(daemon.url, cwd, "Unsafe custom template"); @@ -10937,7 +10938,7 @@ test("custom workflow template unsafe autostart requires explicit confirmation", }); test("custom workflow template parallel branches start within compiled budget", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-parallel-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-custom-template-parallel-")); const runner = new ParallelDelayedRunner(); let cleanupWorkflowId; const daemon = await startDaemonForTest(runner); @@ -11009,7 +11010,7 @@ test("custom workflow template parallel branches start within compiled budget", }); test("workflow orchestrator keeps serial behavior when smart scheduling max runs is one", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-serial-budget-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-serial-budget-")); const runner = new ParallelDelayedRunner(); let cleanupWorkflowId; const stores = new Map(); @@ -11081,7 +11082,7 @@ test("workflow orchestrator keeps serial behavior when smart scheduling max runs }); test("workflow smart scheduling keeps side-effectful branches serial", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-smart-write-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-smart-write-")); const runner = new ParallelDelayedRunner(); let cleanupWorkflowId; const stores = new Map(); @@ -11148,7 +11149,7 @@ test("workflow smart scheduling keeps side-effectful branches serial", async () }); test("workflow smart scheduling keeps unknown-tool branches serial", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-smart-unknown-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-smart-unknown-")); const runner = new ParallelDelayedRunner(); let cleanupWorkflowId; const stores = new Map(); @@ -11217,7 +11218,7 @@ test("workflow smart scheduling keeps unknown-tool branches serial", async () => }); test("workflow smart scheduling does not start sibling branches while approval is pending", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-smart-approval-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-smart-approval-")); const runner = new ParallelDelayedRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -11278,7 +11279,7 @@ test("workflow smart scheduling does not start sibling branches while approval i }); test("workflow dependencyMode any_succeeded can start a fan-in after mixed terminal branches", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-any-succeeded-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-any-succeeded-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -11349,7 +11350,7 @@ test("workflow dependencyMode any_succeeded can start a fan-in after mixed termi }); test("fan-in requireAllBranchesTerminal is a scheduling barrier even with any_succeeded", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-fanin-barrier-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-fanin-barrier-")); const runner = new ParallelDelayedRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -11458,7 +11459,7 @@ test("fan-in requireAllBranchesTerminal is a scheduling barrier even with any_su }); test("workflow fan-in creates summary artifact, memory candidate, selector decision, and evidence drilldown", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-fanin-evidence-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-fanin-evidence-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -11557,7 +11558,7 @@ test("workflow fan-in creates summary artifact, memory candidate, selector decis }); test("workflow cancel aborts active run, queued steps, and pending workflow approvals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-cancel-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-cancel-")); const runner = new DelayedRunner(); const daemon = await startDaemonForTest(runner); try { @@ -11592,7 +11593,7 @@ test("workflow cancel aborts active run, queued steps, and pending workflow appr }); test("workflow step retry starts a blocked step after configuration is repaired", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-retry-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workflow-retry-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -11636,7 +11637,7 @@ test("workflow step retry starts a blocked step after configuration is repaired" }); test("channel link preview blocks localhost targets instead of fetching them", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-links-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-links-")); const daemon = await startDaemonForTest(); try { const channel = await createChannel(daemon.url, cwd); @@ -11652,7 +11653,7 @@ test("channel link preview blocks localhost targets instead of fetching them", a }); test("AI teammate private workspace endpoints persist roster, setup, private messages, and activity", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ai-teammate-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-ai-teammate-api-")); const runner = new RecordingRunner(); let daemon = await startDaemonForTest(runner); try { @@ -11774,7 +11775,7 @@ test("AI teammate private workspace endpoints persist roster, setup, private mes }); test("AI teammate private reply waits for startup provider refresh instead of using stale timeout", async (t) => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-provider-startup-refresh-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-provider-startup-refresh-")); await ensureGitRepo(cwd); const relay = await startMockOpenAICompatibleServer(t); const runner = new RecordingRunner(); @@ -11830,7 +11831,7 @@ test("AI teammate private reply waits for startup provider refresh instead of us }); test("teammate direct messages default to latest page and load older history with cursor", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-message-pagination-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-message-pagination-")); const daemon = await startDaemonForTest(); try { const teammateId = "teammate_builtin_designer"; @@ -11859,7 +11860,7 @@ test("teammate direct messages default to latest page and load older history wit }); test("workspace settings API persists UI preferences in .ling-stack state", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workspace-settings-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-workspace-settings-api-")); const daemon = await startDaemonForTest(); try { const initial = await getJson(`${daemon.url}/workspace-settings?cwd=${encodeURIComponent(cwd)}`); @@ -11953,7 +11954,7 @@ test("workspace settings API persists UI preferences in .ling-stack state", asyn }); test("channel workspace API supports local and external path handoff", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workspace-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-workspace-api-")); const externalPath = resolve(cwd, "external-workspace"); const daemon = await startDaemonForTest(); try { @@ -12000,7 +12001,7 @@ test("channel workspace API supports local and external path handoff", async () }); test("channel creation creates an isolated managed worktree by default", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-auto-worktree-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-auto-worktree-")); const daemon = await startDaemonForTest(); try { const first = await createChannel(daemon.url, cwd); @@ -12058,7 +12059,7 @@ test("managed channel and direct worktrees use resolved workspace worktrees dire }); test("channel workspace cleanup removes clean managed worktrees and keeps the binding recreatable", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-worktree-cleanup-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-worktree-cleanup-")); const daemon = await startDaemonForTest(); try { const channel = await createChannel(daemon.url, cwd); @@ -12086,7 +12087,7 @@ test("channel workspace cleanup removes clean managed worktrees and keeps the bi }); test("channel workspace cleanup refuses dirty managed worktrees without blocking refresh", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-worktree-dirty-cleanup-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-worktree-dirty-cleanup-")); const daemon = await startDaemonForTest(); try { const channel = await createChannel(daemon.url, cwd); @@ -12115,7 +12116,7 @@ test("channel workspace cleanup refuses dirty managed worktrees without blocking }); test("direct teammate workspace cleanup removes clean managed worktrees and can recreate them", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-worktree-cleanup-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-worktree-cleanup-")); const daemon = await startDaemonForTest(); try { await ensureGitRepo(cwd); @@ -12141,7 +12142,7 @@ test("direct teammate workspace cleanup removes clean managed worktrees and can }); test("direct teammate workspace cleanup refuses dirty managed worktrees", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-worktree-dirty-cleanup-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-worktree-dirty-cleanup-")); const daemon = await startDaemonForTest(); try { await ensureGitRepo(cwd); @@ -12167,7 +12168,7 @@ test("direct teammate workspace cleanup refuses dirty managed worktrees", async }); test("channel creation uses local workspace binding when cwd is not a Git repository", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-worktree-nongit-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-worktree-nongit-")); const boundPath = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-bound-nongit-")); const daemon = await startDaemonForTest(); try { @@ -12221,7 +12222,7 @@ test("channel creation uses local workspace binding when cwd is not a Git reposi }); test("an empty Git repository uses Local automatically but keeps explicit worktree creation strict", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-worktree-empty-git-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-channel-worktree-empty-git-")); await ensureEmptyGitRepo(cwd); const daemon = await startDaemonForTest(); try { @@ -12349,7 +12350,7 @@ test("initial workspace selection falls back to Local only when Git isolation is }); test("direct workspace initialization serializes concurrent first replies into one setup", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-workspace-flight-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-workspace-flight-")); const store = createSqliteRunStore(cwd); try { const thread = store.getOrCreateTeammateDirectThread("teammate_builtin_designer", new Date().toISOString()); @@ -12405,7 +12406,7 @@ test("direct workspace initialization serializes concurrent first replies into o }); test("direct workspace handoff waits for initialization and remains the final binding", { timeout: ASYNC_SETTLE_TIMEOUT_MS }, async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-workspace-handoff-race-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-workspace-handoff-race-")); const managedPath = resolve(cwd, "managed-worktree"); await mkdir(managedPath, { recursive: true }); let store; @@ -12512,7 +12513,7 @@ test("direct workspace handoff waits for initialization and remains the final bi }); test("direct workspace initialization releases coordination after a factory error", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-workspace-flight-retry-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-workspace-flight-retry-")); const store = createSqliteRunStore(cwd); try { const thread = store.getOrCreateTeammateDirectThread("teammate_builtin_designer", new Date().toISOString()); @@ -12564,7 +12565,7 @@ test("direct workspace initialization releases coordination after a factory erro }); test("direct Local workspaces become unavailable when their directory disappears", async () => { - const storeCwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-workspace-local-store-")); + const storeCwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-workspace-local-store-")); const workspaceCwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-workspace-local-path-")); const store = createSqliteRunStore(storeCwd); try { @@ -12594,7 +12595,7 @@ test("direct Local workspaces become unavailable when their directory disappears }); test("a previously created direct worktree never downgrades to Local when recreation fails", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-workspace-owned-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-workspace-owned-")); const store = createSqliteRunStore(cwd); try { const thread = store.getOrCreateTeammateDirectThread("teammate_builtin_engineer", new Date().toISOString()); @@ -12644,7 +12645,7 @@ test("a previously created direct worktree never downgrades to Local when recrea }); test("a recovered managed direct worktree records ownership before any later disappearance", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-workspace-recovered-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-direct-workspace-recovered-")); await ensureGitRepo(cwd); const store = createSqliteRunStore(cwd); try { @@ -12679,7 +12680,7 @@ test("a recovered managed direct worktree records ownership before any later dis }); test("memory API scopes lexical recall and channel replies audit used memory", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-memory-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-memory-api-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner, {}, { defaultWorkspaceCwd: cwd }); try { @@ -12767,7 +12768,7 @@ test("memory API scopes lexical recall and channel replies audit used memory", a }); test("memory review policy exposes promotion inbox and gates candidate recall", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-memory-review-policy-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-memory-review-policy-")); const daemon = await startDaemonForTest(undefined, {}, { defaultWorkspaceCwd: cwd }); try { const channel = await createChannel(daemon.url, cwd); @@ -12894,7 +12895,7 @@ test("memory review policy exposes promotion inbox and gates candidate recall", }); test("memory health API returns read-only findings and respects scope filters", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-memory-health-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-memory-health-api-")); const daemon = await startDaemonForTest(); try { const channel = await createChannel(daemon.url, cwd); @@ -12957,7 +12958,7 @@ test("memory health API returns read-only findings and respects scope filters", }); test("memory consolidation jobs create audited candidates without silent activation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-memory-consolidation-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-memory-consolidation-")); const daemon = await startDaemonForTest(undefined, {}, { defaultWorkspaceCwd: cwd }); try { const channel = await createChannel(daemon.url, cwd); @@ -13003,7 +13004,7 @@ test("memory consolidation jobs create audited candidates without silent activat }); test("task run validates requestedSkillId and records the selected skill", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-skill-aware-run-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-skill-aware-run-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -13088,7 +13089,7 @@ test("task run validates requestedSkillId and records the selected skill", async }); test("task run records compiled skill context fallback plan on missing lazy reference failure", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-skill-runtime-fallback-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-skill-runtime-fallback-")); // A missing Host skill reference fails before model execution, not inside // the SDK provider transport, whose public errors are intentionally redacted. const runner = { runs: [], async runTask(options) { @@ -13191,7 +13192,7 @@ test("task run records compiled skill context fallback plan on missing lazy refe }); test("task run materializes linked channel attachments into runner prompt", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-task-attachment-run-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-task-attachment-run-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -13232,7 +13233,7 @@ test("task run materializes linked channel attachments into runner prompt", asyn }); test("skill suggestions are visible recommendations and do not mutate run selection", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-skill-suggestions-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-skill-suggestions-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -13292,7 +13293,7 @@ test("skill suggestions are visible recommendations and do not mutate run select }); test("teammate persona draft import returns warnings without mutating durable teammate", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-persona-draft-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-persona-draft-")); const daemon = await startDaemonForTest(); try { const teammateId = "teammate_builtin_engineer"; @@ -13313,7 +13314,7 @@ test("teammate persona draft import returns warnings without mutating durable te }); test("skill health API returns read-only registry findings", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-skill-health-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-skill-health-api-")); const daemon = await startDaemonForTest(); try { const missingCwd = await fetch(`${daemon.url}/skills/health`); @@ -13341,7 +13342,7 @@ test("skill health API returns read-only registry findings", async () => { }); test("skill source API returns source health and published snapshots", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-skill-source-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-skill-source-api-")); const stores = new Map(); const daemon = await startDaemonForTest(undefined, { storeFactory: (storeCwd) => { @@ -13438,7 +13439,7 @@ test("skill source API returns source health and published snapshots", async () }); test("gstack skill source sync previews and publishes selected snapshots only", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-skill-source-sync-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-skill-source-sync-api-")); const skillRoot = resolve(cwd, "skills"); const reviewSkill = resolve(skillRoot, "gstack-review"); await mkdir(resolve(reviewSkill, "agents"), { recursive: true }); @@ -13511,7 +13512,7 @@ test("gstack skill source sync previews and publishes selected snapshots only", }); test("gstack skill source sync requires confirmation before publishing new skills", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-skill-source-sync-new-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-skill-source-sync-new-api-")); const skillRoot = resolve(cwd, "skills"); const newSkill = resolve(skillRoot, "gstack-research-lab"); await mkdir(newSkill, { recursive: true }); @@ -13568,7 +13569,7 @@ test("gstack skill source sync requires confirmation before publishing new skill }); test("skill candidate API persists manual candidates and gates review approval", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-skill-candidate-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-skill-candidate-api-")); const daemon = await startDaemonForTest(); try { const payload = { @@ -13744,7 +13745,7 @@ test("skill candidate API persists manual candidates and gates review approval", }); test("procedure observation API routes structured observations before candidate creation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-procedure-observation-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-procedure-observation-api-")); const daemon = await startDaemonForTest(); try { const channel = await createChannel(daemon.url, cwd); @@ -13947,7 +13948,7 @@ test("procedure observation API routes structured observations before candidate }); test("procedure observation job API records explicit jobs idempotently before candidate creation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-procedure-observation-job-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-procedure-observation-job-api-")); const daemon = await startDaemonForTest(); try { const jobPayload = { @@ -14175,7 +14176,7 @@ test("procedure observation job API records explicit jobs idempotently before ca }); test("skill recording eligibility previews run evidence without creating candidates", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-skill-recording-eligibility-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-skill-recording-eligibility-")); const stores = new Map(); const daemon = await startDaemonForTest(undefined, { storeFactory: (storeCwd) => { @@ -14227,7 +14228,7 @@ test("skill recording eligibility previews run evidence without creating candida }); test("skill import draft publishes registry skills and teammate bindings use effective model", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-skill-import-api-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-skill-import-api-")); const daemon = await startDaemonForTest(); try { const initialSkills = await getJson(`${daemon.url}/skills?cwd=${encodeURIComponent(cwd)}`); @@ -14300,7 +14301,7 @@ test("skill import draft publishes registry skills and teammate bindings use eff }); test("teammate direct replies use a local workspace when cwd is not a Git repository", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-nongit-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-nongit-")); const runner = new RecordingRunner(); const stores = new Map(); const daemon = await startDaemonForTest(runner, { @@ -14369,7 +14370,7 @@ test("teammate direct replies use a local workspace when cwd is not a Git reposi }); test("teammate direct message stream emits accepted ready delta and done events", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-stream-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-stream-")); await ensureGitRepo(cwd); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); @@ -14418,7 +14419,7 @@ test("teammate direct message stream emits accepted ready delta and done events" }); test("teammate direct replies hide leading receipt echoes while preserving the answer", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-receipt-echo-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-receipt-echo-")); await ensureGitRepo(cwd); const runner = { runs: [], @@ -14464,7 +14465,7 @@ test("teammate direct replies hide leading receipt echoes while preserving the a }); test("teammate direct execution creates a supervised run and projects tool events", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-supervised-run-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-supervised-run-")); await ensureGitRepo(cwd); const runner = new DirectToolRunner(); const stores = new Map(); @@ -14536,7 +14537,7 @@ test("teammate direct execution creates a supervised run and projects tool event }); test("teammate direct execution waits for approval and resumes the same supervised run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-supervised-approval-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-supervised-approval-")); await ensureGitRepo(cwd); const runner = new DirectApprovalRunner(); const stores = new Map(); @@ -14598,7 +14599,7 @@ test("teammate direct execution waits for approval and resumes the same supervis }); test("teammate direct execution does not expose tools outside teammate policy", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-tool-boundary-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-tool-boundary-")); await ensureGitRepo(cwd); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); @@ -14635,7 +14636,7 @@ test("teammate direct execution does not expose tools outside teammate policy", }); test("teammate direct reply injects web search context before runner reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-web-search-context-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-web-search-context-")); await ensureGitRepo(cwd); const runner = new RecordingRunner(); const webSearchCalls = []; @@ -14741,7 +14742,7 @@ test("teammate direct reply injects web search context before runner reply", asy }); test("teammate direct web search context blocks unsupported providers without fetching", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-web-search-unsupported-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-web-search-unsupported-")); await ensureGitRepo(cwd); const runner = new RecordingRunner(); let fetched = false; @@ -14789,7 +14790,7 @@ test("teammate direct web search context blocks unsupported providers without fe }); test("teammate direct message rejects workspace file refs outside private workspace", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-file-ref-boundary-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-file-ref-boundary-")); await ensureGitRepo(cwd); const daemon = await startDaemonForTest(); try { @@ -14814,7 +14815,7 @@ test("teammate direct message rejects workspace file refs outside private worksp }); test("teammate direct reply materializes current text and docx attachments", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-attachment-materialization-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-attachment-materialization-")); await ensureGitRepo(cwd); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); @@ -14861,7 +14862,7 @@ test("teammate direct reply materializes current text and docx attachments", asy }); test("teammate direct reply uses private chat memory isolation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-memory-isolation-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-memory-isolation-")); const runner = new RecordingRunner(); const daemon = await startDaemonForTest(runner); try { @@ -14917,7 +14918,7 @@ test("teammate direct reply uses private chat memory isolation", async () => { }); test("teammate direct prompt anchors latest attachment message instead of old guidance context", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-attachment-anchor-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-attachment-anchor-")); await ensureGitRepo(cwd); const runner = new RecordingRunner(); const stores = []; @@ -14990,7 +14991,7 @@ test("teammate direct prompt anchors latest attachment message instead of old gu }); test("teammate direct reply cancel aborts active reply and marks pending message cancelled", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-cancel-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-cancel-")); await ensureGitRepo(cwd); const runner = new AbortableDelayedRunner(); const daemon = await startDaemonForTest(runner); @@ -15038,7 +15039,7 @@ test("teammate direct reply cancel aborts active reply and marks pending message }); test("teammate direct queued guidance can promote to immediate continuation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-promote-guidance-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-promote-guidance-")); await ensureGitRepo(cwd); const runner = new AbortableDelayedRunner(); const daemon = await startDaemonForTest(runner); @@ -15124,7 +15125,7 @@ test("teammate direct queued guidance can promote to immediate continuation", as }); test("teammate direct queued guidance starts automatically after source reply completes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-queued-guidance-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-queued-guidance-")); await ensureGitRepo(cwd); const runner = new AbortableDelayedRunner(); const daemon = await startDaemonForTest(runner); @@ -15175,7 +15176,7 @@ test("teammate direct queued guidance starts automatically after source reply co }); test("teammate direct queued guidance drains multiple queued items in thread FIFO order", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-queued-guidance-fifo-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-queued-guidance-fifo-")); await ensureGitRepo(cwd); const runner = new AbortableDelayedRunner(); const daemon = await startDaemonForTest(runner); @@ -15245,7 +15246,7 @@ test("teammate direct queued guidance drains multiple queued items in thread FIF }); test("private attachments avatar and hard delete stay scoped to teammate private data", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-private-attachments-hard-delete-")); + const cwd = await createSqliteWorkspaceFixture(resolve(tmpdir(), "ling-stack-private-attachments-hard-delete-")); const daemon = await startDaemonForTest(undefined, {}, { defaultWorkspaceCwd: cwd }); try { const channel = await createChannel(daemon.url, cwd); diff --git a/packages/daemon/test/recovery-discovery-prefix.test.ts b/packages/daemon/test/recovery-discovery-prefix.test.ts new file mode 100644 index 000000000..bd800e147 --- /dev/null +++ b/packages/daemon/test/recovery-discovery-prefix.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ListRunsOptions, RunStore } from "@ling-stack/core"; +import { readPreparedRunnerInput } from "../dist/features/runs/prepared-runner-input.js"; +import { isRecord } from "../dist/shared/validation.js"; +import { createRecoveryDiscoveryFixture } from "./test-support/recovery-discovery-fixture.mjs"; + +test("500 stale blockers retain durable ownership without growing thread metadata reads", async (t) => { + const fixture = await createRecoveryDiscoveryFixture(t, "prefix-cost"); + const valid = await fixture.seedRun("conversation", "a-valid"); + const { run: validRun, unit: validUnit } = valid; + assert.ok(validRun); + assert.ok(validUnit); + const validRunId = validRun.id; + const validUnitId = validUnit.id; + const listRuns = fixture.store.listRuns.bind(fixture.store); + let largestRead = 0; + let reads = 0; + t.mock.method(fixture.store, "listRuns", (options: ListRunsOptions = {}) => { + const runs = listRuns(options); + largestRead = Math.max(largestRead, runs.length); + reads += 1; + return runs; + }); + fixture.seedBlockedStalePrefix(valid); + assert.ok(reads > 0, "seeding must still exercise real thread metadata rebuilding"); + assert.equal(largestRead, 1, "one blocker's writes must not reread earlier blockers"); + + function assertPrefix(store: RunStore, targetStatus: "running" | "failed"): void { + const blockers = store.listWorkUnits({ kind: "conversation_run", status: "stale", limit: 500 }); + assert.equal(blockers.length, 500); + assert.ok(blockers.every(unit => unit.id !== validUnitId)); + const last = blockers[blockers.length - 1]; + assert.deepEqual(store.listWorkUnits({ kind: "conversation_run", status: "stale", limit: 500, + after: { createdAt: last.createdAt, id: last.id } }).map(unit => unit.id), [validUnitId]); + const threads = new Set(); + const turns = new Set(); + for (const unit of blockers) { + const run = store.getRun(`run-${unit.id.slice("unit-".length)}`); + assert.ok(run?.threadId); + const turn = store.getLingTurnForRun(run.id); + assert.ok(turn); + assert.ok(isRecord(run.requestSnapshot)); + assert.equal(run.status, "cancelled"); + assert.equal(run.workUnitId, unit.id); + assert.equal(unit.ownerId, turn.id); + assert.equal(run.requestSnapshot.threadId, run.threadId); + assert.equal(run.requestSnapshot.turnId, turn.id); + assert.equal(turn.threadId, run.threadId); + assert.equal(store.listWorkUnitAttempts(unit.id)[0]?.status, "lost"); + assert.notEqual(run.threadId, valid.thread.id); + assert.deepEqual(run.taskSnapshot, valid.execution.input.task); + assert.equal(readPreparedRunnerInput(run.requestSnapshot.runnerExecutionInput, + { cwd: run.cwd, task: valid.execution.input.task }).preparedInput.fingerprint, valid.execution.preparedInput.fingerprint); + threads.add(run.threadId); + turns.add(turn.id); + } + assert.equal(threads.size, 500); + assert.equal(turns.size, 500); + assert.equal(store.getRun(validRunId)?.status, targetStatus); + } + assertPrefix(fixture.store, "running"); + // RunStoreRegistry reconciles the unfinished target on reopen, not cancelled blockers. + assertPrefix(fixture.reopenAfterRestart().store, "failed"); +}); diff --git a/packages/daemon/test/sqlite-workspace-fixture.test.ts b/packages/daemon/test/sqlite-workspace-fixture.test.ts new file mode 100644 index 000000000..4d33b4974 --- /dev/null +++ b/packages/daemon/test/sqlite-workspace-fixture.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import test from "node:test"; +import { createSqliteRunStore, resolveRuntimePaths } from "../../core/dist/index.js"; +import { readOriginalOfficeDirectoryNativeRuntime } from "../dist/features/office-documents/office-local-directory-core-authority.js"; +import { createSqliteWorkspaceFixture } from "./test-support/sqlite-workspace-fixture.ts"; + +function databaseSnapshot(databasePath: string, normalizeSeedMetadata = false) { + const database = new DatabaseSync(databasePath, { readOnly: true }); + try { + assert.deepEqual(database.prepare("PRAGMA integrity_check").all().map(row => row.integrity_check), ["ok"]); + assert.deepEqual(database.prepare("PRAGMA foreign_key_check").all(), []); + const schema = database.prepare("SELECT type, name, tbl_name, sql FROM sqlite_schema ORDER BY type, name").all(); + const tables = schema.filter(row => row.type === "table").map(row => String(row.name)); + const contents = Object.fromEntries(tables.map(table => { + const rows = database.prepare(`SELECT * FROM "${table.replaceAll('"', '""')}"`).all(); + return [table, rows.map(row => { + if (normalizeSeedMetadata && table === "teammate_memory_scopes") { + assert.match(String(row.id), /^memory_scope_[0-9a-f-]+$/u); + row.id = "generated-memory-scope-id"; + } + return Object.fromEntries(Object.entries(row).filter(([key]) => + !normalizeSeedMetadata || !["created_at", "updated_at", "applied_at"].includes(key))); + })]; + })); + return { schema, contents }; + } finally { + database.close(); + } +} + +function assertSnapshotsEqual(actual: ReturnType, expected: ReturnType) { + assert.deepEqual(actual.schema.map(row => row.name), expected.schema.map(row => row.name)); + actual.schema.forEach((row, index) => assert.deepEqual(row, expected.schema[index], String(row.name))); + for (const [table, rows] of Object.entries(actual.contents)) { + assert.deepEqual(rows, expected.contents[table], table); + } +} + +test("workspace copies share only a closed template with the current cold schema and defaults", async (t) => { + const root = await mkdtemp(join(tmpdir(), "ling-stack-template-schema-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + const coldCwd = await mkdtemp(join(root, "cold-")); + const cold = createSqliteRunStore(coldCwd); + const coldDatabasePath = cold.paths.databasePath; + cold.close(); + const [first, second] = await Promise.all([ + createSqliteWorkspaceFixture(join(root, "first-")), + createSqliteWorkspaceFixture(join(root, "second-")), + ]); + const firstPaths = resolveRuntimePaths(first); + const secondPaths = resolveRuntimePaths(second); + assert.notEqual((await stat(firstPaths.databasePath)).ino, (await stat(secondPaths.databasePath)).ino); + assert.deepEqual(await readdir(firstPaths.rootDir), ["ling-stack.sqlite"]); + assert.deepEqual(await readdir(secondPaths.rootDir), ["ling-stack.sqlite"]); + // Exact migration timestamps also prove simultaneous callers reused one initialization. + assertSnapshotsEqual(databaseSnapshot(firstPaths.databasePath), databaseSnapshot(secondPaths.databasePath)); + assertSnapshotsEqual(databaseSnapshot(firstPaths.databasePath, true), databaseSnapshot(coldDatabasePath, true)); +}); + +test("workspace copies keep writes, paths, constraints and reopen state independent", async (t) => { + const root = await mkdtemp(join(tmpdir(), "ling-stack-template-isolation-test-")); + const stores: ReturnType[] = []; + t.after(async () => { + for (const store of stores) store.close(); + await rm(root, { recursive: true, force: true }); + }); + function openStore(cwd: string) { + const store = createSqliteRunStore(cwd); + stores.push(store); + return store; + } + const firstCwd = await createSqliteWorkspaceFixture(join(root, "first-")); + const secondCwd = await createSqliteWorkspaceFixture(join(root, "second-")); + const first = openStore(firstCwd); + const second = openStore(secondCwd); + assert.deepEqual(first.paths, resolveRuntimePaths(firstCwd)); + assert.deepEqual(second.paths, resolveRuntimePaths(secondCwd)); + assert.notDeepEqual(first.paths.identity, second.paths.identity); + const now = new Date().toISOString(); + const input = { id: "run_template_isolation", taskId: "task_template_isolation", + providerName: "test", providerModel: "test", taskSnapshot: {}, providerSnapshot: {}, requestSnapshot: {}, createdAt: now }; + const firstRun = first.createRun({ ...input, cwd: firstCwd }); + assert.equal(second.getRun(input.id), undefined); + const secondRun = second.createRun({ ...input, cwd: secondCwd }); + assert.notEqual(firstRun.eventLogPath, secondRun.eventLogPath); + const teammateId = "teammate_builtin_engineer"; + const originalScopes = second.listTeammateMemoryScopes(teammateId); + const changedScopes = first.replaceTeammateMemoryScopes(teammateId, [], now); + assert.notDeepEqual(changedScopes.map(scope => scope.id), originalScopes.map(scope => scope.id)); + assert.deepEqual(second.listTeammateMemoryScopes(teammateId), originalScopes); + assert.throws(() => first.createWorkUnit({ workspaceId: "workspace_missing", kind: "conversation_run", + ownerType: "run", ownerId: input.id, idempotencyKey: "missing-workspace", createdByActor: "system", request: {}, now }), + /FOREIGN KEY constraint failed/u); + assert.deepEqual(first.listWorkUnits(), []); + first.close(); + const reopened = openStore(firstCwd); + assert.equal(reopened.getRun(input.id)?.cwd, firstCwd); + assert.deepEqual(reopened.listTeammateMemoryScopes(teammateId), changedScopes); + assert.equal(second.getRun(input.id)?.cwd, secondCwd); + const thirdCwd = await createSqliteWorkspaceFixture(join(root, "third-")); + const third = openStore(thirdCwd); + assert.deepEqual(third.listRuns(), []); + assert.deepEqual(third.listWorkUnits(), []); + for (const store of [second, reopened, third]) { + assert.throws(() => readOriginalOfficeDirectoryNativeRuntime(store), /office_local_directory_core_composition_invalid/u); + } +}); diff --git a/packages/daemon/test/test-support/recovery-discovery-fixture.mjs b/packages/daemon/test/test-support/recovery-discovery-fixture.mjs index 88f8280f3..6ba115139 100644 --- a/packages/daemon/test/test-support/recovery-discovery-fixture.mjs +++ b/packages/daemon/test/test-support/recovery-discovery-fixture.mjs @@ -17,6 +17,7 @@ import { createDetachedRunExecutionSink, executeExistingRunContinuation } from " import { createRunnerRegistry } from "../../dist/runtime/runners.js"; import { RunStoreRegistry } from "../../dist/runtime/store-registry.js"; import { RuntimeSupervisor } from "../../dist/runtime/supervisor.js"; +import { seedBlockedStalePrefix as seedStalePrefix } from "./recovery-discovery-prefix.ts"; export const DISCOVERY_CREATED_AT = "2026-10-01T00:00:00.000Z"; export const DISCOVERY_DUE_AT = "2026-10-01T01:00:00.000Z"; @@ -129,19 +130,7 @@ export async function createRecoveryDiscoveryFixture(t, suffix, options = {}) { } function seedBlockedStalePrefix(seeded, count = 500) { - for (let index = 0; index < count; index += 1) { - const id = `z-blocked-${seeded.unit.kind}-${String(index).padStart(4, "0")}`; - const unit = store.createWorkUnit({ id: `unit-${id}`, workspaceId: "workspace_default", kind: seeded.unit.kind, - ownerType: seeded.unit.ownerType, ownerId: seeded.unit.ownerId, channelId: seeded.unit.channelId, - taskId: seeded.unit.taskId, teammateId: seeded.unit.teammateId, runnerId: "pi", effectiveRunnerId: "pi", - providerPresetId: preset.id, idempotencyKey: id, status: "stale", request: {}, createdByActor: "system", - now: seeded.unit.createdAt }); - store.createWorkUnitAttempt({ id: `attempt-${id}`, workUnitId: unit.id, attemptNumber: 1, - runtimeInstanceId: "runtime-before-restart", runnerId: "pi", status: "lost", now: seeded.unit.createdAt }); - const run = store.createRun({ ...seeded.run, id: `run-${id}`, workUnitId: unit.id, createdAt: seeded.unit.createdAt }); - store.linkRunToWorkUnit(run.id, unit.id, seeded.unit.createdAt); - store.markRunCancelled(run.id, seeded.unit.createdAt); - } + seedStalePrefix(store, preset.id, seeded, count); } function blockWithApproval(run) { diff --git a/packages/daemon/test/test-support/recovery-discovery-prefix.ts b/packages/daemon/test/test-support/recovery-discovery-prefix.ts new file mode 100644 index 000000000..e6d16709b --- /dev/null +++ b/packages/daemon/test/test-support/recovery-discovery-prefix.ts @@ -0,0 +1,36 @@ +import type { LingThread, RunSession, RunStore, WorkUnit } from "@ling-stack/core"; +import { isRecord } from "../../dist/shared/validation.js"; + +interface DiscoverySeed { + run: RunSession; + unit: WorkUnit; + thread: LingThread; +} + +export function seedBlockedStalePrefix(store: RunStore, providerPresetId: string, seeded: DiscoverySeed, count = 500): void { + if (!isRecord(seeded.run.requestSnapshot)) throw new Error("Discovery fixture requires a frozen request snapshot"); + for (let index = 0; index < count; index += 1) { + const id = `z-blocked-${seeded.unit.kind}-${String(index).padStart(4, "0")}`; + // Create/cancel rebuild thread metadata. A private thread keeps each read + // bounded to one Run instead of repeatedly reading the growing prefix. + const thread = store.createLingThread({ kind: seeded.thread.kind, channelId: seeded.thread.channelId, + taskId: seeded.thread.taskId, teammateId: seeded.thread.teammateId, title: id, + objective: seeded.thread.objective, conversationMode: seeded.thread.conversationMode }, seeded.unit.createdAt); + const turn = store.createLingTurn({ threadId: thread.id, kind: "initial_request", title: id, + userInstruction: seeded.thread.objective, channelId: seeded.unit.channelId, taskId: seeded.unit.taskId, + createdByActor: "local-user" }, seeded.unit.createdAt); + const unit = store.createWorkUnit({ id: `unit-${id}`, workspaceId: "workspace_default", kind: seeded.unit.kind, + ownerType: seeded.unit.ownerType, ownerId: seeded.unit.ownerType === "task" ? seeded.unit.ownerId : turn.id, + channelId: seeded.unit.channelId, taskId: seeded.unit.taskId, teammateId: seeded.unit.teammateId, + runnerId: "pi", effectiveRunnerId: "pi", providerPresetId, idempotencyKey: id, status: "stale", + request: {}, createdByActor: "system", now: seeded.unit.createdAt }); + store.createWorkUnitAttempt({ id: `attempt-${id}`, workUnitId: unit.id, attemptNumber: 1, + runtimeInstanceId: "runtime-before-restart", runnerId: "pi", status: "lost", now: seeded.unit.createdAt }); + const run = store.createRun({ ...seeded.run, id: `run-${id}`, workUnitId: unit.id, + threadId: thread.id, turnId: turn.id, turnRunRole: "primary", + requestSnapshot: { ...seeded.run.requestSnapshot, threadId: thread.id, turnId: turn.id }, + createdAt: seeded.unit.createdAt }); + store.linkRunToWorkUnit(run.id, unit.id, seeded.unit.createdAt); + store.markRunCancelled(run.id, seeded.unit.createdAt); + } +} diff --git a/packages/daemon/test/test-support/sqlite-workspace-fixture.ts b/packages/daemon/test/test-support/sqlite-workspace-fixture.ts new file mode 100644 index 000000000..d4fddcb57 --- /dev/null +++ b/packages/daemon/test/test-support/sqlite-workspace-fixture.ts @@ -0,0 +1,50 @@ +import { constants } from "node:fs"; +import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after } from "node:test"; +import { createSqliteRunStore, resolveRuntimePaths } from "../../../core/dist/index.js"; + +interface EmptyStoreTemplate { + readonly root: string; + readonly databasePath: string; +} + +let template: Promise | undefined; + +after(async () => { + const initialized = await template?.catch(() => undefined); + if (initialized) await rm(initialized.root, { recursive: true, force: true }); +}); + +async function createEmptyStoreTemplate(): Promise { + const root = await mkdtemp(join(tmpdir(), "ling-stack-empty-store-template-")); + try { + const store = createSqliteRunStore(root); + try { + return { root, databasePath: store.paths.databasePath }; + } finally { + // Close the only connection before copying, including SQLite's WAL checkpoint. + store.close(); + } + } catch (error) { + await rm(root, { recursive: true, force: true }); + throw error; + } +} + +/** Opt-in database fixture, not a Store factory: runtime construction stays real. */ +export async function createSqliteWorkspaceFixture(prefix: string): Promise { + const cwd = await mkdtemp(prefix); + try { + template ??= createEmptyStoreTemplate(); + const initialized = await template; + const paths = resolveRuntimePaths(cwd); + await mkdir(paths.rootDir, { recursive: true }); + await copyFile(initialized.databasePath, paths.databasePath, constants.COPYFILE_EXCL); + return cwd; + } catch (error) { + await rm(cwd, { recursive: true, force: true }); + throw error; + } +} diff --git a/packages/daemon/tsconfig.test.json b/packages/daemon/tsconfig.test.json new file mode 100644 index 000000000..4a3227601 --- /dev/null +++ b/packages/daemon/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "allowJs": true, + "checkJs": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["test/**/*.ts"] +} diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 130e80929..37c8a23b0 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -1,22 +1,86 @@ # CI Test Execution The existing quality check remains the required merge gate. Ready PRs run the -selected package graph; Draft policy, dependency checks, Web checks and native -platform acceptance are unchanged. +selected package graph. Draft previews retain the common fast lane; promotion, +`full-ci`, manual and scheduled runs retain their complete execution policy. + +## Scope And Required Checks + +Classification uses the entire PR diff from its merge-base, never just the last +commit. Rename detection is disabled so both old and new paths contribute. +Mixed changes union their affected packages and required lanes. + +| Changed inputs | Selected product checks | +| --- | --- | +| Docs, nested READMEs, PR/Issue templates, CODEOWNERS, FUNDING | None; common checks and the required quality gate still run | +| Allowlisted Markdown-link and public-repository checks | Common fast checks only | +| Package tests and test support | Owning package; shared test-support importers are included | +| Package source or local build/typecheck configuration | Owning package and transitive consumers | +| Existing package manifest, with verified stable identity and unchanged complete script contents | Owner and consumers from the old/current graph, plus dependency policy | +| Dependency-security regression tests | Owning package plus dependency policy | +| Root manifest or TypeScript/build configuration, lockfile, workspace configuration, patches, CI workflows/scripts, unknown paths | Full regression | +| New, deleted, renamed, or unverifiable package identity, or changed scripts | Full regression | +| `full-ci`, manual dispatch, daily schedule | Full regression | + +Package manifests are compared with the base checkout before narrowing. The +directory, name, test/typecheck entry presence and complete scripts fingerprint +must match. Missing baseline identity or any added, removed or changed script +forces full regression. Package-local tsconfig/build configuration selects the +owner and consumers; equivalent root configuration remains full scope. Changed +manifests propagate consumers over the union of old and current dependency edges. +Web's catalog importer is an explicit consumer because it reads Web source, +AST helpers and tsconfig without declaring a workspace dependency. The real +manifest graph also includes CLI's dependency on Web. Selecting those consumers +does not select core/daemon test shards just because a build needs their outputs. + +Dependency policy runs for dependency inputs and its own regression tests, not +for every source/test edit. Typecheck and Linux unit jobs run only for nonempty +selections. Core test-only changes have four core entries and no empty packages job; +daemon-only test changes have no Linux unit entry. Daemon shards and Linux/macOS +native contracts remain coupled to daemon selection. A source change can select +additional consumers, so daemon source may still need the CLI unit entry. + +The aggregate validates explicit dependency/typecheck/unit/daemon selectors. +Selected checks must succeed; only genuinely unselected checks may be skipped. +Failure, cancellation, missing or malformed results never pass as a skip. Docs +must select no product lanes, and full scope must select all lanes. No workflow +path filter suppresses the required aggregate or complete-tier history scan. + +Other workflows retain their existing boundaries: installer/model-cache +contracts use path filters covering their actual imports, installer assets and +dependency inputs; translation automation uses catalog paths; desktop +prereleases require manual dispatch or an explicit label; catalog syncs are +scheduled/manual; `full-ci` label handling only dispatches a complete run. This +audit does not turn a repository merge into a desktop release or deployment. +All workflow Node setup steps use the product's validated runtime floor. + +The daily schedule is a full Linux/macOS regression at 03:17 UTC (11:17 +Asia/Shanghai), even without new commits. Daily success-evidence deduplication +and a weekly-only macOS tier are not implemented; timing caches do not skip tests. + +Selected non-build package tasks fail before execution if any requested package +or required script is missing or blank. Only build closures permit packages +without a build artifact to omit a build script; validation never silently skips +a requested package via `--if-present`. ## Test Lanes - Core: four Linux shards, two serial file workers per shard. These jobs are included in the existing `unit-tests` matrix only when core is selected. -- Other packages and Web checks: one Linux matrix entry, excluding core and - daemon because their complete discovery runs in their own entries. -- Daemon: four macOS shards, two serial file workers per shard. The native Kimi - and delegation lifecycle files run exclusively before other files on their - assigned runner. +- Other packages and Web checks: one Linux matrix entry only when at least one + such package is selected, excluding core and daemon because their complete + discovery runs in their own entries. +- Daemon: four `macos-26` shards, two serial file workers per shard. The Kimi Host + read/terminal, delegation production-tool-surface and channel-workspace files + run serially in an exclusive phase before other files on their assigned runner. - Linux restricted-command contracts and real macOS native smoke remain separate mandatory lanes. No daemon files have been moved to Linux or removed. -Every file gets a private runtime home/workspace and a POSIX process group. +Core and daemon discovery includes top-level `test/*.test.mjs` and +`test/*.test.ts`; daemon's dependency-security test stays in its separate policy +lane. Each shard verifies fresh discovery against the shared plan. +Every file gets a private runtime home/workspace and a POSIX process group, +and each file process runs with `--test-concurrency=1`. An assertion failure does not suppress later files. A file exceeding five minutes fails and its group is terminated; it is not retried or forced green. The two known high-cost daemon integration fixtures, @@ -42,13 +106,64 @@ that same plan and verifies it against fresh discovery. Unknown/new files fall back to source-size/category weights; invalid caches never reduce coverage. Measured timings influence both outer shards and inner worker placement, not test deadlines or platform selection. +Outer placement counts exclusive files against all workers they occupy, so a +serial integration file is not costed as if it could overlap an ordinary file. +Within a shard, ordinary files form one longest-estimated-first queue. An idle +worker takes the next file instead of waiting for a fixed worker bucket to drain. +The exclusive phase still finishes before that shared queue starts; no file can +move between the immutable outer shards during execution. `ci-test-report-core-*` and `ci-test-report-daemon-*` contain JSON results and per-file stdout/stderr logs. The JSON is updated before each file starts and after it finishes, so interrupted runs retain the last active file. Timeouts, cancellations, launch errors and log/cleanup failures fail the job. The aggregate requires every planned file to have passed exactly once before saving new timing -hints. Only successful default-branch runs publish timing caches. +hints. Only test-complete default-branch runs publish timing caches. Report +validation and cache publication still run after an unrelated policy lane fails; +that failure continues to fail the required quality gate. Missing, duplicate, +failed or cancelled test reports never produce a new baseline, and PRs cannot +publish timing hints. + +Recovery-discovery fixtures retain the real 500-row paging boundary and SQLite +persistence. Stale blockers use separate threads and turns so normal metadata +rebuilds read one Run instead of repeatedly reading a growing shared-thread +history. The prefix regression checks that read bound, ownership, frozen input +and store reopen; the existing fair-scan cases still exercise real continuation +execution. Scheduled-prefix preparation and production storage remain unchanged. + +Channel-workspace and approval-policy integration tests opt in to +`test/test-support/sqlite-workspace-fixture.ts` for ordinary workspace directories. +Each test process creates one empty database through the current real Store and +migrations, closes its only connection, then copies that database into independent +temporary directories. Copies are exclusive files, never hard links or shared +Store objects. Normal constructors still check migrations, configure SQLite and +establish daemon-owned runtime capabilities. The template is deleted at file +teardown, is never committed or reused across processes, and contains no test Runs. +Only the main database is copied, not migration recovery manifests or backups; +migration/recovery tests must continue to use cold fixtures. External asset paths, +pure Git probes and custom storage-location fixtures keep their original setup. +The regression checks the complete schema and default rows against a cold Store +(normalizing initialization timestamps and generated default memory-scope IDs), +independent writes and scope changes, destination paths, reopen persistence, +foreign-key enforcement and rejection of daemon-only authority on ordinary fixture +Stores. It also checks distinct inodes; a hard-link mutation probe must fail. + +On 2026-11-03, a local macOS comparison used Node.js v22.22.1 and the same isolated +file runner with `concurrency: 1`. Only the empty-database fixture change differed; +other runner changes stayed constant. One before/after pair measured: + +| Integration file | Tests | Before (s) | After (s) | Reduction | +| --- | ---: | ---: | ---: | ---: | +| `channel-workspace.test.mjs` | 226 | 198.3 | 144.2 | 27.3% | +| `approval-policy-run.test.mjs` | 103 | 132.2 | 67.8 | 48.7% | +| Combined, serial | 329 | 330.5 | 212.0 | 35.8% | + +Both unchanged integration test lists passed with no skips. The optimized run +also passed the two new fixture regressions in 3.5 seconds, for 331 passing tests. +These are local file timings from a single pair, not a hosted-CI latency claim or +a statistical benchmark; full CI setup, shard contention and total job time still +need hosted-runner measurement. This documentation sync does not establish that +the current changes have passed complete hosted CI. Generate a local plan without running tests: @@ -81,9 +196,19 @@ must retain complete history; the scanner cannot reconstruct a shallow clone. ## Validation ```sh -node --test scripts/ci/*.test.mjs +corepack pnpm typecheck:ci-tests +node --test --test-concurrency=1 scripts/ci/*.test.mjs scripts/ci/*.test.ts +corepack pnpm --filter @ling-stack/daemon typecheck:ci ``` +CI-tool tests include both `.test.mjs` and `.test.ts` and run directly on the +pinned Node.js runtime in the common lane. The root `typecheck` command and the +selected CI typecheck lane run `typecheck:ci-tests`, whose strict, no-emit +`scripts/ci/tsconfig.test.json` includes `*.test.ts`; runtime type stripping alone +is not a typecheck. Docs scope and Draft preview do not run that typecheck lane. +Daemon package commands and both shard discovery paths also include `.test.ts`; +the owning `typecheck:ci` validates its new tests and imported TypeScript helpers. + Local fixture and package checks are not evidence of Linux/macOS hosted-runner latency. Compare queue, setup, test-file and total durations after the next authorized complete CI run before claiming a wall-clock improvement. diff --git a/scripts/ci/auxiliary-workflows.test.mjs b/scripts/ci/auxiliary-workflows.test.mjs new file mode 100644 index 000000000..78622da5c --- /dev/null +++ b/scripts/ci/auxiliary-workflows.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { posix } from "node:path"; +import test from "node:test"; + +const root = new URL("../../", import.meta.url); +const installerInputs = [ + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "apps/desktop/package.json", + "apps/desktop/build/installer.nsh", + "apps/desktop/build/icon.ico", + "scripts/release/verify-nsis-plugins.mjs", + "scripts/release/nsis-extractor.mjs", + "scripts/ci/desktop-bun-entry.test.mjs", + "scripts/gstack/packaged-browse-build.mjs", + "apps/desktop/tests/distribution-nsis-native.test.mjs", + "apps/desktop/scripts/distribution/installer-builder.mjs", + "apps/desktop/scripts/distribution/LingShell.nsh", + "apps/desktop/scripts/distribution/vendor-notices/nsis-plugins/sources.json", + ".github/workflows/installer-contracts.yml", +]; +const modelInputs = [ + "scripts/gstack/testsavant-model-cache.mjs", + "scripts/gstack/testsavant-model-cache.test.mjs", + "scripts/gstack/testsavant-model-state.test.mjs", + "scripts/gstack/testsavant-model-pin.json", + "scripts/gstack/testsavant-gs-client.mjs", + "scripts/gstack/testsavant-client.mjs", + ".github/workflows/model-cache-contracts.yml", +]; + +function pullRequestPaths(workflow) { + const block = /^on:\n pull_request:\n paths:\n((?: - [^\n]+\n)+) workflow_dispatch:/mu.exec(workflow); + assert.ok(block, "native workflows must retain a PR path allowlist and manual dispatch"); + return [...block[1].matchAll(/^ - (\S+)$/gmu)].map(match => match[1]); +} + +for (const [name, patterns, positive, negative] of [ + ["installer-contracts", installerInputs, installerInputs, [ + "apps/desktop/src/main.ts", + "apps/desktop/build/icon.icns", + "apps/desktop/build/entitlements.cua-driver.plist", + "apps/desktop/scripts/cua-driver-signing/after-pack.mjs", + "apps/desktop/tests/other.test.mjs", + "scripts/gstack/testsavant-client.mjs", + ".github/workflows/model-cache-contracts.yml", + ]], + ["model-cache-contracts", [ + "scripts/gstack/testsavant-model-*.mjs", + "scripts/gstack/testsavant-model-pin.json", + "scripts/gstack/testsavant-gs-client.mjs", + "scripts/gstack/testsavant-client.mjs", + ".github/workflows/model-cache-contracts.yml", + ], modelInputs, [ + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "apps/desktop/package.json", + "scripts/gstack/testsavant-model-cache.ts", + "scripts/gstack/testsavant-model-other.json", + "scripts/gstack/testsavant-model-fixtures/worker.mjs", + "scripts/gstack/testsavant-client.test.mjs", + "scripts/gstack/packaged-browse-build.mjs", + ".github/workflows/installer-contracts.yml", + ]], +]) { + const workflow = readFileSync(new URL(`.github/workflows/${name}.yml`, root), "utf8"); + const paths = pullRequestPaths(workflow); + const selected = path => paths.some(pattern => posix.matchesGlob(path, pattern)); + + test(`${name} watches only its native contract inputs`, () => { + assert.deepEqual([...paths].sort(), [...patterns].sort()); + for (const path of positive) assert.equal(selected(path), true, `must select ${path}`); + assert.match(workflow, /runs-on: windows-2025/u); + }); + + test(`${name} excludes documentation and unrelated changes`, () => { + for (const path of [ + "README.md", "docs/architecture.md", "apps/desktop/README.md", + "scripts/gstack/testsavant-model-cache.md", "scripts/ci/README.md", + "apps/web/src/App.tsx", "packages/daemon/src/index.ts", ...negative, + ]) assert.equal(selected(path), false, `must not select ${path}`); + }); +} diff --git a/scripts/ci/classify-scope.mjs b/scripts/ci/classify-scope.mjs index 4ac83b556..0bf50d0b3 100644 --- a/scripts/ci/classify-scope.mjs +++ b/scripts/ci/classify-scope.mjs @@ -24,10 +24,16 @@ const MAINTENANCE_CHECK_FILES = new Set([ "scripts/check-public-repo-safety.test.mjs", ]); +const DEPENDENCY_CHECK_FILES = new Set([ + "packages/daemon/test/dependency-security.test.mjs", + "apps/mobile/test/dependency-security-patches.test.mjs", + "apps/mobile/test/dependency-security-tooling.test.mjs", +]); + const FORCE_FULL_PATTERNS = [ /^(?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)$/u, /^(?:\.github|scripts|patches)(?:\/|$)/u, - /(?:^|\/)(?:package\.json|tsconfig(?:\.[^/]+)?\.json|vite\.config\.[^/]+|webpack\.config\.[^/]+|eslint\.config\.[^/]+|\.eslintrc(?:\.[^/]+)?|\.prettierrc(?:\.[^/]+)?)$/u, + /^(?:tsconfig(?:\.[^/]+)?\.json|vite\.config\.[^/]+|webpack\.config\.[^/]+|eslint\.config\.[^/]+|\.eslintrc(?:\.[^/]+)?|\.prettierrc(?:\.[^/]+)?)$/u, /(?:^|\/)(?:Dockerfile|docker-compose[^/]*|Makefile)$/u, ]; @@ -35,6 +41,9 @@ const TEST_PATH_PATTERN = /(?:^|\/)(?:test|tests|__tests__|e2e)(?:\/|$)|(?:^|\/) const TEST_SUPPORT_PATTERN = /(?:fixture|fixtures|helper|helpers|support|setup|util|utils)/iu; const SUPPORTED_TEST_PACKAGE_PREFIXES = ["packages/"]; const SUPPORTED_TEST_PACKAGE_DIRECTORIES = new Set(["apps/web", "apps/chrome-extension", "apps/mobile"]); +// The catalog importer reads Web's AST helper, locale sources and tsconfig +// without a manifest dependency. Keep that validation consumer in the graph. +const VALIDATION_CONSUMERS = new Map([["@ling-stack/web", ["@ling-stack/localization"]]]); /** * Classify a pull request by the narrowest safe CI scope. @@ -42,7 +51,7 @@ const SUPPORTED_TEST_PACKAGE_DIRECTORIES = new Set(["apps/web", "apps/chrome-ext * The classifier is intentionally conservative: a path that is not recognized * as documentation, allowlisted maintenance, or a workspace package escalates * to the full CI lane. - * @param {{files: string[], eventName?: string, forceFull?: boolean, packageEntries?: PackageEntry[]}} input + * @param {{files: string[], eventName?: string, forceFull?: boolean, packageEntries?: PackageEntry[], basePackageEntries?: PackageEntry[]}} input * @returns {CiScope} */ export function classifyChangedFiles(input) { @@ -54,7 +63,8 @@ export function classifyChangedFiles(input) { return buildScope("full", files, packages, "non-PR event or explicit full CI"); } - if (files.some((file) => !isMaintenancePath(file) && FORCE_FULL_PATTERNS.some((pattern) => pattern.test(file)))) { + if (files.some((file) => !isDocumentationPath(file) && !isMaintenancePath(file) + && FORCE_FULL_PATTERNS.some((pattern) => pattern.test(file)))) { return buildScope("full", files, packages, "root, workflow, dependency, or build configuration change"); } @@ -65,6 +75,17 @@ export function classifyChangedFiles(input) { return buildScope("docs", files, packages, reason); } + for (const file of files.filter((file) => file.endsWith("/package.json"))) { + const directory = dirname(file); + const current = packages.find((entry) => entry.directory === directory); + const previous = input.basePackageEntries?.find((entry) => entry.directory === directory); + if (!current || !previous || current.name !== previous.name + || current.hasTestScript !== previous.hasTestScript || current.hasTypecheckScript !== previous.hasTypecheckScript + || !current.scriptsFingerprint || current.scriptsFingerprint !== previous.scriptsFingerprint) { + return buildScope("full", files, packages, "unverified or changed workspace package identity or validation scripts"); + } + } + const changedEntries = new Set(); const changedTestEntries = new Set(); const changedSourceEntries = new Set(); @@ -91,7 +112,18 @@ export function classifyChangedFiles(input) { const sourceEntries = [...changedSourceEntries]; const affectedNames = new Set(changedEntries); - for (const name of sourceEntries) collectDependents(name, packages, affectedNames); + const dependencyGraph = packages.map((entry) => ({ ...entry, dependencies: [...new Set([ + ...entry.dependencies, + ...(input.basePackageEntries?.find((previous) => previous.name === entry.name)?.dependencies ?? []), + ])] })); + for (const name of sourceEntries) collectDependents(name, dependencyGraph, affectedNames); + // These are test inputs, not production edges. Rechecking an importer does + // not change its shipped output or select all of its production consumers. + for (const name of sourceEntries) { + for (const consumer of VALIDATION_CONSUMERS.get(name) ?? []) { + if (packages.some((entry) => entry.name === consumer)) affectedNames.add(consumer); + } + } const testSupportImporters = findChangedTestSupportImporters({ files, @@ -120,6 +152,7 @@ export function classifyChangedFiles(input) { packages: affectedEntries.map((entry) => entry.name).sort(), testPackages, typecheckPackages, + dependencyPolicy: files.some((file) => file.endsWith("/package.json") || DEPENDENCY_CHECK_FILES.has(file)), daemon: affectedNames.has("@ling-stack/daemon"), web: affectedNames.has("@ling-stack/web"), reason: changedSourceEntries.size === 0 @@ -128,8 +161,8 @@ export function classifyChangedFiles(input) { }; } -/** @typedef {{name: string, directory: string, dependencies: string[], hasTestScript: boolean, hasTypecheckScript: boolean}} PackageEntry */ -/** @typedef {{mode: "docs"|"affected"|"full", files: string[], packages: string[], testPackages: string[], typecheckPackages: string[], daemon: boolean, web: boolean, reason: string}} CiScope */ +/** @typedef {{name: string, directory: string, dependencies: string[], hasTestScript: boolean, hasTypecheckScript: boolean, scriptsFingerprint?: string}} PackageEntry */ +/** @typedef {{mode: "docs"|"affected"|"full", files: string[], packages: string[], testPackages: string[], typecheckPackages: string[], dependencyPolicy: boolean, daemon: boolean, web: boolean, reason: string}} CiScope */ /** @param {string} value */ function normalizePath(value) { @@ -145,7 +178,8 @@ function normalizeFiles(files) { function isDocumentationPath(file) { return file.startsWith("docs/") || DOCUMENTATION_ROOT_FILES.has(file) - || /(?:^|\/)README\.md$/iu.test(file); + || /(?:^|\/)README\.md$/iu.test(file) + || /^\.github\/(?:PULL_REQUEST_TEMPLATE\.md|PULL_REQUEST_TEMPLATE\/[^/]+\.md|CODEOWNERS|FUNDING\.yml)$/u.test(file); } /** @param {string} file */ @@ -184,11 +218,13 @@ function findOwningPackage(file, packages) { } /** @param {string} name @param {PackageEntry[]} packages @param {Set} affected */ -function collectDependents(name, packages, affected) { +function collectDependents(name, packages, affected, visited = new Set()) { + if (visited.has(name)) return; + visited.add(name); for (const entry of packages) { - if (affected.has(entry.name) || !entry.dependencies.includes(name)) continue; + if (!entry.dependencies.includes(name)) continue; affected.add(entry.name); - collectDependents(entry.name, packages, affected); + collectDependents(entry.name, packages, affected, visited); } } @@ -229,7 +265,15 @@ function sameImportTarget(changed, imported) { } /** @param {string} root @returns {PackageEntry[]} */ -function discoverPackages(root) { +function discoverPackages(root, ref) { + if (ref) { + return execFileSync("git", ["ls-tree", "-r", "--name-only", "-z", ref, "--", "packages", "apps"], { + cwd: root, encoding: "utf8", + }).split("\0").filter((file) => /^(?:apps|packages)\/[^/]+\/package\.json$/u.test(file)) + .map((file) => packageEntry(dirname(file), JSON.parse(execFileSync("git", ["show", `${ref}:${file}`], { + cwd: root, encoding: "utf8", + })))); + } const entries = []; for (const workspaceRoot of ["packages", "apps"]) { const absoluteRoot = resolve(root, workspaceRoot); @@ -239,24 +283,24 @@ function discoverPackages(root) { const packagePath = resolve(root, directory, "package.json"); if (!existsSync(packagePath)) continue; const manifest = JSON.parse(readFileSync(packagePath, "utf8")); - const dependencyMap = { - ...manifest.dependencies, - ...manifest.devDependencies, - ...manifest.optionalDependencies, - ...manifest.peerDependencies, - }; - entries.push({ - name: manifest.name, - directory, - dependencies: Object.keys(dependencyMap ?? {}), - hasTestScript: typeof manifest.scripts?.test === "string", - hasTypecheckScript: typeof manifest.scripts?.typecheck === "string", - }); + entries.push(packageEntry(directory, manifest)); } } return entries; } +function packageEntry(directory, manifest) { + return { + name: manifest.name, + directory, + dependencies: Object.keys({ ...manifest.dependencies, ...manifest.devDependencies, + ...manifest.optionalDependencies, ...manifest.peerDependencies }), + hasTestScript: typeof manifest.scripts?.test === "string", + hasTypecheckScript: typeof manifest.scripts?.typecheck === "string", + scriptsFingerprint: JSON.stringify(Object.entries(manifest.scripts ?? {}).sort(([a], [b]) => a.localeCompare(b))), + }; +} + /** @param {string} root */ function listRepositoryFiles(root) { return execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { @@ -285,6 +329,7 @@ function buildScope(mode, files, packages, reason) { packages: names, testPackages: mode === "full" ? packages.filter((entry) => entry.hasTestScript && isSupportedTestPackage(entry.directory)).map((entry) => entry.name).sort() : [], typecheckPackages: mode === "full" ? packages.filter((entry) => entry.hasTypecheckScript && isSupportedTypecheckPackage(entry.directory)).map((entry) => entry.name).sort() : [], + dependencyPolicy: mode === "full", daemon: mode === "full" || names.includes("@ling-stack/daemon"), web: mode === "full" || names.includes("@ling-stack/web"), reason, @@ -306,12 +351,14 @@ const args = parseArgs(process.argv.slice(2)); if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const eventName = args.event ?? process.env.GITHUB_EVENT_NAME ?? "pull_request"; let files; + let basePackageEntries; if (args.files) { files = JSON.parse(args.files); } else if (eventName === "pull_request" && args.base && args.head) { - files = execFileSync("git", ["diff", "--name-only", "-z", `${args.base}...${args.head}`], { cwd: repositoryRoot, encoding: "utf8" }).split("\0").filter(Boolean); + files = execFileSync("git", ["diff", "--no-renames", "--name-only", "-z", `${args.base}...${args.head}`], { cwd: repositoryRoot, encoding: "utf8" }).split("\0").filter(Boolean); + if (files.some((file) => file.endsWith("/package.json"))) basePackageEntries = discoverPackages(repositoryRoot, args.base); } else { files = []; } - console.log(JSON.stringify(classifyChangedFiles({ files, eventName, forceFull: args["force-full"] === "true" }))); + console.log(JSON.stringify(classifyChangedFiles({ files, eventName, forceFull: args["force-full"] === "true", basePackageEntries }))); } diff --git a/scripts/ci/classify-scope.test.mjs b/scripts/ci/classify-scope.test.mjs index 717febe95..200671f3d 100644 --- a/scripts/ci/classify-scope.test.mjs +++ b/scripts/ci/classify-scope.test.mjs @@ -1,4 +1,8 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import test from "node:test"; import { classifyChangedFiles } from "./classify-scope.mjs"; @@ -12,22 +16,43 @@ const packageEntries = [ entry("@ling-stack/ui-react", "packages/ui-react", [], true, true), entry("@ling-stack/chrome-extension", "apps/chrome-extension", [], true, true), entry("@ling-stack/web", "apps/web", ["@ling-stack/core", "@ling-stack/ui-react"], true, true), + entry("@ling-stack/localization", "packages/localization", [], true, false), entry("@ling-stack/desktop", "apps/desktop", ["@ling-stack/ui-react"], true, true), entry("@ling-stack/mobile", "apps/mobile", [], true, true), ]; function entry(name, directory, dependencies, hasTestScript, hasTypecheckScript) { - return { name, directory, dependencies, hasTestScript, hasTypecheckScript }; + return { name, directory, dependencies, hasTestScript, hasTypecheckScript, scriptsFingerprint: "[]" }; } function classify(files, extra = {}) { - return classifyChangedFiles({ files, packageEntries, ...extra }); + return classifyChangedFiles({ files, packageEntries, basePackageEntries: packageEntries, ...extra }); } test("documentation-only changes use the fast lane", () => { assert.equal(classify(["README.md", "packages/core/README.md", "docs/assets/preview.png"]).mode, "docs"); }); +test("review templates and nested READMEs are documentation before broad infrastructure rules", () => { + for (const file of ["scripts/ci/README.md", ".github/README.md", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/PULL_REQUEST_TEMPLATE/bugfix.md", ".github/CODEOWNERS", ".github/FUNDING.yml"]) { + const scope = classify([file]); + assert.equal(scope.mode, "docs", file); + assert.equal(scope.daemon, false, file); + assert.equal(scope.web, false, file); + assert.equal(scope.dependencyPolicy, false, file); + } +}); + +test("documentation exemptions cannot hide executable infrastructure or unknown roots", () => { + for (const file of [".github/PULL_REQUEST_TEMPLATE/build.mjs", "scripts/ci/prompt.md", "VERSION", + ".github/workflows/ci.yml", ".gitattributes", "tsconfig.json", "unknown/package.json"]) { + const scope = classify(["scripts/ci/README.md", file]); + assert.equal(scope.mode, "full", file); + assert.equal(scope.dependencyPolicy, true, file); + } +}); + test("Issue templates and allowlisted maintenance checks need no product packages", () => { const maintenanceFiles = [ ".github/ISSUE_TEMPLATE/bug_report.yml", @@ -75,8 +100,6 @@ test("maintenance exceptions do not weaken unknown paths or explicit full runs", "pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", - "packages/core/package.json", - "packages/core/tsconfig.json", "patches/dependency.patch", "native/unknown.rs", ]) { @@ -102,12 +125,77 @@ test("dependency, workflow, and CI script changes force full scope", () => { test("Web changes stay in Web and its transitive consumers", () => { const scope = classify(["apps/web/src/App.tsx"]); assert.equal(scope.mode, "affected"); - assert.deepEqual(scope.packages, ["@ling-stack/web"]); + assert.deepEqual(scope.packages, ["@ling-stack/localization", "@ling-stack/web"]); assert.equal(scope.web, true); assert.equal(scope.daemon, false); + assert.equal(scope.dependencyPolicy, false); assert.ok(scope.testPackages.includes("@ling-stack/web")); }); +test("package-local manifests and tooling configurations select their own consumers, not every package", () => { + for (const file of ["apps/web/package.json", "apps/web/tsconfig.json", "apps/web/vite.config.ts", + "apps/web/eslint.config.mjs"]) { + const scope = classify([file]); + assert.equal(scope.mode, "affected", file); + assert.deepEqual(scope.packages, ["@ling-stack/localization", "@ling-stack/web"], file); + assert.equal(scope.daemon, false, file); + assert.equal(scope.dependencyPolicy, file.endsWith("package.json"), file); + } + const core = classify(["packages/core/package.json"]); + assert.equal(core.mode, "affected"); + assert.deepEqual(core.packages, ["@ling-stack/cli", "@ling-stack/core", "@ling-stack/daemon", "@ling-stack/web"]); + assert.equal(core.daemon, true); + assert.equal(core.dependencyPolicy, true); + const desktop = classify(["apps/desktop/package.json"]); + assert.deepEqual(desktop.packages, ["@ling-stack/desktop"]); + assert.equal(desktop.daemon, false); +}); + +test("package identity changes, removal, or an unavailable baseline keep the full lane", () => { + const renamed = packageEntries.map((entry) => entry.directory === "packages/core" ? { ...entry, name: "@ling-stack/core-v2" } : entry); + for (const extra of [{ basePackageEntries: undefined }, { packageEntries: renamed }, + { packageEntries: packageEntries.filter((entry) => entry.directory !== "packages/core") }, + { packageEntries: packageEntries.map((entry) => entry.directory === "packages/core" ? { ...entry, hasTestScript: false } : entry) }]) { + assert.equal(classify(["packages/core/package.json"], extra).mode, "full"); + } +}); + +test("a missing or changed validation-script fingerprint cannot narrow manifest edits", () => { + for (const scriptsFingerprint of [undefined, '[[]]']) { + const changed = packageEntries.map((entry) => entry.name === "@ling-stack/core" ? { ...entry, scriptsFingerprint } : entry); + assert.equal(classify(["packages/core/package.json"], { packageEntries: changed }).mode, "full"); + } +}); + +test("test-only consumers already selected cannot stop transitive source traversal", () => { + const scope = classify(["packages/providers/src/index.ts", "packages/daemon/test/runtime-supervisor.test.mjs"]); + assert.deepEqual(scope.packages, ["@ling-stack/cli", "@ling-stack/daemon", "@ling-stack/providers"]); +}); + +test("removed dependency edges cannot erase consumers from a multi-package PR", () => { + const changed = packageEntries.map((entry) => entry.name === "@ling-stack/web" ? { ...entry, dependencies: [] } : entry); + const scope = classify(["packages/core/src/index.ts", "apps/web/package.json"], { packageEntries: changed }); + assert.ok(scope.packages.includes("@ling-stack/web")); + assert.ok(scope.packages.includes("@ling-stack/localization")); +}); + +test("dependency regressions cannot be skipped with ordinary test-only scope", () => { + for (const file of ["packages/daemon/test/dependency-security.test.mjs", + "apps/mobile/test/dependency-security-patches.test.mjs", "apps/mobile/test/dependency-security-tooling.test.mjs"]) { + assert.equal(classify([file]).dependencyPolicy, true, file); + } + assert.equal(classify(["packages/daemon/test/runtime-supervisor.test.mjs"]).dependencyPolicy, false); +}); + +test("mixed changes union all affected packages without disabling a dependency lane", () => { + const scope = classify(["apps/web/package.json", "packages/daemon/test/runtime-supervisor.test.mjs", "scripts/ci/README.md"]); + assert.equal(scope.mode, "affected"); + assert.deepEqual(scope.packages, ["@ling-stack/daemon", "@ling-stack/localization", "@ling-stack/web"]); + assert.equal(scope.daemon, true); + assert.equal(scope.web, true); + assert.equal(scope.dependencyPolicy, true); +}); + test("core changes include daemon, CLI, and Web consumers", () => { const scope = classify(["packages/core/src/index.ts"]); assert.equal(scope.mode, "affected"); @@ -153,3 +241,85 @@ test("unknown paths and non-PR events use full scope", () => { assert.equal(classify(["CONTRIBUTORS.txt"]).mode, "full"); assert.equal(classify(["packages/core/src/index.ts"], { eventName: "schedule" }).mode, "full"); }); + +function repositoryFixture(t) { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ling-ci-scope-"))); + t.after(() => rmSync(root, { recursive: true, force: true })); + const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); + const write = (file, contents) => { + const path = join(root, file); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); + }; + git("init", "--quiet"); + git("config", "user.name", "CI scope test"); + git("config", "user.email", "ci-scope@example.invalid"); + mkdirSync(join(root, "scripts/ci"), { recursive: true }); + const script = join(root, "scripts/ci/classify-scope.mjs"); + copyFileSync(new URL("./classify-scope.mjs", import.meta.url), script); + for (const entry of packageEntries) { + write(`${entry.directory}/package.json`, JSON.stringify({ + name: entry.name, dependencies: Object.fromEntries(entry.dependencies.map((name) => [name, "workspace:*"])), + scripts: { ...(entry.hasTestScript ? { test: "node --test" } : {}), ...(entry.hasTypecheckScript ? { typecheck: "tsc" } : {}) }, + })); + } + const commit = () => { git("add", "."); git("commit", "--quiet", "-m", "fixture"); return git("rev-parse", "HEAD"); }; + const classify = (base) => JSON.parse(execFileSync(process.execPath, [script, + "--event=pull_request", `--base=${base}`, "--head=HEAD"], { encoding: "utf8" })); + return { git, write, commit, classify }; +} + +test("the Git boundary retains the entire PR and both sides of source-to-doc renames", (t) => { + const repo = repositoryFixture(t); + repo.write("packages/core/src/probe.ts", "export const probe = true;\n"); + const base = repo.commit(); + repo.write("docs/placeholder.md", "# Placeholder\n"); + repo.git("mv", "packages/core/src/probe.ts", "docs/probe.ts"); + repo.commit(); + repo.write("docs/latest.md", "# Latest commit is documentation only\n"); + repo.commit(); + const scope = repo.classify(base); + assert.equal(scope.mode, "affected"); + assert.ok(scope.files.includes("packages/core/src/probe.ts")); + assert.ok(scope.files.includes("docs/probe.ts")); + assert.ok(scope.packages.includes("@ling-stack/core")); + assert.ok(scope.packages.includes("@ling-stack/daemon")); +}); + +test("the Git boundary verifies old manifest identity before narrowing package edits", (t) => { + const repo = repositoryFixture(t); + const base = repo.commit(); + const manifest = { name: "@ling-stack/core", version: "1.2.3", scripts: { test: "node --test", typecheck: "tsc" } }; + repo.write("packages/core/package.json", JSON.stringify(manifest)); + repo.commit(); + const stable = repo.classify(base); + assert.equal(stable.mode, "affected"); + assert.equal(stable.dependencyPolicy, true); + assert.ok(stable.packages.includes("@ling-stack/daemon")); + assert.ok(!stable.packages.includes("@ling-stack/mobile")); + repo.write("packages/core/package.json", JSON.stringify({ ...manifest, name: "@ling-stack/core-v2" })); + repo.commit(); + assert.equal(repo.classify(base).mode, "full"); + repo.git("rm", "packages/core/package.json"); + repo.commit(); + assert.equal(repo.classify(base).mode, "full"); +}); + +test("the Git boundary compares validation script contents and CI entrypoints, not only their presence", (t) => { + for (const scripts of [{ test: "node --test", typecheck: "tsc", "test:ci": "node --test" }, + { test: "echo omitted", typecheck: "tsc" }, { test: "node --test", typecheck: "echo omitted" }]) { + const repo = repositoryFixture(t); + const base = repo.commit(); + repo.write("packages/core/package.json", JSON.stringify({ name: "@ling-stack/core", scripts })); + repo.commit(); + assert.equal(repo.classify(base).mode, "full", JSON.stringify(scripts)); + } +}); + +test("the real package graph keeps Web's CLI and catalog consumers without core or daemon shards", () => { + const scope = classifyChangedFiles({ files: ["apps/web/tsconfig.json"] }); + assert.equal(scope.mode, "affected"); + for (const name of ["@ling-stack/web", "@ling-stack/cli", "@ling-stack/localization"]) assert.ok(scope.packages.includes(name), name); + assert.equal(scope.daemon, false); + assert.ok(!scope.testPackages.includes("@ling-stack/core")); +}); diff --git a/scripts/ci/daemon-test-shard.mjs b/scripts/ci/daemon-test-shard.mjs index ec6ee3abb..2a1e1e157 100644 --- a/scripts/ci/daemon-test-shard.mjs +++ b/scripts/ci/daemon-test-shard.mjs @@ -8,7 +8,7 @@ export { assignTestShards, estimateTestWeight } from "./test-shard-plan.mjs"; export { createTestIsolation as createDaemonShardIsolation }; export async function discoverDaemonTestFiles(testDirectory = join(testSuites.daemon.cwd, "test")) { - return (await readdir(testDirectory)).filter(file => file.endsWith(".test.mjs") && !testSuites.daemon.excluded.includes(file)) + return (await readdir(testDirectory)).filter(file => /\.test\.(?:mjs|ts)$/u.test(file) && !testSuites.daemon.excluded.includes(file)) .sort().map(file => `test/${file}`); } diff --git a/scripts/ci/execution-policy.mjs b/scripts/ci/execution-policy.mjs index 3df3e55d7..7a2bbf8b8 100644 --- a/scripts/ci/execution-policy.mjs +++ b/scripts/ci/execution-policy.mjs @@ -5,8 +5,12 @@ import { pathToFileURL } from "node:url"; const EXECUTION_TIERS = new Set(["complete", "preview", "ignore"]); const SCOPE_MODES = new Set(["docs", "affected", "full"]); const LANES = ["SCOPE", "COMMON", "DEPENDENCY", "BUILD", "TYPECHECK", "UNIT", "DAEMON", "LINUX_CONTRACT", "MACOS_NATIVE"]; -const PRODUCT_LANES = ["DEPENDENCY", "BUILD", "TYPECHECK", "UNIT"]; -const DAEMON_LANES = ["DAEMON", "LINUX_CONTRACT", "MACOS_NATIVE"]; +const SELECTOR_LANES = { + dependency: ["DEPENDENCY"], + typecheck: ["TYPECHECK"], + unit: ["UNIT"], + daemon: ["DAEMON", "LINUX_CONTRACT", "MACOS_NATIVE"], +}; const JOB_RESULTS = new Set(["success", "failure", "cancelled", "skipped"]); /** @@ -51,21 +55,29 @@ export function selectExecutionTier({ eventName, event }) { * Fail closed if any selected lane did not execute successfully. Even a lane * outside the scope cannot hide a failure or cancellation behind the aggregate. * A Draft preview always fails the merge gate after its common checks pass. - * @param {{tier: "complete"|"preview"|"ignore", mode: "docs"|"affected"|"full", daemon: boolean, results: Record}} input + * @param {{tier: "complete"|"preview"|"ignore", mode: "docs"|"affected"|"full", dependency: boolean, typecheck: boolean, unit: boolean, daemon: boolean, results: Record}} input * @returns {true} Only a complete execution with all required lanes succeeding. * @throws {Error} For malformed scope, failed lanes, or intentionally deferred CI. */ -export function assertCiResults({ tier, mode, daemon, results }) { +export function assertCiResults({ tier, mode, dependency, typecheck, unit, daemon, results }) { if (!EXECUTION_TIERS.has(tier)) throw new Error(`Invalid EXECUTION_TIER: ${String(tier)}`); if (tier === "ignore") throw new Error("ignored_event_not_quality_gate: unrelated label events cannot publish the required CI context"); if (!SCOPE_MODES.has(mode)) throw new Error(`Invalid SCOPE_MODE: ${String(mode)}`); - if (typeof daemon !== "boolean") throw new Error("DAEMON_SELECTED must be true or false"); + const selectors = { dependency, typecheck, unit, daemon }; + for (const [selector, selected] of Object.entries(selectors)) { + const name = `${selector.toUpperCase()}_SELECTED`; + if (typeof selected !== "boolean") throw new Error(`${name} must be true or false`); + if (mode === "docs" && selected) throw new Error(`Invalid docs scope: ${name} must be false`); + if (mode === "full" && !selected) throw new Error(`Invalid full scope: ${name} must be true`); + } requireObject(results, "results"); const required = new Set(["SCOPE", "COMMON"]); if (tier === "complete") { - if (mode !== "docs") for (const lane of PRODUCT_LANES) required.add(lane); - if (daemon) for (const lane of DAEMON_LANES) required.add(lane); + if (mode !== "docs") required.add("BUILD"); + for (const [selector, lanes] of Object.entries(SELECTOR_LANES)) { + if (selectors[selector]) for (const lane of lanes) required.add(lane); + } } const failures = LANES.filter((lane) => { @@ -124,13 +136,15 @@ function parseArguments(args) { function main(args, env) { const options = parseArguments(args); if (options.checkQuality) { - if (env.DAEMON_SELECTED !== "true" && env.DAEMON_SELECTED !== "false") { - throw new Error("DAEMON_SELECTED must be true or false"); - } + const selectors = Object.fromEntries(Object.keys(SELECTOR_LANES).map((selector) => { + const name = `${selector.toUpperCase()}_SELECTED`; + if (env[name] !== "true" && env[name] !== "false") throw new Error(`${name} must be true or false`); + return [selector, env[name] === "true"]; + })); assertCiResults({ tier: env.EXECUTION_TIER, mode: env.SCOPE_MODE, - daemon: env.DAEMON_SELECTED === "true", + ...selectors, results: Object.fromEntries(LANES.map((lane) => [lane, env[`${lane}_RESULT`]])), }); console.log("All applicable CI lanes passed; skipped lanes were outside this change scope."); diff --git a/scripts/ci/execution-policy.test.mjs b/scripts/ci/execution-policy.test.mjs index f464695ff..c32bcb00b 100644 --- a/scripts/ci/execution-policy.test.mjs +++ b/scripts/ci/execution-policy.test.mjs @@ -9,6 +9,8 @@ import { assertCiResults, selectExecutionTier } from "./execution-policy.mjs"; const script = fileURLToPath(new URL("./execution-policy.mjs", import.meta.url)); const lanes = ["SCOPE", "COMMON", "DEPENDENCY", "BUILD", "TYPECHECK", "UNIT", "DAEMON", "LINUX_CONTRACT", "MACOS_NATIVE"]; +const selectors = ["dependency", "typecheck", "unit", "daemon"]; +const unselected = Object.fromEntries(selectors.map((selector) => [selector, false])); function prEvent(action, { draft = false, labels = [], label } = {}) { return { action, pull_request: { draft, labels: labels.map((name) => ({ name })) }, ...(label === undefined ? {} : { label: { name: label } }) }; @@ -23,7 +25,8 @@ function results(overrides = {}) { } function quality(overrides = {}) { - return assertCiResults({ tier: "complete", mode: "full", daemon: true, results: results(), ...overrides }); + return assertCiResults({ tier: "complete", mode: "full", dependency: true, typecheck: true, unit: true, + daemon: true, results: results(), ...overrides }); } test("every Draft update previews while opened and ready PR updates run complete checks", () => { @@ -91,13 +94,13 @@ for (const lane of lanes) { test("documentation scope requires scope and common but permits every product lane to skip", () => { const skipped = results(Object.fromEntries(lanes.slice(2).map((lane) => [lane, "skipped"]))); - assert.equal(quality({ mode: "docs", daemon: false, results: skipped }), true); + assert.equal(quality({ mode: "docs", ...unselected, results: skipped }), true); for (const lane of ["SCOPE", "COMMON"]) { - assert.throws(() => quality({ mode: "docs", daemon: false, results: { ...skipped, [lane]: "skipped" } }), new RegExp(`${lane}=skipped`, "u")); + assert.throws(() => quality({ mode: "docs", ...unselected, results: { ...skipped, [lane]: "skipped" } }), new RegExp(`${lane}=skipped`, "u")); } for (const lane of lanes.slice(2)) { for (const result of ["failure", "cancelled"]) { - assert.throws(() => quality({ mode: "docs", daemon: false, results: { ...skipped, [lane]: result } }), new RegExp(`${lane}=${result}`, "u")); + assert.throws(() => quality({ mode: "docs", ...unselected, results: { ...skipped, [lane]: result } }), new RegExp(`${lane}=${result}`, "u")); } } }); @@ -110,10 +113,53 @@ test("Web-only scope can skip platform lanes but requires every Linux product la } }); +test("affected scope permits every selector combination without skipping selected work", () => { + for (let mask = 0; mask < 2 ** selectors.length; mask += 1) { + const selected = Object.fromEntries(selectors.map((selector, index) => [selector, Boolean(mask & (1 << index))])); + const outcomes = results({ + DEPENDENCY: selected.dependency ? "success" : "skipped", + TYPECHECK: selected.typecheck ? "success" : "skipped", + UNIT: selected.unit ? "success" : "skipped", + DAEMON: selected.daemon ? "success" : "skipped", + LINUX_CONTRACT: selected.daemon ? "success" : "skipped", + MACOS_NATIVE: selected.daemon ? "success" : "skipped", + }); + assert.equal(quality({ mode: "affected", ...selected, results: outcomes }), true, JSON.stringify(selected)); + for (const lane of lanes) { + if (outcomes[lane] !== "success") continue; + assert.throws(() => quality({ mode: "affected", ...selected, results: { ...outcomes, [lane]: "skipped" } }), + new RegExp(`${lane}=skipped`, "u")); + } + } +}); + +test("unselected lanes permit explicit skips but still reject failed, cancelled, missing or invalid results", () => { + const skipped = results(Object.fromEntries(lanes.slice(2).filter((lane) => lane !== "BUILD").map((lane) => [lane, "skipped"]))); + for (const lane of lanes.slice(2).filter((lane) => lane !== "BUILD")) { + for (const result of ["failure", "cancelled", "", undefined, "pending", null, false]) { + assert.throws(() => quality({ mode: "affected", ...unselected, results: { ...skipped, [lane]: result } }), + new RegExp(`${lane}=`, "u")); + } + } +}); + +test("every selector is a required boolean and docs/full modes cannot contradict it", () => { + for (const selector of selectors) { + const name = `${selector.toUpperCase()}_SELECTED`; + for (const value of [undefined, null, "true", "false", "", 0, 1, [], {}]) { + assert.throws(() => quality({ [selector]: value }), new RegExp(name, "u")); + } + for (const tier of ["complete", "preview"]) { + assert.throws(() => quality({ tier, [selector]: false }), new RegExp(`full.*${name}`, "u")); + assert.throws(() => quality({ tier, mode: "docs", ...unselected, [selector]: true }), new RegExp(`docs.*${name}`, "u")); + } + } +}); + test("Draft common-check success is explicitly deferred instead of certifying an incomplete merge gate", () => { const draftResults = results(Object.fromEntries(lanes.slice(2).map((lane) => [lane, "skipped"]))); for (const mode of ["docs", "affected", "full"]) { - assert.throws(() => quality({ tier: "preview", mode, results: draftResults }), /deferred_by_draft_policy/u); + assert.throws(() => quality({ tier: "preview", mode, ...(mode === "docs" ? unselected : {}), results: draftResults }), /deferred_by_draft_policy/u); } for (const lane of ["SCOPE", "COMMON"]) { for (const result of ["failure", "cancelled", "skipped", "", undefined]) { @@ -159,6 +205,7 @@ test("event-file CLI emits only the tier and rejects unreadable, invalid or ambi test("quality CLI enforces environment types and reports failures in the workflow log", () => { const env = { ...process.env, EXECUTION_TIER: "complete", SCOPE_MODE: "full", DAEMON_SELECTED: "true", + DEPENDENCY_SELECTED: "true", TYPECHECK_SELECTED: "true", UNIT_SELECTED: "true", ...Object.fromEntries(lanes.map((lane) => [`${lane}_RESULT`, "success"])) }; const run = (overrides = {}) => spawnSync(process.execPath, [script, "--check-quality"], { encoding: "utf8", env: { ...env, ...overrides } }); assert.equal(run().status, 0); @@ -170,10 +217,33 @@ test("quality CLI enforces environment types and reports failures in the workflo } } const skippedProducts = Object.fromEntries(lanes.slice(2).map((lane) => [`${lane}_RESULT`, "skipped"])); - assert.equal(run({ SCOPE_MODE: "docs", DAEMON_SELECTED: "false", ...skippedProducts }).status, 0); + const unselectedEnv = Object.fromEntries(selectors.map((selector) => [`${selector.toUpperCase()}_SELECTED`, "false"])); + assert.equal(run({ SCOPE_MODE: "docs", ...unselectedEnv, ...skippedProducts }).status, 0); assert.equal(run({ SCOPE_MODE: "affected", DAEMON_SELECTED: "false", DAEMON_RESULT: "skipped", LINUX_CONTRACT_RESULT: "skipped", MACOS_NATIVE_RESULT: "skipped" }).status, 0); + const affectedSkips = { SCOPE_MODE: "affected", ...unselectedEnv, ...skippedProducts, BUILD_RESULT: "success" }; + assert.equal(run(affectedSkips).status, 0); + for (const lane of lanes.slice(2).filter((lane) => lane !== "BUILD")) { + for (const result of ["failure", "cancelled", "", undefined, "pending"]) { + const check = run({ ...affectedSkips, [`${lane}_RESULT`]: result }); + assert.equal(check.status, 1, check.stdout + check.stderr); + assert.match(check.stdout, new RegExp(`${lane}=`, "u")); + } + } + for (const selector of selectors) { + const name = `${selector.toUpperCase()}_SELECTED`; + for (const value of [undefined, "", "TRUE", "False", " true", "false ", "0", "1"]) { + const check = run({ [name]: value }); + assert.equal(check.status, 1, check.stdout + check.stderr); + assert.match(check.stdout, new RegExp(name, "u")); + } + for (const overrides of [{ [name]: "false" }, { SCOPE_MODE: "docs", ...unselectedEnv, [name]: "true" }]) { + const check = run(overrides); + assert.equal(check.status, 1, check.stdout + check.stderr); + assert.match(check.stdout, new RegExp(`${overrides.SCOPE_MODE ?? "full"}.*${name}`, "u")); + } + } for (const mode of ["docs", "affected", "full"]) { - const preview = run({ EXECUTION_TIER: "preview", SCOPE_MODE: mode, ...skippedProducts }); + const preview = run({ EXECUTION_TIER: "preview", SCOPE_MODE: mode, ...(mode === "docs" ? unselectedEnv : {}), ...skippedProducts }); assert.equal(preview.status, 1, preview.stdout + preview.stderr); assert.match(preview.stdout, /deferred_by_draft_policy/u); } diff --git a/scripts/ci/execution-workflow.test.mjs b/scripts/ci/execution-workflow.test.mjs index 327d23842..7ce53a788 100644 --- a/scripts/ci/execution-workflow.test.mjs +++ b/scripts/ci/execution-workflow.test.mjs @@ -3,12 +3,16 @@ import { spawnSync } from "node:child_process"; import { readFileSync } from "node:fs"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { classifyChangedFiles } from "./classify-scope.mjs"; +import { createTestPlan } from "./test-shard-plan.mjs"; const workflow = readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8"); const labelWorkflow = readFileSync(new URL("../../.github/workflows/full-ci-label.yml", import.meta.url), "utf8"); const root = fileURLToPath(new URL("../../", import.meta.url)); const lanes = ["SCOPE", "COMMON", "DEPENDENCY", "BUILD", "TYPECHECK", "UNIT", "DAEMON", "LINUX_CONTRACT", "MACOS_NATIVE"]; const platformJobs = ["daemon-tests", "daemon-linux-contracts", "restricted-command-macos"]; +const selectorOutputs = { DEPENDENCY: "dependency-policy", TYPECHECK: "typecheck-selected", UNIT: "unit-selected", DAEMON: "daemon" }; +const unselected = Object.fromEntries(Object.keys(selectorOutputs).map((selector) => [`${selector}_SELECTED`, "false"])); /** Read a top-level job without matching similarly named steps. */ function jobSource(id) { @@ -24,11 +28,123 @@ function runQuality(overrides = {}) { return spawnSync("bash", ["-c", command.replace(/^ {10}/gmu, "")], { cwd: root, encoding: "utf8", - env: { ...process.env, EXECUTION_TIER: "complete", SCOPE_MODE: "full", DAEMON_SELECTED: "true", + env: { ...process.env, EXECUTION_TIER: "complete", SCOPE_MODE: "full", + ...Object.fromEntries(Object.keys(selectorOutputs).map((selector) => [`${selector}_SELECTED`, "true"])), ...Object.fromEntries(lanes.map((lane) => [`${lane}_RESULT`, "success"])), ...overrides }, }); } +/** Execute the workflow's own heredoc and output redirection, without a GitHub runner. */ +function runOutputTransform(variable, value) { + const source = jobSource("scope"); + const start = source.indexOf(` ${variable}=`); + assert.notEqual(start, -1, `Missing ${variable} transformation`); + const end = source.indexOf(" NODE\n", start); + assert.notEqual(end, -1, `Missing ${variable} heredoc terminator`); + const command = source.slice(start, end + " NODE\n".length).replace(/^ {10}/gmu, ""); + const result = spawnSync("bash", ["-euo", "pipefail", "-c", command], { + cwd: root, encoding: "utf8", timeout: 10_000, + env: { ...process.env, scope_json: value, unit_matrix: value, GITHUB_OUTPUT: "/dev/stdout" }, + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + const lines = result.stdout.trim().split("\n").map((line) => { + const separator = line.indexOf("="); + assert.ok(separator > 0, `Invalid workflow output: ${line}`); + return [line.slice(0, separator), line.slice(separator + 1)]; + }); + assert.equal(new Set(lines.map(([key]) => key)).size, lines.length); + return Object.fromEntries(lines); +} + +async function assertScopeOutputs(scope, { mode, packages, daemon, web, dependency = false, matrix }) { + const outputs = runOutputTransform("SCOPE_JSON", JSON.stringify(scope)); + assert.deepEqual(outputs, { + mode, packages: JSON.stringify(packages), "test-packages": JSON.stringify(packages), + "typecheck-packages": JSON.stringify(packages), "dependency-policy": String(dependency), + "typecheck-selected": String(packages.length > 0), daemon: String(daemon), web: String(web), + }); + const plan = await createTestPlan(JSON.parse(outputs["test-packages"])); + assert.deepEqual(plan.unitMatrix, { include: matrix }); + const selected = runOutputTransform("UNIT_MATRIX", JSON.stringify(plan.unitMatrix)); + assert.deepEqual(selected, { "unit-selected": matrix.length > 0 ? "true" : "false" }); + const gate = { + SCOPE_MODE: outputs.mode, + ...Object.fromEntries(Object.entries(selectorOutputs).map(([selector, output]) => + [`${selector}_SELECTED`, (output === "unit-selected" ? selected : outputs)[output]])), + ...Object.fromEntries(lanes.slice(2).map((lane) => [`${lane}_RESULT`, "skipped"])), + }; + // Expected lanes are independent of the emitted selectors: a false selector + // must not turn a selected lane's skipped result into a passing quality gate. + const required = ["SCOPE", "COMMON", ...(mode === "docs" ? [] : ["BUILD"]), + ...(dependency ? ["DEPENDENCY"] : []), ...(packages.length ? ["TYPECHECK"] : []), + ...(matrix.length ? ["UNIT"] : []), ...(daemon ? ["DAEMON", "LINUX_CONTRACT", "MACOS_NATIVE"] : [])]; + for (const lane of required) gate[`${lane}_RESULT`] = "success"; + const passed = runQuality(gate); + assert.equal(passed.status, 0, passed.stdout + passed.stderr); + for (const lane of required) { + const skipped = runQuality({ ...gate, [`${lane}_RESULT`]: "skipped" }); + assert.equal(skipped.status, 1, `${lane}: ${skipped.stdout}${skipped.stderr}`); + assert.match(skipped.stdout, new RegExp(`${lane}=skipped`, "u")); + } +} + +// protects: docs outputs and empty matrix; fails_when: docs selects product work; +// why_new: executes inline output conversion plus quality; seam=none; +// no_claim=hosted GitHub scheduler not executed. +test("workflow transformations preserve docs empty arrays and deselected lanes", { timeout: 90_000 }, async () => { + await assertScopeOutputs(classifyChangedFiles({ files: ["README.md"] }), { + mode: "docs", packages: [], daemon: false, web: false, matrix: [], + }); +}); + +// protects: daemon-only selection with an empty unit matrix; fails_when: daemon is hidden or unit is selected; +// why_new: joins real discovery, inline selectors and quality; seam=none; +// no_claim=hosted GitHub scheduler not executed. +test("workflow transformations keep daemon test-only work outside the unit matrix", { timeout: 90_000 }, async () => { + await assertScopeOutputs(classifyChangedFiles({ files: ["packages/daemon/test/approval-policy-run.test.mjs"] }), { + mode: "affected", packages: ["@ling-stack/daemon"], daemon: true, web: false, matrix: [], + }); +}); + +// protects: all four core unit shards; fails_when: matrix or unit selector drops core; +// why_new: executes matrix-to-selector heredoc against the real plan; seam=none; +// no_claim=hosted GitHub scheduler not executed. +test("workflow transformations select the four core shards for core test-only work", { timeout: 90_000 }, async () => { + await assertScopeOutputs(classifyChangedFiles({ files: ["packages/core/test/example.test.ts"] }), { + mode: "affected", packages: ["@ling-stack/core"], daemon: false, web: false, + matrix: [0, 1, 2, 3].map((shard) => ({ suite: "core", shard })), + }); +}); + +// protects: web test and source package arrays and package-suite selection; fails_when: web work disappears; +// why_new: checks both classifier paths through actual workflow JS and quality; seam=none; +// no_claim=hosted GitHub scheduler not executed. +test("workflow transformations retain web test and source validation consumers", { timeout: 90_000 }, async () => { + for (const [file, packages] of [ + ["apps/web/test/example.test.ts", ["@ling-stack/web"]], + ["apps/web/src/example.ts", ["@ling-stack/cli", "@ling-stack/localization", "@ling-stack/web"]], + ]) { + await assertScopeOutputs(classifyChangedFiles({ files: [file] }), { + mode: "affected", packages, daemon: false, web: true, matrix: [{ suite: "packages", shard: 0 }], + }); + } +}); + +// protects: full-scope selectors and combined package/core matrix; fails_when: any required lane is hidden; +// why_new: bounded full classifier graph exercises inline outputs end to end; seam=none; +// no_claim=hosted GitHub scheduler not executed. +test("workflow transformations require every lane for a bounded full package graph", { timeout: 90_000 }, async () => { + const packages = ["@ling-stack/core", "@ling-stack/daemon", "@ling-stack/web"]; + const packageEntries = packages.map((name) => ({ name, + directory: name.endsWith("web") ? "apps/web" : `packages/${name.split("/")[1]}`, + dependencies: [], hasTestScript: true, hasTypecheckScript: true, + })); + await assertScopeOutputs(classifyChangedFiles({ files: ["package.json"], packageEntries }), { + mode: "full", packages, dependency: true, daemon: true, web: true, + matrix: [{ suite: "packages", shard: 0 }, ...[0, 1, 2, 3].map((shard) => ({ suite: "core", shard }))], + }); +}); + test("PR transitions and full-ci labels trigger validation while full regression is daily", () => { assert.match(workflow, /types: \[opened, synchronize, reopened, ready_for_review, converted_to_draft\]/u); assert.match(labelWorkflow, /pull_request_target:\n types: \[labeled\]/u); @@ -46,10 +162,12 @@ test("PR transitions and full-ci labels trigger validation while full regression }); test("every expensive job requires complete execution and retains its scope selection", () => { + const selectedJobs = { "dependency-policy": "dependency-policy", typecheck: "typecheck-selected", "unit-tests": "unit-selected" }; for (const id of ["dependency-policy", "build", "typecheck", "unit-tests", ...platformJobs]) { const source = jobSource(id); assert.match(source, /^ if: .*needs\.scope\.outputs\.execution-tier == 'complete'/mu, id); - assert.match(source, platformJobs.includes(id) ? /needs\.scope\.outputs\.daemon == 'true'/u : /needs\.scope\.outputs\.mode != 'docs'/u, id); + const selector = platformJobs.includes(id) ? "daemon" : selectedJobs[id]; + assert.match(source, selector ? new RegExp(`^ if: .*needs\\.scope\\.outputs\\.${selector} == 'true'`, "mu") : /^ if: .*needs\.scope\.outputs\.mode != 'docs'/mu, id); } const common = jobSource("common-fast"); assert.match(common, /run: node --test scripts\/check-public-repo-safety\.test\.mjs scripts\/ci\/\*\.test\.mjs/u); @@ -92,7 +210,10 @@ test("workflow gate requires all nine lanes and refuses incomplete Draft validat assert.match(quality, /^ if: always\(\)/mu); assert.match(quality, /EXECUTION_TIER: \$\{\{ needs\.scope\.outputs\.execution-tier \}\}/u); assert.match(quality, /SCOPE_MODE: \$\{\{ needs\.scope\.outputs\.mode \}\}/u); - assert.match(quality, /DAEMON_SELECTED: \$\{\{ needs\.scope\.outputs\.daemon \}\}/u); + for (const [selector, output] of Object.entries(selectorOutputs)) { + assert.match(jobSource("scope"), new RegExp(`^ ${output}: \\$\\{\\{ steps\\.[a-z-]+\\.outputs\\.${output} \\}\\}$`, "mu")); + assert.match(quality, new RegExp(`${selector}_SELECTED: \\$\\{\\{ needs\\.scope\\.outputs\\.${output} \\}\\}`, "u")); + } for (const lane of lanes) assert.match(quality, new RegExp(`${lane}_RESULT: \\$\\{\\{ needs\\.`, "u")); assert.equal(runQuality().status, 0); const preview = runQuality({ EXECUTION_TIER: "preview", @@ -102,7 +223,7 @@ test("workflow gate requires all nine lanes and refuses incomplete Draft validat }); test("workflow docs can skip product lanes but cannot hide failed common checks", () => { - const docs = { SCOPE_MODE: "docs", DAEMON_SELECTED: "false", + const docs = { SCOPE_MODE: "docs", ...unselected, ...Object.fromEntries(lanes.slice(2).map((lane) => [`${lane}_RESULT`, "skipped"])) }; assert.equal(runQuality(docs).status, 0); for (const lane of ["SCOPE", "COMMON"]) { @@ -122,6 +243,37 @@ test("workflow affected scope cannot hide skipped product work or selected Daemo assert.equal(web.status, 0, web.stderr); }); +test("workflow affected scope permits only explicit out-of-scope skips and still requires build", () => { + const affected = { SCOPE_MODE: "affected", ...unselected, + ...Object.fromEntries(lanes.slice(2).filter((lane) => lane !== "BUILD").map((lane) => [`${lane}_RESULT`, "skipped"])) }; + assert.equal(runQuality(affected).status, 0); + for (const lane of lanes.slice(2)) { + for (const result of ["failure", "cancelled", "", undefined, "pending", ...(lane === "BUILD" ? ["skipped"] : [])]) { + const check = runQuality({ ...affected, [`${lane}_RESULT`]: result }); + assert.equal(check.status, 1, check.stdout + check.stderr); + assert.match(check.stdout, new RegExp(`${lane}=`, "u")); + } + } +}); + +test("workflow selectors are mandatory strict booleans and cannot weaken docs or full scope", () => { + for (const selector of Object.keys(selectorOutputs)) { + const name = `${selector}_SELECTED`; + for (const value of [undefined, "", "TRUE", "False", " true", "false ", "0", "1"]) { + const check = runQuality({ [name]: value }); + assert.equal(check.status, 1, check.stdout + check.stderr); + assert.match(check.stdout, new RegExp(name, "u")); + } + for (const tier of ["complete", "preview"]) { + for (const overrides of [{ [name]: "false" }, { SCOPE_MODE: "docs", ...unselected, [name]: "true" }]) { + const check = runQuality({ EXECUTION_TIER: tier, ...overrides }); + assert.equal(check.status, 1, check.stdout + check.stderr); + assert.match(check.stdout, new RegExp(`${overrides.SCOPE_MODE ?? "full"}.*${name}`, "u")); + } + } + } +}); + test("workflow gate rejects failed, cancelled or missing results in every selected lane", () => { for (const lane of lanes) { for (const result of ["failure", "cancelled", "", undefined]) { diff --git a/scripts/ci/node-test-shard.mjs b/scripts/ci/node-test-shard.mjs index c121e2a18..c199c483d 100644 --- a/scripts/ci/node-test-shard.mjs +++ b/scripts/ci/node-test-shard.mjs @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { finished } from "node:stream/promises"; -import { assignTestShards, assertSuiteCoverage, discoverTestFiles, parseArguments, testSuites } from "./test-shard-plan.mjs"; +import { assignTestShards, assertSuiteCoverage, discoverTestFiles, estimateTestWeight, parseArguments, testSuites } from "./test-shard-plan.mjs"; import { trackTestProcesses } from "./test-processes.mjs"; export const defaultFileTimeoutMs = 300_000; @@ -136,6 +136,13 @@ async function runFile(file, { cwd, shard, worker, suite, temporaryDirectory, st return result; } +/** + * @param {string[]} files + * @param {{ concurrency?: number, shard?: number, shardCount?: number, suite?: string, + * cwd?: string, exclusive?: string[], timings?: Record, fileTimeouts?: Record, + * temporaryDirectory?: string, stdio?: string, fileTimeoutMs?: number, shardTimeoutMs?: number, + * terminationGraceMs?: number, reportPath?: string, signal?: AbortSignal }} [options] + */ export async function runNodeTestFiles(files, { concurrency = 1, shard = 0, shardCount = 1, suite = "tests", cwd = process.cwd(), exclusive = [], timings = {}, fileTimeouts = {}, temporaryDirectory = tmpdir(), stdio = "inherit", fileTimeoutMs = defaultFileTimeoutMs, shardTimeoutMs = defaultShardTimeoutMs, terminationGraceMs = 5000, @@ -193,9 +200,13 @@ export async function runNodeTestFiles(files, { await persist(); const exclusiveFiles = selected.filter(file => exclusive.includes(file)).sort(); await worker(exclusiveFiles, "exclusive"); - const parallel = selected.filter(file => !exclusive.includes(file)); - const buckets = parallel.length ? assignTestShards(parallel, Math.min(concurrency, parallel.length), { cwd, timings }) : []; - const outcomes = await Promise.allSettled(buckets.map((bucket, index) => worker(bucket.files, index))); + const parallel = selected.filter(file => !exclusive.includes(file)) + .map(file => ({ file, weight: estimateTestWeight(file, { cwd, timings }) })) + .sort((a, b) => b.weight - a.weight || a.file.localeCompare(b.file)); + // Each idle worker claims one file synchronously from the same iterator. + const queue = parallel.map(item => item.file).values(); + const outcomes = await Promise.allSettled(Array.from({ length: Math.min(concurrency, parallel.length) }, + (_, index) => worker(queue, index))); for (const outcome of outcomes) if (outcome.status === "rejected") errors.push(outcome.reason); complete = !controller.signal.aborted && [...records.values()].every(result => result.status !== "pending" && result.status !== "running"); } finally { @@ -217,11 +228,12 @@ export async function runTestShard(suiteName, argv) { if (!config) throw new Error(`Unknown suite: ${suiteName}`); const shard = Number(args["--shard"] ?? 0); const shardCount = Number(args["--shard-count"] ?? config.shardCount); + const concurrency = Number(args["--concurrency"] ?? config.concurrency); if (!Number.isInteger(shard) || !Number.isInteger(shardCount) || shardCount < 1 || shard < 0 || shard >= shardCount) { throw new RangeError(`expected --shard in [0, ${shardCount - 1}]`); } const discovered = await discoverTestFiles(config.cwd, config.excluded); - let shards = assignTestShards(discovered, shardCount, { cwd: config.cwd }); + let shards = assignTestShards(discovered, shardCount, { ...config, concurrency }); let timings = {}; if (args["--plan"]) { const plan = JSON.parse(await readFile(args["--plan"], "utf8")); @@ -240,7 +252,7 @@ export async function runTestShard(suiteName, argv) { process.once("SIGTERM", onTerm); try { return await runNodeTestFiles(files, { ...config, suite: suiteName, shard, shardCount, timings, - concurrency: Number(args["--concurrency"] ?? config.concurrency), reportPath: args["--report"], signal: controller.signal, + concurrency, reportPath: args["--report"], signal: controller.signal, fileTimeoutMs: Number(args["--file-timeout-ms"] ?? defaultFileTimeoutMs), shardTimeoutMs: Number(args["--shard-timeout-ms"] ?? defaultShardTimeoutMs) }); } finally { diff --git a/scripts/ci/node-test-shard.test.mjs b/scripts/ci/node-test-shard.test.ts similarity index 68% rename from scripts/ci/node-test-shard.test.mjs rename to scripts/ci/node-test-shard.test.ts index d573d2e90..f16c208ee 100644 --- a/scripts/ci/node-test-shard.test.mjs +++ b/scripts/ci/node-test-shard.test.ts @@ -5,9 +5,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import test from "node:test"; +import type { TestContext } from "node:test"; import { runNodeTestFiles } from "./node-test-shard.mjs"; -async function fixture(t, sources) { +async function fixture(t: TestContext, sources: string[]) { const cwd = await mkdtemp(join(tmpdir(), "ling-node-runner-")); t.after(() => rm(cwd, { recursive: true, force: true })); await Promise.all([mkdir(join(cwd, "test")), mkdir(join(cwd, "isolation"))]); @@ -17,7 +18,7 @@ async function fixture(t, sources) { options: { cwd, reportPath, stdio: "ignore", temporaryDirectory: join(cwd, "isolation"), terminationGraceMs: 100 } }; } -async function waitFor(check) { +async function waitFor(check: () => Promise) { const deadline = AbortSignal.timeout(5000); for (;;) { const result = await check(); @@ -66,6 +67,48 @@ test("serial files never reuse a previous file's runtime home or workspace", asy assert.notEqual(records[0].workspace, records[1].workspace); }); +test("an idle worker takes the next weighted file while another worker is still busy", async t => { + const f = await fixture(t, [ + `import { access, writeFile } from 'node:fs/promises'; + import { setTimeout } from 'node:timers/promises'; import test from 'node:test'; + test('first worker finishes', { timeout: 5000 }, async context => { + for (;;) { + try { await access('busy-started'); break; } + catch (error) { if (error.code !== 'ENOENT') throw error; } + await setTimeout(10, undefined, { signal: context.signal }); + } + await writeFile('first-finished', 'yes'); + });`, + `import { access, writeFile } from 'node:fs/promises'; + import { setTimeout } from 'node:timers/promises'; import test from 'node:test'; + test('busy worker needs queued file', { timeout: 5000 }, async context => { + await writeFile('busy-started', 'yes'); + for (;;) { + try { await access('released'); break; } + catch (error) { if (error.code !== 'ENOENT') throw error; } + await setTimeout(10, undefined, { signal: context.signal }); + } + });`, + `import { access, writeFile } from 'node:fs/promises'; import test from 'node:test'; + test('queued file releases busy worker', async () => { + await access('busy-started'); await access('first-finished'); + await writeFile('released', 'yes'); + });`, + ]); + const timings = Object.fromEntries(f.files.map((file, index) => [file, [100000, 90000, 10000][index]])); + assert.equal(await runNodeTestFiles([...f.files].reverse(), { + ...f.options, concurrency: 2, timings, + }), 0); + const report = JSON.parse(await readFile(f.reportPath, "utf8")); + assert.equal(report.complete, true); + assert.equal(report.results.length, 3); + assert.ok(report.results.every((result: { status: string }) => result.status === "passed")); + const workers = new Map(report.results.map((result: { file: string; worker: number }) => [result.file, result.worker])); + assert.equal(workers.get(f.files[0]), workers.get(f.files[2])); + assert.notEqual(workers.get(f.files[0]), workers.get(f.files[1])); + assert.deepEqual(await readdir(f.options.temporaryDirectory), []); +}); + test("file deadlines kill SIGTERM-resistant descendants, fail the file and continue", async t => { const f = await fixture(t, [ `import { spawn } from 'node:child_process'; import { writeFileSync } from 'node:fs'; import test from 'node:test'; @@ -79,11 +122,11 @@ test("file deadlines kill SIGTERM-resistant descendants, fail the file and conti assert.equal(await runNodeTestFiles(f.files, { ...f.options, fileTimeoutMs: 800 }), 1); const report = JSON.parse(await readFile(f.reportPath, "utf8")); assert.equal(report.complete, true); - assert.deepEqual(report.results.map(result => result.status), ["timed_out", "passed"]); + assert.deepEqual(report.results.map((result: { status: string }) => result.status), ["timed_out", "passed"]); const pid = Number(await readFile(join(f.cwd, "child-pid"), "utf8")); await waitFor(async () => { try { process.kill(pid, 0); return false; } - catch (error) { assert.equal(error.code, "ESRCH"); return true; } + catch (error) { assert.equal((error as NodeJS.ErrnoException).code, "ESRCH"); return true; } }); assert.deepEqual(await readdir(f.options.temporaryDirectory), []); }); @@ -99,19 +142,19 @@ for (const timeout of [false, true]) test(`detached descendants cannot survive $ });`, "import test from 'node:test'; test('next file still runs', () => {});", ]); - let pid; + let pid = 0; t.after(() => { - if (pid) { try { process.kill(pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; } } + if (pid) { try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } } }); let code; try { code = await runNodeTestFiles(f.files, { ...f.options, fileTimeoutMs: 800 }); } finally { pid = Number(await readFile(join(f.cwd, "detached-pid"), "utf8").catch(() => 0)); } assert.equal(code, 1); const report = JSON.parse(await readFile(f.reportPath, "utf8")); - assert.deepEqual(report.results.map(result => result.status), [timeout ? "timed_out" : "leaked_processes", "passed"]); + assert.deepEqual(report.results.map((result: { status: string }) => result.status), [timeout ? "timed_out" : "leaked_processes", "passed"]); await waitFor(async () => { try { process.kill(pid, 0); return false; } - catch (error) { assert.equal(error.code, "ESRCH"); return true; } + catch (error) { assert.equal((error as NodeJS.ErrnoException).code, "ESRCH"); return true; } }); }); @@ -131,29 +174,32 @@ test("cancellation reaps a detached child with a cleared environment without tou const controller = new AbortController(); const run = runNodeTestFiles(f.files, { ...f.options, signal: controller.signal }); const pid = Number(await waitFor(() => readFile(join(f.cwd, "detached-pid"), "utf8").catch(() => false))); - t.after(() => { try { process.kill(pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; } }); + t.after(() => { try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } }); controller.abort("fixture cancelled"); assert.equal(await run, 1); + assert.ok(unrelated.pid); assert.equal(process.kill(unrelated.pid, 0), true); await waitFor(async () => { try { process.kill(pid, 0); return false; } - catch (error) { assert.equal(error.code, "ESRCH"); return true; } + catch (error) { assert.equal((error as NodeJS.ErrnoException).code, "ESRCH"); return true; } }); }); -test("cancellation stops active files, does not launch queued files, and retains incomplete evidence", async t => { +for (const concurrency of [1, 2]) test(`cancellation stops ${concurrency} active files without launching queued files`, async t => { const f = await fixture(t, [ - "import { writeFileSync } from 'node:fs'; writeFileSync('ready', 'yes'); setInterval(() => {}, 1000);", + ...Array.from({ length: concurrency }, (_, index) => + `import { writeFileSync } from 'node:fs'; writeFileSync('ready-${index}', 'yes'); setInterval(() => {}, 1000);`), "throw new Error('cancelled queue must not launch');", ]); const controller = new AbortController(); - const run = runNodeTestFiles(f.files, { ...f.options, signal: controller.signal }); - await waitFor(async () => readFile(join(f.cwd, "ready"), "utf8").catch(() => false)); + const run = runNodeTestFiles(f.files, { ...f.options, concurrency, signal: controller.signal }); + await Promise.all(Array.from({ length: concurrency }, (_, index) => + waitFor(() => readFile(join(f.cwd, `ready-${index}`), "utf8").catch(() => false)))); controller.abort(new Error("fixture cancelled")); assert.equal(await run, 1); const report = JSON.parse(await readFile(f.reportPath, "utf8")); assert.equal(report.complete, false); - assert.deepEqual(report.results.map(result => result.status), ["cancelled", "cancelled"]); + assert.deepEqual(report.results.map((result: { status: string }) => result.status), Array(concurrency + 1).fill("cancelled")); assert.deepEqual(await readdir(f.options.temporaryDirectory), []); }); @@ -162,7 +208,7 @@ test("the shard deadline bounds the entire queue independently of file deadlines assert.equal(await runNodeTestFiles(f.files, { ...f.options, fileTimeoutMs: 10000, shardTimeoutMs: 500 }), 1); const report = JSON.parse(await readFile(f.reportPath, "utf8")); assert.equal(report.complete, false); - assert.deepEqual(report.results.map(result => result.status), ["cancelled", "cancelled"]); + assert.deepEqual(report.results.map((result: { status: string }) => result.status), ["cancelled", "cancelled"]); }); test("an already cancelled shard cannot become green, including an empty assignment", async t => { diff --git a/scripts/ci/package-script-coverage.test.mjs b/scripts/ci/package-script-coverage.test.mjs index 1a9792315..83c3a4606 100644 --- a/scripts/ci/package-script-coverage.test.mjs +++ b/scripts/ci/package-script-coverage.test.mjs @@ -20,8 +20,7 @@ for (const directory of ["apps", "packages"]) { } } -// The workflow runner uses --if-present: a selected package without its CI -// script otherwise reports success without checking that package at all. +// Static coverage complements the runner's fail-closed selected-script check. for (const [task, selected] of [["typecheck:ci", scope.typecheckPackages], ["test:ci", scope.testPackages]]) { test(`every package selected for ${task} has an executable CI entry`, () => { assert.ok(selected.length > 0, "full regression must select workspace packages"); diff --git a/scripts/ci/platform-lanes.test.mjs b/scripts/ci/platform-lanes.test.mjs index 08ccfc80d..02f997b07 100644 --- a/scripts/ci/platform-lanes.test.mjs +++ b/scripts/ci/platform-lanes.test.mjs @@ -27,7 +27,8 @@ function runQuality(overrides = {}) { SCOPE_RESULT: "success", COMMON_RESULT: "success", DEPENDENCY_RESULT: "success", BUILD_RESULT: "success", TYPECHECK_RESULT: "success", UNIT_RESULT: "success", DAEMON_RESULT: "success", LINUX_CONTRACT_RESULT: "success", - MACOS_NATIVE_RESULT: "success", DAEMON_SELECTED: "true", ...overrides }, + MACOS_NATIVE_RESULT: "success", DAEMON_SELECTED: "true", DEPENDENCY_SELECTED: "true", + TYPECHECK_SELECTED: "true", UNIT_SELECTED: "true", ...overrides }, }); } @@ -76,8 +77,8 @@ for (const lane of ["DAEMON", "LINUX_CONTRACT", "MACOS_NATIVE"]) { } } -test("out-of-scope platform skips do not block documentation or Web-only changes", () => { - const result = runQuality({ DAEMON_SELECTED: "false", DAEMON_RESULT: "skipped", +test("out-of-scope platform skips do not block affected Web-only changes", () => { + const result = runQuality({ SCOPE_MODE: "affected", DAEMON_SELECTED: "false", DAEMON_RESULT: "skipped", LINUX_CONTRACT_RESULT: "skipped", MACOS_NATIVE_RESULT: "skipped" }); assert.equal(result.status, 0, result.stderr); }); @@ -96,6 +97,7 @@ test("workflow quality gate keeps a Draft full scope deferred after common check test("workflow documentation gate requires successful scope and common checks", () => { const docs = { SCOPE_MODE: "docs", DAEMON_SELECTED: "false", DEPENDENCY_RESULT: "skipped", + DEPENDENCY_SELECTED: "false", TYPECHECK_SELECTED: "false", UNIT_SELECTED: "false", BUILD_RESULT: "skipped", TYPECHECK_RESULT: "skipped", UNIT_RESULT: "skipped", DAEMON_RESULT: "skipped", LINUX_CONTRACT_RESULT: "skipped", MACOS_NATIVE_RESULT: "skipped" }; assert.equal(runQuality(docs).status, 0); @@ -108,7 +110,7 @@ test("workflow documentation gate requires successful scope and common checks", } }); -test("workflow non-daemon scope cannot pass with skipped build or unit tests", () => { +test("workflow non-daemon scope cannot pass with skipped build or selected unit tests", () => { for (const lane of ["BUILD", "UNIT"]) { const check = runQuality({ SCOPE_MODE: "affected", DAEMON_SELECTED: "false", DAEMON_RESULT: "skipped", LINUX_CONTRACT_RESULT: "skipped", MACOS_NATIVE_RESULT: "skipped", @@ -117,3 +119,28 @@ test("workflow non-daemon scope cannot pass with skipped build or unit tests", ( assert.ok(check.stdout.includes(`${lane}=skipped`), check.stdout); } }); + +test("workflow affected platform-only scope may skip unselected product lanes but never build", () => { + const platformOnly = { SCOPE_MODE: "affected", DEPENDENCY_SELECTED: "false", TYPECHECK_SELECTED: "false", + UNIT_SELECTED: "false", DEPENDENCY_RESULT: "skipped", TYPECHECK_RESULT: "skipped", UNIT_RESULT: "skipped" }; + assert.equal(runQuality(platformOnly).status, 0); + for (const lane of ["BUILD", "DAEMON", "LINUX_CONTRACT", "MACOS_NATIVE"]) { + const check = runQuality({ ...platformOnly, [`${lane}_RESULT`]: "skipped" }); + assert.equal(check.status, 1, check.stdout + check.stderr); + assert.match(check.stdout, new RegExp(`${lane}=skipped`, "u")); + } + for (const lane of ["DEPENDENCY", "TYPECHECK", "UNIT"]) { + for (const result of ["failure", "cancelled", "", undefined, "pending"]) { + const check = runQuality({ ...platformOnly, [`${lane}_RESULT`]: result }); + assert.equal(check.status, 1, check.stdout + check.stderr); + assert.match(check.stdout, new RegExp(`${lane}=`, "u")); + } + } +}); + +test("workflow full scope cannot opt out of any platform lane", () => { + const check = runQuality({ DAEMON_SELECTED: "false", DAEMON_RESULT: "skipped", + LINUX_CONTRACT_RESULT: "skipped", MACOS_NATIVE_RESULT: "skipped" }); + assert.equal(check.status, 1, check.stdout + check.stderr); + assert.match(check.stdout, /full.*DAEMON_SELECTED/u); +}); diff --git a/scripts/ci/run-packages.mjs b/scripts/ci/run-packages.mjs index 94ab038f6..a78b5e7b1 100644 --- a/scripts/ci/run-packages.mjs +++ b/scripts/ci/run-packages.mjs @@ -1,4 +1,7 @@ import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; /** * Run one CI-only package script without invoking the package's local build @@ -21,11 +24,38 @@ if (packages.length === 0) { process.exit(0); } +// Build closures may include artifact-free workspaces (for example desktop). +// Selected validation tasks must never succeed by silently skipping a package. +if (!includeDependencies) { + const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + const manifests = new Map(); + for (const workspace of ["apps", "packages"]) { + const directory = resolve(root, workspace); + if (!existsSync(directory)) continue; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const path = resolve(directory, entry.name, "package.json"); + if (!existsSync(path)) continue; + const manifest = JSON.parse(readFileSync(path, "utf8")); + manifests.set(manifest.name, manifest); + } + } + const missing = packages.filter(name => { + const script = manifests.get(name)?.scripts?.[task]; + return typeof script !== "string" || script.trim().length === 0; + }); + if (missing.length) { + console.error(`Selected packages missing ${task}: ${missing.join(", ")}`); + process.exit(1); + } +} + const pnpmArgs = ["pnpm", "--workspace-concurrency=1"]; for (const packageName of packages) { pnpmArgs.push("--filter", includeDependencies ? `${packageName}...` : packageName); } -pnpmArgs.push("--if-present", "run", task); +if (includeDependencies) pnpmArgs.push("--if-present"); +pnpmArgs.push("run", task); const result = spawnSync("corepack", pnpmArgs, { stdio: "inherit" }); if (result.error) throw result.error; diff --git a/scripts/ci/run-packages.test.mjs b/scripts/ci/run-packages.test.mjs new file mode 100644 index 000000000..2d30126f7 --- /dev/null +++ b/scripts/ci/run-packages.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +function fixture(t, scripts) { + const root = mkdtempSync(join(tmpdir(), "ling-ci-packages-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, "scripts/ci"), { recursive: true }); + mkdirSync(join(root, "packages/probe"), { recursive: true }); + mkdirSync(join(root, "bin"), { recursive: true }); + const runner = join(root, "scripts/ci/run-packages.mjs"); + copyFileSync(new URL("./run-packages.mjs", import.meta.url), runner); + writeFileSync(join(root, "packages/probe/package.json"), JSON.stringify({ name: "@ling-stack/probe", scripts })); + writeFileSync(join(root, "bin/corepack"), '#!/bin/sh\nprintf "invoked:%s\\n" "$@"\n', { mode: 0o755 }); + return (...args) => spawnSync(process.execPath, [runner, ...args], { + cwd: root, encoding: "utf8", env: { ...process.env, PATH: `${join(root, "bin")}:${process.env.PATH}` }, + }); +} + +test("selected validation packages must have a nonempty script before pnpm can execute", (t) => { + for (const scripts of [{}, { "test:ci": "" }, { "test:ci": " " }]) { + const run = fixture(t, scripts); + const result = run("--task=test:ci", '--packages=["@ling-stack/probe"]'); + assert.equal(result.status, 1); + assert.match(result.stderr, /Selected packages missing test:ci: @ling-stack\/probe/u); + assert.doesNotMatch(result.stdout, /invoked:/u); + } +}); + +test("unknown selections fail even when other selected packages have scripts", (t) => { + const result = fixture(t, { "typecheck:ci": "tsc" })("--task=typecheck:ci", + '--packages=["@ling-stack/probe","@ling-stack/missing"]'); + assert.equal(result.status, 1); + assert.match(result.stderr, /missing typecheck:ci: @ling-stack\/missing/u); + assert.doesNotMatch(result.stdout, /invoked:/u); +}); + +test("valid selected tasks run without if-present, and empty selections do no work", (t) => { + const run = fixture(t, { "test:ci": "node --test" }); + const result = run("--task=test:ci", '--packages=["@ling-stack/probe"]'); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /invoked:run\ninvoked:test:ci/u); + assert.doesNotMatch(result.stdout, /--if-present/u); + const empty = run("--task=test:ci", '--packages=["@ling-stack/probe"]', '--exclude=["@ling-stack/probe"]'); + assert.equal(empty.status, 0, empty.stderr); + assert.match(empty.stdout, /No packages selected/u); + assert.doesNotMatch(empty.stdout, /invoked:/u); +}); + +test("build closures keep artifact-free dependency workspaces optional", (t) => { + const result = fixture(t, {})("--task=build:ci", '--packages=["@ling-stack/probe"]', "--closure=true"); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /invoked:@ling-stack\/probe\.\.\./u); + assert.match(result.stdout, /invoked:--if-present/u); +}); diff --git a/scripts/ci/runtime-baseline.test.mjs b/scripts/ci/runtime-baseline.test.mjs index f5d0e6dbe..d21b564dd 100644 --- a/scripts/ci/runtime-baseline.test.mjs +++ b/scripts/ci/runtime-baseline.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { readFileSync, readdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -8,16 +8,63 @@ import test from "node:test"; const root = new URL("../../", import.meta.url); const manifest = JSON.parse(readFileSync(new URL("package.json", root), "utf8")); +const minimumNodeVersion = manifest.engines.node.match(/^>=(\d+\.\d+\.\d+)$/u)?.[1]; + +function assertNodeSetupVersions(workflow, name) { + let checked = 0; + // Keep each pin inside its own block-style step, not a later step or job. + for (const [, indent, body] of workflow.matchAll(/^( *)- ([^\n]*(?:\n(?:\1 {2}[^\n]*|[ \t]*$))*)/gmu)) { + const step = body.replace(new RegExp(`^ {${indent.length + 2}}`, "gmu"), ""); + if (!/^uses: ["']?actions\/setup-node@/mu.test(step)) continue; + const inputs = /^with: *\n((?: {2}[^\n]*(?:\n|$)|[ \t]*\n)*)/mu.exec(step)?.[1] ?? ""; + const versions = [...inputs.matchAll(/^ {2}node-version: (["']?)([^"'\s]+)\1 *(?:#.*)?$/gmu)] + .map(match => match[2]); + assert.deepEqual(versions, [minimumNodeVersion], `${name}: setup-node step ${checked + 1} must pin the runtime floor`); + checked++; + } + const references = [...workflow.matchAll(/\buses: *["']?actions\/setup-node@/gu)].length; + assert.equal(checked, references, `${name}: every setup-node reference must be checked as a step`); + return checked; +} // Value: protects=the documented minimum runtime supports actual Host transaction fences and source imports; // fails_when=CI or the product promises a Node release without these required APIs; // why_new=CI previously pinned 22.15 while local validation used 22.22.1; seam=none test("product runtime floor and every CI Node setup share the validated baseline", () => { assert.equal(manifest.engines.node, ">=22.22.1"); - const workflow = readFileSync(new URL(".github/workflows/ci.yml", root), "utf8"); - const versions = [...workflow.matchAll(/node-version: (\S+)/g)].map(match => match[1]); - assert.ok(versions.length > 0, "CI must explicitly pin a Node runtime"); - assert.deepEqual([...new Set(versions)], [manifest.engines.node.slice(2)]); + const directory = new URL(".github/workflows/", root); + const workflows = readdirSync(directory).filter(name => /\.ya?ml$/u.test(name)); + assert.ok(workflows.length > 0, "CI workflows must exist"); + let checked = 0; + for (const name of workflows) { + checked += assertNodeSetupVersions(readFileSync(new URL(name, directory), "utf8"), name); + } + assert.ok(checked > 0, "CI must explicitly pin a Node runtime"); +}); + +function setupStep(version) { + return ` - uses: actions/setup-node@pinned\n with:\n cache: pnpm\n${version === undefined ? "" : ` node-version: ${version}\n`}`; +} + +test("baseline checks named and unnamed setup-node steps with quoted or unquoted pins", () => { + const workflow = setupStep(minimumNodeVersion) + + ` - name: Set up Node\n with:\n node-version: '${minimumNodeVersion}'\n uses: actions/setup-node@pinned\n` + + ` another-job:\n steps:\n${setupStep(`"${minimumNodeVersion}"`)}`; + assert.equal(assertNodeSetupVersions(workflow, "fixture"), 3); +}); + +for (const version of [undefined, "22.15.0", "22", "${{ matrix.node }}"]) { + test(`baseline rejects a setup-node step with ${version ?? "no node-version"}`, () => { + const workflow = setupStep(version) + setupStep(minimumNodeVersion); + assert.throws(() => assertNodeSetupVersions(workflow, "fixture"), /must pin the runtime floor/u); + }); +} + +test("baseline rejects a pin outside setup-node inputs or in an unsupported step shape", () => { + const misplaced = setupStep(undefined) + ` env:\n node-version: ${minimumNodeVersion}\n`; + assert.throws(() => assertNodeSetupVersions(misplaced, "fixture"), /must pin the runtime floor/u); + const inline = ` - { uses: actions/setup-node@pinned, with: { node-version: ${minimumNodeVersion} } }\n`; + assert.throws(() => assertNodeSetupVersions(inline, "fixture"), /every setup-node reference/u); }); test("the runtime exposes real SQLite transaction state used by tracked-launch fences", () => { diff --git a/scripts/ci/runtime-workflow.test.mjs b/scripts/ci/runtime-workflow.test.ts similarity index 60% rename from scripts/ci/runtime-workflow.test.mjs rename to scripts/ci/runtime-workflow.test.ts index 6029a3957..02974f2a9 100644 --- a/scripts/ci/runtime-workflow.test.mjs +++ b/scripts/ci/runtime-workflow.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import test from "node:test"; const workflow = readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8"); -function job(id) { +function job(id: string) { const match = new RegExp(`^ ${id}:\\n([\\s\\S]*?)(?=^ [a-z][a-z-]*:|$(?![\\s\\S]))`, "mu").exec(workflow); assert.ok(match, id); return match[1]; @@ -50,6 +50,46 @@ test("the aggregate verifies reports before caching only default-branch successf assert.doesNotMatch(quality, /continue-on-error/u); }); +test("test-complete timing evidence survives unrelated gate failures without weakening the gate", () => { + const quality = job("quality"); + for (const name of [ + "Download the shared test plan for coverage verification", + "Download all shard reports", + "Verify every planned file passed exactly once and collect timings", + ]) { + const start = quality.indexOf(` - name: ${name}\n`); + assert.ok(start >= 0, name); + const end = quality.indexOf("\n - name:", start + 1); + const step = quality.slice(start, end < 0 ? undefined : end); + assert.match(step, /if: \$\{\{ !cancelled\(\) && needs\.scope\.result == 'success'/u, name); + assert.match(step, /needs\.scope\.outputs\.execution-tier == 'complete'/u, name); + assert.match(step, /needs\.scope\.outputs\.daemon == 'true' \|\| contains\(needs\.scope\.outputs\.test-packages, '"@ling-stack\/core"'\)/u, name); + } + assert.match(quality, /name: Verify every planned file passed exactly once and collect timings\n id: test-timings/u); + const save = quality.slice(quality.indexOf(" - name: Save trusted default-branch test timings\n")); + assert.match(save, /if: \$\{\{ !cancelled\(\) && steps\.test-timings\.outcome == 'success'/u); + assert.match(save, /github\.ref == format\('refs\/heads\/\{0\}', github\.event\.repository\.default_branch\)/u); + assert.doesNotMatch(quality, /continue-on-error/u); +}); + +test("CI tools' TypeScript tests are discovered and typechecked in the required lanes", () => { + assert.match(job("common-fast"), /node --test[^\n]*scripts\/ci\/\*\.test\.ts/u); + assert.match(job("typecheck"), /run: corepack pnpm typecheck:ci-tests/u); + const manifest = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")); + assert.match(manifest.scripts["typecheck:ci-tests"], /tsc -p scripts\/ci\/tsconfig\.test\.json/u); + assert.match(manifest.scripts.typecheck, /typecheck:ci-tests/u); + const config = JSON.parse(readFileSync(new URL("./tsconfig.test.json", import.meta.url), "utf8")); + assert.deepEqual(config.include, ["*.test.ts"]); + assert.equal(config.compilerOptions.strict, true); + assert.equal(config.compilerOptions.noEmit, true); + assert.equal(config.compilerOptions.module, "NodeNext"); + const daemon = JSON.parse(readFileSync(new URL("../../packages/daemon/package.json", import.meta.url), "utf8")); + assert.match(daemon.scripts["test:ci"], /test\/\*\.test\.ts/u); + assert.match(daemon.scripts["typecheck:ci"], /tsc -p tsconfig\.test\.json/u); + assert.match(daemon.scripts.typecheck, /pnpm typecheck:ci/u); + assert.match(daemon.scripts.test, /pnpm test:ci/u); +}); + test("complete PR history scans ignore docs-only final diffs while Draft previews stay deferred", () => { const common = job("common-fast"); assert.doesNotMatch(common, /^ if:/mu, "the common job must not bypass history scans based on file scope"); diff --git a/scripts/ci/test-shard-placement.test.ts b/scripts/ci/test-shard-placement.test.ts new file mode 100644 index 000000000..99b02edf7 --- /dev/null +++ b/scripts/ci/test-shard-placement.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { discoverDaemonTestFiles } from "./daemon-test-shard.mjs"; +import { assertSuiteCoverage, assignTestShards, createTestPlan, discoverTestFiles, testSuites } from "./test-shard-plan.mjs"; + +test("shared and daemon discovery include TypeScript tests without treating helpers as tests", async (t) => { + const root = await mkdtemp(join(tmpdir(), "ling-test-discovery-")); + t.after(() => rm(root, { recursive: true, force: true })); + const directory = join(root, "test"); + await mkdir(directory); + for (const file of ["legacy.test.mjs", "typed.test.ts", "helper.ts", "dependency-security.test.mjs"]) { + await writeFile(join(directory, file), ""); + } + const expected = ["test/legacy.test.mjs", "test/typed.test.ts"]; + assert.deepEqual(await discoverTestFiles(root, testSuites.daemon.excluded), expected); + assert.deepEqual(await discoverDaemonTestFiles(directory), expected); +}); + +test("outer placement accounts for all workers occupied by an exclusive file", () => { + const exclusive = "test/exclusive.test.mjs"; + const ordinary = Array.from({ length: 12 }, (_, index) => `test/ordinary-${String(index).padStart(2, "0")}.test.mjs`); + const files = [exclusive, ...ordinary]; + const timings = Object.fromEntries(files.map(file => [file, file === exclusive ? 60000 : 10000])); + const options = { timings, concurrency: 2, exclusive: [exclusive] }; + const shards = assignTestShards(files, 2, options); + assert.deepEqual(shards.map(shard => shard.files), [[exclusive], ordinary]); + assert.deepEqual(shards.map(shard => shard.weight), [120, 120]); + assert.deepEqual(assignTestShards([...files].reverse(), 2, options), shards); + assert.deepEqual(shards.flatMap(shard => shard.files).sort(), files.sort()); + assert.deepEqual(assignTestShards(files, 2, { ...options, concurrency: 1 }), + assignTestShards(files, 2, { timings })); + for (const concurrency of [0, -1, 1.5, NaN, Infinity]) { + assert.throws(() => assignTestShards(files, 2, { ...options, concurrency }), /positive integer/u); + } +}); + +test("published plans apply exclusive cost without altering measured timing evidence", async () => { + const base = await createTestPlan(["@ling-stack/daemon"]); + const config = testSuites.daemon; + const durationsMs = Object.fromEntries(base.suites.daemon.files.map((file: string) => + [file, config.exclusive.includes(file) ? 60000 : 1000])); + const plan = await createTestPlan(["@ling-stack/daemon"], { timings: { suites: { + daemon: { platform: config.platform, durationsMs }, + } } }); + const suite = plan.suites.daemon; + assert.ok(suite.files.includes("test/recovery-discovery-prefix.test.ts")); + assert.deepEqual(suite.shards, assignTestShards(suite.files, config.shardCount, { + cwd: config.cwd, timings: durationsMs, exclusive: config.exclusive, concurrency: config.concurrency, + })); + const expectedCost = Object.entries(durationsMs).reduce((sum, [file, duration]) => + sum + duration / 1000 * (config.exclusive.includes(file) ? config.concurrency : 1), 0); + assert.equal(suite.shards.reduce((sum: number, shard: { weight: number }) => sum + shard.weight, 0), expectedCost); + assert.deepEqual(suite.timings, durationsMs); + assert.doesNotThrow(() => assertSuiteCoverage(suite, base.suites.daemon.files)); +}); diff --git a/scripts/ci/test-shard-plan.mjs b/scripts/ci/test-shard-plan.mjs index 71ff05c8f..a73ad83bf 100644 --- a/scripts/ci/test-shard-plan.mjs +++ b/scripts/ci/test-shard-plan.mjs @@ -25,7 +25,7 @@ export const testSuites = { }; export async function discoverTestFiles(cwd, excluded = []) { - return (await readdir(join(cwd, "test"))).filter(file => file.endsWith(".test.mjs") && !excluded.includes(file)) + return (await readdir(join(cwd, "test"))).filter(file => /\.test\.(?:mjs|ts)$/u.test(file) && !excluded.includes(file)) .sort().map(file => `test/${file}`); } @@ -39,10 +39,19 @@ export function estimateTestWeight(file, { cwd = testSuites.daemon.cwd, timings catch (error) { if (error.code !== "ENOENT") throw error; return floor; } } -export function assignTestShards(files, shardCount, options) { +/** + * @param {string[]} files + * @param {number} shardCount + * @param {{ cwd?: string, timings?: Record, exclusive?: string[], concurrency?: number }} [options] + */ +export function assignTestShards(files, shardCount, options = {}) { if (!Number.isInteger(shardCount) || shardCount < 1) throw new RangeError("shardCount must be a positive integer"); + const concurrency = options.concurrency ?? 1; + if (!Number.isSafeInteger(concurrency) || concurrency < 1) throw new RangeError("concurrency must be a positive integer"); const buckets = Array.from({ length: shardCount }, () => ({ files: [], weight: 0 })); - const weighted = [...new Set(files)].map(file => ({ file, weight: estimateTestWeight(file, options) })) + // Exclusive files occupy the whole runner; ordinary files use one worker. + const weighted = [...new Set(files)].map(file => ({ file, weight: estimateTestWeight(file, options) + * (options.exclusive?.includes(file) ? concurrency : 1) })) .sort((a, b) => b.weight - a.weight || a.file.localeCompare(b.file)); for (const item of weighted) { let target = 0; @@ -68,6 +77,8 @@ export async function loadTimingHints(path) { export async function createTestPlan(packages, { timings = { suites: {} } } = {}) { if (!Array.isArray(packages) || packages.some(name => typeof name !== "string")) throw new TypeError("packages must be a string array"); + /** @type {Record, shards: ReturnType }>} */ const suites = {}; for (const [name, config] of Object.entries(testSuites)) { if (!packages.includes(config.packageName)) continue; @@ -76,10 +87,11 @@ export async function createTestPlan(packages, { timings = { suites: {} } } = {} const hints = timings.suites?.[name]; const durations = hints?.platform === config.platform ? hints.durationsMs : {}; suites[name] = { platform: config.platform, files, shardCount: config.shardCount, concurrency: config.concurrency, timings: durations ?? {}, - shards: assignTestShards(files, config.shardCount, { cwd: config.cwd, timings: durations ?? {} }) }; + shards: assignTestShards(files, config.shardCount, { ...config, timings: durations ?? {} }) }; } + const shardedPackages = Object.values(testSuites).map(config => config.packageName); return { version: 1, suites, unitMatrix: { include: [ - { suite: "packages", shard: 0 }, + ...(packages.some(name => !shardedPackages.includes(name)) ? [{ suite: "packages", shard: 0 }] : []), ...Array.from({ length: suites.core?.shardCount ?? 0 }, (_, shard) => ({ suite: "core", shard })), ] } }; } diff --git a/scripts/ci/test-shard-plan.test.mjs b/scripts/ci/test-shard-plan.test.mjs index 4576e036d..d22f51604 100644 --- a/scripts/ci/test-shard-plan.test.mjs +++ b/scripts/ci/test-shard-plan.test.mjs @@ -12,8 +12,8 @@ test("core and daemon plans preserve full discovery exactly once with dynamic co for (const packages of [[], ["@ling-stack/core"], ["@ling-stack/daemon"], ["@ling-stack/core", "@ling-stack/daemon"]]) { const plan = await createTestPlan(packages); assert.deepEqual(Object.keys(plan.suites), Object.keys(testSuites).filter(name => packages.includes(testSuites[name].packageName))); - assert.equal(plan.unitMatrix.include.length, packages.includes("@ling-stack/core") ? 5 : 1); - assert.deepEqual(plan.unitMatrix.include[0], { suite: "packages", shard: 0 }); + assert.deepEqual(plan.unitMatrix.include, packages.includes("@ling-stack/core") + ? Array.from({ length: 4 }, (_, shard) => ({ suite: "core", shard })) : []); for (const [name, suite] of Object.entries(plan.suites)) { const discovered = await discoverTestFiles(testSuites[name].cwd, testSuites[name].excluded); assert.ok(discovered.length > 100); @@ -24,6 +24,17 @@ test("core and daemon plans preserve full discovery exactly once with dynamic co } }); +test("only selected unsharded packages create the packages job", async () => { + for (const name of ["@ling-stack/web", "@ling-stack/cli", "@ling-stack/mobile"]) { + const plan = await createTestPlan([name, "@ling-stack/daemon"]); + assert.deepEqual(plan.unitMatrix.include, [{ suite: "packages", shard: 0 }]); + assert.deepEqual(Object.keys(plan.suites), ["daemon"]); + } + const mixed = await createTestPlan(["@ling-stack/web", "@ling-stack/core", "@ling-stack/daemon"]); + assert.deepEqual(mixed.unitMatrix.include, [{ suite: "packages", shard: 0 }, + ...Array.from({ length: 4 }, (_, shard) => ({ suite: "core", shard }))]); +}); + test("measured durations balance placement deterministically without changing coverage", () => { const files = ["test/a.test.mjs", "test/b.test.mjs", "test/c.test.mjs", "test/new.test.mjs"]; const options = { timings: { [files[0]]: 100_000, [files[1]]: 90_000, [files[2]]: 10_000 } }; diff --git a/scripts/ci/tsconfig.test.json b/scripts/ci/tsconfig.test.json new file mode 100644 index 000000000..74e65ed90 --- /dev/null +++ b/scripts/ci/tsconfig.test.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["*.test.ts"] +} From c1a7d636e9f476b3da3d90591cec5a70062b00d7 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Tue, 3 Nov 2026 02:53:16 +0800 Subject: [PATCH 2/4] chore: bump version and changelog (v0.2.2) Co-Authored-By: OpenAI Codex --- CHANGELOG.md | 15 +++++++++++++++ VERSION | 2 +- apps/desktop/package.json | 2 +- package.json | 2 +- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 429f72beb..43fda8bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ open-source baseline onward. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and future release versions will follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.2] - 2026-11-03 + +### Changed + +- Pull requests run checks affected by their complete change set, avoiding unrelated + test suites while retaining full regression for shared infrastructure and scheduled + or manual runs. The CI maintenance guide documents the selection rules. +- Daemon validation avoids repeated workspace setup and idle file workers without + reducing recovery, security or native-platform coverage. + +### Fixed + +- Selected CI checks fail when skipped or missing, preventing incomplete validation + from appearing successful. + ## [0.2.1] - 2026-11-02 ### Fixed diff --git a/VERSION b/VERSION index 0c62199f1..ee1372d33 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.1 +0.2.2 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 447d566cd..bfe989a78 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@ling-stack/desktop", - "version": "0.2.1", + "version": "0.2.2", "description": "Ling Stack desktop shell with shared Web UI and Computer Use picture-in-picture.", "license": "MIT", "author": "Ling Stack contributors", diff --git a/package.json b/package.json index cb60a4e1e..f2c64a887 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ling-stack", "private": true, - "version": "0.2.1", + "version": "0.2.2", "description": "Multilingual local-first AI teammate workspace with first-class domestic AI providers, GPT, Claude, and OpenAI-compatible relays.", "license": "MIT", "type": "module", From e6268eaea12654b3ce577a7b5a993b94f5ac6204 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Tue, 3 Nov 2026 03:45:56 +0800 Subject: [PATCH 3/4] fix(ci): use a real output file in workflow tests --- scripts/ci/execution-workflow.test.mjs | 34 ++++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/scripts/ci/execution-workflow.test.mjs b/scripts/ci/execution-workflow.test.mjs index 7ce53a788..8da8dd596 100644 --- a/scripts/ci/execution-workflow.test.mjs +++ b/scripts/ci/execution-workflow.test.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { classifyChangedFiles } from "./classify-scope.mjs"; @@ -42,18 +44,24 @@ function runOutputTransform(variable, value) { const end = source.indexOf(" NODE\n", start); assert.notEqual(end, -1, `Missing ${variable} heredoc terminator`); const command = source.slice(start, end + " NODE\n".length).replace(/^ {10}/gmu, ""); - const result = spawnSync("bash", ["-euo", "pipefail", "-c", command], { - cwd: root, encoding: "utf8", timeout: 10_000, - env: { ...process.env, scope_json: value, unit_matrix: value, GITHUB_OUTPUT: "/dev/stdout" }, - }); - assert.equal(result.status, 0, result.stdout + result.stderr); - const lines = result.stdout.trim().split("\n").map((line) => { - const separator = line.indexOf("="); - assert.ok(separator > 0, `Invalid workflow output: ${line}`); - return [line.slice(0, separator), line.slice(separator + 1)]; - }); - assert.equal(new Set(lines.map(([key]) => key)).size, lines.length); - return Object.fromEntries(lines); + const directory = mkdtempSync(join(tmpdir(), "ling-ci-workflow-output-")); + const outputFile = join(directory, "output"); + try { + const result = spawnSync("bash", ["-euo", "pipefail", "-c", command], { + cwd: root, encoding: "utf8", timeout: 10_000, + env: { ...process.env, scope_json: value, unit_matrix: value, GITHUB_OUTPUT: outputFile }, + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + const lines = readFileSync(outputFile, "utf8").trim().split("\n").map((line) => { + const separator = line.indexOf("="); + assert.ok(separator > 0, `Invalid workflow output: ${line}`); + return [line.slice(0, separator), line.slice(separator + 1)]; + }); + assert.equal(new Set(lines.map(([key]) => key)).size, lines.length); + return Object.fromEntries(lines); + } finally { + rmSync(directory, { recursive: true, force: true }); + } } async function assertScopeOutputs(scope, { mode, packages, daemon, web, dependency = false, matrix }) { From a54048d9a17717f4f7b66f159a71bfc97a24d0fd Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Tue, 3 Nov 2026 05:22:54 +0800 Subject: [PATCH 4/4] test(daemon): isolate login deadline from native startup --- .../execution-engine-login-routes.test.mjs | 43 +++++++++++++------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/daemon/test/execution-engine-login-routes.test.mjs b/packages/daemon/test/execution-engine-login-routes.test.mjs index be6bbff05..86dfe7939 100644 --- a/packages/daemon/test/execution-engine-login-routes.test.mjs +++ b/packages/daemon/test/execution-engine-login-routes.test.mjs @@ -78,7 +78,8 @@ test("revocation and host shutdown cancel a pending login without requiring stat }); test("HTTP timeout clears challenges and malformed input cannot start a process", { timeout: 10_000, skip: process.platform === "win32" }, async (t) => { - const f = await fixture(t, { login: { timeoutMs: 1200 } }); + const timeoutMs = 1200; + const f = await fixture(t, { login: { timeoutMs } }); for (const body of [ { expectedRevision: 1, method: "apiKey", apiKey: "never-echo-this" }, { expectedRevision: 1, method: "chatgptAuthTokens" }, @@ -90,14 +91,31 @@ test("HTTP timeout clears challenges and malformed input cannot start a process" assert.equal(f.child(), undefined); } assert.equal((await f.request(`/${engineId}/auth`, "POST", { expectedRevision: 1, method: "chatgpt" })).body.error, "engine_login_method_unavailable"); - const started = await f.request(`/${engineId}/auth`, "POST", { expectedRevision: 1, method: "chatgptDeviceCode" }); - const path = `/${engineId}/auth/${started.body.id}`; - await f.until(path, "waiting"); - const timedOut = await f.until(path, "timed_out"); - assert.equal(timedOut.challenge, undefined); - assert.equal(timedOut.errorCode, "engine_login_timed_out"); - assert.equal(existsSync(f.authHome()), false); - assert.equal((await f.request(`/${engineId}/profile`)).body.profile.revision, 1); + let elapsed = 0; + const clock = t.mock.method(performance, "now", () => elapsed); + try { + // Native startup uses real I/O; only the login deadline advances with this clock. + t.mock.timers.enable({ apis: ["setTimeout"] }); + const started = await f.request(`/${engineId}/auth`, "POST", { expectedRevision: 1, method: "chatgptDeviceCode" }); + const path = `/${engineId}/auth/${started.body.id}`; + const waiting = await f.until(path, "waiting"); + assert.equal(waiting.challenge.userCode, "ABCD-1234"); + elapsed = timeoutMs - 1; + t.mock.timers.tick(elapsed); + assert.equal((await f.request(path)).body.status, "waiting"); + elapsed = timeoutMs; + t.mock.timers.tick(1); + t.mock.timers.reset(); + const timedOut = await f.until(path, "timed_out"); + assert.equal(timedOut.challenge, undefined); + assert.equal(timedOut.errorCode, "engine_login_timed_out"); + assert.equal(existsSync(f.authHome()), false); + assert.equal((await f.request(`/${engineId}/profile`)).body.profile.revision, 1); + assert.equal(f.child().signalCode, "SIGKILL"); + } finally { + t.mock.timers.reset(); + clock.mock.restore(); + } }); test("concurrent profile references prevent adopting a completed native account", { timeout: 10_000, skip: process.platform === "win32" }, async (t) => { @@ -131,6 +149,7 @@ test("a changed profile invalidates its pending challenge instead of showing sta }); async function fixture(t, options = {}) { + const pollNow = performance.now.bind(performance), pollDelay = delay; const root = realpathSync(mkdtempSync(join(tmpdir(), "ling-login-routes-"))); let child, authHome; const paths = resolveUserConfigPaths({ userRootDir: root }); @@ -186,15 +205,15 @@ async function fixture(t, options = {}) { return (await request("/session", "POST", { code }, null)).body; }, until: async (path, status, credentials) => { - const deadline = performance.now() + (options.login?.timeoutMs ?? 5000) + 1000; + const deadline = pollNow() + (options.login?.timeoutMs ?? 5000) + 1000; let last; - while (performance.now() < deadline) { + while (pollNow() < deadline) { const current = await request(path, "GET", undefined, credentials); last = current.body; assert.equal(current.status, 200); if (current.body.status === status) return current.body; assert.equal(["failed", "cancelled", "timed_out"].includes(current.body.status), false, JSON.stringify(current.body)); - await delay(20); + await pollDelay(20); } assert.fail(`login did not reach ${status}: ${JSON.stringify(last)}`); },