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

@@ -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"
},