From 4bb60b10692f98c1869da8cdc3e62c23e08c7968 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:27:42 +0800 Subject: [PATCH 01/32] feat(dev-infra): make gate plans inspectable and replayable --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 6 + .../2026-07-27-replayable-gate-plans.md | 43 + .../2026-07-27-replayable-gate-plans.zh.md | 43 + .github/workflows/ci.yml | 39 +- package.json | 1 + scripts/gate-log-helper.mjs | 220 ++++ scripts/publint-all.spec.ts | 6 + scripts/run-gates.spec.ts | 523 +++++++++ scripts/run-gates.ts | 1011 +++++++++++++++-- 9 files changed, 1758 insertions(+), 134 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md create mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md create mode 100644 scripts/gate-log-helper.mjs create mode 100644 scripts/run-gates.spec.ts diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml new file mode 100644 index 0000000000..3966caf9cf --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +2026-07-27-replayable-gate-plans.md: 604fb87ebe91f8ebd606d128e8927535502b6ea9 +2026-07-27-replayable-gate-plans.zh.md: e24d77ff6245bac6a06cd9f9ea8e849dafc95051 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md new file mode 100644 index 0000000000..604fb87ebe --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -0,0 +1,43 @@ +# Agent Note: Validated, self-describing, replayable gate plans + +Status: implemented + +English | [中文](2026-07-27-replayable-gate-plans.zh.md) + +## Problem + +Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. Operators also need the exact scheduler-owned environment and dependency context for a failed command; reconstructing it from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) is slow and error-prone during a CI incident. + +The Node 24 consumer job compounds this problem when it owns a separate shell process pool. Commands, concurrency, environment, and failure collection then have two executable inventories, while a restored build can be consumed before any command establishes that the downloaded artifacts are complete. + +## Decision + +[`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract. + +Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn (`set`, `unset`, or `append`), so inspection and failure metadata never enumerate or bake in inherited values; values under secret-like names are redacted. + +`--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. + +On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Every repository-relative path component must be a verified real directory before it can anchor a mutation. A dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process starts with the verified repository root as its process working directory, checks the pinned device and inode, and descends to the log directory one component at a time. It creates a missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A concurrent ancestor replacement therefore fails before the next mutation or leaves operations anchored to a verified directory instead of redirecting them. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. + +The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the shell pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. A failed restored-build validation skips later artifact consumers but does not suppress independent source diagnostics. + +## Verification + +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. + +## Alternatives considered + +- **Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. +- **Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. +- **Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. +- **Persist the complete child environment for exact replay.** Ambient runner state is incidental and can contain credentials. Replay instead records only scheduler-owned operations and reconstructs inherited state at execution time. +- **Add an eighth archive-manifest validator to the consumer plan.** Publint already rejects missing manifest-declared public exports, while the built-package invariant verifier loads every package's compiled invariant, its declared runtime chunks, and the restored Loader bundle. Chaining those existing commands keeps the seven-command inventory and gives later artifact consumers both checks without another executable inventory entry. + +## Consequences + +The scheduler owns a small CLI and a versioned JSON schema that must evolve deliberately with the gate model. Focused replay is faster to diagnose but is not complete evidence, so the CLI labels it explicitly and always names the owning aggregate. + +Later artifact consumers start only after publint and built-package invariant validation, reducing their overlap when either verifier is slow. Independent source checks still overlap both stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. + +Retained output and orthogonal exit/signal metadata improve failure attribution at the cost of local sensitive-data exposure when a child prints a secret. On POSIX, a repository-pinned helper that identity-checks each path descent, validated owner-only paths, exclusive creation, count and byte bounds, an explicit validated cleanup command, and exclusion from workflow uploads contain that risk without claiming the output itself is safe. The helper process and request protocol are additional local machinery, but they avoid relying on a check-then-use pathname for directory creation or destructive operations. Windows deliberately gives up durable local failure logs because Node file modes cannot establish the same privacy contract there; its console output remains complete. diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md new file mode 100644 index 0000000000..e24d77ff62 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 经过验证、自描述、可回放的门禁计划 + +Status: implemented + +[English](2026-07-27-replayable-gate-plans.md) | 中文 + +## 问题 + +仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。故障排查者还需要失败命令的确切依赖上下文,以及由调度器掌管的环境设置;在 CI 故障期间从 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 还原这些信息既慢又容易出错。 + +Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使这个问题更加严重。此时,命令、并发度、环境和失败收集分别拥有两份可执行清单;而且在任何命令确认下载产物完整之前,恢复后的构建产物就可能被消费。 + +## 决策 + +[`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。 + +每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式(`set`、`unset` 或 `append`),因此检查结果与失败元数据不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 + +`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 + +在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。相对于仓库的每一级路径都必须是经过验证的真实目录,才能作为修改操作的固定起点。专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认固定目录的设备号和 inode,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,并发替换上层目录时,操作要么在下一次修改前失败,要么仍限定在经过验证的目录内,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 + +`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有 shell 进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。 + +## 验证 + +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接以及确定性触发的写入、裁剪和清理上层目录替换都无法创建外部日志目录或触达外部受害文件,UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 + +## 曾考虑的替代方案 + +- **不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 +- **只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 +- **采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 +- **为精确回放而持久化完整的子进程环境。** 运行器的环境状态只是偶然因素,其中可能包含凭据。回放只记录由调度器掌管的操作,并在执行时重建继承状态。 +- **在消费方计划中增加第 8 条归档 manifest 验证命令。** publint 已经能拒绝缺失 manifest 所声明公开导出的情况,而已构建包不变式验证器会加载每个包的已编译不变式、声明的运行时分片和恢复后的 Loader bundle。串联这两条现有命令,既能保持 7 条命令的清单,又能让后续产物消费方获得两项检查,而无需增加另一项可执行清单条目。 + +## 后果 + +调度器负责维护一个小型 CLI(命令行界面)以及一套带版本的 JSON schema,两者都必须随门禁模型有意演进。聚焦回放可以更快地诊断问题,但不构成完整证据,因此 CLI 会明确标记这一点,并始终给出所属的完整聚合任务。 + +后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 + +保留输出以及彼此独立的退出码与信号元数据可以改善失败归因,但当子进程打印机密时,也会带来本地敏感数据暴露的代价。在 POSIX 上,以仓库根目录为固定起点、每进入一级路径都核验身份的辅助进程,加上经过验证且仅属主可访问的路径、排他创建、数量与字节双重上限、经过验证的显式清理命令以及工作流不上传日志,共同约束了这项风险,但并不声称输出本身是安全的。辅助进程和请求协议增加了本地机制,但避免了让目录创建或破坏性操作依赖“先检查、后使用”的路径名。Windows 会有意放弃持久保留的本地失败日志,因为 Node 文件模式无法在那里建立相同的隐私契约;其控制台输出仍保持完整。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aecff76a7d..3639333861 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,44 +240,7 @@ jobs: exit "$sandbox_status" - name: Run compatibility, snapshot, and artifact gates - run: | - pnpm run check:ci:lint & - lint_pid=$! - pnpm run check:node-compat & - compat_pid=$! - DSH_EXAMPLE_MODE=lib pnpm run test:snapshot & - snapshot_pid=$! - pnpm run publint & - publint_pid=$! - pnpm run verify-node-next-types & - node_next_pid=$! - pnpm run verify-built-package-invariants & - built_invariants_pid=$! - DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts \ - examples/headless-agent/tests/keyless-smoke.e2e.ts \ - examples/tui-agent/tests/tui-keyless-smoke.e2e.ts \ - packages/examples/cli-demo/tests/built-bin.e2e.ts \ - packages/examples/acp-demo/tests/built-bin.e2e.ts \ - packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts \ - packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts \ - packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts & - built_bin_pid=$! - - final_status=0 - capture_status() { - local child_status=0 - wait "$1" || child_status=$? - if (( final_status == 0 && child_status != 0 )); then - final_status=$child_status - fi - } - for child_pid in \ - "$lint_pid" "$compat_pid" "$snapshot_pid" \ - "$publint_pid" "$node_next_pid" "$built_invariants_pid" "$built_bin_pid" - do - capture_status "$child_pid" - done - exit "$final_status" + run: pnpm run check:ci:consumers node-compat: diff --git a/package.json b/package.json index d74a780925..a004a99d85 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", + "check:ci:consumers": "tsx scripts/run-gates.ts ci-consumers", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", diff --git a/scripts/gate-log-helper.mjs b/scripts/gate-log-helper.mjs new file mode 100644 index 0000000000..363b538f70 --- /dev/null +++ b/scripts/gate-log-helper.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** Pin the repository and each log-path component before creating or operating on private logs. */ + +import { constants } from 'node:fs' +import { chmod, lstat, mkdir, open, readdir, stat, unlink } from 'node:fs/promises' +import { isAbsolute, sep } from 'node:path' + +const MAX_REQUEST_BYTES = 8 * 1024 * 1024 +const LOG_NAME = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.log$/ + +function errorCode(error) { + return typeof error === 'object' && error !== null && 'code' in error + ? error.code + : undefined +} + +async function readRequest() { + const chunks = [] + let bytes = 0 + for await (const chunk of process.stdin) { + bytes += chunk.length + if (bytes > MAX_REQUEST_BYTES) throw new Error('request exceeds the gate-log helper limit') + chunks.push(chunk) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function assertInteger(value, label, minimum) { + if (!Number.isSafeInteger(value) || value < minimum) { + throw new Error(`${label} must be an integer of at least ${minimum}`) + } +} + +function assertLogName(name) { + if (typeof name !== 'string' || !LOG_NAME.test(name)) { + throw new Error(`invalid gate-log filename ${JSON.stringify(name)}`) + } +} + +function assertRequest(request) { + if (typeof request !== 'object' || request === null) throw new Error('gate-log request must be an object') + switch (request.operation) { + case 'write': + assertLogName(request.filename) + assertInteger(request.retention, 'retention', 1) + if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') + return + case 'prune': + assertInteger(request.retain, 'retain', 0) + return + case 'clean': + return + default: + throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) + } +} + +function assertIdentity(value, label) { + if ( + typeof value !== 'object' + || value === null + || typeof value.dev !== 'string' + || typeof value.ino !== 'string' + ) { + throw new Error(`missing expected ${label} identity`) + } +} + +function identityOf(metadata) { + return { dev: String(metadata.dev), ino: String(metadata.ino) } +} + +function sameIdentity(metadata, expected) { + return String(metadata.dev) === expected.dev && String(metadata.ino) === expected.ino +} + +async function assertPinnedRepository(repository) { + if ( + typeof repository !== 'object' + || repository === null + || typeof repository.root !== 'string' + || !isAbsolute(repository.root) + || typeof repository.relative !== 'string' + || repository.relative === '' + || repository.relative === '..' + || repository.relative.startsWith(`..${sep}`) + || isAbsolute(repository.relative) + ) { + throw new Error('invalid repository-relative gate-log path') + } + assertIdentity(repository.identity, 'repository') + const pinnedMetadata = await stat('.', { bigint: true }) + if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) { + throw new Error('gate-log repository identity changed before the helper started') + } + const rootMetadata = await lstat(repository.root, { bigint: true }) + if ( + !rootMetadata.isDirectory() + || rootMetadata.isSymbolicLink() + || !sameIdentity(rootMetadata, repository.identity) + ) { + throw new Error('gate-log repository root is not a real directory') + } +} + +async function enterLogDirectory(relativePath, create) { + const traversed = [] + for (const component of relativePath.split(sep)) { + if (component === '' || component === '.' || component === '..') { + throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`) + } + traversed.push(component) + let componentMetadata + try { + componentMetadata = await lstat(component, { bigint: true }) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + if (!create) return undefined + try { + await mkdir(component, { mode: 0o700 }) + } catch (mkdirError) { + if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError + } + componentMetadata = await lstat(component, { bigint: true }) + } + const shown = traversed.join('/') + if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) { + throw new Error(`gate-log path component is not a real directory: ${shown}`) + } + const expected = identityOf(componentMetadata) + process.chdir(component) + const pinnedMetadata = await stat('.', { bigint: true }) + if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) { + throw new Error(`gate-log path component identity changed before pinning: ${shown}`) + } + } + await chmod('.', 0o700) + return identityOf(await stat('.', { bigint: true })) +} + +async function removeOldLogs(retain, newest) { + assertInteger(retain, 'retain', 0) + const entries = await readdir('.', { withFileTypes: true }) + const logs = [] + for (const entry of entries) { + if (!entry.isFile() || !LOG_NAME.test(entry.name)) continue + let metadata + try { + metadata = await lstat(entry.name, { bigint: true }) + } catch (error) { + if (errorCode(error) === 'ENOENT') continue + throw error + } + if (!metadata.isFile() || metadata.isSymbolicLink()) continue + logs.push({ name: entry.name, mtimeNs: metadata.mtimeNs }) + } + logs.sort((left, right) => { + if (left.name === newest) return 1 + if (right.name === newest) return -1 + if (left.mtimeNs < right.mtimeNs) return -1 + if (left.mtimeNs > right.mtimeNs) return 1 + return left.name.localeCompare(right.name) + }) + const removed = [] + for (const entry of logs.slice(0, Math.max(0, logs.length - retain))) { + try { + await unlink(entry.name) + removed.push(entry.name) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + } + } + return removed +} + +async function writeLog(request) { + assertLogName(request.filename) + assertInteger(request.retention, 'retention', 1) + if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') + const handle = await open( + request.filename, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ) + try { + await handle.writeFile(request.content, 'utf8') + await handle.chmod(0o600) + } finally { + await handle.close() + } + const removed = await removeOldLogs(request.retention, request.filename) + return { filename: request.filename, removed } +} + +async function main() { + const request = await readRequest() + assertRequest(request) + await assertPinnedRepository(request.repository) + const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write') + if (directory === undefined) return { removed: [] } + switch (request.operation) { + case 'write': { + const result = await writeLog(request) + return { ...result, directory } + } + case 'prune': + return { directory, removed: await removeOldLogs(request.retain) } + case 'clean': + return { directory, removed: await removeOldLogs(0) } + default: + throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) + } +} + +try { + process.stdout.write(`${JSON.stringify(await main())}\n`) +} catch (error) { + process.stderr.write(`gate-log-helper: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +} diff --git a/scripts/publint-all.spec.ts b/scripts/publint-all.spec.ts index 22dd80d6b0..9e6d4ef26e 100644 --- a/scripts/publint-all.spec.ts +++ b/scripts/publint-all.spec.ts @@ -58,4 +58,10 @@ describe('publint package runner', () => { expect(result.status).toBe(1) expect(result.stdout).toContain('unpublished.js') }) + + it('rejects a public export whose built file is missing', () => { + const result = run(fixture('./lib/missing.js')) + expect(result.status).toBe(1) + expect(result.stdout).toContain('missing.js') + }) }) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts new file mode 100644 index 0000000000..49c1aab675 --- /dev/null +++ b/scripts/run-gates.spec.ts @@ -0,0 +1,523 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + cleanGateFailureLogs, + executeGatePlan, + failureLogUnavailableReason, + formatGateFailureLog, + formatGatePlanJson, + formatGatePlanList, + formatGateResultReason, + formatOnlyNotice, + gateDependencyClosure, + gatePlanForMode, + listedGatePlan, + limitGateFailureLog, + parseCliRequest, + pruneGateLogs, + replayCommand, + resolveGateEnvironment, + resolvePlanConcurrency, + runGate, + validateGatePlan, + writeGateFailureLog, + type Gate, + type GatePlan, + type GateResult, +} from './run-gates.ts' + +const temporaryRoots: string[] = [] +const repositoryRoot = join(import.meta.dirname, '..') + +afterEach(() => { + vi.unstubAllEnvs() + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +function gate(id: string, options: Partial = {}): Gate { + return { + id, + label: id, + displayCommand: `run ${id}`, + command: process.execPath, + args: ['-e', ''], + ...options, + } +} + +function plan(gates: Gate[]): GatePlan { + return { mode: 'check-all', script: 'check:all', gates } +} + +function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult { + return { + gate: subject, + status, + durationMs: 10, + stdout: '', + stderr: '', + output: [], + exitCode: status === 'passed' ? 0 : 1, + signalCode: null, + } +} + +function temporaryRoot(prefix = 'dsh-gate-logs-'): string { + const root = mkdtempSync(join(tmpdir(), prefix)) + temporaryRoots.push(root) + return root +} + +function withPnpmEntrypoint(action: () => T): T { + const previous = process.env.npm_execpath + process.env.npm_execpath = '/private/pnpm.cjs' + try { + return action() + } finally { + if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath') + else process.env.npm_execpath = previous + } +} + +describe('gate plan validation', () => { + it.each([ + 'ci-primary', + 'ci-static', + 'ci-lint', + 'ci-coverage', + 'ci-snapshot', + 'ci-artifacts', + 'ci-consumers', + 'ci-windows-blocking', + 'ci-windows-complete', + 'ci-windows-observational', + 'node-compat', + 'check-all', + 'doc-sync', + ] as const)('constructs a valid non-empty %s plan', (mode) => { + const subject = withPnpmEntrypoint(() => gatePlanForMode(mode)) + expect(() => { + validateGatePlan(subject) + }).not.toThrow() + }) + + it.each([ + ['empty', plan([]), /plan has no gates/], + ['duplicate ids', plan([gate('same'), gate('same')]), /duplicate gate id "same"/], + ['unknown dependencies', plan([gate('subject', { needs: ['missing'] })]), /depends on unknown gate "missing"/], + ['cycles', plan([gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })]), /dependency cycle: first -> second -> first/], + ])('rejects %s before starting a child', async (_label, invalid, message) => { + const execute = vi.fn(async (subject: Gate) => resultFor(subject)) + await expect(executeGatePlan(invalid, 1, execute)).rejects.toThrow(message) + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects an invalid plan worker bound', () => { + expect(() => { + validateGatePlan({ ...plan([gate('subject')]), maxWorkers: 0 }) + }).toThrow( + 'maxWorkers must be a positive integer', + ) + }) + + it('rejects an executor request above the plan worker ceiling before starting a child', async () => { + const execute = vi.fn(async (subject: Gate) => resultFor(subject)) + await expect(executeGatePlan({ ...plan([gate('subject')]), maxWorkers: 1 }, 2, execute)).rejects.toThrow( + 'exceeds the check-all plan ceiling 1', + ) + expect(execute).not.toHaveBeenCalled() + }) + + it('selects a target with its transitive dependencies in canonical plan order', () => { + const subject = plan([ + gate('prepare'), + gate('build', { needs: ['prepare'] }), + gate('snapshot', { needs: ['build'], env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } } }), + gate('unrelated'), + ]) + expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual(['prepare', 'build', 'snapshot']) + expect(gateDependencyClosure(subject, 'snapshot').at(-1)?.env).toEqual({ + DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, + }) + }) +}) + +describe('gate plan inspection and replay', () => { + it('parses package-script separators, list JSON, focused runs, and cleanup', () => { + expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({ + kind: 'run', mode: 'check-all', list: true, json: true, + }) + expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({ + kind: 'run', mode: 'check-all', list: false, json: false, only: 'snapshot', + }) + expect(parseCliRequest(['--clean-logs'])).toEqual({ kind: 'clean-logs' }) + expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list') + expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode') + }) + + it('renders deterministic human and stable JSON fields without inherited environment values', () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') + const subject = plan([ + gate('prepare'), + gate('subject', { + needs: ['prepare'], + allowFailure: true, + env: { + Z_MODE: { operation: 'set', value: 'lib' }, + ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, + NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, + }, + }), + ]) + + const json = formatGatePlanJson(subject) + expect(formatGatePlanJson(subject)).toBe(json) + expect(json).not.toContain('ambient-secret') + expect(json).not.toContain('scheduler-secret') + expect(JSON.parse(json)).toEqual({ + version: 1, + mode: 'check-all', + script: 'check:all', + scope: 'complete', + maxWorkers: null, + gates: [ + { id: 'prepare', label: 'prepare', command: 'run prepare', needs: [], env: {}, blocking: true }, + { + id: 'subject', + label: 'subject', + command: 'run subject', + needs: ['prepare'], + env: { + ACCESS_TOKEN: { operation: 'set', value: '' }, + NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, + Z_MODE: { operation: 'set', value: 'lib' }, + }, + blocking: false, + }, + ], + }) + expect(formatGatePlanList(subject)).toContain('- subject [non-blocking] subject') + expect(formatGatePlanList(subject)).toContain('needs: prepare') + expect(formatGatePlanList(subject)).toContain('max workers: (host and gate count)') + }) + + it('emits one clean JSON object through the documented silent package-script entry', () => { + const result = spawnSync('pnpm', [ + '--silent', + 'run', + 'check:ci:consumers', + '--', + '--list', + '--json', + ], { + cwd: repositoryRoot, + encoding: 'utf8', + shell: process.platform === 'win32', + timeout: 10_000, + }) + if (result.error !== undefined) throw result.error + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ + version: 1, + mode: 'ci-consumers', + script: 'check:ci:consumers', + scope: 'complete', + maxWorkers: 7, + }) + }) + + it('renders a cross-platform scheduler replay and labels focused evidence', () => { + const subject = plan([gate('snapshot')]) + expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') + expect(formatOnlyNotice(subject, 'snapshot')).toBe( + 'run-gates: --only snapshot is partial diagnostic evidence; the complete owning mode is pnpm run check:all.', + ) + }) + + it('resolves append, set, and unset operations only when spawning', () => { + const resolved = resolveGateEnvironment(gate('subject', { + env: { + NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, + MODE: { operation: 'set', value: 'lib' }, + REMOVE_ME: { operation: 'unset' }, + }, + }), { NODE_OPTIONS: '--trace-warnings', REMOVE_ME: 'yes', INHERITED: 'kept' }) + expect(resolved).toEqual({ + NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192', + MODE: 'lib', + INHERITED: 'kept', + }) + }) + + it.skipIf(process.platform === 'win32')('reports signal termination as an orthogonal real-process outcome', async () => { + const subjectGate = gate('terminated', { + args: ['-e', "process.kill(process.pid, 'SIGTERM')"], + }) + const result = await runGate(subjectGate) + + expect(result.status).toBe('failed') + expect(result.exitCode).toBeNull() + expect(result.signalCode).toBe('SIGTERM') + expect(formatGateResultReason(result)).toBe('signal SIGTERM') + expect(formatGateFailureLog(plan([subjectGate]), result)).toContain('signal: SIGTERM') + }) +}) + +describe('gate failure logs', () => { + it('records attributable scheduler metadata without inherited secrets', () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') + const subjectGate = gate('snapshot', { + env: { + DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, + ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, + }, + }) + const subject = plan([subjectGate]) + const failure: GateResult = { + ...resultFor(subjectGate, 'failed'), + output: [{ stream: 'stderr', text: 'failure details\n' }], + stderr: 'failure details\n', + } + const log = formatGateFailureLog(subject, failure) + expect(log).toContain('replay: pnpm run check:all -- --only snapshot') + expect(log).toContain('DSH_EXAMPLE_MODE') + expect(log).toContain('') + expect(log).toContain('[stderr]\nfailure details') + expect(log).not.toContain('ambient-secret') + expect(log).not.toContain('scheduler-secret') + }) + + it.skipIf(process.platform === 'win32')('uses private exclusive files and bounds retention', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + const subject = plan([subjectGate]) + const failure = resultFor(subjectGate, 'failed') + + const first = await writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 2, unique: 'first', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', + }) + const second = await writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 2, unique: 'second', now: new Date('2026-07-27T00:00:01Z'), platform: 'linux', + }) + const third = await writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 2, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', + }) + + expect(readdirSync(directory).sort()).toEqual([second, third].map(path => path.slice(directory.length + 1)).sort()) + expect(readFileSync(third, 'utf8')).toContain('run-gates failure log') + expect(statSync(directory).mode & 0o777).toBe(0o700) + expect(statSync(third).mode & 0o777).toBe(0o600) + expect(() => statSync(first)).toThrow() + await expect(writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 3, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', + })).rejects.toThrow('EEXIST') + await cleanGateFailureLogs(directory, repositoryRoot) + expect(readdirSync(directory)).toEqual([]) + }) + + it.skipIf(process.platform === 'win32')('uses cross-platform filenames for replay-safe gate ids', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('build:web') + const path = await writeGateFailureLog( + plan([subjectGate]), + resultFor(subjectGate, 'failed'), + { + directory, repositoryRoot, retention: 1, unique: 'unique', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', + }, + ) + expect(path.slice(directory.length + 1)).toContain('-build-web-') + expect(path.slice(directory.length + 1)).not.toContain(':') + }) + + it.skipIf(process.platform === 'win32')('bounds retained UTF-8 output with explicit truncation metadata', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + const failure: GateResult = { + ...resultFor(subjectGate, 'failed'), + output: [{ stream: 'stderr', text: `${'界'.repeat(200)}\nlast detail\n` }], + } + const path = await writeGateFailureLog(plan([subjectGate]), failure, { + directory, + repositoryRoot, + retention: 1, + maxBytes: 256, + unique: 'bounded', + now: new Date('2026-07-27T00:00:00Z'), + platform: 'linux', + }) + const content = readFileSync(path, 'utf8') + + expect(Buffer.byteLength(content)).toBeLessThanOrEqual(256) + expect(content).toContain('[run-gates log truncated: original-bytes=') + expect(content).toContain('max-bytes=256') + expect(content).toContain('last detail') + expect(content).not.toContain('\uFFFD') + expect(limitGateFailureLog('x'.repeat(256), 256)).toBe('x'.repeat(256)) + }) + + it.skipIf(process.platform === 'win32')('accepts the worst-case JSON expansion of a bounded log', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + const failure: GateResult = { + ...resultFor(subjectGate, 'failed'), + output: [{ stream: 'stderr', text: '\0'.repeat(400_000) }], + } + const path = await writeGateFailureLog(plan([subjectGate]), failure, { + directory, + repositoryRoot, + retention: 1, + maxBytes: 400_000, + unique: 'control-heavy', + platform: 'linux', + }) + + expect(statSync(path).size).toBeLessThanOrEqual(400_000) + expect(readFileSync(path, 'utf8')).not.toContain('\uFFFD') + }) + + it('rejects symlinked repository cache components before writing, pruning, or cleanup', async () => { + const auditRoot = temporaryRoot('dsh-gate-symlink-') + const repositoryRoot = join(auditRoot, 'repository') + const external = join(auditRoot, 'external') + const directory = join(repositoryRoot, '.cache/gates') + mkdirSync(repositoryRoot) + mkdirSync(join(external, 'gates'), { recursive: true }) + const victim = join(external, 'gates/victim.log') + writeFileSync(victim, 'keep\n') + symlinkSync(external, join(repositoryRoot, '.cache'), process.platform === 'win32' ? 'junction' : 'dir') + const subjectGate = gate('subject') + const message = 'gate-log path component is a symbolic link: .cache' + + await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, repositoryRoot, retention: 1, unique: 'safe', platform: 'linux', + })).rejects.toThrow(message) + await expect(pruneGateLogs(directory, 0, repositoryRoot)).rejects.toThrow(message) + await expect(cleanGateFailureLogs(directory, repositoryRoot)).rejects.toThrow(message) + expect(existsSync(victim)).toBe(true) + }) + + it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => { + const subjectGate = gate('subject') + const subject = plan([subjectGate]) + + for (const operation of ['write', 'prune', 'clean'] as const) { + const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`) + const repositoryRoot = join(auditRoot, 'repository') + const external = join(auditRoot, 'external') + const cache = join(repositoryRoot, '.cache') + const directory = join(cache, 'gates') + const displacedCache = join(repositoryRoot, '.cache-pinned') + mkdirSync(directory, { recursive: true }) + mkdirSync(external) + writeFileSync(join(directory, 'old.log'), 'old private log\n') + const victim = operation === 'write' ? undefined : join(external, 'gates/victim.log') + if (victim !== undefined) { + mkdirSync(join(external, 'gates')) + writeFileSync(victim, 'keep\n') + } + const swapAncestor = (): void => { + renameSync(cache, displacedCache) + symlinkSync(external, cache, 'dir') + } + + let invocation: Promise + if (operation === 'write') { + invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot, + retention: 1, + unique: operation, + platform: 'linux', + beforeHelper: swapAncestor, + }) + } else if (operation === 'prune') { + invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) + } else { + invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) + } + + await expect(invocation).rejects.toThrow('gate-log helper') + if (victim === undefined) { + expect(existsSync(join(external, 'gates'))).toBe(false) + } else { + expect(readFileSync(victim, 'utf8')).toBe('keep\n') + expect(readdirSync(join(external, 'gates'))).toEqual(['victim.log']) + } + expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') + } + }) + + it('uses a console-only fallback on Windows before creating a retention directory', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + expect(failureLogUnavailableReason('win32')).toContain('complete output remains on the console') + expect(failureLogUnavailableReason('linux')).toBeUndefined() + + await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, repositoryRoot, platform: 'win32', + })).rejects.toThrow('retained failure logs are disabled on Windows') + expect(existsSync(directory)).toBe(false) + }) +}) + +describe('Node 24 consumer plan', () => { + it('owns the same seven-worker command pool and orders restored-artifact validation before dependent consumers', () => { + const subject = withPnpmEntrypoint(() => gatePlanForMode('ci-consumers')) + validateGatePlan(subject) + expect(subject.maxWorkers).toBe(7) + expect(listedGatePlan(subject).maxWorkers).toBe(7) + expect(resolvePlanConcurrency(subject, undefined, 4)).toEqual({ + workers: 7, + source: 'ci-consumers plan default 7', + }) + expect(resolvePlanConcurrency(subject, '4', 32)).toEqual({ + workers: 4, + source: '$DSH_GATE_CONCURRENCY', + }) + expect(resolvePlanConcurrency(subject, '8', 32)).toEqual({ + workers: 7, + source: '$DSH_GATE_CONCURRENCY, ci-consumers plan cap 7', + }) + expect(subject.gates.map(item => item.id)).toEqual([ + 'lint-and-duplication', + 'node-compat', + 'snapshot', + 'publint', + 'node-next-types', + 'built-package-invariants', + 'built-bin-smoke', + ]) + expect(subject.gates.find(item => item.id === 'publint')?.needs).toBeUndefined() + expect(subject.gates.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { + expect(subject.gates.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) + } + expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual([ + 'snapshot', + 'publint', + 'built-package-invariants', + ]) + expect(listedGatePlan(subject).gates.find(item => item.id === 'snapshot')?.env).toEqual({ + DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, + }) + }) +}) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ae11479274..268c2ffd0f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -1,44 +1,69 @@ /** - * Run local and CI quality gates with bounded in-process scheduling. + * Construct, inspect, and run local and CI quality-gate plans with bounded scheduling. * - * The gate vocabulary stays in package.json; this runner only decides which - * independent commands can overlap and which commands wait for built artifacts. + * Package scripts own public aggregate names; this runner owns their validated + * dependency graphs, scheduler environment, replay diagnostics, and private logs. + * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md */ import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { lstat } from 'node:fs/promises' import { availableParallelism } from 'node:os' -import { resolve } from 'node:path' +import { isAbsolute, relative, resolve, sep } from 'node:path' import { performance } from 'node:perf_hooks' +import { pathToFileURL } from 'node:url' + +const MODES = [ + 'ci-primary', + 'ci-static', + 'ci-lint', + 'ci-coverage', + 'ci-snapshot', + 'ci-artifacts', + 'ci-consumers', + 'ci-windows-blocking', + 'ci-windows-complete', + 'ci-windows-observational', + 'node-compat', + 'check-all', + 'doc-sync', +] as const + +/** A named aggregate exposed by the gate runner. */ +export type Mode = typeof MODES[number] -type Mode = - | 'ci-primary' - | 'ci-static' - | 'ci-lint' - | 'ci-coverage' - | 'ci-snapshot' - | 'ci-artifacts' - | 'ci-windows-blocking' - | 'ci-windows-complete' - | 'ci-windows-observational' - | 'node-compat' - | 'pre-push' - | 'check-all' - | 'doc-sync' type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' -interface Gate { +/** One scheduler-owned environment operation, resolved against inherited values only at spawn time. */ +export type GateEnvironmentOverride = + | { operation: 'set'; value: string } + | { operation: 'unset' } + | { operation: 'append'; value: string; separator?: string } + +/** A command and its dependency metadata inside one gate plan. */ +export interface Gate { id: string label: string displayCommand: string command: string args: string[] needs?: string[] - env?: Record + env?: Record input?: string verify?: (result: GateResult) => Promise allowFailure?: boolean } -interface GateResult { +/** A complete executable aggregate and the package script that owns its diagnostics. */ +export interface GatePlan { + mode: Mode + script: string + gates: Gate[] + maxWorkers?: number +} + +/** The observed outcome of one gate process. */ +export interface GateResult { gate: Gate status: GateStatus durationMs: number @@ -46,7 +71,10 @@ interface GateResult { stderr: string output: GateOutputChunk[] exitCode: number | null + signalCode: NodeJS.Signals | null error?: string + logPath?: string + logError?: string } interface GateOutputChunk { @@ -59,71 +87,199 @@ interface RunningGate { promise: Promise } -interface ConcurrencyDefault { +/** The effective worker count and the facts that selected it. */ +export interface ResolvedConcurrency { workers: number source: string } +interface RunRequest { + kind: 'run' + mode: Mode + list: boolean + json: boolean + only?: string +} + +interface CleanLogsRequest { + kind: 'clean-logs' +} + +type CliRequest = RunRequest | CleanLogsRequest + +interface ListedEnvironmentOverride { + operation: GateEnvironmentOverride['operation'] + value?: string + separator?: string +} + +interface ListedGate { + id: string + label: string + command: string + needs: string[] + env: Record + blocking: boolean +} + +interface ListedPlan { + version: 1 + mode: Mode + script: string + scope: 'complete' + maxWorkers: number | null + gates: ListedGate[] +} + +interface GateLogDirectoryIdentity { + dev: string + ino: string +} + +type GateLogHelperRequest = + | { operation: 'write'; filename: string; content: string; retention: number } + | { operation: 'prune'; retain: number } + | { operation: 'clean' } + +interface GateLogHelperResult { + directory?: GateLogDirectoryIdentity + filename?: string + removed: string[] +} + +type GateExecutor = (gate: Gate) => Promise +type ResultObserver = (result: GateResult) => Promise | void + const root = resolve(import.meta.dirname, '..') -const mode = parseMode(process.argv[2]) -const gates = gatesForMode(mode) -const concurrencyDefault = defaultConcurrency(mode, gates.length) -const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY -const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers) -const verbose = process.env.DSH_GATE_VERBOSE === '1' -const startedAt = performance.now() +const gateLogRoot = resolve(root, '.cache/gates') +const gateLogHelper = resolve(import.meta.dirname, 'gate-log-helper.mjs') +const GATE_LOG_RETENTION = 20 +const GATE_LOG_MAX_BYTES = 1_048_576 +const MIN_GATE_LOG_MAX_BYTES = 128 +const MODE_SCRIPTS: Record = { + 'ci-primary': 'check:ci', + 'ci-static': 'check:ci:static', + 'ci-lint': 'check:ci:lint', + 'ci-coverage': 'check:ci:coverage', + 'ci-snapshot': 'check:ci:snapshot', + 'ci-artifacts': 'check:ci:artifacts', + 'ci-consumers': 'check:ci:consumers', + 'ci-windows-blocking': 'check:ci:windows-blocking', + 'ci-windows-complete': 'check:ci:windows-complete', + 'ci-windows-observational': 'check:ci:windows-observational', + 'node-compat': 'check:node-compat', + 'check-all': 'check:all', + 'doc-sync': 'doc-sync', +} -const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === '' - ? concurrencyDefault.source - : '$DSH_GATE_CONCURRENCY' -console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) +if (isMainModule()) process.exitCode = await main(process.argv.slice(2)) -const results = await runGates(gates, maxConcurrency) -printSummary(results, performance.now() - startedAt) +async function main(args: string[]): Promise { + const request = parseCliRequest(args) + if (request.kind === 'clean-logs') { + await cleanGateFailureLogs() + console.log('run-gates: cleared retained logs in .cache/gates/.') + return 0 + } -if (results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))) { - process.exit(1) + const completePlan = gatePlanForMode(request.mode) + validateGatePlan(completePlan) + if (request.list) { + console.log(request.json ? formatGatePlanJson(completePlan) : formatGatePlanList(completePlan)) + return 0 + } + + const plan = request.only === undefined + ? completePlan + : { ...completePlan, gates: gateDependencyClosure(completePlan, request.only) } + validateGatePlan(plan) + if (request.only !== undefined) console.log(formatOnlyNotice(completePlan, request.only)) + + const concurrency = resolvePlanConcurrency(plan, process.env.DSH_GATE_CONCURRENCY) + const maxConcurrency = concurrency.workers + const concurrencySource = concurrency.source + const startedAt = performance.now() + console.log(`run-gates: ${request.mode} running ${plan.gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) + + const results = await executeGatePlan(plan, maxConcurrency, runGate, async (result) => { + await attachFailureLog(completePlan, result) + printResult(completePlan, result) + }) + printSummary(completePlan, results, performance.now() - startedAt) + return results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped')) + ? 1 + : 0 +} + +function isMainModule(): boolean { + const entry = process.argv[1] + return entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href +} + +/** + * Parse one runner invocation without constructing or starting its plan. + * @param args - command-line arguments after the script entrypoint. + * @returns the validated run or cleanup request. + */ +export function parseCliRequest(args: readonly string[]): CliRequest { + if (args[0] === '--clean-logs') { + if (args.length !== 1) throw new Error('run-gates: --clean-logs does not accept other arguments.') + return { kind: 'clean-logs' } + } + + const mode = parseMode(args[0]) + let list = false + let json = false + let only: string | undefined + const firstOption = args[1] === '--' ? 2 : 1 + for (let index = firstOption; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--list') { + if (list) throw new Error('run-gates: --list may be specified only once.') + list = true + } else if (arg === '--json') { + if (json) throw new Error('run-gates: --json may be specified only once.') + json = true + } else if (arg === '--only') { + if (only !== undefined) throw new Error('run-gates: --only may be specified only once.') + const id = args[index + 1] + if (id === undefined || id.startsWith('--')) throw new Error('run-gates: --only requires a gate id.') + only = id + index += 1 + } else { + throw new Error(`run-gates: unsupported argument ${JSON.stringify(arg)}.`) + } + } + if (json && !list) throw new Error('run-gates: --json requires --list.') + if (list && only !== undefined) throw new Error('run-gates: --list and --only are mutually exclusive.') + return { kind: 'run', mode, list, json, ...only === undefined ? {} : { only } } } function parseMode(raw: string | undefined): Mode { - switch (raw) { - case 'ci-primary': - case 'ci-static': - case 'ci-lint': - case 'ci-coverage': - case 'ci-snapshot': - case 'ci-artifacts': - case 'ci-windows-blocking': - case 'ci-windows-complete': - case 'ci-windows-observational': - case 'node-compat': - case 'pre-push': - case 'check-all': - case 'doc-sync': - return raw - default: - throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | check-all | doc-sync, got ${JSON.stringify(raw)}.`, - ) - } + if (MODES.includes(raw as Mode)) return raw as Mode + throw new Error(`run-gates: expected mode ${MODES.join(' | ')}, got ${JSON.stringify(raw)}.`) } -function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault { - const available = availableParallelism() +function defaultConcurrency(plan: GatePlan, available: number): ResolvedConcurrency { + if (plan.maxWorkers !== undefined) { + return { + workers: Math.min(plan.gates.length, plan.maxWorkers), + source: `${plan.mode} plan default ${plan.maxWorkers}`, + } + } // Local modes cap workers: several doc gates each build a full ts.Program, // so an uncapped default on a large host trades wall clock for memory blowups. - const localCap = selectedMode === 'pre-push' || selectedMode === 'check-all' || selectedMode === 'doc-sync' + const localCap = plan.mode === 'check-all' || plan.mode === 'doc-sync' const modeLimit = localCap ? Math.min(4, available) : available return { - workers: Math.min(total, modeLimit), + workers: Math.min(plan.gates.length, modeLimit), source: localCap - ? `${available} available CPU(s), ${selectedMode} cap 4` + ? `${available} available CPU(s), ${plan.mode} cap 4` : `${available} available CPU(s)`, } } -function concurrencyFromEnv(name: string, fallback: number): number { - const raw = process.env[name] +function concurrencyFromValue(name: string, raw: string | undefined, fallback: number): number { if (raw === undefined || raw === '') return fallback const parsed = Number.parseInt(raw, 10) if (!Number.isSafeInteger(parsed) || parsed < 1) { @@ -132,6 +288,33 @@ function concurrencyFromEnv(name: string, fallback: number): number { return parsed } +/** + * Resolve a plan's default, optional environment request, and hard worker ceiling. + * @param plan - validated complete or diagnostic plan. + * @param override - optional `DSH_GATE_CONCURRENCY` value. + * @param available - host CPU availability for modes without a plan-owned default. + * @returns the effective worker count and its inspectable source. + */ +export function resolvePlanConcurrency( + plan: GatePlan, + override: string | undefined, + available = availableParallelism(), +): ResolvedConcurrency { + validateGatePlan(plan) + const defaultValue = defaultConcurrency(plan, available) + const requested = concurrencyFromValue('DSH_GATE_CONCURRENCY', override, defaultValue.workers) + const workers = Math.min(requested, plan.maxWorkers ?? requested) + const requestedSource = override === undefined || override === '' + ? defaultValue.source + : '$DSH_GATE_CONCURRENCY' + return { + workers, + source: workers === requested + ? requestedSource + : `${requestedSource}, ${plan.mode} plan cap ${String(plan.maxWorkers)}`, + } +} + function pnpmScript(id: string, script: string, options: Partial = {}): Gate { return { id, @@ -161,8 +344,18 @@ function pnpmInvocation(args: string[]): Pick { return { command: process.execPath, args: [entrypoint, ...args] } } -function nodeOptions(...options: string[]): string { - return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ') +/** + * Construct the complete plan for a named aggregate without executing it. + * @param selected - aggregate mode to construct. + * @returns the aggregate's package-script identity and gate graph. + */ +export function gatePlanForMode(selected: Mode): GatePlan { + return { + mode: selected, + script: MODE_SCRIPTS[selected], + gates: gatesForMode(selected), + ...selected === 'ci-consumers' ? { maxWorkers: 7 } : {}, + } } function gatesForMode(selected: Mode): Gate[] { @@ -182,6 +375,8 @@ function gatesForMode(selected: Mode): Gate[] { return [pnpmScript('build', 'build'), snapshotGate()] case 'ci-artifacts': return ciArtifactGates() + case 'ci-consumers': + return ciConsumerGates() case 'ci-windows-blocking': return ciWindowsBlockingGates() case 'ci-windows-complete': @@ -190,7 +385,6 @@ function gatesForMode(selected: Mode): Gate[] { return ciWindowsObservationalGates() case 'node-compat': return nodeCompatGates() - case 'pre-push': return [] case 'check-all': return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), @@ -204,7 +398,7 @@ function gatesForMode(selected: Mode): Gate[] { ...hygieneLeafGates({ artifactNeeds: ['build'] }), ...docSyncLeafGates({ docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: { operation: 'set', value: '1' } }, }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), ] @@ -273,7 +467,7 @@ function ciStaticGates(): Gate[] { pnpmScript('build', 'build'), ...docSyncLeafGates({ docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: { operation: 'set', value: '1' } }, docsBuildScript: 'docs:build:mpa', }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), @@ -294,6 +488,23 @@ function ciArtifactGates(): Gate[] { ] } +function ciConsumerGates(): Gate[] { + const publicArtifacts = ['publint'] + const restoredBuild = ['built-package-invariants'] + return [ + pnpmScript('lint-and-duplication', 'check:ci:lint', { label: 'lint and duplication' }), + pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), + snapshotGate(restoredBuild), + pnpmScript('publint', 'publint'), + pnpmScript('node-next-types', 'verify-node-next-types', { + label: 'node-next types', + needs: restoredBuild, + }), + builtPackageInvariantsGate(publicArtifacts), + builtBinSmokeGate(restoredBuild), + ] +} + function ciWindowsBlockingGates(): Gate[] { return [ pnpmScript('windows-build', 'build', { label: 'build' }), @@ -343,17 +554,17 @@ function lintGate(eslintTargets: readonly string[] = ['.']): Gate { 'content', ], { label: 'lint', - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, }) } if (concurrencyArgs.length > 0) { return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], { label: 'lint', - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, }) } return pnpmScript('lint', 'lint', { - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, }) } @@ -381,11 +592,11 @@ function coverageGate(): Gate { // Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node, // plugins via real exports); repository-script snapshots execute their real source entry path. -// CI and check-all already build before either class runs, so the suite waits on `build`. -function snapshotGate(): Gate { +// Build-owning modes wait on `build`; a restored-artifact mode passes its validation dependency. +function snapshotGate(needs: string[] = ['build']): Gate { return pnpmScript('snapshot', 'test:snapshot', { - env: { DSH_EXAMPLE_MODE: 'lib' }, - needs: ['build'], + env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } }, + needs, }) } @@ -430,7 +641,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { function docSyncLeafGates(options: { docTypecheckNeeds?: string[] - docTypecheckEnv?: Record + docTypecheckEnv?: Record docsBuildScript?: 'docs:build' | 'docs:build:mpa' } = {}): Gate[] { const docTypecheckOptions: Partial = {} @@ -468,7 +679,7 @@ function docSyncLeafGates(options: { ] } -function builtBinSmokeGate(): Gate { +function builtBinSmokeGate(needs: string[] = ['build']): Gate { return pnpmExec('built-bin-smoke', [ 'vitest', 'run', @@ -486,12 +697,582 @@ function builtBinSmokeGate(): Gate { 'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts', ], { label: 'built-bin smoke', - needs: ['build'], - env: { DSH_EXAMPLE_MODE: 'lib' }, + needs, + env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } }, }) } -async function runGates(allGates: Gate[], maxActive: number): Promise { +/** + * Reject a plan whose graph cannot be executed unambiguously. + * @param plan - complete or diagnostic plan to validate. + */ +export function validateGatePlan(plan: GatePlan): void { + const errors: string[] = [] + if (plan.gates.length === 0) errors.push('plan has no gates') + if (plan.maxWorkers !== undefined && (!Number.isSafeInteger(plan.maxWorkers) || plan.maxWorkers < 1)) { + errors.push(`maxWorkers must be a positive integer, got ${JSON.stringify(plan.maxWorkers)}`) + } + + const counts = new Map() + for (const gate of plan.gates) { + counts.set(gate.id, (counts.get(gate.id) ?? 0) + 1) + if (!/^[a-z0-9][a-z0-9:-]*$/.test(gate.id)) { + errors.push(`gate id ${JSON.stringify(gate.id)} must contain only lowercase letters, digits, colons, and hyphens`) + } + } + for (const [id, count] of counts) { + if (count > 1) errors.push(`duplicate gate id ${JSON.stringify(id)}`) + } + + const ids = new Set(counts.keys()) + for (const gate of plan.gates) { + for (const dependency of gate.needs ?? []) { + if (!ids.has(dependency)) { + errors.push(`gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}`) + } + } + } + + const cycle = findDependencyCycle(plan.gates) + if (cycle !== undefined) errors.push(`dependency cycle: ${cycle.join(' -> ')}`) + if (errors.length > 0) { + throw new Error(`run-gates: invalid ${plan.mode} plan:\n${errors.map(error => ` - ${error}`).join('\n')}`) + } +} + +function findDependencyCycle(gates: readonly Gate[]): string[] | undefined { + const byId = new Map(gates.map(gate => [gate.id, gate])) + const complete = new Set() + const active = new Map() + const path: string[] = [] + + const visit = (id: string): string[] | undefined => { + if (complete.has(id)) return undefined + const cycleStart = active.get(id) + if (cycleStart !== undefined) return [...path.slice(cycleStart), id] + const gate = byId.get(id) + if (gate === undefined) return undefined + + active.set(id, path.length) + path.push(id) + for (const dependency of gate.needs ?? []) { + const cycle = visit(dependency) + if (cycle !== undefined) return cycle + } + path.pop() + active.delete(id) + complete.add(id) + return undefined + } + + for (const gate of gates) { + const cycle = visit(gate.id) + if (cycle !== undefined) return cycle + } + return undefined +} + +/** + * Return one target and all of its transitive dependencies in canonical plan order. + * @param plan - validated complete owning plan. + * @param targetId - gate selected for diagnostic execution. + * @returns the target's dependency closure in owning-plan order. + */ +export function gateDependencyClosure(plan: GatePlan, targetId: string): Gate[] { + validateGatePlan(plan) + const byId = new Map(plan.gates.map(gate => [gate.id, gate])) + if (!byId.has(targetId)) { + throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(targetId)}.`) + } + + const selected = new Set() + const include = (id: string): void => { + if (selected.has(id)) return + const gate = byId.get(id) + if (gate === undefined) throw new Error(`run-gates: missing validated dependency ${JSON.stringify(id)}.`) + for (const dependency of gate.needs ?? []) include(dependency) + selected.add(id) + } + include(targetId) + return plan.gates.filter(gate => selected.has(gate.id)) +} + +/** + * Produce the stable machine-readable view used by `--list --json`. + * @param plan - complete plan to inspect. + * @returns the versioned environment-redacted plan view. + */ +export function listedGatePlan(plan: GatePlan): ListedPlan { + validateGatePlan(plan) + return { + version: 1, + mode: plan.mode, + script: plan.script, + scope: 'complete', + maxWorkers: plan.maxWorkers ?? null, + gates: plan.gates.map(listedGate), + } +} + +function listedGate(gate: Gate): ListedGate { + return { + id: gate.id, + label: gate.label, + command: gate.displayCommand, + needs: [...gate.needs ?? []], + env: listedEnvironment(gate.env), + blocking: gate.allowFailure !== true, + } +} + +function listedEnvironment( + environment: Readonly> | undefined, +): Record { + if (environment === undefined) return {} + return Object.fromEntries(Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, override]) => { + const value = 'value' in override + ? { value: sensitiveEnvironmentName(name) ? '' : override.value } + : {} + const separator = override.operation === 'append' && override.separator !== undefined + ? { separator: override.separator } + : {} + return [name, { operation: override.operation, ...value, ...separator }] + })) +} + +function sensitiveEnvironmentName(name: string): boolean { + return /(key|secret|token|password|credential)/i.test(name) +} + +/** + * Render the deterministic human-readable view used by `--list`. + * @param plan - complete plan to inspect. + * @returns the formatted plan. + */ +export function formatGatePlanList(plan: GatePlan): string { + const listed = listedGatePlan(plan) + const lines = [ + `run-gates: complete ${listed.mode} plan (pnpm run ${listed.script})`, + `max workers: ${listed.maxWorkers === null ? '(host and gate count)' : listed.maxWorkers}`, + ] + for (const gate of listed.gates) { + lines.push(`- ${gate.id} [${gate.blocking ? 'blocking' : 'non-blocking'}] ${gate.label}`) + lines.push(` command: ${gate.command}`) + lines.push(` needs: ${gate.needs.length === 0 ? '(none)' : gate.needs.join(', ')}`) + lines.push(` env: ${Object.keys(gate.env).length === 0 ? '(none)' : JSON.stringify(gate.env)}`) + } + return lines.join('\n') +} + +/** + * Render the stable JSON view used by `--list --json`. + * @param plan - complete plan to inspect. + * @returns the formatted JSON object. + */ +export function formatGatePlanJson(plan: GatePlan): string { + return JSON.stringify(listedGatePlan(plan), null, 2) +} + +/** + * Render the package-script command that restores a gate's scheduler context. + * @param plan - complete owning plan. + * @param gateId - gate to replay with its dependencies. + * @returns a shell-independent pnpm command. + */ +export function replayCommand(plan: GatePlan, gateId: string): string { + validateGatePlan(plan) + if (!plan.gates.some(gate => gate.id === gateId)) { + throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(gateId)}.`) + } + return `pnpm run ${plan.script} -- --only ${gateId}` +} + +/** + * Explain that a focused run is diagnostic rather than the complete aggregate. + * @param plan - complete owning plan. + * @param gateId - selected diagnostic gate. + * @returns the partial-evidence notice. + */ +export function formatOnlyNotice(plan: GatePlan, gateId: string): string { + return `run-gates: --only ${gateId} is partial diagnostic evidence; the complete owning mode is pnpm run ${plan.script}.` +} + +/** + * Resolve only scheduler-declared environment operations against the spawn environment. + * @param gate - gate whose operations to apply. + * @param inherited - environment inherited by the runner. + * @returns the child environment without mutating the inherited object. + */ +export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const resolved = { ...inherited } + for (const [name, override] of Object.entries(gate.env ?? {})) { + if (override.operation === 'unset') { + Reflect.deleteProperty(resolved, name) + } else if (override.operation === 'set') { + resolved[name] = override.value + } else { + const current = resolved[name] + resolved[name] = current === undefined || current === '' + ? override.value + : `${current}${override.separator ?? ' '}${override.value}` + } + } + return resolved +} + +/** + * Run a validated plan; invalid input rejects before the injected executor can start a child. + * @param plan - complete or diagnostic plan to execute. + * @param maxActive - maximum concurrent child count. + * @param execute - child-process executor. + * @param observe - serialized result observer. + * @returns results in canonical plan order. + */ +export async function executeGatePlan( + plan: GatePlan, + maxActive: number, + execute: GateExecutor, + observe: ResultObserver = () => {}, +): Promise { + validateGatePlan(plan) + if (!Number.isSafeInteger(maxActive) || maxActive < 1) { + throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`) + } + if (plan.maxWorkers !== undefined && maxActive > plan.maxWorkers) { + throw new Error(`run-gates: max concurrency ${maxActive} exceeds the ${plan.mode} plan ceiling ${plan.maxWorkers}.`) + } + return runGates(plan.gates, maxActive, execute, observe) +} + +/** + * Format one private failure log without consulting or enumerating the inherited environment. + * @param plan - complete owning plan. + * @param result - failed child outcome. + * @returns attributable metadata and interleaved output. + */ +export function formatGateFailureLog(plan: GatePlan, result: GateResult): string { + const gate = listedGate(result.gate) + const lines = [ + 'run-gates failure log', + `mode: ${plan.mode}`, + `gate: ${gate.id}`, + `status: ${result.status}`, + `blocking: ${gate.blocking}`, + `command: ${gate.command}`, + `replay: ${replayCommand(plan, gate.id)}`, + `scheduler environment: ${JSON.stringify(gate.env)}`, + `exit code: ${result.exitCode === null ? 'none' : result.exitCode}`, + `signal: ${result.signalCode ?? 'none'}`, + ] + if (result.error !== undefined) lines.push(`error: ${result.error}`) + lines.push('', 'interleaved output:') + for (const chunk of result.output) lines.push(`[${chunk.stream}]`, chunk.text) + return `${lines.join('\n')}\n` +} + +/** + * Explain why retained logs are unavailable on a platform. + * @param platform - host platform to evaluate. + * @returns the console-fallback diagnostic, or `undefined` when POSIX retention is supported. + */ +export function failureLogUnavailableReason(platform: NodeJS.Platform = process.platform): string | undefined { + return platform === 'win32' + ? 'retained failure logs are disabled on Windows because POSIX owner-only permissions are unavailable; complete output remains on the console' + : undefined +} + +/** + * Bound a UTF-8 failure log while retaining its beginning, end, and explicit truncation metadata. + * @param content - complete formatted failure log. + * @param maxBytes - maximum encoded byte length. + * @returns the original log when it fits, otherwise a bounded prefix and suffix around a marker. + */ +export function limitGateFailureLog(content: string, maxBytes: number): string { + if (!Number.isSafeInteger(maxBytes) || maxBytes < MIN_GATE_LOG_MAX_BYTES) { + throw new Error(`run-gates: failure-log byte limit must be an integer of at least ${MIN_GATE_LOG_MAX_BYTES}, got ${JSON.stringify(maxBytes)}.`) + } + const originalBytes = Buffer.byteLength(content) + if (originalBytes <= maxBytes) return content + + const marker = `\n[run-gates log truncated: original-bytes=${originalBytes}; max-bytes=${maxBytes}]\n` + const available = maxBytes - Buffer.byteLength(marker) + if (available < 0) throw new Error('run-gates: failure-log truncation marker exceeds the configured byte limit.') + const prefixBytes = Math.ceil(available / 2) + const suffixBytes = available - prefixBytes + return `${utf8Prefix(content, prefixBytes)}${marker}${utf8Suffix(content, suffixBytes)}` +} + +function utf8Prefix(content: string, maxBytes: number): string { + const encoded = Buffer.from(content) + if (encoded.length <= maxBytes) return content + let end = maxBytes + while (end > 0) { + const byte = encoded[end] + if (byte === undefined || (byte & 0xc0) !== 0x80) break + end -= 1 + } + return encoded.subarray(0, end).toString('utf8') +} + +function utf8Suffix(content: string, maxBytes: number): string { + const encoded = Buffer.from(content) + if (encoded.length <= maxBytes) return content + let start = encoded.length - maxBytes + while (start < encoded.length) { + const byte = encoded[start] + if (byte === undefined || (byte & 0xc0) !== 0x80) break + start += 1 + } + return encoded.subarray(start).toString('utf8') +} + +/** + * Write one exclusive owner-only POSIX failure log and keep only the newest bounded set. + * @param plan - complete owning plan. + * @param result - failed child outcome. + * @param options - injectable storage, bound, clock, identity, and platform seams. + * @returns the absolute log path. + */ +export async function writeGateFailureLog( + plan: GatePlan, + result: GateResult, + options: { + directory?: string + repositoryRoot?: string + retention?: number + maxBytes?: number + unique?: string + now?: Date + platform?: NodeJS.Platform + beforeHelper?: () => Promise | void + } = {}, +): Promise { + const directory = options.directory ?? gateLogRoot + const repositoryRoot = options.repositoryRoot ?? root + const retention = options.retention ?? GATE_LOG_RETENTION + const maxBytes = options.maxBytes ?? GATE_LOG_MAX_BYTES + const unique = options.unique ?? randomUUID() + const now = options.now ?? new Date() + const unavailable = failureLogUnavailableReason(options.platform) + if (unavailable !== undefined) throw new Error(`run-gates: ${unavailable}.`) + if (!Number.isSafeInteger(retention) || retention < 1) { + throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`) + } + await assertRepoLocalLogPath(repositoryRoot, directory) + const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if (repositoryIdentity === undefined) throw new Error(`run-gates: repository root disappeared: ${repositoryRoot}`) + const timestamp = now.toISOString().replaceAll(/[:.]/g, '-') + const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '') + if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.') + const safeGateId = result.gate.id.replaceAll(/[^a-zA-Z0-9-]/g, '-') + const filename = `${timestamp}-${plan.mode}-${safeGateId}-${safeUnique}.log` + const helperResult = await runGateLogHelper( + directory, + repositoryRoot, + repositoryIdentity, + { + operation: 'write', + filename, + content: limitGateFailureLog(formatGateFailureLog(plan, result), maxBytes), + retention, + }, + options.beforeHelper, + ) + if (helperResult.filename !== filename) throw new Error('run-gates: gate-log helper returned the wrong filename.') + return resolve(directory, filename) +} + +async function assertRepoLocalLogPath(repositoryRoot: string, target: string): Promise { + const relativeTarget = relative(repositoryRoot, target) + if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) { + throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`) + } + + const rootMetadata = await lstat(repositoryRoot) + if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { + throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`) + } + let current = repositoryRoot + for (const component of relativeTarget.split(sep)) { + current = resolve(current, component) + let metadata + try { + metadata = await lstat(current) + } catch (error: unknown) { + if (hasErrorCode(error, 'ENOENT')) return + throw error + } + const shown = relative(repositoryRoot, current).split(sep).join('/') + if (metadata.isSymbolicLink()) { + throw new Error(`run-gates: gate-log path component is a symbolic link: ${shown}`) + } + if (!metadata.isDirectory()) { + throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`) + } + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +async function readDirectoryIdentity(directory: string): Promise { + let metadata + try { + metadata = await lstat(directory, { bigint: true }) + } catch (error: unknown) { + if (hasErrorCode(error, 'ENOENT')) return undefined + throw error + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`run-gates: gate-log path is not a real directory: ${directory}`) + } + return { dev: String(metadata.dev), ino: String(metadata.ino) } +} + +async function runGateLogHelper( + directory: string, + repositoryRoot: string, + repositoryIdentity: GateLogDirectoryIdentity, + request: GateLogHelperRequest, + beforeHelper: (() => Promise | void) | undefined, +): Promise { + await beforeHelper?.() + const payload = JSON.stringify({ + ...request, + repository: { + root: repositoryRoot, + relative: relative(repositoryRoot, directory), + identity: repositoryIdentity, + }, + }) + const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => { + const child = spawn(process.execPath, [gateLogHelper], { + cwd: repositoryRoot, + env: {}, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + stdout += chunk + }) + child.stderr.on('data', (chunk: string) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (status) => { + resolveResult({ status, stdout, stderr }) + }) + child.stdin.on('error', (error: NodeJS.ErrnoException) => { + if (error.code !== 'EPIPE') reject(error) + }) + child.stdin.end(payload) + }) + if (result.status !== 0) { + throw new Error(`run-gates: gate-log helper failed: ${result.stderr.trim() || `exit status ${String(result.status)}`}`) + } + let parsed: unknown + try { + parsed = JSON.parse(result.stdout) + } catch { + throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`) + } + if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.') + await assertRepoLocalLogPath(repositoryRoot, directory) + const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if ( + currentRepositoryIdentity === undefined + || currentRepositoryIdentity.dev !== repositoryIdentity.dev + || currentRepositoryIdentity.ino !== repositoryIdentity.ino + ) { + throw new Error('run-gates: repository root identity changed while the gate-log helper was running.') + } + if (parsed.directory !== undefined) { + const currentDirectoryIdentity = await readDirectoryIdentity(directory) + if ( + currentDirectoryIdentity === undefined + || currentDirectoryIdentity.dev !== parsed.directory.dev + || currentDirectoryIdentity.ino !== parsed.directory.ino + ) { + throw new Error('run-gates: gate-log directory identity changed while the helper was running.') + } + } else if (request.operation === 'write') { + throw new Error('run-gates: gate-log helper did not return the created directory identity.') + } + return parsed +} + +function isGateLogHelperResult(value: unknown): value is GateLogHelperResult { + if (typeof value !== 'object' || value === null || !('removed' in value) || !Array.isArray(value.removed)) return false + if (!value.removed.every(entry => typeof entry === 'string')) return false + if ('filename' in value && value.filename !== undefined && typeof value.filename !== 'string') return false + return !('directory' in value) + || value.directory === undefined + || isGateLogDirectoryIdentity(value.directory) +} + +function isGateLogDirectoryIdentity(value: unknown): value is GateLogDirectoryIdentity { + return typeof value === 'object' + && value !== null + && 'dev' in value + && typeof value.dev === 'string' + && 'ino' in value + && typeof value.ino === 'string' +} + +/** Clear retained logs through a subprocess that pins the repository and each path component before use. */ +export async function cleanGateFailureLogs( + directory = gateLogRoot, + repositoryRoot = root, + beforeHelper?: () => Promise | void, +): Promise { + await assertRepoLocalLogPath(repositoryRoot, directory) + const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if (repositoryIdentity === undefined) return + await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'clean' }, beforeHelper) +} + +/** + * Remove older scheduler log files until at most `retain` remain. + * @param directory - private log directory. + * @param retain - number of newest log files to preserve. + * @param repositoryRoot - repository boundary containing the log directory. + * @param beforeHelper - test seam invoked after identity capture and before subprocess spawn. + */ +export async function pruneGateLogs( + directory: string, + retain: number, + repositoryRoot = root, + beforeHelper?: () => Promise | void, +): Promise { + if (!Number.isSafeInteger(retain) || retain < 0) { + throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`) + } + await assertRepoLocalLogPath(repositoryRoot, directory) + const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if (repositoryIdentity === undefined) return + await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'prune', retain }, beforeHelper) +} + +async function attachFailureLog(plan: GatePlan, result: GateResult): Promise { + if (result.status !== 'failed') return + try { + const path = await writeGateFailureLog(plan, result) + result.logPath = relative(root, path).split(sep).join('/') + } catch (error: unknown) { + result.logError = error instanceof Error ? error.message : String(error) + } +} + +async function runGates( + allGates: Gate[], + maxActive: number, + execute: GateExecutor, + observe: ResultObserver, +): Promise { const states = new Map(allGates.map(gate => [gate.id, 'pending'])) const results = new Map() const running: RunningGate[] = [] @@ -502,7 +1283,7 @@ async function runGates(allGates: Gate[], maxActive: number): Promise states.get(gate.id) === 'pending' && dependenciesPassed(gate, states)) if (ready === undefined) break states.set(ready.id, 'running') - running.push({ gate: ready, promise: runGate(ready) }) + running.push({ gate: ready, promise: execute(ready) }) console.log(`run-gates: start ${ready.label}`) madeProgress = true } @@ -519,11 +1300,12 @@ async function runGates(allGates: Gate[], maxActive: number): Promise): boolea return (gate.needs ?? []).every(id => states.get(id) === 'passed') } -async function runGate(gate: Gate): Promise { +/** + * Execute one gate through the real shell-free child-process boundary. + * @param gate - command and scheduler environment to execute. + * @returns the complete process and verification outcome. + */ +export async function runGate(gate: Gate): Promise { const started = performance.now() let stdout = '' let stderr = '' const output: GateOutputChunk[] = [] let spawnError: string | undefined - const exitCode = await new Promise((resolveExit) => { + const outcome = await new Promise<{ + exitCode: number | null + signalCode: NodeJS.Signals | null + }>((resolveExit) => { const child = spawn(gate.command, gate.args, { cwd: root, - env: { ...process.env, ...gate.env }, + env: resolveGateEnvironment(gate, process.env), stdio: ['pipe', 'pipe', 'pipe'], }) child.stdout.setEncoding('utf8') @@ -573,18 +1363,21 @@ async function runGate(gate: Gate): Promise { }) child.on('error', (error) => { spawnError = `failed to start command: ${error.message}` - resolveExit(null) + resolveExit({ exitCode: null, signalCode: null }) + }) + child.on('close', (exitCode, signalCode) => { + resolveExit({ exitCode, signalCode }) }) - child.on('close', resolveExit) if (gate.input !== undefined) child.stdin.end(gate.input) else child.stdin.end() }) + const { exitCode, signalCode } = outcome - let status: GateStatus = exitCode === 0 && spawnError === undefined ? 'passed' : 'failed' + let status: GateStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' let error = spawnError if (status === 'passed' && gate.verify !== undefined) { try { - await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode }) + await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode, signalCode }) } catch (verifyError: unknown) { status = 'failed' error = verifyError instanceof Error ? verifyError.message : String(verifyError) @@ -599,12 +1392,27 @@ async function runGate(gate: Gate): Promise { stderr, output, exitCode, + signalCode, } if (error !== undefined) result.error = error return result } -function printResult(result: GateResult): void { +/** + * Format every independently observed failure fact for the aggregate summary. + * @param result - unsuccessful gate result. + * @returns error, exit, and signal facts without allowing one to hide another. + */ +export function formatGateResultReason(result: GateResult): string { + const facts: string[] = [] + if (result.error !== undefined) facts.push(result.error) + if (result.exitCode !== null) facts.push(`exit ${result.exitCode}`) + if (result.signalCode !== null) facts.push(`signal ${result.signalCode}`) + return facts.length === 0 ? 'no exit code or signal' : facts.join(', ') +} + +function printResult(plan: GatePlan, result: GateResult): void { + const verbose = process.env.DSH_GATE_VERBOSE === '1' const seconds = (result.durationMs / 1000).toFixed(2) if (result.status === 'passed' && !verbose) { console.log(`run-gates: PASS ${result.gate.label} (${seconds}s)`) @@ -614,12 +1422,22 @@ function printResult(result: GateResult): void { const heading = `${result.status.toUpperCase()} ${result.gate.label} (${seconds}s)` const writeHeading = result.status === 'passed' ? console.log : console.error writeHeading(`\n== ${heading} ==`) - if (result.status !== 'passed') console.error(`command: ${result.gate.displayCommand}`) + if (result.status !== 'passed') { + const environment = listedGate(result.gate).env + console.error(`command: ${result.gate.displayCommand}`) + if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) + console.error(`replay: ${replayCommand(plan, result.gate.id)}`) + if (result.logPath !== undefined) { + console.error(`full log: ${result.logPath} (private; newest ${GATE_LOG_RETENTION} retained)`) + console.error('cleanup: pnpm exec tsx scripts/run-gates.ts --clean-logs') + } + if (result.logError !== undefined) console.error(`full log unavailable: ${result.logError}`) + } printOutput(result.output) if (result.error !== undefined) console.error(result.error) } -function printSummary(results: GateResult[], durationMs: number): void { +function printSummary(plan: GatePlan, results: GateResult[], durationMs: number): void { const passed = results.filter(result => result.status === 'passed').length const failed = results.filter(result => result.status === 'failed').length const skipped = results.filter(result => result.status === 'skipped').length @@ -632,10 +1450,11 @@ function printSummary(results: GateResult[], durationMs: number): void { console.error('run-gates: unsuccessful gates:') for (const result of unsuccessful) { const duration = (result.durationMs / 1000).toFixed(2) - const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`) + const reason = formatGateResultReason(result) const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : '' console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) - console.error(` ${result.gate.displayCommand}`) + console.error(` replay: ${replayCommand(plan, result.gate.id)}`) + if (result.logPath !== undefined) console.error(` full log: ${result.logPath}`) } } From 2e210c369bff058f335417c7224e8fdfce815f58 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:54:58 +0800 Subject: [PATCH 02/32] fix(dev-infra): pin replay log path identities --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 4 +- .../2026-07-27-replayable-gate-plans.zh.md | 4 +- scripts/gate-log-helper.mjs | 57 +++++++--- scripts/run-gates.spec.ts | 107 ++++++++++++++++++ scripts/run-gates.ts | 88 ++++++++++---- 6 files changed, 226 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index 3966caf9cf..4b12a533b2 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: 604fb87ebe91f8ebd606d128e8927535502b6ea9 -2026-07-27-replayable-gate-plans.zh.md: e24d77ff6245bac6a06cd9f9ea8e849dafc95051 +2026-07-27-replayable-gate-plans.md: 0b052352f54c98506c135077a471ffaf35c50009 +2026-07-27-replayable-gate-plans.zh.md: 0c9f20d72361ab84a648094256e6d5cd9f4c77c5 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index 604fb87ebe..0b052352f5 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -18,13 +18,13 @@ Every mode supports deterministic `--list` output and a versioned stable `--list `--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. -On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Every repository-relative path component must be a verified real directory before it can anchor a mutation. A dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process starts with the verified repository root as its process working directory, checks the pinned device and inode, and descends to the log directory one component at a time. It creates a missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A concurrent ancestor replacement therefore fails before the next mutation or leaves operations anchored to a verified directory instead of redirecting them. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. +On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Before spawning its dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process, one prevalidation pass records the repository root's device and inode plus each repository-relative path component's identity or absence. The helper starts with the verified repository root as its process working directory, checks that every existing identity and missing state still matches, and descends to the log directory one component at a time. It creates an expected-missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A root or component symlink, real-directory replacement, or unexpected directory introduced after validation therefore fails before mutation instead of redirecting an operation. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the shell pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. A failed restored-build validation skips later artifact consumers but does not suppress independent source diagnostics. ## Verification -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, a symlinked script entry remains executable, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks, repository-root and component real-directory replacements, expected-missing directory insertion, and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index e24d77ff62..0c9f20d723 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -18,13 +18,13 @@ Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使 `--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 -在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。相对于仓库的每一级路径都必须是经过验证的真实目录,才能作为修改操作的固定起点。专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认固定目录的设备号和 inode,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,并发替换上层目录时,操作要么在下一次修改前失败,要么仍限定在经过验证的目录内,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 +在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。启动专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程之前,一次预验证会记录仓库根目录的设备号和 inode,以及每个仓库相对路径组件的身份或缺失状态。辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认每个已有身份和缺失状态仍然匹配,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建预期缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,根目录或路径组件是符号链接、真实目录被替换,或验证后意外出现目录时,操作都会在修改前失败,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 `check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有 shell 进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。 ## 验证 -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接以及确定性触发的写入、裁剪和清理上层目录替换都无法创建外部日志目录或触达外部受害文件,UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、通过符号链接调用的脚本入口仍可执行、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接、仓库根目录和路径组件中的真实目录替换、原本应缺失的目录被插入,以及确定性触发的写入、裁剪和清理上层目录替换,都无法创建外部日志目录或触达外部受害文件;UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 ## 曾考虑的替代方案 diff --git a/scripts/gate-log-helper.mjs b/scripts/gate-log-helper.mjs index 363b538f70..0c1e4fa3af 100644 --- a/scripts/gate-log-helper.mjs +++ b/scripts/gate-log-helper.mjs @@ -89,6 +89,22 @@ async function assertPinnedRepository(repository) { throw new Error('invalid repository-relative gate-log path') } assertIdentity(repository.identity, 'repository') + const names = repository.relative.split(sep) + if (!Array.isArray(repository.components) || repository.components.length !== names.length) { + throw new Error('invalid gate-log path-component plan') + } + for (let index = 0; index < names.length; index += 1) { + const component = repository.components[index] + if ( + typeof component !== 'object' + || component === null + || component.name !== names[index] + || !('identity' in component) + ) { + throw new Error('invalid gate-log path-component plan') + } + if (component.identity !== null) assertIdentity(component.identity, `path component ${component.name}`) + } const pinnedMetadata = await stat('.', { bigint: true }) if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) { throw new Error('gate-log repository identity changed before the helper started') @@ -101,34 +117,49 @@ async function assertPinnedRepository(repository) { ) { throw new Error('gate-log repository root is not a real directory') } + return repository.components } -async function enterLogDirectory(relativePath, create) { +async function enterLogDirectory(components, create) { const traversed = [] - for (const component of relativePath.split(sep)) { - if (component === '' || component === '.' || component === '..') { - throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`) + for (const component of components) { + if (component.name === '' || component.name === '.' || component.name === '..') { + throw new Error(`invalid gate-log path component ${JSON.stringify(component.name)}`) } - traversed.push(component) + traversed.push(component.name) let componentMetadata + let created = false try { - componentMetadata = await lstat(component, { bigint: true }) + componentMetadata = await lstat(component.name, { bigint: true }) } catch (error) { if (errorCode(error) !== 'ENOENT') throw error + if (component.identity !== null) { + throw new Error(`gate-log path component disappeared after validation: ${traversed.join('/')}`) + } if (!create) return undefined try { - await mkdir(component, { mode: 0o700 }) + await mkdir(component.name, { mode: 0o700 }) } catch (mkdirError) { - if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError + if (errorCode(mkdirError) === 'EEXIST') { + throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) + } + throw mkdirError } - componentMetadata = await lstat(component, { bigint: true }) + componentMetadata = await lstat(component.name, { bigint: true }) + created = true + } + if (component.identity === null && !created) { + throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) + } + if (component.identity !== null && !sameIdentity(componentMetadata, component.identity)) { + throw new Error(`gate-log path component identity changed after validation: ${traversed.join('/')}`) } const shown = traversed.join('/') if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) { throw new Error(`gate-log path component is not a real directory: ${shown}`) } - const expected = identityOf(componentMetadata) - process.chdir(component) + const expected = component.identity ?? identityOf(componentMetadata) + process.chdir(component.name) const pinnedMetadata = await stat('.', { bigint: true }) if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) { throw new Error(`gate-log path component identity changed before pinning: ${shown}`) @@ -195,8 +226,8 @@ async function writeLog(request) { async function main() { const request = await readRequest() assertRequest(request) - await assertPinnedRepository(request.repository) - const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write') + const components = await assertPinnedRepository(request.repository) + const directory = await enterLogDirectory(components, request.operation === 'write') if (directory === undefined) return { removed: [] } switch (request.operation) { case 'write': { diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 49c1aab675..46d41a5df8 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -25,6 +25,7 @@ import { formatOnlyNotice, gateDependencyClosure, gatePlanForMode, + isMainModule, listedGatePlan, limitGateFailureLog, parseCliRequest, @@ -240,6 +241,15 @@ describe('gate plan inspection and replay', () => { }) }) + it.skipIf(process.platform === 'win32')('recognizes a symlinked script entry path', () => { + const temporary = temporaryRoot('dsh-run-gates-entry-') + const entry = join(temporary, 'run-gates.ts') + symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) + + expect(isMainModule(entry)).toBe(true) + expect(isMainModule(join(temporary, 'missing.ts'))).toBe(false) + }) + it('renders a cross-platform scheduler replay and labels focused evidence', () => { const subject = plan([gate('snapshot')]) expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') @@ -465,6 +475,103 @@ describe('gate failure logs', () => { } }) + it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => { + const subjectGate = gate('subject') + const subject = plan([subjectGate]) + + for (const operation of ['write', 'prune', 'clean'] as const) { + const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`) + const repositoryRoot = join(auditRoot, 'repository') + const external = join(auditRoot, 'external') + const cache = join(repositoryRoot, '.cache') + const directory = join(cache, 'gates') + const displacedCache = join(repositoryRoot, '.cache-pinned') + const externalCache = join(external, 'cache') + mkdirSync(directory, { recursive: true }) + mkdirSync(join(externalCache, 'gates'), { recursive: true }) + writeFileSync(join(directory, 'old.log'), 'old private log\n') + const victim = operation === 'write' ? undefined : join(externalCache, 'gates/victim.log') + if (victim !== undefined) writeFileSync(victim, 'keep\n') + const swapAncestor = (): void => { + renameSync(cache, displacedCache) + renameSync(externalCache, cache) + } + + let invocation: Promise + if (operation === 'write') { + invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot, + retention: 1, + unique: operation, + platform: 'linux', + beforeHelper: swapAncestor, + }) + } else if (operation === 'prune') { + invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) + } else { + invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) + } + + await expect(invocation).rejects.toThrow('gate-log helper') + if (victim === undefined) { + expect(readdirSync(join(cache, 'gates'))).toEqual([]) + } else { + expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') + } + expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') + } + }) + + it.skipIf(process.platform === 'win32')('rejects a real directory introduced at a previously missing component', async () => { + const auditRoot = temporaryRoot('dsh-gate-missing-real-swap-') + const repositoryRoot = join(auditRoot, 'repository') + const externalCache = join(auditRoot, 'external-cache') + const cache = join(repositoryRoot, '.cache') + const directory = join(cache, 'gates') + mkdirSync(repositoryRoot) + mkdirSync(join(externalCache, 'gates'), { recursive: true }) + const victim = join(externalCache, 'gates/victim.log') + writeFileSync(victim, 'keep\n') + const subjectGate = gate('subject') + + const invocation = writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot, + retention: 1, + unique: 'missing-swap', + platform: 'linux', + beforeHelper: () => { + renameSync(externalCache, cache) + }, + }) + + await expect(invocation).rejects.toThrow('gate-log helper') + expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') + expect(readdirSync(join(cache, 'gates'))).toEqual(['victim.log']) + }) + + it.skipIf(process.platform === 'win32')('rejects a repository root replaced after validation', async () => { + const auditRoot = temporaryRoot('dsh-gate-root-swap-') + const repositoryRoot = join(auditRoot, 'repository') + const externalRoot = join(auditRoot, 'external-repository') + const displacedRoot = join(auditRoot, 'repository-pinned') + const directory = join(repositoryRoot, '.cache/gates') + mkdirSync(directory, { recursive: true }) + mkdirSync(join(externalRoot, '.cache/gates'), { recursive: true }) + writeFileSync(join(directory, 'old.log'), 'old private log\n') + writeFileSync(join(externalRoot, '.cache/gates/victim.log'), 'keep\n') + + const invocation = cleanGateFailureLogs(directory, repositoryRoot, () => { + renameSync(repositoryRoot, displacedRoot) + renameSync(externalRoot, repositoryRoot) + }) + + await expect(invocation).rejects.toThrow('gate-log helper') + expect(readFileSync(join(repositoryRoot, '.cache/gates/victim.log'), 'utf8')).toBe('keep\n') + expect(readFileSync(join(displacedRoot, '.cache/gates/old.log'), 'utf8')).toBe('old private log\n') + }) + it('uses a console-only fallback on Windows before creating a retention directory', async () => { const repositoryRoot = temporaryRoot() const directory = join(repositoryRoot, '.cache/gates') diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 268c2ffd0f..08a524556e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -7,6 +7,7 @@ */ import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' +import { realpathSync } from 'node:fs' import { lstat } from 'node:fs/promises' import { availableParallelism } from 'node:os' import { isAbsolute, relative, resolve, sep } from 'node:path' @@ -136,6 +137,16 @@ interface GateLogDirectoryIdentity { ino: string } +interface GateLogPathComponent { + name: string + identity: GateLogDirectoryIdentity | null +} + +interface GateLogPathPlan { + repositoryIdentity: GateLogDirectoryIdentity + pathComponents: GateLogPathComponent[] +} + type GateLogHelperRequest = | { operation: 'write'; filename: string; content: string; retention: number } | { operation: 'prune'; retain: number } @@ -211,9 +222,19 @@ async function main(args: string[]): Promise { : 0 } -function isMainModule(): boolean { - const entry = process.argv[1] - return entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href +/** + * Decide whether this module is the process entry, including through a symlinked path. + * @param entry - process entry path to compare with this module. + * @returns Whether the entry resolves to this module. + */ +export function isMainModule(entry: string | undefined = process.argv[1]): boolean { + if (entry === undefined) return false + if (import.meta.url === pathToFileURL(resolve(entry)).href) return true + try { + return import.meta.url === pathToFileURL(realpathSync(entry)).href + } catch { + return false + } } /** @@ -1058,9 +1079,7 @@ export async function writeGateFailureLog( if (!Number.isSafeInteger(retention) || retention < 1) { throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`) } - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) throw new Error(`run-gates: repository root disappeared: ${repositoryRoot}`) + const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) const timestamp = now.toISOString().replaceAll(/[:.]/g, '-') const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '') if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.') @@ -1070,6 +1089,7 @@ export async function writeGateFailureLog( directory, repositoryRoot, repositoryIdentity, + pathComponents, { operation: 'write', filename, @@ -1082,24 +1102,37 @@ export async function writeGateFailureLog( return resolve(directory, filename) } -async function assertRepoLocalLogPath(repositoryRoot: string, target: string): Promise { +async function inspectRepoLocalLogPath( + repositoryRoot: string, + target: string, +): Promise { const relativeTarget = relative(repositoryRoot, target) if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) { throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`) } - const rootMetadata = await lstat(repositoryRoot) + const rootMetadata = await lstat(repositoryRoot, { bigint: true }) if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`) } + const components: GateLogPathComponent[] = [] let current = repositoryRoot + let missing = false for (const component of relativeTarget.split(sep)) { current = resolve(current, component) + if (missing) { + components.push({ name: component, identity: null }) + continue + } let metadata try { - metadata = await lstat(current) + metadata = await lstat(current, { bigint: true }) } catch (error: unknown) { - if (hasErrorCode(error, 'ENOENT')) return + if (hasErrorCode(error, 'ENOENT')) { + missing = true + components.push({ name: component, identity: null }) + continue + } throw error } const shown = relative(repositoryRoot, current).split(sep).join('/') @@ -1109,6 +1142,11 @@ async function assertRepoLocalLogPath(repositoryRoot: string, target: string): P if (!metadata.isDirectory()) { throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`) } + components.push({ name: component, identity: { dev: String(metadata.dev), ino: String(metadata.ino) } }) + } + return { + repositoryIdentity: { dev: String(rootMetadata.dev), ino: String(rootMetadata.ino) }, + pathComponents: components, } } @@ -1134,6 +1172,7 @@ async function runGateLogHelper( directory: string, repositoryRoot: string, repositoryIdentity: GateLogDirectoryIdentity, + pathComponents: GateLogPathComponent[], request: GateLogHelperRequest, beforeHelper: (() => Promise | void) | undefined, ): Promise { @@ -1144,6 +1183,7 @@ async function runGateLogHelper( root: repositoryRoot, relative: relative(repositoryRoot, directory), identity: repositoryIdentity, + components: pathComponents, }, }) const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => { @@ -1181,7 +1221,7 @@ async function runGateLogHelper( throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`) } if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.') - await assertRepoLocalLogPath(repositoryRoot, directory) + await inspectRepoLocalLogPath(repositoryRoot, directory) const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot) if ( currentRepositoryIdentity === undefined @@ -1229,10 +1269,15 @@ export async function cleanGateFailureLogs( repositoryRoot = root, beforeHelper?: () => Promise | void, ): Promise { - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) return - await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'clean' }, beforeHelper) + const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) + await runGateLogHelper( + directory, + repositoryRoot, + repositoryIdentity, + pathComponents, + { operation: 'clean' }, + beforeHelper, + ) } /** @@ -1251,10 +1296,15 @@ export async function pruneGateLogs( if (!Number.isSafeInteger(retain) || retain < 0) { throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`) } - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) return - await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'prune', retain }, beforeHelper) + const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) + await runGateLogHelper( + directory, + repositoryRoot, + repositoryIdentity, + pathComponents, + { operation: 'prune', retain }, + beforeHelper, + ) } async function attachFailureLog(plan: GatePlan, result: GateResult): Promise { From 0fabbd72ad8f4bbb5e453642b8052daa774e6000 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:05:21 +0800 Subject: [PATCH 03/32] test(dev-infra): share gate log swap setup --- scripts/run-gates.spec.ts | 70 +++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 46d41a5df8..13a37d0070 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -83,6 +83,30 @@ function temporaryRoot(prefix = 'dsh-gate-logs-'): string { return root } +function invokeGateLogOperation( + operation: 'write' | 'prune' | 'clean', + subjectGate: Gate, + directory: string, + root: string, + beforeHelper: () => void, +): Promise { + switch (operation) { + case 'write': + return writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot: root, + retention: 1, + unique: operation, + platform: 'linux', + beforeHelper, + }) + case 'prune': + return pruneGateLogs(directory, 0, root, beforeHelper) + case 'clean': + return cleanGateFailureLogs(directory, root, beforeHelper) + } +} + function withPnpmEntrypoint(action: () => T): T { const previous = process.env.npm_execpath process.env.npm_execpath = '/private/pnpm.cjs' @@ -426,7 +450,6 @@ describe('gate failure logs', () => { it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => { const subjectGate = gate('subject') - const subject = plan([subjectGate]) for (const operation of ['write', 'prune', 'clean'] as const) { const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`) @@ -448,21 +471,13 @@ describe('gate failure logs', () => { symlinkSync(external, cache, 'dir') } - let invocation: Promise - if (operation === 'write') { - invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot, - retention: 1, - unique: operation, - platform: 'linux', - beforeHelper: swapAncestor, - }) - } else if (operation === 'prune') { - invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) - } else { - invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) - } + const invocation = invokeGateLogOperation( + operation, + subjectGate, + directory, + repositoryRoot, + swapAncestor, + ) await expect(invocation).rejects.toThrow('gate-log helper') if (victim === undefined) { @@ -477,7 +492,6 @@ describe('gate failure logs', () => { it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => { const subjectGate = gate('subject') - const subject = plan([subjectGate]) for (const operation of ['write', 'prune', 'clean'] as const) { const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`) @@ -497,21 +511,13 @@ describe('gate failure logs', () => { renameSync(externalCache, cache) } - let invocation: Promise - if (operation === 'write') { - invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot, - retention: 1, - unique: operation, - platform: 'linux', - beforeHelper: swapAncestor, - }) - } else if (operation === 'prune') { - invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) - } else { - invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) - } + const invocation = invokeGateLogOperation( + operation, + subjectGate, + directory, + repositoryRoot, + swapAncestor, + ) await expect(invocation).rejects.toThrow('gate-log helper') if (victim === undefined) { From 3f27434f388d100d88333f1acc6c0cf3a4eaa2d1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:47:00 +0800 Subject: [PATCH 04/32] refactor(dev-infra): drop retained gate logs --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 28 +- .../2026-07-27-replayable-gate-plans.zh.md | 28 +- scripts/gate-log-helper.mjs | 251 ---------- scripts/run-gates.spec.ts | 349 ++------------ scripts/run-gates.ts | 429 +----------------- 6 files changed, 70 insertions(+), 1019 deletions(-) delete mode 100644 scripts/gate-log-helper.mjs diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index 4b12a533b2..98e78e752a 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: 0b052352f54c98506c135077a471ffaf35c50009 -2026-07-27-replayable-gate-plans.zh.md: 0c9f20d72361ab84a648094256e6d5cd9f4c77c5 +2026-07-27-replayable-gate-plans.md: a312481a4da68f0d990e28c07cced4511c922b9b +2026-07-27-replayable-gate-plans.zh.md: c213068f5413110a2c5952ac05ebc326de12682c diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index 0b052352f5..a312481a4d 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -6,33 +6,37 @@ English | [中文](2026-07-27-replayable-gate-plans.zh.md) ## Problem -Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. Operators also need the exact scheduler-owned environment and dependency context for a failed command; reconstructing it from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) is slow and error-prone during a CI incident. +Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. -The Node 24 consumer job compounds this problem when it owns a separate shell process pool. Commands, concurrency, environment, and failure collection then have two executable inventories, while a restored build can be consumed before any command establishes that the downloaded artifacts are complete. +Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing restored build artifacts to be consumed before any command established that the download was complete. ## Decision [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract. -Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn (`set`, `unset`, or `append`), so inspection and failure metadata never enumerate or bake in inherited values; values under secret-like names are redacted. +Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn, so inspection never enumerates or bakes in inherited values; values under secret-like names are redacted. `--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. -On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Before spawning its dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process, one prevalidation pass records the repository root's device and inode plus each repository-relative path component's identity or absence. The helper starts with the verified repository root as its process working directory, checks that every existing identity and missing state still matches, and descends to the log directory one component at a time. It creates an expected-missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A root or component symlink, real-directory replacement, or unexpected directory introduced after validation therefore fails before mutation instead of redirecting an operation. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. +The scheduler announces each start, buffers a child's stdout and stderr until that gate settles, and then emits one attributable result while unrelated gates continue. Failure blocks include the display command, redacted scheduler-owned environment operations, orthogonal exit and signal outcomes, complete child output, and the replay command; successful child output remains suppressed unless `DSH_GATE_VERBOSE=1`. Child output is not persisted. -The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the shell pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. A failed restored-build validation skips later artifact consumers but does not suppress independent source diagnostics. +The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. ## Verification -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, a symlinked script entry remains executable, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks, repository-root and component real-directory replacements, expected-missing directory insertion, and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. ## Alternatives considered -- **Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. -- **Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. -- **Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. -- **Persist the complete child environment for exact replay.** Ambient runner state is incidental and can contain credentials. Replay instead records only scheduler-owned operations and reconstructs inherited state at execution time. -- **Add an eighth archive-manifest validator to the consumer plan.** Publint already rejects missing manifest-declared public exports, while the built-package invariant verifier loads every package's compiled invariant, its declared runtime chunks, and the restored Loader bundle. Chaining those existing commands keeps the seven-command inventory and gives later artifact consumers both checks without another executable inventory entry. +**Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. + +**Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. + +**Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. + +**Persist child output under the repository.** Runner-local files disappear with hosted CI jobs unless uploaded, can contain sensitive child data, and require a filesystem ownership and cleanup contract unrelated to plan replay. The console remains the authoritative diagnostic record. + +**Stream concurrent child output live.** Unprefixed streams interleave and lose attribution. Emitting each complete block as soon as its gate settles preserves attribution without waiting for unrelated gates. ## Consequences @@ -40,4 +44,4 @@ The scheduler owns a small CLI and a versioned JSON schema that must evolve deli Later artifact consumers start only after publint and built-package invariant validation, reducing their overlap when either verifier is slow. Independent source checks still overlap both stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. -Retained output and orthogonal exit/signal metadata improve failure attribution at the cost of local sensitive-data exposure when a child prints a secret. On POSIX, a repository-pinned helper that identity-checks each path descent, validated owner-only paths, exclusive creation, count and byte bounds, an explicit validated cleanup command, and exclusion from workflow uploads contain that risk without claiming the output itself is safe. The helper process and request protocol are additional local machinery, but they avoid relying on a check-then-use pathname for directory creation or destructive operations. Windows deliberately gives up durable local failure logs because Node file modes cannot establish the same privacy contract there; its console output remains complete. +Buffered output is coherent and attributable, but no progress from a long-running child appears until that child settles, and the runner retains no second copy after the console is lost. Operators trade live interleaving and durable local output for a smaller scheduler whose diagnostic state is the inspected plan, settlement block, and replay command. diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index 0c9f20d723..c213068f54 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -6,33 +6,37 @@ Status: implemented ## 问题 -仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。故障排查者还需要失败命令的确切依赖上下文,以及由调度器掌管的环境设置;在 CI 故障期间从 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 还原这些信息既慢又容易出错。 +仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。 -Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使这个问题更加严重。此时,命令、并发度、环境和失败收集分别拥有两份可执行清单;而且在任何命令确认下载产物完整之前,恢复后的构建产物就可能被消费。 +故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许在任何命令确认下载产物完整之前消费恢复后的构建产物。 ## 决策 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。 -每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式(`set`、`unset` 或 `append`),因此检查结果与失败元数据不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 +每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,因此检查结果不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 `--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 -在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。启动专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程之前,一次预验证会记录仓库根目录的设备号和 inode,以及每个仓库相对路径组件的身份或缺失状态。辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认每个已有身份和缺失状态仍然匹配,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建预期缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,根目录或路径组件是符号链接、真实目录被替换,或验证后意外出现目录时,操作都会在修改前失败,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 +调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 -`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有 shell 进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。 +`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。 ## 验证 -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、通过符号链接调用的脚本入口仍可执行、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接、仓库根目录和路径组件中的真实目录替换、原本应缺失的目录被插入,以及确定性触发的写入、裁剪和清理上层目录替换,都无法创建外部日志目录或触达外部受害文件;UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 ## 曾考虑的替代方案 -- **不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 -- **只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 -- **采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 -- **为精确回放而持久化完整的子进程环境。** 运行器的环境状态只是偶然因素,其中可能包含凭据。回放只记录由调度器掌管的操作,并在执行时重建继承状态。 -- **在消费方计划中增加第 8 条归档 manifest 验证命令。** publint 已经能拒绝缺失 manifest 所声明公开导出的情况,而已构建包不变式验证器会加载每个包的已编译不变式、声明的运行时分片和恢复后的 Loader bundle。串联这两条现有命令,既能保持 7 条命令的清单,又能让后续产物消费方获得两项检查,而无需增加另一项可执行清单条目。 +**不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 + +**只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 + +**采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 + +**在仓库中持久化子进程输出。** 除非上传,否则运行器本地文件会在托管 CI 作业结束后消失;这些文件可能包含敏感的子进程数据,而且还需要一套与计划回放无关的文件系统所有权与清理契约。控制台仍是权威的诊断记录。 + +**实时流式输出并发子进程的内容。** 无前缀的流会相互交错并丧失归属。每项门禁结束便输出其完整块,既能保留归属,也无需等待无关门禁。 ## 后果 @@ -40,4 +44,4 @@ Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使 后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 -保留输出以及彼此独立的退出码与信号元数据可以改善失败归因,但当子进程打印机密时,也会带来本地敏感数据暴露的代价。在 POSIX 上,以仓库根目录为固定起点、每进入一级路径都核验身份的辅助进程,加上经过验证且仅属主可访问的路径、排他创建、数量与字节双重上限、经过验证的显式清理命令以及工作流不上传日志,共同约束了这项风险,但并不声称输出本身是安全的。辅助进程和请求协议增加了本地机制,但避免了让目录创建或破坏性操作依赖“先检查、后使用”的路径名。Windows 会有意放弃持久保留的本地失败日志,因为 Node 文件模式无法在那里建立相同的隐私契约;其控制台输出仍保持完整。 +缓冲后的输出连贯且归属明确,但长时间运行的子进程结束前不会显示其进度,控制台内容丢失后运行器也不保留第二份副本。故障排查者接受不再实时交错输出、也不持久保留本地输出,以换取更小的调度器;其诊断状态只由检视后的计划、门禁结束时输出的块和回放命令组成。 diff --git a/scripts/gate-log-helper.mjs b/scripts/gate-log-helper.mjs deleted file mode 100644 index 0c1e4fa3af..0000000000 --- a/scripts/gate-log-helper.mjs +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env node -/** Pin the repository and each log-path component before creating or operating on private logs. */ - -import { constants } from 'node:fs' -import { chmod, lstat, mkdir, open, readdir, stat, unlink } from 'node:fs/promises' -import { isAbsolute, sep } from 'node:path' - -const MAX_REQUEST_BYTES = 8 * 1024 * 1024 -const LOG_NAME = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.log$/ - -function errorCode(error) { - return typeof error === 'object' && error !== null && 'code' in error - ? error.code - : undefined -} - -async function readRequest() { - const chunks = [] - let bytes = 0 - for await (const chunk of process.stdin) { - bytes += chunk.length - if (bytes > MAX_REQUEST_BYTES) throw new Error('request exceeds the gate-log helper limit') - chunks.push(chunk) - } - return JSON.parse(Buffer.concat(chunks).toString('utf8')) -} - -function assertInteger(value, label, minimum) { - if (!Number.isSafeInteger(value) || value < minimum) { - throw new Error(`${label} must be an integer of at least ${minimum}`) - } -} - -function assertLogName(name) { - if (typeof name !== 'string' || !LOG_NAME.test(name)) { - throw new Error(`invalid gate-log filename ${JSON.stringify(name)}`) - } -} - -function assertRequest(request) { - if (typeof request !== 'object' || request === null) throw new Error('gate-log request must be an object') - switch (request.operation) { - case 'write': - assertLogName(request.filename) - assertInteger(request.retention, 'retention', 1) - if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') - return - case 'prune': - assertInteger(request.retain, 'retain', 0) - return - case 'clean': - return - default: - throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) - } -} - -function assertIdentity(value, label) { - if ( - typeof value !== 'object' - || value === null - || typeof value.dev !== 'string' - || typeof value.ino !== 'string' - ) { - throw new Error(`missing expected ${label} identity`) - } -} - -function identityOf(metadata) { - return { dev: String(metadata.dev), ino: String(metadata.ino) } -} - -function sameIdentity(metadata, expected) { - return String(metadata.dev) === expected.dev && String(metadata.ino) === expected.ino -} - -async function assertPinnedRepository(repository) { - if ( - typeof repository !== 'object' - || repository === null - || typeof repository.root !== 'string' - || !isAbsolute(repository.root) - || typeof repository.relative !== 'string' - || repository.relative === '' - || repository.relative === '..' - || repository.relative.startsWith(`..${sep}`) - || isAbsolute(repository.relative) - ) { - throw new Error('invalid repository-relative gate-log path') - } - assertIdentity(repository.identity, 'repository') - const names = repository.relative.split(sep) - if (!Array.isArray(repository.components) || repository.components.length !== names.length) { - throw new Error('invalid gate-log path-component plan') - } - for (let index = 0; index < names.length; index += 1) { - const component = repository.components[index] - if ( - typeof component !== 'object' - || component === null - || component.name !== names[index] - || !('identity' in component) - ) { - throw new Error('invalid gate-log path-component plan') - } - if (component.identity !== null) assertIdentity(component.identity, `path component ${component.name}`) - } - const pinnedMetadata = await stat('.', { bigint: true }) - if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) { - throw new Error('gate-log repository identity changed before the helper started') - } - const rootMetadata = await lstat(repository.root, { bigint: true }) - if ( - !rootMetadata.isDirectory() - || rootMetadata.isSymbolicLink() - || !sameIdentity(rootMetadata, repository.identity) - ) { - throw new Error('gate-log repository root is not a real directory') - } - return repository.components -} - -async function enterLogDirectory(components, create) { - const traversed = [] - for (const component of components) { - if (component.name === '' || component.name === '.' || component.name === '..') { - throw new Error(`invalid gate-log path component ${JSON.stringify(component.name)}`) - } - traversed.push(component.name) - let componentMetadata - let created = false - try { - componentMetadata = await lstat(component.name, { bigint: true }) - } catch (error) { - if (errorCode(error) !== 'ENOENT') throw error - if (component.identity !== null) { - throw new Error(`gate-log path component disappeared after validation: ${traversed.join('/')}`) - } - if (!create) return undefined - try { - await mkdir(component.name, { mode: 0o700 }) - } catch (mkdirError) { - if (errorCode(mkdirError) === 'EEXIST') { - throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) - } - throw mkdirError - } - componentMetadata = await lstat(component.name, { bigint: true }) - created = true - } - if (component.identity === null && !created) { - throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) - } - if (component.identity !== null && !sameIdentity(componentMetadata, component.identity)) { - throw new Error(`gate-log path component identity changed after validation: ${traversed.join('/')}`) - } - const shown = traversed.join('/') - if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) { - throw new Error(`gate-log path component is not a real directory: ${shown}`) - } - const expected = component.identity ?? identityOf(componentMetadata) - process.chdir(component.name) - const pinnedMetadata = await stat('.', { bigint: true }) - if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) { - throw new Error(`gate-log path component identity changed before pinning: ${shown}`) - } - } - await chmod('.', 0o700) - return identityOf(await stat('.', { bigint: true })) -} - -async function removeOldLogs(retain, newest) { - assertInteger(retain, 'retain', 0) - const entries = await readdir('.', { withFileTypes: true }) - const logs = [] - for (const entry of entries) { - if (!entry.isFile() || !LOG_NAME.test(entry.name)) continue - let metadata - try { - metadata = await lstat(entry.name, { bigint: true }) - } catch (error) { - if (errorCode(error) === 'ENOENT') continue - throw error - } - if (!metadata.isFile() || metadata.isSymbolicLink()) continue - logs.push({ name: entry.name, mtimeNs: metadata.mtimeNs }) - } - logs.sort((left, right) => { - if (left.name === newest) return 1 - if (right.name === newest) return -1 - if (left.mtimeNs < right.mtimeNs) return -1 - if (left.mtimeNs > right.mtimeNs) return 1 - return left.name.localeCompare(right.name) - }) - const removed = [] - for (const entry of logs.slice(0, Math.max(0, logs.length - retain))) { - try { - await unlink(entry.name) - removed.push(entry.name) - } catch (error) { - if (errorCode(error) !== 'ENOENT') throw error - } - } - return removed -} - -async function writeLog(request) { - assertLogName(request.filename) - assertInteger(request.retention, 'retention', 1) - if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') - const handle = await open( - request.filename, - constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, - 0o600, - ) - try { - await handle.writeFile(request.content, 'utf8') - await handle.chmod(0o600) - } finally { - await handle.close() - } - const removed = await removeOldLogs(request.retention, request.filename) - return { filename: request.filename, removed } -} - -async function main() { - const request = await readRequest() - assertRequest(request) - const components = await assertPinnedRepository(request.repository) - const directory = await enterLogDirectory(components, request.operation === 'write') - if (directory === undefined) return { removed: [] } - switch (request.operation) { - case 'write': { - const result = await writeLog(request) - return { ...result, directory } - } - case 'prune': - return { directory, removed: await removeOldLogs(request.retain) } - case 'clean': - return { directory, removed: await removeOldLogs(0) } - default: - throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) - } -} - -try { - process.stdout.write(`${JSON.stringify(await main())}\n`) -} catch (error) { - process.stderr.write(`gate-log-helper: ${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 -} diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 13a37d0070..1a86ccd064 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,24 +1,14 @@ import { - existsSync, - mkdirSync, mkdtempSync, - readFileSync, - readdirSync, - renameSync, rmSync, - statSync, symlinkSync, - writeFileSync, } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { - cleanGateFailureLogs, executeGatePlan, - failureLogUnavailableReason, - formatGateFailureLog, formatGatePlanJson, formatGatePlanList, formatGateResultReason, @@ -27,15 +17,12 @@ import { gatePlanForMode, isMainModule, listedGatePlan, - limitGateFailureLog, parseCliRequest, - pruneGateLogs, replayCommand, resolveGateEnvironment, resolvePlanConcurrency, runGate, validateGatePlan, - writeGateFailureLog, type Gate, type GatePlan, type GateResult, @@ -77,36 +64,12 @@ function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): Gate } } -function temporaryRoot(prefix = 'dsh-gate-logs-'): string { +function temporaryRoot(prefix = 'dsh-run-gates-'): string { const root = mkdtempSync(join(tmpdir(), prefix)) temporaryRoots.push(root) return root } -function invokeGateLogOperation( - operation: 'write' | 'prune' | 'clean', - subjectGate: Gate, - directory: string, - root: string, - beforeHelper: () => void, -): Promise { - switch (operation) { - case 'write': - return writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot: root, - retention: 1, - unique: operation, - platform: 'linux', - beforeHelper, - }) - case 'prune': - return pruneGateLogs(directory, 0, root, beforeHelper) - case 'clean': - return cleanGateFailureLogs(directory, root, beforeHelper) - } -} - function withPnpmEntrypoint(action: () => T): T { const previous = process.env.npm_execpath process.env.npm_execpath = '/private/pnpm.cjs' @@ -167,6 +130,31 @@ describe('gate plan validation', () => { expect(execute).not.toHaveBeenCalled() }) + it('reports a settled failure before an unrelated gate finishes', async () => { + const first = gate('first') + const second = gate('second') + const settle = new Map void>() + const observed: string[] = [] + const execution = executeGatePlan( + plan([first, second]), + 2, + subject => new Promise(resolve => settle.set(subject.id, resolve)), + result => observed.push(`${result.gate.id}:${result.status}`), + ) + + const settleFirst = settle.get(first.id) + const settleSecond = settle.get(second.id) + if (settleFirst === undefined || settleSecond === undefined) throw new Error('expected both gates to start') + settleFirst(resultFor(first, 'failed')) + await vi.waitFor(() => { + expect(observed).toEqual(['first:failed']) + }) + settleSecond(resultFor(second)) + + await expect(execution).resolves.toHaveLength(2) + expect(observed).toEqual(['first:failed', 'second:passed']) + }) + it('selects a target with its transitive dependencies in canonical plan order', () => { const subject = plan([ gate('prepare'), @@ -182,14 +170,13 @@ describe('gate plan validation', () => { }) describe('gate plan inspection and replay', () => { - it('parses package-script separators, list JSON, focused runs, and cleanup', () => { + it('parses package-script separators, list JSON, and focused runs', () => { expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({ kind: 'run', mode: 'check-all', list: true, json: true, }) expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({ kind: 'run', mode: 'check-all', list: false, json: false, only: 'snapshot', }) - expect(parseCliRequest(['--clean-logs'])).toEqual({ kind: 'clean-logs' }) expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list') expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode') }) @@ -307,288 +294,6 @@ describe('gate plan inspection and replay', () => { expect(result.exitCode).toBeNull() expect(result.signalCode).toBe('SIGTERM') expect(formatGateResultReason(result)).toBe('signal SIGTERM') - expect(formatGateFailureLog(plan([subjectGate]), result)).toContain('signal: SIGTERM') - }) -}) - -describe('gate failure logs', () => { - it('records attributable scheduler metadata without inherited secrets', () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') - const subjectGate = gate('snapshot', { - env: { - DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, - ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, - }, - }) - const subject = plan([subjectGate]) - const failure: GateResult = { - ...resultFor(subjectGate, 'failed'), - output: [{ stream: 'stderr', text: 'failure details\n' }], - stderr: 'failure details\n', - } - const log = formatGateFailureLog(subject, failure) - expect(log).toContain('replay: pnpm run check:all -- --only snapshot') - expect(log).toContain('DSH_EXAMPLE_MODE') - expect(log).toContain('') - expect(log).toContain('[stderr]\nfailure details') - expect(log).not.toContain('ambient-secret') - expect(log).not.toContain('scheduler-secret') - }) - - it.skipIf(process.platform === 'win32')('uses private exclusive files and bounds retention', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - const subject = plan([subjectGate]) - const failure = resultFor(subjectGate, 'failed') - - const first = await writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 2, unique: 'first', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', - }) - const second = await writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 2, unique: 'second', now: new Date('2026-07-27T00:00:01Z'), platform: 'linux', - }) - const third = await writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 2, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', - }) - - expect(readdirSync(directory).sort()).toEqual([second, third].map(path => path.slice(directory.length + 1)).sort()) - expect(readFileSync(third, 'utf8')).toContain('run-gates failure log') - expect(statSync(directory).mode & 0o777).toBe(0o700) - expect(statSync(third).mode & 0o777).toBe(0o600) - expect(() => statSync(first)).toThrow() - await expect(writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 3, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', - })).rejects.toThrow('EEXIST') - await cleanGateFailureLogs(directory, repositoryRoot) - expect(readdirSync(directory)).toEqual([]) - }) - - it.skipIf(process.platform === 'win32')('uses cross-platform filenames for replay-safe gate ids', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('build:web') - const path = await writeGateFailureLog( - plan([subjectGate]), - resultFor(subjectGate, 'failed'), - { - directory, repositoryRoot, retention: 1, unique: 'unique', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', - }, - ) - expect(path.slice(directory.length + 1)).toContain('-build-web-') - expect(path.slice(directory.length + 1)).not.toContain(':') - }) - - it.skipIf(process.platform === 'win32')('bounds retained UTF-8 output with explicit truncation metadata', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - const failure: GateResult = { - ...resultFor(subjectGate, 'failed'), - output: [{ stream: 'stderr', text: `${'界'.repeat(200)}\nlast detail\n` }], - } - const path = await writeGateFailureLog(plan([subjectGate]), failure, { - directory, - repositoryRoot, - retention: 1, - maxBytes: 256, - unique: 'bounded', - now: new Date('2026-07-27T00:00:00Z'), - platform: 'linux', - }) - const content = readFileSync(path, 'utf8') - - expect(Buffer.byteLength(content)).toBeLessThanOrEqual(256) - expect(content).toContain('[run-gates log truncated: original-bytes=') - expect(content).toContain('max-bytes=256') - expect(content).toContain('last detail') - expect(content).not.toContain('\uFFFD') - expect(limitGateFailureLog('x'.repeat(256), 256)).toBe('x'.repeat(256)) - }) - - it.skipIf(process.platform === 'win32')('accepts the worst-case JSON expansion of a bounded log', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - const failure: GateResult = { - ...resultFor(subjectGate, 'failed'), - output: [{ stream: 'stderr', text: '\0'.repeat(400_000) }], - } - const path = await writeGateFailureLog(plan([subjectGate]), failure, { - directory, - repositoryRoot, - retention: 1, - maxBytes: 400_000, - unique: 'control-heavy', - platform: 'linux', - }) - - expect(statSync(path).size).toBeLessThanOrEqual(400_000) - expect(readFileSync(path, 'utf8')).not.toContain('\uFFFD') - }) - - it('rejects symlinked repository cache components before writing, pruning, or cleanup', async () => { - const auditRoot = temporaryRoot('dsh-gate-symlink-') - const repositoryRoot = join(auditRoot, 'repository') - const external = join(auditRoot, 'external') - const directory = join(repositoryRoot, '.cache/gates') - mkdirSync(repositoryRoot) - mkdirSync(join(external, 'gates'), { recursive: true }) - const victim = join(external, 'gates/victim.log') - writeFileSync(victim, 'keep\n') - symlinkSync(external, join(repositoryRoot, '.cache'), process.platform === 'win32' ? 'junction' : 'dir') - const subjectGate = gate('subject') - const message = 'gate-log path component is a symbolic link: .cache' - - await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, repositoryRoot, retention: 1, unique: 'safe', platform: 'linux', - })).rejects.toThrow(message) - await expect(pruneGateLogs(directory, 0, repositoryRoot)).rejects.toThrow(message) - await expect(cleanGateFailureLogs(directory, repositoryRoot)).rejects.toThrow(message) - expect(existsSync(victim)).toBe(true) - }) - - it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => { - const subjectGate = gate('subject') - - for (const operation of ['write', 'prune', 'clean'] as const) { - const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`) - const repositoryRoot = join(auditRoot, 'repository') - const external = join(auditRoot, 'external') - const cache = join(repositoryRoot, '.cache') - const directory = join(cache, 'gates') - const displacedCache = join(repositoryRoot, '.cache-pinned') - mkdirSync(directory, { recursive: true }) - mkdirSync(external) - writeFileSync(join(directory, 'old.log'), 'old private log\n') - const victim = operation === 'write' ? undefined : join(external, 'gates/victim.log') - if (victim !== undefined) { - mkdirSync(join(external, 'gates')) - writeFileSync(victim, 'keep\n') - } - const swapAncestor = (): void => { - renameSync(cache, displacedCache) - symlinkSync(external, cache, 'dir') - } - - const invocation = invokeGateLogOperation( - operation, - subjectGate, - directory, - repositoryRoot, - swapAncestor, - ) - - await expect(invocation).rejects.toThrow('gate-log helper') - if (victim === undefined) { - expect(existsSync(join(external, 'gates'))).toBe(false) - } else { - expect(readFileSync(victim, 'utf8')).toBe('keep\n') - expect(readdirSync(join(external, 'gates'))).toEqual(['victim.log']) - } - expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') - } - }) - - it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => { - const subjectGate = gate('subject') - - for (const operation of ['write', 'prune', 'clean'] as const) { - const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`) - const repositoryRoot = join(auditRoot, 'repository') - const external = join(auditRoot, 'external') - const cache = join(repositoryRoot, '.cache') - const directory = join(cache, 'gates') - const displacedCache = join(repositoryRoot, '.cache-pinned') - const externalCache = join(external, 'cache') - mkdirSync(directory, { recursive: true }) - mkdirSync(join(externalCache, 'gates'), { recursive: true }) - writeFileSync(join(directory, 'old.log'), 'old private log\n') - const victim = operation === 'write' ? undefined : join(externalCache, 'gates/victim.log') - if (victim !== undefined) writeFileSync(victim, 'keep\n') - const swapAncestor = (): void => { - renameSync(cache, displacedCache) - renameSync(externalCache, cache) - } - - const invocation = invokeGateLogOperation( - operation, - subjectGate, - directory, - repositoryRoot, - swapAncestor, - ) - - await expect(invocation).rejects.toThrow('gate-log helper') - if (victim === undefined) { - expect(readdirSync(join(cache, 'gates'))).toEqual([]) - } else { - expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') - } - expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') - } - }) - - it.skipIf(process.platform === 'win32')('rejects a real directory introduced at a previously missing component', async () => { - const auditRoot = temporaryRoot('dsh-gate-missing-real-swap-') - const repositoryRoot = join(auditRoot, 'repository') - const externalCache = join(auditRoot, 'external-cache') - const cache = join(repositoryRoot, '.cache') - const directory = join(cache, 'gates') - mkdirSync(repositoryRoot) - mkdirSync(join(externalCache, 'gates'), { recursive: true }) - const victim = join(externalCache, 'gates/victim.log') - writeFileSync(victim, 'keep\n') - const subjectGate = gate('subject') - - const invocation = writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot, - retention: 1, - unique: 'missing-swap', - platform: 'linux', - beforeHelper: () => { - renameSync(externalCache, cache) - }, - }) - - await expect(invocation).rejects.toThrow('gate-log helper') - expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') - expect(readdirSync(join(cache, 'gates'))).toEqual(['victim.log']) - }) - - it.skipIf(process.platform === 'win32')('rejects a repository root replaced after validation', async () => { - const auditRoot = temporaryRoot('dsh-gate-root-swap-') - const repositoryRoot = join(auditRoot, 'repository') - const externalRoot = join(auditRoot, 'external-repository') - const displacedRoot = join(auditRoot, 'repository-pinned') - const directory = join(repositoryRoot, '.cache/gates') - mkdirSync(directory, { recursive: true }) - mkdirSync(join(externalRoot, '.cache/gates'), { recursive: true }) - writeFileSync(join(directory, 'old.log'), 'old private log\n') - writeFileSync(join(externalRoot, '.cache/gates/victim.log'), 'keep\n') - - const invocation = cleanGateFailureLogs(directory, repositoryRoot, () => { - renameSync(repositoryRoot, displacedRoot) - renameSync(externalRoot, repositoryRoot) - }) - - await expect(invocation).rejects.toThrow('gate-log helper') - expect(readFileSync(join(repositoryRoot, '.cache/gates/victim.log'), 'utf8')).toBe('keep\n') - expect(readFileSync(join(displacedRoot, '.cache/gates/old.log'), 'utf8')).toBe('old private log\n') - }) - - it('uses a console-only fallback on Windows before creating a retention directory', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - expect(failureLogUnavailableReason('win32')).toContain('complete output remains on the console') - expect(failureLogUnavailableReason('linux')).toBeUndefined() - - await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, repositoryRoot, platform: 'win32', - })).rejects.toThrow('retained failure logs are disabled on Windows') - expect(existsSync(directory)).toBe(false) }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 08a524556e..1f6d18c2e0 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -2,15 +2,13 @@ * Construct, inspect, and run local and CI quality-gate plans with bounded scheduling. * * Package scripts own public aggregate names; this runner owns their validated - * dependency graphs, scheduler environment, replay diagnostics, and private logs. + * dependency graphs, scheduler environment, and replay diagnostics. * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md */ import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' import { realpathSync } from 'node:fs' -import { lstat } from 'node:fs/promises' import { availableParallelism } from 'node:os' -import { isAbsolute, relative, resolve, sep } from 'node:path' +import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { pathToFileURL } from 'node:url' @@ -74,8 +72,6 @@ export interface GateResult { exitCode: number | null signalCode: NodeJS.Signals | null error?: string - logPath?: string - logError?: string } interface GateOutputChunk { @@ -102,12 +98,6 @@ interface RunRequest { only?: string } -interface CleanLogsRequest { - kind: 'clean-logs' -} - -type CliRequest = RunRequest | CleanLogsRequest - interface ListedEnvironmentOverride { operation: GateEnvironmentOverride['operation'] value?: string @@ -132,41 +122,10 @@ interface ListedPlan { gates: ListedGate[] } -interface GateLogDirectoryIdentity { - dev: string - ino: string -} - -interface GateLogPathComponent { - name: string - identity: GateLogDirectoryIdentity | null -} - -interface GateLogPathPlan { - repositoryIdentity: GateLogDirectoryIdentity - pathComponents: GateLogPathComponent[] -} - -type GateLogHelperRequest = - | { operation: 'write'; filename: string; content: string; retention: number } - | { operation: 'prune'; retain: number } - | { operation: 'clean' } - -interface GateLogHelperResult { - directory?: GateLogDirectoryIdentity - filename?: string - removed: string[] -} - type GateExecutor = (gate: Gate) => Promise -type ResultObserver = (result: GateResult) => Promise | void +type ResultObserver = (result: GateResult) => void const root = resolve(import.meta.dirname, '..') -const gateLogRoot = resolve(root, '.cache/gates') -const gateLogHelper = resolve(import.meta.dirname, 'gate-log-helper.mjs') -const GATE_LOG_RETENTION = 20 -const GATE_LOG_MAX_BYTES = 1_048_576 -const MIN_GATE_LOG_MAX_BYTES = 128 const MODE_SCRIPTS: Record = { 'ci-primary': 'check:ci', 'ci-static': 'check:ci:static', @@ -187,12 +146,6 @@ if (isMainModule()) process.exitCode = await main(process.argv.slice(2)) async function main(args: string[]): Promise { const request = parseCliRequest(args) - if (request.kind === 'clean-logs') { - await cleanGateFailureLogs() - console.log('run-gates: cleared retained logs in .cache/gates/.') - return 0 - } - const completePlan = gatePlanForMode(request.mode) validateGatePlan(completePlan) if (request.list) { @@ -212,8 +165,7 @@ async function main(args: string[]): Promise { const startedAt = performance.now() console.log(`run-gates: ${request.mode} running ${plan.gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) - const results = await executeGatePlan(plan, maxConcurrency, runGate, async (result) => { - await attachFailureLog(completePlan, result) + const results = await executeGatePlan(plan, maxConcurrency, runGate, (result) => { printResult(completePlan, result) }) printSummary(completePlan, results, performance.now() - startedAt) @@ -240,14 +192,9 @@ export function isMainModule(entry: string | undefined = process.argv[1]): boole /** * Parse one runner invocation without constructing or starting its plan. * @param args - command-line arguments after the script entrypoint. - * @returns the validated run or cleanup request. + * @returns the validated run request. */ -export function parseCliRequest(args: readonly string[]): CliRequest { - if (args[0] === '--clean-logs') { - if (args.length !== 1) throw new Error('run-gates: --clean-logs does not accept other arguments.') - return { kind: 'clean-logs' } - } - +export function parseCliRequest(args: readonly string[]): RunRequest { const mode = parseMode(args[0]) let list = false let json = false @@ -946,7 +893,7 @@ export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv) * @param plan - complete or diagnostic plan to execute. * @param maxActive - maximum concurrent child count. * @param execute - child-process executor. - * @param observe - serialized result observer. + * @param observe - result observer invoked when each gate settles. * @returns results in canonical plan order. */ export async function executeGatePlan( @@ -965,358 +912,6 @@ export async function executeGatePlan( return runGates(plan.gates, maxActive, execute, observe) } -/** - * Format one private failure log without consulting or enumerating the inherited environment. - * @param plan - complete owning plan. - * @param result - failed child outcome. - * @returns attributable metadata and interleaved output. - */ -export function formatGateFailureLog(plan: GatePlan, result: GateResult): string { - const gate = listedGate(result.gate) - const lines = [ - 'run-gates failure log', - `mode: ${plan.mode}`, - `gate: ${gate.id}`, - `status: ${result.status}`, - `blocking: ${gate.blocking}`, - `command: ${gate.command}`, - `replay: ${replayCommand(plan, gate.id)}`, - `scheduler environment: ${JSON.stringify(gate.env)}`, - `exit code: ${result.exitCode === null ? 'none' : result.exitCode}`, - `signal: ${result.signalCode ?? 'none'}`, - ] - if (result.error !== undefined) lines.push(`error: ${result.error}`) - lines.push('', 'interleaved output:') - for (const chunk of result.output) lines.push(`[${chunk.stream}]`, chunk.text) - return `${lines.join('\n')}\n` -} - -/** - * Explain why retained logs are unavailable on a platform. - * @param platform - host platform to evaluate. - * @returns the console-fallback diagnostic, or `undefined` when POSIX retention is supported. - */ -export function failureLogUnavailableReason(platform: NodeJS.Platform = process.platform): string | undefined { - return platform === 'win32' - ? 'retained failure logs are disabled on Windows because POSIX owner-only permissions are unavailable; complete output remains on the console' - : undefined -} - -/** - * Bound a UTF-8 failure log while retaining its beginning, end, and explicit truncation metadata. - * @param content - complete formatted failure log. - * @param maxBytes - maximum encoded byte length. - * @returns the original log when it fits, otherwise a bounded prefix and suffix around a marker. - */ -export function limitGateFailureLog(content: string, maxBytes: number): string { - if (!Number.isSafeInteger(maxBytes) || maxBytes < MIN_GATE_LOG_MAX_BYTES) { - throw new Error(`run-gates: failure-log byte limit must be an integer of at least ${MIN_GATE_LOG_MAX_BYTES}, got ${JSON.stringify(maxBytes)}.`) - } - const originalBytes = Buffer.byteLength(content) - if (originalBytes <= maxBytes) return content - - const marker = `\n[run-gates log truncated: original-bytes=${originalBytes}; max-bytes=${maxBytes}]\n` - const available = maxBytes - Buffer.byteLength(marker) - if (available < 0) throw new Error('run-gates: failure-log truncation marker exceeds the configured byte limit.') - const prefixBytes = Math.ceil(available / 2) - const suffixBytes = available - prefixBytes - return `${utf8Prefix(content, prefixBytes)}${marker}${utf8Suffix(content, suffixBytes)}` -} - -function utf8Prefix(content: string, maxBytes: number): string { - const encoded = Buffer.from(content) - if (encoded.length <= maxBytes) return content - let end = maxBytes - while (end > 0) { - const byte = encoded[end] - if (byte === undefined || (byte & 0xc0) !== 0x80) break - end -= 1 - } - return encoded.subarray(0, end).toString('utf8') -} - -function utf8Suffix(content: string, maxBytes: number): string { - const encoded = Buffer.from(content) - if (encoded.length <= maxBytes) return content - let start = encoded.length - maxBytes - while (start < encoded.length) { - const byte = encoded[start] - if (byte === undefined || (byte & 0xc0) !== 0x80) break - start += 1 - } - return encoded.subarray(start).toString('utf8') -} - -/** - * Write one exclusive owner-only POSIX failure log and keep only the newest bounded set. - * @param plan - complete owning plan. - * @param result - failed child outcome. - * @param options - injectable storage, bound, clock, identity, and platform seams. - * @returns the absolute log path. - */ -export async function writeGateFailureLog( - plan: GatePlan, - result: GateResult, - options: { - directory?: string - repositoryRoot?: string - retention?: number - maxBytes?: number - unique?: string - now?: Date - platform?: NodeJS.Platform - beforeHelper?: () => Promise | void - } = {}, -): Promise { - const directory = options.directory ?? gateLogRoot - const repositoryRoot = options.repositoryRoot ?? root - const retention = options.retention ?? GATE_LOG_RETENTION - const maxBytes = options.maxBytes ?? GATE_LOG_MAX_BYTES - const unique = options.unique ?? randomUUID() - const now = options.now ?? new Date() - const unavailable = failureLogUnavailableReason(options.platform) - if (unavailable !== undefined) throw new Error(`run-gates: ${unavailable}.`) - if (!Number.isSafeInteger(retention) || retention < 1) { - throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`) - } - const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) - const timestamp = now.toISOString().replaceAll(/[:.]/g, '-') - const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '') - if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.') - const safeGateId = result.gate.id.replaceAll(/[^a-zA-Z0-9-]/g, '-') - const filename = `${timestamp}-${plan.mode}-${safeGateId}-${safeUnique}.log` - const helperResult = await runGateLogHelper( - directory, - repositoryRoot, - repositoryIdentity, - pathComponents, - { - operation: 'write', - filename, - content: limitGateFailureLog(formatGateFailureLog(plan, result), maxBytes), - retention, - }, - options.beforeHelper, - ) - if (helperResult.filename !== filename) throw new Error('run-gates: gate-log helper returned the wrong filename.') - return resolve(directory, filename) -} - -async function inspectRepoLocalLogPath( - repositoryRoot: string, - target: string, -): Promise { - const relativeTarget = relative(repositoryRoot, target) - if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) { - throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`) - } - - const rootMetadata = await lstat(repositoryRoot, { bigint: true }) - if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { - throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`) - } - const components: GateLogPathComponent[] = [] - let current = repositoryRoot - let missing = false - for (const component of relativeTarget.split(sep)) { - current = resolve(current, component) - if (missing) { - components.push({ name: component, identity: null }) - continue - } - let metadata - try { - metadata = await lstat(current, { bigint: true }) - } catch (error: unknown) { - if (hasErrorCode(error, 'ENOENT')) { - missing = true - components.push({ name: component, identity: null }) - continue - } - throw error - } - const shown = relative(repositoryRoot, current).split(sep).join('/') - if (metadata.isSymbolicLink()) { - throw new Error(`run-gates: gate-log path component is a symbolic link: ${shown}`) - } - if (!metadata.isDirectory()) { - throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`) - } - components.push({ name: component, identity: { dev: String(metadata.dev), ino: String(metadata.ino) } }) - } - return { - repositoryIdentity: { dev: String(rootMetadata.dev), ino: String(rootMetadata.ino) }, - pathComponents: components, - } -} - -function hasErrorCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code -} - -async function readDirectoryIdentity(directory: string): Promise { - let metadata - try { - metadata = await lstat(directory, { bigint: true }) - } catch (error: unknown) { - if (hasErrorCode(error, 'ENOENT')) return undefined - throw error - } - if (!metadata.isDirectory() || metadata.isSymbolicLink()) { - throw new Error(`run-gates: gate-log path is not a real directory: ${directory}`) - } - return { dev: String(metadata.dev), ino: String(metadata.ino) } -} - -async function runGateLogHelper( - directory: string, - repositoryRoot: string, - repositoryIdentity: GateLogDirectoryIdentity, - pathComponents: GateLogPathComponent[], - request: GateLogHelperRequest, - beforeHelper: (() => Promise | void) | undefined, -): Promise { - await beforeHelper?.() - const payload = JSON.stringify({ - ...request, - repository: { - root: repositoryRoot, - relative: relative(repositoryRoot, directory), - identity: repositoryIdentity, - components: pathComponents, - }, - }) - const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => { - const child = spawn(process.execPath, [gateLogHelper], { - cwd: repositoryRoot, - env: {}, - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdout += chunk - }) - child.stderr.on('data', (chunk: string) => { - stderr += chunk - }) - child.on('error', reject) - child.on('close', (status) => { - resolveResult({ status, stdout, stderr }) - }) - child.stdin.on('error', (error: NodeJS.ErrnoException) => { - if (error.code !== 'EPIPE') reject(error) - }) - child.stdin.end(payload) - }) - if (result.status !== 0) { - throw new Error(`run-gates: gate-log helper failed: ${result.stderr.trim() || `exit status ${String(result.status)}`}`) - } - let parsed: unknown - try { - parsed = JSON.parse(result.stdout) - } catch { - throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`) - } - if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.') - await inspectRepoLocalLogPath(repositoryRoot, directory) - const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if ( - currentRepositoryIdentity === undefined - || currentRepositoryIdentity.dev !== repositoryIdentity.dev - || currentRepositoryIdentity.ino !== repositoryIdentity.ino - ) { - throw new Error('run-gates: repository root identity changed while the gate-log helper was running.') - } - if (parsed.directory !== undefined) { - const currentDirectoryIdentity = await readDirectoryIdentity(directory) - if ( - currentDirectoryIdentity === undefined - || currentDirectoryIdentity.dev !== parsed.directory.dev - || currentDirectoryIdentity.ino !== parsed.directory.ino - ) { - throw new Error('run-gates: gate-log directory identity changed while the helper was running.') - } - } else if (request.operation === 'write') { - throw new Error('run-gates: gate-log helper did not return the created directory identity.') - } - return parsed -} - -function isGateLogHelperResult(value: unknown): value is GateLogHelperResult { - if (typeof value !== 'object' || value === null || !('removed' in value) || !Array.isArray(value.removed)) return false - if (!value.removed.every(entry => typeof entry === 'string')) return false - if ('filename' in value && value.filename !== undefined && typeof value.filename !== 'string') return false - return !('directory' in value) - || value.directory === undefined - || isGateLogDirectoryIdentity(value.directory) -} - -function isGateLogDirectoryIdentity(value: unknown): value is GateLogDirectoryIdentity { - return typeof value === 'object' - && value !== null - && 'dev' in value - && typeof value.dev === 'string' - && 'ino' in value - && typeof value.ino === 'string' -} - -/** Clear retained logs through a subprocess that pins the repository and each path component before use. */ -export async function cleanGateFailureLogs( - directory = gateLogRoot, - repositoryRoot = root, - beforeHelper?: () => Promise | void, -): Promise { - const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) - await runGateLogHelper( - directory, - repositoryRoot, - repositoryIdentity, - pathComponents, - { operation: 'clean' }, - beforeHelper, - ) -} - -/** - * Remove older scheduler log files until at most `retain` remain. - * @param directory - private log directory. - * @param retain - number of newest log files to preserve. - * @param repositoryRoot - repository boundary containing the log directory. - * @param beforeHelper - test seam invoked after identity capture and before subprocess spawn. - */ -export async function pruneGateLogs( - directory: string, - retain: number, - repositoryRoot = root, - beforeHelper?: () => Promise | void, -): Promise { - if (!Number.isSafeInteger(retain) || retain < 0) { - throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`) - } - const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) - await runGateLogHelper( - directory, - repositoryRoot, - repositoryIdentity, - pathComponents, - { operation: 'prune', retain }, - beforeHelper, - ) -} - -async function attachFailureLog(plan: GatePlan, result: GateResult): Promise { - if (result.status !== 'failed') return - try { - const path = await writeGateFailureLog(plan, result) - result.logPath = relative(root, path).split(sep).join('/') - } catch (error: unknown) { - result.logError = error instanceof Error ? error.message : String(error) - } -} - async function runGates( allGates: Gate[], maxActive: number, @@ -1355,7 +950,7 @@ async function runGates( } states.set(gate.id, 'skipped') results.set(gate.id, result) - await observe(result) + observe(result) } break } @@ -1365,7 +960,7 @@ async function runGates( running.splice(running.indexOf(settled.item), 1) states.set(settled.item.gate.id, settled.result.status) results.set(settled.item.gate.id, settled.result) - await observe(settled.result) + observe(settled.result) } } @@ -1477,11 +1072,6 @@ function printResult(plan: GatePlan, result: GateResult): void { console.error(`command: ${result.gate.displayCommand}`) if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) console.error(`replay: ${replayCommand(plan, result.gate.id)}`) - if (result.logPath !== undefined) { - console.error(`full log: ${result.logPath} (private; newest ${GATE_LOG_RETENTION} retained)`) - console.error('cleanup: pnpm exec tsx scripts/run-gates.ts --clean-logs') - } - if (result.logError !== undefined) console.error(`full log unavailable: ${result.logError}`) } printOutput(result.output) if (result.error !== undefined) console.error(result.error) @@ -1504,7 +1094,6 @@ function printSummary(plan: GatePlan, results: GateResult[], durationMs: number) const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : '' console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) console.error(` replay: ${replayCommand(plan, result.gate.id)}`) - if (result.logPath !== undefined) console.error(` full log: ${result.logPath}`) } } From 3e6fbccffaa55120189b61b3b2ae800585d79104 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:58:27 +0800 Subject: [PATCH 05/32] refactor(dev-infra): trim gate plan surfaces --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 2 +- .../2026-07-27-replayable-gate-plans.zh.md | 2 +- scripts/run-gates.spec.ts | 37 +------ scripts/run-gates.ts | 96 ++++++------------- 5 files changed, 37 insertions(+), 104 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index 98e78e752a..aa9b6d0cbc 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: a312481a4da68f0d990e28c07cced4511c922b9b -2026-07-27-replayable-gate-plans.zh.md: c213068f5413110a2c5952ac05ebc326de12682c +2026-07-27-replayable-gate-plans.md: 8a42ae3c89a3f75bb248f78583f6ef825af3241e +2026-07-27-replayable-gate-plans.zh.md: 572ef1ce131a0ced8d723e1caa822fe47556fcee diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index a312481a4d..8a42ae3c89 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -14,7 +14,7 @@ Operators also need the scheduler-owned environment and dependency context for a [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract. -Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn, so inspection never enumerates or bakes in inherited values; values under secret-like names are redacted. +Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn and support only the forms current plans use: setting a value or appending one with a space. Inspection therefore never enumerates or bakes in inherited values; values under secret-like names are redacted. `--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index c213068f54..572ef1ce13 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -14,7 +14,7 @@ Status: implemented [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。 -每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,因此检查结果不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 +每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,并且只支持当前计划使用的两种形式:设置值,或以空格分隔后追加值。检查结果因此不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 `--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 1a86ccd064..d112bccd89 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,10 +1,4 @@ -import { - mkdtempSync, - rmSync, - symlinkSync, -} from 'node:fs' import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -15,7 +9,6 @@ import { formatOnlyNotice, gateDependencyClosure, gatePlanForMode, - isMainModule, listedGatePlan, parseCliRequest, replayCommand, @@ -28,13 +21,9 @@ import { type GateResult, } from './run-gates.ts' -const temporaryRoots: string[] = [] const repositoryRoot = join(import.meta.dirname, '..') -afterEach(() => { - vi.unstubAllEnvs() - for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) +afterEach(() => vi.unstubAllEnvs()) function gate(id: string, options: Partial = {}): Gate { return { @@ -64,12 +53,6 @@ function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): Gate } } -function temporaryRoot(prefix = 'dsh-run-gates-'): string { - const root = mkdtempSync(join(tmpdir(), prefix)) - temporaryRoots.push(root) - return root -} - function withPnpmEntrypoint(action: () => T): T { const previous = process.env.npm_execpath process.env.npm_execpath = '/private/pnpm.cjs' @@ -172,10 +155,10 @@ describe('gate plan validation', () => { describe('gate plan inspection and replay', () => { it('parses package-script separators, list JSON, and focused runs', () => { expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({ - kind: 'run', mode: 'check-all', list: true, json: true, + mode: 'check-all', list: true, json: true, }) expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({ - kind: 'run', mode: 'check-all', list: false, json: false, only: 'snapshot', + mode: 'check-all', list: false, json: false, only: 'snapshot', }) expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list') expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode') @@ -252,15 +235,6 @@ describe('gate plan inspection and replay', () => { }) }) - it.skipIf(process.platform === 'win32')('recognizes a symlinked script entry path', () => { - const temporary = temporaryRoot('dsh-run-gates-entry-') - const entry = join(temporary, 'run-gates.ts') - symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) - - expect(isMainModule(entry)).toBe(true) - expect(isMainModule(join(temporary, 'missing.ts'))).toBe(false) - }) - it('renders a cross-platform scheduler replay and labels focused evidence', () => { const subject = plan([gate('snapshot')]) expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') @@ -269,14 +243,13 @@ describe('gate plan inspection and replay', () => { ) }) - it('resolves append, set, and unset operations only when spawning', () => { + it('resolves append and set operations only when spawning', () => { const resolved = resolveGateEnvironment(gate('subject', { env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, MODE: { operation: 'set', value: 'lib' }, - REMOVE_ME: { operation: 'unset' }, }, - }), { NODE_OPTIONS: '--trace-warnings', REMOVE_ME: 'yes', INHERITED: 'kept' }) + }), { NODE_OPTIONS: '--trace-warnings', INHERITED: 'kept' }) expect(resolved).toEqual({ NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192', MODE: 'lib', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 1f6d18c2e0..9a8367ec79 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -6,38 +6,38 @@ * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md */ import { spawn } from 'node:child_process' -import { realpathSync } from 'node:fs' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { pathToFileURL } from 'node:url' -const MODES = [ - 'ci-primary', - 'ci-static', - 'ci-lint', - 'ci-coverage', - 'ci-snapshot', - 'ci-artifacts', - 'ci-consumers', - 'ci-windows-blocking', - 'ci-windows-complete', - 'ci-windows-observational', - 'node-compat', - 'check-all', - 'doc-sync', -] as const +const MODE_SCRIPTS = { + 'ci-primary': 'check:ci', + 'ci-static': 'check:ci:static', + 'ci-lint': 'check:ci:lint', + 'ci-coverage': 'check:ci:coverage', + 'ci-snapshot': 'check:ci:snapshot', + 'ci-artifacts': 'check:ci:artifacts', + 'ci-consumers': 'check:ci:consumers', + 'ci-windows-blocking': 'check:ci:windows-blocking', + 'ci-windows-complete': 'check:ci:windows-complete', + 'ci-windows-observational': 'check:ci:windows-observational', + 'node-compat': 'check:node-compat', + 'check-all': 'check:all', + 'doc-sync': 'doc-sync', +} as const /** A named aggregate exposed by the gate runner. */ -export type Mode = typeof MODES[number] +export type Mode = keyof typeof MODE_SCRIPTS + +const MODES = Object.keys(MODE_SCRIPTS) as Mode[] type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' /** One scheduler-owned environment operation, resolved against inherited values only at spawn time. */ export type GateEnvironmentOverride = | { operation: 'set'; value: string } - | { operation: 'unset' } - | { operation: 'append'; value: string; separator?: string } + | { operation: 'append'; value: string } /** A command and its dependency metadata inside one gate plan. */ export interface Gate { @@ -91,18 +91,13 @@ export interface ResolvedConcurrency { } interface RunRequest { - kind: 'run' mode: Mode list: boolean json: boolean only?: string } -interface ListedEnvironmentOverride { - operation: GateEnvironmentOverride['operation'] - value?: string - separator?: string -} +type ListedEnvironmentOverride = GateEnvironmentOverride interface ListedGate { id: string @@ -126,24 +121,11 @@ type GateExecutor = (gate: Gate) => Promise type ResultObserver = (result: GateResult) => void const root = resolve(import.meta.dirname, '..') -const MODE_SCRIPTS: Record = { - 'ci-primary': 'check:ci', - 'ci-static': 'check:ci:static', - 'ci-lint': 'check:ci:lint', - 'ci-coverage': 'check:ci:coverage', - 'ci-snapshot': 'check:ci:snapshot', - 'ci-artifacts': 'check:ci:artifacts', - 'ci-consumers': 'check:ci:consumers', - 'ci-windows-blocking': 'check:ci:windows-blocking', - 'ci-windows-complete': 'check:ci:windows-complete', - 'ci-windows-observational': 'check:ci:windows-observational', - 'node-compat': 'check:node-compat', - 'check-all': 'check:all', - 'doc-sync': 'doc-sync', +const entry = process.argv[1] +if (entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href) { + process.exitCode = await main(process.argv.slice(2)) } -if (isMainModule()) process.exitCode = await main(process.argv.slice(2)) - async function main(args: string[]): Promise { const request = parseCliRequest(args) const completePlan = gatePlanForMode(request.mode) @@ -174,21 +156,6 @@ async function main(args: string[]): Promise { : 0 } -/** - * Decide whether this module is the process entry, including through a symlinked path. - * @param entry - process entry path to compare with this module. - * @returns Whether the entry resolves to this module. - */ -export function isMainModule(entry: string | undefined = process.argv[1]): boolean { - if (entry === undefined) return false - if (import.meta.url === pathToFileURL(resolve(entry)).href) return true - try { - return import.meta.url === pathToFileURL(realpathSync(entry)).href - } catch { - return false - } -} - /** * Parse one runner invocation without constructing or starting its plan. * @param args - command-line arguments after the script entrypoint. @@ -220,7 +187,7 @@ export function parseCliRequest(args: readonly string[]): RunRequest { } if (json && !list) throw new Error('run-gates: --json requires --list.') if (list && only !== undefined) throw new Error('run-gates: --list and --only are mutually exclusive.') - return { kind: 'run', mode, list, json, ...only === undefined ? {} : { only } } + return { mode, list, json, ...only === undefined ? {} : { only } } } function parseMode(raw: string | undefined): Mode { @@ -798,13 +765,8 @@ function listedEnvironment( ): Record { if (environment === undefined) return {} return Object.fromEntries(Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, override]) => { - const value = 'value' in override - ? { value: sensitiveEnvironmentName(name) ? '' : override.value } - : {} - const separator = override.operation === 'append' && override.separator !== undefined - ? { separator: override.separator } - : {} - return [name, { operation: override.operation, ...value, ...separator }] + const value = sensitiveEnvironmentName(name) ? '' : override.value + return [name, { operation: override.operation, value }] })) } @@ -874,15 +836,13 @@ export function formatOnlyNotice(plan: GatePlan, gateId: string): string { export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const resolved = { ...inherited } for (const [name, override] of Object.entries(gate.env ?? {})) { - if (override.operation === 'unset') { - Reflect.deleteProperty(resolved, name) - } else if (override.operation === 'set') { + if (override.operation === 'set') { resolved[name] = override.value } else { const current = resolved[name] resolved[name] = current === undefined || current === '' ? override.value - : `${current}${override.separator ?? ' '}${override.value}` + : `${current} ${override.value}` } } return resolved From 55d6ab52207e3f8092047f46608c95f487636001 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:12:16 +0800 Subject: [PATCH 06/32] fix(dev-infra): harden gate runner execution --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 6 +- .../2026-07-27-replayable-gate-plans.zh.md | 8 +-- scripts/run-gates.spec.ts | 70 ++++++++++++++++--- scripts/run-gates.ts | 47 +++++++++---- 5 files changed, 103 insertions(+), 32 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index aa9b6d0cbc..f61cb168d2 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: 8a42ae3c89a3f75bb248f78583f6ef825af3241e -2026-07-27-replayable-gate-plans.zh.md: 572ef1ce131a0ced8d723e1caa822fe47556fcee +2026-07-27-replayable-gate-plans.md: fc4f883d74d765d173a6d1419c2e75f44bd1966f +2026-07-27-replayable-gate-plans.zh.md: d6c6eb9649120c7dfbfb6cd0c935e915c62f40ef diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index 8a42ae3c89..fc4f883d74 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -8,13 +8,13 @@ English | [中文](2026-07-27-replayable-gate-plans.zh.md) Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. -Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing restored build artifacts to be consumed before any command established that the download was complete. +Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing later commands to consume restored artifacts before publint and built-package invariant checks established their public and runtime-closure contracts. ## Decision [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract. -Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn and support only the forms current plans use: setting a value or appending one with a space. Inspection therefore never enumerates or bakes in inherited values; values under secret-like names are redacted. +Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Gate-level spawn overrides remain declarative until spawn and support only the forms current plans use: setting a value or appending one with a space. Inspection serializes those operations without resolving them against inherited values; values under secret-like declared names are redacted. `--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. @@ -24,7 +24,7 @@ The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level co ## Verification -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, direct and symlinked entries emit one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index 572ef1ce13..d6c6eb9649 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -8,15 +8,15 @@ Status: implemented 仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。 -故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许在任何命令确认下载产物完整之前消费恢复后的构建产物。 +故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许后续命令在 publint 和已构建包(package)不变式检查确立恢复后产物的公开契约与运行时闭包契约之前,就消费这些产物。 ## 决策 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。 -每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,并且只支持当前计划使用的两种形式:设置值,或以空格分隔后追加值。检查结果因此不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 +每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。门禁级 spawn 覆盖在 spawn 之前保持声明式,并且只支持当前计划使用的两种形式:设置值,或以空格分隔后追加值。检查会序列化这些操作,而不会结合继承值进行解析;声明的名称若疑似机密,其值会被脱敏。 -`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 +`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 @@ -24,7 +24,7 @@ Status: implemented ## 验证 -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、直接入口和符号链接入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 ## 曾考虑的替代方案 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index d112bccd89..142a221070 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,4 +1,6 @@ import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -12,7 +14,6 @@ import { listedGatePlan, parseCliRequest, replayCommand, - resolveGateEnvironment, resolvePlanConcurrency, runGate, validateGatePlan, @@ -89,6 +90,7 @@ describe('gate plan validation', () => { it.each([ ['empty', plan([]), /plan has no gates/], ['duplicate ids', plan([gate('same'), gate('same')]), /duplicate gate id "same"/], + ['unsafe ids', plan([gate('unsafe id')]), /gate id "unsafe id" must contain only lowercase letters/], ['unknown dependencies', plan([gate('subject', { needs: ['missing'] })]), /depends on unknown gate "missing"/], ['cycles', plan([gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })]), /dependency cycle: first -> second -> first/], ])('rejects %s before starting a child', async (_label, invalid, message) => { @@ -138,6 +140,27 @@ describe('gate plan validation', () => { expect(observed).toEqual(['first:failed', 'second:passed']) }) + it('propagates dependency skips in causal order', async () => { + const leaf = gate('leaf', { needs: ['middle'] }) + const middle = gate('middle', { needs: ['root'] }) + const rootGate = gate('root') + const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed')) + const observed: string[] = [] + + const results = await executeGatePlan( + plan([leaf, middle, rootGate]), + 1, + execute, + result => observed.push(`${result.gate.id}:${result.status}`), + ) + + expect(execute).toHaveBeenCalledOnce() + expect(execute).toHaveBeenCalledWith(rootGate) + expect(observed).toEqual(['root:failed', 'middle:skipped', 'leaf:skipped']) + expect(results.find(result => result.gate === middle)?.error).toBe('dependency failed or skipped: root') + expect(results.find(result => result.gate === leaf)?.error).toBe('dependency failed or skipped: middle') + }) + it('selects a target with its transitive dependencies in canonical plan order', () => { const subject = plan([ gate('prepare'), @@ -235,6 +258,32 @@ describe('gate plan inspection and replay', () => { }) }) + it.skipIf(process.platform === 'win32')('executes when the script entry path is a symlink', () => { + const temporary = mkdtempSync(join(tmpdir(), 'dsh-run-gates-entry-')) + const entry = join(temporary, 'run-gates.ts') + try { + symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) + const result = spawnSync(process.execPath, [ + '--import', + 'tsx', + entry, + 'ci-consumers', + '--list', + '--json', + ], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, npm_execpath: process.env.npm_execpath ?? '/private/pnpm.cjs' }, + timeout: 10_000, + }) + + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ mode: 'ci-consumers', maxWorkers: 7 }) + } finally { + rmSync(temporary, { recursive: true, force: true }) + } + }) + it('renders a cross-platform scheduler replay and labels focused evidence', () => { const subject = plan([gate('snapshot')]) expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') @@ -243,17 +292,22 @@ describe('gate plan inspection and replay', () => { ) }) - it('resolves append and set operations only when spawning', () => { - const resolved = resolveGateEnvironment(gate('subject', { + it('applies append and set operations through the child spawn environment', async () => { + vi.stubEnv('NODE_OPTIONS', '--trace-warnings') + vi.stubEnv('INHERITED', 'kept') + const result = await runGate(gate('subject', { + args: ['-e', 'process.stdout.write(JSON.stringify({ nodeOptions: process.env.NODE_OPTIONS, mode: process.env.MODE, inherited: process.env.INHERITED }))'], env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, MODE: { operation: 'set', value: 'lib' }, }, - }), { NODE_OPTIONS: '--trace-warnings', INHERITED: 'kept' }) - expect(resolved).toEqual({ - NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192', - MODE: 'lib', - INHERITED: 'kept', + })) + + expect(result.status).toBe('passed') + expect(JSON.parse(result.stdout)).toEqual({ + nodeOptions: '--trace-warnings --max-old-space-size=8192', + mode: 'lib', + inherited: 'kept', }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 9a8367ec79..2b376d6882 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -9,7 +9,6 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' -import { pathToFileURL } from 'node:url' const MODE_SCRIPTS = { 'ci-primary': 'check:ci', @@ -121,8 +120,7 @@ type GateExecutor = (gate: Gate) => Promise type ResultObserver = (result: GateResult) => void const root = resolve(import.meta.dirname, '..') -const entry = process.argv[1] -if (entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href) { +if (import.meta.main) { process.exitCode = await main(process.argv.slice(2)) } @@ -833,21 +831,31 @@ export function formatOnlyNotice(plan: GatePlan, gateId: string): string { * @param inherited - environment inherited by the runner. * @returns the child environment without mutating the inherited object. */ -export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const resolved = { ...inherited } for (const [name, override] of Object.entries(gate.env ?? {})) { - if (override.operation === 'set') { - resolved[name] = override.value - } else { - const current = resolved[name] - resolved[name] = current === undefined || current === '' - ? override.value - : `${current} ${override.value}` + switch (override.operation) { + case 'set': + resolved[name] = override.value + break + case 'append': { + const current = resolved[name] + resolved[name] = current === undefined || current === '' + ? override.value + : `${current} ${override.value}` + break + } + default: + assertNever(override) } } return resolved } +function assertNever(value: never): never { + throw new Error(`run-gates: unreachable value ${JSON.stringify(value)}.`) +} + /** * Run a validated plan; invalid input rejects before the injected executor can start a child. * @param plan - complete or diagnostic plan to execute. @@ -894,9 +902,17 @@ async function runGates( } if (running.length === 0) { - const pending = allGates.filter(gate => states.get(gate.id) === 'pending') - for (const gate of pending) { - const failedDeps = (gate.needs ?? []).filter(id => states.get(id) !== 'passed') + let pending = allGates.filter(gate => states.get(gate.id) === 'pending') + while (pending.length > 0) { + const gate = pending.find(item => (item.needs ?? []).some((id) => { + const state = states.get(id) + return state === 'failed' || state === 'skipped' + })) + if (gate === undefined) throw new Error('run-gates: validated plan stalled without a failed dependency.') + const failedDeps = (gate.needs ?? []).filter((id) => { + const state = states.get(id) + return state === 'failed' || state === 'skipped' + }) const result: GateResult = { gate, status: 'skipped', @@ -911,6 +927,7 @@ async function runGates( states.set(gate.id, 'skipped') results.set(gate.id, result) observe(result) + pending = pending.filter(item => item !== gate) } break } @@ -1031,10 +1048,10 @@ function printResult(plan: GatePlan, result: GateResult): void { const environment = listedGate(result.gate).env console.error(`command: ${result.gate.displayCommand}`) if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) + console.error(`outcome: ${formatGateResultReason(result)}`) console.error(`replay: ${replayCommand(plan, result.gate.id)}`) } printOutput(result.output) - if (result.error !== undefined) console.error(result.error) } function printSummary(plan: GatePlan, results: GateResult[], durationMs: number): void { From 6b19be28430902558bcfce5f46adfaafbba6e31f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:13:11 +0800 Subject: [PATCH 07/32] refactor(dev-infra): use Node gate argv parsing --- scripts/run-gates.ts | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 2b376d6882..fbcc3232ef 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -9,6 +9,7 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' +import { parseArgs } from 'node:util' const MODE_SCRIPTS = { 'ci-primary': 'check:ci', @@ -96,14 +97,12 @@ interface RunRequest { only?: string } -type ListedEnvironmentOverride = GateEnvironmentOverride - interface ListedGate { id: string label: string command: string needs: string[] - env: Record + env: Record blocking: boolean } @@ -161,28 +160,17 @@ async function main(args: string[]): Promise { */ export function parseCliRequest(args: readonly string[]): RunRequest { const mode = parseMode(args[0]) - let list = false - let json = false - let only: string | undefined - const firstOption = args[1] === '--' ? 2 : 1 - for (let index = firstOption; index < args.length; index += 1) { - const arg = args[index] - if (arg === '--list') { - if (list) throw new Error('run-gates: --list may be specified only once.') - list = true - } else if (arg === '--json') { - if (json) throw new Error('run-gates: --json may be specified only once.') - json = true - } else if (arg === '--only') { - if (only !== undefined) throw new Error('run-gates: --only may be specified only once.') - const id = args[index + 1] - if (id === undefined || id.startsWith('--')) throw new Error('run-gates: --only requires a gate id.') - only = id - index += 1 - } else { - throw new Error(`run-gates: unsupported argument ${JSON.stringify(arg)}.`) - } - } + const optionArgs = args[1] === '--' ? args.slice(2) : args.slice(1) + const { values: { list, json, only } } = parseArgs({ + args: optionArgs, + options: { + list: { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + only: { type: 'string' }, + }, + strict: true, + allowPositionals: false, + }) if (json && !list) throw new Error('run-gates: --json requires --list.') if (list && only !== undefined) throw new Error('run-gates: --list and --only are mutually exclusive.') return { mode, list, json, ...only === undefined ? {} : { only } } From 4338b2725cc2ec7b18c10e8ab29af7a361ea10d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:13:52 +0800 Subject: [PATCH 08/32] fix(dev-infra): distinguish gate results from states --- scripts/run-gates.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index fbcc3232ef..8feaf576e5 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -32,7 +32,8 @@ export type Mode = keyof typeof MODE_SCRIPTS const MODES = Object.keys(MODE_SCRIPTS) as Mode[] -type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' +type GateResultStatus = 'passed' | 'failed' | 'skipped' +type GateState = 'pending' | 'running' | GateResultStatus /** One scheduler-owned environment operation, resolved against inherited values only at spawn time. */ export type GateEnvironmentOverride = @@ -64,7 +65,7 @@ export interface GatePlan { /** The observed outcome of one gate process. */ export interface GateResult { gate: Gate - status: GateStatus + status: GateResultStatus durationMs: number stdout: string stderr: string @@ -874,7 +875,7 @@ async function runGates( execute: GateExecutor, observe: ResultObserver, ): Promise { - const states = new Map(allGates.map(gate => [gate.id, 'pending'])) + const states = new Map(allGates.map(gate => [gate.id, 'pending'])) const results = new Map() const running: RunningGate[] = [] @@ -936,7 +937,7 @@ async function runGates( }) } -function dependenciesPassed(gate: Gate, states: Map): boolean { +function dependenciesPassed(gate: Gate, states: Map): boolean { return (gate.needs ?? []).every(id => states.get(id) === 'passed') } @@ -983,7 +984,7 @@ export async function runGate(gate: Gate): Promise { }) const { exitCode, signalCode } = outcome - let status: GateStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' + let status: GateResultStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' let error = spawnError if (status === 'passed' && gate.verify !== undefined) { try { From 32645aaa294e7a3e8386ca4f3c6ec6cd612d7cea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:17:09 +0800 Subject: [PATCH 09/32] fix(dev-infra): repair gate listing type --- scripts/run-gates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 8feaf576e5..bc0b3d9476 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -749,7 +749,7 @@ function listedGate(gate: Gate): ListedGate { function listedEnvironment( environment: Readonly> | undefined, -): Record { +): Record { if (environment === undefined) return {} return Object.fromEntries(Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, override]) => { const value = sensitiveEnvironmentName(name) ? '' : override.value From e71aa49a26925c6df2a34b6e0a203d6c9d438c60 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:19:46 +0800 Subject: [PATCH 10/32] test(dev-infra): exercise replay diagnostics --- scripts/run-gates.spec.ts | 27 +++++++++++++++++++-------- scripts/run-gates.ts | 4 ++-- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 142a221070..898a637e6a 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -8,12 +8,10 @@ import { formatGatePlanJson, formatGatePlanList, formatGateResultReason, - formatOnlyNotice, gateDependencyClosure, gatePlanForMode, listedGatePlan, parseCliRequest, - replayCommand, resolvePlanConcurrency, runGate, validateGatePlan, @@ -284,12 +282,25 @@ describe('gate plan inspection and replay', () => { } }) - it('renders a cross-platform scheduler replay and labels focused evidence', () => { - const subject = plan([gate('snapshot')]) - expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') - expect(formatOnlyNotice(subject, 'snapshot')).toBe( - 'run-gates: --only snapshot is partial diagnostic evidence; the complete owning mode is pnpm run check:all.', - ) + it('prints focused-run context and replay through a real failure block', () => { + const result = spawnSync(process.execPath, [ + '--import', + 'tsx', + join(repositoryRoot, 'scripts/run-gates.ts'), + 'ci-lint', + '--only', + 'duplication', + ], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, npm_execpath: join(repositoryRoot, 'scripts/missing-pnpm-entrypoint.cjs') }, + timeout: 10_000, + }) + + expect(result.status).toBe(1) + expect(result.stdout).toContain('partial diagnostic evidence; the complete owning mode is pnpm run check:ci:lint') + expect(result.stderr).toContain('outcome: exit 1') + expect(result.stderr).toContain('replay: pnpm run check:ci:lint -- --only duplication') }) it('applies append and set operations through the child spawn environment', async () => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index bc0b3d9476..b5137b836b 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -796,7 +796,7 @@ export function formatGatePlanJson(plan: GatePlan): string { * @param gateId - gate to replay with its dependencies. * @returns a shell-independent pnpm command. */ -export function replayCommand(plan: GatePlan, gateId: string): string { +function replayCommand(plan: GatePlan, gateId: string): string { validateGatePlan(plan) if (!plan.gates.some(gate => gate.id === gateId)) { throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(gateId)}.`) @@ -810,7 +810,7 @@ export function replayCommand(plan: GatePlan, gateId: string): string { * @param gateId - selected diagnostic gate. * @returns the partial-evidence notice. */ -export function formatOnlyNotice(plan: GatePlan, gateId: string): string { +function formatOnlyNotice(plan: GatePlan, gateId: string): string { return `run-gates: --only ${gateId} is partial diagnostic evidence; the complete owning mode is pnpm run ${plan.script}.` } From 5b79a43d4cf4d9b219a2d35b8d0017ba594f0af1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:36:05 +0800 Subject: [PATCH 11/32] fix(dev-infra): serialize lint after invariant staging --- .../process/2026-07-27-replayable-gate-plans.i18n.yaml | 4 ++-- .../implemented/process/2026-07-27-replayable-gate-plans.md | 4 ++-- .../process/2026-07-27-replayable-gate-plans.zh.md | 2 +- scripts/run-gates.spec.ts | 1 + scripts/run-gates.ts | 5 ++++- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index f61cb168d2..dc28813ed1 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: fc4f883d74d765d173a6d1419c2e75f44bd1966f -2026-07-27-replayable-gate-plans.zh.md: d6c6eb9649120c7dfbfb6cd0c935e915c62f40ef +2026-07-27-replayable-gate-plans.md: a45b4e056a57b0d4c55b5f0a5d61c85e10cc0f92 +2026-07-27-replayable-gate-plans.zh.md: 0d356825c9727dd404d78249619c15ccf2dfbea2 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index fc4f883d74..2b912024ce 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -20,7 +20,7 @@ Every mode supports deterministic `--list` output and a versioned stable `--list The scheduler announces each start, buffers a child's stdout and stderr until that gate settles, and then emits one attributable result while unrelated gates continue. Failure blocks include the display command, redacted scheduler-owned environment operations, orthogonal exit and signal outcomes, complete child output, and the replay command; successful child output remains suppressed unless `DSH_GATE_VERBOSE=1`. Child output is not persisted. -The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. +The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`. Source compatibility smokes may overlap the validation stages; lint and duplication wait for built-package invariant validation so ESLint cannot traverse its transient staged package views, then may overlap downstream consumers. ## Verification @@ -42,6 +42,6 @@ The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level co The scheduler owns a small CLI and a versioned JSON schema that must evolve deliberately with the gate model. Focused replay is faster to diagnose but is not complete evidence, so the CLI labels it explicitly and always names the owning aggregate. -Later artifact consumers start only after publint and built-package invariant validation, reducing their overlap when either verifier is slow. Independent source checks still overlap both stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. +Later artifact consumers and lint start only after publint and built-package invariant validation, so ESLint cannot traverse the verifier's transient staged views and those downstream gates may overlap one another. Source compatibility smokes still overlap both validation stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. Buffered output is coherent and attributable, but no progress from a long-running child appears until that child settles, and the runner retains no second copy after the console is lost. Operators trade live interleaving and durable local output for a smaller scheduler whose diagnostic state is the inspected plan, settlement block, and replay command. diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index d6c6eb9649..0d356825c9 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -20,7 +20,7 @@ Status: implemented 调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 -`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。 +`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段。源码兼容性冒烟测试可以与验证阶段并行;lint 和 duplication 会等待已构建包不变式验证,以免 ESLint 遍历验证过程中临时暂存的包视图,之后可以与下游消费方并行。 ## 验证 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 898a637e6a..344f678374 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -364,6 +364,7 @@ describe('Node 24 consumer plan', () => { ]) expect(subject.gates.find(item => item.id === 'publint')?.needs).toBeUndefined() expect(subject.gates.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + expect(subject.gates.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { expect(subject.gates.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b5137b836b..a517a19127 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -414,7 +414,10 @@ function ciConsumerGates(): Gate[] { const publicArtifacts = ['publint'] const restoredBuild = ['built-package-invariants'] return [ - pnpmScript('lint-and-duplication', 'check:ci:lint', { label: 'lint and duplication' }), + pnpmScript('lint-and-duplication', 'check:ci:lint', { + label: 'lint and duplication', + needs: restoredBuild, + }), pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), snapshotGate(restoredBuild), pnpmScript('publint', 'publint'), From 02f9b55699423f11a5730f5a3ffa39f7f5f6f62e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:37:42 +0800 Subject: [PATCH 12/32] docs(dev-infra): synchronize gate plan consequences --- .../process/2026-07-27-replayable-gate-plans.i18n.yaml | 4 ++-- .../process/2026-07-27-replayable-gate-plans.zh.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index dc28813ed1..771b3270bd 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: a45b4e056a57b0d4c55b5f0a5d61c85e10cc0f92 -2026-07-27-replayable-gate-plans.zh.md: 0d356825c9727dd404d78249619c15ccf2dfbea2 +2026-07-27-replayable-gate-plans.md: 2b912024ce337063cf677532e85d64f4fd8ab4a7 +2026-07-27-replayable-gate-plans.zh.md: 26bf4632ea84292ed48ce0cf8405d06e5bfa4aa9 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index 0d356825c9..26bf4632ea 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -42,6 +42,6 @@ Status: implemented 调度器负责维护一个小型 CLI(命令行界面)以及一套带版本的 JSON schema,两者都必须随门禁模型有意演进。聚焦回放可以更快地诊断问题,但不构成完整证据,因此 CLI 会明确标记这一点,并始终给出所属的完整聚合任务。 -后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 +后续产物消费方和 lint 只在 publint 和已构建包不变式验证通过后才启动,因此 ESLint 不会遍历验证器临时暂存的视图,而这些下游门禁可以彼此并行。源码兼容性冒烟测试仍可与这两个验证阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 缓冲后的输出连贯且归属明确,但长时间运行的子进程结束前不会显示其进度,控制台内容丢失后运行器也不保留第二份副本。故障排查者接受不再实时交错输出、也不持久保留本地输出,以换取更小的调度器;其诊断状态只由检视后的计划、门禁结束时输出的块和回放命令组成。 From 1f79b780454e06b30b04cfac31b74c9887654f39 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 04:25:18 +0800 Subject: [PATCH 13/32] fix(web-ui): menu placement and scrolling, tool-row and settings polish Menus: keep 12px viewport clearance with internal scroll, pin workspace create actions in a footer, and pre-render portal lists hidden so the first painted frame is already at its final position (no open jump). Tool rows: 14px icons, secondary titles, no hover fill, and a hover chevron preview on in-place expandable rows. Settings: 800x600 layer-2 panel over a blurred mask, hover states, and wrapping selector cubes; ModelSelect surface tokens now match the Menu primitive. --- .../locale/src/client/LanguageRow.module.css | 4 + .../src/client/chat/AssistantMarkdown.tsx | 2 +- .../src/client/chat/GenericToolCard.tsx | 18 +- .../src/client/chat/ToolRow.module.css | 25 ++- .../src/client/chat/ToolRow.tsx | 21 +- .../src/client/contract/slots.ts | 2 + .../skeleton/ConversationRoot.module.css | 13 +- .../src/client/skeleton/ConversationRoot.tsx | 5 +- .../src/client/skeleton/HeroShell.module.css | 11 +- .../client/toolviews/bash-sample.module.css | 6 +- .../src/client/toolviews/bash-sample.tsx | 2 +- .../src/client/toolviews/todo-row.module.css | 6 +- .../src/client/ModelSelect.module.css | 19 +- .../ui-primitives/src/Button.module.css | 10 +- .../client/ui-primitives/src/Menu.module.css | 33 +++- packages/client/ui-primitives/src/Menu.tsx | 182 +++++++++++------- .../client/ui-primitives/src/Modal.module.css | 2 +- packages/client/ui-primitives/src/Tooltip.tsx | 2 +- .../client/ui-primitives/tests/atoms.spec.tsx | 43 ++++- .../ui-primitives/tests/tooltip.spec.tsx | 9 +- .../src/client/GeneralSection.module.css | 20 +- .../src/client/SettingsRoot.module.css | 14 +- .../src/client/AppearanceRow.module.css | 10 +- .../ui-theme/src/styles/design-platform.css | 9 +- .../src/client/WorkspaceBrowser.module.css | 1 + .../src/client/WorkspaceBrowser.tsx | 9 +- .../src/client/WorkspacePicker.tsx | 42 +++- .../tests/workspace-browser.spec.tsx | 20 +- .../tests/workspace-picker.spec.tsx | 2 + 29 files changed, 367 insertions(+), 175 deletions(-) diff --git a/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css index f17a67d279..57d975d9b7 100644 --- a/packages/client/locale/src/client/LanguageRow.module.css +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -42,6 +42,10 @@ cursor: pointer; } +.selector:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + .chevron { flex: none; } diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 0e91afcc07..fc487843df 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { return ( } + icon={} title="Think" summary={firstLine(text)} body={text} diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 5cb2126f34..24b3b36a22 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -13,16 +13,16 @@ import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.t import { ToolRow } from './ToolRow.tsx' import { IconSparkle16 } from './IconSparkle16.tsx' -/** Variant leading icons (figma table). */ +/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */ const VARIANT_ICONS: Record = { - think: , - search: , - read: , - bash: , - write: , - edit: , - code: , - others: , + think: , + search: , + read: , + bash: , + write: , + edit: , + code: , + others: , } export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 5d44a9260e..6f99bb2696 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -13,15 +13,12 @@ min-width: 0; } +/* Clickable rows keep only the cursor affordance — no hover fill. */ .row[data-clickable] { cursor: pointer; border-radius: 6px; } -.row[data-clickable]:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - .leading { flex: none; width: 16px; @@ -65,11 +62,29 @@ button.leading { color: var(--dsw-alias-label-secondary); } +/* Hover preview on expandable rows: the idle tool icon yields to a down + chevron before the row is opened. */ +.iconIdle { + display: inline-flex; +} + +.chevronHover { + display: none; +} + +.row:hover .iconIdle { + display: none; +} + +.row:hover .chevronHover { + display: inline-flex; +} + .title { flex: none; font-size: 14px; line-height: 24px; - color: var(--dsw-alias-label-primary-dimmed); + color: var(--dsw-alias-label-secondary); } .sep { diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index a81c084b8e..a406d05cc3 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -1,8 +1,10 @@ // ToolRow: the single-line tool summary row (figma component set 122:9479) — -// 16px leading slot (state dot / tool icon, chevron when expanded) + title + +// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title + // separator dot + FILL-truncated summary. Expanded body is indented gray text; // no inline output (full results live in the details panel). Expand state is // component-local view state; row click hands the selection off to the owner. +// TODO(ux): converge every chat-tab tool row on in-place expansion for its +// expandable content, retiring the details-panel handoff where feasible. import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' @@ -66,6 +68,19 @@ export function ToolRow({ event.preventDefault() toggleExpand() } + // Expandable rows preview the toggle on hover: the tool icon yields to a + // down chevron (CSS swap on .row:hover); state dots still take precedence. + const collapsedIcon = expandable + ? ( + <> + {icon} + + + ) + : icon + const leading = open + ? + : leadingFor(state, collapsedIcon) return (
- {open ? : leadingFor(state, icon)} + {leading} ) : ( - {open ? : leadingFor(state, icon)} + {leading} )} {title} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 44f337d61c..c7c53aaafe 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -300,6 +300,8 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & export interface EmptyWorkspaceOwnerProps { open: boolean anchorRef?: RefObject + /** Currently active workspace (renders a trailing check in the picker list). */ + selectedId?: WorkspaceId | undefined onPick: (workspaceId: WorkspaceId) => void onClose: () => void } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index be68ea1394..0bfd772f60 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -87,7 +87,7 @@ padding-left: 8px; } -/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */ +/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */ .tab { position: relative; padding: 0 0 11px; @@ -95,7 +95,7 @@ background: transparent; font-size: 13px; line-height: 16px; - font-weight: 510; + font-weight: 500; color: var(--dsw-alias-label-tertiary); cursor: pointer; } @@ -140,10 +140,19 @@ block for position:fixed descendants (pickers/modals), shrinking them. */ .composerHero { align-self: center; + /* figma 75:8208: 12 between hero chrome / workspace row / card. */ + gap: 12px; width: min(776px, calc(100% - 48px)); z-index: 1; } +.heroWorkspaceRow { + display: flex; + align-items: center; + min-width: 0; + padding-left: 8px; +} + .root[data-phase='hero'] { justify-content: center; } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index d6e7492836..2c97e5ce0e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -48,7 +48,7 @@ export function ConversationRoot({ session === undefined || inputState === undefined ? undefined : { session, input: inputState } const heroWorkspaceRow = ( - <> +
{ setPickerOpen(false) setPendingWorkspaceId(workspaceId) @@ -72,7 +73,7 @@ export function ConversationRoot({ }, onClose: () => { setPickerOpen(false) }, })} - +
) const inputBar = sessionId === undefined diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 3bba50c67c..6bc6af5fea 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -8,8 +8,7 @@ justify-content: center; height: 100%; min-width: 0; - padding: 24px; - margin-bottom: -70px; + padding: 0 24px; } /* Cap matches InputBar card width (800). Glow may paint past the sides. */ @@ -24,17 +23,15 @@ overflow: visible; } -/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block - keeps 36px below the headline before the flex gap. */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */ .headline { display: flex; align-items: center; justify-content: center; gap: 10px; - padding-bottom: 36px; font-size: 26px; line-height: 32px; - font-weight: 600; + font-weight: 500; color: var(--dsw-alias-label-primary); } @@ -88,7 +85,7 @@ display: inline-flex; align-items: center; gap: 4px; - max-width: fit-content; + max-width: min(100%, 360px); min-height: 28px; padding: 0 8px; border: none; diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 9c42e69b59..84224c9979 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -9,10 +9,6 @@ border-radius: 6px; } -.root:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - .leading { flex: none; width: 16px; @@ -39,7 +35,7 @@ flex: none; font-size: 14px; line-height: 24px; - color: var(--dsw-alias-label-primary-dimmed); + color: var(--dsw-alias-label-secondary); } .sep { diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 616eee5943..dc4dd6b367 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -15,7 +15,7 @@ function leadingFor(state: ToolRowState) { case 'running': return case 'error': return case 'stopped': return - default: return + default: return } } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css index ff4068d49c..829c2886e8 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -11,10 +11,6 @@ font-size: 13px; } -.row:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - .badge { flex: none; color: var(--dsw-alias-state-business-primary); @@ -22,7 +18,7 @@ .title { flex: none; - font-weight: 510; + font-weight: 500; /* figma wt510, rendered 500 */ color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css index cdf7f4be2e..d17a777edd 100644 --- a/packages/client/ui-model/src/client/ModelSelect.module.css +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -72,9 +72,11 @@ max-height: min(360px, calc(100vh - 96px)); overflow: hidden; padding: 4px; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + /* Surface tokens match the Menu primitive card (ui-primitives + * Menu.module.css) so every dropdown reads as the same material. */ + border: 1px solid var(--dsw-alias-border-inverted); border-radius: 12px; - background: var(--dsw-specific-input-major); + background: var(--dsw-specific-menu); box-shadow: var(--dsw-shadow-lv3); color: var(--dsw-alias-label-primary); } @@ -132,7 +134,7 @@ top: 0; z-index: 1; padding: 5px 8px 3px; - background: var(--dsw-specific-input-major); + background: var(--dsw-specific-menu); color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; @@ -156,11 +158,16 @@ } .option:hover:not(:disabled), -.option:focus-visible, -.selected { +.option:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } +/* Selection marker is the trailing check, not a fill — matches the Menu + * primitive's selected treatment. */ +.selected { + background: transparent; +} + .option:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; @@ -201,7 +208,7 @@ display: grid; place-items: center; flex: 0 0 18px; - color: var(--dsw-alias-state-business-primary); + color: var(--dsw-alias-label-primary); } /* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index 3f415b5d15..b9384dd522 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -18,7 +18,7 @@ .button:disabled { cursor: not-allowed; - color: var(--dsw-alias-label-dimmed); + opacity: 0.4; } .md { @@ -44,10 +44,6 @@ background: var(--dsw-alias-button-primary-hover); } -.primary:disabled { - background: var(--dsw-alias-button-primary-dimmed); -} - .ghost:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } @@ -66,10 +62,6 @@ background: var(--dsw-alias-interactive-bg-hover); } -.outline:disabled { - border-color: var(--dsw-alias-border-l1); -} - .toolbar { background: var(--dsw-alias-button-tool-bar-fill); } diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 6825731275..8e16b252d3 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -26,6 +26,7 @@ left: 0; z-index: 100; min-width: 218px; + max-width: 360px; } /* Portal mode: fixed in the viewport, coordinates supplied inline from the @@ -50,6 +51,36 @@ right: 0; } +/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges + * (24 = 2 × the portal MARGIN in Menu.tsx) and taller content scrolls inside + * .viewport, so a pinned .footer stays visible. Menus with submenu rows skip + * this class — the overflow clip would crop the side card, so they rely on + * staying short. */ +.scrollable { + max-height: calc(100vh - 24px); +} + +.viewport { + display: flex; + flex-direction: column; + min-height: 0; +} + +.scrollable .viewport { + overflow-y: auto; +} + +/* Pinned rows below the scroll region; l2 hairline (l1 is near-invisible on + * the menu surface) mirrors the .separator spacing. */ +.footer { + flex: none; + display: flex; + flex-direction: column; + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--dsw-alias-border-l2); +} + .itemWrap { position: relative; } @@ -78,7 +109,7 @@ } .item:disabled { - color: var(--dsw-alias-label-dimmed); + opacity: 0.4; cursor: not-allowed; } diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 6ed7f6c0c2..1a6409f42b 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -5,6 +5,8 @@ // The owner controls `open`; outside-click closing uses one document listener // active only while open. Submenus open on hover/focus inside the same root. // Entries also cover non-interactive `label` headings and `danger` rows. +// Lists keep 12px clearance to the viewport's top/bottom edges and scroll +// internally past that; submenu-bearing menus are exempt (see .scrollable). import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' @@ -50,6 +52,9 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { return 'type' in entry && entry.type === 'label' } +/** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */ +const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } + /** * Render an anchored dropdown menu. * @param props.open - whether the list is showing (owner-controlled). @@ -72,17 +77,20 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { * the trigger (render-prop anchors, effect-positioned proxies — measuring the * wrapper there races the host's layout effects). Called on open and on every * scroll/resize; return null to skip placement for that frame. + * @param props.footer - rows pinned below the scrolling items area, separated + * by a hairline; they stay visible while the items above scroll. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] - selectedId?: string + footer?: readonly MenuEntry[] + selectedId?: string | undefined onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' - side?: 'bottom' | 'top' + side?: 'bottom' | 'top' | 'right' portal?: boolean closeOnPointerLeave?: boolean getAnchorRect?: () => DOMRect | null @@ -109,11 +117,34 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align r = rootRef.current?.getBoundingClientRect() ?? null } if (r === null) return - setFixedPos({ - ...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }), - ...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }), - }) + const MARGIN = 12 + const vw = window.innerWidth + const vh = window.innerHeight + const listEl = listRef.current + const lw = listEl?.offsetWidth ?? 0 + const lh = listEl?.offsetHeight ?? 0 + + let x: number + let y: number + if (side === 'right') { + x = r.right + 4 + y = r.top + } else if (align === 'start') { + x = r.left + y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + } else { + x = r.right - lw + y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + } + + if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) + if (lh > 0) y = Math.min(Math.max(y, MARGIN), vh - lh - MARGIN) + + setFixedPos({ left: x, top: y }) } + // First run measures the hidden pre-render (same commit as `open`), so + // end/top alignment and clamping use real dimensions before anything + // paints — no visible jump from a zero-size first guess. place() window.addEventListener('scroll', place, true) window.addEventListener('resize', place) @@ -146,11 +177,77 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } }, [open, onClose]) - const list = open && (!portal || fixedPos !== null) && ( + // The submenu card is absolutely positioned outside the list box; the + // scroll clip would crop it, so only submenu-free menus get the height cap. + const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0) + + const renderEntry = (entry: MenuEntry) => { + if (isSeparator(entry)) { + return
+ } + if (isLabel(entry)) { + return
{entry.text}
+ } + const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 + const subOpen = hasSub && openSubmenuId === entry.id + return ( +
{ setOpenSubmenuId(hasSub ? entry.id : null) }} + onMouseLeave={() => { setOpenSubmenuId(null) }} + > + + {subOpen && entry.submenu !== undefined && ( +
+ {entry.submenu.map(sub => ( + + ))} +
+ )} +
+ ) + } + + // Portal lists render hidden until placed: the placement effect measures + // this pre-render in the same commit, so the first painted frame is + // already at the final position (with getAnchorRect returning null the + // list simply stays hidden). + const list = open && (
{ onClose() } : undefined} // React portals bubble synthetic events through the REACT tree: without @@ -158,63 +255,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align // (open/toggle) after onSelect. onClick={(e) => { e.stopPropagation() }} > - {items.map((entry) => { - if (isSeparator(entry)) { - return
- } - if (isLabel(entry)) { - return
{entry.text}
- } - const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 - const subOpen = hasSub && openSubmenuId === entry.id - return ( -
{ setOpenSubmenuId(hasSub ? entry.id : null) }} - onMouseLeave={() => { setOpenSubmenuId(null) }} - > - - {subOpen && entry.submenu !== undefined && ( -
- {entry.submenu.map(sub => ( - - ))} -
- )} -
- ) - })} +
+ {items.map(renderEntry)} +
+ {footer !== undefined && footer.length > 0 && ( +
+ {footer.map(renderEntry)} +
+ )}
) diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 02c075803e..0c6d68e5fa 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -54,7 +54,7 @@ margin: 0; font-size: 16px; line-height: 24px; - font-weight: 510; + font-weight: 500; /* figma wt510, rendered 500 */ color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 2c48854055..30e0c5fe0e 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -72,7 +72,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { {cloneElement(children, { ref: mergedRef, onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() }, - onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) }, onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 9e298d057a..7724afa493 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -271,14 +271,47 @@ describe('Menu', () => { expect(onClose).toHaveBeenCalledTimes(1) }) - it('portal mode positions from the opposite edges for align=end / side=top', () => { + it('portal mode resolves align=end / side=top to clamped left/top coordinates', () => { render( trigger} items={items} onSelect={() => {}} onClose={() => {}} />) const menu = screen.getByRole('menu') - expect(menu.style.right).not.toBe('') - expect(menu.style.bottom).not.toBe('') - expect(menu.style.left).toBe('') - expect(menu.style.top).toBe('') + expect(menu.style.left).not.toBe('') + expect(menu.style.top).not.toBe('') + expect(menu.style.right).toBe('') + expect(menu.style.bottom).toBe('') + }) + + it('renders footer rows in a pinned section below the items; they still select', () => { + const onSelect = vi.fn() + render( + trigger} + items={items} + footer={[{ id: 'new', label: 'Create new' }]} + onSelect={onSelect} + onClose={() => {}} + />) + const footerItem = screen.getByRole('menuitem', { name: 'Create new' }) + expect((footerItem.closest('div[class*="footer"]'))).not.toBeNull() + expect(screen.getByRole('menuitem', { name: 'Alpha' }).closest('div[class*="footer"]')).toBeNull() + fireEvent.click(footerItem) + expect(onSelect).toHaveBeenCalledWith('new') + }) + + it('caps the list height for internal scrolling unless a submenu row is present', () => { + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={() => {}} />) + expect(screen.getByRole('menu').className).toMatch(/scrollable/) + rerender( + trigger} + items={[{ id: 'p', label: 'Parent', submenu: [{ id: 's', label: 'Sub' }] }]} + onSelect={() => {}} + onClose={() => {}} + />) + expect(screen.getByRole('menu').className).not.toMatch(/scrollable/) }) }) diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 6741921c66..591a5eb67a 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -81,23 +81,20 @@ describe('Tooltip', () => { expect(screen.getByRole('tooltip')).toBeTruthy() }) - it('keeps the bubble while either hover or focus is still active', () => { + it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => { render( , ) const anchor = screen.getByText('anchor') - // Focused AND hovered: leaving with the mouse must not drop the bubble. + // Focused AND hovered: leaving with the mouse drops the bubble at once. fireEvent.focus(anchor) fireEvent.mouseEnter(anchor) fireEvent.mouseLeave(anchor) - expect(screen.getByRole('tooltip')).toBeTruthy() - fireEvent.blur(anchor) expect(screen.queryByRole('tooltip')).toBeNull() - // Symmetric: blurring while still hovered keeps it, mouseleave ends it. + // Re-entering shows it again; blurring while still hovered keeps it. fireEvent.mouseEnter(anchor) - fireEvent.focus(anchor) fireEvent.blur(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() fireEvent.mouseLeave(anchor) diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index 5e053a49c5..aced3b2962 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -72,6 +72,10 @@ cursor: pointer; } +.selector:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + .selector:disabled { cursor: default; } @@ -80,18 +84,21 @@ flex: none; } -/* Tool Call mode cubes share an 8px gap. */ +/* Tool Call mode cubes share an 8px gap and wrap to one per row when the + panel is too narrow. */ .cubeRow { display: flex; align-items: stretch; gap: 8px; + flex-wrap: wrap; } -/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset = - * outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */ +/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the + * 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10, + * vertical = inner pad 8). */ .modeCube { box-sizing: border-box; - width: 418px; + flex: 1 1 276px; display: flex; flex-direction: column; justify-content: center; @@ -101,6 +108,11 @@ border-radius: 16px; background: transparent; text-align: left; + cursor: pointer; +} + +.modeCube:hover:not(.selected) { + background: var(--dsw-alias-interactive-bg-hover); } /* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index e2b2c878df..5f7ca9ec23 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -44,7 +44,8 @@ white-space: nowrap; } -/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */ +/* Full-viewport layer (figma Mask 501:29946 #000@24%): mask tokens match the + Modal primitive (--dsw-alias-bg-mask-1 + --dsw-mask-blur). */ .overlay { position: fixed; inset: 0; @@ -58,21 +59,22 @@ position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); } -/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow - (figma effects match --dsw-shadow-lv3 exactly). */ +/* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects + match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */ .panel { position: relative; z-index: 1; display: flex; - width: 1080px; - height: 700px; + width: 800px; + height: 600px; max-width: calc(100vw - 48px); max-height: calc(100vh - 48px); border-radius: 24px; overflow: hidden; - background: var(--dsw-alias-bg-layer-1); + background: var(--dsw-alias-bg-layer-2); box-shadow: var(--dsw-shadow-lv3); } diff --git a/packages/client/ui-theme/src/client/AppearanceRow.module.css b/packages/client/ui-theme/src/client/AppearanceRow.module.css index 9619caa823..6c5354b0ac 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.module.css +++ b/packages/client/ui-theme/src/client/AppearanceRow.module.css @@ -20,13 +20,15 @@ display: flex; align-items: stretch; gap: 8px; + flex-wrap: wrap; } /* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered - * icon-over-label column, gap 4). */ + * icon-over-label column, gap 4); flexed down from the figma width so all + * three sit on one row in the 800 panel, wrapping when narrower. */ .themeCube { box-sizing: border-box; - width: 276px; + flex: 1 1 180px; display: flex; flex-direction: column; align-items: center; @@ -43,6 +45,10 @@ cursor: pointer; } +.themeCube:hover:not(.selected) { + background: var(--dsw-alias-interactive-bg-hover); +} + /* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 * step has no alias-layer name). */ .selected { diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index e00ec415b7..3e8710822e 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -1,3 +1,6 @@ +/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as + font-weight: 500 in this UI — non-variable webfonts snap intermediate + weights unpredictably across platforms. */ body { --dsw-static-amber-100: rgb(254, 245, 231); --dsw-static-amber-400: rgb(247, 173, 49); @@ -20,7 +23,7 @@ body { --dsw-static-deepseek-300: rgb(183, 200, 254); --dsw-static-deepseek-400: rgb(103, 158, 254); --dsw-static-deepseek-450: rgb(86, 134, 254); - --dsw-static-deepseek-500: rgb(57, 100, 254); + --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); --dsw-static-deepseek-600: rgb(72, 104, 178); --dsw-static-deepseek-700-delete: rgb(47, 76, 143); @@ -95,7 +98,7 @@ body[data-ds-dark-theme] { --dsw-static-deepseek-300: rgb(183, 200, 254); --dsw-static-deepseek-400: rgb(103, 158, 254); --dsw-static-deepseek-450: rgb(86, 134, 254); - --dsw-static-deepseek-500: rgb(57, 100, 254); + --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); --dsw-static-deepseek-600: rgb(72, 104, 178); --dsw-static-deepseek-700-delete: rgb(47, 76, 143); @@ -302,7 +305,7 @@ body[data-ds-dark-theme] { --dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600); --dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600); --dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550); - --dsw-alias-state-business-primary: var(--dsw-static-deepseek-500); + --dsw-alias-state-business-primary: var(--dsw-static-deepseek-400); --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800); --dsw-alias-state-error-primary: var(--dsw-static-red-400); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index c03d511c92..7dcb4f9b9d 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -129,6 +129,7 @@ reads the shell's class names): the two icon controls stack as 36x36 circles matching the shell's rail rhythm. */ .rail .sectionHeader { + gap: 0; padding-left: 0; margin-bottom: 12px; } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index ea1753a63e..dfca686a6e 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -265,6 +265,7 @@ export function WorkspaceBrowser({ // states; the menu anchors on this button). const [wsPickerOpen, setWsPickerOpen] = useState(false) const wsPlusRef = useRef(null) + const composingRef = useRef(false) // Rail search = expand + land in the search box: the flag arms before the // expand request; once the shell flips wide the input mounts and takes focus. @@ -358,7 +359,6 @@ export function WorkspaceBrowser({ className={css.iconButton} aria-label="Create workspace" onClick={() => { - if (!wide) expandSidebar() setWsPickerOpen(v => !v) }} > @@ -372,6 +372,8 @@ export function WorkspaceBrowser({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + createOnly + side="right" onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) @@ -459,9 +461,12 @@ export function WorkspaceBrowser({ aria-label="Workspace name" autoFocus disabled={renaming} + onFocus={(e) => { e.target.select() }} onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} onKeyDown={(e) => { - if (e.key === 'Enter') { + if (e.key === 'Enter' && !composingRef.current) { e.preventDefault() confirmRename() } diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 4ec84c7d3d..f33e7dc526 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -5,7 +5,7 @@ * slot registration. */ import type { RefObject } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -37,6 +37,12 @@ export interface WorkspaceCreateFlowProps { onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ onClose: () => void + /** Only show create actions (open folder / create new), hide existing workspaces. */ + createOnly?: boolean + /** Menu opening direction relative to the anchor. */ + side?: 'bottom' | 'top' | 'right' + /** Currently active workspace (trailing check in the picker list). */ + selectedId?: WorkspaceId | undefined } /** @@ -52,6 +58,9 @@ export function WorkspaceCreateFlow({ pickDirectory, onPick, onClose, + createOnly = false, + side = 'bottom', + selectedId, }: WorkspaceCreateFlowProps) { const workspaceSnapshot = useWorkspaces(state => state) const workspaces = workspaceSnapshot.items @@ -65,21 +74,26 @@ export function WorkspaceCreateFlow({ const [modalError, setModalError] = useState(null) const [pickingFolder, setPickingFolder] = useState(false) const [folderConflict, setFolderConflict] = useState(false) + const composingRef = useRef(false) const normalizedWorkspaceName = workspaceName.trim() const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) - const items: MenuEntry[] = [ - ...workspaces.map(workspace => ({ + const createEntries: MenuEntry[] = [ + { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, + ] + // With workspaces listed, the create actions pin below the scroll region + // (divider + always visible); otherwise they ARE the menu. + const pinCreate = !createOnly && workspaces.length > 0 + const items: MenuEntry[] = pinCreate + ? workspaces.map(workspace => ({ id: workspace.workspaceId, label: workspace.title, icon: , disabled: pickingFolder, - })), - ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, - { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, - ] + })) + : createEntries const closeModal = (): void => { if (creating) return @@ -114,7 +128,7 @@ export function WorkspaceCreateFlow({ } if (id === CREATE_NEW) { onClose() - setWorkspaceName('workspace') + setWorkspaceName('') setModalError(null) setModalKind('create') return @@ -149,8 +163,11 @@ export function WorkspaceCreateFlow({ open={open} anchor={null} items={items} + {...pinCreate ? { footer: createEntries } : {}} + selectedId={selectedId} onSelect={handleSelect} onClose={onClose} + side={side} portal getAnchorRect={getAnchorRect} /> @@ -194,12 +211,15 @@ export function WorkspaceCreateFlow({ { setWorkspaceName(event.target.value); setModalError(null) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} onKeyDown={(event) => { - if (event.key === 'Enter') { + if (event.key === 'Enter' && !composingRef.current) { event.preventDefault() confirmCreate() } @@ -225,6 +245,7 @@ export function WorkspacePicker({ open, anchorRef, useWorkspaces, + selectedId, onPick, onClose, createWorkspace, @@ -237,6 +258,7 @@ export function WorkspacePicker({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + selectedId={selectedId} onPick={onPick} onClose={onClose} /> diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7b8462f6db..e8405c6bcd 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -263,25 +263,21 @@ describe('WorkspaceBrowser', () => { } }) - it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => { + it('rail create-workspace toggles the create-only picker in place, without expanding', () => { const expandSidebar = vi.fn() - const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) + mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - expect(expandSidebar).toHaveBeenCalledTimes(1) - rerender(b, { wide: true }) - // The picker menu is open (anchored on the +); picking starts a session. - fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' })) - expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha')) - expect(screen.queryByRole('menu')).toBeNull() - // Wide toggle: open and close without expand requests. - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - expect(screen.getByRole('menu')).toBeTruthy() + expect(expandSidebar).not.toHaveBeenCalled() + // createOnly: existing workspaces are not listed, only the create actions. + expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull() + expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() + // Toggle: open and close in place. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) expect(screen.queryByRole('menu')).toBeNull() - expect(expandSidebar).toHaveBeenCalledTimes(1) // Escape closes the picker through its own onClose. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.getByRole('menu')).toBeTruthy() fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 6cad175ff3..081ff0c044 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -205,6 +205,8 @@ describe('WorkspacePicker', () => { it('reports non-Error creation failures', async () => { const b = mount([], vi.fn(async () => { throw 'permission denied' })) chooseItem('Create a new workspace') + // The name field starts empty (no prefill); a name is required to submit. + fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') From ae76ed28740b6493bc6431b349aeecbcf36cd290 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 04:56:14 +0800 Subject: [PATCH 14/32] fix(web-ui): workspace chip placeholder, logo new-session shortcut, hero foot padding - The hero workspace chip is a selector: no-live-selection states (cold start, workspace deleted from the sidebar after the list is ready) now render a "Choose workspace" placeholder (closed-folder icon) instead of resurrecting the deleted folder name via the session cwd; the cwd-derived name still bridges the initial list load. Stale pending picks clear when their workspace leaves a ready list. - The expanded sidebar wordmark starts a new session (visuals unchanged, pointer cursor only); the collapsed rail logo keeps its expand toggle. - The centered hero composer stack gains a 32px foot for visual balance. --- .../skeleton/ConversationRoot.module.css | 2 ++ .../src/client/skeleton/ConversationRoot.tsx | 33 ++++++++++++++----- .../src/client/skeleton/EmptyHero.tsx | 24 +++++++------- .../src/client/SidebarRoot.module.css | 8 ++++- .../ui-sidebar/src/client/SidebarRoot.tsx | 11 +++++-- .../ui-sidebar/tests/sidebar-root.spec.tsx | 9 +++-- 6 files changed, 61 insertions(+), 26 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 0bfd772f60..11f3502325 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -142,6 +142,8 @@ align-self: center; /* figma 75:8208: 12 between hero chrome / workspace row / card. */ gap: 12px; + /* Foot inside the centered box floats the stack a bit above true center. */ + padding-bottom: 32px; width: min(776px, calc(100% - 48px)); z-index: 1; } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 2c97e5ce0e..382a42cb40 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -36,27 +36,42 @@ export function ConversationRoot({ workspace => workspace.workspaceId === pendingWorkspaceId, ) + // Clear the pending pick once the session lands in it, or when the picked + // workspace disappears from a ready list (deleted from the sidebar). useEffect(() => { - if (pendingWorkspaceId !== undefined - && sessionWorkspace?.workspaceId === pendingWorkspaceId) { + if (pendingWorkspaceId === undefined) return + if (sessionWorkspace?.workspaceId === pendingWorkspaceId + || (workspaces.phase === 'ready' && pendingWorkspace === undefined)) { setPendingWorkspaceId(undefined) } - }, [pendingWorkspaceId, sessionWorkspace?.workspaceId]) + }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace]) const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } + // Flow optimization — worth a close PR review for code/boundary issues. + // The chip is a selector; label resolution walks the flow top-down: + // 1. a just-picked workspace (pending) → its title; + // 2. cold start, no session yet → placeholder ("Choose workspace"); + // 3. the blank session's workspace is in the list → its title; + // 4. list still loading → cwd folder name bridges so the title does not + // flash on refresh (empty cwd → placeholder); + // 5. list ready but no owning workspace (deleted from the sidebar) → + // placeholder, never the deleted folder's name via cwd. + const chipTitle = pendingWorkspace?.title + ?? (sessionId === undefined + ? undefined + : sessionWorkspace?.title + ?? (workspaces.phase === 'ready' || cwd === undefined || cwd === '' + ? undefined + : workspaceLabel(cwd))) + const heroWorkspaceRow = (
{ setPickerOpen(open => !open) }} /> diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index c6122ecbcc..ca64fd7f44 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -7,20 +7,18 @@ import { useId } from 'react' import type { ReactNode, RefObject } from 'react' import { - FishLogo, IconChevronDownOutline14, IconFolderOpen16, + FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, } from '@deepseek-ai/dsh-client-ui-primitives' import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client' import css from './HeroShell.module.css' /** - * Basename label for the workspace chip / menu rows (the shared derivation); - * empty → the design's "New Workspace" placeholder copy; separator-only - * paths echo the raw cwd. - * @param cwd - workspace directory path ('' for none). + * Basename label for the workspace chip (the shared derivation); + * separator-only paths echo the raw cwd. + * @param cwd - workspace directory path (non-empty). * @returns chip label. */ export function workspaceLabel(cwd: string): string { - if (cwd === '') return 'New Workspace' const base = workspaceTitleOf(cwd) return base !== '' ? base : cwd } @@ -28,15 +26,17 @@ export function workspaceLabel(cwd: string): string { /** * The workspace chip (folder + label + chevron), always interactive: before * the first message the workspace stays switchable — picking another one - * moves the New Session flow to that workspace's blank session. - * @param props.label - chip label (see {@link workspaceLabel}). + * moves the New Session flow to that workspace's blank session. Without a + * label the chip renders its placeholder state: closed folder + the + * "Choose workspace" call to action. + * @param props.label - chip label (see {@link workspaceLabel}); omitted → placeholder. * @param props.menuOpen - menu expansion echo. * @param props.onClick - menu toggle. * @returns the chip button element. */ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { buttonRef?: RefObject - label: string + label?: string | undefined menuOpen?: boolean onClick?: () => void }) { @@ -50,8 +50,10 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { aria-expanded={menuOpen} onClick={onClick} > - - {label} + {label === undefined + ? + : } + {label ?? 'Choose workspace'} ) diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index fe73435df8..ebb47467af 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -79,13 +79,19 @@ /* Brand group (figma I133:7632): the full wordmark rides the text ink (figma-flows ruling: main-screen instance is black; blue is brand - emphasis only). */ + emphasis only). A button only in behavior (New Session shortcut): the + pointer cursor is the sole affordance — no hover chrome on the mark. */ .brand { flex: 1; min-width: 0; display: inline-flex; align-items: center; overflow: hidden; + padding: 0; + border: none; + background: transparent; + color: inherit; + cursor: pointer; } .iconButton { diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index f5f810cbbe..30d705c780 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -61,10 +61,17 @@ export function SidebarRoot({ style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined} >
+ {/* Expanded, the wordmark doubles as a New Session shortcut; the + collapsed rail's logo is the expand toggle below instead. */} {wide && ( - + )} {/* Rail resting state is the whale mark; hovering swaps in the panel icon (the expand affordance, figma sidebar-hover flow). */} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 458ed0bda4..3c8086e4ce 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -54,10 +54,13 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w } describe('SidebarRoot shell', () => { - it('routes New Session and the column toggle', () => { + it('routes New Session (capsule + wordmark) and the column toggle', () => { const b = mountShell() - fireEvent.click(screen.getByRole('button', { name: 'New session' })) - expect(b.startSession).toHaveBeenCalledWith() + // Expanded, both the wordmark and the capsule start a session. + const starters = screen.getAllByRole('button', { name: 'New session' }) + expect(starters).toHaveLength(2) + for (const button of starters) fireEvent.click(button) + expect(b.startSession).toHaveBeenCalledTimes(2) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) expect(b.toggleSidebar).toHaveBeenCalledOnce() }) From 484e0f70cc63592e0829490fc96be0c4682d5bc9 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 07:59:08 +0800 Subject: [PATCH 15/32] fix(web-ui): pixel loading language, tool-row sweep, composer polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StateDot ongoing: gradient spin ring replaced by an 8-cell pixel chase (2px matrix cells, stepped trail, no tweening) - Chat: streaming pulse block replaced by a turn-level 4-pixel chase at the flow tail — rides the whole running turn (first-token wait, tools, streaming) instead of flickering with partial presence - Tool rows (ToolRow/BashRow): running no longer swaps the icon for a dot; an animated mask band sweeps the row content, gliding off on exit via mask-position transition - Composer: send/stop unified on the blue fill (bigger stop glyph, static white arrow), textarea box-sizing overflow fix, settling phase hides the composer while replay decides hero vs docked, workspace-placeholder fallback disables the bar - Hero: glow moved behind (z-index) with lower opacity; tool-row hover icon crossfade at 100ms --- .../client/chat/AssistantMarkdown.module.css | 12 ---- .../src/client/chat/AssistantMarkdown.tsx | 7 +-- .../src/client/chat/ChatView.module.css | 25 ++++++++ .../src/client/chat/ChatView.tsx | 36 +++++++++++ .../src/client/chat/ToolRow.module.css | 41 ++++++++++-- .../src/client/chat/ToolRow.tsx | 6 +- .../skeleton/ConversationRoot.module.css | 23 +++++++ .../src/client/skeleton/ConversationRoot.tsx | 16 +++-- .../src/client/skeleton/DisabledInputBar.tsx | 2 +- .../src/client/skeleton/EmptyHero.tsx | 62 +++++++++++-------- .../src/client/skeleton/HeroShell.module.css | 19 ++---- .../src/client/skeleton/InputBar.module.css | 19 +++--- .../src/client/skeleton/InputBar.tsx | 6 +- .../client/toolviews/bash-sample.module.css | 16 +++++ .../src/client/toolviews/bash-sample.tsx | 2 +- .../tests/chat-code-subcalls.spec.tsx | 2 +- .../tests/chat-tool-row.spec.tsx | 6 +- .../tests/coverage-tails.spec.tsx | 2 +- .../ui-primitives/src/StateDot.module.css | 30 ++++----- .../client/ui-primitives/src/StateDot.tsx | 34 ++++++---- .../ui-primitives/tests/state-dot.spec.tsx | 15 ++--- 21 files changed, 257 insertions(+), 124 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index b4570af921..9177d721c3 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -9,18 +9,6 @@ color: var(--dsw-alias-label-primary); } -.pulse { - display: inline-block; - width: 8px; - height: 14px; - background: var(--dsw-alias-state-business-primary); - animation: pulse 1s infinite ease-in-out; -} - -@keyframes pulse { - 50% { opacity: 0.2; } -} - /* Interrupted-turn terminal marker: quiet inline tag, no animation. */ .stopped { align-self: flex-start; diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index fc487843df..71b7a8a142 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -2,8 +2,8 @@ // reasoning as the figma Think summary row (expand = indented gray text), // other-block JSON fallback. Tool-call heads are NOT rendered here: the chat // view groups them into tool rows through its keyed toolview slot (figma -// step-summary flow). Shared by finalized nodes and the streaming partial -// (pulse marker). +// step-summary flow). Shared by finalized nodes and the streaming partial; +// the turn-level loading dots live in the chat view's tail, not here. import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -14,7 +14,7 @@ import css from './AssistantMarkdown.module.css' export interface AssistantMarkdownProps { blocks: readonly AssistantBlock[] streaming: boolean - /** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */ + /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */ interrupted?: boolean | undefined } @@ -51,7 +51,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea default: return } })} - {streaming && } {interrupted && 已停止}
) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 6d75f9a519..f4ebce83db 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -51,6 +51,31 @@ border-left: 1px solid var(--dsw-alias-border-l2); } +/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to + right with a stepped trail — flat keyframe holds, no tweening. Phase + offsets come from per-rect animation-delay (index * -250ms) set inline + by the component. */ +.turnDots { + align-self: flex-start; + flex: none; + /* Same pin as StateDot: ongoing blue has no alias token (business-primary + is the 500 step, not this 450). */ + color: var(--dsw-static-deepseek-450); +} + +.turnDotCell { + fill: currentColor; + opacity: 0.15; + animation: dsh-turn-dots-chase 1s infinite; +} + +@keyframes dsh-turn-dots-chase { + 0%, 24.9% { opacity: 1; } + 25%, 49.9% { opacity: 0.6; } + 50%, 74.9% { opacity: 0.35; } + 75%, 100% { opacity: 0.15; } +} + .hint { color: var(--dsw-alias-label-tertiary); font-size: 12px; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index f61c6da6ef..737f1f042f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -149,6 +149,38 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, ) }) +/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot + * 2px cell, same blue) chasing left to right with a stepped trail — flat + * keyframe holds, no tweening, no rotation. Phase offsets come from + * per-rect animation-delay. */ +const LOADER_CELLS = [0, 5, 10, 15] as const + +function TurnDots() { + return ( + + ) +} + /** The streaming partial, isolated so chunk batches re-render only this tail. * onGrow lets the scroll owner follow content the parent never re-renders for. */ function StreamingTail({ useSession, onGrow }: { @@ -169,6 +201,7 @@ function StreamingTail({ useSession, onGrow }: { */ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + const running = useSession(s => s.running) const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) const pending = useSession(s => s.pending) @@ -314,6 +347,9 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
)} {pending.map(item => )} + {/* Turn-level loading signal: rides the whole running turn (first-token + wait, tool execution, streaming) so it never flickers per step. */} + {running && }
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 6f99bb2696..d4ae79cd71 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -6,11 +6,34 @@ flex-direction: column; } +/* Row sweep mask (running signal): the row content itself is the medium — + an animated mask band modulates the alpha of everything in the row + (glyphs, icon, dot), so the light reads as passing THROUGH the content, + not behind it. The mask is ALWAYS mounted with the band parked off-screen + (-50% = past the right edge; tiled copies sit outside the view too), so + at rest it is a no-op. Running only adds the position animation. On exit + the animation drops and `transition: mask-position` carries the band from + its animated position onward off-screen right (before-change style + includes animation effects per css-transitions-1) — the sweep finishes + instead of snapping. */ .row { display: flex; align-items: center; height: 24px; min-width: 0; + mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%); + mask-size: 200% 100%; + mask-position: -50% 0; + transition: mask-position 1.6s ease-out; +} + +.root[data-state='running'] .row { + animation: dsh-tool-row-sweep 2.2s linear infinite; +} + +@keyframes dsh-tool-row-sweep { + from { mask-position: 150% 0; } + to { mask-position: -50% 0; } } /* Clickable rows keep only the cursor affordance — no hover fill. */ @@ -20,6 +43,7 @@ } .leading { + position: relative; /* .chevronHover overlay anchor */ flex: none; width: 16px; height: 16px; @@ -62,22 +86,29 @@ button.leading { color: var(--dsw-alias-label-secondary); } -/* Hover preview on expandable rows: the idle tool icon yields to a down - chevron before the row is opened. */ +/* Hover preview on expandable rows: the idle tool icon crossfades (100ms) + into a down chevron before the row is opened. The chevron overlays the + icon cell absolutely so both can stay mounted for the opacity transition. */ .iconIdle { display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; } .chevronHover { - display: none; + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; } .row:hover .iconIdle { - display: none; + opacity: 0; } .row:hover .chevronHover { - display: inline-flex; + opacity: 1; } .title { diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index a406d05cc3..5c5d059292 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -30,11 +30,11 @@ export interface ToolRowProps { onOpenDetails?: (() => void) | undefined } -/** Leading-slot state substitution: the tool icon yields to the state semantic - * (running = blue ring, error = red, interrupted = amber halo; ok = icon). */ +/** Leading-slot state substitution: the tool icon yields to the terminal state + * semantic (error = red, interrupted = amber halo). Running keeps the icon — + * the row sweep (CSS on data-state) carries the in-flight signal. */ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { switch (state) { - case 'running': return case 'error': return case 'stopped': return default: return icon diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 11f3502325..6c18b8c1a5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -139,6 +139,7 @@ NOT absolute+transform: a transform would make this box the containing block for position:fixed descendants (pickers/modals), shrinking them. */ .composerHero { + position: relative; /* .heroGlow positioning context */ align-self: center; /* figma 75:8208: 12 between hero chrome / workspace row / card. */ gap: 12px; @@ -148,6 +149,22 @@ z-index: 1; } +/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the + card's resting center sits ~92px above the stack bottom (32 foot pad + + half of the ~120px two-row card); width tracks the card (glow asset 1051 + vs design card 776) so blur scales in userSpace with it. z-index -1 keeps + it behind the in-flow hero content inside this stacking context. */ +.heroGlow { + position: absolute; + left: 50%; + bottom: 92px; + z-index: -1; + width: calc(100% * 1051 / 776); + aspect-ratio: 1051 / 468; + transform: translate(-50%, 50%); + pointer-events: none; +} + .heroWorkspaceRow { display: flex; align-items: center; @@ -158,3 +175,9 @@ .root[data-phase='hero'] { justify-content: center; } + +/* Settling (session replaying, hero/docked unknown): keep the composer + mounted but invisible so no wrong layout flashes before the phase lands. */ +.root[data-phase='settling'] .composerStack { + visibility: hidden; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 382a42cb40..b110860685 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' -import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' +import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import { DisabledInputBar } from './DisabledInputBar.tsx' import css from './ConversationRoot.module.css' @@ -46,7 +46,11 @@ export function ConversationRoot({ } }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace]) - const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) + // While a session is still replaying (loading + blank) the hero/docked + // choice is unknowable — render the composer hidden instead of flashing + // the centered hero and snapping to the docked bar (or vice versa). + const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading' + const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open') const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } @@ -91,7 +95,10 @@ export function ConversationRoot({
) - const inputBar = sessionId === undefined + // The placeholder chip ("Choose workspace") and the inert input travel + // together: a blank session whose workspace vanished (deleted from the + // sidebar) reverts to the same disabled bar as the initial no-session state. + const inputBar = sessionId === undefined || (hero && chipTitle === undefined) ? : renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', @@ -103,6 +110,7 @@ export function ConversationRoot({ const composerBar = (
+ {hero && } {hero && } {hero && heroWorkspaceRow} {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} @@ -112,7 +120,7 @@ export function ConversationRoot({ ) return ( -
+
{/* Mounted for every real session, hero included: ConversationSession renders no chrome while blank but owns the draft-persistence mirror bind — unmounting it in the hero would lose pre-first-send text on diff --git a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx index 118baf5ac9..bfaa0b8d02 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx @@ -29,7 +29,7 @@ export function DisabledInputBar() {
diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index ca64fd7f44..fe3ee28d32 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -59,6 +59,40 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { ) } +/** + * The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero + * owner (ConversationRoot), not HeroShell, so it can center on the input + * card; the owner's className supplies all positioning. + * @param props.className - positioning class from the owner. + * @returns the blurred-ellipse svg element. + */ +export function HeroGlow({ className }: { className?: string }) { + // Stable filter id so multiple hero mounts do not collide in the DOM. + const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` + return ( + + ) +} + /** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */ export interface HeroShellProps { /** Overlay content after the stack (modals). */ @@ -66,13 +100,12 @@ export interface HeroShellProps { } /** - * Render the hero chrome (headline + glow; no composer, no workspace row). + * Render the hero chrome (headline only; no glow, no composer, no workspace + * row — the glow is the owner's {@link HeroGlow}). * @param props - see {@link HeroShellProps}. * @returns the centered hero element tree. */ export function HeroShell({ children }: HeroShellProps) { - // Stable filter id so multiple hero mounts do not collide in the DOM. - const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` return (
@@ -82,29 +115,6 @@ export function HeroShell({ children }: HeroShellProps) { Let's start building
- {/* figma 313:14109: soft ellipse behind workspace + composer; width - tracks the card (glow asset 1051 vs design card 776) so blur - scales in userSpace with it. */} - {/* The resident composer (rendered by ConversationRoot at its stable tree position; the workspace row rides its accessory hole) is CSS-positioned into this gap during the hero phase — see diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 6bc6af5fea..71b90bfdfa 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -41,8 +41,9 @@ color: var(--dsw-alias-state-business-primary); } -/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is - centered on this block so it stays under the picker + InputBar together. */ +/* Workspace row sits 12px above the input card (figma y80 → y112). The blue + glow lives with the owner (ConversationRoot .heroGlow) so it can center on + the input card. */ .body { position: relative; display: flex; @@ -52,19 +53,7 @@ overflow: visible; } -/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */ -.glow { - position: absolute; - left: 50%; - top: 50%; - z-index: 0; - width: calc(100% * 1051 / 776); - aspect-ratio: 1051 / 468; - transform: translate(-50%, -50%); - pointer-events: none; -} - -.body > :not(.glow) { +.body > * { position: relative; z-index: 1; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 7b2711da39..0cb3920f6e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -171,6 +171,10 @@ .input, .mirror, .backdrop { + /* Textareas default to content-box (unlike buttons/inputs): without this the + width:100% textarea gains its padding OUTSIDE the card and text runs past + the right padding — and wraps 28px later than the mirror/backdrop layers. */ + box-sizing: border-box; /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these metrics or the highlight ranges drift off the glyphs. */ padding: 4px 12px 0 16px; @@ -306,11 +310,14 @@ border: none; border-radius: 999px; background: var(--dsw-alias-button-info-fill); - color: var(--dsw-alias-label-primary-foreground); + /* Static white, not the foreground token: the arrow stays white on the blue + fill in both themes (design 34:10465). */ + color: #fff; cursor: pointer; + transition: background-color 100ms ease; } -.primary:hover { +.primary:hover:not(:disabled) { background: var(--dsw-alias-button-info-hover); } @@ -319,14 +326,6 @@ cursor: default; } -/* Stop state: same slot, dimmed brand fill — the running-state send-key - replacement is a design gap filled by us (figma gives no stop form). */ -.stopping, -.stopping:hover { - background: var(--dsw-alias-button-primary-dimmed); - color: var(--dsw-alias-label-primary); -} - .retry { margin-left: 8px; padding: 1px 8px; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f2313d798b..f72103ffc0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -374,7 +374,7 @@ export function InputBar({ {machineBusy && } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 84224c9979..81123532d5 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,5 +1,8 @@ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ +/* Row sweep mask — same masked in-flight signal and exit-glide contract as + ToolRow (mask always mounted, band parked off-screen; running only adds + the animation; the transition finishes the sweep on exit). */ .root { display: flex; align-items: center; @@ -7,6 +10,19 @@ min-width: 0; cursor: pointer; border-radius: 6px; + mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%); + mask-size: 200% 100%; + mask-position: -50% 0; + transition: mask-position 1.6s ease-out; +} + +.root[data-state='running'] { + animation: dsh-bash-row-sweep 2.2s linear infinite; +} + +@keyframes dsh-bash-row-sweep { + from { mask-position: 150% 0; } + to { mask-position: -50% 0; } } .leading { diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index dc4dd6b367..fbb1104eca 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -12,9 +12,9 @@ import css from './bash-sample.module.css' function leadingFor(state: ToolRowState) { switch (state) { - case 'running': return case 'error': return case 'stopped': return + // Running keeps the icon — the row sweep carries the in-flight signal. default: return } } diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index aa9451b413..798f5c52bc 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -251,7 +251,7 @@ describe('run_code sub-calls through the real chat machinery', () => { const b = await bench(snapshotWith([], dispatches, [runningCode(parent)])) const view = mountApp(b.slots) // The nested row derives 'running' from the RunningToolCall shape — the - // same StateDot ring a native in-flight row wears. + // same data-state chrome (row sweep) a native in-flight row wears. const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]') expect(nested).not.toBeNull() }) diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 13a74548b4..4425e98cb3 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -125,12 +125,12 @@ describe('ToolRow', () => { expect(view.getByText('List files')).toBeTruthy() }) - it('running and error states replace the icon with a StateDot', () => { + it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => { const runningView = render() - expect(runningView.queryByTestId('tool-icon')).toBeNull() + expect(runningView.queryByTestId('tool-icon')).not.toBeNull() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() const errorView = render() - expect(errorView.queryByTestId('tool-icon')).toBeNull() + expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull() }) it('non-expandable rows render a passive leading slot', () => { diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 136901bd6d..86bb99246d 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -76,7 +76,7 @@ describe('tails', () => { expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() }) - it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => { + it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => { const sid = 'root-1' as SessionId const list = createSnapshotStore({ ids: [sid], diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css index e5a8ebdb50..e9442f46bb 100644 --- a/packages/client/ui-primitives/src/StateDot.module.css +++ b/packages/client/ui-primitives/src/StateDot.module.css @@ -1,7 +1,7 @@ /* Ongoing blue has no alias token (state-business-primary is the 500 step, * not this 450) — component-level var pinned to the static scale instead. */ .dot, -.ring { +.matrix { --dsh-state-ongoing: var(--dsw-static-deepseek-450); } @@ -42,24 +42,24 @@ color: var(--dsw-alias-state-error-primary); } -.ring { +/* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe + * holds, no tweening — the retro feel), peaking when the chase hits it and + * decaying over the next three cells. Phase offsets come from per-rect + * animation-delay (index * -125ms) set inline by the component. */ +.matrix { flex: none; color: var(--dsh-state-ongoing); - animation: dsh-state-dot-spin 1s linear infinite; } -.stopFrom { - stop-color: currentColor; - stop-opacity: 1; +.cell { + fill: currentColor; + opacity: 0.15; + animation: dsh-state-dot-chase 1s infinite; } -.stopTo { - stop-color: currentColor; - stop-opacity: 0; -} - -@keyframes dsh-state-dot-spin { - to { - transform: rotate(360deg); - } +@keyframes dsh-state-dot-chase { + 0%, 12.4% { opacity: 1; } + 12.5%, 24.9% { opacity: 0.6; } + 25%, 37.4% { opacity: 0.35; } + 37.5%, 100% { opacity: 0.15; } } diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index c4117673ba..643f9283f4 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -1,15 +1,19 @@ // StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182). // done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid -// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a -// linear gradient, spinning. Colors resolve through --dsw-* tokens only. +// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light +// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only. -import { useId } from 'react' import clsx from 'clsx' import css from './StateDot.module.css' /** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */ export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' +/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */ +const MATRIX_CELLS: readonly (readonly [number, number])[] = [ + [0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4], +] + /** * Render a state dot. * @param props.state - which of the four states to show. @@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: { size?: number className?: string }) { - const gradientId = useId() if (state === 'ongoing') { return ( ) } diff --git a/packages/client/ui-primitives/tests/state-dot.spec.tsx b/packages/client/ui-primitives/tests/state-dot.spec.tsx index a3759174ff..86e41c7787 100644 --- a/packages/client/ui-primitives/tests/state-dot.spec.tsx +++ b/packages/client/ui-primitives/tests/state-dot.spec.tsx @@ -14,16 +14,17 @@ describe('StateDot', () => { expect(dot.getAttribute('aria-hidden')).toBe('true') }) - it('solid states are spans; ongoing is an svg gradient ring', () => { + it('solid states are spans; ongoing is an svg pixel matrix', () => { const { container, rerender } = render() expect(container.firstElementChild?.tagName).toBe('SPAN') rerender() - const ring = container.firstElementChild as SVGSVGElement - expect(ring.tagName).toBe('svg') - const circle = ring.querySelector('circle') - expect(circle?.getAttribute('stroke-width')).toBe('1') - expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/) - expect(ring.querySelector('linearGradient')).not.toBeNull() + const matrix = container.firstElementChild as SVGSVGElement + expect(matrix.tagName).toBe('svg') + const cells = matrix.querySelectorAll('rect') + expect(cells).toHaveLength(8) + // Chase phase: every cell carries its own negative animation delay. + const delays = [...cells].map(cell => (cell).style.animationDelay) + expect(new Set(delays).size).toBe(8) }) it('sizes via the size prop in both shapes', () => { From 16b54aa4c4012897144e851acf47776f5fa9d2e6 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 12:05:58 +0800 Subject: [PATCH 16/32] fix(web-ui): cwd-relative path summaries, sweep glare rework, uniform 16px chat rhythm Tool row summaries strip the session workspace root; the running sweep becomes a glare-band overlay (deepsuite ShimmerText pattern); assistant nodes that render nothing no longer split tool-row groups; block and tool-row spacing collapse to one 16px rhythm. --- .../src/client/chat/ChatView.module.css | 11 ++- .../src/client/chat/ChatView.tsx | 75 +++++++++++-------- .../src/client/chat/GenericToolCard.tsx | 4 +- .../src/client/chat/ToolRow.module.css | 41 +++++----- .../src/client/chat/chat-flow.ts | 11 +++ .../src/client/contract/slots.ts | 2 + .../src/client/contract/tool-call-model.ts | 13 +++- .../src/client/skeleton/EmptyHero.tsx | 2 +- .../src/client/skeleton/TodoPanel.module.css | 8 +- .../client/toolviews/bash-sample.module.css | 31 +++++--- .../tests/chat-tool-row.spec.tsx | 10 +++ .../ui-conversation/tests/chat-view.spec.tsx | 15 ++++ 12 files changed, 152 insertions(+), 71 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index f4ebce83db..fae96e5f6c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -1,5 +1,6 @@ -/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma); - tool rows inside a group gap 10. Input padding cap rides the skeleton. */ +/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool + runs) via the column gap and between consecutive tool rows via the group + gap. Input padding cap rides the skeleton. */ .root { position: relative; @@ -30,7 +31,7 @@ .toolGroup { display: flex; flex-direction: column; - gap: 10px; + gap: 16px; } .callRow { @@ -58,6 +59,10 @@ .turnDots { align-self: flex-start; flex: none; + display: flex; + align-items: center; + /* One message line box: the dots center inside the text line height. */ + height: 26px; /* Same pin as StateDot: ongoing blue has no alias token (business-primary is the 500 step, not this 450). */ color: var(--dsw-static-deepseek-450); diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 737f1f042f..deb7f09f6c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -49,19 +49,20 @@ type UseConversation = SnapshotSelectorHook * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and * renders the running state exactly as a native in-flight row. */ -const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: { +const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected, cwd }: { renderSlot: RenderToolRow node: CodeSubCall onOpenDetails: OpenDetails selected: boolean + cwd: string | undefined }) { const settled = 'kind' in node const toolName = settled ? node.call?.name ?? '' : node.name const seq = settled ? node.seq : node.time const owner = useMemo(() => ({ - callId: node.callId, toolName, block: node, + callId: node.callId, toolName, block: node, cwd, openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) }, - }), [node, toolName, seq, onOpenDetails]) + }), [node, toolName, seq, cwd, onOpenDetails]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -77,7 +78,9 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s * GenericToolCard at this render site. A `run_code` call additionally * renders its logged sub-dispatches as always-visible indented rows — * each one the same keyed-slot dispatch as a native top-level call. */ -const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: { +const CallRow = memo(function CallRow({ + renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId, cwd, +}: { renderSlot: RenderToolRow callId: string toolName: string @@ -91,11 +94,13 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq subCalls?: readonly CodeSubCall[] | undefined /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ selectedCallId?: string | undefined + /** Session workspace root for path-relative summaries. */ + cwd: string | undefined }) { const owner = useMemo(() => ({ - callId, toolName, block, + callId, toolName, block, cwd, openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) }, - }), [callId, toolName, block, seq, onOpenDetails]) + }), [callId, toolName, block, seq, cwd, onOpenDetails]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -111,6 +116,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq node={node} onOpenDetails={onOpenDetails} selected={node.callId === selectedCallId} + cwd={cwd} /> ))}
@@ -119,8 +125,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq ) }) -/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: { +/** Consecutive tool results as one step-run group (uniform 16px rhythm). */ +const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: { renderSlot: RenderToolRow results: readonly ToolResultNode[] onOpenDetails: OpenDetails @@ -128,6 +134,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId: string | undefined /** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */ codeDispatches: ReadonlyMap + /** Session workspace root for path-relative summaries. */ + cwd: string | undefined }) { return (
@@ -143,6 +151,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selected={node.callId === selectedCallId} subCalls={codeDispatches.get(node.callId)} selectedCallId={selectedCallId} + cwd={cwd} /> ))}
@@ -157,27 +166,29 @@ const LOADER_CELLS = [0, 5, 10, 15] as const function TurnDots() { return ( - + /* The wrapper is a 26px line box (message line height) so the loader + occupies one text line and centers the dots inside it. */ + ) } @@ -199,8 +210,10 @@ function StreamingTail({ useSession, onGrow }: { * The chat view slot entry: pure component over the composed props (tool rows * render through the declared keyed hole's renderSlot share). */ -export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { +export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openDetails, loadOlder }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + // Workspace root off the session list row: path summaries display relative to it. + const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) @@ -301,6 +314,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl onOpenDetails={openDetails} selectedCallId={inGroup ? selectedCallId : undefined} codeDispatches={codeDispatches} + cwd={cwd} /> ) } @@ -342,6 +356,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl selected={call.callId === selectedCallId} subCalls={codeDispatches.get(call.callId)} selectedCallId={selectedCallId} + cwd={cwd} /> ))}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 24b3b36a22..e5b5fb541b 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -25,8 +25,8 @@ const VARIANT_ICONS: Record = { others: , } -export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { - const model = toolRowModel(toolName, block) +export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) { + const model = toolRowModel(toolName, block, cwd) return ( b.kind === 'tool-call' + || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes (surface order). @@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem const items: ChatFlowItem[] = [] let group: ToolResultNode[] | null = null for (const node of nodes) { + if (rendersNothing(node)) continue if (node.kind === 'tool-result') { if (group === null) { group = [node] diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index c7c53aaafe..a40eb3cd7f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -143,6 +143,8 @@ export interface ToolRowOwnerProps { toolName: string /** Frozen call slice: the running call or the settled result node. */ block: ToolCallBlock + /** Session workspace root; path summaries display relative to it. */ + cwd?: string | undefined /** Open the details panel for this call (session-level facility, supplied by the view). */ openDetails: () => void } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 5b725df00b..c7db85b0aa 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -101,6 +101,14 @@ const SUMMARY_KEYS: Record = { others: [], } +/** Strip the workspace root from workspace-rooted absolute paths (display only). */ +function relativizeToCwd(text: string, cwd: string | undefined): string { + if (cwd === undefined || cwd === '') return text + const root = cwd.replace(/[/\\]+$/, '') + if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1) + return text +} + function deriveSummary(variant: ToolRowVariant, argsRaw: string): string { const parsed = parseArgs(argsRaw) if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw) @@ -130,16 +138,17 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { * Derive the full row model from a frozen call slice. * @param toolName - wire tool name (dispatch-supplied; survives windowless results). * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @param cwd - session workspace root; workspace-rooted path summaries display relative to it. * @returns the row model. */ -export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel { +export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel { const variant = classifyTool(toolName) const done = 'kind' in block const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const state: ToolRowState = !done ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' - const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw) + const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd) const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool // name rides the mutable summary slot unless the tool owns a specific title. diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index fe3ee28d32..7fbed0ee25 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -66,7 +66,7 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { * @param props.className - positioning class from the owner. * @returns the blurred-ellipse svg element. */ -export function HeroGlow({ className }: { className?: string }) { +export function HeroGlow({ className }: { className?: string | undefined }) { // Stable filter id so multiple hero mounts do not collide in the DOM. const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` return ( diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 8086cbfea7..c6b50a2c77 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -92,15 +92,15 @@ color: var(--dsw-alias-state-success-primary); } +.glyphPending { + color: var(--dsw-alias-label-caption); +} + .glyphProgress { color: var(--dsw-alias-state-business-primary); animation: todo-progress-spin 1s linear infinite; } -.glyphPending { - color: var(--dsw-alias-label-caption); -} - @keyframes todo-progress-spin { to { transform: rotate(360deg); diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 81123532d5..a79faf30f1 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,28 +1,37 @@ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ -/* Row sweep mask — same masked in-flight signal and exit-glide contract as - ToolRow (mask always mounted, band parked off-screen; running only adds - the animation; the transition finishes the sweep on exit). */ .root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; display: flex; align-items: center; height: 24px; min-width: 0; cursor: pointer; border-radius: 6px; - mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%); - mask-size: 200% 100%; - mask-position: -50% 0; - transition: mask-position 1.6s ease-out; } -.root[data-state='running'] { - animation: dsh-bash-row-sweep 2.2s linear infinite; +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-bash-row-sweep 2.6s ease-out infinite; + pointer-events: none; } @keyframes dsh-bash-row-sweep { - from { mask-position: 150% 0; } - to { mask-position: -50% 0; } + 0% { left: -300px; } + 90%, 100% { left: 100%; } } .leading { diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 4425e98cb3..63f2da6586 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -64,6 +64,16 @@ describe('tool-call-model', () => { expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1') }) + it('displays workspace-rooted paths relative to the session cwd', () => { + const cwd = '/Users/u/ws/' + expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md') + // Paths outside the workspace (and non-path summaries) stay verbatim. + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts') + expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md') + }) + it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => { expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}') expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw') diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 20389c9e23..f9028bd386 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -131,6 +131,21 @@ describe('chat-flow derivation', () => { expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') }) + + it('skips render-nothing assistant nodes so tool runs stay one group', () => { + // A tool-call-only step message (and blank text/reasoning) renders nothing: + // it must not split the run into two groups with an empty line between. + const headsOnly: AssistantMessageNode = { + kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2, + blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }], + } + const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')]) + expect(flowKeys(items)).toBe('g3') + expect(items[0]!.kind === 'tool-group' && items[0].results.map(r => r.callId)).toEqual(['a', 'b']) + // Interrupted and visible-content nodes still render (已停止 marker / prose). + expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5') + expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') + }) }) describe('ChatView', () => { From 0a625b1144bd5affebda2dcd486d5b73f1fca4cf Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 12:18:18 +0800 Subject: [PATCH 17/32] fix(web-ui): header title 14/20, drop turns counter, global grayscale antialiasing --- .../src/client/skeleton/ConversationRoot.module.css | 11 ++--------- .../src/client/skeleton/ConversationSession.tsx | 8 -------- packages/client/web/src/base.css | 4 ++++ 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 6c18b8c1a5..de974da86c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -54,8 +54,8 @@ border: none; border-radius: 12px; background: transparent; - font-size: 13px; - line-height: 16px; + font-size: 14px; + line-height: 20px; color: var(--dsw-alias-label-tertiary); text-overflow: ellipsis; white-space: nowrap; @@ -72,13 +72,6 @@ cursor: default; } -.meta { - margin-left: 4px; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - /* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ .tabs { display: flex; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 45d98c1693..0c1addcba7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -31,7 +31,6 @@ export function ConversationSession({ const activeId = useStore(s => s.view) ?? 'chat' const active = tabs.find(view => view.id === activeId) ?? tabs[0] const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) - const turns = useSession(s => countTurns(s)) const composerPhase = useSession(s => s.composerPhase) const blank = useSession(s => s.blank) const inputState = useInput(s => s) @@ -69,7 +68,6 @@ export function ConversationSession({ ) })} {ancestry.length === 0 && {sessionId}} - · {turns} turns
{tabs.length > 1 && ( @@ -95,9 +93,3 @@ export function ConversationSession({ ) } - -function countTurns(snapshot: { nodes: readonly { kind: string }[] }): number { - let count = 0 - for (const node of snapshot.nodes) if (node.kind === 'user') count += 1 - return count -} diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css index b8449634eb..03593fa829 100644 --- a/packages/client/web/src/base.css +++ b/packages/client/web/src/base.css @@ -15,6 +15,10 @@ body, body { font-family: var(--dsw-font-family); + /* Grayscale antialiasing over subpixel rendering: WebKit/Blink and the + Firefox macOS equivalent; other engines ignore both lines. */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); } From 8eb1a6f66b941f4025115a4727ce3c1875afab20 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 12:25:32 +0800 Subject: [PATCH 18/32] fix(web-ui): narrow flow item before reading group results in chat-view spec --- packages/client/ui-conversation/tests/chat-view.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index f9028bd386..1754397eba 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -141,7 +141,8 @@ describe('chat-flow derivation', () => { } const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')]) expect(flowKeys(items)).toBe('g3') - expect(items[0]!.kind === 'tool-group' && items[0].results.map(r => r.callId)).toEqual(['a', 'b']) + const group = items[0]! + expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b']) // Interrupted and visible-content nodes still render (已停止 marker / prose). expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5') expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5') From 3134f2d4301b120ad20bc7581f25a74d923f3ee6 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 28 Jul 2026 12:36:29 +0800 Subject: [PATCH 19/32] doc: agent note for the web conversation polish sweep --- ...28-web-conversation-polish-sweep.i18n.yaml | 6 +++ ...026-07-28-web-conversation-polish-sweep.md | 37 +++++++++++++++++++ ...-07-28-web-conversation-polish-sweep.zh.md | 37 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml new file mode 100644 index 0000000000..3df46dedf2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md +2026-07-28-web-conversation-polish-sweep.md: cae52217d66017509c025a5d8d37b1e1e8173c6a +2026-07-28-web-conversation-polish-sweep.zh.md: d19a5f75937e9ae9f553b2c941594db118fa434e diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md new file mode 100644 index 0000000000..cae52217d6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md @@ -0,0 +1,37 @@ +# Agent Note: Web conversation UI polish sweep + +Status: implemented + +English | [中文](2026-07-28-web-conversation-polish-sweep.zh.md) + +## Problem + +A design review of the web GUI's conversation surfaces found a batch of presentation defects: portal menus painted one frame at the wrong position before repositioning (visible open jump), the chat column split one tool run into several groups whenever a step message carried only tool-call heads, tool row summaries printed workspace-absolute paths that consumed most of the row, the running-row sweep was implemented as an alpha mask that dimmed the whole row, the hero workspace chip resurrected a deleted workspace's folder name from the session cwd, and the header showed a turns counter nobody asked for next to a 13px title. + +## Decision + +The sweep lands as presentation-layer changes only; nothing enters the session log. + +- **Portal menus pre-render hidden and measure before paint.** The menu list mounts with `visibility: hidden` at (0,0), measures in `useLayoutEffect`, and becomes visible already at its final position. Menus keep 12px viewport clearance with internal scroll; workspace create actions pin in a non-scrolling footer. +- **The chat flow skips assistant nodes that render nothing.** A finalized assistant node whose blocks are only tool-call heads and blank text/reasoning is dropped from the flow derivation, so consecutive tool results merge into one group. Interrupted nodes always render (they carry the 已停止 marker). +- **Tool row summaries relativize workspace-rooted paths.** The session cwd threads through the toolview slot contract (`ToolRowOwnerProps.cwd`) and `toolRowModel` strips it from summaries that start with it; paths outside the workspace stay verbatim. Display-only — args and the log are untouched. +- **The running sweep is a glare-band overlay.** A fixed-width `::after` gradient band animates across the row (the deepsuite ShimmerText pattern), replacing the previous `mask-image` approach, in both ToolRow and the Bash toolview. +- **The hero workspace chip is a selector, not an echo.** With no live selection (cold start, or the workspace was deleted after the list settled) it shows a "Choose workspace" placeholder; the cwd-derived name only bridges the initial list load, and stale pending picks clear when their workspace leaves a ready list. +- **One 16px vertical rhythm.** The chat column gap and in-group tool-row gap are both 16px, replacing the 10px in-group gap plus a negative cross-group margin. +- **Header title reads 14/20 with no turns counter**; StateDot ongoing and the turn tail use a stepped pixel-chase loading language; `body` gets grayscale antialiasing (`-webkit-font-smoothing` and the Firefox macOS equivalent). + +## Alternatives considered + +- **Position menus synchronously from anchor rects before mount.** Rejected: the list's own size is unknown until it lays out, so clamping to the viewport still needs a post-layout measure; measuring a hidden mounted node is the pattern React and Floating UI document. +- **Filter empty assistant messages host-side.** Rejected: the node is real model output that Trajectory and replay must keep; only the chat presentation should skip it, and the web layer is pure presentation by contract. +- **Relativize paths in each tool's presenter.** Rejected: the redundancy is shared by every path-summarizing tool; one display-only pass in `toolRowModel` covers them all and non-chat consumers keep absolute paths. +- **Keep the mask-based sweep.** Rejected: the mask dims the entire row content including state dots, and its exit transition fought the hover icon crossfade; an overlay band composites above the content without touching its alpha. +- **Keep showing the deleted workspace's name in the chip.** Rejected: the chip is the selector for the *next* session; echoing a cwd whose workspace the user just deleted misrepresents the current pick. + +## Consequences + +Chat renders fewer flow items than the snapshot has nodes: anyone counting rendered blocks against nodes must account for skipped render-nothing assistants (the chat-view spec pins this). The path relativization is a prefix check against the session cwd, so a workspace rename mid-session shows absolute paths until the summary re-derives — accepted as display-only staleness. The uniform 16px rhythm retires the tighter 10px tool-run look; a future denser layout would reintroduce a second constant deliberately. The menu pre-render adds one hidden layout pass per open, negligible at menu sizes. + +## Testing + +`chat-view.spec.tsx` pins the render-nothing grouping (including the interrupted exception); `chat-tool-row.spec.tsx` pins cwd relativization inside/outside the workspace and with an empty cwd; `atoms.spec.tsx` and `workspace-picker.spec.tsx` cover the menu and chip states; the full ui-conversation, ui-primitives, and ui-workspace suites pass. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md new file mode 100644 index 0000000000..d19a5f7593 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web conversation UI polish sweep + +Status: implemented + +[English](2026-07-28-web-conversation-polish-sweep.md) | 中文 + +## Problem + +一次针对 web GUI 对话界面的设计评审发现了一批视觉呈现缺陷:portal 菜单在重新定位前会先在错误位置绘制一帧(打开时可见跳动);只要某条步骤消息只携带工具调用头,聊天列就会把一次工具运行拆成好几组;工具行摘要打印以工作区为根的绝对路径,占掉行内大部分空间;运行中行的扫光效果用 alpha 遮罩实现,把整行都压暗;hero 区的工作区 chip 会从会话 cwd 里复现已删除工作区的文件夹名;标题栏还在 13px 的标题旁显示一个没人需要的轮次计数。 + +## Decision + +本次修复全部为纯展示侧改动落地;不会有任何内容进入会话日志。 + +- **Portal 菜单先隐藏预渲染,绘制前完成测量。**菜单列表以 `visibility: hidden` 挂载在 (0,0),在 `useLayoutEffect` 中测量,显示时已处于最终位置。菜单与视口保持 12px 间距并支持内部滚动;工作区创建操作固定在不滚动的页脚区。 +- **聊天流跳过不渲染任何内容的助手节点。**已定稿的助手节点若其块仅含工具调用头和空白的文本/推理(reasoning)内容,就会从流推导中剔除,于是连续的工具结果合并为一组。被中断的节点始终渲染(它们携带「已停止」标记)。 +- **工具行摘要把以工作区为根的路径转为相对路径。**会话 cwd 经由 toolview 插槽契约(`ToolRowOwnerProps.cwd`)逐层传递,`toolRowModel` 从以其开头的摘要中剥去该前缀;工作区之外的路径保持原样。这只影响显示:工具参数与日志均不受影响。 +- **运行中的扫光效果改为高光带叠加层。**一条固定宽度的 `::after` 渐变光带横向扫过整行(即 deepsuite 的 ShimmerText 模式),取代先前的 `mask-image` 方案,ToolRow 与 Bash toolview 两处均已替换。 +- **hero 区的工作区 chip 是选择器,而非回显。**没有有效选中项时(冷启动,或列表稳定后工作区被删除),它显示「Choose workspace」占位文案;由 cwd 推导的名称只用于衔接列表的首次加载,待定选择对应的工作区从已就绪的列表中消失时,该陈旧选择会被清除。 +- **统一为 16px 的纵向节奏。**聊天列间距与分组内工具行间距统一为 16px,取代原先「分组内 10px 间距加跨分组负外边距」的做法。 +- **标题栏标题改为 14/20,去掉轮次计数**;StateDot 的进行中状态与轮次尾部采用逐格推进的像素追逐式加载视觉语言;`body` 启用灰度抗锯齿(`-webkit-font-smoothing` 及 Firefox 在 macOS 上的等价设置)。 + +## Alternatives considered + +- **挂载前根据锚点矩形同步定位菜单。**不予采纳:列表自身尺寸在布局完成前无从得知,向视口内收拢仍然需要布局后测量;对已挂载的隐藏节点做测量正是 React 与 Floating UI 文档记载的模式。 +- **在宿主侧过滤空的助手消息。**不予采纳:该节点是真实的模型输出,Trajectory 与回放都必须保留它;只有聊天展示应当跳过它,且按契约 web 层只负责呈现。 +- **在每个工具各自的展示器中做路径相对化。**不予采纳:这种冗余是所有输出路径摘要的工具共有的;在 `toolRowModel` 里做一次仅影响显示的处理即可覆盖全部工具,非聊天消费方仍拿到绝对路径。 +- **保留基于遮罩的扫光。**不予采纳:遮罩会把包括状态圆点在内的整行内容压暗,其退出过渡还与悬停图标的交叉淡入淡出相互冲突;叠加光带在内容之上合成,完全不触碰内容的 alpha。 +- **让 chip 继续显示已删除工作区的名称。**不予采纳:chip 是为*下一个*会话服务的选择器;用户刚删掉某个工作区,还回显它的 cwd,就是在错误呈现当前的选择。 + +## Consequences + +聊天渲染出的流条目数少于快照中的节点数:凡是拿渲染出的块与节点数对账的人,都必须把被跳过的「不渲染任何内容」的助手节点计算在内(chat-view 规格测试固定了这一点)。路径相对化只是针对会话 cwd 的前缀检查,因此会话中途重命名工作区后,摘要在重新推导前会显示绝对路径,这被接受为仅影响显示的陈旧状态。统一的 16px 节奏淘汰了原先更紧凑的 10px 工具运行外观;将来若要更紧凑的布局,应当有意识地重新引入第二个常量。菜单预渲染让每次打开多一次隐藏布局计算,在菜单的尺寸量级下开销可忽略。 + +## Testing + +`chat-view.spec.tsx` 固定了「不渲染任何内容」节点的分组行为(含被中断节点这一例外);`chat-tool-row.spec.tsx` 固定了工作区内、工作区外以及 cwd 为空时的 cwd 相对化行为;`atoms.spec.tsx` 与 `workspace-picker.spec.tsx` 覆盖菜单与 chip 的各种状态;ui-conversation、ui-primitives 与 ui-workspace 的全量测试套件通过。 From b926044c13ba3ae79d24815134a7f09eb2ee0046 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 28 Jul 2026 14:24:41 +0800 Subject: [PATCH 20/32] feat: click file name to open file in toolcall, remove hover bg of toolcall, do not trigger sidebar any more (follow designer's instruction) --- ...-07-28-tool-call-file-open-in-os.i18n.yaml | 6 ++ .../2026-07-28-tool-call-file-open-in-os.md | 30 +++++++ ...2026-07-28-tool-call-file-open-in-os.zh.md | 30 +++++++ .../client/connection/src/client/fixture.ts | 2 + packages/client/connection/src/index.ts | 3 +- .../connection/src/native-dialog-request.ts | 2 +- packages/client/connection/tests/fake-api.ts | 3 + .../client/connection/tests/node-half.spec.ts | 34 ++++---- .../runtime/src/client/workspaces/service.ts | 11 +++ packages/client/runtime/tests/fake-api.ts | 3 + .../runtime/tests/workspaces-service.spec.ts | 11 +++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../ui-conversation/src/client/apply.ts | 8 ++ .../src/client/chat/ChatView.tsx | 40 ++++------ .../src/client/chat/GenericToolCard.tsx | 9 ++- .../src/client/chat/ToolRow.module.css | 31 ++++++-- .../src/client/chat/ToolRow.tsx | 41 ++++++++-- .../src/client/contract/slots.ts | 12 ++- .../src/client/contract/tool-call-model.ts | 36 +++++++++ .../client/toolviews/bash-sample.module.css | 6 -- .../src/client/toolviews/bash-sample.tsx | 4 +- .../src/client/toolviews/todo-row.module.css | 6 -- .../src/client/toolviews/todo-row.tsx | 20 +---- .../tests/apply-inject.spec.tsx | 10 +++ .../ui-conversation/tests/chat-apply.spec.tsx | 1 + .../tests/chat-code-subcalls.spec.tsx | 22 ++++-- .../tests/chat-stats-bash-sample.spec.tsx | 19 ++--- .../tests/chat-tool-row.spec.tsx | 66 +++++++++++++--- .../tests/chat-toolview-slot.spec.tsx | 24 ++++-- .../ui-conversation/tests/chat-view.spec.tsx | 23 +++++- .../tests/coverage-tails.spec.tsx | 4 +- .../ui-conversation/tests/todo-panel.spec.tsx | 27 ++----- .../tests/views-type-chain.spec.tsx | 2 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/src/api-proxy.ts | 25 ++++++ packages/host/apiproxy/src/api/host.schema.ts | 10 +++ packages/host/apiproxy/src/api/host.ts | 10 +++ packages/host/apiproxy/src/api/rpc-map.ts | 1 + packages/host/apiproxy/src/fetch/client.ts | 7 +- packages/host/apiproxy/src/fetch/handler.ts | 5 +- .../host/apiproxy/src/native-path-opener.ts | 78 +++++++++++++++++++ .../tests/api-proxy-workspace.spec.ts | 44 +++++++++-- .../apiproxy/tests/client-handler.spec.ts | 1 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 3 + .../apiproxy/tests/native-path-opener.spec.ts | 62 +++++++++++++++ workspace/作文-星光不负赶路人.md | 9 +++ 50 files changed, 649 insertions(+), 172 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md create mode 100644 packages/host/apiproxy/src/native-path-opener.ts create mode 100644 packages/host/apiproxy/tests/native-path-opener.spec.ts create mode 100644 workspace/作文-星光不负赶路人.md diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml new file mode 100644 index 0000000000..44869d6f05 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +2026-07-28-tool-call-file-open-in-os.md: a2c9b52507d32c2d851f811f0ecdd878a60b1e1c +2026-07-28-tool-call-file-open-in-os.zh.md: efb4c39503d9de71a9d773bdae7fac4fb2b08ee3 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md new file mode 100644 index 0000000000..a2c9b52507 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -0,0 +1,30 @@ +# Agent Note: Tool-call file open in OS + +Status: implemented + +English | [中文](2026-07-28-tool-call-file-open-in-os.zh.md) + +## Problem + +Chat tool rows treated the whole summary line as a click target that opened the right-hand details panel, with a hover background on the row. For filesystem tools the useful action is opening the mentioned file in the operating system's default application, not inspecting the raw tool payload in a sidebar. + +## Decision + +File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as hover-underline links with a pointer cursor. Clicking the path calls `host.openPath` through `WorkspacesService.openPath`, resolving relative paths against the session cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. + +`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, `xdg-open` on Linux. The opener is injectable for tests. URL-only read args (`web_fetch`) are not file links. + +## Alternatives considered + +- Keep row-click details and add a separate file affordance — rejected; the product ask replaces the row gesture with the file link. +- Open files inside an in-app preview — rejected; the ask is the OS default application. +- Reuse `host.pickDirectory`'s timeout exemption — unnecessary; path open hand-off completes quickly under the normal unary deadline. + +## Consequences + +Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. + +## Risks + +- Linux hosts without `xdg-open` fail the RPC; the chat row stays silent while the host returns an internal error. +- Relative paths without a session cwd are forwarded verbatim and may fail on the host. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md new file mode 100644 index 0000000000..efb4c39503 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -0,0 +1,30 @@ +# Agent Note: 在工具调用中用系统应用打开文件 + +Status: implemented + +[English](2026-07-28-tool-call-file-open-in-os.md) | 中文 + +## Problem + +聊天工具行把整行摘要当作点击目标,点击后打开右侧 details 面板,并带有整行悬停背景。对文件系统工具而言,有用的动作是用操作系统默认应用打开所涉文件,而不是在侧栏里查看原始工具载荷。 + +## Decision + +文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为悬停下划线链接并使用 pointer 光标。点击路径会经 `WorkspacesService.openPath` 调用 `host.openPath`,相对路径相对会话 cwd 解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 + +`host.openPath` 是特权一元 RPC,仅接受来自回环、同源浏览器请求(与 `host.pickDirectory` 相同的载体守卫)。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,Linux 为 `xdg-open`。打开器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 + +## Alternatives considered + +- 保留整行点击打开 details,另加文件入口 — 否决;产品要求用文件链接替换整行手势。 +- 在应用内预览文件 — 否决;要求是操作系统默认应用。 +- 复用 `host.pickDirectory` 的超时豁免 — 不必要;打开路径的交接在常规一元截止时间内即可完成。 + +## Consequences + +点击工具行中的文件路径会在宿主上打开该路径。非文件工具行是惰性摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。 + +## Risks + +- 没有 `xdg-open` 的 Linux 宿主会使 RPC 失败;聊天行保持静默,宿主返回 internal 错误。 +- 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 5c7befd8c9..ff90453278 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -776,6 +776,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), pickDirectory: request => ok(request, { path: null }), + openPath: request => ok(request, { opened: true as const }), }, workspace: { list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), @@ -1027,6 +1028,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) + case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index bc73e0e054..33f6d0cc41 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -26,7 +26,8 @@ export function apply(ctx: Context): void { path: API_PATH, handler: async (req, res) => { const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - if (pathname === `${API_PATH}/host.pickDirectory` + if ((pathname === `${API_PATH}/host.pickDirectory` + || pathname === `${API_PATH}/host.openPath`) && !isTrustedNativeDialogRequest(req)) { res.writeHead(403) res.end('forbidden') diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts index fe91bbae2d..0eaf09f149 100644 --- a/packages/client/connection/src/native-dialog-request.ts +++ b/packages/client/connection/src/native-dialog-request.ts @@ -1,4 +1,4 @@ -/** Trust check for browser requests that can open an operating-system dialog. */ +/** Trust check for browser requests that can invoke privileged native host actions. */ import type { IncomingHttpHeaders } from 'node:http' diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 1bb49b19fb..f4058deda1 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -66,6 +66,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) + onOpenPath: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ opened: true as const })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -88,6 +90,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), + openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), } readonly workspace: IApiClient['workspace'] = { diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 86af61ba0d..2c90cd8b7a 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -28,22 +28,24 @@ describe('connection node half', () => { expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) - let status: number | undefined - let body: unknown - const deniedRequest = { - url: '/api/host.pickDirectory', - headers: { - host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', - }, - socket: { remoteAddress: '192.168.1.8' }, - } as unknown as IncomingMessage - const deniedResponse = { - writeHead(value: number) { status = value; return this }, - end(value?: unknown) { body = value; return this }, - } as unknown as ServerResponse - await routes[0]!.handler(deniedRequest, deniedResponse) - expect(status).toBe(403) - expect(body).toBe('forbidden') + for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) { + let status: number | undefined + let body: unknown + const deniedRequest = { + url, + headers: { + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }, + socket: { remoteAddress: '192.168.1.8' }, + } as unknown as IncomingMessage + const deniedResponse = { + writeHead(value: number) { status = value; return this }, + end(value?: unknown) { body = value; return this }, + } as unknown as ServerResponse + await routes[0]!.handler(deniedRequest, deniedResponse) + expect(status).toBe(403) + expect(body).toBe('forbidden') + } await fiber.dispose() expect(routes).toHaveLength(0) diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index fe345801c6..97f01d0bf1 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -182,6 +182,17 @@ export class WorkspacesService { return response.result.value.path } + /** + * Open a filesystem path with the Host operating system's default application. + * @param path - absolute or host-resolvable path. + */ + async openPath(path: string): Promise { + const response = await this.api.host.openPath({ path }) + if (!response.result.ok) { + throw new Error(`path open failed: ${response.result.error.message}`) + } + } + /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index a5eecd0cf5..dc33e20128 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -84,6 +84,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) + onOpenPath: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ opened: true as const })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -106,6 +108,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), + openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 5327066651..6768fddff7 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -236,6 +236,17 @@ describe('WorkspacesService', () => { expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}]) }) + it('opens a filesystem path through the host without local state', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined() + expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }]) + api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) + await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/) + }) + it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { const ctx = new Context() const api = new FakeApiClient() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index c1a6828d5b..b685a6d9fe 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 56a445ccfa86e0b11cf5aefc37819a30746f0739 -README.zh.md: a7c160ecdd74074257c9d149630663dacd05c070 +README.md: a04c20f225c731581accbe8c12c52a5e7597029a +README.zh.md: f9e6a635ea6090c87a66f029785af214025b9bda diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 56a445ccfa..a04c20f225 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,9 +8,9 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. -Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). +Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a7c160ecdd..f9e6a635ea 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -8,9 +8,9 @@ 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 -工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 +工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 789f86aeb6..8b2db853c0 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -7,6 +7,7 @@ import type { ViewTab } from './contract/views.ts' import type { ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected, } from './contract/slots.ts' +import { resolveToolPath } from './contract/tool-call-model.ts' import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import { InputHub } from './input/hub.ts' @@ -165,6 +166,13 @@ export function apply(ctx: Context): void { actions.select(target) layout.openDetails() }, + openFile: (path) => { + const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd + void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => { + // Host/OS open failures stay silent in the chat row; the native + // app surfaces its own error dialog when the path is unusable. + }) + }, loadOlder: () => { void scoped.loadOlder() }, } }, diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index f61c6da6ef..7f0071c143 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -25,7 +25,6 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import type { SelectionTarget } from '../contract/views.ts' import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -36,7 +35,7 @@ import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 -type OpenDetails = (target: SelectionTarget) => void +type OpenFile = (path: string) => void /** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */ type RenderToolRow = ChatViewSlotProps['renderSlot'] @@ -49,19 +48,17 @@ type UseConversation = SnapshotSelectorHook * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and * renders the running state exactly as a native in-flight row. */ -const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: { +const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected }: { renderSlot: RenderToolRow node: CodeSubCall - onOpenDetails: OpenDetails + openFile: OpenFile selected: boolean }) { const settled = 'kind' in node const toolName = settled ? node.call?.name ?? '' : node.name - const seq = settled ? node.seq : node.time const owner = useMemo(() => ({ - callId: node.callId, toolName, block: node, - openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) }, - }), [node, toolName, seq, onOpenDetails]) + callId: node.callId, toolName, block: node, openFile, + }), [node, toolName, openFile]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -77,14 +74,12 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s * GenericToolCard at this render site. A `run_code` call additionally * renders its logged sub-dispatches as always-visible indented rows — * each one the same keyed-slot dispatch as a native top-level call. */ -const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: { +const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId }: { renderSlot: RenderToolRow callId: string toolName: string block: ToolResultNode | RunningToolCall - /** Surface seq for finalized results; the call's turn for running calls. */ - seq: number - onOpenDetails: OpenDetails + openFile: OpenFile selected: boolean /** `run_code` sub-dispatches in dispatch order (reference-stable per * parent; running entries settle in place); undefined for ordinary calls. */ @@ -93,9 +88,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq selectedCallId?: string | undefined }) { const owner = useMemo(() => ({ - callId, toolName, block, - openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) }, - }), [callId, toolName, block, seq, onOpenDetails]) + callId, toolName, block, openFile, + }), [callId, toolName, block, openFile]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -109,7 +103,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq key={node.callId} renderSlot={renderSlot} node={node} - onOpenDetails={onOpenDetails} + openFile={openFile} selected={node.callId === selectedCallId} /> ))} @@ -120,10 +114,10 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq }) /** Consecutive tool results as one step-run group (figma VERTICAL gap10). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: { +const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches }: { renderSlot: RenderToolRow results: readonly ToolResultNode[] - onOpenDetails: OpenDetails + openFile: OpenFile /** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */ selectedCallId: string | undefined /** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */ @@ -138,8 +132,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, callId={node.callId} toolName={node.call?.name ?? ''} block={node} - seq={node.seq} - onOpenDetails={onOpenDetails} + openFile={openFile} selected={node.callId === selectedCallId} subCalls={codeDispatches.get(node.callId)} selectedCallId={selectedCallId} @@ -167,7 +160,7 @@ function StreamingTail({ useSession, onGrow }: { * The chat view slot entry: pure component over the composed props (tool rows * render through the declared keyed hole's renderSlot share). */ -export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { +export function ChatView({ useSession, useStore, renderSlot, openFile, loadOlder }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) const runningCalls = useSession(s => s.runningCalls) const codeDispatches = useSession(s => s.codeDispatches) @@ -265,7 +258,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl key={item.key} renderSlot={renderSlot} results={item.results} - onOpenDetails={openDetails} + openFile={openFile} selectedCallId={inGroup ? selectedCallId : undefined} codeDispatches={codeDispatches} /> @@ -304,8 +297,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl callId={call.callId} toolName={call.name} block={call} - seq={call.turn} - onOpenDetails={openDetails} + openFile={openFile} selected={call.callId === selectedCallId} subCalls={codeDispatches.get(call.callId)} selectedCallId={selectedCallId} diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 5cb2126f34..266b429c0b 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -25,8 +25,9 @@ const VARIANT_ICONS: Record = { others: , } -export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { +export function GenericToolCard({ toolName, block, openFile }: ToolRowOwnerProps) { const model = toolRowModel(toolName, block) + const singleFile = model.filePath !== undefined return ( ) } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 5d44a9260e..9b18e83eaa 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -13,13 +13,9 @@ min-width: 0; } -.row[data-clickable] { +/* Expand-on-row (Think / code): pointer only — no row fill hover. */ +.row[data-expandable] { cursor: pointer; - border-radius: 6px; -} - -.row[data-clickable]:hover { - background: var(--dsw-alias-interactive-bg-hover); } .leading { @@ -92,6 +88,29 @@ button.leading { color: var(--dsw-alias-label-tertiary); } +/* File-tool path: same geometry as .summary; hover underline + pointer. */ +.fileLink { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + text-align: left; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.fileLink:hover { + text-decoration: underline; +} + /* Expanded body: pad-left 22 indented gray text, no border, no fill. */ .body { padding: 4px 0 4px 22px; diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index a81c084b8e..4870f2a791 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -2,7 +2,8 @@ // 16px leading slot (state dot / tool icon, chevron when expanded) + title + // separator dot + FILL-truncated summary. Expanded body is indented gray text; // no inline output (full results live in the details panel). Expand state is -// component-local view state; row click hands the selection off to the owner. +// component-local view state. File-tool summaries are path links that open +// through the host; the row itself is not a details-panel control. import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' @@ -24,8 +25,13 @@ export interface ToolRowProps { state: ToolRowState /** Makes the row itself the expand control instead of only its leading icon. */ expandOnRowClick?: boolean | undefined - /** Selection handoff (row click), already bound to this call by the owner. */ - onOpenDetails?: (() => void) | undefined + /** + * Filesystem path from tool args; when set with onOpenFile, the summary + * renders as a hover-underline link that opens the host default app. + */ + filePath?: string | undefined + /** Open the path with the host OS default application (already cwd-resolved). */ + onOpenFile?: ((path: string) => void) | undefined } /** Leading-slot state substitution: the tool icon yields to the state semantic @@ -48,10 +54,15 @@ export function ToolRow({ body, state, expandOnRowClick = false, - onOpenDetails, + filePath, + onOpenFile, }: ToolRowProps) { const [expanded, setExpanded] = useState(false) - const expandable = body !== null + // A row that names a single file keeps one interaction (open that path); + // args expand is off whether or not the open callback is wired yet. + const singleFile = filePath !== undefined + const fileLink = singleFile && onOpenFile !== undefined + const expandable = body !== null && !singleFile const open = expanded && expandable const rowExpands = expandable && expandOnRowClick const toggleExpand = () => { @@ -66,15 +77,19 @@ export function ToolRow({ event.preventDefault() toggleExpand() } + const openFile = (event: MouseEvent) => { + event.stopPropagation() + if (filePath !== undefined) onOpenFile?.(filePath) + } return (
{expandable && !rowExpands ? ( @@ -95,7 +110,17 @@ export function ToolRow({ {!open && ( <> - {summary} + {fileLink ? ( + + ) : ( + {summary} + )} )}
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 44f337d61c..f4dc6bd90d 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -143,8 +143,11 @@ export interface ToolRowOwnerProps { toolName: string /** Frozen call slice: the running call or the settled result node. */ block: ToolCallBlock - /** Open the details panel for this call (session-level facility, supplied by the view). */ - openDetails: () => void + /** + * Open a tool-arg filesystem path with the host OS default application. + * The chat view resolves relative paths against the session cwd. + */ + openFile: (path: string) => void } /** @@ -276,6 +279,11 @@ export type ConversationSessionSlotProps = export interface ChatViewInjected { /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ openDetails: (target: SelectionTarget) => void + /** + * Open a tool-arg filesystem path with the host OS default application + * (relative paths resolve against the session cwd). + */ + openFile: (path: string) => void loadOlder: () => void } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 5b725df00b..ae88519daf 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -62,6 +62,12 @@ export interface ToolRowModel { variant: ToolRowVariant title: string summary: string + /** + * Filesystem path from args (`path` / `file_path`) when the row is a file + * tool; absent for URL reads and non-file tools. The chat view resolves + * relative values against the session cwd before opening. + */ + filePath: string | undefined /** Expanded-body text (pretty args); null = row not expandable. */ body: string | null state: ToolRowState @@ -113,6 +119,35 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string { return firstLine(argsRaw) } +/** Path keys only — never `url` (web_fetch lands on the read variant). */ +const FILE_PATH_KEYS = ['path', 'file_path'] as const + +/** File-tool variants whose summary may be an openable workspace path. */ +const FILE_PATH_VARIANTS: ReadonlySet = new Set(['read', 'write', 'edit']) + +function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined { + if (!FILE_PATH_VARIANTS.has(variant)) return undefined + const parsed = parseArgs(argsRaw) + if (typeof parsed !== 'object' || parsed === null) return undefined + const picked = pickString(parsed as Record, FILE_PATH_KEYS) + return picked === undefined ? undefined : firstLine(picked) +} + +/** + * Resolve a tool-arg path against the session cwd for host.openPath. + * Absolute POSIX/Windows paths pass through; relative paths join under cwd. + * @param cwd - session working directory (may be absent for ungrouped sessions). + * @param path - path as carried in tool args. + * @returns a host-facing path string. + */ +export function resolveToolPath(cwd: string | undefined, path: string): string { + if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path + if (cwd === undefined || cwd === '') return path + const base = cwd.replace(/[/\\]+$/, '') + const rel = path.replace(/^[/\\]+/, '') + return `${base}/${rel}` +} + function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { if (argsRaw === '') return null const parsed = parseArgs(argsRaw) @@ -150,6 +185,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod variant, title: toolTitle ?? VARIANT_TITLES[variant], summary, + filePath: deriveFilePath(variant, argsRaw), body: deriveBody(variant, argsRaw), state, } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 9c42e69b59..2ba9429dd3 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -5,12 +5,6 @@ align-items: center; height: 24px; min-width: 0; - cursor: pointer; - border-radius: 6px; -} - -.root:hover { - background: var(--dsw-alias-interactive-bg-hover); } .leading { diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 616eee5943..269fc5c576 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -30,7 +30,7 @@ function stateStatus(state: ToolRowState): string | null { } /** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */ -export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) { +export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) { const model = toolRowModel(toolName, block) const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) const status = stateStatus(model.state) @@ -40,8 +40,6 @@ export function BashRow({ toolName, block, openDetails, sessionId, useSessions } data-sample={isChild ? 'bash-scoped' : 'bash-global'} data-variant="bash" data-state={model.state} - data-clickable - onClick={openDetails} > {leadingFor(model.state)} {status !== null && {status}} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css index dd32d56b01..93b5452257 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -6,12 +6,6 @@ align-items: center; height: 24px; min-width: 0; - cursor: pointer; - border-radius: 6px; -} - -.row:hover { - background: var(--dsw-alias-interactive-bg-hover); } .leading { diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index a47322b614..67732a3650 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -5,7 +5,6 @@ // durable list itself renders in the TodoPanel above the composer, so the // row stays one line. Chrome matches ToolRow (figma 780:53675). -import type { KeyboardEvent } from 'react' import type { Context } from 'cordis' import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' @@ -51,29 +50,18 @@ function leadingFor(state: ToolRowState) { } } -/** One-line plan update row (click opens the raw args in details). Non-ok - * execution states keep the generic row's dot semantics — a cancelled call - * wrote no todo/write, so it must not read as a completed update. */ -export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { +/** One-line plan update row. Non-ok execution states keep the generic row's + * dot semantics — a cancelled call wrote no todo/write, so it must not read + * as a completed update. */ +export function TodoRow({ toolName, block }: ToolRowProps) { const model = toolRowModel(toolName, block) const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' const summary = summarize(argsRaw) ?? model.summary - // Button semantics, not a