feat(workflow): show durable run records in Chat

This commit is contained in:
pku-xht
2026-08-10 18:37:30 +08:00
parent d2321d210a
commit 8de6df19d9
73 changed files with 3013 additions and 227 deletions

View File

@@ -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/feature/2026-07-05-dynamic-workflows.md
2026-07-05-dynamic-workflows.md: 3e491478286eb77b56872fcbbdd5ebb6b62a5545
2026-07-05-dynamic-workflows.zh.md: 7888d83f981a96ac5eb31d5ca6f1f8d0b4930ec7
2026-07-05-dynamic-workflows.md: 287b0031a5fecaaa815befa3c7b792c3179f1dae
2026-07-05-dynamic-workflows.zh.md: 8b63498fd7f82159bfc0cc3b5d29f3a151d84338

View File

@@ -40,6 +40,8 @@ The engine exposes an in-process `MessageChannel` test path because main-process
A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed``isError`. Render intent: a `generic` card titled by the call's `meta.name` parameter (presentation is a pure function of args). The tool description IS the model-facing authoring spec. The usage policy ships with the tool as its own `tool:<toolName>` prompt section (explicit-ask-only guidance — tool guidance lives in tool plugins, never in the deployment persona); the harness has no ultracode-style effort gate.
For a top-level tool execution, the same consumer also writes the run and actual member lifecycle into the calling parent Session as four log-only `tool-workflow/*` events. The recording path observes rather than controls execution: its first append failure disables later writes for that run and leaves a legal prefix without changing the tool result. [`ui-workflow-run`](../../../../packages/client/ui-workflow-run/README.md) rebuilds those facts through the Conversation Node engine as a separate keyed Chat row; the existing generic tool row remains its own presentation owner. The detailed persistence, replay, disclosure, and live-navigation decision lives in [durable workflow runs in Chat](2026-08-10-durable-workflow-runs-in-chat.md).
### The foundation: structured output on the subagent seam
`SubagentStartRequest.outputSchema` is implemented by `dsh-subagent-inprocess` for both in-process backends. Each structured child receives its own scoped capture tool, instruction, and enforcement registrations on `child.ctx`; concurrent children can use different schemas without sharing mutable policy, and disposing the child removes the entire attachment.
@@ -60,7 +62,6 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea
- **Nested `workflow()`**, **token `budget`**, and the `effort`/`isolation`/`agentType` agent options (each rejects loud with a message naming it deferred).
- **An overall run wall-clock timeout** — cancellation always frees the caller (result settles within the grace), so a cap on total run time is a policy knob for the background redesign, not a correctness need here.
- **Engine hardening beyond worker threads**: an isolated-vm or separate-process engine behind the same seam (actual sandboxing; memory limits).
- **Human-interface progress UI** over the `workflow/*` events (a `/workflows`-style view); the events exist for it.
- **ACP-backend structured output** and **`toolFilter`** (both still capability-gated `false`).
## Alternatives considered
@@ -77,4 +78,4 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea
## Consequences
Fan-out plans now live in rerunnable scripts, and `outputSchema` provides authoritative structured child results. Each run pays worker startup and message-port RPC costs, but host startup stays non-blocking, cancellation can terminate the worker, and serialization enforces the value boundary. Worker threads are not a security boundary. Invalid options fail rather than degrading to Claude Code's `null`; consumers retain control through the run handle while observers receive snapshots only.
Fan-out plans now live in rerunnable scripts, and `outputSchema` provides authoritative structured child results. Each run pays worker startup and message-port RPC costs, but host startup stays non-blocking, cancellation can terminate the worker, and serialization enforces the value boundary. Worker threads are not a security boundary. Invalid options fail rather than degrading to Claude Code's `null`; consumers retain control through the run handle while observers receive snapshots only. Top-level Web users also receive a durable, replayable workflow record without widening the execution seam or coupling the original tool card to workflow-specific UI.

View File

@@ -40,6 +40,8 @@ harness 可以将一个任务委派给一个子 agent`dsh-tool-subagent`
一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态启动、await、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed``isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述即面向模型的编写规范。使用策略以工具自身的 `tool:<toolName>` 提示词段落随工具发布(显式请求才使用的引导——工具引导存在于工具插件中,从不在部署 persona 中harness 没有 ultracode 风格的 effort 门控。
对于顶层工具执行,同一消费方还会把运行及真正开始过的成员生命周期写入调用方父 Session形成四类 log-only `tool-workflow/*` 事件。记录路径只观察、不控制执行:第一次 append 失败会禁用本运行后续写入并留下合法前缀,不改变工具结果。[`ui-workflow-run`](../../../../packages/client/ui-workflow-run/README.md) 通过 Conversation Node 引擎重建这些事实,形成独立 keyed Chat 行;现有 generic 工具行继续拥有自己的展示。持久化、回放、折叠与实时导航的详细决策见 [Chat 中的持久工作流运行](2026-08-10-durable-workflow-runs-in-chat.md)。
### 基础subagent seam 上的结构化输出
`SubagentStartRequest.outputSchema``dsh-subagent-inprocess` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同的 schema 而不共享可变策略dispose 子 agent 时移除整个附件。
@@ -60,7 +62,6 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够
- **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都会明确拒绝,并在消息中注明其已延迟实现)。
- **整体运行的挂钟超时**取消总能释放调用方result 在宽限期内 settle因此总运行时间上限是后台重设计的策略旋钮不是此处的正确性需求。
- **超越 worker 线程的引擎加固**:在同一 seam 背后使用 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。
- **面向人类界面的进度 UI**(基于 `workflow/*` 事件的 `/workflows` 风格视图);事件已为此而存在。
- **ACPAgent Client Protocol后端结构化输出**和 **`toolFilter`**(两者仍以能力标志 `false` 门控)。
## 曾考虑的替代方案
@@ -77,4 +78,4 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够
## 后果
扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和消息端口 RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker序列化强制执行值边界。worker 线程不是安全边界。无效选项会失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。
扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和消息端口 RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker序列化强制执行值边界。worker 线程不是安全边界。无效选项会失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。顶层 Web 用户还会得到持久、可回放的工作流记录,同时不扩宽执行 seam也不把原工具卡耦合到工作流专属 UI。

View File

@@ -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-08-10-durable-workflow-runs-in-chat.md
2026-08-10-durable-workflow-runs-in-chat.md: 791a81e9e304a11f45557197ac1f97184132ccab
2026-08-10-durable-workflow-runs-in-chat.zh.md: e6c87f61a144cebc0282055c8ae315d9068616fd

View File

@@ -0,0 +1,45 @@
# Agent Note: Durable workflow runs in Chat
Status: implemented
English | [中文](2026-08-10-durable-workflow-runs-in-chat.zh.md)
## Problem
The ordinary workflow tool row owns the model call and final tool result, but those two records do not explain which members actually started, how they were grouped, whether each member completed, failed, or was cancelled, or what remained unfinished when a process stopped. Live `workflow/*` events expose those facts only inside the current process, so a refresh or later Session open loses the run history.
The Web Client already assembles business-owned Conversation Nodes from durable Session events. Workflow history therefore needs a producer that can correlate one accepted run with its calling Session, a minimal durable protocol that remains meaningful as a prefix, and an independent renderer that does not take ownership away from the existing tool card.
## Decision
`dsh-tool-workflow` projects every top-level accepted run into the calling Agent's Session. `tool-workflow/run-start` records the stable `runId` and validated name; matching workflow member events record the member sequence, exact label, optional exact phase, child Session id, and outcome; `tool-workflow/run-end` records the stop reason only after the result exists and `run.dispose()` has reached quiescence. Nested transport executions run normally but write no workflow record because they do not own an independent Chat row.
Recording is observational. The first failed Session append disables all later writes for that run, logs one warning, and never changes cancellation, result mapping, or disposal. Each possible failure leaves either no record or a legal continuous prefix: a started run may lack later members or its ending, and a started member may lack its ending. The package invariant rejects duplicate run starts, invalid or reused positive member sequences, unpaired or repeated member endings, a run ending while members remain open, and every update after a run ending on both cold load and live append.
The workflow package exposes browser-safe run and observation vocabulary through `@deepseek-ai/dsh-workflow/types`; live `Agent` requests and control handles remain Host-only. `@deepseek-ai/dsh-tool-workflow/types` owns the four Session events. Client code imports only these type faces, so the Host and Client TypeScript programs share the durable contract without merging Host Cordis context.
`ui-workflow-run` registers one `workflow-run` Conversation Definition and one keyed Chat renderer. Every event independently yields the same `runId`; run-start initializes State, later events update it in log order, and an update-only history tail remains pending until prepend supplies the unique start. The final node keeps the engine-owned key and anchors at run-start, placing it after the original tool call while preserving one React parent from running through terminal state.
The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present.
Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static.
The [seven-state Figma reference](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2) fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner.
## Verification
Package tests cover top-level and nested eligibility, zero-member and concurrent runs, disposal-before-ending order, all four append-failure prefixes, and cold/live invariant rejection. Conversation tests compare complete replace, update-only prepend, and live append; they cover exact phase identity, terminal and interrupted status, disclosure state, list-fact navigation, and HMR removal and re-registration. The shipped Web replay uses the existing workflow parent and child model fixtures to exercise the real worker, spawn provider, Session persistence, browser bundle, running child navigation, terminal retention, original tool-row coexistence, narrow dark tokens, and refresh reconstruction.
## Alternatives considered
**Append workflow content inside the existing tool card.** Rejected because `ui-tool` and the tool definition own that row's presentation and interaction. A workflow-specific appendix would couple two independently keyed business lifecycles and revive the removed post-tool attachment model.
**Persist a server-side projection or add a workflow wire channel.** Rejected because Session events already provide persistence, live delivery, pagination, and gap repair. Another service, cache, or transport would duplicate the same facts and create a second lifecycle owner.
**Render declared phases or infer a static workflow graph from script text.** Rejected because only member-start events prove work happened. `meta.phases`, `phase()` narration, branches, and script syntax do not describe one authoritative runtime topology.
**Keep terminal child navigation.** Rejected because the workflow record proves historical identity, not current accessibility. Cold or remote Session opening needs a separate catalog and authorization contract; this node grants no such promise.
## Consequences
Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, disclosure choices remain local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening.

View File

@@ -0,0 +1,45 @@
# Agent Note: Chat 中的持久工作流运行
Status: implemented
[English](2026-08-10-durable-workflow-runs-in-chat.md) | 中文
## 问题
普通工作流工具行拥有模型调用与最终工具结果,但这两条记录无法说明哪些成员真正开始、如何分组、各成员是完成、失败还是取消,也无法说明进程停止时哪些工作尚未结束。实时 `workflow/*` 事件只存在于当前进程,因此刷新或稍后重新打开 Session 会丢失运行历史。
Web Client 已经能够从持久 Session 事件组装由业务拥有的 Conversation Node。工作流历史因此需要能够把一次已接受运行关联到调用 Session 的生产方、作为前缀也始终有意义的最小持久协议,以及不夺走现有工具卡所有权的独立 renderer。
## 决策
`dsh-tool-workflow` 把每个已接受的顶层运行投影到调用 Agent 的 Session。`tool-workflow/run-start` 记录稳定 `runId` 与已校验名称;匹配的工作流成员事件记录成员序号、精确标签、可选精确阶段、子 Session id 与结果;只有在结果已取得且 `run.dispose()` 完全停稳后,`tool-workflow/run-end` 才记录停止原因。嵌套 transport 执行照常运行,但不会写工作流记录,因为它不拥有独立 Chat 行。
记录只供观察。任一次 Session append 首次失败后,本运行会停止所有后续写入、只记录一次告警,并且绝不改变取消、结果映射或 dispose。每种失败位置都留下空记录或合法连续前缀已开始运行可以缺少后续成员或运行终点已开始成员也可以缺少成员终点。包 invariant 会在冷加载与实时 append 时拒绝重复运行 start、无效或复用的正成员序号、无配对或重复成员 end、仍有开放成员时结束运行以及运行结束后的任何更新。
workflow 包通过 `@deepseek-ai/dsh-workflow/types` 提供浏览器安全的运行与观察词汇;包含活跃 `Agent` 的请求和控制句柄继续只属于 Host。`@deepseek-ai/dsh-tool-workflow/types` 拥有四类 Session 事件。Client 只导入这些类型 face因此 Host 与 Client TypeScript 程序共享持久合同,而不会合并 Host Cordis Context。
`ui-workflow-run` 注册一个 `workflow-run` Conversation Definition 和一个 keyed Chat renderer。每条事件都能独立给出同一 `runId`run-start 初始化 State后续事件按日志顺序更新只有 update 的历史尾页会保持 pending直到 prepend 补入唯一 start。最终节点保留引擎拥有的 key并以 run-start 锚定在原工具调用之后,从运行中到终态始终保留同一个 React 父级。
renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron并以内联状态点加状态文字表达结局不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。
导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'``parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环固定状态列继续只表达生命周期而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。
[七状态 Figma 参考](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2)固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow``StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。
## 验证
包测试覆盖顶层与嵌套准入、零成员与并发运行、先 dispose 后写终点的顺序、四个 append 失败前缀,以及冷/实时 invariant 拒绝。Conversation 测试比较完整 replace、只有 update 的 prepend 和实时 append并覆盖精确阶段身份、终态与中断状态、disclosure 状态、列表事实导航、HMR 移除与重新注册。shipped Web replay 复用现有工作流父/子模型 fixture驱动真实 worker、spawn provider、Session 持久化、浏览器 bundle、运行中子级导航、终态保留、原工具行并存、暗色窄列 token 与刷新重建。
## 曾考虑的替代方案
**把工作流内容附加到现有工具卡。** 拒绝,因为 `ui-tool` 与工具定义拥有该行的展示和交互。工作流专属 appendix 会耦合两个独立 keyed 业务生命周期,并恢复已移除的工具后附加模型。
**持久化服务端 projection 或新增 workflow wire 通道。** 拒绝,因为 Session 事件已经提供持久化、实时传输、分页和 gap repair。另一个 service、cache 或 transport 会复制同一事实并建立第二个生命周期 owner。
**展示声明阶段,或从脚本文本推断静态工作流图。** 拒绝,因为只有成员 start 事件能证明工作真正发生。`meta.phases``phase()` 叙述、分支和脚本语法都不是一次运行的权威拓扑。
**保留终态子级导航。** 拒绝,因为工作流记录证明历史身份,不证明当前可访问性。冷 Session 或远程 Session 的打开需要独立目录与授权合同;本节点不作这种承诺。
## 后果
工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant首次写入失败会刻意牺牲后续观察而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生disclosure 选择保持本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。

View File

@@ -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/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md
2026-07-12-collapse-workflow-to-foreground-core.md: 5fc46584f83eb5307ff16f3353b56951b928aef3
2026-07-12-collapse-workflow-to-foreground-core.zh.md: 0b4c73e5df973215b10166f3dc2bbd525cc8231b
2026-07-12-collapse-workflow-to-foreground-core.md: 9151d9fb72a97aadf040fbdc13b5e0a4943f2f30
2026-07-12-collapse-workflow-to-foreground-core.zh.md: c9eafe83e931de7aec4ec39e2471f0669c73609d

View File

@@ -6,15 +6,11 @@ English | [中文](2026-07-12-collapse-workflow-to-foreground-core.zh.md)
## Problem
The workflow capability executes foreground JavaScript that composes subagents, but it also carries an unconsumed progress-observation system. No production listener subscribes to any of the six `workflow/*` events; listeners exist only in workflow tests. Nevertheless the seam defines run/phase/agent outcome payloads, the worker sends phase/log/agent lifecycle protocol messages, the host forwards them through a `liveAgents` pairing ledger, and the engine maintains run ids solely to correlate those notifications.
The workflow capability carries an observe-only lifecycle beside its execution handle. That surface can look removable because the script still completes without a UI listener, but it is the only provider-neutral source of the actual members that started, their exact labels and phases, and their paired outcomes.
The progress vocabulary is not merely unused; it cannot serve its only named future owner without redesign. `WorkflowRunInfo` contains `{id, meta}` but no parent agent, session, or tool-call identity, while the model-facing tool never exposes the run id. A global ACP listener could not route an event to the correct client session. `meta.phases` is never consulted, `phase(title)` does not validate against it, phase `detail`/`model` and agent `label`/`phase` feed only events, and `whenToUse` is validated and copied but never rendered or selected. `phase()` and `log()` still cross the worker boundary despite having no receiver.
The top-level `dsh-tool-workflow` consumer now uses those events to write four minimal `tool-workflow/*` facts into the calling parent Session, and `ui-workflow-run` rebuilds them into a durable Chat node. The consumer deliberately owns the projection because it alone holds the calling Agent, knows whether the tool execution is top-level, and can keep recording failure separate from workflow execution. `WorkflowRun.id` and `meta` therefore correlate live engine events with that exact durable record rather than duplicating presentation state.
The live handle repeats event-era data after those observers disappear. `WorkflowRun.id` has no non-event consumer, while the tool reads `run.meta.name` only to render a value it already owns as `args.meta.name`; neither belongs on the execution/cancellation handle.
Cancellation also has two public channels for one synchronous start. `WorkflowStartRequest.signal` is passed to the worker host, while the sole production caller separately bridges the same signal to `WorkflowRun.cancel()`. Because `start()` returns the run before control can yield, there is no readiness window that requires request-time cancellation; the duplicate signal adds host listener/disarm state without closing a race.
`WorkflowError.fatal` is the same speculative branch in miniature: every production construction is fatal, `fatal: false` exists only in tests, and combinators already distinguish workflow failures with `instanceof`.
Deleting the event vocabulary, member labels or phases, or run identity would remove the current replay and navigation result rather than merely simplify unused scaffolding. The rejected proposal below remains useful as the contraction to avoid; [durable workflow runs in Chat](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md) owns the present consumer and boundaries.
## Proposal
@@ -24,7 +20,7 @@ Amend the implemented dynamic-workflow Agent Note and update the seam/tool/worke
## Alternatives considered
**Keep the prebuilt observation vocabulary for a future UI.** The current shape resembles Claude Code dynamic-workflow metadata, and the host deliberately pairs each forwarded agent start with either the worker's end or a synthesized terminal end. Removing it gives up compatibility-by-shape and makes progress UI a new design task, but the existing payloads still lack routable ownership, so balanced lifecycles alone cannot make the named ACP owner viable without redesign.
**Move durable recording into the workflow engine.** The engine knows run and member lifecycle but does not own the calling parent Session or the top-level-versus-nested tool boundary. Giving it those facts would couple a provider seam to one consumer and make recording failure part of engine execution. The tool-owned projection adds the missing ownership without widening worker messages or the service contract.
## Acceptance criteria

View File

@@ -6,15 +6,11 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通
## 问题
工作流能力在前台执行用于编排 subagent 的 JavaScript但它同时携带一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个监听器仅存在于工作流测试中。尽管如此seam 定义了 run/phase/agent智能体outcome 载荷worker 发送 phase/log/agent 生命周期协议消息host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知
工作流能力在执行句柄之外还携带一套只供观察的生命周期。脚本即使没有 UI 监听器也能完成,因此这套界面看似可删除;但它是唯一与提供方无关、能够报告真正开始过的成员、精确标签与阶段以及配对结果的事实来源
这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent、会话或工具调用标识而面向模型的工具也从不暴露 run id。一个全局 ACPAgent Client Protocol监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()``log()` 仍然跨越 worker 边界,尽管没有接收方
顶层 `dsh-tool-workflow` 消费方现在利用这些事件,把四类最小 `tool-workflow/*` 事实写入调用方父 Session`ui-workflow-run` 再把它们重建为持久 Chat 节点。投影由消费方拥有,因为只有它同时持有调用 Agent、知道工具执行是顶层还是嵌套并能让记录故障与工作流执行隔离。`WorkflowRun.id``meta` 因此用于把实时引擎事件关联到该条精确持久记录,而不是复制展示状态
这些观测者移除后live handle 仍重复携带事件机制所需的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle
取消机制也为一个同步启动提供了两条公开通道。`WorkflowStartRequest.signal` 被传递给 worker host而唯一的生产调用方另外将同一个 signal 桥接到 `WorkflowRun.cancel()`。因为 `start()` 在控制权让出之前就返回了 run不存在需要请求时取消的就绪窗口重复的 signal 增加了 host 的 listener/disarm 状态却没有封堵任何竞态。
`WorkflowError.fatal` 是同一种推测性分支的微缩版:所有生产环境的构造都是 fatal 的,`fatal: false` 仅存在于测试中,组合子已经通过 `instanceof` 区分工作流失败。
删除事件词汇、成员标签或阶段、运行身份,会移除当前回放和导航结果,而不再只是清理未使用脚手架。下方提案继续记录应避免的收缩;[Chat 中的持久工作流运行](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md)拥有当前消费方与边界
## 提案
@@ -24,7 +20,7 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通
## 曾考虑的替代方案
**为未来 UI 保留预建的观测词汇。** 当前形态类似 Claude Code 的动态工作流元数据host 有意地将每个转发的 agent start 与 worker 的 end 或一个合成的终止 end 配对。移除它意味着放弃形态兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让已命名的 ACP 消费方可行
**把持久记录移入工作流引擎。** 引擎知道运行与成员生命周期,却不拥有调用方父 Session也不知道顶层与嵌套工具边界。把这些事实交给引擎会让提供方 seam 耦合到单一消费方,并使记录故障进入引擎执行域。由工具拥有的投影补齐了缺失所有权,同时不扩展 worker 消息或 service 合同
## 验收标准

View File

@@ -27,6 +27,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
{ id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
bundlePath: 'packages/client/ui-workspace/lib/client.js',

View File

@@ -0,0 +1,55 @@
- banner:
- navigation "Session hierarchy":
- button "Use the workflow tool exactly" [disabled]
- button "1 subagent":
- text: 1 subagent
- img
- img
- text: 标准模式
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}"
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:":
- img
- img
- text: "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:"
- button "Tool call workflow ·":
- img
- img
- text: Tool call workflow ·
- button "snapshot-flow 1 members Completed" [expanded]:
- img
- text: snapshot-flow 1 members Completed
- button "Run 1 members Completed 1" [expanded]:
- img
- text: Run 1 members Completed 1
- text: Reply with exactly the word WF_CHILD_OK and not… Completed
- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.":
- img
- img
- text: Think The workflow returned successfully with the reply "WF_CHILD_OK". Now I need to reply with exactly "WORKFLOW_DONE" and stop.
- paragraph: WORKFLOW_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- button "Back to bottom":
- img
- textbox "Message the agent"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "3% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 47% Input 6.6K tok · Output 227 tok

View File

@@ -0,0 +1,170 @@
// Keyless shipped-Web acceptance for the durable workflow Conversation Node.
// Reuses the existing recorded workflow parent/child model fixtures; the real
// workflow tool, worker, subagent provider, Session log, browser plugin graph,
// and navigation all execute during replay.
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import {
connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot,
} from './support.ts'
const MODE = webSnapshotMode()
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workflow-run', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const PARENT_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.jsonl')
const CHILD_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl')
const CHILD_PROMPT = 'Reply with exactly the word WF_CHILD_OK and nothing else.'
describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let prompt: string
const waitForParentSettlement = (): Promise<SessionId> => new Promise((resolve, reject) => {
let dispose = (): void => {}
dispose = scaffold.ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type !== 'turn/end' || session.header.origin === 'subagent') return
dispose()
void (async () => {
await scaffold.ctx.agents.get(session.id)?.whenIdle()
await scaffold.ctx.sessions.flush(session)
resolve(session.id)
})().catch(reject)
})
})
beforeAll(async () => {
const prompts = fixtureUserPrompts(await readFile(PARENT_FIXTURE, 'utf8'))
expect(prompts).toHaveLength(1)
prompt = prompts[0]!
scaffold = await launchWebScaffold({
replayFixture: PARENT_FIXTURE,
replayChildFixtures: [CHILD_FIXTURE],
paceMs: 25,
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live'))
const settled = waitForParentSettlement()
const input = page.locator('textarea').first()
await input.fill(prompt)
await input.press('Enter')
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
await workflow.waitFor({ timeout: 30_000 })
expect(await workflow.getAttribute('aria-expanded')).toBe('true')
const phase = page.getByRole('button', { name: /^Run/ })
await phase.waitFor({ timeout: 15_000 })
await phase.click()
const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ })
await member.waitFor({ timeout: 15_000 })
await member.focus()
const lightColor = await member.locator('[data-member-label]').evaluate(element => getComputedStyle(element).color)
await page.setViewportSize({ width: 560, height: 800 })
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const darkNarrow = await page.locator('[data-workflow-run]').evaluate((element) => {
const panel = element as HTMLElement
panel.style.width = '356px'
const label = element.querySelector('[data-member-label]')
const labelWrap = element.querySelector('[data-member-label-wrap]')
const status = element.querySelector('[data-member-status-text]')
const runHeader = element.querySelector('[data-run-header]')
const phaseHeader = element.querySelector('[data-phase-header]')
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
color: label === null ? '' : getComputedStyle(label).color,
decoration: label === null ? '' : getComputedStyle(label).textDecorationLine,
focusWidth: labelWrap === null ? '' : getComputedStyle(labelWrap).outlineWidth,
statusWidth: status?.getBoundingClientRect().width ?? 0,
statusFontSize: status === null ? '' : getComputedStyle(status).fontSize,
runHeight: runHeader?.getBoundingClientRect().height ?? 0,
phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0,
}
})
expect(darkNarrow.clientWidth).toBe(356)
expect(darkNarrow.scrollWidth).toBeLessThanOrEqual(darkNarrow.clientWidth)
expect(darkNarrow.color).not.toBe(lightColor)
expect(darkNarrow.decoration).toContain('underline')
expect(Number.parseFloat(darkNarrow.focusWidth)).toBeGreaterThanOrEqual(2)
expect(darkNarrow.statusWidth).toBe(64)
expect(darkNarrow.statusFontSize).toBe('13px')
expect(darkNarrow.runHeight).toBe(32)
expect(darkNarrow.phaseHeight).toBe(32)
await page.locator('[data-workflow-run]').evaluate((element) => {
(element as HTMLElement).style.removeProperty('width')
document.body.removeAttribute('data-ds-dark-theme')
})
await page.setViewportSize({ width: 1280, height: 800 })
await member.click()
await page.getByText(CHILD_PROMPT, { exact: true }).waitFor({ timeout: 15_000 })
const sessions = page.getByRole('tree', { name: 'Sessions' })
await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
await settled
expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1)
const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ })
await terminalWorkflow.waitFor()
if (await terminalWorkflow.getAttribute('aria-expanded') !== 'true') await terminalWorkflow.click()
const terminalPhase = page.getByRole('button', { name: /^Run/ })
await terminalPhase.waitFor()
if (await terminalPhase.getAttribute('aria-expanded') !== 'true') await terminalPhase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
await expect.poll(
() => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(),
{ timeout: 10_000 },
).toBe(0)
}, 90_000)
it('rebuilds the terminal record from history after reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history'))
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
await workflow.waitFor({ timeout: 15_000 })
expect(await workflow.getAttribute('aria-expanded')).toBe('false')
await workflow.click()
const phase = page.getByRole('button', { name: /^Run/ })
await phase.waitFor()
await phase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
}, 60_000)
it('stays clean and owns only its one golden', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -82,7 +82,8 @@
"tests/chat-continuous-conversation.e2e.ts",
"tests/composer-tab-geometry.e2e.ts",
"tests/complex-history.perf.ts",
"tests/pwsh-terminal.e2e.ts"
"tests/pwsh-terminal.e2e.ts",
"tests/workflow-run.e2e.ts"
],
"references": [
{

View File

@@ -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 docs/config-catalog.md
config-catalog.md: 51c6ae46eeca1279390c9d9315a6161edd2de618
config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df
config-catalog.md: d9b70eb15865b45d0d8251789d6d661cd9747024
config-catalog.zh.md: 4974a7e53c60507c2dced9a93cb5e2a2ba0ed850

View File

@@ -2412,7 +2412,7 @@ export interface Config {
}
```
Source: [`packages/workflow/tool-workflow/src/index.ts:27`](../packages/workflow/tool-workflow/src/index.ts)
Source: [`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts)
## `@deepseek-ai/dsh-tools`
@@ -2725,6 +2725,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
- `@deepseek-ai/dsh-client-ui-tool` ([`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts))
- `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts))
- `@deepseek-ai/dsh-client-ui-workflow-run` ([`packages/client/ui-workflow-run/src/index.ts`](../packages/client/ui-workflow-run/src/index.ts))
- `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts))
- `@deepseek-ai/dsh-command-compact` — requires `commands` · `compact` ([`packages/compact/command-compact/src/index.ts`](../packages/compact/command-compact/src/index.ts))
- `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts))

View File

@@ -2413,7 +2413,7 @@ export interface Config {
}
```
来源:[`packages/workflow/tool-workflow/src/index.ts:27`](../packages/workflow/tool-workflow/src/index.ts)
来源:[`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts)
## `@deepseek-ai/dsh-tools`
@@ -2726,6 +2726,7 @@ export interface Config {
- `@deepseek-ai/dsh-client-ui-theme`[`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)
- `@deepseek-ai/dsh-client-ui-tool`[`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts)
- `@deepseek-ai/dsh-client-ui-trajectory`[`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)
- `@deepseek-ai/dsh-client-ui-workflow-run`[`packages/client/ui-workflow-run/src/index.ts`](../packages/client/ui-workflow-run/src/index.ts)
- `@deepseek-ai/dsh-client-ui-workspace`[`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)
- `@deepseek-ai/dsh-command-compact` — 需要 `commands` · `compact`[`packages/compact/command-compact/src/index.ts`](../packages/compact/command-compact/src/index.ts)
- `@deepseek-ai/dsh-command-feedback` — 需要 `commands`[`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)

View File

@@ -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 docs/event-producer-consumer.md
event-producer-consumer.md: b78171ce51931f02a3f39ef98104ea9dedc27360
event-producer-consumer.zh.md: c044385bf91559f5c4f82d99601642b932066e7f
event-producer-consumer.md: 0963d996b50a363d434ede40a877cb89d8ba9923
event-producer-consumer.zh.md: d258f775e8724d18f64ea4ed77c39ae74a73f45c

View File

@@ -30,9 +30,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
@@ -50,12 +50,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
## Non-harness or undeclared event strings seen in package source
@@ -64,7 +64,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `commands/changed` | `runtime` (`emit`) | `ui-command` |
| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
| `credentials/changed` | `runtime` (`emit`) | `ui-models` |
| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` |
| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent) |

View File

@@ -32,9 +32,9 @@
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
@@ -51,13 +51,13 @@
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
## Non-harness or undeclared event strings seen in package source
@@ -66,7 +66,7 @@
| `commands/changed` | `runtime` (`emit`) | `ui-command` |
| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
| `credentials/changed` | `runtime` (`emit`) | `ui-models` |
| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` |
| `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets)、`gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent) |

View File

@@ -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 docs/module-graph.md
module-graph.md: 6763ca6e84a5cc2e5e776a56e5cfc20b710017aa
module-graph.zh.md: 339345b39a8225c34ecf0ba73efff34d4b940046
module-graph.md: bf1b3cb36de9071ca1d4d6d0b8795a28435916c6
module-graph.zh.md: 3deb62f24c0c319922f80d33f5a7d726bf0a2d9f

View File

@@ -169,6 +169,7 @@ flowchart TD
pkg_client_ui_theme["client-ui-theme"]
pkg_client_ui_tool["client-ui-tool"]
pkg_client_ui_trajectory["client-ui-trajectory"]
pkg_client_ui_workflow_run["client-ui-workflow-run"]
pkg_client_ui_workspace["client-ui-workspace"]
pkg_client_web["client-web"]
pkg_client_web_react["client-web-react"]
@@ -969,6 +970,7 @@ flowchart TD
pkg_tool_workflow --> pkg_agent
pkg_tool_workflow --> pkg_invariants
pkg_tool_workflow --> pkg_llm
pkg_tool_workflow --> pkg_session
pkg_tool_workflow --> pkg_system_prompt
pkg_tool_workflow --> pkg_tools
pkg_tool_workflow --> pkg_workflow
@@ -1160,6 +1162,15 @@ flowchart TD
pkg_client_ui_tool --> pkg_client_ui_primitives
pkg_client_ui_tool --> pkg_client_ui_slots
pkg_client_ui_tool --> pkg_invariants
pkg_client_ui_workflow_run --> pkg_client_locale
pkg_client_ui_workflow_run --> pkg_client_runtime
pkg_client_ui_workflow_run --> pkg_client_ui_conversation
pkg_client_ui_workflow_run --> pkg_client_ui_primitives
pkg_client_ui_workflow_run --> pkg_client_ui_slots
pkg_client_ui_workflow_run --> pkg_invariants
pkg_client_ui_workflow_run --> pkg_session
pkg_client_ui_workflow_run --> pkg_tool_workflow
pkg_client_ui_workflow_run --> pkg_workflow
pkg_agent_spine_demo --> pkg_agent
pkg_agent_spine_demo --> pkg_agent_loop
pkg_agent_spine_demo --> pkg_bash_env
@@ -1412,7 +1423,7 @@ flowchart TD
| [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
@@ -1440,6 +1451,7 @@ flowchart TD
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) |

View File

@@ -171,6 +171,7 @@ flowchart TD
pkg_client_ui_theme["client-ui-theme"]
pkg_client_ui_tool["client-ui-tool"]
pkg_client_ui_trajectory["client-ui-trajectory"]
pkg_client_ui_workflow_run["client-ui-workflow-run"]
pkg_client_ui_workspace["client-ui-workspace"]
pkg_client_web["client-web"]
pkg_client_web_react["client-web-react"]
@@ -971,6 +972,7 @@ flowchart TD
pkg_tool_workflow --> pkg_agent
pkg_tool_workflow --> pkg_invariants
pkg_tool_workflow --> pkg_llm
pkg_tool_workflow --> pkg_session
pkg_tool_workflow --> pkg_system_prompt
pkg_tool_workflow --> pkg_tools
pkg_tool_workflow --> pkg_workflow
@@ -1162,6 +1164,15 @@ flowchart TD
pkg_client_ui_tool --> pkg_client_ui_primitives
pkg_client_ui_tool --> pkg_client_ui_slots
pkg_client_ui_tool --> pkg_invariants
pkg_client_ui_workflow_run --> pkg_client_locale
pkg_client_ui_workflow_run --> pkg_client_runtime
pkg_client_ui_workflow_run --> pkg_client_ui_conversation
pkg_client_ui_workflow_run --> pkg_client_ui_primitives
pkg_client_ui_workflow_run --> pkg_client_ui_slots
pkg_client_ui_workflow_run --> pkg_invariants
pkg_client_ui_workflow_run --> pkg_session
pkg_client_ui_workflow_run --> pkg_tool_workflow
pkg_client_ui_workflow_run --> pkg_workflow
pkg_agent_spine_demo --> pkg_agent
pkg_agent_spine_demo --> pkg_agent_loop
pkg_agent_spine_demo --> pkg_bash_env
@@ -1414,7 +1425,7 @@ flowchart TD
| [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
@@ -1442,6 +1453,7 @@ flowchart TD
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) |

View File

@@ -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 docs/persistence-catalog.md
persistence-catalog.md: f44569d3bacec0a832f4b4bca6acf4abb0846a0d
persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4
persistence-catalog.md: 34803a69f11a964ccc6da3e74eb678fe398f94c8
persistence-catalog.zh.md: 3146712e342519fdf687d5b18bd1c95c8c0f0a37

View File

@@ -716,6 +716,56 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types
Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts)
### `tool-workflow/*`
#### `tool-workflow/agent-end` — log-only
```ts persistence-catalog
/**
* Records one member settlement.
* @param data - run identity, paired member sequence, and outcome.
*/
'tool-workflow/agent-end': ToolWorkflowAgentEndData
```
Source: [`packages/workflow/tool-workflow/src/types.ts:57`](../packages/workflow/tool-workflow/src/types.ts)
#### `tool-workflow/agent-start` — log-only
```ts persistence-catalog
/**
* Records one published workflow member.
* @param data - run identity, member sequence, display identity, and child Session.
*/
'tool-workflow/agent-start': ToolWorkflowAgentStartData
```
Source: [`packages/workflow/tool-workflow/src/types.ts:52`](../packages/workflow/tool-workflow/src/types.ts)
#### `tool-workflow/run-end` — log-only
```ts persistence-catalog
/**
* Closes one workflow record after cleanup.
* @param data - stable run identity and terminal reason.
*/
'tool-workflow/run-end': ToolWorkflowRunEndData
```
Source: [`packages/workflow/tool-workflow/src/types.ts:62`](../packages/workflow/tool-workflow/src/types.ts)
#### `tool-workflow/run-start` — log-only
```ts persistence-catalog
/**
* Opens one top-level workflow record.
* @param data - stable run identity and display name.
*/
'tool-workflow/run-start': ToolWorkflowRunStartData
```
Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow/tool-workflow/src/types.ts)
### `turn/*`
#### `turn/end` — log-only

View File

@@ -718,6 +718,56 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
来源:[`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts)
### `tool-workflow/*`
#### `tool-workflow/agent-end` — log-only
```ts persistence-catalog
/**
* Records one member settlement.
* @param data - run identity, paired member sequence, and outcome.
*/
'tool-workflow/agent-end': ToolWorkflowAgentEndData
```
来源:[`packages/workflow/tool-workflow/src/types.ts:57`](../packages/workflow/tool-workflow/src/types.ts)
#### `tool-workflow/agent-start` — log-only
```ts persistence-catalog
/**
* Records one published workflow member.
* @param data - run identity, member sequence, display identity, and child Session.
*/
'tool-workflow/agent-start': ToolWorkflowAgentStartData
```
来源:[`packages/workflow/tool-workflow/src/types.ts:52`](../packages/workflow/tool-workflow/src/types.ts)
#### `tool-workflow/run-end` — log-only
```ts persistence-catalog
/**
* Closes one workflow record after cleanup.
* @param data - stable run identity and terminal reason.
*/
'tool-workflow/run-end': ToolWorkflowRunEndData
```
来源:[`packages/workflow/tool-workflow/src/types.ts:62`](../packages/workflow/tool-workflow/src/types.ts)
#### `tool-workflow/run-start` — log-only
```ts persistence-catalog
/**
* Opens one top-level workflow record.
* @param data - stable run identity and display name.
*/
'tool-workflow/run-start': ToolWorkflowRunStartData
```
来源:[`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow/tool-workflow/src/types.ts)
### `turn/*`
#### `turn/end` — log-only

