From a7ddded2ef44bc806a96f9ee00806109ee62b130 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 20:56:31 +0800 Subject: [PATCH] fix(workflow): address ready review findings --- ...apse-workflow-to-foreground-core.i18n.yaml | 4 +- ...12-collapse-workflow-to-foreground-core.md | 12 ++-- ...collapse-workflow-to-foreground-core.zh.md | 12 ++-- .../snapshots/workflow-run/ui.expected.md | 8 +-- .../snapshots/workflow-run/session.jsonl | 2 +- .../src/client/WorkflowRunPanel.tsx | 60 +++++++++++-------- .../ui-workflow-run/src/client/locales.ts | 6 +- .../src/client/workflow-definition.ts | 16 +++-- .../tests/workflow-run.spec.tsx | 4 +- packages/workflow/tool-workflow/src/index.ts | 9 ++- .../workflow/tool-workflow/src/invariant.ts | 9 +-- 11 files changed, 80 insertions(+), 62 deletions(-) diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index cc9f18fbef..9ade4e5770 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md -2026-07-12-collapse-workflow-to-foreground-core.md: 9151d9fb72a97aadf040fbdc13b5e0a4943f2f30 -2026-07-12-collapse-workflow-to-foreground-core.zh.md: c9eafe83e931de7aec4ec39e2471f0669c73609d +2026-07-12-collapse-workflow-to-foreground-core.md: 5fc46584f83eb5307ff16f3353b56951b928aef3 +2026-07-12-collapse-workflow-to-foreground-core.zh.md: 0b4c73e5df973215b10166f3dc2bbd525cc8231b diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md index 9151d9fb72..5fc46584f8 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md @@ -6,11 +6,15 @@ English | [中文](2026-07-12-collapse-workflow-to-foreground-core.zh.md) ## Problem -The workflow capability carries an observe-only lifecycle beside its execution handle. That surface can look removable because the script still completes without a UI listener, but it is the only provider-neutral source of the actual members that started, their exact labels and phases, and their paired outcomes. +The workflow capability executes foreground JavaScript that composes subagents, but it also carries an unconsumed progress-observation system. No production listener subscribes to any of the six `workflow/*` events; listeners exist only in workflow tests. Nevertheless the seam defines run/phase/agent outcome payloads, the worker sends phase/log/agent lifecycle protocol messages, the host forwards them through a `liveAgents` pairing ledger, and the engine maintains run ids solely to correlate those notifications. -The top-level `dsh-tool-workflow` consumer now uses those events to write four minimal `tool-workflow/*` facts into the calling parent Session, and `ui-workflow-run` rebuilds them into a durable Chat node. The consumer deliberately owns the projection because it alone holds the calling Agent, knows whether the tool execution is top-level, and can keep recording failure separate from workflow execution. `WorkflowRun.id` and `meta` therefore correlate live engine events with that exact durable record rather than duplicating presentation state. +The progress vocabulary is not merely unused; it cannot serve its only named future owner without redesign. `WorkflowRunInfo` contains `{id, meta}` but no parent agent, session, or tool-call identity, while the model-facing tool never exposes the run id. A global ACP listener could not route an event to the correct client session. `meta.phases` is never consulted, `phase(title)` does not validate against it, phase `detail`/`model` and agent `label`/`phase` feed only events, and `whenToUse` is validated and copied but never rendered or selected. `phase()` and `log()` still cross the worker boundary despite having no receiver. -Deleting the event vocabulary, member labels or phases, or run identity would remove the current replay and navigation result rather than merely simplify unused scaffolding. The rejected proposal below remains useful as the contraction to avoid; [durable workflow runs in Chat](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md) owns the present consumer and boundaries. +The live handle repeats event-era data after those observers disappear. `WorkflowRun.id` has no non-event consumer, while the tool reads `run.meta.name` only to render a value it already owns as `args.meta.name`; neither belongs on the execution/cancellation handle. + +Cancellation also has two public channels for one synchronous start. `WorkflowStartRequest.signal` is passed to the worker host, while the sole production caller separately bridges the same signal to `WorkflowRun.cancel()`. Because `start()` returns the run before control can yield, there is no readiness window that requires request-time cancellation; the duplicate signal adds host listener/disarm state without closing a race. + +`WorkflowError.fatal` is the same speculative branch in miniature: every production construction is fatal, `fatal: false` exists only in tests, and combinators already distinguish workflow failures with `instanceof`. ## Proposal @@ -20,7 +24,7 @@ Amend the implemented dynamic-workflow Agent Note and update the seam/tool/worke ## Alternatives considered -**Move durable recording into the workflow engine.** The engine knows run and member lifecycle but does not own the calling parent Session or the top-level-versus-nested tool boundary. Giving it those facts would couple a provider seam to one consumer and make recording failure part of engine execution. The tool-owned projection adds the missing ownership without widening worker messages or the service contract. +**Keep the prebuilt observation vocabulary for a future UI.** The current shape resembles Claude Code dynamic-workflow metadata, and the host deliberately pairs each forwarded agent start with either the worker's end or a synthesized terminal end. Removing it gives up compatibility-by-shape and makes progress UI a new design task, but the existing payloads still lack routable ownership, so balanced lifecycles alone cannot make the named ACP owner viable without redesign. ## Acceptance criteria diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index c9eafe83e9..0b4c73e5df 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -6,11 +6,15 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 问题 -工作流能力在执行句柄之外还携带一套只供观察的生命周期。脚本即使没有 UI 监听器也能完成,因此这套界面看似可删除;但它是唯一与提供方无关、能够报告真正开始过的成员、精确标签与阶段以及配对结果的事实来源。 +工作流能力在前台执行用于编排 subagent 的 JavaScript,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent(智能体)outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 -顶层 `dsh-tool-workflow` 消费方现在利用这些事件,把四类最小 `tool-workflow/*` 事实写入调用方父 Session;`ui-workflow-run` 再把它们重建为持久 Chat 节点。投影由消费方拥有,因为只有它同时持有调用 Agent、知道工具执行是顶层还是嵌套,并能让记录故障与工作流执行隔离。`WorkflowRun.id` 与 `meta` 因此用于把实时引擎事件关联到该条精确持久记录,而不是复制展示状态。 +这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent、会话或工具调用标识,而面向模型的工具也从不暴露 run id。一个全局 ACP(Agent Client Protocol)监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 -删除事件词汇、成员标签或阶段、运行身份,会移除当前回放和导航结果,而不再只是清理未使用脚手架。下方提案继续记录应避免的收缩;[Chat 中的持久工作流运行](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md)拥有当前消费方与边界。 +这些观测者移除后,live handle 仍重复携带事件机制所需的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 + +取消机制也为一个同步启动提供了两条公开通道。`WorkflowStartRequest.signal` 被传递给 worker host,而唯一的生产调用方另外将同一个 signal 桥接到 `WorkflowRun.cancel()`。因为 `start()` 在控制权让出之前就返回了 run,不存在需要请求时取消的就绪窗口;重复的 signal 增加了 host 的 listener/disarm 状态却没有封堵任何竞态。 + +`WorkflowError.fatal` 是同一种推测性分支的微缩版:所有生产环境的构造都是 fatal 的,`fatal: false` 仅存在于测试中,组合子已经通过 `instanceof` 区分工作流失败。 ## 提案 @@ -20,7 +24,7 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 曾考虑的替代方案 -**把持久记录移入工作流引擎。** 引擎知道运行与成员生命周期,却不拥有调用方父 Session,也不知道顶层与嵌套工具边界。把这些事实交给引擎会让提供方 seam 耦合到单一消费方,并使记录故障进入引擎执行域。由工具拥有的投影补齐了缺失所有权,同时不扩展 worker 消息或 service 合同。 +**为未来 UI 保留预建的观测词汇。** 当前形态类似 Claude Code 的动态工作流元数据,host 有意地将每个转发的 agent start 与 worker 的 end 或一个合成的终止 end 配对。移除它意味着放弃形态兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让已命名的 ACP 消费方可行。 ## 验收标准 diff --git a/apps/web/tests/snapshots/workflow-run/ui.expected.md b/apps/web/tests/snapshots/workflow-run/ui.expected.md index 297aad1b70..be377da995 100644 --- a/apps/web/tests/snapshots/workflow-run/ui.expected.md +++ b/apps/web/tests/snapshots/workflow-run/ui.expected.md @@ -13,12 +13,12 @@ - img - img - text: Tool call workflow · -- button "snapshot-flow 1 members Completed" [expanded]: +- button "snapshot-flow 1 member Completed" [expanded]: - img - - text: snapshot-flow 1 members Completed -- button "Run 1 members Completed 1" [expanded]: + - text: snapshot-flow 1 member Completed +- button "Run 1 member Completed 1" [expanded]: - img - - text: Run 1 members Completed 1 + - text: Run 1 member Completed 1 - text: Reply with exactly the word WF_CHILD_OK and not… Completed - button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.": - img diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 16d284eb09..75efc1a3e0 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -28,7 +28,7 @@ {"type":"assistant/chunk","seq":173,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":174,"time0":1783600640862,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," workflow"," returned"," successfully"," with"," the"," reply"," \"","WF","_CH","ILD","_OK","\"."," Now"," I"," need"," to"," reply"," with"," exactly"," \"","WORK","FL","OW","_D","ONE","\""," and"," stop","."]}} {"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":205,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,1898159500,231656974],"texts":["WORK","FL","OW","_D","ONE"]}} +{"type":"text-chunks","seq0":205,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0],"texts":["WORK","FL","OW","_D","ONE"]}} {"type":"assistant/chunk","seq":210,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} {"type":"assistant/chunk","seq":211,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} {"type":"assistant/chunk","seq":212,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index 8e48ffb4be..fcb36da7a3 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -1,9 +1,9 @@ -import { useMemo, useState } from 'react' +import { useState } from 'react' import { DisclosureRow, 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 { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkflowRunKey } from './locales.ts' import type { WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus, @@ -58,6 +58,10 @@ function statusCount( return t(`statusCount.${status}`, { count }) } +function memberCount(count: number, t: WorkflowRunPanelProps['t']): string { + return t(count === 1 ? 'run.members.one' : 'run.members.other', { count }) +} + function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string { const counts = new Map() for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1) @@ -71,6 +75,28 @@ function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: Workfl return visible.map(status => statusCount(status, count(status), t)).join(' · ') } +function navigableMembers( + sessions: SessionListState, + phases: readonly WorkflowRunPhaseData[], + parentId: SessionId, +): readonly SessionId[] { + const ordinary = new Set(sessions.ids) + const result: SessionId[] = [] + for (const phase of 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 === parentId + && summary.running) { + result.push(member.childId) + } + } + } + return result +} + function RunHeader({ count, name, onToggle, open, status, t }: { readonly count: number readonly name: string @@ -95,7 +121,7 @@ function RunHeader({ count, name, onToggle, open, status, t }: { collapsedContent={( <> - {t('run.members', { count })} + {memberCount(count, t)} {t(STATUS_KEYS[status])} @@ -138,7 +164,7 @@ function MemberRow({ member, navigable, openSession, t }: { function PhaseSection({ phase, navigable, openSession, t }: { readonly phase: WorkflowRunPhaseData - readonly navigable: ReadonlySet + readonly navigable: readonly SessionId[] readonly openSession: WorkflowRunInjected['openSession'] readonly t: WorkflowRunPanelProps['t'] }) { @@ -161,7 +187,7 @@ function PhaseSection({ phase, navigable, openSession, t }: { collapsedContent={( <> - {t('run.members', { count: phase.members.length })} + {memberCount(phase.members.length, t)} {phaseStatusSummary(phase.members, t)} )} @@ -171,7 +197,7 @@ function PhaseSection({ phase, navigable, openSession, t }: { @@ -184,25 +210,11 @@ function PhaseSection({ phase, navigable, openSession, t }: { /** 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 memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0) - const navigable = useMemo(() => { - const ordinary = new Set(sessions.ids) - const result = new Set() - 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]) + const navigable = useSessions( + sessions => navigableMembers(sessions, node.data.phases, sessionId), + shallowEqual, + ) return (
= { 'run.title': '{name}', - 'run.members': '{count} members', + 'run.members.one': '{count} member', + 'run.members.other': '{count} members', 'run.empty': 'No members started', 'phase.unassigned': 'Unphased', 'phase.empty': 'Empty phase name', diff --git a/packages/client/ui-workflow-run/src/client/workflow-definition.ts b/packages/client/ui-workflow-run/src/client/workflow-definition.ts index 3a4672d30b..2716988941 100644 --- a/packages/client/ui-workflow-run/src/client/workflow-definition.ts +++ b/packages/client/ui-workflow-run/src/client/workflow-definition.ts @@ -80,8 +80,7 @@ function statusFromOutcome(outcome: WorkflowAgentOutcome): WorkflowRunStatus { } } -function locationClosed(location: ConversationLocation | undefined): boolean { - if (location === undefined) return false +function locationClosed(location: ConversationLocation): boolean { if (location.kind === 'step') { return location.step.status === 'closed' || location.turn.status === 'closed' } @@ -90,11 +89,11 @@ function locationClosed(location: ConversationLocation | undefined): boolean { function projectWorkflow( context: ConversationNodeContext, -): WorkflowRunChatData | undefined { - const state = context.state - if (state === undefined) return undefined + location: ConversationLocation, +): WorkflowRunChatData { + const state = context.state as WorkflowState const interrupted = state.stopReason === undefined - && locationClosed(context.start?.location ?? context.matches[0]?.location) + && locationClosed(location) const phases = new Map() for (const member of state.members) { const phase = member.phase === undefined ? null : member.phase @@ -177,9 +176,8 @@ export const workflowRunDefinition: ConversationNodeDefinition = 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 + if (target !== 'chat' || context.start === undefined) return null + const data = projectWorkflow(context, context.start.location) return { key: context.key, kind: 'workflow-run', diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 71ca4109a7..38e779f961 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -7,7 +7,7 @@ import { } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition, - ConversationViewDefinition, ConversationViewNode, SessionId, SessionListState, + ConversationViewDefinition, 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' @@ -517,5 +517,3 @@ describe('plugin lifecycle', () => { expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run']) }) }) - -void ({} as ConversationViewNode) diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index b479e6c9fc..ad0ee0e51d 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -289,6 +289,8 @@ export function apply(ctx: Context, config: Config): void { signal: exec.signal, }) const recordsRun = exec.parent === undefined + // The shipped worker-thread engine publishes member events from later + // worker messages, after start() returns and this run record is active. if (recordsRun) recorder.start(parent.session, run) // Bridge the tool's abort signal to the run: if the parent step is aborted while the @@ -317,8 +319,11 @@ export function apply(ctx: Context, config: Config): void { // 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 (recordsRun && result !== undefined) recorder.finish(run.id, result.stopReason) + if (recordsRun) { + /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ + if (result === undefined) throw new Error('workflow run settled without a result') + recorder.finish(run.id, result.stopReason) + } } finally { if (recordsRun) recorder.abandon(run.id) } diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 127b6d8780..549b317379 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -128,11 +128,6 @@ function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFa } } -/** 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() @@ -140,7 +135,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): WorkflowTrace => { const trace: WorkflowTrace = new Map() - for (const event of session.events.filter(isWorkflowRecordEvent)) applyChecked(trace, event, fail) + for (const event of session.events.filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail) traces.set(session, trace) return trace } @@ -152,7 +147,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (!isWorkflowRecordEvent(event)) return // session/event dispatch follows list() or session/created seeding. const trace = cloneTraceForEvent(traces.get(session) as WorkflowTrace, event, fail) - applyChecked(trace, event, fail) + applyEvent(trace, event, fail) staged.set(event, { session, trace }) }, { global: true }) ctx.on('session/event', (session, event) => {