Merge branch 'master' into feat/produced-files-folder
This commit is contained in:
@@ -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/attachment/attachment-local/README.md
|
||||
README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f
|
||||
README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa
|
||||
README.md: ba0b9efb2cf51bfef671020bed4a2c16f6ee0119
|
||||
README.zh.md: 8e2474357a0dbb5e8834a3b25de7a977827a29e3
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
|
||||
|
||||
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path.
|
||||
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
|
||||
|
||||
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。
|
||||
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ export class LocalAttachmentStore extends AttachmentStore {
|
||||
return saveImageFile(this.root, input, this.imageLimits)
|
||||
}
|
||||
|
||||
async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
return readImageFile(this.root, ref)
|
||||
async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment> {
|
||||
return readImageFile(this.root, ref, signal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -197,22 +197,32 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
|
||||
* Read and verify one content-addressed image.
|
||||
* @param root - absolute `DSH_HOME/attachments/v1` root.
|
||||
* @param ref - reference recorded in the session log.
|
||||
* @param signal - optional cancellation for filesystem and verification work.
|
||||
* @returns verified bytes and reference.
|
||||
* @throws the signal reason when aborted, or an AttachmentError when verification fails.
|
||||
*/
|
||||
export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
export async function readImageFile(
|
||||
root: string,
|
||||
ref: ImageAttachmentRef,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StoredImageAttachment> {
|
||||
signal?.throwIfAborted()
|
||||
const sha256 = ensureReference(ref)
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = new Uint8Array(await readFile(objectPath(root, sha256)))
|
||||
data = new Uint8Array(await readFile(objectPath(root, sha256), { signal }))
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
|
||||
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
// The digest proves these are the exact bytes admission fully decoded, so
|
||||
// the read path only re-derives the header fields (no raster decode, no
|
||||
// per-request pixel amplification on history replay).
|
||||
const metadata = await probeImage(data)
|
||||
signal?.throwIfAborted()
|
||||
if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes
|
||||
|| metadata.width !== ref.width || metadata.height !== ref.height) {
|
||||
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')
|
||||
|
||||
@@ -9,12 +9,23 @@ import sharp from 'sharp'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import { readImageFile, saveImageFile } from '../src/store.ts'
|
||||
|
||||
const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] }))
|
||||
const fsControl = vi.hoisted(() => ({
|
||||
readSignals: [] as AbortSignal[],
|
||||
syncedDirectories: [] as string[],
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
readFile(...args: Parameters<typeof actual.readFile>): ReturnType<typeof actual.readFile> {
|
||||
const options = args[1]
|
||||
if (typeof options === 'object' && options !== null) {
|
||||
const signal = (options as { signal?: AbortSignal }).signal
|
||||
if (signal !== undefined) fsControl.readSignals.push(signal)
|
||||
}
|
||||
return actual.readFile(...args)
|
||||
},
|
||||
async open(...args: Parameters<typeof actual.open>): ReturnType<typeof actual.open> {
|
||||
if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0]))
|
||||
return actual.open(...args)
|
||||
@@ -130,6 +141,20 @@ describe('local attachment store', () => {
|
||||
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
|
||||
})
|
||||
|
||||
it('forwards read cancellation to the filesystem and preserves its reason', async () => {
|
||||
const storageRoot = await root()
|
||||
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
|
||||
const controller = new AbortController()
|
||||
fsControl.readSignals.length = 0
|
||||
|
||||
await expect(readImageFile(storageRoot, ref, controller.signal)).resolves.toEqual({ ref, data: PNG })
|
||||
expect(fsControl.readSignals).toEqual([controller.signal])
|
||||
|
||||
const cancellation = new Error('attachment read cancelled')
|
||||
controller.abort(cancellation)
|
||||
await expect(readImageFile(storageRoot, ref, controller.signal)).rejects.toBe(cancellation)
|
||||
})
|
||||
|
||||
it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => {
|
||||
const storageRoot = await root()
|
||||
await expect(saveImageFile(storageRoot, {
|
||||
|
||||
@@ -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/attachment/attachment/README.md
|
||||
README.md: 4f450316294e554396adb9a8454051a08d9befd3
|
||||
README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890
|
||||
README.md: baeeca0cf939f1a3d4608769b362d532507b90f5
|
||||
README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
|
||||
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata.
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
|
||||
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -52,9 +52,11 @@ export abstract class AttachmentStore extends Service {
|
||||
/**
|
||||
* Read one image and verify that bytes still match the recorded reference.
|
||||
* @param ref - durable reference from the session log.
|
||||
* @param signal - optional cancellation for backend read and verification work.
|
||||
* @returns the verified bytes and canonical reference.
|
||||
* @throws the signal reason when aborted, or a storage error when verification fails.
|
||||
*/
|
||||
abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
|
||||
abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
|
||||
}
|
||||
|
||||
export default AttachmentStore
|
||||
|
||||
@@ -179,6 +179,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
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-task": "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-cmdline": "workspace:^",
|
||||
|
||||
@@ -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: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67
|
||||
README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c
|
||||
README.md: 42258a961b522271cf61367856c162a7312c79f2
|
||||
README.zh.md: 5ecebbfd1e3c8aaa294e3bfd1bf943600ab3366e
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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) | 提供会话感知的命令发现与分发。 |
|
||||
|
||||
@@ -2837,8 +2837,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
// Satisfies the ApiProxy contract type only: the browser export button
|
||||
// fetches GET /api/session.export directly (window.fetch), so this stub is
|
||||
// never reached through the fixture's dispatch.
|
||||
// hands GET /api/session.export to the native download manager, so this
|
||||
// stub is never reached through the fixture's dispatch.
|
||||
downloads: {
|
||||
sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: e82b2cc9d4a65c3095aeee7002fb6c43a43b695d
|
||||
README.zh.md: a1ba62393c2aae3f6baa7c481dd80f04dbbb477d
|
||||
README.md: f4b3bd223c2872f0341d49bdaa102440d73b4f29
|
||||
README.zh.md: 9bcb3b6ad98d672cc524c168f2024be9ba56b577
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button downloads the session log — the root plus every subagent descendant — as a ZIP streamed by the host (`GET /api/session.export`): every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/<attachmentId>.<ext>`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button hands the session log — the root plus every subagent descendant — directly to the browser download manager as a ZIP streamed by the host (`GET /api/session.export`), so JavaScript never buffers the response: every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/<attachmentId>.<ext>`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——下载为宿主流式返回的 ZIP(`GET /api/session.export`):每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/<attachmentId>.<ext>` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——作为宿主流式返回的 ZIP(`GET /api/session.export`)直接交给浏览器下载管理器,因此 JavaScript 不会缓冲响应:每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/<attachmentId>.<ext>` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Session log export: browser download of the host-streamed ZIP. The archive
|
||||
* itself is produced and streamed by the host (GET /api/session.export); this
|
||||
* module only derives the download filename and triggers the browser save.
|
||||
* Session log export delivery. The host streams the archive from
|
||||
* `GET /api/session.export`; this module owns the browser-native download
|
||||
* handoff so the browser can stream the response directly to its download
|
||||
* manager instead of buffering the ZIP in JavaScript.
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -27,16 +28,18 @@ export function sessionLogZipFilename(sessionId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of a blob response.
|
||||
* @param blob - the response body to save (passed straight through, no copy).
|
||||
* @param filename - the download filename.
|
||||
* Hand one host-streamed session archive to the browser download manager.
|
||||
* The operation resolves after dispatching the native download; HTTP delivery
|
||||
* continues outside JavaScript and is reported by the browser itself.
|
||||
* @param sessionId - the root session id to export with all descendants.
|
||||
* @returns a promise that rejects if the browser handoff itself fails.
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
// Revoke one tick later: some browsers read the blob URL after click().
|
||||
setTimeout(() => { URL.revokeObjectURL(url) }, 0)
|
||||
export function downloadSessionLog(sessionId: string): Promise<void> {
|
||||
return Promise.resolve().then(() => {
|
||||
const query = new URLSearchParams({ sessionId, includeDescendants: 'true' })
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = `/api/session.export?${query.toString()}`
|
||||
anchor.download = sessionLogZipFilename(sessionId)
|
||||
anchor.click()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||
import { downloadBlob, sessionLogZipFilename } from './export-log.ts'
|
||||
import { downloadSessionLog } from './export-log.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
|
||||
import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
|
||||
@@ -60,23 +60,7 @@ export function apply(ctx: Context): void {
|
||||
return session.getSnapshot().views.get('trajectory') !== before
|
||||
},
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
exportLog: async () => {
|
||||
// The host streams the ZIP (root + descendant artifacts verbatim)
|
||||
// from GET /api/session.export; the browser downloads the response.
|
||||
// A null origin (no-location Node contexts) falls back like the
|
||||
// carrier's resolveBase so the URL stays valid.
|
||||
const loc = (globalThis as { location?: { origin?: string } }).location
|
||||
const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal'
|
||||
const url = new URL('/api/session.export', origin)
|
||||
url.searchParams.set('sessionId', sessionId)
|
||||
url.searchParams.set('includeDescendants', 'true')
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
|
||||
}
|
||||
downloadBlob(await response.blob(), sessionLogZipFilename(sessionId))
|
||||
},
|
||||
exportLog: () => downloadSessionLog(sessionId),
|
||||
}
|
||||
},
|
||||
}, TrajectoryView))
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// @vitest-environment node
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Session-log export filename derivation. The archive itself is produced and
|
||||
* streamed by the host (GET /api/session.export); this package only derives
|
||||
* the download filename and triggers the browser save.
|
||||
* Session-log export browser delivery: safe filename derivation and a native
|
||||
* download handoff that leaves the streamed response outside JavaScript.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sessionLogZipFilename } from '../src/client/export-log.ts'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { downloadSessionLog, sessionLogZipFilename } from '../src/client/export-log.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('sessionLogZipFilename', () => {
|
||||
it('keeps safe session ids verbatim', () => {
|
||||
@@ -22,3 +25,27 @@ describe('sessionLogZipFilename', () => {
|
||||
expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadSessionLog', () => {
|
||||
it('hands the descendant-inclusive endpoint directly to the browser', async () => {
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await downloadSessionLog('session/with spaces')
|
||||
|
||||
expect(click).toHaveBeenCalledOnce()
|
||||
const anchor = click.mock.contexts[0] as HTMLAnchorElement
|
||||
const url = new URL(anchor.href)
|
||||
expect(url.pathname).toBe('/api/session.export')
|
||||
expect(url.searchParams.get('sessionId')).toBe('session/with spaces')
|
||||
expect(url.searchParams.get('includeDescendants')).toBe('true')
|
||||
expect(anchor.download).toBe('dsh-session-session_with_spaces.zip')
|
||||
})
|
||||
|
||||
it('rejects when the browser download handoff fails', async () => {
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {
|
||||
throw new Error('download denied')
|
||||
})
|
||||
|
||||
await expect(downloadSessionLog('session-root')).rejects.toThrow('download denied')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1141,39 +1141,26 @@ describe('timeline projection', () => {
|
||||
describe('session log export', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(URL, 'createObjectURL')
|
||||
Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click')
|
||||
})
|
||||
|
||||
it('downloads the host-streamed ZIP with descendants on click', async () => {
|
||||
// exportLog always fetches a URL instance, so the mock's shape stays narrow.
|
||||
const fetchMock = vi.fn(async (input: URL) => {
|
||||
expect(input.pathname).toBe('/api/session.export')
|
||||
expect(input.searchParams.get('sessionId')).toBe(SID)
|
||||
expect(input.searchParams.get('includeDescendants')).toBe('true')
|
||||
return new Response('zip-bytes')
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const createObjectURL = vi.fn(() => 'blob:export')
|
||||
URL.createObjectURL = createObjectURL
|
||||
const clickAnchor = vi.fn()
|
||||
HTMLAnchorElement.prototype.click = clickAnchor
|
||||
const b = await bench(historySnapshot(NODES))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export session log' }))
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
// The blob download lands a few microtasks after the fetch settles.
|
||||
await vi.waitFor(() => {
|
||||
expect(createObjectURL).toHaveBeenCalled()
|
||||
})
|
||||
expect(clickAnchor).toHaveBeenCalled()
|
||||
await vi.waitFor(() => { expect(clickAnchor).toHaveBeenCalledOnce() })
|
||||
const anchor = clickAnchor.mock.contexts[0] as HTMLAnchorElement
|
||||
const url = new URL(anchor.href)
|
||||
expect(url.pathname).toBe('/api/session.export')
|
||||
expect(url.searchParams.get('sessionId')).toBe(SID)
|
||||
expect(url.searchParams.get('includeDescendants')).toBe('true')
|
||||
})
|
||||
|
||||
it('surfaces the download failure in the visible alert bar', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 })))
|
||||
it('surfaces a browser handoff failure in the visible alert bar', async () => {
|
||||
HTMLAnchorElement.prototype.click = vi.fn(() => { throw new Error('download denied') })
|
||||
const b = await bench(historySnapshot(NODES))
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
@@ -1181,7 +1168,7 @@ describe('session log export', () => {
|
||||
await vi.waitFor(() => {
|
||||
const alert = screen.queryByRole('alert')
|
||||
expect(alert).not.toBeNull()
|
||||
expect(alert!.textContent).toContain('HTTP 404')
|
||||
expect(alert!.textContent).toContain('download denied')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
6
packages/client/ui-workflow-run/README.i18n.yaml
Normal file
6
packages/client/ui-workflow-run/README.i18n.yaml
Normal 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
|
||||
35
packages/client/ui-workflow-run/README.md
Normal file
35
packages/client/ui-workflow-run/README.md
Normal 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.
|
||||
35
packages/client/ui-workflow-run/README.zh.md
Normal file
35
packages/client/ui-workflow-run/README.zh.md
Normal 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 入口。
|
||||
- 节点只显示运行、阶段、成员身份与状态;脚本、输出、错误、日志、用量、静态拓扑和控制操作都不属于本界面。
|
||||
82
packages/client/ui-workflow-run/package.json
Normal file
82
packages/client/ui-workflow-run/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"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-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-workflow-run"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"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": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-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:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"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",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
.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;
|
||||
margin-right: 0;
|
||||
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;
|
||||
margin-right: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.phaseTitle {
|
||||
overflow: hidden;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 42%;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
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;
|
||||
}
|
||||
}
|
||||
245
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx
Normal file
245
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
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 { shallowEqual, type SessionId, type SessionListState } 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 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<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 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
|
||||
readonly onToggle: () => void
|
||||
readonly open: boolean
|
||||
readonly status: WorkflowRunStatus
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
return (
|
||||
<DisclosureRow
|
||||
icon={<IconChevronRightOutline14 />}
|
||||
title={t('run.title', { name })}
|
||||
open={open}
|
||||
expandable
|
||||
onToggle={onToggle}
|
||||
expandOnRowClick
|
||||
previewChevron={false}
|
||||
keepContentWhenOpen
|
||||
rowClassName={css.runHeader}
|
||||
leadingClassName={css.runLeading}
|
||||
titleClassName={css.runTitle}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.runSummary}>{memberCount(count, t)}</span>
|
||||
<span className={css.statusTail} data-status={status}>
|
||||
<StateDot state={dotState(status)} />
|
||||
<span>{t(STATUS_KEYS[status])}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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: readonly SessionId[]
|
||||
readonly openSession: WorkflowRunInjected['openSession']
|
||||
readonly t: WorkflowRunPanelProps['t']
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const toggle = (): void => { setOpen(value => !value) }
|
||||
return (
|
||||
<DisclosureRow
|
||||
icon={<IconChevronRightOutline14 />}
|
||||
title={readablePhase(phase.phase, t)}
|
||||
open={open}
|
||||
expandable
|
||||
onToggle={toggle}
|
||||
expandOnRowClick
|
||||
previewChevron={false}
|
||||
keepContentWhenOpen
|
||||
className={css.phase}
|
||||
rowClassName={css.phaseHeader}
|
||||
leadingClassName={css.phaseLeading}
|
||||
titleClassName={css.phaseTitle}
|
||||
collapsedContent={(
|
||||
<>
|
||||
<span className={css.separator} aria-hidden />
|
||||
<span className={css.phaseCount} data-phase-count>{memberCount(phase.members.length, t)}</span>
|
||||
<span className={css.phaseStatus} data-phase-status-text>{phaseStatusSummary(phase.members, t)}</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.members}>
|
||||
{phase.members.map(member => (
|
||||
<MemberRow
|
||||
key={member.seq}
|
||||
member={member}
|
||||
navigable={navigable.includes(member.childId)}
|
||||
openSession={openSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DisclosureRow>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0)
|
||||
const navigable = useSessions(
|
||||
sessions => navigableMembers(sessions, node.data.phases, sessionId),
|
||||
shallowEqual,
|
||||
)
|
||||
return (
|
||||
<section className={css.root} data-workflow-run data-run-status={node.data.status}>
|
||||
<RunHeader
|
||||
count={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>
|
||||
)
|
||||
}
|
||||
32
packages/client/ui-workflow-run/src/client/index.ts
Normal file
32
packages/client/ui-workflow-run/src/client/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** 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'
|
||||
|
||||
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))
|
||||
}
|
||||
51
packages/client/ui-workflow-run/src/client/locales.ts
Normal file
51
packages/client/ui-workflow-run/src/client/locales.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** `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.one': '{count} 个成员',
|
||||
'run.members.other': '{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.one': '{count} member',
|
||||
'run.members.other': '{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
|
||||
@@ -0,0 +1,193 @@
|
||||
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 members: readonly WorkflowRunMemberData[]
|
||||
}
|
||||
|
||||
/** Final keyed Chat payload for one workflow run. */
|
||||
export interface WorkflowRunChatData {
|
||||
readonly name: string
|
||||
readonly status: WorkflowRunStatus
|
||||
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 Omit<ToolWorkflowAgentStartData, 'runId'> {
|
||||
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): boolean {
|
||||
if (location.kind === 'step') {
|
||||
return location.step.status === 'closed' || location.turn.status === 'closed'
|
||||
}
|
||||
return location.kind === 'turn' && location.turn.status === 'closed'
|
||||
}
|
||||
|
||||
function projectWorkflow(
|
||||
context: ConversationNodeContext<WorkflowState>,
|
||||
location: ConversationLocation,
|
||||
): WorkflowRunChatData {
|
||||
const state = context.state as WorkflowState
|
||||
const interrupted = state.stopReason === undefined
|
||||
&& locationClosed(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,
|
||||
members: phase.members,
|
||||
}))
|
||||
return {
|
||||
name: state.name,
|
||||
status: state.stopReason === undefined
|
||||
? interrupted ? 'interrupted' : 'running'
|
||||
: statusFromStopReason(state.stopReason),
|
||||
phases: projectedPhases,
|
||||
}
|
||||
}
|
||||
|
||||
function updateAgentStart(state: WorkflowState, data: ToolWorkflowAgentStartData): WorkflowState {
|
||||
const member: WorkflowMemberState = {
|
||||
seq: data.seq,
|
||||
label: data.label,
|
||||
...data.phase === undefined ? {} : { phase: data.phase },
|
||||
childId: data.childId,
|
||||
}
|
||||
return { ...state, members: [...state.members, member] }
|
||||
}
|
||||
|
||||
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',
|
||||
target: 'chat',
|
||||
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): ChatConversationViewNode | null => {
|
||||
if (context.start === undefined) return null
|
||||
const data = projectWorkflow(context, context.start.location)
|
||||
return {
|
||||
key: context.key,
|
||||
kind: 'workflow-run',
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq: context.start.event.seq,
|
||||
location: context.start.location,
|
||||
visibility: 'visible',
|
||||
data,
|
||||
}
|
||||
},
|
||||
}
|
||||
6
packages/client/ui-workflow-run/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-workflow-run/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
4
packages/client/ui-workflow-run/src/index.ts
Normal file
4
packages/client/ui-workflow-run/src/index.ts
Normal 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 {}
|
||||
24
packages/client/ui-workflow-run/src/invariant.ts
Normal file
24
packages/client/ui-workflow-run/src/invariant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Package-owned invariant companion for the workflow-run UI plugin. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/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 */
|
||||
521
packages/client/ui-workflow-run/tests/workflow-run.spec.tsx
Normal file
521
packages/client/ui-workflow-run/tests/workflow-run.spec.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
// @vitest-environment jsdom
|
||||
import { Context, Service } from '@deepseek-ai/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, 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',
|
||||
phases: [
|
||||
{
|
||||
key: 'value:0:', phase: '',
|
||||
members: [{ seq: 1, label: 'first', childId: 'child-1', status: 'completed' }],
|
||||
},
|
||||
{
|
||||
key: 'missing', phase: null,
|
||||
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', 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', 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.target).toBe('chat')
|
||||
expect(workflowRunDefinition.buildViewNode?.({
|
||||
...updateContext, matches: [], start: undefined,
|
||||
})).toBeNull()
|
||||
const directNode = workflowRunDefinition.buildViewNode?.(updateContext) as ChatConversationViewNode | null | undefined
|
||||
if (directNode === null) throw new Error('expected direct workflow Chat node')
|
||||
if (directNode === undefined) throw new Error('expected workflow Chat view builder')
|
||||
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,
|
||||
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: {},
|
||||
tasksBySession: {},
|
||||
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', 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',
|
||||
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', phases: [],
|
||||
})} />)
|
||||
expect(screen.getByText('没有启动成员')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps phase disclosure independent and preserves empty versus absent names', () => {
|
||||
render(<WorkflowRunPanel {...panelProps({
|
||||
name: 'audit', status: 'running',
|
||||
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',
|
||||
phases: [phase({
|
||||
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',
|
||||
phases: [phase({
|
||||
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',
|
||||
phases: [
|
||||
phase({
|
||||
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',
|
||||
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', 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',
|
||||
phases: [phase({
|
||||
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()
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
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'])
|
||||
})
|
||||
})
|
||||
42
packages/client/ui-workflow-run/tsconfig.json
Normal file
42
packages/client/ui-workflow-run/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-workflow-run/tsdown.config.ts
Normal file
3
packages/client/ui-workflow-run/tsdown.config.ts
Normal 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'])
|
||||
@@ -48,6 +48,10 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'step/start',
|
||||
'subagent/descriptor',
|
||||
'todo/write',
|
||||
'tool-workflow/agent-end',
|
||||
'tool-workflow/agent-start',
|
||||
'tool-workflow/run-end',
|
||||
'tool-workflow/run-start',
|
||||
'tool/call',
|
||||
'tool/code-dispatch',
|
||||
'tool/code-dispatch-start',
|
||||
|
||||
@@ -109,6 +109,8 @@ export function messageFixture(
|
||||
|
||||
/** Minimal controllable persistence provider for service-level tests. */
|
||||
class TestPersistence extends SessionPersistence {
|
||||
override readonly supportsRawArtifacts = false
|
||||
|
||||
static inject = ['sessions']
|
||||
|
||||
readonly durable = new Map<SessionId, SessionInspection>()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
|
||||
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?, sessionExportCompressionLevel?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
|
||||
|
||||
## The shared Agent default (`agent-default-model` Settings section)
|
||||
|
||||
@@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a missing root session 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
||||
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
|
||||
所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?, sessionExportCompressionLevel?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
|
||||
|
||||
## 共享 Agent 默认值(`agent-default-model` Settings 分节)
|
||||
|
||||
@@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
||||
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
|
||||
@@ -43,10 +43,13 @@ import type {
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
||||
flushLiveSessionLog,
|
||||
sessionLogExportDeps,
|
||||
sessionLogZipFilename,
|
||||
streamSessionLogZip,
|
||||
type SessionLogExportReady,
|
||||
type SessionLogCompressionLevel,
|
||||
} from './session-export.ts'
|
||||
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
import {
|
||||
@@ -543,6 +546,8 @@ export interface ApiProxyDefaults {
|
||||
openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Native text-editor handoff; injectable for settings-document tests. */
|
||||
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
|
||||
sessionExportCompressionLevel?: SessionLogCompressionLevel
|
||||
/**
|
||||
* Whether handing a path to the native opener can work at all — the
|
||||
* `hasDocument` capability the preset roster reports, and the switch
|
||||
@@ -988,6 +993,8 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
|
||||
* @returns the ApiProxy implementation.
|
||||
*/
|
||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
||||
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
|
||||
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
|
||||
/** The seed model each create/resume declares; re-read so it never goes stale. */
|
||||
const agentOptions = (): AgentOptions => {
|
||||
const { provider, model } = defaults.defaultModelSelection()
|
||||
@@ -3490,24 +3497,41 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
if (!deps.sessionPersistence.supportsRawArtifacts) {
|
||||
return new Response(
|
||||
'session log export is unavailable: the persistence backend does not expose per-session raw artifacts',
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
const ready: SessionLogExportReady = {
|
||||
sessionQuery: deps.sessionQuery,
|
||||
sessionPersistence: deps.sessionPersistence,
|
||||
attachments: deps.attachments,
|
||||
sessions: deps.sessions,
|
||||
}
|
||||
let root: SessionRawArtifact | undefined
|
||||
try {
|
||||
await flushLiveSessionLog(deps, request.sessionId, signal)
|
||||
root = await deps.sessionPersistence.readRaw(request.sessionId, signal)
|
||||
signal.throwIfAborted()
|
||||
} catch {
|
||||
// Backend read failure: answer 500 without echoing the error, which
|
||||
// may carry absolute host paths into the browser error bar.
|
||||
return new Response('session log export failed to read the stored artifact', { status: 500 })
|
||||
signal.throwIfAborted()
|
||||
// Root preparation failure: answer 500 without echoing the error,
|
||||
// which may carry absolute host paths into the browser error bar.
|
||||
return new Response('session log export failed to prepare the stored artifact', { status: 500 })
|
||||
}
|
||||
if (root === undefined) {
|
||||
return new Response('session not found', { status: 404 })
|
||||
}
|
||||
return new Response(
|
||||
streamSessionLogZip(ready, root, request.sessionId, request.includeDescendants === true, signal),
|
||||
streamSessionLogZip(
|
||||
ready,
|
||||
root,
|
||||
request.sessionId,
|
||||
request.includeDescendants === true,
|
||||
sessionExportCompressionLevel,
|
||||
signal,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'application/zip',
|
||||
|
||||
@@ -17,6 +17,10 @@ import z from '@deepseek-ai/schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
import {
|
||||
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
||||
type SessionLogCompressionLevel,
|
||||
} from './session-export.ts'
|
||||
|
||||
export type * from './api/index.ts'
|
||||
export { RpcId } from './api/rpc.ts'
|
||||
@@ -33,7 +37,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Gateway plugin config for native Host integration. */
|
||||
/** Gateway plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Whether this deployment can hand paths to a native desktop opener —
|
||||
@@ -43,6 +47,12 @@ export interface Config {
|
||||
* container whose DISPLAY points nowhere a user can see.
|
||||
*/
|
||||
nativeOpen?: boolean
|
||||
/**
|
||||
* DEFLATE level for every session-log ZIP entry: `0` stores without
|
||||
* compression, `1` favors CPU/latency, and `9` favors archive size.
|
||||
* @default 6
|
||||
*/
|
||||
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +68,8 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
nativeOpen: z.boolean(),
|
||||
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
|
||||
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
@@ -82,6 +94,9 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
|
||||
cwd: process.cwd(),
|
||||
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
|
||||
...(config.sessionExportCompressionLevel === undefined
|
||||
? {}
|
||||
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.subagents = api.subagents
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
|
||||
* so one archive never duplicates a shared image). No manifest is written —
|
||||
* every file is byte-identical to the backend's durable artifact or attachment
|
||||
* store and self-describing through its own header line or media type.
|
||||
* store and self-describing through its own header line or media type. Before
|
||||
* each live session's artifact read, the SessionStore flush barrier makes the
|
||||
* current in-memory log durable; cold sessions need no barrier. Request abort
|
||||
* and response-consumer cancellation share one producer signal and terminate
|
||||
* the active compressor.
|
||||
* Compression runs on the host with fflate's streaming Zip API, so the archive
|
||||
* bytes are produced incrementally and the host never holds the whole archive
|
||||
* in one buffer; production yields to the consumer whenever the response queue
|
||||
* fills past its high-water mark, so a slow consumer bounds the accumulation
|
||||
* instead of piling up the whole archive (fflate's callback is synchronous —
|
||||
* this drain point is the only backpressure available).
|
||||
* in one buffer; production waits for consumer pull whenever the response queue
|
||||
* reaches its byte high-water mark, so a slow consumer bounds accumulation to
|
||||
* the fixed 64 KiB response queue plus one synchronous fflate push.
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -20,14 +23,21 @@ import { Zip, ZipDeflate } from 'fflate'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
/** The services a session-log export needs (absent → the export is unavailable). */
|
||||
/** Valid fflate DEFLATE levels accepted by session-log export. */
|
||||
export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
|
||||
/** Balanced default used when a direct createApiProxy caller omits deployment config. */
|
||||
export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6
|
||||
|
||||
/** The services a session-log export needs (the live-session store is optional). */
|
||||
export interface SessionLogExportDeps {
|
||||
readonly sessionQuery: SessionQueryService | undefined
|
||||
readonly sessionPersistence: SessionPersistence | undefined
|
||||
readonly attachments: AttachmentStore | undefined
|
||||
readonly sessions: SessionStore | undefined
|
||||
}
|
||||
|
||||
/** The export services narrowed to the mounted ones streaming actually reads. */
|
||||
@@ -35,6 +45,7 @@ export interface SessionLogExportReady {
|
||||
readonly sessionQuery: SessionQueryService
|
||||
readonly sessionPersistence: SessionPersistence
|
||||
readonly attachments: AttachmentStore
|
||||
readonly sessions: SessionStore | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,9 +58,32 @@ export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
|
||||
sessionQuery: ctx.get('sessionQuery'),
|
||||
sessionPersistence: ctx.get('sessionPersistence'),
|
||||
attachments: ctx.get('attachments'),
|
||||
sessions: ctx.get('sessions'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush one currently live session through the store's authoritative durability
|
||||
* barrier immediately before its raw artifact is read. A cold or absent id has
|
||||
* no in-memory work to flush.
|
||||
* @param deps - export services, including the optional live-session store.
|
||||
* @param id - the session whose artifact is about to be read.
|
||||
* @param signal - optional cancellation observed around the flush barrier.
|
||||
*/
|
||||
export async function flushLiveSessionLog(
|
||||
deps: Pick<SessionLogExportDeps, 'sessions'>,
|
||||
id: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted()
|
||||
const sessions = deps.sessions
|
||||
if (sessions === undefined) return
|
||||
const session = sessions.get(id)
|
||||
if (session === undefined) return
|
||||
await sessions.flush(session)
|
||||
signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
/** One exported file: a stored artifact text or one referenced media object. */
|
||||
export type SessionLogZipEntry =
|
||||
| { readonly path: string; readonly content: string }
|
||||
@@ -168,9 +202,9 @@ export function sessionLogZipFilename(sessionId: string): string {
|
||||
|
||||
/**
|
||||
* Yield the export entries in zip order: the preloaded root artifact first,
|
||||
* then every subagent descendant in lineage order (each read from the
|
||||
* persistence backend right before it is yielded and dropped after the
|
||||
* consumer moves on), then every distinct media object referenced by any of
|
||||
* then every subagent descendant in lineage order (each flushed when live,
|
||||
* read from the persistence backend right before it is yielded, and dropped
|
||||
* after the consumer moves on), then every distinct media object referenced by any of
|
||||
* the included logs (read and verified from the attachment store, one archive
|
||||
* entry per attachment id). The host holds at most one descendant's artifact
|
||||
* text and one media object at a time beyond the root.
|
||||
@@ -179,7 +213,7 @@ export function sessionLogZipFilename(sessionId: string): string {
|
||||
* missing-session path can answer cleanly before streaming starts).
|
||||
* @param sessionId - the root session id.
|
||||
* @param includeDescendants - whether to include every subagent descendant.
|
||||
* @param signal - optional cancellation for read work.
|
||||
* @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
|
||||
* @returns the export entries in zip order.
|
||||
*/
|
||||
export async function* sessionLogZipEntries(
|
||||
@@ -205,7 +239,9 @@ export async function* sessionLogZipEntries(
|
||||
const id = node.session.header.id
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const raw = await deps.sessionPersistence.readRaw(id)
|
||||
await flushLiveSessionLog(deps, id, signal)
|
||||
const raw = await deps.sessionPersistence.readRaw(id, signal)
|
||||
signal?.throwIfAborted()
|
||||
if (raw === undefined) {
|
||||
throw new Error(`subagent "${id}" has no stored log artifact`)
|
||||
}
|
||||
@@ -217,12 +253,14 @@ export async function* sessionLogZipEntries(
|
||||
yield* collect(node.descendants)
|
||||
}
|
||||
}
|
||||
const lineage = await deps.sessionQuery.traceSession(sessionId)
|
||||
const lineage = await deps.sessionQuery.traceSession(sessionId, signal)
|
||||
signal?.throwIfAborted()
|
||||
yield* collect(lineage.descendants)
|
||||
}
|
||||
for (const ref of media.values()) {
|
||||
signal?.throwIfAborted()
|
||||
const stored = await deps.attachments.readImage(ref)
|
||||
const stored = await deps.attachments.readImage(ref, signal)
|
||||
signal?.throwIfAborted()
|
||||
yield { path: mediaEntryPath(ref), data: stored.data }
|
||||
}
|
||||
}
|
||||
@@ -233,30 +271,66 @@ const PUSH_CHUNK_CODE_UNITS = 1 << 16
|
||||
/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
|
||||
const PUSH_CHUNK_BYTES = 1 << 16
|
||||
|
||||
/** Byte capacity retained by the response stream before ZIP production waits for pull. */
|
||||
const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16
|
||||
|
||||
/** One producer waiter released only when ReadableStream pull restores capacity. */
|
||||
class ResponseCapacityGate {
|
||||
private releasePending: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* Wait until the response queue has positive byte capacity or cancellation wins.
|
||||
* @param controller - response controller whose desired size owns capacity.
|
||||
* @param signal - combined request/consumer cancellation.
|
||||
*/
|
||||
async wait(
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
if (controller.desiredSize === null || controller.desiredSize > 0) return
|
||||
await new Promise<void>((resolve) => {
|
||||
const release = (): void => {
|
||||
this.releasePending = undefined
|
||||
signal.removeEventListener('abort', release)
|
||||
resolve()
|
||||
}
|
||||
this.releasePending = release
|
||||
signal.addEventListener('abort', release, { once: true })
|
||||
})
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
|
||||
/** Release the current producer waiter after a consumer pull. */
|
||||
pulled(): void {
|
||||
this.releasePending?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one media object's bytes into a deflate stream in bounded chunks,
|
||||
* yielding to a slow consumer between chunks like the artifact path does.
|
||||
* waiting for consumer capacity between chunks like the artifact path does.
|
||||
* @param deflate - the zip entry's deflate stream.
|
||||
* @param data - the stored image bytes.
|
||||
* @param signal - optional cancellation; throws when aborted.
|
||||
* @param controller - response queue controller.
|
||||
* @param capacity - pull-driven response-capacity gate.
|
||||
* @param signal - cancellation; throws when aborted.
|
||||
*/
|
||||
async function pushBinaryChunks(
|
||||
deflate: ZipDeflate,
|
||||
data: Uint8Array,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
capacity: ResponseCapacityGate,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
let offset = 0
|
||||
do {
|
||||
signal?.throwIfAborted()
|
||||
signal.throwIfAborted()
|
||||
const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
|
||||
const finalChunk = end >= data.byteLength
|
||||
deflate.push(data.subarray(offset, end), finalChunk)
|
||||
offset = end
|
||||
/* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */
|
||||
if (controller.desiredSize !== null && controller.desiredSize < 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
await capacity.wait(controller, signal)
|
||||
} while (offset < data.byteLength)
|
||||
}
|
||||
|
||||
@@ -266,19 +340,22 @@ async function pushBinaryChunks(
|
||||
* re-encodes as U+FFFD and would silently corrupt the exported artifact).
|
||||
* @param deflate - the zip entry's deflate stream.
|
||||
* @param content - the artifact text verbatim.
|
||||
* @param signal - optional cancellation; throws when aborted.
|
||||
* @param controller - response queue controller.
|
||||
* @param capacity - pull-driven response-capacity gate.
|
||||
* @param signal - cancellation; throws when aborted.
|
||||
*/
|
||||
async function pushArtifactChunks(
|
||||
deflate: ZipDeflate,
|
||||
content: string,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
capacity: ResponseCapacityGate,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const encoder = new TextEncoder()
|
||||
let offset = 0
|
||||
let finalChunk: boolean
|
||||
do {
|
||||
signal?.throwIfAborted()
|
||||
signal.throwIfAborted()
|
||||
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
|
||||
if (end < content.length && end - offset > 1) {
|
||||
// Back off one code unit when the boundary lands inside a surrogate
|
||||
@@ -289,10 +366,7 @@ async function pushArtifactChunks(
|
||||
finalChunk = end >= content.length
|
||||
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
|
||||
offset = end
|
||||
/* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */
|
||||
if (controller.desiredSize !== null && controller.desiredSize < 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
await capacity.wait(controller, signal)
|
||||
} while (!finalChunk)
|
||||
}
|
||||
|
||||
@@ -307,7 +381,8 @@ async function pushArtifactChunks(
|
||||
* @param root - the already-read root artifact (first zip entry).
|
||||
* @param sessionId - the root session id.
|
||||
* @param includeDescendants - whether to include every subagent descendant.
|
||||
* @param signal - optional cancellation for read work.
|
||||
* @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
|
||||
* @param signal - request cancellation combined with response-consumer cancellation.
|
||||
* @returns the zip byte stream.
|
||||
*/
|
||||
export function streamSessionLogZip(
|
||||
@@ -315,15 +390,26 @@ export function streamSessionLogZip(
|
||||
root: SessionRawArtifact,
|
||||
sessionId: SessionId,
|
||||
includeDescendants: boolean,
|
||||
signal?: AbortSignal,
|
||||
compressionLevel: SessionLogCompressionLevel,
|
||||
signal: AbortSignal,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const consumerAbort = new AbortController()
|
||||
const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
|
||||
let zip: Zip | undefined
|
||||
let zipTerminated = false
|
||||
const capacity = new ResponseCapacityGate()
|
||||
const terminateZip = (): void => {
|
||||
if (zip === undefined || zipTerminated) return
|
||||
zipTerminated = true
|
||||
zip.terminate()
|
||||
}
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// fflate invokes the callback synchronously per compressed chunk, so a
|
||||
// single push can enqueue ahead of a slow consumer; pushArtifactChunks
|
||||
// yields between chunks once the queue is over-full, bounding the
|
||||
// accumulation to the queue high-water mark plus one push.
|
||||
const zip = new Zip((error, data, final) => {
|
||||
// single push can enqueue ahead of a slow consumer; the capacity gate
|
||||
// waits for pull between pushes once the byte queue is full, bounding
|
||||
// accumulation to the queue high-water mark plus one synchronous push.
|
||||
const archive = new Zip((error, data, final) => {
|
||||
/* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
|
||||
if (error) {
|
||||
controller.error(error)
|
||||
@@ -333,25 +419,39 @@ export function streamSessionLogZip(
|
||||
if (data.byteLength > 0) controller.enqueue(data)
|
||||
if (final) controller.close()
|
||||
})
|
||||
zip = archive
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) {
|
||||
const deflate = new ZipDeflate(entry.path, { level: 6 })
|
||||
zip.add(deflate)
|
||||
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
|
||||
const deflate = new ZipDeflate(entry.path, { level: compressionLevel })
|
||||
archive.add(deflate)
|
||||
if ('content' in entry) {
|
||||
await pushArtifactChunks(deflate, entry.content, controller, signal)
|
||||
await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal)
|
||||
} else {
|
||||
await pushBinaryChunks(deflate, entry.data, controller, signal)
|
||||
await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal)
|
||||
}
|
||||
}
|
||||
zip.end()
|
||||
archive.end()
|
||||
} catch (error) {
|
||||
// A mid-stream failure (missing descendant, cancellation, read
|
||||
// error) must fail the download rather than ship a truncated archive.
|
||||
/* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
|
||||
terminateZip()
|
||||
controller.error(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})()
|
||||
},
|
||||
pull() {
|
||||
capacity.pulled()
|
||||
},
|
||||
cancel(reason) {
|
||||
consumerAbort.abort(
|
||||
reason instanceof Error ? reason : new Error('session log export stream cancelled'),
|
||||
)
|
||||
terminateZip()
|
||||
},
|
||||
}, {
|
||||
highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
|
||||
size: chunk => chunk.byteLength,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* root → 404, missing descendant → errored stream).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { unzipSync, strFromU8 } from 'fflate'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
@@ -13,8 +14,7 @@ import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import ApiProxyService, { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
@@ -59,8 +59,21 @@ async function buildApi(
|
||||
descendants: SessionLineageNode[] = [],
|
||||
services: {
|
||||
query?: boolean
|
||||
persistence?: boolean | 'throw'
|
||||
attachments?: boolean | ((ref: ImageAttachmentRef) => Promise<ReturnType<typeof storedImage>>)
|
||||
persistence?: boolean | 'throw' | 'unsupported'
|
||||
attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise<ReturnType<typeof storedImage>>)
|
||||
sessions?: {
|
||||
get(id: SessionId): { readonly id: SessionId } | undefined
|
||||
flush(session: { readonly id: SessionId }): Promise<boolean>
|
||||
}
|
||||
readRaw?: (id: SessionId, signal?: AbortSignal) => Promise<SessionRawArtifact | undefined>
|
||||
traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{
|
||||
target: { header: SessionHeader; live: boolean; persisted: boolean }
|
||||
ancestors: readonly SessionLineageNode[]
|
||||
complete: boolean
|
||||
root: { header: SessionHeader; live: boolean; persisted: boolean }
|
||||
descendants: readonly SessionLineageNode[]
|
||||
}>
|
||||
compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||
} = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
@@ -69,21 +82,22 @@ async function buildApi(
|
||||
const persistence = services.persistence ?? true
|
||||
if (query) {
|
||||
ctx.provide('sessionQuery', {
|
||||
traceSession: async () => ({
|
||||
traceSession: services.traceSession ?? (async () => ({
|
||||
target: { header: header('session-root'), live: false, persisted: true },
|
||||
ancestors: [],
|
||||
complete: true,
|
||||
root: { header: header('session-root'), live: false, persisted: true },
|
||||
descendants,
|
||||
}),
|
||||
})),
|
||||
} as never)
|
||||
}
|
||||
if (persistence) {
|
||||
ctx.provide('sessionPersistence', {
|
||||
readRaw: async (id: SessionId) => {
|
||||
supportsRawArtifacts: persistence !== 'unsupported',
|
||||
readRaw: services.readRaw ?? (async (id: SessionId) => {
|
||||
if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
|
||||
return artifacts[id]
|
||||
},
|
||||
}),
|
||||
} as never)
|
||||
}
|
||||
if (services.attachments !== false) {
|
||||
@@ -97,9 +111,13 @@ async function buildApi(
|
||||
readImage,
|
||||
} as never)
|
||||
}
|
||||
if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never)
|
||||
return createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/tmp',
|
||||
...services.compressionLevel === undefined
|
||||
? {}
|
||||
: { sessionExportCompressionLevel: services.compressionLevel },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,6 +125,19 @@ async function responseBytes(response: Response): Promise<Uint8Array> {
|
||||
return new Uint8Array(await response.arrayBuffer())
|
||||
}
|
||||
|
||||
describe('session export compression config', () => {
|
||||
it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
|
||||
expect(ApiProxyService.Config({})).toEqual({ sessionExportCompressionLevel: 6 })
|
||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 0 })
|
||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 9 })
|
||||
for (const value of [-1, 10, 1.5]) {
|
||||
expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.export download endpoint', () => {
|
||||
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
@@ -121,6 +152,24 @@ describe('session.export download endpoint', () => {
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content)
|
||||
})
|
||||
|
||||
it('uses the resolved compression level for ZIP entries', async () => {
|
||||
const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024))
|
||||
const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 })
|
||||
const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 })
|
||||
const stored = await storedApi.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const compressed = await compressedApi.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const storedBytes = await responseBytes(stored)
|
||||
const compressedBytes = await responseBytes(compressed)
|
||||
expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength)
|
||||
expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('includes descendant artifacts under subagents/<id>/ when requested', async () => {
|
||||
const api = await buildApi({
|
||||
'session-root': artifact('session-root'),
|
||||
@@ -143,6 +192,55 @@ describe('session.export download endpoint', () => {
|
||||
.toBe(artifact('child-a').content)
|
||||
})
|
||||
|
||||
it('flushes each live root and descendant immediately before reading its artifact', async () => {
|
||||
const stored: Record<string, SessionRawArtifact> = {
|
||||
'session-root': artifact('session-root', undefined, 'stale root'),
|
||||
'child-a': artifact('child-a', sid('session-root'), 'stale child'),
|
||||
}
|
||||
const durable: Record<string, SessionRawArtifact> = {
|
||||
'session-root': artifact('session-root', undefined, 'durable root'),
|
||||
'child-a': artifact('child-a', sid('session-root'), 'durable child'),
|
||||
}
|
||||
const flushed: SessionId[] = []
|
||||
const api = await buildApi(stored, [node('child-a')], {
|
||||
sessions: {
|
||||
get: id => durable[id] === undefined ? undefined : { id },
|
||||
flush: async (session) => {
|
||||
const artifactAfterFlush = durable[session.id]
|
||||
if (artifactAfterFlush === undefined) throw new Error('unexpected session')
|
||||
flushed.push(session.id)
|
||||
stored[session.id] = artifactAfterFlush
|
||||
return true
|
||||
},
|
||||
},
|
||||
})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(flushed).toEqual([sid('session-root'), sid('child-a')])
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root')
|
||||
expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child')
|
||||
})
|
||||
|
||||
it('reads a cold artifact without asking the live-session store to flush', async () => {
|
||||
const flush = vi.fn(async () => true)
|
||||
const root = artifact('session-root')
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
sessions: {
|
||||
get: () => undefined,
|
||||
flush,
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(flush).not.toHaveBeenCalled()
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('answers 404 for a missing root session', async () => {
|
||||
const api = await buildApi({})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
@@ -151,6 +249,15 @@ describe('session.export download endpoint', () => {
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('answers 501 when the persistence backend has no per-session raw artifacts', async () => {
|
||||
const api = await buildApi({}, [], { persistence: 'unsupported' })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(501)
|
||||
expect(await response.text()).toContain('does not expose per-session raw artifacts')
|
||||
})
|
||||
|
||||
it('answers 400 when the sessionId query parameter is absent', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
@@ -214,6 +321,37 @@ describe('session.export download endpoint', () => {
|
||||
expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
|
||||
})
|
||||
|
||||
it('waits for response pull capacity before reading the next archive entry', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
imageEventLine('after-root'),
|
||||
randomBytes(512 * 1024).toString('base64'),
|
||||
].join('\n'))
|
||||
let imageReads = 0
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
attachments: async (ref) => {
|
||||
imageReads += 1
|
||||
return storedImage(String(ref.attachmentId), ref.mediaType)
|
||||
},
|
||||
})
|
||||
vi.useFakeTimers()
|
||||
let response: Response | undefined
|
||||
try {
|
||||
response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
// Exhausting timer turns must not advance a producer whose byte queue is
|
||||
// full; only a consumer pull can release it.
|
||||
await vi.runAllTimersAsync()
|
||||
expect(imageReads).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
if (response === undefined) throw new Error('missing export response')
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(imageReads).toBe(1)
|
||||
expect(files['media/after-root.png']).toEqual(storedImage('after-root').data)
|
||||
})
|
||||
|
||||
it('exports an empty artifact as an empty zip entry', async () => {
|
||||
const root = { ...artifact('session-root'), content: '' }
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
@@ -254,10 +392,179 @@ describe('session.export download endpoint', () => {
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
const body = await response.text()
|
||||
expect(body).toBe('session log export failed to read the stored artifact')
|
||||
expect(body).toBe('session log export failed to prepare the stored artifact')
|
||||
expect(body).not.toContain('/host/private/')
|
||||
})
|
||||
|
||||
it('answers the private-error-safe 500 when the live root flush fails', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') }, [], {
|
||||
sessions: {
|
||||
get: id => ({ id }),
|
||||
flush: async () => { throw new Error('/host/private/flush-state') },
|
||||
},
|
||||
})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
const body = await response.text()
|
||||
expect(body).toBe('session log export failed to prepare the stored artifact')
|
||||
expect(body).not.toContain('/host/private/')
|
||||
})
|
||||
|
||||
it('forwards one request signal through root, lineage, and descendant reads', async () => {
|
||||
const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = []
|
||||
const traces: AbortSignal[] = []
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id, signal) => {
|
||||
reads.push({ id, signal })
|
||||
return id === sid('session-root')
|
||||
? artifact('session-root')
|
||||
: artifact('child-a', sid('session-root'))
|
||||
},
|
||||
traceSession: async (_id, signal) => {
|
||||
if (signal !== undefined) traces.push(signal)
|
||||
return {
|
||||
target: { header: header('session-root'), live: false, persisted: true },
|
||||
ancestors: [],
|
||||
complete: true,
|
||||
root: { header: header('session-root'), live: false, persisted: true },
|
||||
descendants: [node('child-a')],
|
||||
}
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
controller.signal,
|
||||
)
|
||||
await response.arrayBuffer()
|
||||
const producerSignal = traces[0]
|
||||
if (producerSignal === undefined) throw new Error('missing lineage signal')
|
||||
expect(reads[0]).toEqual({ id: sid('session-root'), signal: controller.signal })
|
||||
expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal })
|
||||
const cancellation = new Error('request cancelled after response')
|
||||
controller.abort(cancellation)
|
||||
expect(producerSignal.aborted).toBe(true)
|
||||
expect(producerSignal.reason).toBe(cancellation)
|
||||
})
|
||||
|
||||
it('preserves request cancellation instead of translating it to HTTP 500', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
const controller = new AbortController()
|
||||
const cancellation = new Error('request cancelled')
|
||||
controller.abort(cancellation)
|
||||
await expect(api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
controller.signal,
|
||||
)).rejects.toBe(cancellation)
|
||||
})
|
||||
|
||||
it('aborts descendant work and terminates ZIP production when its reader cancels', async () => {
|
||||
let reportDescendantStarted!: (signal: AbortSignal) => void
|
||||
const descendantStarted = new Promise<AbortSignal>((resolve) => {
|
||||
reportDescendantStarted = resolve
|
||||
})
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id, signal) => {
|
||||
if (id === sid('session-root')) return artifact('session-root')
|
||||
if (signal === undefined) throw new Error('missing descendant signal')
|
||||
reportDescendantStarted(signal)
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason as Error)
|
||||
}, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new Error('missing response body')
|
||||
const descendantSignal = await descendantStarted
|
||||
const cancellation = new Error('download consumer left')
|
||||
await reader.cancel(cancellation)
|
||||
expect(descendantSignal.aborted).toBe(true)
|
||||
expect(descendantSignal.reason).toBe(cancellation)
|
||||
})
|
||||
|
||||
it('aborts attachment reads when its reader cancels', async () => {
|
||||
let reportAttachmentStarted!: (signal: AbortSignal) => void
|
||||
const attachmentStarted = new Promise<AbortSignal>((resolve) => {
|
||||
reportAttachmentStarted = resolve
|
||||
})
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
imageEventLine('slow-img'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
attachments: async (_ref, signal) => {
|
||||
if (signal === undefined) throw new Error('missing attachment signal')
|
||||
reportAttachmentStarted(signal)
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason as Error)
|
||||
}, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: false },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new Error('missing response body')
|
||||
const attachmentSignal = await attachmentStarted
|
||||
const cancellation = new Error('download consumer left during attachment read')
|
||||
await reader.cancel(cancellation)
|
||||
expect(attachmentSignal.aborted).toBe(true)
|
||||
expect(attachmentSignal.reason).toBe(cancellation)
|
||||
})
|
||||
|
||||
it('uses a stable Error reason when its reader cancels without one', async () => {
|
||||
let reportDescendantStarted!: (signal: AbortSignal) => void
|
||||
const descendantStarted = new Promise<AbortSignal>((resolve) => {
|
||||
reportDescendantStarted = resolve
|
||||
})
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id, signal) => {
|
||||
if (id === sid('session-root')) return artifact('session-root')
|
||||
if (signal === undefined) throw new Error('missing descendant signal')
|
||||
reportDescendantStarted(signal)
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason as Error)
|
||||
}, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new Error('missing response body')
|
||||
const descendantSignal = await descendantStarted
|
||||
await reader.cancel()
|
||||
expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled'))
|
||||
})
|
||||
|
||||
it('normalizes a non-Error descendant failure before erroring the stream', async () => {
|
||||
const api = await buildApi({}, [node('child-a')], {
|
||||
readRaw: async (id) => {
|
||||
if (id === sid('session-root')) return artifact('session-root')
|
||||
throw 'descendant read failed'
|
||||
},
|
||||
})
|
||||
const response = await api.downloads.sessionLog(
|
||||
{ sessionId: sid('session-root'), includeDescendants: true },
|
||||
new AbortController().signal,
|
||||
)
|
||||
await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed'))
|
||||
})
|
||||
|
||||
it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
|
||||
@@ -237,8 +237,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>',
|
||||
jsDoc: '/**\n * Read one image and verify that bytes still match the recorded reference.\n * @param ref - durable reference from the session log.\n * @returns the verified bytes and canonical reference.\n */',
|
||||
signature: 'abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>',
|
||||
jsDoc: '/**\n * Read one image and verify that bytes still match the recorded reference.\n * @param ref - durable reference from the session log.\n * @param signal - optional cancellation for backend read and verification work.\n * @returns the verified bytes and canonical reference.\n * @throws the signal reason when aborted, or a storage error when verification fails.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -720,7 +720,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'readRaw(_id: SessionId, signal?: AbortSignal): Promise<SessionRawArtifact | undefined>',
|
||||
jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Backends without a\n * per-session artifact (SQLite) inherit the `undefined` default.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent or the backend owns no per-session artifact.\n */',
|
||||
jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Callers first test\n * {@link supportsRawArtifacts}; `undefined` then means only that the requested\n * session has no materialized artifact.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent.\n * @throws when this backend does not expose per-session raw artifacts.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'abstract create(meta: SessionHeader): Promise<void>',
|
||||
|
||||
@@ -67,6 +67,8 @@ function replaceCursorOffset(
|
||||
}
|
||||
|
||||
class TestPersistence extends SessionPersistence {
|
||||
override readonly supportsRawArtifacts = false
|
||||
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static revisions = new Map<SessionIdType, number>()
|
||||
static nextRevision = 0
|
||||
|
||||
@@ -29,6 +29,8 @@ function eventLog(text = 'hello'): SessionEvent[] {
|
||||
}
|
||||
|
||||
class TestPersistence extends SessionPersistence {
|
||||
override readonly supportsRawArtifacts = false
|
||||
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static listFailure: unknown
|
||||
static listOverride: ((signal?: AbortSignal) => Promise<SessionHeader[]>) | undefined
|
||||
|
||||
@@ -32,6 +32,8 @@ function appendEvent(seq: number, sources?: number[]): SessionEvent {
|
||||
}
|
||||
|
||||
class TracePersistence extends SessionPersistence {
|
||||
override readonly supportsRawArtifacts = false
|
||||
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static listCalls = 0
|
||||
static inspectCalls = 0
|
||||
|
||||
@@ -13,6 +13,8 @@ import * as checkpointPolicy from '../src/index.ts'
|
||||
const contexts: Context[] = []
|
||||
|
||||
class TestPersistence extends SessionPersistence {
|
||||
override readonly supportsRawArtifacts = false
|
||||
|
||||
locate(_meta: SessionHeader): undefined { return undefined }
|
||||
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
|
||||
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
|
||||
|
||||
@@ -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/session/session-persistence-jsonl/README.md
|
||||
README.md: e2416cd36e3fb1d8f93e921800f2247fe29f3b09
|
||||
README.zh.md: 4eb2d4f2bebf9ed17190ef3cb21a2bc3c8d9123b
|
||||
README.md: 4cff3215cdb083d2fdb7c4a8f1b60e8c4028ba84
|
||||
README.zh.md: 7e3ba5be4f2707ff6408d296ece1f43550d76286
|
||||
|
||||
@@ -42,7 +42,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
|
||||
- **Bound storage identity.** Lookup requires one matching session directory across the readable project directories, then verifies that the header id equals the requested id and that the header's id/cwd derive the selected transcript path. Listing applies the same path check and rejects duplicate ids. Identity failures occur before repair or append.
|
||||
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
|
||||
- **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length.
|
||||
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects.
|
||||
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. An existing compressed artifact with no complete header frame, a checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end` is corruption and rejects.
|
||||
- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without truncating an incomplete tail or changing the lightweight revision.
|
||||
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
|
||||
- **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. A full-prefix read requires the same identity before and after reading the bytes, and `readStoredRevision()` uses that identity to validate retained preparations without loading the log. Snapshot listing forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another.
|
||||
|
||||
@@ -42,7 +42,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d
|
||||
- **绑定存储身份。** 查找要求可读项目目录中只有一个匹配会话目录,然后验证 header id 等于请求 id,且 header id/cwd 派生所选 transcript 路径。列表应用同一路径检查,并拒绝重复 id。身份失败发生在修复或 append 前。
|
||||
- **延迟实体化。**`create(meta)` 不写入;第一次 `append` 将编码 header 和第一批写入临时文件并执行 `fsync`。POSIX 通过硬链接无覆盖发布,并对父目录 `fsync`。Windows 通过 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 无覆盖发布,并通过同一 write-through pattern 创建缺失目录。已创建但从未 append 的会话不留下磁盘内容,不在 `list` 中。
|
||||
- **仅追加。** 已 flush 事件绝不重写。后续原始批次 append 行;压缩批次 append 一个 frame。两条路径都执行 `fsync`,并在捕获到写入或同步失败时回滚到之前字节长度。
|
||||
- **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷属于损坏,会被拒绝。
|
||||
- **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。已经存在却没有完整 header frame 的压缩工件、完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷都属于损坏,会被拒绝。
|
||||
- **非变更检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会截断不完整尾部或更改轻量修订。
|
||||
- **连续 seq。**`append` 拒绝第一个 `seq` 不继续已存储日志的批次,并拒绝非 JSON 可序列化 `event.data`,同时命名违规事件类型。
|
||||
- **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志;该标识会在 append、修复、替换或存储变更后改变。完整前缀读取要求读取字节前后的身份一致,`readStoredRevision()` 使用同一身份校验保留的 preparation,而不加载日志。快照列表通过产物发现转发精确信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用完成,然后在不启动另一次调用的情况下拒绝。
|
||||
|
||||
@@ -119,6 +119,8 @@ function isENOENT(error: unknown): boolean {
|
||||
* recovered from an incomplete final Zstandard frame.
|
||||
*/
|
||||
export class SessionPersistenceJsonl extends SessionPersistence implements PersistenceBackend<JsonlTornMarker> {
|
||||
override readonly supportsRawArtifacts = true
|
||||
|
||||
static inject = ['sessions']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -257,7 +259,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
let content: string
|
||||
if (this.compression === 'zstd') {
|
||||
const { frames } = scanZstdFrames(buffer)
|
||||
if (frames.length === 0) return undefined
|
||||
if (frames.length === 0) throw new Error('empty or header-less Zstandard session log')
|
||||
const decoder = createZstdFrameDecoder()
|
||||
const plaintexts: Buffer[] = []
|
||||
// The decoder yields views into a reused buffer; copy each frame's
|
||||
|
||||
@@ -377,16 +377,16 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type))
|
||||
})
|
||||
|
||||
it('readRaw is undefined for a zstd artifact that carries no frame', async () => {
|
||||
it('readRaw rejects a present zstd artifact that carries no frame', async () => {
|
||||
const root = await freshRoot()
|
||||
const ctx = await mount(root)
|
||||
const header = meta('raw-zero-frame', '/work')
|
||||
await ctx.sessionPersistence.create(header)
|
||||
await ctx.sessionPersistence.append(header.id, oneTurnLog())
|
||||
// Overwrite the physical artifact with a short buffer: frame scanning
|
||||
// answers zero frames before any magic check, so readRaw reports no artifact.
|
||||
// The path still exists, so zero frames is corruption rather than absence.
|
||||
await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0))
|
||||
expect(await ctx.sessionPersistence.readRaw(header.id)).toBeUndefined()
|
||||
await expect(ctx.sessionPersistence.readRaw(header.id))
|
||||
.rejects.toThrow('empty or header-less Zstandard session log')
|
||||
})
|
||||
|
||||
it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => {
|
||||
|
||||
@@ -97,6 +97,8 @@ export interface Config {
|
||||
* listeners. Its torn-tail marker is the seq to delete from.
|
||||
*/
|
||||
export class SessionPersistenceSqlite extends SessionPersistence implements PersistenceBackend<number> {
|
||||
override readonly supportsRawArtifacts = false
|
||||
|
||||
static inject = ['sessions']
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
|
||||
@@ -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/session/session-persistence/README.md
|
||||
README.md: c6875dbcfecdfd6ba4eb46d75feca1fbc6fc956d
|
||||
README.zh.md: 2ef5e9a90f0323f8edf8fdc4f936c41ca7e08c70
|
||||
README.md: 6e1898f8a49e54f8fe90ff27cf8571c5959f27e9
|
||||
README.zh.md: 901c41b6894d86bdc4ffb345314a3dd506e4a770
|
||||
|
||||
@@ -11,6 +11,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
| Method | Contract |
|
||||
|---|---|
|
||||
| `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. |
|
||||
| `supportsRawArtifacts: boolean` | State explicitly whether this backend exposes one verbatim artifact per session. Consumers check this capability before calling `readRaw`; `false` is not session absence. |
|
||||
| `readRaw(id, signal?): Promise<SessionRawArtifact \| undefined>` | Read a supported backend's own artifact text verbatim, decoded from its physical encoding but never reconstructed from events. `undefined` means only that the requested artifact is absent; an unsupported backend rejects. |
|
||||
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
|
||||
| `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
|
||||
| `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. |
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
| 方法 | 约定 |
|
||||
|---|---|
|
||||
| `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 |
|
||||
| `supportsRawArtifacts: boolean` | 明确说明该后端是否为每个会话暴露一份逐字工件。Consumer 在调用 `readRaw` 前检查此能力;`false` 并不表示会话缺失。 |
|
||||
| `readRaw(id, signal?): Promise<SessionRawArtifact \| undefined>` | 读取受支持后端自身的逐字工件文本;只解码物理编码,绝不从事件重建。`undefined` 仅表示所请求工件缺失;不支持的后端会拒绝。 |
|
||||
| `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 |
|
||||
| `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
|
||||
| `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 |
|
||||
|
||||
@@ -95,24 +95,32 @@ export abstract class SessionPersistence extends Service {
|
||||
*/
|
||||
abstract locate(meta: SessionHeader): SessionLocation | undefined
|
||||
|
||||
/**
|
||||
* Whether this backend exposes one verbatim raw artifact per session.
|
||||
* A backend that declares `true` must override {@link readRaw}.
|
||||
*/
|
||||
abstract readonly supportsRawArtifacts: boolean
|
||||
|
||||
/**
|
||||
* Read a session's backend-owned artifact text verbatim — the exact durable
|
||||
* bytes the backend wrote (decoded from its physical encoding, e.g. a
|
||||
* decompressed JSONL). The returned `content` is the raw text, not a
|
||||
* reconstruction from parsed events, so it preserves backend-specific
|
||||
* serialization (chunk packing, key order, line breaks). Backends without a
|
||||
* per-session artifact (SQLite) inherit the `undefined` default.
|
||||
* serialization (chunk packing, key order, line breaks). Callers first test
|
||||
* {@link supportsRawArtifacts}; `undefined` then means only that the requested
|
||||
* session has no materialized artifact.
|
||||
* @param _id - the persisted session to read (unused by the default: no
|
||||
* per-session artifact).
|
||||
* @param signal - optional cancellation for backend read work.
|
||||
* @returns the raw artifact plus its parsed header, or `undefined` when the
|
||||
* session is absent or the backend owns no per-session artifact.
|
||||
* session is absent.
|
||||
* @throws when this backend does not expose per-session raw artifacts.
|
||||
*/
|
||||
readRaw(_id: SessionId, signal?: AbortSignal): Promise<SessionRawArtifact | undefined> {
|
||||
if (signal?.aborted === true) {
|
||||
return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
return Promise.reject(new Error('this session persistence backend does not expose raw artifacts'))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,6 +68,8 @@ interface CoordinatorInternals {
|
||||
* durable behavior is covered by the JSONL and SQLite backends.
|
||||
*/
|
||||
class MemoryPersistence extends SessionPersistence implements PersistenceBackend<never> {
|
||||
override readonly supportsRawArtifacts = false
|
||||
|
||||
static inject = ['sessions']
|
||||
|
||||
override readonly name = 'session-persistence-memory'
|
||||
@@ -247,11 +249,14 @@ runPersistenceContract('memory', async () => {
|
||||
})
|
||||
|
||||
describe('the inherited readRaw default', () => {
|
||||
it('answers undefined and honors an aborted signal', async () => {
|
||||
it('rejects unsupported reads distinctly from absence and honors an aborted signal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(MemoryPersistence)
|
||||
expect(await ctx.sessionPersistence.readRaw(SessionId('any-session'))).toBeUndefined()
|
||||
expect(ctx.sessionPersistence.supportsRawArtifacts).toBe(false)
|
||||
await expect(
|
||||
ctx.sessionPersistence.readRaw(SessionId('any-session')),
|
||||
).rejects.toThrow('does not expose raw artifacts')
|
||||
await expect(
|
||||
ctx.sessionPersistence.readRaw(SessionId('any-session'), AbortSignal.abort()),
|
||||
).rejects.toThrow()
|
||||
|
||||
@@ -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: 2f328f642fea43578c4a0088b00915cea06066d5
|
||||
README.zh.md: 12e1ecd8932120c74384a289530954422ba145f2
|
||||
README.md: 349184a6e56e64cc3775990b9031ffecba7fcbb0
|
||||
README.zh.md: 2af8f5f8b8db2d5edf530d79dd81319846cfeeea
|
||||
|
||||
@@ -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 API, 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.
|
||||
|
||||
@@ -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 不记录;记录故障会刻意退化为不完整前缀,而不改变执行。
|
||||
|
||||
@@ -22,12 +22,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",
|
||||
@@ -35,6 +40,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow": "workspace:^",
|
||||
|
||||
@@ -15,8 +15,14 @@ import z from '@deepseek-ai/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 {
|
||||
WorkflowResult, WorkflowRun, WorkflowRunId, 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 +44,92 @@ export const Config: z<Config> = z.object({
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
interface WorkflowRecorder {
|
||||
start(session: Session, run: WorkflowRun): void
|
||||
finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void
|
||||
abandon(runId: WorkflowRunId): 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 active top-level workflow runs into their parent Sessions without
|
||||
* letting recording failure affect tool execution.
|
||||
*/
|
||||
function createWorkflowRecorder(ctx: Context): WorkflowRecorder {
|
||||
const active = new Map<WorkflowRunId, Session>()
|
||||
const append = <Type extends keyof ToolWorkflowRecordEventMap>(
|
||||
session: Session,
|
||||
type: Type,
|
||||
data: SessionEventMap[Type],
|
||||
): boolean => {
|
||||
// These four package-owned events are all log-only. Narrowing the generic
|
||||
// append face here discharges Session.append's conditional options tuple.
|
||||
const appendRecord = session.append.bind(session) as <Event extends keyof ToolWorkflowRecordEventMap>(
|
||||
event: Event,
|
||||
value: SessionEventMap[Event],
|
||||
) => void
|
||||
try {
|
||||
appendRecord(type, data)
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
ctx.on('workflow/agent-start', (info, agent) => {
|
||||
const session = active.get(info.id)
|
||||
if (session === undefined) return
|
||||
const data: ToolWorkflowAgentStartData = {
|
||||
runId: info.id,
|
||||
seq: agent.seq,
|
||||
label: agent.label,
|
||||
...agent.phase === undefined ? {} : { phase: agent.phase },
|
||||
childId: agent.childId,
|
||||
}
|
||||
if (!append(session, 'tool-workflow/agent-start', data)) active.delete(info.id)
|
||||
})
|
||||
ctx.on('workflow/agent-end', (info, agent) => {
|
||||
const session = active.get(info.id)
|
||||
if (session === undefined) return
|
||||
const data: ToolWorkflowAgentEndData = {
|
||||
runId: info.id,
|
||||
seq: agent.seq,
|
||||
outcome: agent.outcome,
|
||||
}
|
||||
if (!append(session, 'tool-workflow/agent-end', data)) active.delete(info.id)
|
||||
})
|
||||
|
||||
return {
|
||||
start(session, run) {
|
||||
if (append(session, 'tool-workflow/run-start', { runId: run.id, name: run.meta.name })) {
|
||||
active.set(run.id, session)
|
||||
}
|
||||
},
|
||||
finish(runId, stopReason) {
|
||||
const session = active.get(runId)
|
||||
if (session !== undefined) append(session, 'tool-workflow/run-end', { runId, stopReason })
|
||||
active.delete(runId)
|
||||
},
|
||||
abandon: (runId) => { active.delete(runId) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -114,6 +206,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// schemastery (the exported Config schema) has already filled the defaulted
|
||||
// fields; the assertion records that resolution, not a hidden fallback.
|
||||
const { toolName, maxResultChars } = config as ResolvedConfig
|
||||
const recorder = createWorkflowRecorder(ctx)
|
||||
// Usage policy ships with the tool (the master convention: tool guidance
|
||||
// lives in tool plugins as prompt sections, not in the deployment persona).
|
||||
ctx.systemPrompt.section({
|
||||
@@ -188,13 +281,17 @@ 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({
|
||||
const run = ctx.workflows.start({
|
||||
script: args.script,
|
||||
meta: args.meta,
|
||||
...args.args !== undefined ? { args: args.args } : {},
|
||||
parent,
|
||||
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
|
||||
// script is in flight, cancel the whole run. The signal also enters the engine directly, but
|
||||
@@ -202,8 +299,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 +315,18 @@ 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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
presentCall: args => presentWorkflowCall(args),
|
||||
|
||||
@@ -1,30 +1,167 @@
|
||||
/**
|
||||
* 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 '@deepseek-ai/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>
|
||||
|
||||
/** Whether this package owns the candidate Session event. */
|
||||
function isWorkflowRecordEvent(event: SessionEvent): boolean {
|
||||
return event.type.startsWith('tool-workflow/')
|
||||
}
|
||||
|
||||
/** 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>
|
||||
}
|
||||
|
||||
/** Copy only the run one candidate can mutate; other committed states stay shared. */
|
||||
function cloneTraceForEvent(
|
||||
source: WorkflowTrace,
|
||||
event: SessionEvent,
|
||||
fail: InvariantFailure,
|
||||
): WorkflowTrace {
|
||||
const trace = new Map(source)
|
||||
if (event.type === 'tool-workflow/run-start') return trace
|
||||
const data = recordOf(event, fail)
|
||||
const runId = stringId(data.runId, `${event.type} runId`, fail)
|
||||
const run = source.get(runId)
|
||||
if (run !== undefined) {
|
||||
trace.set(runId, { ended: run.ended, members: new Map(run.members) })
|
||||
}
|
||||
return trace
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
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
|
||||
run.members.clear()
|
||||
return
|
||||
}
|
||||
default:
|
||||
fail(`unknown tool-workflow event type ${event.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail)
|
||||
traces.set(session, trace)
|
||||
return trace
|
||||
}
|
||||
ctx.sessions.list().forEach(seed)
|
||||
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]
|
||||
if (!isWorkflowRecordEvent(event)) return
|
||||
// session/event dispatch follows list() or session/created seeding.
|
||||
const trace = cloneTraceForEvent(traces.get(session) as WorkflowTrace, event, fail)
|
||||
applyEvent(trace, event, fail)
|
||||
staged.set(event, { session, trace })
|
||||
}, { global: true })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (!isWorkflowRecordEvent(event)) return
|
||||
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 */
|
||||
|
||||
64
packages/workflow/tool-workflow/src/types.ts
Normal file
64
packages/workflow/tool-workflow/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
199
packages/workflow/tool-workflow/tests/invariant.spec.ts
Normal file
199
packages/workflow/tool-workflow/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/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/)
|
||||
})
|
||||
})
|
||||
@@ -3,15 +3,18 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-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,55 @@ 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
|
||||
|
||||
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)
|
||||
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 +81,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 +101,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 +124,150 @@ 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('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 +429,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',
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
@@ -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: 78e9e662f378c88b6790d128fefd6c01f3928bd6
|
||||
README.zh.md: 62abd00c013d054f4111a2db2ce72c58d3514087
|
||||
README.md: af61308b99bffb290fe2a11cb6c3ccd228cbfaf6
|
||||
README.zh.md: 6d85c3b7e847b8c6176d4c1938805c22c543678b
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 兑现。
|
||||
|
||||
@@ -22,12 +22,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",
|
||||
|
||||
@@ -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 '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
|
||||
49
packages/workflow/workflow/src/runtime-types.ts
Normal file
49
packages/workflow/workflow/src/runtime-types.ts
Normal 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>
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user