View File

@@ -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 docs/subsystems/workflow.md
workflow.md: 22dcaad608cc2ca7f407b8837fc3856abcc43555
workflow.zh.md: 7ccd47f414ad574f2daa8e74f6cfb65abfbe06c2
workflow.md: b651a5459d4ff8c71de223ca2b51dca997ab86bf
workflow.zh.md: 0fd32675c8612dfeee1dbce7cd8e9977bbe330ef

View File

@@ -6,7 +6,7 @@ The workflow seam lets an agent run a model-written orchestration SCRIPT that st
Service Definition: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The Service provider is [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread) (a `node:worker_threads` engine — one worker per run, the script's vm context inside it); the model-facing Consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md).
Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts)
Sources: browser-safe vocabulary in [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts), Host request and live-run handles in [`runtime-types.ts`](../../packages/workflow/workflow/src/runtime-types.ts).
## The start request
@@ -15,33 +15,23 @@ What a caller asks for when starting a run. The ordinary workflow tool builds th
```ts type-equiv
/**
* What a caller asks for when starting a workflow run. `meta` and `args` are
* plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call;
* the engine validates `meta` against its schema and rejects loud
* before anything runs) — an engine never evaluates script text to obtain
* them. `parent` is REQUIRED — every `agent()` the script spawns is
* attributed to it (cwd, lineage, depth flow through the subagent seam).
* plain JSON data by the seam contract. `parent` is required because every
* `agent()` spawned by the script is attributed to that live Agent.
*/
interface WorkflowStartRequest {
/** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */
script: string
/** The workflow's identity fields as plain JSON data, validated by the engine. */
/** The workflow's identity block, as plain JSON data (shape-validated by the engine). */
meta: WorkflowMeta
/** Optional input exposed verbatim to the script as the `args` global. */
args?: unknown
/**
* Optional engine-wide child-provider override for this run. The workflow
* script cannot observe or replace it; omission uses the engine's configured
* provider.
*/
/** Optional engine-wide child-provider override for this run. */
subagentProvider?: string
/**
* Optional per-run total-child ceiling. Implementations reject values above
* their deployment ceiling before publishing the run.
*/
/** Optional per-run total-child ceiling. */
maxTotalAgents?: number
/** The agent on whose behalf the run executes (parent of every child). */
parent: Agent
/** Cancels the run when aborted (the tool's `exec.signal`). */
/** Cancels the run when aborted. */
signal?: AbortSignal
}
```
@@ -76,7 +66,7 @@ The outcome of one run, resolved by `WorkflowRun.result`. `value` is the script'
```ts type-equiv
/**
* The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is
* The outcome resolved by a live workflow run. `value` is
* the script's materialized return value (plain host-realm JSON data; `null`
* when the script returned `undefined`) — meaningful only for `completed`.
* A non-`completed` reason carries the failure in `error`; the consumer maps
@@ -106,19 +96,17 @@ The handle the consumer holds while a script executes. The consumer awaits `resu
```ts type-equiv
/**
* Holder-owned live workflow. `result` never rejects and settles within the
* engine's cancellation grace; failures resolve through `stopReason`. Consumers
* may cancel and must call idempotent `dispose()` on every path to await bounded
* script settlement and child quiescence.
* Holder-owned live workflow. `result` never rejects; consumers may cancel
* and must call idempotent `dispose()` to await script and child quiescence.
*/
interface WorkflowRun {
readonly id: WorkflowRunId
/** The validated meta block (available before the body runs). */
/** The validated meta block available before the script body runs. */
readonly meta: WorkflowMeta
readonly result: Promise<WorkflowResult>
/** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */
/** Cancel the run and its children. */
cancel(reason?: string): void
/** Cancel + bounded-grace settle; safe to call on every path (idempotent). */
/** Cancel if needed and await bounded settlement and cleanup. */
dispose(): Promise<void>
}
```
@@ -131,6 +119,14 @@ Hook misuse inside a script — bad arguments, unknown/deferred `agent()` option
The `workflow/*` events (`workflow/start`, `workflow/phase`, `workflow/log`, `workflow/agent-start`, `workflow/agent-end`, `workflow/end` — see the [events catalog](#cordis-surface)) are **observe-only** emits carrying DATA SNAPSHOTS: every payload starts with `WorkflowRunInfo` (id + meta), never the live `WorkflowRun`, so a subscriber cannot gain `cancel`/`dispose`, and `workflow/end` deliberately omits the result value (a listener observing outcomes must not receive a mutable alias of the caller's result). Every emit is per-listener contained — a throwing subscriber is logged, never propagated, and cannot starve the listeners registered after it — and every listener receives its own payload clone, so mutating it corrupts neither the engine nor other listeners; the containment mirrors `subagent/start`/`subagent/end`.
## Durable Chat records
The top-level `dsh-tool-workflow` consumer projects display facts into its calling parent Session without changing execution ownership. It writes `tool-workflow/run-start` after a run is accepted, pairs member start and end by `runId + seq`, and writes `tool-workflow/run-end` only after the result is known and disposal reaches quiescence. Nested transport calls write no record. The first append failure disables later writes for that run, so the log remains empty or a legal continuous prefix and the tool result is unchanged.
`dsh-tool-workflow/invariant` validates the same protocol before live commit and when a Session is loaded: one start per run, positive unique member sequences, paired member endings, no run ending with open members, and no updates after the run ending. A missing member ending or run ending at the log tail is valid interruption evidence rather than corruption.
`dsh-client-ui-workflow-run` folds the four events through the Conversation Node engine into one `workflow-run` Chat node anchored at the run-start sequence, after the original workflow tool node. Phase groups come only from actual member starts and preserve exact strings, including the distinction between an omitted phase and `''`. Closed Locations turn missing terminal facts into interrupted presentation. The 32-pixel run row uses module-platform background, persistent chevrons, and inline dot plus status text; 32-pixel phase rows keep title and count in the main area and precise aggregate status in a fixed tail without another dot; members use a 16-pixel dot slot and fixed 64-pixel lifecycle column. Underlined names alone mark navigation while the member and current list both prove a running same-parent local subagent.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -155,7 +151,7 @@ Workflow Service Definition contract. Invalid requests throw before publication;
abstract start(request: WorkflowStartRequest): WorkflowRun
```
Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:157`](../../packages/workflow/workflow/src/index.ts)
<a id="workflow-events"></a>
@@ -181,7 +177,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:79`](../../packages/workflow/workflow/src/index.ts)
<a id="workflowagent-start--emit"></a>
@@ -202,7 +198,7 @@ One `agent()` call established a published child run. Paired with Events['workfl
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts)
<a id="workflowend--emit"></a>
@@ -223,7 +219,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:89`](../../packages/workflow/workflow/src/index.ts)
<a id="workflowlog--emit"></a>
@@ -241,7 +237,7 @@ The script emitted a narration line (a `log(message)` call).
'workflow/log'(info: WorkflowRunInfo, message: string): void
```
Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:58`](../../packages/workflow/workflow/src/index.ts)
<a id="workflowphase--emit"></a>
@@ -260,7 +256,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs
'workflow/phase'(info: WorkflowRunInfo, title: string): void
```
Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:51`](../../packages/workflow/workflow/src/index.ts)
<a id="workflowstart--emit"></a>
@@ -278,5 +274,5 @@ A workflow run started — the script's meta block validated, the body about to
'workflow/start'(info: WorkflowRunInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:43`](../../packages/workflow/workflow/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -6,7 +6,7 @@
Service Definition[dsh-workflow](../../packages/workflow/workflow)`ctx.workflows` + 下文词汇。Service provider 是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker脚本的 vm 上下文位于其中);面向模型的 Consumer 是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。
源码:[`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts)
源码:浏览器安全词汇位于 [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts)Host 请求与活跃运行句柄位于 [`runtime-types.ts`](../../packages/workflow/workflow/src/runtime-types.ts)。
## 启动请求
@@ -15,33 +15,23 @@ Service Definition[dsh-workflow](../../packages/workflow/workflow)`ctx.wor
```ts type-equiv
/**
* What a caller asks for when starting a workflow run. `meta` and `args` are
* plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call;
* the engine validates `meta` against its schema and rejects loud
* before anything runs) — an engine never evaluates script text to obtain
* them. `parent` is REQUIRED — every `agent()` the script spawns is
* attributed to it (cwd, lineage, depth flow through the subagent seam).
* plain JSON data by the seam contract. `parent` is required because every
* `agent()` spawned by the script is attributed to that live Agent.
*/
interface WorkflowStartRequest {
/** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */
script: string
/** The workflow's identity fields as plain JSON data, validated by the engine. */
/** The workflow's identity block, as plain JSON data (shape-validated by the engine). */
meta: WorkflowMeta
/** Optional input exposed verbatim to the script as the `args` global. */
args?: unknown
/**
* Optional engine-wide child-provider override for this run. The workflow
* script cannot observe or replace it; omission uses the engine's configured
* provider.
*/
/** Optional engine-wide child-provider override for this run. */
subagentProvider?: string
/**
* Optional per-run total-child ceiling. Implementations reject values above
* their deployment ceiling before publishing the run.
*/
/** Optional per-run total-child ceiling. */
maxTotalAgents?: number
/** The agent on whose behalf the run executes (parent of every child). */
parent: Agent
/** Cancels the run when aborted (the tool's `exec.signal`). */
/** Cancels the run when aborted. */
signal?: AbortSignal
}
```
@@ -76,7 +66,7 @@ interface WorkflowMeta {
```ts type-equiv
/**
* The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is
* The outcome resolved by a live workflow run. `value` is
* the script's materialized return value (plain host-realm JSON data; `null`
* when the script returned `undefined`) — meaningful only for `completed`.
* A non-`completed` reason carries the failure in `error`; the consumer maps
@@ -106,19 +96,17 @@ interface WorkflowResult {
```ts type-equiv
/**
* Holder-owned live workflow. `result` never rejects and settles within the
* engine's cancellation grace; failures resolve through `stopReason`. Consumers
* may cancel and must call idempotent `dispose()` on every path to await bounded
* script settlement and child quiescence.
* Holder-owned live workflow. `result` never rejects; consumers may cancel
* and must call idempotent `dispose()` to await script and child quiescence.
*/
interface WorkflowRun {
readonly id: WorkflowRunId
/** The validated meta block (available before the body runs). */
/** The validated meta block available before the script body runs. */
readonly meta: WorkflowMeta
readonly result: Promise<WorkflowResult>
/** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */
/** Cancel the run and its children. */
cancel(reason?: string): void
/** Cancel + bounded-grace settle; safe to call on every path (idempotent). */
/** Cancel if needed and await bounded settlement and cleanup. */
dispose(): Promise<void>
}
```
@@ -131,6 +119,14 @@ interface WorkflowRun {
`workflow/*` 事件(`workflow/start`、`workflow/phase`、`workflow/log`、`workflow/agent-start`、`workflow/agent-end`、`workflow/end`,见[事件目录](#cordis-surface))是**仅供观察**的 emit携带数据快照每个 payload 以 `WorkflowRunInfo`id + meta开头而非活跃的 `WorkflowRun`,因此订阅者无法获得 `cancel`/`dispose``workflow/end` 刻意省略 result value观察结果的监听器不得收到调用方 result 的可变别名)。每次 emit 对每个监听器隔离:抛出异常的订阅者被记录日志但不传播,不会饿死在它之后注册的监听器;每个监听器收到自己的 payload 克隆,因此修改它既不会损坏引擎也不会影响其他监听器。这种隔离方式与 `subagent/start`/`subagent/end` 一致。
## 持久 Chat 记录
顶层 `dsh-tool-workflow` 消费方把展示事实投影到调用它的父 Session同时不改变执行所有权。运行接受后写 `tool-workflow/run-start`,以 `runId + seq` 配对成员开始与结束,并且只在结果已取得且 dispose 完全停稳后写 `tool-workflow/run-end`。嵌套 transport 调用不写记录。第一次 append 失败会禁用本运行后续写入,因此日志保持为空或合法连续前缀,工具结果不变。
`dsh-tool-workflow/invariant` 会在实时提交前和 Session 加载时校验同一协议:每个运行只有一个 start成员序号为正且唯一成员 end 必须配对,仍有开放成员时不能结束运行,运行结束后不能继续更新。日志尾部缺少成员 end 或 run end 是有效的中断证据,不是损坏。
`dsh-client-ui-workflow-run` 通过 Conversation Node 引擎把四类事件折叠为一个 `workflow-run` Chat 节点,以 run-start 序号锚定在原工作流工具节点之后。阶段组只来自真正开始过的成员,并保留精确字符串,包括字段缺省与 `''` 的区别。Location 关闭时缺失终点会显示为已中断。32 像素运行行使用 module-platform 背景、常驻 chevron 与内联状态点加文字32 像素阶段行在主区显示标题和计数,在固定尾部精确显示聚合状态且不重复状态点;成员使用 16 像素状态点槽和固定 64 像素生命周期列。只有成员状态与当前列表同时证明它是同父级、仍运行的本地 subagent 时,带下划线名称才标记普通 Session 导航。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -155,7 +151,7 @@ Workflow Service Definition contract. Invalid requests throw before publication;
abstract start(request: WorkflowStartRequest): WorkflowRun
```
Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:157`](../../packages/workflow/workflow/src/index.ts)
<a id="workflow-events"></a>
@@ -181,7 +177,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:79`](../../packages/workflow/workflow/src/index.ts)
<a id="workflowagent-start--emit"></a>
@@ -202,7 +198,7 @@ One `agent()` call established a published child run. Paired with Events['workfl
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts)
<a id="workflowend--emit"></a>
@@ -223,7 +219,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:89`](../../packages/workflow/workflow/src/index.ts)
<a id="workflowlog--emit"></a>
@@ -241,7 +237,7 @@ The script emitted a narration line (a `log(message)` call).
'workflow/log'(info: WorkflowRunInfo, message: string): void
```
Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:58`](../../packages/workflow/workflow/src/index.ts)
<a id="workflowphase--emit"></a>
@@ -260,7 +256,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs
'workflow/phase'(info: WorkflowRunInfo, title: string): void
```
Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:51`](../../packages/workflow/workflow/src/index.ts)
<a id="workflowstart--emit"></a>
@@ -278,5 +274,5 @@ A workflow run started — the script's meta block validated, the body about to
'workflow/start'(info: WorkflowRunInfo): void
```
Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts)
Source: [`packages/workflow/workflow/src/index.ts:43`](../../packages/workflow/workflow/src/index.ts)
<!-- END GENERATED cordis-surface -->

View File

@@ -156,6 +156,16 @@
"tests/**/*.tsx"
]
},
"packages/client/ui-workflow-run": {
"entry": [
"tests/**/*.spec.tsx"
],
"project": [
"src/**/*.ts",
"src/**/*.tsx",
"tests/**/*.tsx"
]
},
"packages/client/web-react": {
"entry": [
"tests/**/*.spec.tsx"

View File

@@ -149,6 +149,11 @@
- id: ui-tool
name: '@deepseek-ai/dsh-client-ui-tool'
# Durable workflow lifecycle as an independent Chat node after the
# existing generic workflow tool row.
- id: ui-workflow-run
name: '@deepseek-ai/dsh-client-ui-workflow-run'
# Turn tail: the produced-files row under each closing assistant message.
# Remove this entry to turn the surface off; the tail hole renders empty.
- id: ui-deliverables

View File

@@ -59,6 +59,7 @@
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
"@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",

View File

@@ -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/README.md
README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd
README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169
README.md: 6b3904c1e97a5a3da4864731aa624b3afbf5d027
README.zh.md: 9f71ffa04fb80f0fd6d62b1d4b23f0ea1474c107

View File

@@ -23,6 +23,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. |
| [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested Chat disclosures with live-only child navigation. |
| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
| [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |

View File

@@ -23,6 +23,7 @@ dsh web GUI 的浏览器侧shell 启动、浏览器与宿主通信、共享 U
| [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
| [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
| [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 |
| [`ui-workflow-run/`](ui-workflow-run/README.md) | 把持久工作流运行回放为 Chat 嵌套折叠项,并只为实时子 Session 提供导航。 |
| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent智能体活动的其他视图。 |
| [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |

View File

@@ -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 packages/client/ui-workflow-run/README.md
README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5
README.zh.md: a803857af24802e8a4645c4d5aca56c04424c85e

View File

@@ -0,0 +1,35 @@
# @deepseek-ai/dsh-client-ui-workflow-run
English | [中文](README.zh.md)
The browser plugin that reconstructs durable top-level workflow runs as independent Chat nodes. It consumes the four `tool-workflow/*` Session events owned by [`dsh-tool-workflow`](../../workflow/tool-workflow/README.md), registers one `ConversationNodeDefinition`, and renders through the keyed `conversation.chat.node` slot without changing the existing workflow tool card.
## Durable state and replay
`tool-workflow/run-start` creates one Context keyed by `runId`; member starts, member endings, and the run ending update that Context in log order. A history tail containing only updates remains pending until an older page supplies the unique start, after which prepend, complete replay, and live append produce the same state. A closed Turn or Step with missing terminal events presents the affected run or members as interrupted without changing the tool result.
Phase groups come only from members that actually started. Exact phase strings share a group, an omitted phase is distinct from the empty string, and settlement changes status without removing or reordering members.
## Presentation and navigation
The run and each phase have independent disclosure state. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A running run initially expands; a terminal run loaded from history initially collapses. Local choices survive data updates while the keyed node remains mounted and reset only on a full remount.
A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive.
## Composition
The package registers its Definition, locale dictionary, and `workflow-run` renderer as Cordis effects. Removing the client entry retracts all three contributions. The shipped Web bundle includes the plugin after `ui-conversation` and `ui-tool`.
## Model Experience
None, as this package renders durable Session facts for humans and adds no prompt, tool schema, request content, or model-visible result.
#### KV Cache effect
None.
## Known Limitations and Deferred Work
- Only top-level calls through `dsh-tool-workflow` produce these records; nested Code Mode calls and direct `WorkflowService` consumers do not.
- Navigation is intentionally live-only. Terminal members remain visible for review but never expose a cold-session opener from this node.
- The node shows run, phase, member identity, and status only; scripts, outputs, errors, logs, usage, static topology, and controls remain outside this surface.

View File

@@ -0,0 +1,35 @@
# @deepseek-ai/dsh-client-ui-workflow-run
[English](README.md) | 中文
这个浏览器插件把持久化的顶层工作流运行重建为独立 Chat 节点。它消费由 [`dsh-tool-workflow`](../../workflow/tool-workflow/README.md) 拥有的四类 `tool-workflow/*` Session 事件,注册一个 `ConversationNodeDefinition`,并通过 keyed `conversation.chat.node` slot 渲染,不改变现有工作流工具卡。
## 持久状态与回放
`tool-workflow/run-start``runId` 创建唯一 Context成员开始、成员结束和运行结束事件按日志顺序更新该 Context。只有 update 的历史尾页会保持 pending直到更早页面补入唯一 start此后 prepend、完整回放和实时 append 得到相同状态。若所属 Turn 或 Step 已关闭但终点事件缺失,界面把相应运行或成员显示为已中断,而不改写工具结果。
阶段组只来自真正开始过的成员。完全相同的阶段字符串归入同一组,字段缺省与空字符串保持不同身份;成员结算只改变状态,不删除或重排成员。
## 展示与导航
运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron并以内联状态点加状态文字表达结局不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。
只有所有实时事实同时成立时,成员才可打开子 Session成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'``parentId` 等于当前 Session且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示键盘聚焦时名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。
## 装配
本包把 Definition、locale 字典和 `workflow-run` renderer 都注册为 Cordis effect移除客户端 entry 会撤销三者。shipped Web bundle 在 `ui-conversation``ui-tool` 之后装配该插件。
## Model Experience
无,因为本包只为人类展示持久 Session 事实,不增加 prompt、工具 schema、请求内容或模型可见结果。
#### KV Cache effect
无。
## Known Limitations and Deferred Work
- 只有经 `dsh-tool-workflow` 发起的顶层调用会生成这些记录;嵌套 Code Mode 调用和直接 `WorkflowService` 消费方不会生成。
- 导航刻意只面向实时运行。终态成员继续保留供复盘,但本节点永不为其提供冷 Session 入口。
- 节点只显示运行、阶段、成员身份与状态;脚本、输出、错误、日志、用量、静态拓扑和控制操作都不属于本界面。

View File

@@ -0,0 +1,73 @@
{
"name": "@deepseek-ai/dsh-client-ui-workflow-run",
"description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-tool-workflow": "^0.0.1",
"@deepseek-ai/dsh-workflow": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-workflow": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,250 @@
.root {
width: 100%;
min-width: 0;
}
.runHeader {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
height: 32px;
padding: 0 8px;
border-radius: 8px;
background: var(--dsw-alias-bg-module-platform);
cursor: pointer;
}
.runHeader:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: -2px;
}
.runLeading {
display: inline-flex;
flex: none;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
color: var(--dsw-alias-label-tertiary);
}
.runTitle {
overflow: hidden;
flex: none;
max-width: 42%;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
font-weight: 510;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.runSummary {
overflow: hidden;
flex: 1;
min-width: 0;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusTail {
display: inline-flex;
flex: none;
height: 20px;
align-items: center;
gap: 4px;
overflow: hidden;
font-size: 11px;
font-weight: 510;
line-height: 16px;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
}
.phaseHeader {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
height: 32px;
cursor: pointer;
}
.phaseHeader:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: -2px;
border-radius: 4px;
}
.phaseLeading {
display: inline-flex;
flex: none;
width: 16px;
height: 16px;
align-items: center;
justify-content: center;
color: var(--dsw-alias-label-tertiary);
}
.phaseTitle {
flex: none;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 24px;
white-space: nowrap;
}
.phaseCount {
overflow: hidden;
flex: 1;
min-width: 0;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.phaseStatus {
overflow: hidden;
flex: none;
width: 132px;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.separator {
flex: none;
width: 2px;
height: 2px;
border-radius: 50%;
background: var(--dsw-alias-label-tertiary);
}
.phaseList {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 4px 0 0 16px;
}
.phase {
min-width: 0;
}
.members {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: 0 0 0 16px;
}
.memberRow,
.memberButton {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 0;
min-height: 24px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: inherit;
text-align: left;
}
.memberButton {
cursor: pointer;
}
.memberButton .memberLabel {
color: var(--dsw-alias-state-business-primary);
text-decoration: underline;
text-underline-position: from-font;
}
.dotSlot {
display: inline-flex;
flex: none;
width: 16px;
height: 24px;
align-items: center;
justify-content: center;
overflow: hidden;
}
.memberButton:focus-visible {
outline: none;
}
.memberButton:focus-visible .memberLabelWrap {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
.memberLabelWrap {
display: flex;
overflow: hidden;
flex: 1;
min-width: 0;
height: 24px;
align-items: center;
padding: 0 2px;
border-radius: 4px;
}
.memberLabel {
overflow: hidden;
flex: 1;
min-width: 0;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.memberStatus {
flex: none;
overflow: hidden;
width: 64px;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
padding: 0;
}
@media (max-width: 560px) {
.phaseList,
.members {
padding-left: 12px;
}
}

View File

@@ -0,0 +1,235 @@
import { useMemo, useState, type KeyboardEvent } from 'react'
import {
IconChevronDownOutline14, IconChevronRightOutline14, StateDot, type StateDotState,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkflowRunKey } from './locales.ts'
import type {
WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus,
} from './workflow-definition.ts'
import css from './WorkflowRunPanel.module.css'
/** Navigation action injected from the plugin's own SessionsService access. */
export interface WorkflowRunInjected {
readonly openSession: (id: SessionId) => void
}
/** Complete keyed Chat renderer props. */
export type WorkflowRunPanelProps =
PropsRuntime<'conversation.chat.node', 'workflow-run'>
& PropsLocale<'workflowRun'>
& WorkflowRunInjected
const STATUS_KEYS = {
running: 'status.running',
completed: 'status.completed',
failed: 'status.failed',
cancelled: 'status.cancelled',
interrupted: 'status.interrupted',
} as const satisfies Record<WorkflowRunStatus, WorkflowRunKey>
function dotState(status: WorkflowRunStatus): StateDotState {
switch (status) {
case 'running': return 'ongoing'
case 'completed': return 'done'
case 'failed': return 'error'
case 'cancelled':
case 'interrupted': return 'warning'
/* v8 ignore next -- WorkflowRunStatus is closed and every variant is handled above. */
default: return status satisfies never
}
}
function readablePhase(phase: string | null, t: WorkflowRunPanelProps['t']): string {
if (phase === null) return t('phase.unassigned')
return phase === '' ? t('phase.empty') : phase
}
function readableMember(label: string, t: WorkflowRunPanelProps['t']): string {
return label === '' ? t('member.empty') : label
}
function statusCount(
status: WorkflowRunStatus,
count: number,
t: WorkflowRunPanelProps['t'],
): string {
return t(`statusCount.${status}`, { count })
}
function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string {
const counts = new Map<WorkflowRunStatus, number>()
for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1)
const count = (status: WorkflowRunStatus): number => counts.get(status) ?? 0
const active = (['running', 'failed', 'cancelled', 'interrupted'] as const)
.filter(status => count(status) > 0)
if (active.length === 0) return statusCount('completed', count('completed'), t)
const visible = active.includes('interrupted') && count('completed') > 0
? ['completed' as const, ...active]
: active
return visible.map(status => statusCount(status, count(status), t)).join(' · ')
}
function handleDisclosureKey(event: KeyboardEvent<HTMLDivElement>, onToggle: () => void): void {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onToggle()
}
function RunHeader({ count, name, onToggle, open, status, t }: {
readonly count: number
readonly name: string
readonly onToggle: () => void
readonly open: boolean
readonly status: WorkflowRunStatus
readonly t: WorkflowRunPanelProps['t']
}) {
return (
<div
className={css.runHeader}
data-run-header
data-status={status}
role="button"
tabIndex={0}
aria-expanded={open}
onClick={onToggle}
onKeyDown={(event) => { handleDisclosureKey(event, onToggle) }}
>
<span className={css.runLeading}>
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
</span>
<span className={css.runTitle}>{t('run.title', { name })}</span>
<span className={css.separator} aria-hidden />
<span className={css.runSummary}>{t('run.members', { count })}</span>
<span className={css.statusTail} data-run-status-tail data-status={status}>
<StateDot state={dotState(status)} />
<span>{t(STATUS_KEYS[status])}</span>
</span>
</div>
)
}
function MemberRow({ member, navigable, openSession, t }: {
readonly member: WorkflowRunMemberData
readonly navigable: boolean
readonly openSession: WorkflowRunInjected['openSession']
readonly t: WorkflowRunPanelProps['t']
}) {
const name = readableMember(member.label, t)
const content = (
<>
<span className={css.dotSlot}><StateDot state={dotState(member.status)} /></span>
<span className={css.memberLabelWrap} data-member-label-wrap><span className={css.memberLabel} data-member-label>{name}</span></span>
<span className={css.memberStatus} data-member-status-text>{t(STATUS_KEYS[member.status])}</span>
</>
)
if (!navigable) {
return <div className={css.memberRow} data-member-status={member.status}>{content}</div>
}
return (
<button
type="button"
className={css.memberButton}
data-member-status={member.status}
aria-label={t('member.open', { name })}
onClick={() => { openSession(member.childId) }}
>
{content}
</button>
)
}
function PhaseSection({ phase, navigable, openSession, t }: {
readonly phase: WorkflowRunPhaseData
readonly navigable: ReadonlySet<SessionId>
readonly openSession: WorkflowRunInjected['openSession']
readonly t: WorkflowRunPanelProps['t']
}) {
const [open, setOpen] = useState(false)
const toggle = (): void => { setOpen(value => !value) }
return (
<div className={css.phase} data-phase-key={phase.key} data-phase-status={phase.status}>
<div
className={css.phaseHeader}
data-phase-header
role="button"
tabIndex={0}
aria-expanded={open}
onClick={toggle}
onKeyDown={(event) => { handleDisclosureKey(event, toggle) }}
>
<span className={css.phaseLeading}>
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
</span>
<span className={css.phaseTitle}>{readablePhase(phase.phase, t)}</span>
<span className={css.separator} aria-hidden />
<span className={css.phaseCount} data-phase-count>{t('run.members', { count: phase.members.length })}</span>
<span className={css.phaseStatus} data-phase-status-text>{phaseStatusSummary(phase.members, t)}</span>
</div>
{open && (
<div className={css.members}>
{phase.members.map(member => (
<MemberRow
key={member.seq}
member={member}
navigable={navigable.has(member.childId)}
openSession={openSession}
t={t}
/>
))}
</div>
)}
</div>
)
}
/** Render one durable workflow run with independent run and phase disclosure. */
export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) {
const [open, setOpen] = useState(() => node.data.status === 'running')
const sessions = useSessions(value => value)
const navigable = useMemo(() => {
const ordinary = new Set(sessions.ids)
const result = new Set<SessionId>()
for (const phase of node.data.phases) {
for (const member of phase.members) {
const summary = sessions.byId[member.childId]
if (member.status === 'running'
&& ordinary.has(member.childId)
&& summary?.origin === 'subagent'
&& summary.parentId === sessionId
&& summary.running) {
result.add(member.childId)
}
}
}
return result
}, [node.data.phases, sessionId, sessions])
return (
<section className={css.root} data-workflow-run data-run-status={node.data.status}>
<RunHeader
count={node.data.memberCount}
name={node.data.name}
open={open}
status={node.data.status}
t={t}
onToggle={() => { setOpen(value => !value) }}
/>
{open && (
<div className={css.phaseList}>
{node.data.phases.length === 0
? <span className={css.empty}>{t('run.empty')}</span>
: node.data.phases.map(phase => (
<PhaseSection
key={phase.key}
phase={phase}
navigable={navigable}
openSession={openSession}
t={t}
/>
))}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,38 @@
/** Browser plugin for durable workflow-run Conversation Nodes. */
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { WorkflowRunPanel, type WorkflowRunInjected } from './WorkflowRunPanel.tsx'
import { en, NS, type WorkflowRunKey, zh } from './locales.ts'
import { workflowRunDefinition } from './workflow-definition.ts'
export type { WorkflowRunInjected, WorkflowRunPanelProps } from './WorkflowRunPanel.tsx'
export type {
WorkflowRunChatData, WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus,
} from './workflow-definition.ts'
export type { WorkflowRunKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** Durable workflow-run node copy. */
workflowRun: WorkflowRunKey
}
}
/** Required services for Definition, keyed renderer, navigation, and copy. */
export const inject = ['conversationEvents', 'slots', 'sessions', 'locale']
/** Register the workflow Definition, dictionary, and keyed Chat renderer. */
export function apply(ctx: ClientContext): void {
ctx.conversationEvents.register(workflowRunDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workflow-run: dictionaries')
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'workflow-run',
locale: NS,
inject: (): WorkflowRunInjected => ({
openSession: (id: SessionId) => { ctx.sessions.open(id) },
}),
}, WorkflowRunPanel))
}

View File

@@ -0,0 +1,49 @@
/** `workflowRun` namespace dictionaries. */
/** Dictionary namespace owned by this plugin. */
export const NS = 'workflowRun'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'run.title': '{name}',
'run.members': '{count} 个成员',
'run.empty': '没有启动成员',
'phase.unassigned': '未分阶段',
'phase.empty': '空阶段名',
'statusCount.running': '运行中 {count}',
'statusCount.completed': '已完成 {count}',
'statusCount.failed': '失败 {count}',
'statusCount.cancelled': '已取消 {count}',
'statusCount.interrupted': '已中断 {count}',
'member.empty': '空成员名',
'member.open': '打开 {name}',
'status.running': '运行中',
'status.completed': '已完成',
'status.failed': '失败',
'status.cancelled': '已取消',
'status.interrupted': '已中断',
}
/** English dictionary (same key set). */
export const en: Record<WorkflowRunKey, string> = {
'run.title': '{name}',
'run.members': '{count} members',
'run.empty': 'No members started',
'phase.unassigned': 'Unphased',
'phase.empty': 'Empty phase name',
'statusCount.running': 'Running {count}',
'statusCount.completed': 'Completed {count}',
'statusCount.failed': 'Failed {count}',
'statusCount.cancelled': 'Cancelled {count}',
'statusCount.interrupted': 'Interrupted {count}',
'member.empty': 'Empty member name',
'member.open': 'Open {name}',
'status.running': 'Running',
'status.completed': 'Completed',
'status.failed': 'Failed',
'status.cancelled': 'Cancelled',
'status.interrupted': 'Interrupted',
}
/** Union of this namespace's dictionary keys. */
export type WorkflowRunKey = keyof typeof zh

View File

@@ -0,0 +1,200 @@
import type {
ChatConversationViewNode, ConversationLocation, ConversationNodeContext,
ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ToolWorkflowAgentEndData, ToolWorkflowAgentStartData,
} from '@deepseek-ai/dsh-tool-workflow/types'
import type { WorkflowAgentOutcome, WorkflowStopReason } from '@deepseek-ai/dsh-workflow/types'
/** Status shown for a workflow, phase, or member. */
export type WorkflowRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted'
/** Final renderer data for one member. */
export interface WorkflowRunMemberData {
readonly seq: number
readonly label: string
readonly childId: SessionId
readonly status: WorkflowRunStatus
}
/** Final renderer data for one exact phase identity. */
export interface WorkflowRunPhaseData {
readonly key: string
/** `null` is the absent field; the empty string remains a distinct identity. */
readonly phase: string | null
readonly status: WorkflowRunStatus
readonly members: readonly WorkflowRunMemberData[]
}
/** Final keyed Chat payload for one workflow run. */
export interface WorkflowRunChatData {
readonly name: string
readonly status: WorkflowRunStatus
readonly memberCount: number
readonly phases: readonly WorkflowRunPhaseData[]
}
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Durable top-level workflow run and all members that actually started. */
'workflow-run': WorkflowRunChatData
}
}
interface WorkflowMemberState extends ToolWorkflowAgentStartData {
readonly outcome?: WorkflowAgentOutcome
}
interface WorkflowState {
readonly name: string
readonly stopReason?: WorkflowStopReason
readonly members: readonly WorkflowMemberState[]
}
/**
* Build a collision-free phase key preserving absent versus empty identity.
* @param phase - exact phase string, or null for an omitted field.
* @returns the stable renderer key for that phase identity.
*/
export function workflowPhaseKey(phase: string | null): string {
return phase === null ? 'missing' : `value:${phase.length}:${phase}`
}
function statusFromStopReason(stopReason: WorkflowStopReason): WorkflowRunStatus {
switch (stopReason) {
case 'completed': return 'completed'
case 'cancelled': return 'cancelled'
case 'error': return 'failed'
/* v8 ignore next -- WorkflowStopReason is closed and every variant is handled above. */
default: return stopReason satisfies never
}
}
function statusFromOutcome(outcome: WorkflowAgentOutcome): WorkflowRunStatus {
switch (outcome) {
case 'completed': return 'completed'
case 'cancelled': return 'cancelled'
case 'failed': return 'failed'
/* v8 ignore next -- WorkflowAgentOutcome is closed and every variant is handled above. */
default: return outcome satisfies never
}
}
function locationClosed(location: ConversationLocation | undefined): boolean {
if (location === undefined) return false
if (location.kind === 'step') {
return location.step.status === 'closed' || location.turn.status === 'closed'
}
return location.kind === 'turn' && location.turn.status === 'closed'
}
function aggregateStatus(members: readonly WorkflowRunMemberData[]): WorkflowRunStatus {
if (members.some(member => member.status === 'running')) return 'running'
if (members.some(member => member.status === 'failed')) return 'failed'
if (members.some(member => member.status === 'cancelled')) return 'cancelled'
if (members.some(member => member.status === 'interrupted')) return 'interrupted'
return 'completed'
}
function projectWorkflow(
context: ConversationNodeContext<WorkflowState>,
): WorkflowRunChatData | undefined {
const state = context.state
if (state === undefined) return undefined
const interrupted = state.stopReason === undefined
&& locationClosed(context.start?.location ?? context.matches[0]?.location)
const phases = new Map<string, { phase: string | null; members: WorkflowRunMemberData[] }>()
for (const member of state.members) {
const phase = member.phase === undefined ? null : member.phase
const key = workflowPhaseKey(phase)
let group = phases.get(key)
if (group === undefined) {
group = { phase, members: [] }
phases.set(key, group)
}
group.members.push({
seq: member.seq,
label: member.label,
childId: member.childId,
status: member.outcome === undefined
? interrupted ? 'interrupted' : 'running'
: statusFromOutcome(member.outcome),
})
}
const projectedPhases = [...phases].map(([key, phase]) => ({
key,
phase: phase.phase,
status: aggregateStatus(phase.members),
members: phase.members,
}))
return {
name: state.name,
status: state.stopReason === undefined
? interrupted ? 'interrupted' : 'running'
: statusFromStopReason(state.stopReason),
memberCount: state.members.length,
phases: projectedPhases,
}
}
function updateAgentStart(state: WorkflowState, data: ToolWorkflowAgentStartData): WorkflowState {
return { ...state, members: [...state.members, data] }
}
function updateAgentEnd(state: WorkflowState, data: ToolWorkflowAgentEndData): WorkflowState {
return {
...state,
members: state.members.map(member => member.seq === data.seq
? { ...member, outcome: data.outcome }
: member),
}
}
/** Durable workflow event family folded into one keyed Chat node. */
export const workflowRunDefinition: ConversationNodeDefinition<WorkflowState> = {
kind: 'workflow-run',
match: (event) => {
if (event.type === 'tool-workflow/run-start') return { id: String(event.data.runId), role: 'start' }
if (event.type === 'tool-workflow/agent-start'
|| event.type === 'tool-workflow/agent-end'
|| event.type === 'tool-workflow/run-end') {
return { id: String(event.data.runId), role: 'update' }
}
return null
},
start: (_context, match) => {
if (match.event.type !== 'tool-workflow/run-start') {
throw new Error('workflow-run start requires tool-workflow/run-start')
}
return { name: match.event.data.name, members: [] }
},
update: (context, match) => {
if (match.event.type === 'tool-workflow/agent-start') {
return updateAgentStart(context.state, match.event.data)
}
if (match.event.type === 'tool-workflow/agent-end') {
return updateAgentEnd(context.state, match.event.data)
}
if (match.event.type === 'tool-workflow/run-end') {
return { ...context.state, stopReason: match.event.data.stopReason }
}
return context.state
},
buildViewNode: (context, target): ChatConversationViewNode | null => {
if (target !== 'chat') return null
const data = projectWorkflow(context)
if (data === undefined || context.start === undefined) return null
return {
key: context.key,
kind: 'workflow-run',
id: context.id,
target: 'chat',
anchorSeq: context.start.event.seq,
location: context.start.location,
visibility: 'visible',
data,
}
},
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,4 @@
/** Durable workflow-run UI plugin, node half. */
/** Host plugin body; the feature is entirely browser-side. */
export function apply(): void {}

View File

@@ -0,0 +1,24 @@
/** Package-owned invariant companion for the workflow-run UI plugin. */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workflow-run'
/** Cordis companion plugin name. */
export const name = 'client-ui-workflow-run-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the browser plugin contributes one effect-owned
* Conversation Definition, keyed renderer, and dictionary; tests prove their
* disposal and the Host tool package owns the durable event invariant.
*/
const install: InvariantInstaller = () => {}
/** Register this package's invariant companion. */
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,526 @@
// @vitest-environment jsdom
import { Context, Service } from 'cordis'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ConversationEventRegistry, ConversationNodeAssembler, SlotsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import type {
ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition,
ConversationViewDefinition, ConversationViewNode, SessionId, SessionListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import {
WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps,
} from '../src/client/WorkflowRunPanel.tsx'
import { apply, inject } from '../src/client/index.ts'
import { zh } from '../src/client/locales.ts'
import {
workflowRunDefinition, type WorkflowRunChatData,
} from '../src/client/workflow-definition.ts'
import { apply as applyNode } from '../src/index.ts'
import { apply as applyInvariant } from '../src/invariant.ts'
import type {} from '../src/client/index.ts'
afterEach(cleanup)
const PARENT_ID = 'parent' as SessionId
const CHILD_ID = 'child-1' as SessionId
interface ChatSnapshot {
readonly nodes: ReadonlyMap<string, ChatConversationViewNode>
}
class TestEventDefinitions {
entries(): readonly ConversationNodeDefinition[] { return [workflowRunDefinition] }
fallbackEntry(): undefined { return undefined }
}
class TestViewDefinitions {
entries(): readonly ConversationViewDefinition[] { return [chatViewDefinition] }
}
const chatViewDefinition: ConversationViewDefinition<ChatConversationViewNode, ChatSnapshot> = {
target: 'chat',
create: () => {
let nodes = new Map<string, ChatConversationViewNode>()
const snapshot = (): ChatSnapshot => ({ nodes })
return {
empty: snapshot(),
replace: ({ nodes: values }) => {
nodes = new Map(values.map(node => [node.key, node]))
return snapshot()
},
apply: ({ upserts }) => {
nodes = new Map(nodes)
for (const node of upserts) nodes.set(node.key, node)
return snapshot()
},
}
},
}
function at(seq: number, type: string, data: unknown): ConversationEventInput {
return { event: { seq, time: seq * 100, type, data } as ConversationEventInput['event'], view: undefined }
}
function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch {
return { ...input, role, location: { kind: 'unresolved' } }
}
function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler {
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
value.replaceWindow(entries, hasMore)
value.flush()
return value
}
function workflowData(value: ConversationNodeAssembler): WorkflowRunChatData | undefined {
const snapshot = value.snapshot('chat') as ChatSnapshot
return [...snapshot.nodes.values()][0]?.data as WorkflowRunChatData | undefined
}
function completeEvents(): ConversationEventInput[] {
return [
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }),
at(4, 'tool-workflow/agent-start', {
runId: 'run-1', seq: 1, label: 'first', phase: '', childId: 'child-1',
}),
at(5, 'tool-workflow/agent-start', {
runId: 'run-1', seq: 2, label: 'second', childId: 'child-2',
}),
at(6, 'tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }),
at(7, 'tool-workflow/agent-end', { runId: 'run-1', seq: 2, outcome: 'failed' }),
at(8, 'tool-workflow/run-end', { runId: 'run-1', stopReason: 'error' }),
at(9, 'step/end', { turn: 1, step: 1 }),
at(10, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
]
}
describe('workflow-run Conversation Definition', () => {
it('groups exact phase identities in first-member order and preserves terminal members', () => {
const value = assembler(completeEvents())
const data = workflowData(value)
expect(data).toEqual({
name: 'audit',
status: 'failed',
memberCount: 2,
phases: [
{
key: 'value:0:', phase: '', status: 'completed',
members: [{ seq: 1, label: 'first', childId: 'child-1', status: 'completed' }],
},
{
key: 'missing', phase: null, status: 'failed',
members: [{ seq: 2, label: 'second', childId: 'child-2', status: 'failed' }],
},
],
})
const node = [...(value.snapshot('chat') as ChatSnapshot).nodes.values()][0]!
expect(node.anchorSeq).toBe(3)
expect(node.kind).toBe('workflow-run')
})
it('keeps an update-only tail pending until prepend supplies the unique start', () => {
const tail = completeEvents().slice(3)
const value = assembler(tail, true)
expect(workflowData(value)).toBeUndefined()
value.prepend(completeEvents().slice(0, 3), false)
value.flush()
expect(workflowData(value)).toEqual(workflowData(assembler(completeEvents())))
})
it('produces the same final data through live append as complete replay', () => {
const events = completeEvents()
const value = assembler(events.slice(0, 3))
for (const event of events.slice(3)) value.append(event)
value.flush()
expect(workflowData(value)).toEqual(workflowData(assembler(events)))
})
it('shows missing terminal facts as interrupted only after the owning Location closes', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }),
at(4, 'tool-workflow/agent-start', {
runId: 'run-1', seq: 1, label: 'worker', childId: 'child-1',
}),
])
expect(workflowData(value)?.status).toBe('running')
value.append(at(5, 'step/end', { turn: 1, step: 1 }))
value.flush()
expect(workflowData(value)).toMatchObject({
status: 'interrupted',
phases: [{ members: [{ status: 'interrupted' }] }],
})
})
it('retains a zero-member run as its own completed node', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'step/start', { turn: 1, step: 1 }),
at(3, 'tool-workflow/run-start', { runId: 'empty', name: 'empty' }),
at(4, 'tool-workflow/run-end', { runId: 'empty', stopReason: 'completed' }),
])
expect(workflowData(value)).toEqual({
name: 'empty', status: 'completed', memberCount: 0, phases: [],
})
})
it('folds same-phase cancellation and a turn-level interruption', () => {
const cancelled = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'tool-workflow/run-start', { runId: 'cancelled', name: 'cancelled' }),
at(3, 'tool-workflow/agent-start', {
runId: 'cancelled', seq: 1, label: 'one', phase: 'Research', childId: 'child-1',
}),
at(4, 'tool-workflow/agent-start', {
runId: 'cancelled', seq: 2, label: 'two', phase: 'Research', childId: 'child-2',
}),
at(5, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 1, outcome: 'cancelled' }),
at(6, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 2, outcome: 'completed' }),
at(7, 'tool-workflow/run-end', { runId: 'cancelled', stopReason: 'cancelled' }),
])
expect(workflowData(cancelled)).toMatchObject({
status: 'cancelled',
phases: [{ phase: 'Research', status: 'cancelled', members: [{ status: 'cancelled' }, { status: 'completed' }] }],
})
const interruptedTurn = assembler([
at(1, 'turn/start', { turn: 1 }),
at(2, 'tool-workflow/run-start', { runId: 'turn', name: 'turn' }),
at(3, 'tool-workflow/agent-start', {
runId: 'turn', seq: 1, label: 'open', childId: 'child-1',
}),
at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
])
expect(workflowData(interruptedTurn)?.status).toBe('interrupted')
})
it('handles session/unresolved placement and defensive Definition calls', () => {
const sessionLevel = assembler([
at(1, 'tool-workflow/run-start', { runId: 'session', name: 'session' }),
at(2, 'tool-workflow/agent-start', {
runId: 'session', seq: 1, label: 'open', childId: 'child-1',
}),
])
expect(workflowData(sessionLevel)?.status).toBe('running')
const invalidStart = matched(at(1, 'tool-workflow/agent-start', {
runId: 'direct', seq: 1, label: 'member', childId: 'child-1',
}), 'start')
const emptyContext: Parameters<typeof workflowRunDefinition.start>[0] = {
key: 'workflow-run:direct', kind: 'workflow-run', id: 'direct',
matches: [invalidStart], start: invalidStart, state: undefined, current: new Map(),
}
const reader: Parameters<typeof workflowRunDefinition.start>[2] = { previous: () => undefined }
expect(() => workflowRunDefinition.start(emptyContext, invalidStart, reader))
.toThrow('workflow-run start requires tool-workflow/run-start')
const start = matched(at(2, 'tool-workflow/run-start', { runId: 'direct', name: 'direct' }), 'start')
const startedContext = { ...emptyContext, matches: [start], start }
const state = workflowRunDefinition.start(startedContext, start, reader)
const updateContext: Parameters<typeof workflowRunDefinition.update>[0] = { ...startedContext, state }
const unrelated = matched(at(3, 'turn/start', { turn: 1 }), 'update')
expect(workflowRunDefinition.update(updateContext, unrelated)).toBe(state)
expect(workflowRunDefinition.buildViewNode(updateContext, 'trajectory')).toBeNull()
expect(workflowRunDefinition.buildViewNode({
...updateContext, matches: [], start: undefined,
}, 'chat')).toBeNull()
const directNode = workflowRunDefinition.buildViewNode(updateContext, 'chat') as ChatConversationViewNode | null
if (directNode === null) throw new Error('expected direct workflow Chat node')
expect(directNode.kind).toBe('workflow-run')
expect((directNode.data as WorkflowRunChatData).status).toBe('running')
})
})
function node(data: WorkflowRunChatData): WorkflowRunPanelProps['node'] {
return {
key: '12:workflow-runrun-1',
kind: 'workflow-run',
id: 'run-1',
target: 'chat',
anchorSeq: 3,
location: { kind: 'unresolved' },
visibility: 'visible',
data,
}
}
const phase = (overrides: Partial<WorkflowRunChatData['phases'][number]> = {}): WorkflowRunChatData['phases'][number] => ({
key: 'missing',
phase: null,
status: 'running',
members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: 'running' }],
...overrides,
})
const listState = (overrides: Partial<SessionListState> = {}): SessionListState => ({
ids: [PARENT_ID, CHILD_ID],
byId: {
[PARENT_ID]: {
id: PARENT_ID, displayTitle: 'parent', running: true, blank: false, updatedAt: 0,
},
[CHILD_ID]: {
id: CHILD_ID, displayTitle: 'child', parentId: PARENT_ID, origin: 'subagent',
running: true, blank: false, updatedAt: 0,
},
},
current: PARENT_ID,
phase: 'ready',
subagentsByParent: {},
currentAddress: undefined,
...overrides,
})
function panelProps(data: WorkflowRunChatData, sessions = listState(), openSession = vi.fn()): WorkflowRunPanelProps {
return {
node: node(data),
sessionId: PARENT_ID,
useSessions: selector => selector(sessions),
useSession: (() => undefined) as WorkflowRunPanelProps['useSession'],
useProjection: () => undefined,
useInput: () => { throw new Error('unused') },
inputActions: { setDraft: () => {}, submit: () => {} } as unknown as WorkflowRunPanelProps['inputActions'],
useWorkspaces: (() => undefined) as WorkflowRunPanelProps['useWorkspaces'],
useTurnData: () => undefined,
selectedCallId: undefined,
cwd: undefined,
openFile: () => {},
inspectCall: () => {},
forkAt: () => {},
loadImage: () => Promise.reject(new Error('unused')),
fileMentions: () => undefined,
openSession,
t: makeTranslate(zh),
}
}
describe('WorkflowRunPanel', () => {
it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => {
const running: WorkflowRunChatData = {
name: 'audit', status: 'running', memberCount: 1, phases: [phase()],
}
const view = render(<WorkflowRunPanel {...panelProps(running)} />)
expect(screen.getByText('未分阶段')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /^audit/ }))
expect(screen.queryByText('未分阶段')).toBeNull()
const terminal: WorkflowRunChatData = { ...running, status: 'completed' }
view.rerender(<WorkflowRunPanel {...panelProps(terminal)} />)
expect(screen.queryByText('未分阶段')).toBeNull()
cleanup()
render(<WorkflowRunPanel {...panelProps(terminal)} />)
expect(screen.queryByText('未分阶段')).toBeNull()
})
it('supports root keyboard disclosure and renders a zero-member running state', () => {
render(<WorkflowRunPanel {...panelProps({
name: 'keyboard', status: 'running', memberCount: 1,
phases: [phase({ key: 'research', phase: 'Research' })],
})} />)
const header = screen.getByRole('button', { name: /^keyboard/ })
expect(header.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(header, { key: 'ArrowDown' })
expect(header.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(header, { key: 'Enter' })
expect(header.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(header, { key: ' ' })
expect(header.getAttribute('aria-expanded')).toBe('true')
expect(screen.getByText('Research')).toBeTruthy()
expect(screen.getByText('运行中 1')).toBeTruthy()
const phaseHeader = screen.getByRole('button', { name: /Research/ })
fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
fireEvent.keyDown(phaseHeader, { key: 'Enter' })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('true')
fireEvent.keyDown(phaseHeader, { key: ' ' })
expect(phaseHeader.getAttribute('aria-expanded')).toBe('false')
cleanup()
render(<WorkflowRunPanel {...panelProps({
name: 'empty', status: 'running', memberCount: 0, phases: [],
})} />)
expect(screen.getByText('没有启动成员')).toBeTruthy()
})
it('keeps phase disclosure independent and preserves empty versus absent names', () => {
render(<WorkflowRunPanel {...panelProps({
name: 'audit', status: 'running', memberCount: 2,
phases: [
phase({ key: 'value:0:', phase: '', members: [{
seq: 1, label: '', childId: 'child-1' as SessionId, status: 'running',
}] }),
phase({ key: 'missing', phase: null, members: [{
seq: 2, label: 'second', childId: 'child-2' as SessionId, status: 'running',
}] }),
],
})} />)
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
expect(screen.getByText('空成员名')).toBeTruthy()
expect(screen.queryByText('second')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.getByText('second')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /空阶段名/ }))
expect(screen.queryByText('空成员名')).toBeNull()
expect(screen.getByText('second')).toBeTruthy()
})
it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => {
const completed: WorkflowRunChatData = {
name: 'repo-audit', status: 'completed', memberCount: 1,
phases: [phase({
status: 'completed',
members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }],
})],
}
const completedView = render(<WorkflowRunPanel {...panelProps(completed)} />)
const completedHeader = screen.getByRole('button', { name: /^repo-audit/ })
expect(completedHeader.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(completedHeader)
expect(completedHeader.getAttribute('aria-expanded')).toBe('true')
completedView.unmount()
const mixed: WorkflowRunChatData = {
name: 'repo-audit', status: 'failed', memberCount: 2,
phases: [phase({
status: 'failed',
members: [
{ seq: 1, label: 'failed', childId: 'child-1' as SessionId, status: 'failed' },
{ seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' },
],
})],
}
const mixedView = render(<WorkflowRunPanel {...panelProps(mixed)} />)
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy()
expect([...mixedView.container.querySelectorAll('[data-member-status]')]
.map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled'])
expect(mixedView.container.querySelectorAll('[data-state="error"]')).toHaveLength(2)
expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
mixedView.unmount()
const interrupted: WorkflowRunChatData = {
name: 'repo-audit', status: 'interrupted', memberCount: 2,
phases: [
phase({
status: 'interrupted',
members: [
{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' },
{ seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' },
],
}),
phase({
key: 'interrupted-only', phase: 'Interrupted only', status: 'interrupted',
members: [{
seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted',
}],
}),
],
}
const interruptedView = render(<WorkflowRunPanel {...panelProps(interrupted)} />)
fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ }))
expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy()
expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy()
expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1)
})
it('opens only a running ordinary-list subagent proven to have this parent', () => {
const data: WorkflowRunChatData = {
name: 'audit', status: 'running', memberCount: 1, phases: [phase()],
}
const openSession = vi.fn()
render(<WorkflowRunPanel {...panelProps(data, listState(), openSession)} />)
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
fireEvent.click(screen.getByRole('button', { name: '打开 worker' }))
expect(openSession).toHaveBeenCalledWith('child-1')
})
it.each([
['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'],
['remote row', listState({ byId: {
...listState().byId,
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, origin: undefined },
} }), 'running'],
['wrong parent', listState({ byId: {
...listState().byId,
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, parentId: 'other' as SessionId },
} }), 'running'],
['list terminal', listState({ byId: {
...listState().byId,
[CHILD_ID]: { ...listState().byId[CHILD_ID]!, running: false },
} }), 'running'],
['member terminal', listState(), 'completed'],
] as const)('does not navigate when %s', (_name, sessions, memberStatus) => {
const data: WorkflowRunChatData = {
name: 'audit', status: 'running', memberCount: 1,
phases: [phase({
status: memberStatus === 'running' ? 'running' : 'completed',
members: [{
seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: memberStatus,
}],
})],
}
render(<WorkflowRunPanel {...panelProps(data, sessions)} />)
fireEvent.click(screen.getByRole('button', { name: /未分阶段/ }))
expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull()
cleanup()
})
})
class TestSessions extends Service {
readonly opened: SessionId[] = []
constructor(ctx: Context) { super(ctx, 'sessions') }
open(id: SessionId): void { this.opened.push(id) }
}
describe('plugin lifecycle', () => {
it('registers and removes the Definition and keyed renderer with its fiber', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
await ctx.plugin(ConversationEventRegistry).await()
await ctx.plugin(TestSessions).await()
ctx.slots.register({
name: 'root',
children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session' } },
} as never, () => null)
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])
expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1)
const entry = ctx.slots.entries('conversation.chat.node')[0]!
const face = entry.inject?.() as unknown as WorkflowRunInjected
face.openSession(CHILD_ID)
expect((ctx.sessions as unknown as TestSessions).opened).toEqual([CHILD_ID])
await fiber.dispose()
expect(ctx.conversationEvents.entries()).toEqual([])
expect(ctx.slots.entries('conversation.chat.node')).toEqual([])
const replacement = ctx.plugin({ inject: [...inject], apply })
await replacement.await()
expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run'])
expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1)
await replacement.dispose()
})
it('keeps the node half inert and registers invariant ownership', async () => {
applyNode()
const registered: string[] = []
const ctx = new Context()
ctx.provide('invariants')
ctx.set('invariants', {
register: (pkg: string) => { registered.push(pkg); return () => {} },
} as never)
await applyInvariant(ctx)
expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run'])
})
})
void ({} as ConversationViewNode)

View File

@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../core/session"
},
{
"path": "../../workflow/workflow"
},
{
"path": "../../workflow/tool-workflow"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-workflow-run', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -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/workflow/tool-workflow/README.md
README.md: 29896bee0f78a1d1764c3908965325fcecbf7b53
README.zh.md: 12e1ecd8932120c74384a289530954422ba145f2
README.md: ba8283a6b517eea79e6c75674a906db01e4b5890
README.zh.md: 2af8f5f8b8db2d5edf530d79dd81319846cfeeea

View File

@@ -12,6 +12,10 @@ Three parameters: `meta` (required identity data: `name`, `description`, and opt
Collection is synchronous (like [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)): `execute` starts a run and awaits `run.result` inside a `try/finally` that always disposes the run, so the script and its children reach quiescence on every path. `exec.signal` is bridged to `run.cancel()` (including the already-aborted-before-start case). A non-`completed` stop reason maps to an `isError` result reporting the reason—never partial output as success; a parse/meta failure thrown synchronously by `start()` becomes an `isError` the model can correct from. Completion returns canonical `{ runId, agentsStarted, result }`; the Native renderer preserves the meta name, agent count, and JSON value, truncating only that projection at `maxResultChars`.
For a root transport execution (`exec.parent` absent), the tool also projects the run into the calling Agent's Session: run-start after `start()` returns, matching member starts and endings filtered by `run.id`, then run-end only after `run.result` is available and `dispose()` has reached quiescence. Nested transport calls execute normally but write no workflow record. The first failed Session append disables later recording for that run, emits one warning, and leaves either no record or a legal continuous prefix without changing the tool result or cleanup.
The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes.
## Render intent
Decided up front (per the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)): a `generic` card titled `workflow: <meta.name>`, read directly from `args.meta.name` (presentation is a pure function of args and does not ask the engine to parse); the script text rides as `rawInput`. The result keeps the generic card.
@@ -78,3 +82,4 @@ Append-only; newly visible content follows the reusable request prefix and does
- **The parent turn blocks until the whole workflow settles** — there is no background start/poll surface, and cancellation discards partial output as an error.
- **`args` must be an object and Native result text is bounded** — callers wrap top-level arrays/scalars in a field; the canonical workflow result remains complete, while JSON beyond `maxResultChars` is truncated in the model-facing projection rather than stored behind a retrieval handle.
- **Workflow policy is fixed per tool registration** — provider selection, caps, and tool name are deployment config, not model-call arguments.
- **Durable records are top-level and observational** — nested Code Mode dispatches are not recorded, and a recording failure intentionally degrades to an incomplete prefix rather than changing execution.

View File

@@ -12,6 +12,10 @@
收集是同步的(类似 [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)`execute` 启动运行并等待 `run.result`;这些操作位于 `try/finally` 中,该结构总会 dispose资源释放运行使脚本及其子 agent智能体在每条路径上完全停稳。`exec.signal` 会桥接到 `run.cancel()`,包括启动前已经中止的情况。非 `completed` 结束原因会映射为报告原因的 `isError` 结果,绝不会把局部输出当作成功;`start()` 同步抛出的解析/meta 失败会变成模型可据以修正的 `isError`。完成时返回规范值 `{ runId, agentsStarted, result }`Native 渲染器保留 meta 名称、agent 数量和 JSON 值,只会在 `maxResultChars` 处截断该投影。
对于根 transport 执行(`exec.parent` 缺省),工具还会把运行投影到调用 Agent 的 Session`start()` 返回后写 run-start只记录 `run.id` 匹配的成员开始与结束,并且只在 `run.result` 已取得且 `dispose()` 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写工作流记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。
浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。
## 渲染意图
渲染意图预先确定(见[渲染意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)):使用一个 `generic` 卡片,标题为 `workflow: <meta.name>`,直接从 `args.meta.name` 读取(呈现是参数的纯函数,不要求引擎解析);脚本文本作为 `rawInput` 携带。结果继续使用 generic 卡片。
@@ -78,3 +82,4 @@ Use the <toolName> tool ONLY when the user explicitly asks for a workflow or for
- **父级轮次会阻塞到整个工作流结算**:没有后台启动/轮询接口,取消会把局部输出作为错误丢弃。
- **`args` 必须是对象Native 结果文本有界**:调用方把顶层数组/标量包装到字段中;规范工作流结果保持完整,超过 `maxResultChars` 的 JSON 会在面向模型的投影中截断,而不是存储在检索句柄背后。
- **每次工具注册的工作流策略固定**:提供方选择、上限和工具名称属于部署配置,不是模型调用参数。
- **持久记录只覆盖顶层且只供观察**:嵌套 Code Mode dispatch 不记录;记录故障会刻意退化为不完整前缀,而不改变执行。

View File

@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
@@ -28,6 +33,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-workflow": "^0.0.1",

View File

@@ -15,8 +15,15 @@ import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'
import type { JsonValue, Session, SessionEventMap } from '@deepseek-ai/dsh-session'
import type {
WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun,
WorkflowRunId, WorkflowRunInfo, WorkflowStopReason,
} from '@deepseek-ai/dsh-workflow'
import type {
ToolWorkflowAgentEndData, ToolWorkflowAgentStartData,
ToolWorkflowRunEndData, ToolWorkflowRunStartData,
} from './types.ts'
// Declaration merge only: makes ctx.systemPrompt visible for the section registration.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -38,6 +45,114 @@ export const Config: z<Config> = z.object({
type ResolvedConfig = Required<Config>
type BufferedWorkflowEvent =
| { readonly kind: 'agent-start'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentInfo }
| { readonly kind: 'agent-end'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentEndInfo }
interface WorkflowRecorder {
bind(run: WorkflowRun): void
finish(stopReason: WorkflowStopReason): void
dispose(): void
}
interface ToolWorkflowRecordEventMap {
'tool-workflow/run-start': ToolWorkflowRunStartData
'tool-workflow/agent-start': ToolWorkflowAgentStartData
'tool-workflow/agent-end': ToolWorkflowAgentEndData
'tool-workflow/run-end': ToolWorkflowRunEndData
}
/** Render a contained recording failure without trusting the thrown value. */
function renderRecordingError(error: unknown): string {
try {
return String(error)
} catch {
return '[unrenderable thrown value]'
}
}
/**
* Project one top-level workflow run into its parent Session without letting
* recording failure affect tool execution. Listeners are installed before
* `start()` so even a synchronous provider cannot outrun the recorder.
*/
function createWorkflowRecorder(ctx: Context, session: Session): WorkflowRecorder {
let runId: WorkflowRunId | undefined
let enabled = true
const buffered: BufferedWorkflowEvent[] = []
// These four package-owned events are all log-only. Narrowing the generic
// append face here lets TypeScript discharge Session.append's conditional
// surface-options tuple once for the complete closed event set.
const appendRecord = session.append.bind(session) as <Type extends keyof ToolWorkflowRecordEventMap>(
type: Type,
data: SessionEventMap[Type],
) => void
const append = <Type extends keyof ToolWorkflowRecordEventMap>(
type: Type,
data: SessionEventMap[Type],
): void => {
if (!enabled) return
try {
appendRecord(type, data)
} catch (error: unknown) {
enabled = false
ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`)
}
}
const record = (event: BufferedWorkflowEvent): void => {
if (runId === undefined) {
buffered.push(event)
return
}
if (event.info.id !== runId) return
if (event.kind === 'agent-start') {
const data: ToolWorkflowAgentStartData = {
runId,
seq: event.agent.seq,
label: event.agent.label,
...event.agent.phase === undefined ? {} : { phase: event.agent.phase },
childId: event.agent.childId,
}
append('tool-workflow/agent-start', data)
return
}
const data: ToolWorkflowAgentEndData = {
runId,
seq: event.agent.seq,
outcome: event.agent.outcome,
}
append('tool-workflow/agent-end', data)
}
const disposeStart = ctx.on('workflow/agent-start', (info, agent) => {
record({ kind: 'agent-start', info, agent })
})
const disposeEnd = ctx.on('workflow/agent-end', (info, agent) => {
record({ kind: 'agent-end', info, agent })
})
return {
bind(run) {
runId = run.id
append('tool-workflow/run-start', { runId, name: run.meta.name })
for (const event of buffered) record(event)
buffered.length = 0
},
finish(stopReason) {
/* v8 ignore next -- execute binds every returned run before result settlement can call finish. */
if (runId === undefined) return
append('tool-workflow/run-end', { runId, stopReason })
},
dispose() {
disposeStart()
disposeEnd()
buffered.length = 0
},
}
}
/**
* The script-authoring contract, embedded in the tool description. This IS the
* model-facing spec: the meta block, the hooks and their exact semantics, and
@@ -188,13 +303,23 @@ export function apply(ctx: Context, config: Config): void {
// Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw
// synchronously here and become isError results via the registry — the
// model sees the violation list and can correct the call.
const run: WorkflowRun = ctx.workflows.start({
script: args.script,
meta: args.meta,
...args.args !== undefined ? { args: args.args } : {},
parent,
signal: exec.signal,
})
const recorder = exec.parent === undefined
? createWorkflowRecorder(ctx, parent.session)
: undefined
let run: WorkflowRun
try {
run = ctx.workflows.start({
script: args.script,
meta: args.meta,
...args.args !== undefined ? { args: args.args } : {},
parent,
signal: exec.signal,
})
} catch (error: unknown) {
recorder?.dispose()
throw error
}
recorder?.bind(run)
// Bridge the tool's abort signal to the run: if the parent step is aborted while the
// script is in flight, cancel the whole run. The signal also enters the engine directly, but
@@ -202,8 +327,9 @@ export function apply(ctx: Context, config: Config): void {
const onAbort = (): void => { run.cancel('parent step aborted') }
exec.signal.addEventListener('abort', onAbort, { once: true })
let result: WorkflowResult | undefined
try {
const result = await run.result
result = await run.result
const error = stopReasonError(result)
if (error !== undefined) {
// Map a non-clean finish to an isError result (the registry turns a
@@ -217,8 +343,15 @@ export function apply(ctx: Context, config: Config): void {
}
} finally {
exec.signal.removeEventListener('abort', onAbort)
// Always reach run quiescence — never leak a live script or children.
await run.dispose()
try {
// Keep member listeners alive through disposal: an engine may
// synthesize cancelled member endings while reaching quiescence.
await run.dispose()
/* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */
if (result !== undefined) recorder?.finish(result.stopReason)
} finally {
recorder?.dispose()
}
}
},
presentCall: args => presentWorkflowCall(args),

View File

@@ -1,30 +1,158 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-workflow`.
* @module @deepseek-ai/dsh-tool-workflow/invariant
*/
/** Package-owned durable workflow-record invariants. @module @deepseek-ai/dsh-tool-workflow/invariant */
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type {} from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow'
/** Cordis companion plugin name. */
export const name = 'tool-workflow-invariant'
/** Service required before the companion can reserve package ownership. */
/** Services required to validate existing and newly appended Session logs. */
export const inject = ['invariants']
/**
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
* relations are owned by the capability seam it calls.
*/
const install: InvariantInstaller = () => {}
interface RunTrace {
ended: boolean
readonly members: Map<number, boolean>
}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
type WorkflowTrace = Map<string, RunTrace>
/** Clone the independent fold before validating one candidate append. */
function cloneTrace(source: WorkflowTrace): WorkflowTrace {
return new Map([...source].map(([runId, run]) => [runId, {
ended: run.ended,
members: new Map(run.members),
}]))
}
/** Require a durable opaque identity to be a non-empty string. */
function stringId(value: unknown, label: string, fail: InvariantFailure): string {
if (typeof value !== 'string' || value.length === 0) fail(`${label} must be a non-empty string`)
return value
}
/** Require one workflow member's 1-based sequence identity. */
function memberSeq(value: unknown, fail: InvariantFailure): number {
if (!Number.isSafeInteger(value) || (value as number) < 1) {
fail('tool-workflow member seq must be a positive safe integer')
}
return value as number
}
/** Read one plain payload field without trusting restored plugin data. */
function recordOf(event: SessionEvent, fail: InvariantFailure): Record<string, unknown> {
const data: unknown = event.data
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
fail(`${event.type} data must be a JSON object`)
}
return data as Record<string, unknown>
}
/** Require the named run to exist and remain open. */
function openRun(trace: WorkflowTrace, runId: string, eventType: string, fail: InvariantFailure): RunTrace {
const run = trace.get(runId)
if (run === undefined) fail(`${eventType} has no matching tool-workflow/run-start for run ${runId}`)
if (run.ended) fail(`${eventType} appears after tool-workflow/run-end for run ${runId}`)
return run
}
/** Advance the workflow-record fold with one relevant Session event. */
function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void {
if (!event.type.startsWith('tool-workflow/')) return
const data = recordOf(event, fail)
const runId = stringId(data.runId, `${event.type} runId`, fail)
switch (event.type) {
case 'tool-workflow/run-start': {
if (typeof data.name !== 'string' || data.name.length === 0) {
fail('tool-workflow/run-start name must be a non-empty string')
}
if (trace.has(runId)) fail(`tool-workflow/run-start repeats run ${runId}`)
trace.set(runId, { ended: false, members: new Map() })
return
}
case 'tool-workflow/agent-start': {
const run = openRun(trace, runId, event.type, fail)
const seq = memberSeq(data.seq, fail)
if (typeof data.label !== 'string') fail('tool-workflow/agent-start label must be a string')
if (data.phase !== undefined && typeof data.phase !== 'string') {
fail('tool-workflow/agent-start phase must be a string when present')
}
stringId(data.childId, 'tool-workflow/agent-start childId', fail)
if (run.members.has(seq)) fail(`tool-workflow/agent-start repeats member seq ${seq} in run ${runId}`)
run.members.set(seq, false)
return
}
case 'tool-workflow/agent-end': {
const run = openRun(trace, runId, event.type, fail)
const seq = memberSeq(data.seq, fail)
if (data.outcome !== 'completed' && data.outcome !== 'failed' && data.outcome !== 'cancelled') {
fail(`tool-workflow/agent-end outcome ${String(data.outcome)} is invalid`)
}
const ended = run.members.get(seq)
if (ended === undefined) fail(`tool-workflow/agent-end has no matching member seq ${seq} in run ${runId}`)
if (ended) fail(`tool-workflow/agent-end repeats member seq ${seq} in run ${runId}`)
run.members.set(seq, true)
return
}
case 'tool-workflow/run-end': {
const run = openRun(trace, runId, event.type, fail)
if (data.stopReason !== 'completed' && data.stopReason !== 'cancelled' && data.stopReason !== 'error') {
fail(`tool-workflow/run-end stopReason ${String(data.stopReason)} is invalid`)
}
const openMembers = [...run.members].filter(([, ended]) => !ended).map(([seq]) => seq)
if (openMembers.length > 0) {
fail(`tool-workflow/run-end leaves member seq ${openMembers.join(', ')} open in run ${runId}`)
}
run.ended = true
return
}
default:
fail(`unknown tool-workflow event type ${event.type}`)
}
}
/** Apply one cold-load or live-append candidate through the package reporter. */
function applyChecked(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void {
applyEvent(trace, event, fail)
}
/** Install an independent incremental fold over every attached Session. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
const traces = new WeakMap<Session, WorkflowTrace>()
const staged = new WeakMap<SessionEvent, { session: Session; trace: WorkflowTrace }>()
const seed = (session: Session): WorkflowTrace => {
const trace: WorkflowTrace = new Map()
for (const event of session.events) applyChecked(trace, event, fail)
traces.set(session, trace)
return trace
}
/* v8 ignore next -- session/event always follows list() or session/created seeding. */
const traceFor = (session: Session): WorkflowTrace => traces.get(session) ?? seed(session)
for (const session of ctx.sessions.list()) seed(session)
ctx.on('session/created', (session) => { seed(session) }, { global: true })
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
const trace = cloneTrace(traceFor(session))
applyChecked(trace, event, fail)
staged.set(event, { session, trace })
}, { global: true })
ctx.on('session/event', (session, event) => {
const candidate = staged.get(event)
/* v8 ignore next 2 -- internal/dispatch stages the exact session/event callback arguments. */
if (candidate === undefined || candidate.session !== session) {
return fail('session/event reached publication without matching workflow-record validation')
}
staged.delete(event)
traces.set(session, candidate.trace)
}, { global: true })
}, { inject: ['sessions'] })
/** Register this package's invariant companion. */
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,64 @@
/**
* Browser-safe durable workflow-record events written by the model-facing
* workflow tool into its calling parent Session.
*
* @module @deepseek-ai/dsh-tool-workflow/types
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
WorkflowAgentOutcome, WorkflowRunId, WorkflowStopReason,
} from '@deepseek-ai/dsh-workflow/types'
/** Opens one durable top-level workflow run record. */
export interface ToolWorkflowRunStartData {
readonly runId: WorkflowRunId
readonly name: string
}
/** Records one workflow member after its child Session is published. */
export interface ToolWorkflowAgentStartData {
readonly runId: WorkflowRunId
readonly seq: number
readonly label: string
readonly phase?: string
readonly childId: SessionId
}
/** Settles one previously started workflow member. */
export interface ToolWorkflowAgentEndData {
readonly runId: WorkflowRunId
readonly seq: number
readonly outcome: WorkflowAgentOutcome
}
/** Settles one workflow run after its live resources reach quiescence. */
export interface ToolWorkflowRunEndData {
readonly runId: WorkflowRunId
readonly stopReason: WorkflowStopReason
}
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* Opens one top-level workflow record.
* @param data - stable run identity and display name.
*/
'tool-workflow/run-start': ToolWorkflowRunStartData
/**
* Records one published workflow member.
* @param data - run identity, member sequence, display identity, and child Session.
*/
'tool-workflow/agent-start': ToolWorkflowAgentStartData
/**
* Records one member settlement.
* @param data - run identity, paired member sequence, and outcome.
*/
'tool-workflow/agent-end': ToolWorkflowAgentEndData
/**
* Closes one workflow record after cleanup.
* @param data - stable run identity and terminal reason.
*/
'tool-workflow/run-end': ToolWorkflowRunEndData
}
}

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import { WorkflowRunId, type WorkflowRunId as WorkflowRunIdType } from '@deepseek-ai/dsh-workflow/types'
import * as ToolWorkflowInvariant from '../src/invariant.ts'
import type {} from '../src/types.ts'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(ToolWorkflowInvariant)
return ctx
}
describe('durable workflow-record invariants', () => {
it('accepts interleaved complete runs and an unfinished continuous prefix', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('workflow-record-valid'))
session.append('turn/start', { turn: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const first = WorkflowRunId('first')
const second = WorkflowRunId('second')
const third = WorkflowRunId('third')
session.append('tool-workflow/run-start', { runId: first, name: 'first' })
session.append('tool-workflow/run-start', { runId: second, name: 'second' })
session.append('tool-workflow/agent-start', {
runId: second, seq: 1, label: '', phase: '', childId: SessionId('child'),
})
session.append('tool-workflow/run-end', { runId: first, stopReason: 'completed' })
session.append('tool-workflow/agent-end', { runId: second, seq: 1, outcome: 'cancelled' })
session.append('tool-workflow/run-end', { runId: second, stopReason: 'cancelled' })
session.append('tool-workflow/run-start', { runId: third, name: 'third' })
session.append('tool-workflow/agent-start', {
runId: third, seq: 1, label: 'failed', childId: SessionId('failed-child'),
})
session.append('tool-workflow/agent-end', { runId: third, seq: 1, outcome: 'failed' })
session.append('tool-workflow/run-end', { runId: third, stopReason: 'error' })
session.append('tool-workflow/run-start', { runId: WorkflowRunId('prefix'), name: 'prefix' })
expect(() => session.append('tool-workflow/agent-start', {
runId: WorkflowRunId('prefix'), seq: 1, label: 'open', childId: SessionId('open-child'),
})).not.toThrow()
})
it('rejects a malformed candidate before commit and keeps the fold reusable', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('workflow-record-invalid'))
const runId = WorkflowRunId('run')
session.append('tool-workflow/run-start', { runId, name: 'run' })
const before = session.seq
expect(() => session.append('tool-workflow/agent-end', {
runId, seq: 1, outcome: 'completed',
})).toThrow(expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-tool-workflow',
}))
expect(session.seq).toBe(before)
expect(() => session.append('tool-workflow/run-end', {
runId, stopReason: 'completed',
})).not.toThrow()
})
type Mutation = (session: Session, runId: WorkflowRunIdType) => void
const appendRaw = (session: Session, type: string, data: unknown): void => {
const append = session.append.bind(session) as (eventType: string, eventData: unknown) => unknown
append(type, data)
}
const invalidCases: readonly [string, Mutation, RegExp][] = [
['null event data', (session) => {
appendRaw(session, 'tool-workflow/run-start', null)
}, /data must be a JSON object/],
['primitive event data', (session) => {
appendRaw(session, 'tool-workflow/run-start', 1)
}, /data must be a JSON object/],
['array event data', (session) => {
appendRaw(session, 'tool-workflow/run-start', [])
}, /data must be a JSON object/],
['numeric run id', (session) => {
session.append('tool-workflow/agent-start', {
runId: 1 as never, seq: 1, label: 'bad', childId: SessionId('child'),
})
}, /runId must be a non-empty string/],
['empty run id', (session) => {
session.append('tool-workflow/agent-start', {
runId: WorkflowRunId(''), seq: 1, label: 'bad', childId: SessionId('child'),
})
}, /runId must be a non-empty string/],
['empty run name', (session) => {
session.append('tool-workflow/run-start', { runId: WorkflowRunId('empty-name'), name: '' })
}, /name must be a non-empty string/],
['non-string run name', (session) => {
session.append('tool-workflow/run-start', { runId: WorkflowRunId('bad-name'), name: 1 as never })
}, /name must be a non-empty string/],
['duplicate run', (session, runId) => {
session.append('tool-workflow/run-start', { runId, name: 'again' })
}, /repeats run/],
['missing run', (session) => {
session.append('tool-workflow/agent-start', {
runId: WorkflowRunId('missing'), seq: 1, label: 'bad', childId: SessionId('child'),
})
}, /no matching tool-workflow\/run-start/],
['non-positive member seq', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 0, label: 'bad', childId: SessionId('child'),
})
}, /positive safe integer/],
['non-integer member seq', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1.5, label: 'bad', childId: SessionId('child'),
})
}, /positive safe integer/],
['non-string member label', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 1 as never, childId: SessionId('child'),
})
}, /label must be a string/],
['non-string member phase', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'bad', phase: 1 as never, childId: SessionId('child'),
})
}, /phase must be a string/],
['empty child id', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'bad', childId: SessionId(''),
})
}, /childId must be a non-empty string/],
['duplicate member start', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'one', childId: SessionId('child'),
})
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'two', childId: SessionId('child-2'),
})
}, /repeats member seq/],
['invalid member outcome', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'one', childId: SessionId('child'),
})
session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'unknown' as never })
}, /outcome unknown is invalid/],
['duplicate member end', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'one', childId: SessionId('child'),
})
session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' })
session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' })
}, /repeats member seq/],
['run end with an open member', (session, runId) => {
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'open', childId: SessionId('child'),
})
session.append('tool-workflow/run-end', { runId, stopReason: 'completed' })
}, /leaves member seq 1 open/],
['invalid run stop reason', (session, runId) => {
session.append('tool-workflow/run-end', { runId, stopReason: 'unknown' as never })
}, /stopReason unknown is invalid/],
['event after run end', (session, runId) => {
session.append('tool-workflow/run-end', { runId, stopReason: 'completed' })
session.append('tool-workflow/agent-start', {
runId, seq: 1, label: 'late', childId: SessionId('child'),
})
}, /appears after/],
['unknown workflow event', (session, runId) => {
appendRaw(session, 'tool-workflow/unknown', { runId })
}, /unknown tool-workflow event type/],
]
it.each(invalidCases)('rejects %s', async (_name, mutate, pattern) => {
const ctx = await setup()
const session = ctx.sessions.create()
const runId = WorkflowRunId('run')
session.append('tool-workflow/run-start', { runId, name: 'run' })
expect(() => { mutate(session, runId) }).toThrow(pattern)
})
it('validates existing cold history while allowing an unfinished prefix', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const valid = ctx.sessions.create(SessionId('workflow-record-cold-valid'))
valid.append('tool-workflow/run-start', { runId: WorkflowRunId('valid'), name: 'valid' })
valid.append('tool-workflow/agent-start', {
runId: WorkflowRunId('valid'), seq: 1, label: 'open', childId: SessionId('child'),
})
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(ToolWorkflowInvariant)).resolves.toBeDefined()
const brokenCtx = new Context()
await brokenCtx.plugin(SessionStore)
const broken = brokenCtx.sessions.create(SessionId('workflow-record-cold-invalid'))
broken.append('tool-workflow/run-start', { runId: WorkflowRunId('broken'), name: 'broken' })
broken.append('tool-workflow/run-end', { runId: WorkflowRunId('broken'), stopReason: 'completed' })
broken.append('tool-workflow/agent-start', {
runId: WorkflowRunId('broken'), seq: 1, label: 'late', childId: SessionId('late'),
})
await brokenCtx.plugin(InvariantService, { enabled: true })
await expect(brokenCtx.plugin(ToolWorkflowInvariant)).rejects.toThrow(/appears after/)
})
})

View File

@@ -3,15 +3,18 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import type {
WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun,
WorkflowRunId as WorkflowRunIdType, WorkflowStartRequest,
} from '@deepseek-ai/dsh-workflow'
import { CallId } from '@deepseek-ai/dsh-llm'
import SubagentService from '@deepseek-ai/dsh-subagent'
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
import * as toolWorkflow from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
const testToolSignal = new AbortController().signal
@@ -20,30 +23,62 @@ class StubEngine extends WorkflowService {
requests: WorkflowStartRequest[] = []
cancels: string[] = []
disposed = 0
disposeBarrier: Promise<void> | undefined
settle!: (result: WorkflowResult) => void
readonly settlements = new Map<WorkflowRunIdType, (result: WorkflowResult) => void>()
startError: Error | undefined
emitMemberDuringStart = false
start(request: WorkflowStartRequest): WorkflowRun {
if (this.startError) throw this.startError
this.requests.push(request)
const id = WorkflowRunId(`run-${this.requests.length}`)
const result = new Promise<WorkflowResult>((resolve) => { this.settle = resolve })
this.settlements.set(id, this.settle)
if (this.emitMemberDuringStart) {
const info = { id, meta: request.meta }
const member = { seq: 1, label: 'synchronous', childId: SessionId('sync-child') }
this.emitWorkflowEvent('workflow/agent-start', info, member)
this.emitWorkflowEvent('workflow/agent-end', info, { ...member, outcome: 'completed' })
}
request.signal?.addEventListener('abort', () => {
this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 })
}, { once: true })
return {
id: WorkflowRunId('run-1'),
meta: { name: 'stub-flow', description: 'd' },
id,
meta: request.meta,
result,
cancel: (reason?: string) => {
this.cancels.push(reason ?? 'cancelled')
this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 })
},
dispose: () => {
dispose: async () => {
this.disposed += 1
return Promise.resolve()
await this.disposeBarrier
this.settlements.delete(id)
},
}
}
settleRun(id: WorkflowRunIdType, result: WorkflowResult): void {
const settle = this.settlements.get(id)
if (settle === undefined) throw new Error(`unknown stub workflow ${id}`)
settle(result)
}
agentStart(id: WorkflowRunIdType, agent: WorkflowAgentInfo): void {
this.emitWorkflowEvent('workflow/agent-start', {
id,
meta: this.requests[Number(String(id).slice(4)) - 1]!.meta,
}, agent)
}
agentEnd(id: WorkflowRunIdType, agent: WorkflowAgentEndInfo): void {
this.emitWorkflowEvent('workflow/agent-end', {
id,
meta: this.requests[Number(String(id).slice(4)) - 1]!.meta,
}, agent)
}
}
async function setup(config?: { toolName?: string; maxResultChars?: number }) {
@@ -53,14 +88,19 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) {
await ctx.plugin(StubEngine)
await ctx.plugin(toolWorkflow, config ?? {})
const engine = ctx.workflows as StubEngine
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
return { ctx, engine, parent }
const session = Session.create(SessionId('caller'))
const parent = { id: session.id, options: {}, session } as unknown as Agent
return { ctx, engine, parent, session }
}
const SCRIPT = 'return 1'
const META = { name: 'audit', description: 'd' }
function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
function execute(ctx: Context, args: unknown, extra?: {
agent?: Agent
signal?: AbortSignal
parent?: ToolExecutionToken
}): Promise<ToolExecutionResult> {
return ctx.tools.execute({
signal: testToolSignal,
callId: CallId('call-1'),
@@ -68,6 +108,7 @@ function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?:
arguments: args,
...extra?.agent ? { agent: extra.agent } : {},
...extra?.signal ? { signal: extra.signal } : {},
...extra?.parent ? { parent: extra.parent } : {},
})
}
@@ -90,6 +131,167 @@ describe('dsh-tool-workflow', () => {
expect(engine.disposed).toBe(1)
})
it('records one top-level run and its members in the calling Session after cleanup', async () => {
const { ctx, engine, parent, session } = await setup()
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
const runId = WorkflowRunId('run-1')
engine.agentStart(runId, {
seq: 1,
label: '',
phase: '',
childId: SessionId('child-1'),
})
engine.agentEnd(runId, {
seq: 1,
label: '',
phase: '',
childId: SessionId('child-1'),
outcome: 'completed',
})
engine.settleRun(runId, { value: 1, stopReason: 'completed', agentsStarted: 1 })
expect((await pending).isError).toBe(false)
expect(engine.disposed).toBe(1)
expect(session.events.map(event => [event.type, event.data])).toEqual([
['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }],
['tool-workflow/agent-start', {
runId: 'run-1', seq: 1, label: '', phase: '', childId: 'child-1',
}],
['tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }],
['tool-workflow/run-end', { runId: 'run-1', stopReason: 'completed' }],
])
})
it('writes run-end only after run disposal reaches quiescence', async () => {
const { ctx, engine, parent, session } = await setup()
const barrier = Promise.withResolvers<undefined>()
engine.disposeBarrier = barrier.promise
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
engine.settleRun(WorkflowRunId('run-1'), {
value: null, stopReason: 'completed', agentsStarted: 0,
})
await vi.waitFor(() => { expect(engine.disposed).toBe(1) })
expect(session.events.map(event => event.type)).toEqual(['tool-workflow/run-start'])
barrier.resolve(undefined)
expect((await pending).isError).toBe(false)
expect(session.events.map(event => event.type)).toEqual([
'tool-workflow/run-start', 'tool-workflow/run-end',
])
})
it('records zero-member and concurrent runs independently', async () => {
const { ctx, engine, parent, session } = await setup()
const first = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'first' } }, { agent: parent })
const second = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'second' } }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) })
const secondId = WorkflowRunId('run-2')
engine.agentStart(secondId, {
seq: 1, label: 'member', childId: SessionId('child-2'),
})
engine.agentEnd(secondId, {
seq: 1, label: 'member', childId: SessionId('child-2'), outcome: 'failed',
})
engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 })
engine.settleRun(secondId, { value: null, stopReason: 'error', error: 'child failed', agentsStarted: 1 })
expect((await first).isError).toBe(false)
expect((await second).isError).toBe(true)
expect(session.events.filter(event => event.type === 'tool-workflow/agent-start'))
.toHaveLength(1)
expect(session.events.filter(event => event.type === 'tool-workflow/run-end').map(event => event.data))
.toEqual([
{ runId: 'run-1', stopReason: 'completed' },
{ runId: 'run-2', stopReason: 'error' },
])
})
it('buffers synchronous member events until start returns the run identity', async () => {
const { ctx, engine, parent, session } = await setup()
engine.emitMemberDuringStart = true
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
engine.settleRun(WorkflowRunId('run-1'), {
value: null, stopReason: 'completed', agentsStarted: 1,
})
expect((await pending).isError).toBe(false)
expect(session.events.map(event => event.type)).toEqual([
'tool-workflow/run-start',
'tool-workflow/agent-start',
'tool-workflow/agent-end',
'tool-workflow/run-end',
])
})
it('does not record nested transport executions', async () => {
const { ctx, engine, parent, session } = await setup()
const pending = execute(ctx, { script: SCRIPT, meta: META }, {
agent: parent,
parent: Symbol('outer') as ToolExecutionToken,
})
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 })
expect((await pending).isError).toBe(false)
expect(session.events).toEqual([])
})
it.each([
'tool-workflow/run-start',
'tool-workflow/agent-start',
'tool-workflow/agent-end',
'tool-workflow/run-end',
] as const)('isolates a first append failure at %s and preserves a valid prefix', async (failedType) => {
const { ctx, engine, parent, session } = await setup()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
const append = session.append.bind(session)
session.append = ((type: Parameters<Session['append']>[0], data: never) => {
if (type === failedType) throw new Error(`injected ${failedType} failure`)
return append(type, data)
}) as Session['append']
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
const runId = WorkflowRunId('run-1')
engine.agentStart(runId, {
seq: 1, label: 'member', childId: SessionId('child-1'),
})
engine.agentEnd(runId, {
seq: 1, label: 'member', childId: SessionId('child-1'), outcome: 'completed',
})
engine.settleRun(runId, { value: null, stopReason: 'completed', agentsStarted: 1 })
expect((await pending).isError).toBe(false)
expect(engine.disposed).toBe(1)
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain(failedType)
const types = session.events.map(event => event.type)
const expectedPrefixes = {
'tool-workflow/run-start': [],
'tool-workflow/agent-start': ['tool-workflow/run-start'],
'tool-workflow/agent-end': ['tool-workflow/run-start', 'tool-workflow/agent-start'],
'tool-workflow/run-end': [
'tool-workflow/run-start', 'tool-workflow/agent-start', 'tool-workflow/agent-end',
],
} as const
expect(types).toEqual(expectedPrefixes[failedType])
})
it('contains an append failure whose thrown value cannot be rendered', async () => {
const { ctx, engine, parent, session } = await setup()
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
session.append = () => {
throw { toString: () => { throw new Error('coercion trap') } }
}
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
engine.settleRun(WorkflowRunId('run-1'), {
value: null, stopReason: 'completed', agentsStarted: 0,
})
expect((await pending).isError).toBe(false)
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('[unrenderable thrown value]')
})
it('maps a non-completed stop reason to an isError result (and still disposes)', async () => {
const { ctx, engine, parent } = await setup()
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
@@ -251,7 +453,8 @@ describe('dsh-tool-workflow', () => {
})
await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 })
await ctx.plugin(toolWorkflow, {})
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
const session = Session.create(SessionId('caller'))
const parent = { id: session.id, options: {}, session } as unknown as Agent
const controller = new AbortController()
const pending = execute(ctx, {
script: 'await new Promise(() => {})\nreturn 1',

View File

@@ -20,6 +20,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../llm/llm"
},

View File

@@ -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/workflow/workflow/README.md
README.md: 0de661423206cc71eb4669bc8ddb2419202bcb4a
README.zh.md: 62abd00c013d054f4111a2db2ce72c58d3514087
README.md: f1b101159e656d7d76812c95c020fe6b3f48115e
README.zh.md: 6d85c3b7e847b8c6176d4c1938805c22c543678b

View File

@@ -6,6 +6,8 @@ The workflow seam (`ctx.workflows`) executes a model-written orchestration scrip
`@deepseek-ai/dsh-workflow-workerthread` is the current engine and `@deepseek-ai/dsh-tool-workflow` is the model-facing consumer. A future process or sandbox engine can replace the implementation without changing the tool.
The package root is the Host face. The browser-safe `@deepseek-ai/dsh-workflow/types` subpath contains run identities, metadata, results, and observe-only lifecycle payloads without importing `Agent`, Cordis services, or Host context declarations; Host-only `WorkflowStartRequest` and `WorkflowRun` live behind the package root.
## Service and run contract
`WorkflowService.start(request): WorkflowRun` validates enough synchronously to reject a malformed meta block, unparseable script, unavailable provider route, or unsupported per-run limit before a run exists. Once returned, `WorkflowRun.result` never rejects: execution failures resolve with `stopReason: 'error'`, and cancellation resolves with `cancelled` within the engine's bounded grace.

View File

@@ -6,6 +6,8 @@
`@deepseek-ai/dsh-workflow-workerthread` 是当前引擎,`@deepseek-ai/dsh-tool-workflow` 是面向模型的消费方。未来的进程或沙箱引擎可以替换实现,而无需更改工具。
包根是 Host face。浏览器安全的 `@deepseek-ai/dsh-workflow/types` 子路径包含运行身份、元数据、结果和仅供观察的生命周期 payload不导入 `Agent`、Cordis service 或 Host Context 声明Host 专用的 `WorkflowStartRequest``WorkflowRun` 只从包根提供。
## 服务与运行约定
`WorkflowService.start(request): WorkflowRun` 会同步完成足够多的校验,在运行创建前拒绝格式错误的 meta 块、无法解析的脚本、不可用的提供方路由或不受支持的单次运行限制。返回后,`WorkflowRun.result` 绝不拒绝:执行失败以 `stopReason: 'error'` 兑现,取消则在引擎有限的宽限时间内以 `cancelled` 兑现。

View File

@@ -15,12 +15,17 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",

View File

@@ -10,10 +10,9 @@ import type {
WorkflowAgentEndInfo,
WorkflowAgentInfo,
WorkflowResultInfo,
WorkflowRun,
WorkflowRunInfo,
WorkflowStartRequest,
} from './types.ts'
import type { WorkflowRun, WorkflowStartRequest } from './runtime-types.ts'
export { WorkflowRunId } from './types.ts'
export type {
@@ -24,11 +23,10 @@ export type {
WorkflowPhase,
WorkflowResult,
WorkflowResultInfo,
WorkflowRun,
WorkflowRunInfo,
WorkflowStartRequest,
WorkflowStopReason,
} from './types.ts'
export type { WorkflowRun, WorkflowStartRequest } from './runtime-types.ts'
declare module 'cordis' {
interface Context {

View File

@@ -0,0 +1,49 @@
/**
* Host-only workflow request and live-run handles. The browser-safe durable
* vocabulary remains in `./types` so Client programs never import Agent or
* host Cordis context declarations.
*
* @module @deepseek-ai/dsh-workflow
*/
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
WorkflowMeta, WorkflowResult, WorkflowRunId,
} from './types.ts'
/**
* What a caller asks for when starting a workflow run. `meta` and `args` are
* plain JSON data by the seam contract. `parent` is required because every
* `agent()` spawned by the script is attributed to that live Agent.
*/
export interface WorkflowStartRequest {
/** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */
script: string
/** The workflow's identity block, as plain JSON data (shape-validated by the engine). */
meta: WorkflowMeta
/** Optional input exposed verbatim to the script as the `args` global. */
args?: unknown
/** Optional engine-wide child-provider override for this run. */
subagentProvider?: string
/** Optional per-run total-child ceiling. */
maxTotalAgents?: number
/** The agent on whose behalf the run executes (parent of every child). */
parent: Agent
/** Cancels the run when aborted. */
signal?: AbortSignal
}
/**
* Holder-owned live workflow. `result` never rejects; consumers may cancel
* and must call idempotent `dispose()` to await script and child quiescence.
*/
export interface WorkflowRun {
readonly id: WorkflowRunId
/** The validated meta block available before the script body runs. */
readonly meta: WorkflowMeta
readonly result: Promise<WorkflowResult>
/** Cancel the run and its children. */
cancel(reason?: string): void
/** Cancel if needed and await bounded settlement and cleanup. */
dispose(): Promise<void>
}

View File

@@ -7,8 +7,7 @@
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
/** Identifies one workflow run. */
export type WorkflowRunId = Branded<'WorkflowRunId'>
@@ -55,38 +54,6 @@ export interface WorkflowMeta {
phases?: WorkflowPhase[]
}
/**
* What a caller asks for when starting a workflow run. `meta` and `args` are
* plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call;
* the engine validates `meta` against its schema and rejects loud
* before anything runs) — an engine never evaluates script text to obtain
* them. `parent` is REQUIRED — every `agent()` the script spawns is
* attributed to it (cwd, lineage, depth flow through the subagent seam).
*/
export interface WorkflowStartRequest {
/** The plain-JS script body (top-level await allowed; ends with `return <json-value>`). */
script: string
/** The workflow's identity fields as plain JSON data, validated by the engine. */
meta: WorkflowMeta
/** Optional input exposed verbatim to the script as the `args` global. */
args?: unknown
/**
* Optional engine-wide child-provider override for this run. The workflow
* script cannot observe or replace it; omission uses the engine's configured
* provider.
*/
subagentProvider?: string
/**
* Optional per-run total-child ceiling. Implementations reject values above
* their deployment ceiling before publishing the run.
*/
maxTotalAgents?: number
/** The agent on whose behalf the run executes (parent of every child). */
parent: Agent
/** Cancels the run when aborted (the tool's `exec.signal`). */
signal?: AbortSignal
}
/**
* Why a run settled. CLOSED union (engine-owned, consumers may exhaust):
* `completed` = the script ran to its final `return`; `cancelled` = the run
@@ -96,7 +63,7 @@ export interface WorkflowStartRequest {
export type WorkflowStopReason = 'completed' | 'cancelled' | 'error'
/**
* The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is
* The outcome resolved by a live workflow run. `value` is
* the script's materialized return value (plain host-realm JSON data; `null`
* when the script returned `undefined`) — meaningful only for `completed`.
* A non-`completed` reason carries the failure in `error`; the consumer maps
@@ -119,23 +86,6 @@ export interface WorkflowResult {
agentsStarted: number
}
/**
* Holder-owned live workflow. `result` never rejects and settles within the
* engine's cancellation grace; failures resolve through `stopReason`. Consumers
* may cancel and must call idempotent `dispose()` on every path to await bounded
* script settlement and child quiescence.
*/
export interface WorkflowRun {
readonly id: WorkflowRunId
/** The validated meta block (available before the body runs). */
readonly meta: WorkflowMeta
readonly result: Promise<WorkflowResult>
/** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */
cancel(reason?: string): void
/** Cancel + bounded-grace settle; safe to call on every path (idempotent). */
dispose(): Promise<void>
}
/** Identifying detail for a run, carried by every `workflow/*` event as borrowed immutable data, never the live run. */
export interface WorkflowRunInfo {
/** The run's id. */

View File

@@ -17,6 +17,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../util/brand"
},

46
pnpm-lock.yaml generated
View File

@@ -1529,6 +1529,9 @@ importers:
'@deepseek-ai/dsh-client-ui-trajectory':
specifier: workspace:^
version: link:../../client/ui-trajectory
'@deepseek-ai/dsh-client-ui-workflow-run':
specifier: workspace:^
version: link:../../client/ui-workflow-run
'@deepseek-ai/dsh-client-ui-workspace':
specifier: workspace:^
version: link:../../client/ui-workspace
@@ -2758,6 +2761,49 @@ importers:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
packages/client/ui-workflow-run:
dependencies:
react:
specifier: ^18.2.0
version: 18.3.1
devDependencies:
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:^
version: link:../test-runtime
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-tool-workflow':
specifier: workspace:^
version: link:../../workflow/tool-workflow
'@deepseek-ai/dsh-workflow':
specifier: workspace:^
version: link:../../workflow/workflow
'@types/react':
specifier: ~18.3.1
version: 18.3.31
cordis:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis
packages/client/ui-workspace:
dependencies:
clsx:

View File

@@ -1278,7 +1278,7 @@
{
"doc": "docs/subsystems/workflow.md",
"symbol": "WorkflowStartRequest",
"source": "packages/workflow/workflow/src/types.ts"
"source": "packages/workflow/workflow/src/runtime-types.ts"
},
{
"doc": "docs/subsystems/workflow.md",
@@ -1293,7 +1293,7 @@
{
"doc": "docs/subsystems/workflow.md",
"symbol": "WorkflowRun",
"source": "packages/workflow/workflow/src/types.ts"
"source": "packages/workflow/workflow/src/runtime-types.ts"
},
{
"doc": "docs/subsystems/lsp.md",

View File

@@ -72,6 +72,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' },
'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },
'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' },

View File

@@ -69,6 +69,8 @@
"@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
"@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"],
"@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"],
"@deepseek-ai/dsh-workflow/types": ["./packages/workflow/workflow/src/types.ts"],
"@deepseek-ai/dsh-tool-workflow/types": ["./packages/workflow/tool-workflow/src/types.ts"],
"@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"],
"@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"],
"@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"],
@@ -169,6 +171,7 @@
"@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
"@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"],
"@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"],
"@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"],
"@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"],
"@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"],
"@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"],

View File

@@ -61,6 +61,7 @@
{ "path": "./packages/client/ui-conversation" },
{ "path": "./packages/client/ui-tool" },
{ "path": "./packages/client/ui-deliverables" },
{ "path": "./packages/client/ui-workflow-run" },
{ "path": "./packages/client/ui-workspace" },
{ "path": "./packages/client/ui-slash" },
{ "path": "./packages/client/ui-command" },

View File

@@ -70,6 +70,7 @@
"apps/web/tests/composer-tab-geometry.e2e.ts",
"apps/web/tests/complex-history.perf.ts",
"apps/web/tests/pwsh-terminal.e2e.ts",
"apps/web/tests/workflow-run.e2e.ts",
"apps/web/stress-tests/reasoning-chunks.stress.ts",
"apps/cli/tests/**/*.ts",
"examples/*/src/**/*.ts",