diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml new file mode 100644 index 0000000000..07186d7a2c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +2026-08-24-activity-observation-seam.md: da356b3b871a45958dc5a5214ccff8b49c0c193f +2026-08-24-activity-observation-seam.zh.md: 1c0c87d23a08d41882f00fd8f729f794d09169b1 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md new file mode 100644 index 0000000000..da356b3b87 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -0,0 +1,51 @@ +# Agent Note: The activity observation seam (`ctx.activities`) and live output streaming to the Web client + +Status: implemented + +English | [中文](2026-08-24-activity-observation-seam.zh.md) + +## Problem + +Every incremental reader in the shell, terminal, and jobs layers is single-consumer and consuming: `ShellProcess.readOutput()`, `TerminalSendOperation.readOutput()`, and `JobRegistry.read()` all destroy the delta they return, because the owning model is the intended reader. The [web background-job display](2026-08-08-web-background-job-display.md) shipped the roster on those terms — its carrier is test-pinned to never call `ctx.jobs.read()`, since a browser read would silently steal bytes the model's `job_output` would never see — and explicitly deferred the output phase to "a separate non-consuming observation API". So a human watching the Web client saw that a background build was running but never a line of its output, and the `workflow/log` / `workflow/phase` events scripts are told to narrate with had no consumer at all. + +The substrate below bash already had the right shape: `SubprocessOutputReader.readFrom(fromByte)` is offset-based and explicitly non-consuming, and `bash-local` narrows it to a single cursor pair at the shell seam. + +## Decision + +A new capability seam, `packages/activity/`, is the non-consuming observation plane. An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The seam is pure observation — it starts nothing, cancels nothing, and is invisible to the model. Model-visible facts stay exactly where they were (tool results, `ctx.jobs` cursors), so the "model-visible ⟺ logged" invariant is untouched and a composition without the seam loses only live observation. + +- **`@deepseek-ai/dsh-activity` (Service Definition)** — abstract `ActivityRegistry` on `ctx.activities`: `open(spec) → ActivityHandle { append, updateDetail, end }`, fenced `get`/`list`, non-consuming `read(id, from)` over absolute UTF-8 byte offsets, owner-relative `onActivitiesChanged` (roster) and `onOutput` (advancement signal, id only). `ActivityCorrelation { callId?, jobId? }` links a row to its tool call and job. `pumpActivityOutput` is the shared pull-substrate pump. +- **`@deepseek-ai/dsh-activity-local` (Service Provider)** — in-memory chunk ring per activity. Offsets stay absolute across eviction (`outputEarliest` marks the oldest retained byte; a read below it is `lossy`, never an error); a single over-cap chunk keeps its UTF-8-safe tail with `gapBefore`. Config `retainBytes` (256 KiB) live, trimmed to `settledRetainBytes` (16 KiB) at settlement. Records outlive producer fibers; owner disposal force-ends and removes; `end` is first-wins and post-end writes log-and-drop so a producer's trailing flush cannot break its own teardown. +- **`@deepseek-ai/dsh-api-activity-controller`** — the wire, in the workspace-controller shape: `activity.control` streams one roster baseline then whole-bucket replacement frames per owner session (the jobs-frame self-healing semantics), and `activity.observe({ activityId, from? })` streams one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget), then the terminal `status` on the same stream before closing — so settlement can never race a still-open output channel. Reconnects resume from the last frame's `next`. The client half installs `ctx.activityFeed` (roster mirror plus reference-counted observation streams with a bounded render tail). +- **`@deepseek-ai/dsh-client-ui-activity`** — one session-header action beside the job list; expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame. + +Producers mirror best-effort behind `ctx.get('activities')`, never a declared inject, and every observation failure is logged and swallowed — the seam is strictly optional and can never break the work it observes: + +- `ShellProcess` gained optional `observed` — the substrate's non-consuming offset readers re-exposed (bash-local and pwsh-local return `handle.collected`; the e2b subprocess provider satisfies the same contract). `dsh-tool-bash` / `dsh-tool-pwsh` open a correlated activity after `jobs.start()` commits and pump `observed` at `activityPollMs` (default 150 ms). +- `dsh-tool-workflow` mirrors each recorded top-level run as a push-mode `workflow` activity, giving the previously unconsumed `workflow/phase` / `workflow/log` events their consumer. + +### Why not durable session events, the control stream, or the jobs seam + +Peer products converge on the same split (Codex marks every exec-output delta "Transient, non-durable" in its rollout policy; Claude Code persists a file path, never deltas; Kimi's envelope has a first-class `volatile: true`; OpenCode's V2 corrected V1's per-chunk durable part updates to "stream fragments are live-only"). In this repository the durable route is also concretely bad: chunk-run packing is hardcoded to the three `assistant/chunk` shapes so a new output event gets zero compression, each event is one uncoalesced WebSocket frame, and reconnect gap-repair re-reads the window — and the job-display note already rejected it ("spill exists precisely so oversized tool output stays out of the log"). The session control stream's whole-snapshot frames are self-healing for rosters but quadratic for streams. Extending the jobs seam would cover only jobs — not workflow narration, not a future foreground stream — and would re-litigate the settled "producers own their buffers" decision; the activity plane instead recovers the substrate's existing offsets. + +### Two cursor regimes, kept separate + +The model keeps its consuming cursors (`job_output` semantics unchanged, zero prompt or tool changes anywhere in this work); observers get absolute offsets any number of readers can hold. This mirrors the strongest peer pattern (Codex: destructive drain for the model, seq-cursor reads for infrastructure) and is what makes the wire path provably non-stealing. + +## Alternatives considered + +**Durable `activity/output` session events with a conversation node.** Replay for free, but the log-growth math above, plus retention pressure on every follower, killed it; the transcript's durable story remains the producers' tool results. + +**Extending `SessionControlFrame` with output.** One fewer stream, but whole-snapshot semantics resend the accumulated buffer per chunk — O(n²) on exactly the data that grows. + +**A signal frame plus RPC pull.** The subagent-catalog shape; rejected for the split-authority apparatus the job-display note documents, and it fails worst at settlement (running row, dead stream). + +**Observation inside the jobs seam.** One integration point, but only for jobs; foreground runs, terminals, and workflow narration would each need another mechanism, and the registry would grow a second buffer beside the producers' own. + +## Testing + +Unit suites pin the registry (offsets, eviction, UTF-8-safe tail trims, lossy reads, first-wins settlement, owner cleanup, scoped delivery, listener containment, HMR disposal, Loader composition with row config), the pump (cadence, per-source cursors, lossy `gapBefore`, settlement drain, timer hygiene), the controller (baseline/bucket frames, anchor/output/status ordering, resume, frame splitting, fenced owned reads, abort), the client model and reference-counted streams, and both producer taps (real subprocess output for bash including the never-steals-the-model's-cursor assertion; scripted readers for pwsh; event-driven mirror for workflow, including the throwing-registry containment the optionality promise requires). The keyless web e2e drives a real `run_in_background` bash through the full composition and pins ARIA goldens for the streaming panel while the command still runs and for the settled panel after a registry kill. + +## Consequences + +The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the roster duplicates a background job's row across two header lists (accepted, as the subagent catalog precedent was). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart. diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md new file mode 100644 index 0000000000..1c0c87d23a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md @@ -0,0 +1,51 @@ +# Agent Note:activity 观察 seam(`ctx.activities`)与 Web 客户端实时输出流 + +状态:implemented + +[English](2026-08-24-activity-observation-seam.md) | 中文 + +## 问题 + +shell、terminal 与 jobs 各层的增量读取器全都是单读者且消费型的:`ShellProcess.readOutput()`、`TerminalSendOperation.readOutput()` 与 `JobRegistry.read()` 返回即销毁增量,因为预期读者就是持有模型。[Web 后台任务显示](2026-08-08-web-background-job-display.zh.md)在这一前提下交付了 roster——其载体被测试钉死绝不调用 `ctx.jobs.read()`,否则浏览器读取会无声偷走模型 `job_output` 永远看不到的字节——并把输出阶段明确推迟给"一个独立的非消费观察 API"。于是人在 Web 客户端只能看到后台构建"在跑",看不到一行输出;脚本被告知用来叙述进度的 `workflow/log` / `workflow/phase` 事件更是全无消费者。 + +bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom(fromByte)` 是按 offset、明确非消费的,`bash-local` 在 shell seam 把它窄化成了单游标对。 + +## 决策 + +新建能力 seam `packages/activity/`,作为非消费观察面。**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。该 seam 是纯观察——不启动、不取消任何工作,对模型不可见。模型可见事实原地不动(工具结果、`ctx.jobs` 游标),"模型可见 ⟺ 已记录"不变量不受影响;不装该 seam 的组合只失去实时观察。 + +- **`@deepseek-ai/dsh-activity`(Service Definition)**——`ctx.activities` 上的抽象 `ActivityRegistry`:`open(spec) → ActivityHandle { append, updateDetail, end }`、有栅栏的 `get`/`list`、按绝对 UTF-8 字节 offset 的非消费 `read(id, from)`、owner 相对投递的 `onActivitiesChanged`(roster)与 `onOutput`(推进信号,只带 id)。`ActivityCorrelation { callId?, jobId? }` 把行关联到工具调用与 job。`pumpActivityOutput` 是 pull 型底座的共享泵。 +- **`@deepseek-ai/dsh-activity-local`(Service Provider)**——每个 activity 一个内存 chunk 环。offset 跨淘汰稳定(`outputEarliest` 指明最老保留字节;低于它的读取是 `lossy` 而非错误);单个超上限 chunk 保留 UTF-8 安全尾部并带 `gapBefore`。配置 `retainBytes`(256 KiB)存活,结算时裁到 `settledRetainBytes`(16 KiB)。记录存续期长于生产者 fiber;owner 销毁强制终结并移除;`end` first-wins,end 后写入记日志后丢弃,生产者的收尾 flush 砸不掉自己的 teardown。 +- **`@deepseek-ai/dsh-api-activity-controller`**——wire 层,取 workspace-controller 的形态:`activity.control` 流出一份 roster baseline 加按 owner 会话的整桶替换帧(jobs 帧的自愈语义);`activity.observe({ activityId, from? })` 流出一帧 `opened` 锚点、合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算)、再在同一条流上发终态 `status` 后关闭——结算永远不会与仍开着的输出通道竞态。重连以上一帧的 `next` 续传。客户端半区安装 `ctx.activityFeed`(roster 镜像 + 按引用计数的观察流与有界渲染尾巴)。 +- **`@deepseek-ai/dsh-client-ui-activity`**——job 列表旁的一个会话头部 action;展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。 + +生产者一律经 `ctx.get('activities')` 尽力镜像,绝不声明 inject,一切观察失败只记日志并吞掉——该 seam 严格可选,永远不能破坏它所观察的工作: + +- `ShellProcess` 新增可选 `observed`——底座的非消费 offset 读取器复出(bash-local 与 pwsh-local 返回 `handle.collected`;e2b subprocess provider 满足同一契约)。`dsh-tool-bash` / `dsh-tool-pwsh` 在 `jobs.start()` 提交后打开带关联的 activity,并以 `activityPollMs`(默认 150 ms)泵取 `observed`。 +- `dsh-tool-workflow` 把每个被记录的顶层运行镜像为 push 形态的 `workflow` activity,让此前无人消费的 `workflow/phase` / `workflow/log` 事件有了消费者。 + +### 为何不用持久会话事件、control 流或 jobs seam + +同行产品收敛于同一切分(Codex 在 rollout policy 里把所有 exec 输出 delta 标为 "Transient, non-durable";Claude Code 持久化文件路径而非 delta;Kimi 的信封有一等的 `volatile: true`;OpenCode V2 把 V1 逐 chunk 持久化 part 更新纠正为 "stream fragments are live-only")。在本仓库持久路线还有具体的坏处:chunk-run 打包只认三种 `assistant/chunk` 形态,新输出事件零压缩;每事件一条未合并的 WebSocket 帧;重连缺口修复要重读窗口——且 job 显示 note 已否决过它("spill 的存在就是为了让超大工具输出不进日志")。session control 流的整值帧对 roster 自愈、对流则是二次方开销。扩 jobs seam 只覆盖 jobs——workflow 叙述与未来的前台流都覆盖不到——还要重翻已定案的"生产者拥有自己的缓冲";activity 面转而复用底座既有的 offset。 + +### 两套游标制,各归其位 + +模型保留消费游标(`job_output` 语义不变,本次工作零 prompt/工具改动);观察者拿任意多读者可共持的绝对 offset。这正是最强的同行范式(Codex:模型用破坏性 drain,基础设施用 seq 游标读取),也是 wire 路径可证明"不偷字节"的原因。 + +## 曾考虑的替代方案 + +**持久 `activity/output` 会话事件 + conversation node。** 回放免费,但上述日志膨胀账加上对每个 follower 的保留压力否掉了它;transcript 侧的持久故事仍是生产者的工具结果。 + +**给 `SessionControlFrame` 加输出。** 少一条流,但整值语义会按 chunk 重发累计缓冲——恰好在会增长的数据上 O(n²)。 + +**信号帧 + RPC 拉取。** subagent catalog 的形态;因 job 显示 note 记录的权威割裂机制而否决,且它在结算时刻失效得最难看(行还在跑、流已死)。 + +**在 jobs seam 内做观察。** 一个集成点,但只对 jobs;前台运行、terminal 与 workflow 叙述各需另一套机制,注册表还得在生产者自己的缓冲旁再养一份。 + +## 测试 + +单测钉住注册表(offset、淘汰、UTF-8 安全裁尾、lossy 读取、first-wins 结算、owner 清理、scope 投递、监听容错、HMR 可逆、带行配置的 Loader 组合)、泵(节奏、每源游标、lossy `gapBefore`、结算排干、定时器卫生)、controller(baseline/桶帧、锚点/输出/状态排序、续传、帧切分、有栅栏的有主读取、abort)、客户端模型与引用计数流,以及两类生产者接入(bash 用真实子进程输出,含"绝不偷模型游标"断言;pwsh 用脚本化读取器;workflow 用事件驱动镜像,含可选性承诺要求的 throwing-registry 容错)。无钥 web e2e 经完整组合驱动真实 `run_in_background` bash,钉住命令仍在运行时流式面板与注册表 kill 后已结算面板的 ARIA golden。 + +## 后果 + +Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;后台 job 的行会在两个头部列表重复出现(沿用 subagent catalog 的既有取舍)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9ec9a9a22d..31548a2d43 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -40,6 +40,8 @@ flowchart LR cfg --> plugin_dsh_base_agent_default_model plugin_dsh_base_jobs["jobs
@deepseek-ai/dsh-jobs-local"] cfg --> plugin_dsh_base_jobs + plugin_dsh_base_activity["activity
@deepseek-ai/dsh-activity-local"] + cfg --> plugin_dsh_base_activity plugin_dsh_base_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] cfg --> plugin_dsh_base_llm_retry plugin_dsh_base_settings["settings
@deepseek-ai/dsh-settings-file"] @@ -190,6 +192,7 @@ flowchart LR | `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | +| `activity` | `@deepseek-ai/dsh-activity-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | | `settings` | `@deepseek-ai/dsh-settings-file` | | `credentials` | `@deepseek-ai/dsh-credentials-local` | diff --git a/apps/web/tests/live-activity-stream.e2e.ts b/apps/web/tests/live-activity-stream.e2e.ts new file mode 100644 index 0000000000..750e1697f3 --- /dev/null +++ b/apps/web/tests/live-activity-stream.e2e.ts @@ -0,0 +1,134 @@ +// Session-header live activities driven by a real background bash run: the +// roster row arrives over the activity control stream, expanding it opens the +// observation stream, and the panel shows the process's real output while it +// is still running. No model call is involved. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId } from '@deepseek-ai/dsh-jobs' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-activity-stream', import.meta.url)) +const STREAMING_EXPECTED = join(SNAPSHOT_DIR, 'streaming.expected.md') +const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'live-activity-stream-web-e2e' +// One fixed line of early output, then silence long enough that the streaming +// assertions never race the process exiting; the test kills it explicitly. +const COMMAND = "printf 'streamed-%s\\n' marker-line; sleep 45" + +/** + * Wait for opening a session to publish its live Agent. + * @param scaffold - the booted web scaffold. + * @param sessionId - the opened session's identity. + * @returns the registered Agent instance. + */ +async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { + const deadline = Date.now() + 30_000 + for (;;) { + const found = scaffold.ctx.agents.get(sessionId) + if (found !== undefined) return found + if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + let jobId: JobId + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // Opening the session drives the Host's ordinary Agent resolution; the + // activity owner must be that exact live instance, never a second one. + agent = await liveAgent(scaffold, SessionId(SEED_ID)) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('streams a running background command\'s real output into the expanded panel', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-streaming')) + const trigger = page.getByRole('button', { name: '1 live activity' }) + expect(await trigger.count()).toBe(0) + + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('live-activity-stream-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Emit one live line then hold', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-\d+\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) + jobId = JobId(matched[0]) + + // The roster row reaches the header over the activity control stream. + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + + // Expanding the row opens the observation stream; the panel then renders + // the command's real stdout while the process is still running. + const expand = page.getByRole('button', { name: /Show live output/ }) + await expand.waitFor({ timeout: 10_000 }) + await expand.click() + await page.getByText('streamed-marker-line').waitFor({ timeout: 15_000 }) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STREAMING_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('settles the open panel when the job is killed, keeping the streamed tail', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-settled')) + expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') + + // Settlement arrives on the observation stream itself, so the open panel + // flips without any further interaction; the roster trigger follows. + const idle = page.getByRole('button', { name: '1 activity' }) + await idle.waitFor({ timeout: 20_000 }) + await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['settled.expected.md', 'streaming.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md new file mode 100644 index 0000000000..7ffec602cb --- /dev/null +++ b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md @@ -0,0 +1,8 @@ +- list "Live activities": + - listitem: + - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: + - text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM" + - img + - text: done $ printf 'streamed-%s\n' marker-line; sleep 45 + - button "Copy" + - text: streamed-marker-line diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md new file mode 100644 index 0000000000..fd536ef430 --- /dev/null +++ b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md @@ -0,0 +1,8 @@ +- list "Live activities": + - listitem: + - button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]: + - text: bash printf 'streamed-%s\n' marker-line; sleep 45 running + - img + - text: running $ printf 'streamed-%s\n' marker-line; sleep 45 + - button "Copy" + - text: streamed-marker-line diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 386615f059..9ea5d0c61e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -90,6 +90,7 @@ "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/background-job-list.e2e.ts", + "tests/live-activity-stream.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", "tests/turn-tail-actions.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 82e57fd2f7..a7e83a6223 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: add615c252948adab8db7dec7059f94b8f45e52c -architecture.zh.md: 48baf14d6a29e5ef77e6e47f4d9fa9adc4e9e748 +architecture.md: cfa1dee639ebc4ae60db13d11d246c2ffa103cd0 +architecture.zh.md: 0139f575a2721a429ac6b241638c031c68f8530f diff --git a/docs/architecture.md b/docs/architecture.md index add615c252..cfa1dee639 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -127,6 +127,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` | | Add a human command | register on `ctx.commands`; it dispatches without a model turn | | Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it | +| Stream live output to observers | open a `ctx.activities` handle; the Web client renders it | | Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter | | Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 48baf14d6a..0139f575a2 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -131,6 +131,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` | | 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 | | 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 | +| 向观察者流式输出 | 打开一个 `ctx.activities` 句柄;Web 客户端负责渲染 | | 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 | | 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 | | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index d7e1c70e5a..c1a409d2e8 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 0994b186f7daa6afbff0f1484216e55fc79170ff -capability-seams.zh.md: 48aa0a5353bba8d56f63abfcfc4cd2c601569cb6 +capability-seams.md: de9b8e1eb68dd7bf110609044dbf96a93a59adc5 +capability-seams.zh.md: 0b8e93fd3f0b08770652ca735966649b35f98330 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 0994b186f7..de9b8e1eb6 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -40,6 +40,8 @@ flowchart LR pkg_apiproxy["apiproxy"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + pkg_api_activity_controller["api-activity-controller"] + svc_activityController["ctx.activityController
Host Activity Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -178,6 +180,11 @@ flowchart LR svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] pkg_tool_jobs["tool-jobs"] + pkg_activity["activity"] + svc_activities["ctx.activities
Live activity observation registry"] + pkg_activity_local["activity-local"] + pkg_tool_workflow["tool-workflow"] + pkg_activity_controller["activity-controller"] pkg_web["web"] svc_web["ctx.web
Web access provider registry"] pkg_web_search_exa["web-search-exa"] @@ -201,7 +208,6 @@ flowchart LR pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] pkg_workflow_worker_thread["workflow-worker-thread"] - pkg_tool_workflow["tool-workflow"] pkg_webhook["webhook"] svc_webhookRuntime["ctx.webhookRuntime
Webhook rule runtime"] pkg_webhook_github["webhook-github"] @@ -214,11 +220,14 @@ flowchart LR svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_acp --> svc_approval + pkg_activity --> svc_activities + pkg_activity_local --> svc_activities pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_agent_team --> svc_agentTeams + pkg_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_workspace_controller --> svc_workspaceController @@ -325,6 +334,10 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_activities --> pkg_activity_controller + svc_activities --> pkg_tool_bash + svc_activities --> pkg_tool_pwsh + svc_activities --> pkg_tool_workflow svc_agentDefaultModel --> pkg_headless svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo @@ -456,6 +469,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | `apiproxy` | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | +| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace. | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | @@ -501,6 +515,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | `agent-team` | - | `tool-agent-team` | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes the scoped model policy and controls. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | +| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 48aa0a5353..0b8e93fd3f 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -42,6 +42,8 @@ flowchart LR pkg_apiproxy["apiproxy"] pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] + pkg_api_activity_controller["api-activity-controller"] + svc_activityController["ctx.activityController
Host Activity Remote controller"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -180,6 +182,11 @@ flowchart LR svc_jobs["ctx.jobs
Background job registry"] pkg_jobs_local["jobs-local"] pkg_tool_jobs["tool-jobs"] + pkg_activity["activity"] + svc_activities["ctx.activities
Live activity observation registry"] + pkg_activity_local["activity-local"] + pkg_tool_workflow["tool-workflow"] + pkg_activity_controller["activity-controller"] pkg_web["web"] svc_web["ctx.web
Web access provider registry"] pkg_web_search_exa["web-search-exa"] @@ -203,7 +210,6 @@ flowchart LR pkg_workflow["workflow"] svc_workflowEngine["ctx.workflowEngine
Workflow script engine"] pkg_workflow_worker_thread["workflow-worker-thread"] - pkg_tool_workflow["tool-workflow"] pkg_webhook["webhook"] svc_webhookRuntime["ctx.webhookRuntime
Webhook rule runtime"] pkg_webhook_github["webhook-github"] @@ -216,11 +222,14 @@ flowchart LR svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_acp --> svc_approval + pkg_activity --> svc_activities + pkg_activity_local --> svc_activities pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_agent_team --> svc_agentTeams + pkg_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_workspace_controller --> svc_workspaceController @@ -327,6 +336,10 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_activities --> pkg_activity_controller + svc_activities --> pkg_tool_bash + svc_activities --> pkg_tool_pwsh + svc_activities --> pkg_tool_workflow svc_agentDefaultModel --> pkg_headless svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo @@ -458,6 +471,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), `subagent-inprocess`, [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | `apiproxy` | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | +| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | 通过生成的 Remote namespace 负责实时活动 roster 流与逐 activity 的非消费输出观察。 | | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | @@ -503,6 +517,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | `agent-team` | - | `tool-agent-team` | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供作用域化模型策略和控制工具。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | +| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | 生产方把实时输出与状态镜像进非消费、按 offset 寻址的流;activity-controller 把它们送达浏览器,模型侧路径(jobs 游标、工具结果)不受影响。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9ba399370d..6c2e824a55 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 6494070f6204ce6130e22d6f71ddbfd18fdc69dd -config-catalog.zh.md: da19d854ca520425a799cb699238a7dca05a90da +config-catalog.md: 0bf883bbfde80377602be90bd913c32f1b320f63 +config-catalog.zh.md: 381319465dab9dafeecaacf704caba8bab7455f3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6494070f62..0bf883bbfd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -33,6 +33,22 @@ Depends on: `Stream` (`@agentclientprotocol/sdk`) Source: [`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts) + + +## `@deepseek-ai/dsh-activity-local` + +```ts config-catalog +/** Configuration for the process-local observation registry. */ +export interface Config { + /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} +``` + +Source: [`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts) + ## `@deepseek-ai/dsh-agent-default-model` @@ -276,6 +292,24 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-activity-controller` + +Requires: `typert` + +```ts config-catalog +/** Configuration for the Activity Remote owner. */ +export interface Config { + /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ + flushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + maxFrameBytes?: number +} +``` + +Source: [`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -2559,10 +2593,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -Source: [`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) +Source: [`packages/shell/tool-bash/src/index.ts:36`](../packages/shell/tool-bash/src/index.ts) @@ -2724,10 +2760,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv` export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +Source: [`packages/shell/tool-pwsh/src/index.ts:61`](../packages/shell/tool-pwsh/src/index.ts) @@ -3004,7 +3042,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) @@ -3303,6 +3341,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-activity` ([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3376,6 +3415,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). +- `@deepseek-ai/dsh-activity` — abstract `ActivityRegistry` ([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts)) - `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index da19d854ca..381319465d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -35,6 +35,22 @@ export interface AcpConfig { 来源:[`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts) + + +## `@deepseek-ai/dsh-activity-local` + +```ts config-catalog +/** Configuration for the process-local observation registry. */ +export interface Config { + /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} +``` + +来源:[`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts) + ## `@deepseek-ai/dsh-agent-default-model` @@ -278,6 +294,24 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-activity-controller` + +需要:`typert` + +```ts config-catalog +/** Configuration for the Activity Remote owner. */ +export interface Config { + /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ + flushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + maxFrameBytes?: number +} +``` + +来源:[`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -2561,10 +2595,12 @@ export type TokenMeterConfig = Record export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -来源:[`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) +来源:[`packages/shell/tool-bash/src/index.ts:36`](../packages/shell/tool-bash/src/index.ts) @@ -2726,10 +2762,12 @@ export interface Config { export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } ``` -来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +来源:[`packages/shell/tool-pwsh/src/index.ts:60`](../packages/shell/tool-pwsh/src/index.ts) @@ -3305,6 +3343,7 @@ export interface Config { - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-activity`([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) - `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts)) - `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) @@ -3378,6 +3417,7 @@ export interface Config { 抽象服务类——部署时应改为加载具体的实现包(参见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md))。 +- `@deepseek-ai/dsh-activity` — 抽象 `ActivityRegistry`([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts)) - `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — 抽象 `CodeRuntime`([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 33c536f50a..b48dba34e8 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 4ab1275e9309e150504d6a3bdd80792bb1a49ea1 -event-producer-consumer.zh.md: 8d8a401bfdccc74d5774ce5ce7ceac2c548f6c72 +event-producer-consumer.md: ad9a32ed1fd479d1cd14422333f0cd5b6845e55f +event-producer-consumer.zh.md: 783f524b8b31bfcd80237b10f078ed6a27ce13f2 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4ab1275e93..ad9a32ed1f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -69,8 +69,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 8d8a401bfd..783f524b8b 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -71,8 +71,8 @@ | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## 包源码中出现的非 harness 或未声明事件字符串 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index e43820301d..c7bb24dd77 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: 2b277650c4b9e320183f75845d68f922b31a522e -README.zh.md: 2279857e0d58cb36e8027c196075622344d705ae +README.md: b41c74e46e43ca2fea378ac7e9988136a6ee61ee +README.zh.md: fa4fb55034450bfc5f537ea1a2c0aaccfba469de diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 2b277650c4..b41c74e46e 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -44,6 +44,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | | [jobs.md](jobs.md) | the background-job runtime: branded `JobId`s, the producer contract, consumer views, and `ctx.jobs` service behavior | +| [activity.md](activity.md) | live activity observation: branded `ActivityId`s, the producer handle, non-consuming offset reads, and the wire streams | | [permission-presets.md](permission-presets.md) | the permission-preset layer: `PresetSpec`/`PresetOption`, the derived `custom` state, the log-only `permission/preset` event | | [plan.md](plan.md) | plan mode: the log-only `plan/mode` state, pending-selection flush, `PlanModeConfig`, the `exit_plan_mode` review arc | | [invariants.md](invariants.md) | the runtime-invariant registry: selection `Config`, `InvariantInstaller`/`InvariantFailure`, the empty-companion contract | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 2279857e0d..fa4fb55034 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -44,6 +44,7 @@ | [spill.md](spill.zh.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` | | [workflow.md](workflow.zh.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 | | [jobs.md](jobs.zh.md) | 后台任务运行时:品牌化 `JobId`、producer 约定、消费方视图和 `ctx.jobs` 服务行为 | +| [activity.md](activity.zh.md) | 实时活动观察:品牌化 `ActivityId`、生产者句柄、非消费 offset 读取与 wire 流 | | [permission-presets.md](permission-presets.zh.md) | 权限预设层:`PresetSpec`/`PresetOption`、派生的 `custom` 状态、仅记日志的 `permission/preset` 事件 | | [plan.md](plan.zh.md) | 计划模式:仅记日志的 `plan/mode` 状态、待定选择的冲刷、`PlanModeConfig`、`exit_plan_mode` 审阅流程 | | [invariants.md](invariants.zh.md) | 运行时不变式注册表:选择配置 `Config`、`InvariantInstaller`/`InvariantFailure`、空配套插件约定 | diff --git a/docs/subsystems/activity.i18n.yaml b/docs/subsystems/activity.i18n.yaml new file mode 100644 index 0000000000..cf285e6ba7 --- /dev/null +++ b/docs/subsystems/activity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/subsystems/activity.md +activity.md: 000bdc30be05eb17d670367df3fd214e7d0af7f3 +activity.zh.md: 1fc79e9d596351a1f818ea83c15d1b5082689b79 diff --git a/docs/subsystems/activity.md b/docs/subsystems/activity.md new file mode 100644 index 0000000000..000bdc30be --- /dev/null +++ b/docs/subsystems/activity.md @@ -0,0 +1,141 @@ +# Live Activity Observation + +English | [中文](activity.zh.md) + +Types shared by streaming-output producers, `ctx.activities`, and the observation consumers. The [observation-seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) owns the design; this page records the cross-package vocabulary from [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) and the wire frames from [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts). + +An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The registry is a pure observation plane — it starts nothing, cancels nothing, and is invisible to the model. Producers keep their execution resources and their model-facing surfaces (`ctx.jobs` consuming cursors, tool results); observers read non-consumingly at absolute byte offsets, so any number of them coexist with the model's single consuming cursor. + +## Ids, kinds, and correlation + +`ActivityId` is a [branded id](core.md#branded-ids) generated as `-N`; visibility relies on owner authorization, not id secrecy. `ActivityKindMap` is merge-extensible (`bash` seeded; `pwsh` and `workflow` merged by their producers). `ActivityCorrelation` links one activity to the `CallId` that started the work and the `JobId` carrying its model-facing control surface, so a consumer attaches the stream to an existing card or row without guessing from labels. + +## Producer face + +`open(spec)` returns an `ActivityHandle` with synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. Offsets advance by each chunk's UTF-8 byte length; `gapBefore` marks producer-side loss so a discontinuity stays visible. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop rather than throw, so a producer's trailing flush cannot break its own teardown. + +`pumpActivityOutput(handle, sources, { pollMs, done })` adapts pull substrates: it copies each non-consuming offset reader (the subprocess `readFrom(fromByte)` family, re-exposed on `ShellProcess.observed`) into the handle at a bounded cadence and drains once more after `done` settles. Push-natural producers (the workflow tool's run mirror) append directly. + +## Observation + +`read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` and the resume offset `next`, consuming nothing. Offsets stay absolute across eviction: `outputEarliest` on the snapshot names the oldest retained byte, and a read below it is `lossy: true`, never an error. Retention is provider policy ([`dsh-activity-local`](../../packages/activity/activity-local/README.md): `retainBytes` live, trimmed to `settledRetainBytes` at settlement). + +Two effect-scoped listener families deliver owner-relative on the jobs-registry terms: `onActivitiesChanged` fires per visible-set change (open, detail, settlement, removal) so consumers re-read whole rows, and `onOutput` signals stream advancement (append or settlement) carrying only the id, so each consumer reads from its own cursor. + +## Wire transport + +[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.md) exposes the plane to browsers as two Remote streams: `activity.control` (baseline plus whole-bucket roster replacement frames) and `activity.observe` (one `opened` anchor, coalesced `output` frames, then the terminal `status` on the same stream, after which the generation closes). Output frames are transient — durable history stays with the producers' tool results, exactly as the [web job display decision](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md) requires. [`dsh-client-ui-activity`](../../packages/client/ui-activity/README.md) renders the roster in the session header and opens observation streams only while a panel is expanded. + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.activities` — `ActivityRegistry` (abstract seam) + +Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. +- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource. +- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap. +- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained. + +```ts cordis-catalog +/** + * Validate the spec, attach owner cleanup, and atomically register one + * activity. A rejection leaves no id; after return the producer owns the + * handle and the visible set has changed. + * @param spec - activity identity, owner, and correlation. + * @returns the producer face of the registered activity. + */ +abstract open(spec: ActivityOpen): ActivityHandle + +/** + * List caller-owned and unowned activities in registration order without + * exposing another session's labels or output. + * @param caller - reading agent; a non-agent caller sees only unowned activities. + * @returns fresh snapshots. + */ +abstract list(caller?: Agent): ActivitySnapshot[] + +/** + * Return a fresh snapshot. Throws for an unknown or foreign activity. + * @param id - activity to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ +abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot + +/** + * Read retained output from an absolute byte offset without consuming it. + * Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Throws for an unknown or foreign activity or a negative or non-integer + * offset. + * @param id - activity to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead + +/** + * Register an effect-scoped observer of visible-set changes: opening, + * detail updates, settlement, owner-disposal removal, and the emptying that + * service disposal commits — so an observer re-reads rather than + * accumulating deltas. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned activity changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void + +/** + * Register an effect-scoped observer of stream advancement — one signal per + * committed append and one at settlement, carrying only the activity id. + * A consumer schedules a {@link read} from its own cursor; the registry + * never pushes payloads. Listeners are contained and never awaited. + * @param listener - receives the id whose stream advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: ActivityOutputListener): () => void +``` + +Types: [Agent](core.md) + +Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts) + + + +### `ctx.activityController` — `ActivityController` + +Host service backing the generated `ctx.remote.activity` namespace. + +```ts cordis-catalog +/** + * Stream a complete roster baseline followed by whole-bucket replacements. + * @param signal - generation cancellation. + * @returns baseline followed by per-owner roster replacement frames. + */ +@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one activity's retained output from an absolute byte offset, then + * its terminal status once settled and drained. Non-consuming: the + * model-facing cursors never observe these reads. + * @param request - target activity and optional resume offset. + * @param signal - generation cancellation. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts) + diff --git a/docs/subsystems/activity.zh.md b/docs/subsystems/activity.zh.md new file mode 100644 index 0000000000..1fc79e9d59 --- /dev/null +++ b/docs/subsystems/activity.zh.md @@ -0,0 +1,141 @@ +# 实时活动观察 + +[English](activity.md) | 中文 + +流式输出生产者、`ctx.activities` 与观察消费者共享的类型。设计归[观察 seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)所有;本页记录 [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) 的跨包词汇与 [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts) 的 wire 帧。 + +**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。注册表是纯观察面——不启动、不取消任何工作,对模型不可见。生产者保留自己的执行资源与模型面(`ctx.jobs` 消费游标、工具结果);观察者按绝对字节 offset 非消费读取,任意数量的观察者可与模型的单一消费游标共存。 + +## Id、kind 与关联 + +`ActivityId` 是[品牌化 id](core.zh.md#branded-ids),生成为 `-N`;可见性靠 owner 授权而非 id 保密。`ActivityKindMap` 可合并扩展(内置 `bash`;`pwsh` 与 `workflow` 由各自生产者合并)。`ActivityCorrelation` 把一个 activity 关联到发起工作的 `CallId` 与承载其模型控制面的 `JobId`,消费者据此把流挂到已有卡片或行上,不必靠 label 猜。 + +## 生产者面 + +`open(spec)` 返回 `ActivityHandle`:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)` 与 first-wins 的 `end(outcome)`。offset 按每个 chunk 的 UTF-8 字节长度推进;`gapBefore` 标记生产者侧丢失,让断点保持可见。`end` 之后——包括 teardown 强制 end——`append` 与 `updateDetail` 记日志后丢弃而不抛错,生产者的收尾 flush 不会砸掉自己的 teardown。 + +`pumpActivityOutput(handle, sources, { pollMs, done })` 适配 pull 型底座:把每个非消费 offset 读取器(subprocess 的 `readFrom(fromByte)` 家族,经 `ShellProcess.observed` 复出)按有界节奏复制进句柄,并在 `done` 结算后再排干一次。push 天然的生产者(workflow 工具的运行镜像)直接 append。 + +## 观察 + +`read(id, from, caller?)` 返回与 `[from, total)` 重叠的保留 chunk 及续读 offset `next`,绝不消费。offset 跨淘汰稳定:快照上的 `outputEarliest` 指明最老保留字节,低于它的读取得到 `lossy: true` 而非错误。保留量是 provider 策略([`dsh-activity-local`](../../packages/activity/activity-local/README.zh.md):存活 `retainBytes`,结算时裁到 `settledRetainBytes`)。 + +两个 effect 范围的监听族按 jobs 注册表条款做 owner 相对投递:`onActivitiesChanged` 在每次可见集变化(开启、detail、结算、移除)时触发,消费者整读行;`onOutput` 发出流推进信号(append 或结算),只带 id,各消费者按自己的游标读取。 + +## Wire 传输 + +[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.zh.md) 以两条 Remote 流把观察面暴露给浏览器:`activity.control`(baseline + 整桶 roster 替换帧)与 `activity.observe`(一帧 `opened` 锚点、合并的 `output` 帧、同流的终态 `status`,之后本代关闭)。输出帧是瞬态的——持久历史仍在生产者的工具结果里,与 [web job display 决策](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md)的要求一致。[`dsh-client-ui-activity`](../../packages/client/ui-activity/README.zh.md) 在会话头部渲染 roster,只在面板展开时打开观察流。 + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.activities` — `ActivityRegistry` (abstract seam) + +Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). + +Implementations must honor these semantics: + +- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. +- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource. +- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap. +- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained. + +```ts cordis-catalog +/** + * Validate the spec, attach owner cleanup, and atomically register one + * activity. A rejection leaves no id; after return the producer owns the + * handle and the visible set has changed. + * @param spec - activity identity, owner, and correlation. + * @returns the producer face of the registered activity. + */ +abstract open(spec: ActivityOpen): ActivityHandle + +/** + * List caller-owned and unowned activities in registration order without + * exposing another session's labels or output. + * @param caller - reading agent; a non-agent caller sees only unowned activities. + * @returns fresh snapshots. + */ +abstract list(caller?: Agent): ActivitySnapshot[] + +/** + * Return a fresh snapshot. Throws for an unknown or foreign activity. + * @param id - activity to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ +abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot + +/** + * Read retained output from an absolute byte offset without consuming it. + * Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Throws for an unknown or foreign activity or a negative or non-integer + * offset. + * @param id - activity to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead + +/** + * Register an effect-scoped observer of visible-set changes: opening, + * detail updates, settlement, owner-disposal removal, and the emptying that + * service disposal commits — so an observer re-reads rather than + * accumulating deltas. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned activity changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void + +/** + * Register an effect-scoped observer of stream advancement — one signal per + * committed append and one at settlement, carrying only the activity id. + * A consumer schedules a {@link read} from its own cursor; the registry + * never pushes payloads. Listeners are contained and never awaited. + * @param listener - receives the id whose stream advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: ActivityOutputListener): () => void +``` + +Types: [Agent](core.zh.md) + +Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts) + + + +### `ctx.activityController` — `ActivityController` + +Host service backing the generated `ctx.remote.activity` namespace. + +```ts cordis-catalog +/** + * Stream a complete roster baseline followed by whole-bucket replacements. + * @param signal - generation cancellation. + * @returns baseline followed by per-owner roster replacement frames. + */ +@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one activity's retained output from an absolute byte offset, then + * its terminal status once settled and drained. Non-consuming: the + * model-facing cursors never observe these reads. + * @param request - target activity and optional resume offset. + * @param signal - generation cancellation. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts) + diff --git a/docs/subsystems/shell.i18n.yaml b/docs/subsystems/shell.i18n.yaml index 1515b4ed7b..4a80f2cea2 100644 --- a/docs/subsystems/shell.i18n.yaml +++ b/docs/subsystems/shell.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/shell.md -shell.md: 554adcfb1b37a5e2fe787a78dbb61a1206f80cf9 -shell.zh.md: f15b57cc0050bb8f38d2652ddb9ad0e250568663 +shell.md: 8635290b98a19403cc1839b8262831cb99995fd5 +shell.zh.md: ad8fa3254444e43501bbba09964bcfbd95d56bf9 diff --git a/docs/subsystems/shell.md b/docs/subsystems/shell.md index 554adcfb1b..8635290b98 100644 --- a/docs/subsystems/shell.md +++ b/docs/subsystems/shell.md @@ -192,6 +192,13 @@ interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. Absent when the + * backend cannot expose offset reads; observers then see no stream. + */ + observed?: ShellObservedStreams /** * Kill the process group. Returns false when it had already finished * (no-op); idempotent. diff --git a/docs/subsystems/shell.zh.md b/docs/subsystems/shell.zh.md index f15b57cc00..ad8fa32544 100644 --- a/docs/subsystems/shell.zh.md +++ b/docs/subsystems/shell.zh.md @@ -192,6 +192,13 @@ interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. Absent when the + * backend cannot expose offset reads; observers then see no stream. + */ + observed?: ShellObservedStreams /** * Kill the process group. Returns false when it had already finished * (no-op); idempotent. diff --git a/knip.json b/knip.json index e8dc6139ee..b6ab568c31 100644 --- a/knip.json +++ b/knip.json @@ -117,6 +117,11 @@ "tests/**/*.ts" ] }, + "packages/api/activity-controller": { + "ignoreDependencies": [ + "zod" + ] + }, "packages/api/workspace-controller": { "ignoreDependencies": [ "zod" diff --git a/packages/activity/README.i18n.yaml b/packages/activity/README.i18n.yaml new file mode 100644 index 0000000000..4044dfcfec --- /dev/null +++ b/packages/activity/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/activity/README.md +README.md: 1988ca73b4a71095d42f85086d4958eb91bad928 +README.zh.md: b75c7bfcfda6aa829d59d1d46439144bdfaa80e6 diff --git a/packages/activity/README.md b/packages/activity/README.md new file mode 100644 index 0000000000..1988ca73b4 --- /dev/null +++ b/packages/activity/README.md @@ -0,0 +1,14 @@ +# activity/ — streaming-output observation family + +English | [中文](README.zh.md) + +This family gives every long-running producer one non-consuming observation plane: an activity is an append-only bounded output stream plus live status, readable by any number of independent observers at absolute byte offsets while the model-facing paths (`ctx.jobs` cursors, tool results) stay untouched. + +| Package | Role | ctx key | +|---|---|---| +| [`activity/`](activity/README.md) | Defines the observation registry and vocabulary | `ctx.activities` | +| [`activity-local/`](activity-local/README.md) | Implements the in-memory ring-buffer registry | registers on `ctx.activities` | + +The Web transport and client consumers live in [`dsh-api-activity-controller`](../api/activity-controller/README.md) and [`dsh-client-ui-activity`](../client/ui-activity/README.md); background bash/pwsh and the workflow tool are the shipped producers. + +The subsystem reference — offsets, retention, listener delivery — is [docs/subsystems/activity.md](../../docs/subsystems/activity.md); design in the [activity observation seam](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) Agent Note. diff --git a/packages/activity/README.zh.md b/packages/activity/README.zh.md new file mode 100644 index 0000000000..b75c7bfcfd --- /dev/null +++ b/packages/activity/README.zh.md @@ -0,0 +1,14 @@ +# activity/ — 流式输出观察能力族 + +[English](README.md) | 中文 + +本能力族为所有长时生产者提供统一的非消费观察面:一个 activity 是一条 append-only 的有界输出流加实时状态,任意数量的独立观察者按绝对字节 offset 读取,而模型侧路径(`ctx.jobs` 游标、工具结果)完全不受影响。 + +| 包 | 角色 | ctx key | +|---|---|---| +| [`activity/`](activity/README.zh.md) | 定义观察注册表与词汇 | `ctx.activities` | +| [`activity-local/`](activity-local/README.zh.md) | 实现内存环形缓冲注册表 | 注册到 `ctx.activities` | + +Web 传输与客户端消费者在 [`dsh-api-activity-controller`](../api/activity-controller/README.zh.md) 与 [`dsh-client-ui-activity`](../client/ui-activity/README.zh.md);后台 bash/pwsh 与 workflow 工具是已接入的生产者。 + +子系统参考——offset、保留策略、监听投递——见 [docs/subsystems/activity.zh.md](../../docs/subsystems/activity.zh.md);设计见 [activity 观察 seam](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md) Agent Note。 diff --git a/packages/activity/activity-local/README.i18n.yaml b/packages/activity/activity-local/README.i18n.yaml new file mode 100644 index 0000000000..adbd77bb80 --- /dev/null +++ b/packages/activity/activity-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/activity/activity-local/README.md +README.md: 511511a6a5164e0ae4be65230e83a2c1ef37797a +README.zh.md: e4bee07b74abf6c75b68245147f3f5b6fd9f3f6c diff --git a/packages/activity/activity-local/README.md b/packages/activity/activity-local/README.md new file mode 100644 index 0000000000..511511a6a5 --- /dev/null +++ b/packages/activity/activity-local/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-activity-local + +English | [中文](README.zh.md) + +The in-memory Service Provider for the [`dsh-activity`](../activity/README.md) observation seam: `LocalActivityRegistry` keeps every record and its bounded output ring in process memory and hands out fresh snapshots and chunk copies, never live state. + +## Retention + +Each activity retains a chunk ring whose offsets stay absolute across eviction: dropping the head advances `outputEarliest`, and a single chunk larger than the live cap keeps only its UTF-8-safe tail with a `gapBefore` marker. Settlement trims the ring to the settled cap so a finished activity still shows its tail without holding the live budget. + +| Config | Default | Meaning | +|---|---|---| +| `retainBytes` | `262144` | Live output retention per activity, in UTF-8 bytes. | +| `settledRetainBytes` | `16384` | Retention kept after an activity settles. | + +## Lifecycle + +Records outlive producer fibers. The first activity for an owner attaches one cleanup to the exact `Agent` scope; owner disposal ends still-open records (`killed`, `owner disposed`) and removes them, announcing the removal. Service disposal ends everything, clears the store, and announces each emptied owner. Neither awaits a producer — the registry owns no execution resource. Listeners are layered by registration scope exactly like the jobs registry, so delivery stays owner-relative in one process-wide instance. + +## Model Experience + +None, as the provider stores and serves observation state only; the producers own every model-visible surface. + +#### KV Cache effect + +None; the package never assembles or alters provider requests. + +## Known Limitations and Deferred Work + +- **The unowned bucket has no admission bound** — owned producers are already bounded by the jobs admission policy, but a plugin opening many unowned activities grows the store until service disposal. diff --git a/packages/activity/activity-local/README.zh.md b/packages/activity/activity-local/README.zh.md new file mode 100644 index 0000000000..e4bee07b74 --- /dev/null +++ b/packages/activity/activity-local/README.zh.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-activity-local + +[English](README.md) | 中文 + +[`dsh-activity`](../activity/README.zh.md) 观察 seam 的内存 Service Provider:`LocalActivityRegistry` 把每条记录及其有界输出环保存在进程内存中,对外只给全新快照与 chunk 副本,绝不暴露活状态。 + +## 保留策略 + +每个 activity 保留一个 offset 跨淘汰稳定的 chunk 环:丢弃头部使 `outputEarliest` 前移;单个超过存活上限的 chunk 只保留其 UTF-8 安全的尾部并打上 `gapBefore` 标记。结算时把环裁到 settled 上限,已完成的 activity 仍能展示尾巴而不占存活预算。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `retainBytes` | `262144` | 每个 activity 的存活输出保留量(UTF-8 字节)。 | +| `settledRetainBytes` | `16384` | activity 结算后的保留量。 | + +## 生命周期 + +记录的存续期长于生产者 fiber。owner 的第一个 activity 会在 exact `Agent` scope 上挂一个清理;owner 销毁时把未结束的记录终结(`killed`、`owner disposed`)并移除,同时公告移除。服务销毁终结一切、清空存储并逐个公告被清空的 owner。两者都不等待生产者——注册表不拥有任何执行资源。监听按注册 scope 分层,与 jobs 注册表完全一致,一个进程级实例内投递保持 owner 相对。 + +## 模型体验 + +无:provider 只存储与提供观察状态;所有模型可见面都归生产者。 + +#### KV Cache 影响 + +无;本包从不组装或改动 provider 请求。 + +## 已知限制与暂缓事项 + +- **无主桶没有准入上限**——有主生产者已被 jobs 准入策略约束,但插件开出的大量无主 activity 会让存储一直增长到服务销毁。 diff --git a/packages/activity/activity-local/package.json b/packages/activity/activity-local/package.json new file mode 100644 index 0000000000..b06864d251 --- /dev/null +++ b/packages/activity/activity-local/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-activity-local", + "description": "Process-local implementation of the DeepSeek Harness streaming-output observation seam", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/activity/activity-local" + }, + "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" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/activity/activity-local/src/index.ts b/packages/activity/activity-local/src/index.ts new file mode 100644 index 0000000000..caabb5499e --- /dev/null +++ b/packages/activity/activity-local/src/index.ts @@ -0,0 +1,438 @@ +/** + * Process-local provider for the streaming-output observation seam + * (`ctx.activities`). It keeps every record and its bounded output ring in + * memory and hands out fresh snapshots and chunk copies, never live state. + * + * Records outlive producer fibers. Owner disposal ends still-open records and + * removes them; service disposal ends and clears everything. Neither awaits a + * producer — the registry owns no execution resource. + * @module @deepseek-ai/dsh-activity-local + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' +import type { ScopeLayer } from '@deepseek-ai/dsh-scope' +import { ActivityRegistry, ActivityId } from '@deepseek-ai/dsh-activity' +import type { + ActivityAppendOptions, ActivityChannel, ActivityCorrelation, ActivitiesChangedListener, + ActivityHandle, ActivityKind, ActivityOpen, ActivityOutcome, ActivityOutputChunk, + ActivityOutputListener, ActivityRead, ActivitySnapshot, ActivityStatus, +} from '@deepseek-ai/dsh-activity' + +/** Default live retention per activity, in UTF-8 bytes. */ +const DEFAULT_RETAIN_BYTES = 256 * 1024 + +/** Default retention kept after settlement, in UTF-8 bytes. */ +const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024 + +/** Configuration for the process-local observation registry. */ +export interface Config { + /** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number +} + +/** The registry's mutable per-activity record (never handed out — see {@link LocalActivityRegistry.snapshot}). */ +interface TrackedActivity { + id: ActivityId + kind: ActivityKind + label: string + /** Exact lifecycle owner; session-id visibility is derived from it. */ + owner: Agent | undefined + correlation: ActivityCorrelation | undefined + status: ActivityStatus + detail: string | undefined + startedAt: number + finishedAt: number | undefined + /** Retained chunks in offset order; offsets stay absolute across eviction. */ + chunks: RingChunk[] + /** Sum of the retained chunks' byte lengths. */ + retainedBytes: number + /** Total UTF-8 bytes ever appended. */ + total: number + /** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */ + earliest: number +} + +/** One retained ring entry; `bytes` caches the chunk's UTF-8 length. */ +interface RingChunk { + at: number + text: string + bytes: number + channel?: ActivityChannel + gapBefore?: true +} + +/** True for the three terminal {@link ActivityStatus} values. */ +function isTerminal(status: ActivityStatus): boolean { + return status === 'completed' || status === 'failed' || status === 'killed' +} + +/** + * The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut + * advances past continuation bytes so the surviving text never starts inside + * a code point. + * @param text - the oversized chunk text. + * @param maxBytes - positive byte budget for the surviving tail. + * @returns the surviving tail and its exact byte length. + */ +function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } { + const raw = Buffer.from(text, 'utf8') + let start = raw.length - maxBytes + // The loop bound proves the index is in range; the assertion only + // discharges noUncheckedIndexedAccess. + while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1 + const tail = raw.subarray(start) + return { text: tail.toString('utf8'), bytes: tail.length } +} + +/** + * One scope's observer contributions. Both tables are anonymous because a + * contribution is identified by its own disposer, never by a name a second + * registrant could shadow. + */ +class ActivityLayer implements ScopeLayer { + readonly changed = new AnonymousEntries() + readonly output = new AnonymousEntries() + + isEmpty(): boolean { + return this.changed.isEmpty() && this.output.isEmpty() + } +} + +/** + * The in-memory `activities` registry. See the Service Definition contract in + * `@deepseek-ai/dsh-activity` for the observation, retention, and lifecycle + * semantics this implementation honors. + */ +export class LocalActivityRegistry extends ActivityRegistry { + static Config: z = z.object({ + retainBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_RETAIN_BYTES), + settledRetainBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_SETTLED_RETAIN_BYTES), + }) + + /** Schemastery-defaulted live retention cap. */ + private readonly retainBytes: number + /** Schemastery-defaulted settled retention cap. */ + private readonly settledRetainBytes: number + private store = new Map() + private counters = new Map() + /** + * Observers layered by the scope that registered them, in the jobs-registry + * shape: a contribution files into its registering context's scope, and a + * dispatch unions the global layer with the subject owner's scope chain, so + * delivery stays owner-relative in one process-wide registry. Nothing + * derives a cache from a layer, so change notification is a no-op. + */ + private readonly layers = new ScopedLayers(() => new ActivityLayer(), () => {}) + /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ + private ownerCleanups = new Map Promise | void>() + /** Service context used by contained listener reporting and teardown. */ + private readonly selfCtx: Context + + constructor(ctx: Context, config: Config) { + super(ctx) + // Schemastery validates and fills the defaults before constructing the service. + this.retainBytes = (config as Required).retainBytes + this.settledRetainBytes = (config as Required).settledRetainBytes + this.selfCtx = ctx + ctx.effect(() => () => { this.disposeAll() }, 'activities teardown') + } + + open(spec: ActivityOpen): ActivityHandle { + if (spec.kind.length === 0) throw new Error('invalid activity kind: expected a non-empty string') + if (spec.label.length === 0) throw new Error('invalid activity label: expected a non-empty string') + if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner) + + const count = (this.counters.get(spec.kind) ?? 0) + 1 + this.counters.set(spec.kind, count) + const id = ActivityId(`${spec.kind}-${count}`) + const record: TrackedActivity = { + id, + kind: spec.kind, + label: spec.label, + owner: spec.owner, + correlation: spec.correlation === undefined ? undefined : { ...spec.correlation }, + status: 'running', + detail: undefined, + startedAt: Date.now(), + finishedAt: undefined, + chunks: [], + retainedBytes: 0, + total: 0, + earliest: 0, + } + this.store.set(id, record) + // Registration is complete and cannot fail from here, so the visible set + // has genuinely changed. + this.notifyChanged(record.owner) + return { + id, + append: (text, options) => { this.append(record, text, options) }, + updateDetail: (detail) => { this.updateDetail(record, detail) }, + end: (outcome) => { this.settle(record, outcome) }, + } + } + + list(caller?: Agent): ActivitySnapshot[] { + const session = caller?.id + return [...this.store.values()] + .filter(record => record.owner === undefined || record.owner.id === session) + .map(record => this.snapshot(record)) + } + + get(id: ActivityId, caller?: Agent): ActivitySnapshot { + const record = this.expect(id) + this.assertAccess(record, caller) + return this.snapshot(record) + } + + read(id: ActivityId, from: number, caller?: Agent): ActivityRead { + const record = this.expect(id) + this.assertAccess(record, caller) + if (!Number.isSafeInteger(from) || from < 0) { + throw new Error(`invalid read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`) + } + const chunks: ActivityOutputChunk[] = [] + for (const chunk of record.chunks) { + if (chunk.at + chunk.bytes <= from) continue + chunks.push({ + at: chunk.at, + text: chunk.text, + ...chunk.channel !== undefined ? { channel: chunk.channel } : {}, + ...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {}, + }) + } + return { chunks, next: record.total, lossy: from < record.earliest } + } + + onActivitiesChanged(listener: ActivitiesChangedListener): () => void { + return this.layers.effect( + this.ctx, + layer => layer.changed.append(listener), + { label: 'activities.onActivitiesChanged()' }, + ) + } + + onOutput(listener: ActivityOutputListener): () => void { + return this.layers.effect( + this.ctx, + layer => layer.output.append(listener), + { label: 'activities.onOutput()' }, + ) + } + + /** Append one chunk to a live record; log and drop on an ended one. */ + private append(record: TrackedActivity, text: string, options?: ActivityAppendOptions): void { + if (isTerminal(record.status)) { + this.selfCtx.logger.warn(`activities: append to ended activity ${record.id} dropped`) + return + } + if (text.length === 0) return + const bytes = Buffer.byteLength(text, 'utf8') + record.chunks.push({ + at: record.total, + text, + bytes, + ...options?.channel !== undefined ? { channel: options.channel } : {}, + ...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {}, + }) + record.total += bytes + record.retainedBytes += bytes + this.trim(record, this.retainBytes) + this.notifyOutput(record) + } + + /** Replace a live record's detail line; log and drop on an ended one. */ + private updateDetail(record: TrackedActivity, detail: string): void { + if (isTerminal(record.status)) { + this.selfCtx.logger.warn(`activities: detail update on ended activity ${record.id} dropped`) + return + } + record.detail = detail + this.notifyChanged(record.owner) + } + + /** + * Record the first terminal outcome, trim retention to the settled cap, and + * announce the change and the stream end. First-wins: a later call — the + * producer's own report after a teardown force-end, or a duplicate — is + * dropped silently. + */ + private settle(record: TrackedActivity, outcome: ActivityOutcome): void { + if (isTerminal(record.status)) return + record.status = outcome.status + if (outcome.detail !== undefined) record.detail = outcome.detail + record.finishedAt = Date.now() + this.trim(record, this.settledRetainBytes) + this.notifyChanged(record.owner) + this.notifyOutput(record) + } + + /** + * Drop retained head chunks until the ring fits `cap`; a single oversized + * chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets + * stay absolute: `earliest` advances over everything dropped. + */ + private trim(record: TrackedActivity, cap: number): void { + while (record.retainedBytes > cap && record.chunks.length > 1) { + const dropped = record.chunks.shift() + /* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */ + if (dropped === undefined) break + record.retainedBytes -= dropped.bytes + } + const single = record.chunks.length === 1 ? record.chunks[0] : undefined + if (single !== undefined && single.bytes > cap) { + const tail = utf8Tail(single.text, cap) + single.at += single.bytes - tail.bytes + single.text = tail.text + single.bytes = tail.bytes + single.gapBefore = true + record.retainedBytes = tail.bytes + } + record.earliest = record.chunks[0]?.at ?? record.total + } + + /** Look up an activity or fail loud. */ + private expect(id: ActivityId): TrackedActivity { + const record = this.store.get(id) + if (record === undefined) throw new Error(`unknown activity ${id}`) + return record + } + + /** + * The visibility fence: an activity with an owner is reachable only by + * callers whose session id matches (`!== undefined` semantics — an unowned + * activity is open, and a no-agent caller can never match an owned one). + */ + private assertAccess(record: TrackedActivity, caller?: Agent): void { + if (record.owner !== undefined && record.owner.id !== caller?.id) { + throw new Error(`activity ${record.id} belongs to another session`) + } + } + + /** Project a fresh read-only snapshot from the mutable record. */ + private snapshot(record: TrackedActivity): ActivitySnapshot { + const ownerSession = record.owner?.id + return { + id: record.id, + kind: record.kind, + label: record.label, + ...ownerSession !== undefined ? { ownerSession } : {}, + ...record.correlation !== undefined ? { correlation: { ...record.correlation } } : {}, + status: record.status, + ...record.detail !== undefined ? { detail: record.detail } : {}, + startedAt: record.startedAt, + ...record.finishedAt !== undefined ? { finishedAt: record.finishedAt } : {}, + outputTotal: record.total, + outputEarliest: record.earliest, + } + } + + /** + * The change observers that own `owner`'s updates: the global layer — a + * host composition's own carrier, which serves every owner — then each + * scoped layer along the owner's chain. + */ + private *changedFor(owner?: Agent): IterableIterator { + yield* this.layers.global.changed.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values() + } + + /** The output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */ + private *outputFor(owner?: Agent): IterableIterator { + yield* this.layers.global.output.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values() + } + + /** + * Announce that one owner's visible set changed. Each listener is contained + * so an observer cannot break a lifecycle commit that already happened. + */ + private notifyChanged(owner: Agent | undefined): void { + for (const listener of this.changedFor(owner)) { + try { + listener(owner) + } catch (error: unknown) { + this.selfCtx.logger.warn(`activities: onActivitiesChanged listener threw: ${String(error)}`) + } + } + } + + /** Announce that one activity's stream advanced (append or settlement), with per-listener containment. */ + private notifyOutput(record: TrackedActivity): void { + for (const listener of this.outputFor(record.owner)) { + try { + listener(record.id) + } catch (error: unknown) { + this.selfCtx.logger.warn(`activities: onOutput listener threw: ${String(error)}`) + } + } + } + + /** + * Attach one cleanup through the exact owner's scope. This survives + * producer reloads; the retained disposer lets service teardown detach the + * cross-fiber effect. Fails when the agent registry is absent or the owner + * is not its currently registered instance. + */ + private ensureOwnerCleanup(owner: Agent): void { + const ownerId = owner.id + const agents = this.selfCtx.get('agents') + if (agents === undefined) { + throw new Error('activity ownership requires the agent registry (load @deepseek-ai/dsh-agent)') + } + if (agents.get(ownerId) !== owner) { + throw new Error(`agent "${ownerId}" is not the registered agent instance (activity owner must be live)`) + } + if (this.ownerCleanups.has(owner)) return + // Record only after attach succeeds; a disposing scope rejects new effects. + const detach = owner.ctx.effect(() => () => { + this.ownerCleanups.delete(owner) + this.disposeOwned(owner) + }, 'activities.ownerCleanup()') + this.ownerCleanups.set(owner, detach) + } + + /** End still-open records and drop every activity owned by one exact agent lifecycle. */ + private disposeOwned(owner: Agent): void { + const owned = [...this.store.values()].filter(record => record.owner === owner) + for (const record of owned) { + if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'owner disposed' }) + this.store.delete(record.id) + } + // Removal is the one visible-set change no per-activity record carries, so + // it must be announced here or an observer keeps the dropped rows forever. + if (owned.length > 0) this.notifyChanged(owner) + } + + /** End every open record, clear the store, and announce each emptied owner. */ + private disposeAll(): void { + const all = [...this.store.values()] + for (const record of all) { + if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'activity registry disposed' }) + } + const emptied = new Set(all.map(record => record.owner)) + this.store.clear() + for (const owner of emptied) this.notifyChanged(owner) + // Detach cross-fiber owner effects after the shared store is quiescent. + const ownerCleanups = [...this.ownerCleanups.values()] + this.ownerCleanups.clear() + for (const cleanup of ownerCleanups) void cleanup() + } +} + +export default LocalActivityRegistry diff --git a/packages/activity/activity-local/src/invariant.ts b/packages/activity/activity-local/src/invariant.ts new file mode 100644 index 0000000000..7e15b7db09 --- /dev/null +++ b/packages/activity/activity-local/src/invariant.ts @@ -0,0 +1,33 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-activity-local`. + * @module @deepseek-ai/dsh-activity-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-activity-local' + +/** Cordis companion plugin name. */ +export const name = 'activity-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns per-snapshot identity, status, + * timestamp, and offset checks on every visible-set change. This provider's remaining behavior — + * ring retention against private byte caps and pull-only chunk reads — has no authoritative event + * stream to observe: a read happens on a caller's schedule, and repeating the retention arithmetic + * against private configuration would duplicate the implementation rather than check a relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/activity/activity-local/tests/activity.spec.ts b/packages/activity/activity-local/tests/activity.spec.ts new file mode 100644 index 0000000000..557046d617 --- /dev/null +++ b/packages/activity/activity-local/tests/activity.spec.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createScope } from '@deepseek-ai/dsh-scope' +import { ActivityId } from '@deepseek-ai/dsh-activity' +import type { ActivityHandle, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import LocalActivityRegistry, { type Config as ProcessConfig } from '@deepseek-ai/dsh-activity-local' + +declare module '@deepseek-ai/dsh-activity' { + interface ActivityKindMap { + workflow: 'workflow' + } +} + +const agentScopeDisposers = new WeakMap Promise>() + +/** + * A minimal live agent whose ctx carries its own scope on a disposable plugin + * fiber, mirroring the harness convention that a live agent keys its scope. + */ +function stubAgent(ctx: Context, rawId: string): Agent { + const id = SessionId(rawId) + const scopeFiber = ctx.plugin(() => {}) + const agentCtx = createScope(scopeFiber.ctx, {}).ctx + const session = Session.create(id) + const agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle' as const, + ctx: agentCtx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: (job: (signal: AbortSignal) => Promise) => job(new AbortController().signal), + whenIdle() { return Promise.resolve() }, + } + agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() }) + return agent +} + +async function disposeAgentScope(agent: Agent): Promise { + const dispose = agentScopeDisposers.get(agent) + if (dispose === undefined) throw new Error(`missing test scope for agent "${agent.id}"`) + await dispose() +} + +async function harness(config: ProcessConfig = {}) { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry, config) + return ctx +} + +/** Concatenated retained text of one process, as an observer from `from` sees it. */ +function textFrom(ctx: Context, id: ActivityId, from = 0): string { + return ctx.activities.read(id, from).chunks.map(chunk => chunk.text).join('') +} + +describe('LocalActivityRegistry.open', () => { + it('mints per-kind ordinal ids and announces the new visible row', async () => { + const ctx = await harness() + const changes: (Agent | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) }) + const first = ctx.activities.open({ kind: 'bash', label: 'sleep 60' }) + const second = ctx.activities.open({ kind: 'workflow', label: 'run docs' }) + const third = ctx.activities.open({ kind: 'bash', label: 'build' }) + expect([first.id, second.id, third.id]).toEqual(['bash-1', 'workflow-1', 'bash-2']) + expect(changes).toEqual([undefined, undefined, undefined]) + expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual(['bash-1', 'workflow-1', 'bash-2']) + }) + + it('rejects an empty kind or label without minting an id', async () => { + const ctx = await harness() + expect(() => ctx.activities.open({ kind: '' as never, label: 'x' })).toThrow(/invalid activity kind/) + expect(() => ctx.activities.open({ kind: 'bash', label: '' })).toThrow(/invalid activity label/) + expect(ctx.activities.list()).toEqual([]) + }) + + it('rejects an owned open when no agent registry is loaded', async () => { + const bare = new Context() + await bare.plugin(LocalActivityRegistry) + const ghost = { id: SessionId('ghost'), ctx: bare } as unknown as Agent + expect(() => bare.activities.open({ kind: 'bash', label: 'x', owner: ghost })) + .toThrow(/activity ownership requires the agent registry/) + }) + + it('rejects an owner that is not the registered agent instance', async () => { + const ctx = await harness() + const ghost = stubAgent(ctx, 'ghost') + expect(() => ctx.activities.open({ kind: 'bash', label: 'x', owner: ghost })) + .toThrow(/not the registered agent instance/) + }) + + it('carries owner session and correlation into snapshots', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const handle = ctx.activities.open({ + kind: 'bash', + label: 'npm test', + owner, + correlation: { jobId: 'bash-9' as never, callId: 'call-3' as never }, + }) + const snapshot = ctx.activities.get(handle.id, owner) + expect(snapshot.ownerSession).toBe(owner.id) + expect(snapshot.correlation).toEqual({ jobId: 'bash-9', callId: 'call-3' }) + expect(snapshot.status).toBe('running') + }) +}) + +describe('LocalActivityRegistry output', () => { + it('appends advance absolute offsets and reads are non-consuming', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('hello ') + handle.append('world', { channel: 'stderr' }) + const first = ctx.activities.read(handle.id, 0) + expect(first.lossy).toBe(false) + expect(first.next).toBe(11) + expect(first.chunks).toEqual([ + { at: 0, text: 'hello ' }, + { at: 6, text: 'world', channel: 'stderr' }, + ]) + // A second identical read proves nothing was consumed. + expect(ctx.activities.read(handle.id, 0)).toEqual(first) + // Resuming from `next` yields nothing until more output arrives. + expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([]) + handle.append('!') + expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([{ at: 11, text: '!' }]) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.outputTotal).toBe(12) + expect(snapshot.outputEarliest).toBe(0) + }) + + it('drops an empty append without waking observers', async () => { + const ctx = await harness() + const signals: ActivityId[] = [] + ctx.activities.onOutput((id) => { signals.push(id) }) + const handle = ctx.activities.open({ kind: 'bash', label: 'quiet' }) + handle.append('') + expect(signals).toEqual([]) + expect(ctx.activities.get(handle.id).outputTotal).toBe(0) + }) + + it('rejects a negative or fractional read offset', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + expect(() => ctx.activities.read(handle.id, -1)).toThrow(/invalid read offset/) + expect(() => ctx.activities.read(handle.id, 0.5)).toThrow(/invalid read offset/) + }) + + it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => { + const ctx = await harness({ retainBytes: 8 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'spam' }) + handle.append('aaaa') + handle.append('bbbb') + handle.append('cccc') + const read = ctx.activities.read(handle.id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }]) + expect(read.next).toBe(12) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.outputEarliest).toBe(4) + expect(snapshot.outputTotal).toBe(12) + // A reader at the retained boundary is not lossy. + expect(ctx.activities.read(handle.id, 4).lossy).toBe(false) + }) + + it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => { + const ctx = await harness({ retainBytes: 5 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'wide' }) + // '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point. + handle.append('你好') + const read = ctx.activities.read(handle.id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }]) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.outputEarliest).toBe(3) + expect(snapshot.outputTotal).toBe(6) + }) + + it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('abcdef') + const read = ctx.activities.read(handle.id, 3) + expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }]) + expect(read.lossy).toBe(false) + }) + + it('preserves a producer-reported gap marker through reads', async () => { + const ctx = await harness() + const handle = ctx.activities.open({ kind: 'bash', label: 'lossy source' }) + handle.append('tail after drop', { gapBefore: true, channel: 'stdout' }) + expect(ctx.activities.read(handle.id, 0).chunks).toEqual([ + { at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true }, + ]) + }) +}) + +describe('LocalActivityRegistry settlement', () => { + it('records the first terminal outcome and trims retention to the settled cap', async () => { + const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'build' }) + handle.append('abcdefgh') + handle.end({ status: 'completed', detail: 'exit code: 0' }) + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.status).toBe('completed') + expect(snapshot.detail).toBe('exit code: 0') + expect(snapshot.finishedAt).toBeDefined() + const read = ctx.activities.read(handle.id, 0) + expect(read.lossy).toBe(true) + expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }]) + }) + + it('is first-wins: a later end and post-end writes are dropped without throwing', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + const handle = ctx.activities.open({ kind: 'bash', label: 'build' }) + handle.end({ status: 'killed', detail: 'owner disposed' }) + handle.end({ status: 'completed' }) + handle.append('late') + handle.updateDetail('late detail') + const snapshot = ctx.activities.get(handle.id) + expect(snapshot.status).toBe('killed') + expect(snapshot.detail).toBe('owner disposed') + expect(snapshot.outputTotal).toBe(0) + expect(warn.mock.calls.map(call => String(call[0]))).toEqual([ + expect.stringContaining('append to ended activity'), + expect.stringContaining('detail update on ended activity'), + ]) + }) + + it('signals observers for appends and once more at settlement', async () => { + const ctx = await harness() + const signals: ActivityId[] = [] + ctx.activities.onOutput((id) => { signals.push(id) }) + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('a') + handle.append('b') + handle.end({ status: 'completed' }) + expect(signals).toEqual([handle.id, handle.id, handle.id]) + }) + + it('updateDetail changes the visible row without touching output offsets', async () => { + const ctx = await harness() + const changes: (Agent | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) }) + const handle = ctx.activities.open({ kind: 'workflow', label: 'docs run' }) + handle.updateDetail('3/10 agents done') + expect(ctx.activities.get(handle.id).detail).toBe('3/10 agents done') + expect(ctx.activities.get(handle.id).outputTotal).toBe(0) + expect(changes).toHaveLength(2) + }) +}) + +describe('LocalActivityRegistry visibility and delivery', () => { + it('fences owned processes by session id and keeps unowned ones open', async () => { + const ctx = await harness() + const alice = stubAgent(ctx, 'alice') + const bob = stubAgent(ctx, 'bob') + ctx.agents.register(alice) + ctx.agents.register(bob) + const owned = ctx.activities.open({ kind: 'bash', label: 'alice job', owner: alice }) + const open = ctx.activities.open({ kind: 'bash', label: 'shared' }) + owned.append('secret') + expect(ctx.activities.list(alice).map(snapshot => snapshot.id)).toEqual([owned.id, open.id]) + expect(ctx.activities.list(bob).map(snapshot => snapshot.id)).toEqual([open.id]) + expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual([open.id]) + expect(() => ctx.activities.get(owned.id, bob)).toThrow(/belongs to another session/) + expect(() => ctx.activities.read(owned.id, 0)).toThrow(/belongs to another session/) + expect(textFrom(ctx, open.id)).toBe('') + expect(ctx.activities.read(owned.id, 0, alice).chunks[0]?.text).toBe('secret') + }) + + it('delivers scoped listeners only their own agents and global listeners everything', async () => { + const ctx = await harness() + const alice = stubAgent(ctx, 'alice') + const bob = stubAgent(ctx, 'bob') + ctx.agents.register(alice) + ctx.agents.register(bob) + const globalOwners: (string | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { globalOwners.push(owner?.id) }) + const scopedOwners: (string | undefined)[] = [] + await alice.ctx.plugin({ + inject: ['activities'], + apply(scopedCtx: Context) { + scopedCtx.activities.onActivitiesChanged((owner) => { scopedOwners.push(owner?.id) }) + }, + }) + const scopedOutputs: string[] = [] + const scopedFiber = await alice.ctx.plugin({ + inject: ['activities'], + apply(scopedCtx: Context) { + scopedCtx.activities.onOutput((id) => { scopedOutputs.push(String(id)) }) + }, + }) + const aliceHandle = ctx.activities.open({ kind: 'bash', label: 'alice work', owner: alice }) + aliceHandle.append('scoped delivery probe') + expect(scopedOutputs).toEqual([String(aliceHandle.id)]) + ctx.activities.open({ kind: 'bash', label: 'bob work', owner: bob }) + ctx.activities.open({ kind: 'bash', label: 'unowned' }) + expect(globalOwners).toEqual(['alice', 'bob', undefined]) + expect(scopedOwners).toEqual(['alice']) + // Disposing one scoped registrant probes layer reclamation; the sibling + // scoped changed-listener keeps the layer alive and keeps delivering. + await scopedFiber.dispose() + ctx.activities.open({ kind: 'bash', label: 'alice later', owner: alice }) + expect(scopedOwners).toEqual(['alice', 'alice']) + + // A scope whose only contribution goes empties on disposal and is reclaimed. + const bobFiber = await bob.ctx.plugin({ + inject: ['activities'], + apply(scopedCtx: Context) { + scopedCtx.activities.onOutput(() => {}) + }, + }) + await bobFiber.dispose() + }) + + it('contains throwing listeners without breaking the commit', async () => { + const ctx = await harness() + const warn = vi.fn() + ctx.logger.warn = warn as never + ctx.activities.onActivitiesChanged(() => { throw new Error('changed boom') }) + ctx.activities.onOutput(() => { throw new Error('output boom') }) + const seen: (Agent | undefined)[] = [] + ctx.activities.onActivitiesChanged((owner) => { seen.push(owner) }) + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('x') + expect(seen).toHaveLength(1) + expect(ctx.activities.get(handle.id).outputTotal).toBe(1) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onActivitiesChanged listener threw')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw')) + }) + + it('unregisters listeners with their registering fiber', async () => { + const ctx = await harness() + const signals: ActivityId[] = [] + const fiber = ctx.plugin({ + inject: ['activities'], + apply(pluginCtx: Context) { + pluginCtx.activities.onOutput((id) => { signals.push(id) }) + }, + }) + await fiber + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('a') + expect(signals).toHaveLength(1) + await fiber.dispose() + handle.append('b') + expect(signals).toHaveLength(1) + }) +}) + +describe('LocalActivityRegistry lifecycle', () => { + it('owner disposal ends open records, removes rows, and announces the removal', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const handle = ctx.activities.open({ kind: 'bash', label: 'sleep', owner }) + handle.append('partial') + const settledEarly = ctx.activities.open({ kind: 'bash', label: 'done already', owner }) + settledEarly.end({ status: 'completed' }) + const changes: (string | undefined)[] = [] + ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) }) + await disposeAgentScope(owner) + expect(ctx.activities.list(owner)).toEqual([]) + expect(() => ctx.activities.get(handle.id, owner)).toThrow(/unknown activity/) + // The forced end and the removal both announced under the owner. + expect(changes.length).toBeGreaterThanOrEqual(2) + expect(new Set(changes)).toEqual(new Set(['owner'])) + // The producer's own late report is dropped silently, not thrown. + expect(() => { handle.end({ status: 'completed' }) }).not.toThrow() + }) + + it('service disposal ends everything, clears the store, and announces emptied owners', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const registryFiber = ctx.plugin(LocalActivityRegistry, {}) + await registryFiber + const owner = stubAgent(ctx, 'owner') + ctx.agents.register(owner) + const processes = ctx.activities + const owned = processes.open({ kind: 'bash', label: 'owned', owner }) + processes.open({ kind: 'bash', label: 'unowned' }) + const preSettled = processes.open({ kind: 'bash', label: 'already settled' }) + preSettled.end({ status: 'completed' }) + const changes: (string | undefined)[] = [] + ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) }) + await registryFiber.dispose() + expect(changes).toEqual(expect.arrayContaining(['owner', undefined])) + expect(() => { owned.append('late') }).not.toThrow() + }) +}) + +describe('LocalActivityRegistry snapshots', () => { + it('hands out fresh snapshots, never live registry state', async () => { + const ctx = await harness() + const handle: ActivityHandle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + const before: ActivitySnapshot = ctx.activities.get(handle.id) + handle.append('grow') + expect(before.outputTotal).toBe(0) + expect(ctx.activities.get(handle.id).outputTotal).toBe(4) + }) +}) diff --git a/packages/activity/activity-local/tests/loader-composition.spec.ts b/packages/activity/activity-local/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..4b341f7e16 --- /dev/null +++ b/packages/activity/activity-local/tests/loader-composition.spec.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('process-local through a real Loader composition', () => { + it('applies the provider-owned retention config from a Cordis row', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-activity-local-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-activity-local'", + ' config:', + ' retainBytes: 8', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (specifier === '@deepseek-ai/dsh-activity-local') return LocalActivityRegistry + throw new Error(`unexpected Loader import: ${specifier}`) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + expect(context.activities).toBeInstanceOf(LocalActivityRegistry) + const handle = context.activities.open({ kind: 'bash', label: 'loader retention probe' }) + handle.append('aaaa') + handle.append('bbbb') + handle.append('cccc') + const read = context.activities.read(handle.id, 0) + // retainBytes: 8 from the Cordis row dropped the oldest chunk. + expect(read.lossy).toBe(true) + expect(read.chunks.map(chunk => chunk.text)).toEqual(['bbbb', 'cccc']) + expect(read.next).toBe(12) + }) +}) diff --git a/packages/activity/activity-local/tsconfig.json b/packages/activity/activity-local/tsconfig.json new file mode 100644 index 0000000000..0bddbff854 --- /dev/null +++ b/packages/activity/activity-local/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/scope" + }, + { + "path": "../activity" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/activity/activity/README.i18n.yaml b/packages/activity/activity/README.i18n.yaml new file mode 100644 index 0000000000..e8cb7b1bad --- /dev/null +++ b/packages/activity/activity/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/activity/activity/README.md +README.md: a6d465d4d962b3bee59b56cfed1a4b0b1955c560 +README.zh.md: afe843f40a57103436e4c63799d80b86a9c4557a diff --git a/packages/activity/activity/README.md b/packages/activity/activity/README.md new file mode 100644 index 0000000000..a6d465d4d9 --- /dev/null +++ b/packages/activity/activity/README.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-activity + +English | [中文](README.zh.md) + +The streaming-output observation contract (`ctx.activities`). The abstract `ActivityRegistry` and its vocabulary types give any long-running producer one place to publish live output and status, and any number of independent consumers non-consuming reads over it; the in-memory registry lives in [`dsh-activity-local`](../activity-local/README.md). Producer plugins extend `ActivityKindMap` with their opaque id namespace and may link an activity to its tool call and background job through `ActivityCorrelation`. + +The registry is a pure observation plane: it starts nothing, cancels nothing, and is invisible to the model. Producers keep their execution resources and their existing model-facing surfaces; a composition without this seam loses only live observation. + +## Service contract + +- `open(spec): ActivityHandle` validates the kind, label, and exact live owner, then atomically registers the record and returns the producer face: synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop instead of throwing, so a producer's trailing flush cannot break its own teardown. +- `get(id, caller?)` and `list(caller?)` return fresh snapshots. Listing includes only caller-owned and unowned activities; owned access compares the activity's `SessionId` with the caller's, and predictable ids make that fence the boundary. +- `read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` plus the resume offset, without consuming anything. Offsets are absolute UTF-8 byte positions that survive eviction; a reader below the oldest retained byte gets `lossy: true`, never an error. +- `onActivitiesChanged(listener)` observes visible-set changes — opening, detail updates, settlement, owner-disposal removal, and the emptying service disposal commits — so consumers re-read rather than accumulate deltas. `onOutput(listener)` signals stream advancement (one signal per committed append and one at settlement) carrying only the id; a consumer reads from its own cursor. Both deliver owner-relative on the jobs-registry terms and contain every listener. + +`pumpActivityOutput(handle, sources, { pollMs, done })` is the shared pump for producers whose substrate is a non-consuming offset reader (the subprocess `readFrom(fromByte)` family): it copies labeled deltas at a bounded cadence, marks a lossy source read `gapBefore`, and drains once more after `done` settles without ever calling `end()` itself. + +See the [activity subsystem page](../../../docs/subsystems/activity.md) and the [observation-seam Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md). + +## Model Experience + +None, as the registry carries live observation state for humans and registers no prompt, tool, or session event; activity output becomes model-visible only through the producers' existing tool results and `ctx.jobs` reads. + +#### KV Cache effect + +None; the package never assembles or alters provider requests. + +## Known Limitations and Deferred Work + +- **Records are process-local and live-only** — a Host restart empties every roster while the transcript keeps the producing tool cards; durable replay is a separate design. +- **The observation plane has no cancellation verb** — stopping work stays with the producers and `ctx.jobs`; a panel-initiated kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +- **Settled records last until owner disposal** — retention trims their buffers to the settled cap, but the rows themselves are not aged out. diff --git a/packages/activity/activity/README.zh.md b/packages/activity/activity/README.zh.md new file mode 100644 index 0000000000..afe843f40a --- /dev/null +++ b/packages/activity/activity/README.zh.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-activity + +[English](README.md) | 中文 + +流式输出观察契约(`ctx.activities`)。抽象 `ActivityRegistry` 与其词汇类型给所有长时生产者一个发布实时输出与状态的位置,并让任意数量的独立消费者对其做非消费读取;内存实现在 [`dsh-activity-local`](../activity-local/README.zh.md)。生产者插件通过 `ActivityKindMap` 扩展自己的不透明 id 命名空间,并可经 `ActivityCorrelation` 把 activity 关联到它的工具调用与后台 job。 + +注册表是纯观察面:不启动、不取消任何工作,对模型不可见。生产者保留自己的执行资源与既有模型面;不装本 seam 的组合只失去实时观察。 + +## 服务契约 + +- `open(spec): ActivityHandle` 校验 kind、label 与 exact 存活 owner 后原子注册记录并返回生产者句柄:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)` 与 first-wins 的 `end(outcome)`。`end` 之后——包括 teardown 强制 end——`append` 与 `updateDetail` 记日志后丢弃而不抛错,生产者的收尾 flush 不会砸掉自己的 teardown。 +- `get(id, caller?)` 与 `list(caller?)` 返回全新快照。列表只含调用方拥有与无主的 activity;有主访问比较 activity 的 `SessionId` 与调用方的,id 可预测,这道栅栏即边界。 +- `read(id, from, caller?)` 返回与 `[from, total)` 重叠的保留 chunk 及续读 offset,绝不消费。offset 是跨淘汰稳定的绝对 UTF-8 字节位置;落后于最老保留字节的读者得到 `lossy: true`,永不报错。 +- `onActivitiesChanged(listener)` 观察可见集变化——开启、detail 更新、结算、owner 销毁移除、服务销毁清空——消费者整读而非累积增量。`onOutput(listener)` 发出流推进信号(每次提交的 append 一发、结算再一发),只携带 id;消费者按自己的游标去 read。两者按 jobs 注册表的条款做 owner 相对投递,且每个监听都被容错。 + +`pumpActivityOutput(handle, sources, { pollMs, done })` 是给非消费 offset 读取器型底座(subprocess 的 `readFrom(fromByte)` 家族)的共享泵:按有界节奏复制带标签的增量、把 lossy 的源读取标为 `gapBefore`、在 `done` 结算后再排干一次,且自己绝不调用 `end()`。 + +参见 [activity 子系统页](../../../docs/subsystems/activity.zh.md)与[观察 seam Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)。 + +## 模型体验 + +无:注册表只承载面向人的实时观察状态,不注册任何 prompt、工具或会话事件;activity 输出只有经生产者既有的工具结果与 `ctx.jobs` 读取才对模型可见。 + +#### KV Cache 影响 + +无;本包从不组装或改动 provider 请求。 + +## 已知限制与暂缓事项 + +- **记录是进程本地且仅存活期有效**——Host 重启清空全部 roster,而 transcript 侧的生产工具卡片仍在;持久回放是独立设计。 +- **观察面没有取消动词**——停止工作仍属于生产者与 `ctx.jobs`;面板发起的 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 +- **已结算记录保留到 owner 销毁**——保留策略把缓冲裁到 settled 上限,但行本身不会老化清除。 diff --git a/packages/activity/activity/package.json b/packages/activity/activity/package.json new file mode 100644 index 0000000000..0b381d11fa --- /dev/null +++ b/packages/activity/activity/package.json @@ -0,0 +1,57 @@ +{ + "name": "@deepseek-ai/dsh-activity", + "description": "Streaming-output observation registry (ctx.activities) for the DeepSeek Harness — non-consuming offset reads, bounded retention, and change signals over any long-running producer's live output", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/activity/activity" + }, + "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" + }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/activity/activity/src/brand.ts b/packages/activity/activity/src/brand.ts new file mode 100644 index 0000000000..8df786ffb9 --- /dev/null +++ b/packages/activity/activity/src/brand.ts @@ -0,0 +1,28 @@ +/** + * dsh-activity' owned branded id, carried across the registry, the observation + * wire, and correlation metadata on other domains' rows. + * + * It lives in its own leaf because the package root reaches `dsh-agent` + * through the owner and listener signatures, which a Client program cannot + * resolve even as a type. A browser-safe consumer imports the id here; + * `Branded` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`. + * + * @module @deepseek-ai/dsh-activity/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** + * Identifies one observable activity. The registry generates `-N`; + * predictable ids rely on owner authorization rather than secrecy. + */ +export type ActivityId = Branded<'ActivityId'> + +/** + * Brand a string as a {@link ActivityId}. + * @param id - the raw process-id string (the registry generates `-N`). + * @returns the same string, branded; no validation is performed. + */ +export function ActivityId(id: string): ActivityId { + return id as ActivityId +} diff --git a/packages/activity/activity/src/index.ts b/packages/activity/activity/src/index.ts new file mode 100644 index 0000000000..4e3927abe5 --- /dev/null +++ b/packages/activity/activity/src/index.ts @@ -0,0 +1,138 @@ +/** + * The streaming-output observation Service Definition (`ctx.activities`). An + * activity is one observable unit of long-running work: an append-only bounded + * output stream plus live status, readable by any number of independent + * observers at absolute byte offsets. The registry is a pure observation + * plane — it starts nothing, cancels nothing, and is invisible to the model; + * producers keep their execution resources and their existing model-facing + * surfaces (`ctx.jobs` cursors, tool results). The process-local registry + * lives in `@deepseek-ai/dsh-activity-local`. + * @module @deepseek-ai/dsh-activity + */ + +import { Context, Service } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { + ActivitiesChangedListener, ActivityHandle, ActivityId, ActivityOpen, ActivityOutputListener, + ActivityRead, ActivitySnapshot, +} from './types.ts' + +export { ActivityId } from './brand.ts' +export type { + ActivityAppendOptions, + ActivityChannel, + ActivityCorrelation, + ActivitiesChangedListener, + ActivityHandle, + ActivityKind, + ActivityKindMap, + ActivityOpen, + ActivityOutcome, + ActivityOutputChunk, + ActivityOutputListener, + ActivityRead, + ActivitySnapshot, + ActivityStatus, +} from './types.ts' +export { pumpActivityOutput } from './pump.ts' +export type { ActivityPumpOptions, ActivityPumpRead, ActivityPumpSource } from './pump.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + activities: ActivityRegistry + } +} + +/** + * Abstract streaming-output registry. Subclass, implement the abstract + * methods, and load the subclass as a plugin — it registers as + * `ctx.activities` (one implementation per context; loading a second throws, + * which is cordis' standard duplicate-service behavior). + * + * Implementations must honor these semantics: + * - Observation never consumes. Any number of readers hold their own absolute + * byte offsets; a read changes no cursor and no producer state, so the + * model-facing paths (`ctx.jobs.read()`, tool results) are unaffected. + * - Records outlive producer fibers. Owner disposal ends still-open records + * and removes them; service disposal ends and clears everything. Neither + * awaits a producer — the registry owns no execution resource. + * - Retention is bounded. Appends past the live cap drop the oldest retained + * bytes; a reader below the retained window gets a lossy read, never an + * error. Settlement trims retention to the settled cap. + * - Listener delivery is owner-relative: a listener registered from an + * unscoped context — a host composition's own carrier — sees every owner, + * while one registered under an agent composition's scope sees exactly the + * agents composed under it. Every listener is contained. + */ +export abstract class ActivityRegistry extends Service { + constructor(ctx: Context) { + // `abstract` erases at runtime, so a composition row naming this package + // would register a ctx.activities with no method implementations and fail + // far from the misconfiguration. Fail loud at load instead. + if (new.target === ActivityRegistry) { + throw new Error('@deepseek-ai/dsh-activity is the abstract activity observation seam; load an implementation such as @deepseek-ai/dsh-activity-local instead') + } + super(ctx, 'activities') + } + + /** + * Validate the spec, attach owner cleanup, and atomically register one + * activity. A rejection leaves no id; after return the producer owns the + * handle and the visible set has changed. + * @param spec - activity identity, owner, and correlation. + * @returns the producer face of the registered activity. + */ + abstract open(spec: ActivityOpen): ActivityHandle + + /** + * List caller-owned and unowned activities in registration order without + * exposing another session's labels or output. + * @param caller - reading agent; a non-agent caller sees only unowned activities. + * @returns fresh snapshots. + */ + abstract list(caller?: Agent): ActivitySnapshot[] + + /** + * Return a fresh snapshot. Throws for an unknown or foreign activity. + * @param id - activity to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ + abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot + + /** + * Read retained output from an absolute byte offset without consuming it. + * Resume by passing a previous read's `next`; a foreign offset inside a + * retained chunk returns that whole chunk (its `at` may precede `from`). + * Throws for an unknown or foreign activity or a negative or non-integer + * offset. + * @param id - activity to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading agent checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ + abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead + + /** + * Register an effect-scoped observer of visible-set changes: opening, + * detail updates, settlement, owner-disposal removal, and the emptying that + * service disposal commits — so an observer re-reads rather than + * accumulating deltas. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned activity changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ + abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void + + /** + * Register an effect-scoped observer of stream advancement — one signal per + * committed append and one at settlement, carrying only the activity id. + * A consumer schedules a {@link read} from its own cursor; the registry + * never pushes payloads. Listeners are contained and never awaited. + * @param listener - receives the id whose stream advanced. + * @returns disposer that unregisters the listener. + */ + abstract onOutput(listener: ActivityOutputListener): () => void +} + +export default ActivityRegistry diff --git a/packages/activity/activity/src/invariant.ts b/packages/activity/activity/src/invariant.ts new file mode 100644 index 0000000000..6753fa7b0e --- /dev/null +++ b/packages/activity/activity/src/invariant.ts @@ -0,0 +1,62 @@ +/** Package-owned process-snapshot invariants. @module @deepseek-ai/dsh-activity/invariant */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { ActivitySnapshot } from './types.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-activity' +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed']) + +/** Cordis companion plugin name. */ +export const name = 'activity-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Validate the cross-field relationships in one registry snapshot. */ +/* jscpd:ignore-start -- mirrors the jobs invariant's id/ordinal checks by design; the two registries share the `-N` scheme. */ +function validateSnapshot(snapshot: ActivitySnapshot, fail: InvariantFailure): void { + const id = String(snapshot.id) + const prefix = `${snapshot.kind}-` + const ordinal = Number(id.slice(prefix.length)) + if (snapshot.kind.length === 0 || !id.startsWith(prefix) + || !Number.isSafeInteger(ordinal) || ordinal < 1) { + fail(`activity snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`) + } + if (snapshot.label.length === 0) fail(`activity ${JSON.stringify(id)} label must be non-empty`) + if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) { + fail(`activity ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`) + } + /* jscpd:ignore-end */ + + const terminal = TERMINAL_STATUSES.has(snapshot.status) + if (terminal !== (snapshot.finishedAt !== undefined)) { + fail(`activity ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`) + } + if (snapshot.finishedAt !== undefined + && (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) { + fail(`activity ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`) + } + + if (!Number.isSafeInteger(snapshot.outputTotal) || snapshot.outputTotal < 0 + || !Number.isSafeInteger(snapshot.outputEarliest) || snapshot.outputEarliest < 0 + || snapshot.outputEarliest > snapshot.outputTotal) { + fail(`activity ${JSON.stringify(id)} output offsets must satisfy 0 <= outputEarliest <= outputTotal`) + } +} + +/** Install checks over current visible records and every later visible-set change. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const snapshot of ctx.activities.list()) validateSnapshot(snapshot, fail) + ctx.activities.onActivitiesChanged((owner: Agent | undefined) => { + for (const snapshot of ctx.activities.list(owner)) validateSnapshot(snapshot, fail) + }) +}, { inject: ['activities'] }) + +/** + * Register the process-registry invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/activity/activity/src/pump.ts b/packages/activity/activity/src/pump.ts new file mode 100644 index 0000000000..4cc1eae22d --- /dev/null +++ b/packages/activity/activity/src/pump.ts @@ -0,0 +1,95 @@ +/** + * Shared poll pump for producers whose output substrate is a non-consuming + * offset reader (the subprocess `readFrom(fromByte)` family). Pure utility — + * no cordis, no timers retained past settlement. + * @module + */ + +import type { ActivityChannel, ActivityHandle } from './types.ts' + +/** One non-consuming offset read from a producer-owned stream. */ +export interface ActivityPumpRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean +} + +/** One producer-owned stream the pump copies into a process. */ +export interface ActivityPumpSource { + /** Stream label attached to every chunk this source yields. */ + channel?: ActivityChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, and the lossy flag. + */ + read(fromByte: number): ActivityPumpRead +} + +/** Cadence and settlement for one {@link pumpActivityOutput} run. */ +export interface ActivityPumpOptions { + /** Poll interval in milliseconds; a positive finite number. */ + pollMs: number + /** + * Settles when the producer's work finished and its sources hold their + * final bytes. The pump drains once more after settlement and returns; + * a rejection is treated as settlement. + */ + done: Promise +} + +/** + * Copy producer streams into a process at a bounded cadence: drain every + * source, sleep `pollMs` or until `done` settles, repeat, then drain one + * final time. A lossy source read appends its surviving tail with + * `gapBefore`, so the discontinuity stays visible to observers. The pump + * never calls `handle.end()` — the producer maps its own outcome. + * @param handle - the open process receiving the copied chunks. + * @param sources - producer streams, each pumped at its own offset. + * @param options - poll cadence and the producer's settlement promise. + * @returns resolves after the final post-settlement drain. + */ +export async function pumpActivityOutput( + handle: ActivityHandle, + sources: readonly ActivityPumpSource[], + options: ActivityPumpOptions, +): Promise { + if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) { + throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`) + } + const states = sources.map(source => ({ source, cursor: 0 })) + const drain = (): void => { + for (const state of states) { + const { source } = state + const read = source.read(state.cursor) + state.cursor = read.nextOffset + if (read.text.length === 0) continue + if (source.channel === undefined && !read.lossy) { + handle.append(read.text) + } else { + handle.append(read.text, { + ...source.channel !== undefined ? { channel: source.channel } : {}, + ...read.lossy ? { gapBefore: true as const } : {}, + }) + } + } + } + let settled = false + const settlement = options.done.then(() => { settled = true }, () => { settled = true }) + while (!settled) { + drain() + await Promise.race([ + new Promise((resolve) => { + const timer = setTimeout(resolve, options.pollMs) + // The settlement branch wins the race; clear the loser so a finished + // pump holds no pending timer for up to one poll interval. + void settlement.then(() => { clearTimeout(timer); resolve() }) + }), + settlement, + ]) + } + drain() +} diff --git a/packages/activity/activity/src/types.ts b/packages/activity/activity/src/types.ts new file mode 100644 index 0000000000..fd748ec165 --- /dev/null +++ b/packages/activity/activity/src/types.ts @@ -0,0 +1,179 @@ +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { ActivityId } from './brand.ts' + +export type { ActivityId } from './brand.ts' + +/** + * Producer-defined activity kinds. Plugins extend this map by declaration + * merging; the registry treats every value as an opaque id namespace. + */ +export interface ActivityKindMap { + bash: 'bash' +} + +/** A registered activity kind — also the id prefix (`bash`, `workflow`, …). */ +export type ActivityKind = ActivityKindMap[keyof ActivityKindMap] + +/** Lifecycle state of one observable activity. */ +export type ActivityStatus = 'running' | 'completed' | 'failed' | 'killed' + +/** + * Output stream label. Producers without distinct streams omit it; consumers + * treat an unrecognized label like an absent one. + */ +export type ActivityChannel = 'stdout' | 'stderr' + +/** + * Links one activity to the records other domains keep for the same work, so a + * consumer can attach the output stream to an existing card or row without + * guessing from labels. + */ +export interface ActivityCorrelation { + /** Tool call that started the work, when a tool produced it. */ + callId?: CallId + /** Background job carrying the model-facing control surface, when one exists. */ + jobId?: JobId +} + +/** + * Producer declaration passed to {@link ActivityRegistry.open}. The registry + * owns identity, retention, and observer delivery; the producer keeps its + * execution resources and reports through the returned {@link ActivityHandle}. + */ +export interface ActivityOpen { + /** Producer kind — also the id prefix. */ + kind: ActivityKind + /** One-line human-facing label (the command; the run description). */ + label: string + /** + * Owning live agent. Visibility is fenced by its session id, and agent + * disposal ends and removes the record. The instance must be the one + * currently registered under its agent id. Omitting the owner creates an + * unowned activity, visible to any caller until service disposal. + */ + owner?: Agent + /** Optional links to the tool call and background job for the same work. */ + correlation?: ActivityCorrelation +} + +/** Terminal outcome reported through {@link ActivityHandle.end}. */ +export interface ActivityOutcome { + /** How the work ended: finished (`completed`), broke (`failed`), or was cancelled (`killed`). */ + status: 'completed' | 'failed' | 'killed' + /** Kind-specific detail rendered into status lines ('exit code: 3'). */ + detail?: string +} + +/** Options for one {@link ActivityHandle.append}. */ +export interface ActivityAppendOptions { + /** Stream label for the chunk; omitted for producers without distinct streams. */ + channel?: ActivityChannel + /** + * Marks that producer-side bytes between the previous chunk and this one + * were lost (a source window slid before the producer could copy it), so a + * consumer can render the discontinuity instead of a silent splice. + */ + gapBefore?: true +} + +/** + * Producer face of one open activity. All methods are synchronous. After + * {@link end} — including a registry-forced end during owner or service + * teardown — `append` and `updateDetail` log and drop instead of throwing, so + * a producer's trailing flush cannot break its own teardown path. + */ +export interface ActivityHandle { + /** The registry-issued id (`-N`). */ + readonly id: ActivityId + /** + * Append one output chunk. Offsets advance by the chunk's UTF-8 byte + * length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: ActivityAppendOptions): void + /** + * Replace the snapshot's status detail (a progress line such as `3/10`). + * @param detail - the new detail line. + */ + updateDetail(detail: string): void + /** + * Record the terminal outcome and trim retention to the settled cap. + * First-wins: a later call — the producer's own report after a teardown + * force-end, or a duplicate — is dropped silently. + * @param outcome - terminal status and optional detail. + */ + end(outcome: ActivityOutcome): void +} + +/** + * A read-only projection of one activity, safe to hand to listeners and + * consumers — a fresh object per call, never live registry state. + */ +export interface ActivitySnapshot { + /** The registry-issued id (`-N`). */ + id: ActivityId + /** The producer kind the process was opened with. */ + kind: ActivityKind + /** The producer-supplied one-line label. */ + label: string + /** Owner session id used for visibility and grouping; absent for unowned activities. */ + ownerSession?: SessionId + /** Links to the tool call and background job for the same work, when supplied. */ + correlation?: ActivityCorrelation + /** Current lifecycle state. */ + status: ActivityStatus + /** Kind-specific status detail, when the producer supplied one. */ + detail?: string + /** Epoch ms when the activity was opened. */ + startedAt: number + /** Epoch ms when the activity ended; absent while `running`. */ + finishedAt?: number + /** Total UTF-8 bytes ever appended — the offset the next chunk starts at. */ + outputTotal: number + /** + * Offset of the oldest retained byte. Greater than zero exactly when + * retention dropped the head; a reader starting below it gets a lossy read. + */ + outputEarliest: number +} + +/** One retained output chunk returned by {@link ActivityRegistry.read}. */ +export interface ActivityOutputChunk { + /** Absolute offset of the chunk's first byte. */ + at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + text: string + /** Stream label, when the producer supplied one. */ + channel?: ActivityChannel + /** Producer-reported loss immediately before this chunk. */ + gapBefore?: true +} + +/** Result of one non-consuming {@link ActivityRegistry.read}. */ +export interface ActivityRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly ActivityOutputChunk[] + /** Offset to resume from — the process' current `outputTotal`. */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean +} + +/** + * Observer of visible-set changes: opening, detail updates, settlement, and + * removal. Receives the owner whose set changed, or `undefined` when an + * unowned process changed and every caller's set did. Consumers re-read + * rather than accumulating deltas. + */ +export type ActivitiesChangedListener = (owner: Agent | undefined) => void + +/** + * Observer of stream advancement for one activity: new appended output, or the + * settlement that ends the stream. Carries no payload — a consumer schedules a + * {@link ActivityRegistry.read} from its own cursor. + */ +export type ActivityOutputListener = (id: ActivityId) => void diff --git a/packages/activity/activity/tests/invariant.spec.ts b/packages/activity/activity/tests/invariant.spec.ts new file mode 100644 index 0000000000..306cd4533a --- /dev/null +++ b/packages/activity/activity/tests/invariant.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import ActivityRegistry, { ActivityId } from '@deepseek-ai/dsh-activity' +import type { ActivitiesChangedListener, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import * as ProcessInvariant from '@deepseek-ai/dsh-activity/invariant' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' + +const BASE: ActivitySnapshot = { + id: ActivityId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'completed', + startedAt: 10, + finishedAt: 20, + outputTotal: 12, + outputEarliest: 4, +} + +const RUNNING: ActivitySnapshot = { + id: ActivityId('bash-1'), + kind: 'bash', + label: 'compile', + status: 'running', + startedAt: 10, + outputTotal: 0, + outputEarliest: 0, +} + +/** Boot the companion over a probe registry whose visible set the test controls. */ +async function setup(seed: ActivitySnapshot[] = []): Promise<(visible: ActivitySnapshot[], owner?: Agent) => void> { + const ctx = new Context() + let current = seed + let listener: ActivitiesChangedListener | undefined + const probe = { + list: () => current, + onActivitiesChanged(value: ActivitiesChangedListener) { + listener = value + return () => { listener = undefined } + }, + } as unknown as ActivityRegistry + await ctx.plugin(InvariantRegistry) + await ctx.plugin({ + name: 'activity-invariant-probe', + apply(child: Context) { child.provide('activities', probe) }, + }) + await ctx.plugin(ProcessInvariant) + if (listener === undefined) throw new Error('activity invariant did not subscribe to visible-set changes') + return (visible, owner) => { + current = visible + listener!(owner) + } +} + +describe('process-registry invariants', () => { + it('accepts coherent running and terminal snapshots', async () => { + const notify = await setup([RUNNING]) + expect(() => { notify([BASE]) }).not.toThrow() + expect(() => { notify([{ ...BASE, id: ActivityId('workflow-2'), kind: 'workflow' }]) }) + .not.toThrow() + }) + + it.each([ + [{ ...BASE, id: ActivityId('-1'), kind: '' as ActivitySnapshot['kind'] }, /positive ordinal/], + [{ ...BASE, id: ActivityId('other-1') }, /must be "bash-" followed by a positive ordinal/], + [{ ...BASE, id: ActivityId('bash-0') }, /positive ordinal/], + [{ ...BASE, label: '' }, /label must be non-empty/], + [{ ...BASE, startedAt: -1 }, /startedAt must be a non-negative epoch integer/], + [{ ...BASE, status: 'running' as const, finishedAt: 20 }, /finishedAt must be present exactly for a terminal status/], + [{ ...RUNNING, status: 'killed' as const }, /finishedAt must be present exactly for a terminal status/], + [{ ...BASE, finishedAt: 9 }, /no earlier than startedAt/], + [{ ...BASE, outputEarliest: 13 }, /0 <= outputEarliest <= outputTotal/], + [{ ...BASE, outputTotal: -1, outputEarliest: 0 }, /0 <= outputEarliest <= outputTotal/], + ] as const)('rejects an incoherent registry snapshot', async (snapshot, message) => { + const notify = await setup() + expect(() => { notify([snapshot]) }).toThrow(message) + }) + + it('rejects an incoherent record already present at installation', async () => { + await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/) + }) +}) diff --git a/packages/activity/activity/tests/service.spec.ts b/packages/activity/activity/tests/service.spec.ts new file mode 100644 index 0000000000..2dd685c1d3 --- /dev/null +++ b/packages/activity/activity/tests/service.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import ActivityRegistry, { ActivityId, pumpActivityOutput } from '@deepseek-ai/dsh-activity' +import type { ActivityAppendOptions, ActivityHandle, ActivityPumpSource, ActivitySnapshot } from '@deepseek-ai/dsh-activity' + +/** Record every append so pump behavior is observable without a registry. */ +function recordingHandle(): { handle: ActivityHandle; appends: { text: string; options?: ActivityAppendOptions }[] } { + const appends: { text: string; options?: ActivityAppendOptions }[] = [] + const handle: ActivityHandle = { + id: ActivityId('bash-1'), + append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) }, + updateDetail() {}, + end() {}, + } + return { handle, appends } +} + +/** A scripted source: each read() shifts the next scripted result. */ +function scriptedSource( + reads: { text: string; lossy?: boolean }[], + channel?: 'stdout' | 'stderr', +): { source: ActivityPumpSource; offsets: number[] } { + const offsets: number[] = [] + let offset = 0 + const source: ActivityPumpSource = { + ...channel !== undefined ? { channel } : {}, + read(fromByte) { + offsets.push(fromByte) + const next = reads.shift() ?? { text: '' } + offset += Buffer.byteLength(next.text, 'utf8') + return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false } + }, + } + return { source, offsets } +} + +describe('ActivityRegistry seam', () => { + it('refuses to mount the abstract Service Definition directly', async () => { + const ctx = new Context() + await expect(ctx.plugin(ActivityRegistry as unknown as () => void)) + .rejects.toThrow(/load an implementation such as @deepseek-ai\/dsh-activity-local/) + }) + + it('preserves the SessionId brand on public owner snapshots', () => { + expectTypeOf().toEqualTypeOf() + }) +}) + +describe('pumpActivityOutput', () => { + it('rejects a non-positive poll interval before touching any source', async () => { + const { handle } = recordingHandle() + await expect(pumpActivityOutput(handle, [], { pollMs: 0, done: Promise.resolve() })) + .rejects.toThrow(/invalid pump pollMs/) + }) + + it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => { + vi.useFakeTimers() + try { + const { handle, appends } = recordingHandle() + let settle!: () => void + const done = new Promise((resolve) => { settle = resolve }) + const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout') + const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr') + const pump = pumpActivityOutput(handle, [out.source, err.source], { pollMs: 50, done }) + + await vi.advanceTimersByTimeAsync(50) + settle() + await pump + expect(appends).toEqual([ + { text: 'a', options: { channel: 'stdout' } }, + { text: 'bc', options: { channel: 'stdout' } }, + { text: 'E', options: { channel: 'stderr' } }, + ]) + // Every read resumed from the previous nextOffset, never from a shared cursor. + expect(out.offsets).toEqual([0, 1, 3]) + expect(err.offsets).toEqual([0, 0, 1]) + // Settlement cleared the raced poll timer instead of leaving it pending. + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('marks a lossy source read as a gap so observers see the discontinuity', async () => { + const { handle, appends } = recordingHandle() + const { source } = scriptedSource([{ text: 'tail', lossy: true }]) + await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.resolve() }) + expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }]) + }) + + it('treats a rejected done as settlement and still drains the final bytes', async () => { + const { handle, appends } = recordingHandle() + const { source } = scriptedSource([{ text: 'last' }]) + await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) }) + expect(appends).toEqual([{ text: 'last' }]) + }) +}) diff --git a/packages/activity/activity/tsconfig.json b/packages/activity/activity/tsconfig.json new file mode 100644 index 0000000000..f4deee6327 --- /dev/null +++ b/packages/activity/activity/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/api/activity-controller/README.i18n.yaml b/packages/api/activity-controller/README.i18n.yaml new file mode 100644 index 0000000000..e49b392e32 --- /dev/null +++ b/packages/api/activity-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/activity-controller/README.md +README.md: 10885ec087cc295df6bcb033480373db04938097 +README.zh.md: 9509837baa4661209c7d826bee149b76a11f4723 diff --git a/packages/api/activity-controller/README.md b/packages/api/activity-controller/README.md new file mode 100644 index 0000000000..10885ec087 --- /dev/null +++ b/packages/api/activity-controller/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-api-activity-controller + +English | [中文](README.zh.md) + +Activity Remote streams over the optional `ctx.activities` registry, plus their React-free client model. A pure read surface: it starts nothing, cancels nothing, and never touches the model-facing consuming cursors — the wire path reads only the registry's non-consuming offsets. + +The Host `ActivityController` (namespace `activity`) exposes two `@Remote({ mode: 'stream' })` methods: + +- `control(signal)` — one complete roster baseline, then whole-bucket replacement frames per owner session on every registry change (the self-healing shape the session control stream uses for jobs). A composition without the registry serves an empty baseline. +- `observe({ activityId, from? }, signal)` — one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget; one larger chunk ships whole), then one terminal `status` after the settled activity is drained, after which the generation closes normally. Reconnecting callers resume by passing the last frame's `next`; a resume behind the retained window arrives flagged `lossy`. Status rides the same stream as output, so settlement can never race a still-open output channel. + +| Config | Default | Meaning | +|---|---|---| +| `flushMs` | `100` | Coalescing window after new output before an observation read. | +| `maxFrameBytes` | `65536` | Soft byte budget per observation output frame. | + +The client half (`./client`) installs `ctx.activityFeed`: `ClientActivityModel` mirrors the roster (last-wins buckets, generation replace) and accumulates each observed activity's bounded render tail, and `ClientActivityFeed.observe(id)` opens reference-counted observation streams that resume from the model's cursor across carrier generations. + +## Model Experience + +None, as activity transport is browser and Host observation state and registers no prompt, tool, or session event. + +#### KV Cache effect + +None; the package never assembles or alters provider requests. + +## Known Limitations and Deferred Work + +- **Registry presence is sampled at construction** — a registry mounted after the controller stays unobserved until the controller reloads. +- **The roster names no watermark** — an observation stream anchors itself; correlating roster rows against observation offsets is client policy. diff --git a/packages/api/activity-controller/README.zh.md b/packages/api/activity-controller/README.zh.md new file mode 100644 index 0000000000..9509837baa --- /dev/null +++ b/packages/api/activity-controller/README.zh.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-api-activity-controller + +[English](README.md) | 中文 + +架在可选 `ctx.activities` 注册表之上的 Activity Remote 流,附 React-free 客户端模型。纯读面:不启动、不取消任何工作,也绝不触碰模型侧消费游标——wire 路径只读注册表的非消费 offset。 + +宿主 `ActivityController`(命名空间 `activity`)暴露两个 `@Remote({ mode: 'stream' })` 方法: + +- `control(signal)`——一份完整 roster baseline,之后每次注册表变化按 owner 会话推送整桶替换帧(与 session control 流承载 jobs 的自愈形态一致)。没有注册表的组合返回空 baseline。 +- `observe({ activityId, from? }, signal)`——一帧 `opened` 锚点,随后是合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算;更大的单 chunk 整帧发出),activity 结算且排干后发一帧终态 `status`,然后本代正常关闭。重连方以上一帧的 `next` 作为 `from` 续传;落后于保留窗口的续传会带 `lossy` 标记。状态与输出同流,结算永远不会与仍开着的输出通道竞态。 + +| 配置 | 默认值 | 含义 | +|---|---|---| +| `flushMs` | `100` | 新输出到达后、观察读取前的合并窗口。 | +| `maxFrameBytes` | `65536` | 每个观察输出帧的软字节预算。 | + +客户端半区(`./client`)安装 `ctx.activityFeed`:`ClientActivityModel` 镜像 roster(last-wins 分桶、按代整体替换)并为每个被观察 activity 累积有界渲染尾巴;`ClientActivityFeed.observe(id)` 打开按引用计数共享的观察流,跨 carrier 代次从模型游标续传。 + +## 模型体验 + +无:activity 传输是浏览器与宿主的观察状态,不注册任何 prompt、工具或会话事件。 + +#### KV Cache 影响 + +无;本包从不组装或改动 provider 请求。 + +## 已知限制与暂缓事项 + +- **注册表存在性在构造时采样**——控制器之后才挂载的注册表在控制器重载前不会被观察。 +- **roster 不携带水位**——观察流自带锚点;roster 行与观察 offset 的对齐是客户端策略。 diff --git a/packages/api/activity-controller/package.json b/packages/api/activity-controller/package.json new file mode 100644 index 0000000000..6898cff505 --- /dev/null +++ b/packages/api/activity-controller/package.json @@ -0,0 +1,103 @@ +{ + "name": "@deepseek-ai/dsh-api-activity-controller", + "description": "Activity Remote streams: the live-activity roster and per-activity non-consuming output observation", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/activity-controller" + }, + "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" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-activity-local": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + } +} diff --git a/packages/api/activity-controller/src/client/index.ts b/packages/api/activity-controller/src/client/index.ts new file mode 100644 index 0000000000..7338ae3b9b --- /dev/null +++ b/packages/api/activity-controller/src/client/index.ts @@ -0,0 +1,86 @@ +/** Activity-specific adapter for the Gateway-owned snapshot stream lifecycle. */ + +import type { Context } from '@deepseek-ai/cordis' +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { ActivityControlFrame } from '../types.ts' +import { ClientActivityModel } from './model.ts' +import { ClientActivityFeed } from './service.ts' +import type { ActivityRemote } from './service.ts' + +export type { ActivityFeedPhase, ActivityFeedSnapshot, ObservedActivity } from './model.ts' +export type { ActivityFeedSource, ActivityRemote, IActivityFeed } from './service.ts' +export type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +export type { ActivityRow } from '../types.ts' + +type ActivityStreamRemote = Pick & { readonly activity: ActivityRemote } + +type ActivityBaselineFrame = Extract +type ActivityRowsFrame = Extract + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free client activity roster and observation control. */ + activityFeed: import('./service.ts').IActivityFeed + } +} + +/** Required Client Remote services. */ +export const inject = ['remote', 'remote.activity'] + +/** + * Install the client activity model, its service face, and the reconnecting + * roster stream. + * @param ctx - client root Context. + */ +export function apply(ctx: Context): void { + const raw = ctx.remote as ActivityStreamRemote + // Resolve the child namespace to a concrete value while this plugin's + // context is current: later stream (re)opens run on caller stacks (a React + // event, a carrier retry) whose dynamic context has not declared + // `remote.activity`, and a property access there fails the inject check. + const remote: ActivityStreamRemote = { + $stream: options => raw.$stream(options), + activity: raw.activity, + } + const model = new ClientActivityModel() + new ClientActivityFeed(ctx, remote, model) + const control = createActivityRosterStream(remote, model) + control.start() + ctx.effect( + () => async () => { await control.dispose() }, + 'activity-controller.client.control', + ) +} + +/** + * Create the reconnecting roster stream feeding one client model. + * @param remote - generated activity namespace and Gateway stream factory. + * @param model - roster sink. + * @returns an unstarted stream owned by the client activity runtime. + */ +export function createActivityRosterStream( + remote: ActivityStreamRemote, + model: ClientActivityModel, +): RemoteSnapshotStream { + const stream = remote.$stream({ + name: 'activity roster stream', + open: signal => remote.activity.control(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('activity roster stream ended without a terminal result') + : new Error('activity roster stream ended before its opening baseline'), + }) + return new RemoteSnapshotStream(stream, { + name: 'activity roster stream', + isSnapshot: (frame): frame is ActivityBaselineFrame => frame.type === 'baseline', + replace: (frame) => { model.replaceBaseline(frame.activities) }, + update: (frame) => { model.replaceBucket(frame.sessionId, frame.activities) }, + failed: () => { + // The previous roster stays published while the stream retries; a + // terminal protocol failure leaves the last snapshot in place. + }, + }) +} diff --git a/packages/api/activity-controller/src/client/model.ts b/packages/api/activity-controller/src/client/model.ts new file mode 100644 index 0000000000..96fb7cca5a --- /dev/null +++ b/packages/api/activity-controller/src/client/model.ts @@ -0,0 +1,211 @@ +/** Client-side activity state model shared by Remote transport and UI projection. */ + +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { ActivityObserveFrame, ActivityRow } from '../types.ts' + +/** Bounded per-activity render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024 + +/** Roster arrival lifecycle: `pending` until the first baseline lands. */ +export type ActivityFeedPhase = 'pending' | 'ready' + +/** One observed activity's live view state. */ +export interface ObservedActivity { + readonly activityId: ActivityId + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean + /** Latest status delivered on the observation stream. */ + readonly status: ActivityRow['status'] + readonly detail?: string + /** True while the observation stream is open and the activity has not settled. */ + readonly streaming: boolean + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string +} + +/** Immutable client activity state. */ +export interface ActivityFeedSnapshot { + /** Roster rows per owner session; the empty key holds unowned activities. */ + readonly rowsBySession: Readonly> + /** Live observation state keyed by activity id. */ + readonly observed: Readonly> + readonly phase: ActivityFeedPhase +} + +const UNOWNED = '' + +/** Mutable observation bookkeeping behind one {@link ObservedActivity} view. */ +interface ObservedState { + view: ObservedActivity + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined +} + +/** + * Owns the client roster mirror and per-activity observation state. Pure data + * plus subscriptions — transport wiring stays in the client plugin, UI stays + * in slot components. + */ +export class ClientActivityModel { + private rowsBySession: Readonly> = {} + private readonly observedStates = new Map() + private phase: ActivityFeedPhase = 'pending' + private readonly listeners = new Set<() => void>() + private snapshotCache: ActivityFeedSnapshot = { rowsBySession: {}, observed: {}, phase: 'pending' } + private snapshotDirty = false + + /** + * Read the identity-stable current snapshot. + * @returns the cached snapshot, rebuilt only after a change. + */ + getSnapshot(): ActivityFeedSnapshot { + if (this.snapshotDirty) { + const observed: Record = {} + for (const [id, state] of this.observedStates) observed[id] = state.view + this.snapshotCache = { rowsBySession: this.rowsBySession, observed, phase: this.phase } + this.snapshotDirty = false + } + return this.snapshotCache + } + + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Replace the whole roster from a generation baseline. + * @param rows - every visible row across all owner buckets. + */ + replaceBaseline(rows: readonly ActivityRow[]): void { + const next: Record = {} + for (const row of rows) { + const key = row.sessionId ?? UNOWNED + ;(next[key] ??= []).push(row) + } + this.rowsBySession = next + this.phase = 'ready' + this.changed() + } + + /** + * Replace one owner bucket wholesale; an empty replacement drops the key. + * @param sessionId - owner session, or undefined for the unowned bucket. + * @param rows - the bucket's complete visible rows. + */ + replaceBucket(sessionId: string | undefined, rows: readonly ActivityRow[]): void { + const key = sessionId ?? UNOWNED + if (rows.length === 0) { + this.rowsBySession = Object.fromEntries( + Object.entries(this.rowsBySession).filter(([bucket]) => bucket !== key)) + } else { + this.rowsBySession = { ...this.rowsBySession, [key]: rows } + } + this.changed() + } + + /** + * The resume offset for one activity's next observation generation. + * @param id - observed activity. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: ActivityId): number | undefined { + return this.observedStates.get(String(id))?.cursor + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed activity. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: ActivityId, frame: Extract): void { + const existing = this.observedStates.get(String(id)) + const view: ObservedActivity = { + activityId: id, + text: existing?.view.text ?? '', + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: true, + } + this.observedStates.set(String(id), { view, cursor: frame.from }) + this.changed() + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed activity. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: ActivityId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') + let gapBefore = state.view.gapBefore || frame.lossy === true + || frame.chunks.some(chunk => chunk.gapBefore === true) + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut) + if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 + text = text.slice(cut) + gapBefore = true + } + state.view = { ...state.view, text, gapBefore } + state.cursor = frame.next + this.changed() + } + + /** + * Record the terminal status delivered at the end of the observation stream. + * @param id - observed activity. + * @param frame - the terminal `status` frame. + */ + observeStatus(id: ActivityId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + state.view = { + ...state.view, + status: frame.status, + ...frame.detail !== undefined ? { detail: frame.detail } : {}, + streaming: false, + } + this.changed() + } + + /** + * Record a terminal observation failure. + * @param id - observed activity. + * @param error - the stream's terminal failure. + */ + observeFailed(id: ActivityId, error: unknown): void { + const state = this.observedStates.get(String(id)) + if (state === undefined) return + state.view = { ...state.view, streaming: false, error: String(error) } + this.changed() + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed activity. + */ + observeStopped(id: ActivityId): void { + if (!this.observedStates.delete(String(id))) return + this.changed() + } + + private changed(): void { + this.snapshotDirty = true + notifySubscribers(this.listeners, 'activity feed') + } +} diff --git a/packages/api/activity-controller/src/client/service.ts b/packages/api/activity-controller/src/client/service.ts new file mode 100644 index 0000000000..a640bc8ba2 --- /dev/null +++ b/packages/api/activity-controller/src/client/service.ts @@ -0,0 +1,156 @@ +/** React-free client activity service: roster snapshot plus observation control. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-activity-controller/remote' +import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { ActivityObserveFrame } from '../types.ts' +import type { ActivityFeedSnapshot, ClientActivityModel } from './model.ts' + +/** Complete generated `ctx.remote.activity` namespace. */ +export type ActivityRemote = TypertClientRemote['activity'] + +/** Bare observable source for the client activity snapshot. */ +export interface ActivityFeedSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): ActivityFeedSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** The activity feed's client service face. */ +export interface IActivityFeed { + /** Roster rows, observation state, and arrival phase. */ + readonly state: ActivityFeedSource + /** + * Start observing one activity's live output; reference-counted, so two + * viewers of the same activity share one stream. + * @param id - activity to observe. + * @returns stop function releasing this observer's reference. + */ + observe(id: ActivityId): () => void +} + +/** One reference-counted observation stream. */ +interface ObservationEntry { + refs: number + stopped: boolean + dispose: () => Promise +} + +/** Remote face the observation runner drives. */ +type ActivityStreamRemote = Pick & { readonly activity: ActivityRemote } + +/** Owns the bare activity snapshot and per-activity observation streams. */ +export class ClientActivityFeed extends Service implements IActivityFeed { + readonly state: ActivityFeedSource + private readonly entries = new Map() + + /** + * @param ctx - client root Context. + * @param remote - generated activity namespace plus the Gateway stream factory. + * @param model - shared client activity model. + */ + constructor( + ctx: Context, + private readonly remote: ActivityStreamRemote, + private readonly model: ClientActivityModel, + ) { + super(ctx, 'activityFeed') + this.state = model + ctx.effect(() => () => { + const open = [...this.entries.values()] + this.entries.clear() + for (const entry of open) { + entry.stopped = true + void entry.dispose() + } + }, 'activity-controller.client.observations') + } + + observe(id: ActivityId): () => void { + const key = String(id) + const existing = this.entries.get(key) + if (existing !== undefined && !existing.stopped) { + existing.refs += 1 + return this.releaser(key) + } + const entry = this.startObservation(id) + this.entries.set(key, entry) + return this.releaser(key) + } + + private releaser(key: string): () => void { + let released = false + return () => { + if (released) return + released = true + const entry = this.entries.get(key) + if (entry === undefined) return + entry.refs -= 1 + if (entry.refs > 0) return + this.entries.delete(key) + entry.stopped = true + void entry.dispose().then(() => { + // The key is the stringified branded id this releaser was minted for. + this.model.observeStopped(key as ActivityId) + }) + } + } + + private startObservation(id: ActivityId): ObservationEntry { + const name = `activity observation ${String(id)}` + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id) + return this.remote.activity.observe( + { activityId: id, ...from !== undefined ? { from } : {} }, + signal, + ) + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }) + const entry: ObservationEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + const frame = item.value + if (frame.type === 'opened') { + this.model.observeOpened(id, frame) + item.accept() + continue + } + if (frame.type === 'output') { + this.model.observeOutput(id, frame) + continue + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeStatus(id, frame) + break + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } +} diff --git a/packages/api/activity-controller/src/feed.ts b/packages/api/activity-controller/src/feed.ts new file mode 100644 index 0000000000..34091dad8d --- /dev/null +++ b/packages/api/activity-controller/src/feed.ts @@ -0,0 +1,184 @@ +/** Reconnect-safe activity roster producer over the optional `ctx.activities` registry. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import type { ActivityControlFrame, ActivityRow } from './types.ts' + +/** + * Project one registry snapshot into its browser-safe roster row. + * @param snapshot - fresh registry snapshot. + * @returns detached wire row for Remote consumers. + */ +export function activityRow(snapshot: ActivitySnapshot): ActivityRow { + return { + id: snapshot.id, + kind: snapshot.kind, + label: snapshot.label, + ...snapshot.ownerSession !== undefined ? { sessionId: snapshot.ownerSession } : {}, + ...snapshot.correlation !== undefined + ? { + correlation: { + ...snapshot.correlation.callId !== undefined ? { callId: snapshot.correlation.callId } : {}, + ...snapshot.correlation.jobId !== undefined ? { jobId: snapshot.correlation.jobId } : {}, + }, + } + : {}, + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + startedAt: snapshot.startedAt, + ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, + outputTotal: snapshot.outputTotal, + } +} + +/** One owner bucket: the exact live owner used for fenced reads plus its rows. */ +interface OwnerBucket { + /** Exact registered Agent; undefined for the unowned bucket. */ + readonly owner: Agent | undefined + rows: readonly ActivityRow[] +} + +const UNOWNED = '' + +/** + * Owns registry observation, the owner-bucketed roster mirror, and all active + * control-stream generations. The mirror also answers `ownerOf`, which + * observation streams use to read owned activities through the visibility + * fence with the exact owner the registry announced. + */ +export class ActivityFeed { + private readonly followers = new Set() + private readonly buckets = new Map() + private readonly registry: ActivityRegistry | undefined + + /** @param ctx - Host context; the activity registry stays optional. */ + constructor(ctx: Context) { + this.registry = ctx.get('activities') + if (this.registry === undefined) return + const registry = this.registry + this.refresh(undefined) + for (const agent of ctx.get('agents')?.list() ?? []) this.refresh(agent) + registry.onActivitiesChanged((owner) => { + this.refresh(owner) + const bucket = this.buckets.get(owner?.id ?? UNOWNED) + this.publish({ + type: 'rows', + ...owner !== undefined ? { sessionId: owner.id } : {}, + activities: bucket?.rows ?? [], + }) + }) + ctx.effect(() => () => { + for (const follower of this.followers) follower.close() + this.followers.clear() + }, 'activity-controller.feed') + } + + /** + * Read the complete current roster synchronously. + * @returns every visible row across all owner buckets. + */ + baseline(): readonly ActivityRow[] { + return [...this.buckets.values()].flatMap(bucket => bucket.rows) + } + + /** + * Resolve the exact owner the registry announced for one activity, for + * fenced reads. `undefined` covers both unowned activities and ids this + * mirror has never seen — the registry itself settles unknown ids. + * @param id - activity id to resolve. + * @returns the exact live owner, or undefined. + */ + ownerOf(id: string): Agent | undefined { + for (const bucket of this.buckets.values()) { + if (bucket.rows.some(row => String(row.id) === id)) return bucket.owner + } + return undefined + } + + /** + * Open one generation beginning with a complete baseline. + * @param signal - generation cancellation. + * @returns baseline followed by whole-bucket replacement frames. + */ + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const follower = new ActivityFollower() + this.followers.add(follower) + try { + yield { type: 'baseline', activities: this.baseline() } + yield* follower.read(signal) + } finally { + this.followers.delete(follower) + follower.close() + } + } + + /** Re-read one owner's visible rows into its bucket; an emptied bucket is dropped. */ + private refresh(owner: Agent | undefined): void { + /* v8 ignore next -- refresh is only reachable through a live registry's change feed. */ + if (this.registry === undefined) return + const rows = this.registry.list(owner) + .filter(snapshot => owner === undefined + ? snapshot.ownerSession === undefined + : snapshot.ownerSession === owner.id) + .map(activityRow) + const key = owner?.id ?? UNOWNED + if (rows.length === 0) { + this.buckets.delete(key) + return + } + this.buckets.set(key, { owner, rows }) + } + + private publish(frame: Exclude): void { + for (const follower of this.followers) follower.push(frame) + } +} + +/* jscpd:ignore-start -- mirrors WorkspaceFeed's follower fan-out by design; both serve baseline-plus-increment Remote streams. */ +class ActivityFollower { + private readonly frames: ActivityControlFrame[] = [] + private waiting: (() => void) | undefined + private closed = false + + push(frame: ActivityControlFrame): void { + /* v8 ignore next -- closed followers are removed before later publication can reach them. */ + if (this.closed) return + this.frames.push(frame) + this.waiting?.() + } + + close(): void { + if (this.closed) return + this.closed = true + this.waiting?.() + } + + async *read(signal: AbortSignal): AsyncIterable { + while (!this.closed && !signal.aborted) { + const frame = this.frames.shift() + if (frame !== undefined) { + yield frame + continue + } + await this.wait(signal) + } + } + + private wait(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one read owns the sole installed wait callback. */ + if (this.waiting === finish) this.waiting = undefined + resolve() + } + this.waiting = finish + signal.addEventListener('abort', finish, { once: true }) + /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */ + if (signal.aborted || this.closed || this.frames.length > 0) finish() + }) + } +} +/* jscpd:ignore-end */ diff --git a/packages/api/activity-controller/src/index.ts b/packages/api/activity-controller/src/index.ts new file mode 100644 index 0000000000..a2d62c4a33 --- /dev/null +++ b/packages/api/activity-controller/src/index.ts @@ -0,0 +1,104 @@ +/** + * Host Activity Remote owner: the live-activity roster stream and per-activity + * output observation. A pure read surface over the optional `ctx.activities` + * registry — it starts nothing, cancels nothing, and never touches the + * model-facing consuming cursors. Compositions without the registry serve an + * empty roster and refuse observation with a stable error. + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-activity' +import { ActivityFeed } from './feed.ts' +import { observeActivity } from './observe.ts' +import type { ActivityControlFrame, ActivityObserveFrame, ActivityObserveRequest } from './types.ts' + +export type * from './types.ts' + +/** Default coalescing window between observation reads, in milliseconds. */ +const DEFAULT_FLUSH_MS = 100 + +/** Default soft byte budget per observation output frame. */ +const DEFAULT_MAX_FRAME_BYTES = 64 * 1024 + +/** Configuration for the Activity Remote owner. */ +export interface Config { + /** Coalescing window after new output before an observation read, in milliseconds (default 100). */ + flushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + maxFrameBytes?: number +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host Activity read API and Remote namespace owner. */ + activityController: ActivityController + } +} + +/** Host service backing the generated `ctx.remote.activity` namespace. */ +export class ActivityController extends TypertRemoteService { + static inject = ['typert'] + + static Config: z = z.object({ + flushMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_FLUSH_MS), + maxFrameBytes: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(DEFAULT_MAX_FRAME_BYTES), + }) + + private readonly feed: ActivityFeed + private readonly flushMs: number + private readonly maxFrameBytes: number + + /** + * @param ctx - Host context; the activity registry stays optional. + * @param config - validated coalescing and framing bounds. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'activityController', { namespace: 'activity' }) + this.flushMs = (config as Required).flushMs + this.maxFrameBytes = (config as Required).maxFrameBytes + this.feed = new ActivityFeed(ctx) + } + + /** + * Stream a complete roster baseline followed by whole-bucket replacements. + * @param signal - generation cancellation. + * @returns baseline followed by per-owner roster replacement frames. + */ + @Remote({ mode: 'stream' }) + control(signal: AbortSignal): AsyncIterable { + return this.feed.follow(signal) + } + + /** + * Stream one activity's retained output from an absolute byte offset, then + * its terminal status once settled and drained. Non-consuming: the + * model-facing cursors never observe these reads. + * @param request - target activity and optional resume offset. + * @param signal - generation cancellation. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: 'stream' }) + observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable { + const registry = this.ctx.get('activities') + if (registry === undefined) { + throw new Error('activity observation unavailable: no activity registry is loaded (load @deepseek-ai/dsh-activity-local)') + } + return observeActivity(registry, request, { + flushMs: this.flushMs, + maxFrameBytes: this.maxFrameBytes, + ownerOf: id => this.feed.ownerOf(id), + }, signal) + } +} + +export default ActivityController diff --git a/packages/api/activity-controller/src/invariant.ts b/packages/api/activity-controller/src/invariant.ts new file mode 100644 index 0000000000..f85e7460b8 --- /dev/null +++ b/packages/api/activity-controller/src/invariant.ts @@ -0,0 +1,23 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-activity-controller/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-api-activity-controller' + +/** Cordis companion plugin name. */ +export const name = 'api-activity-controller-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns snapshot relations; every roster + * generation is a full projection and observation reads are validated by the registry itself. + */ +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 */ diff --git a/packages/api/activity-controller/src/observe.ts b/packages/api/activity-controller/src/observe.ts new file mode 100644 index 0000000000..f793b11496 --- /dev/null +++ b/packages/api/activity-controller/src/observe.ts @@ -0,0 +1,178 @@ +/** Per-activity observation generations: anchor, coalesced output, terminal status. */ + +import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActivityObserveFrame, ActivityObserveRequest, ActivityWireChunk } from './types.ts' + +/** Cadence and framing bounds for one observation generation. */ +export interface ObserveOptions { + /** Coalescing window after a wake before reading, in milliseconds. */ + readonly flushMs: number + /** + * Soft byte budget per `output` frame. Accumulated chunks flush once the + * budget is met; one chunk larger than the budget ships whole (the + * registry's retention cap is the hard bound on any single chunk). + */ + readonly maxFrameBytes: number + /** Resolve the exact owner for fenced reads (the roster mirror's `ownerOf`). */ + readonly ownerOf: (id: string) => Agent | undefined +} + +function isTerminal(status: ActivitySnapshot['status']): boolean { + return status !== 'running' +} + +/** Wake-flag waiter: a wake between waits is never lost. */ +class OutputWaiter { + private dirty = false + private resolve: (() => void) | undefined + + wake(): void { + this.dirty = true + this.resolve?.() + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false + return Promise.resolve() + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined + this.dirty = false + resolve() + } + this.resolve = finish + signal.addEventListener('abort', finish, { once: true }) + }) + } +} + +function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} + +/** + * Stream one activity's retained output from an absolute offset: one `opened` + * anchor, coalesced `output` frames as the stream advances, then one terminal + * `status` after the settled activity is drained, after which the generation + * closes normally. Reads are non-consuming; reconnecting callers resume by + * passing the last frame's `next` as `from`. + * @param registry - the live activity registry. + * @param request - target activity and optional resume offset. + * @param options - cadence, framing bounds, and owner resolution. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the observation frame sequence for one generation. + */ +export async function* observeActivity( + registry: ActivityRegistry, + request: ActivityObserveRequest, + options: ObserveOptions, + signal: AbortSignal, +): AsyncIterable { + if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { + throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) + } + signal.throwIfAborted() + const id = ActivityId(String(request.activityId)) + const waiter = new OutputWaiter() + // Subscribe before the first read so an append between the anchor read and + // the wait cannot be missed. + const unsubscribe = registry.onOutput((changed) => { + if (changed === id) waiter.wake() + }) + try { + const caller = options.ownerOf(String(id)) + let snapshot = registry.get(id, caller) + let cursor = request.from ?? snapshot.outputEarliest + yield { + type: 'opened', + activityId: id, + from: cursor, + earliest: snapshot.outputEarliest, + total: snapshot.outputTotal, + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + } + while (!signal.aborted) { + const read = registry.read(id, cursor, caller) + if (read.chunks.length > 0) { + yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) + } + cursor = read.next + snapshot = registry.get(id, caller) + if (isTerminal(snapshot.status) && cursor >= snapshot.outputTotal) { + yield { + type: 'status', + status: snapshot.status, + ...snapshot.detail !== undefined ? { detail: snapshot.detail } : {}, + // Settlement always stamps finishedAt; the guard only discharges the optional field type. + /* v8 ignore next */ + ...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {}, + } + return + } + await waiter.wait(signal) + // Let a burst accumulate so producer chatter becomes bounded frames. + await sleep(options.flushMs, signal) + } + } finally { + unsubscribe() + } +} + +/** Split one read into frames along the soft per-frame byte budget. */ +function* outputFrames( + chunks: readonly ActivityWireChunk[], + next: number, + lossy: boolean, + maxFrameBytes: number, +): Iterable { + let batch: ActivityWireChunk[] = [] + let batchBytes = 0 + let flaggedLossy = lossy + for (const chunk of chunks) { + batch.push(chunk) + batchBytes += Buffer.byteLength(chunk.text, 'utf8') + if (batchBytes >= maxFrameBytes) { + const last = batch[batch.length - 1] + /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ + const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8') + /* v8 ignore stop */ + yield { + type: 'output', + chunks: batch, + next: end, + ...flaggedLossy ? { lossy: true as const } : {}, + } + flaggedLossy = false + batch = [] + batchBytes = 0 + } + } + if (batch.length > 0 || flaggedLossy) { + yield { + type: 'output', + chunks: batch, + next, + ...flaggedLossy ? { lossy: true as const } : {}, + } + } +} diff --git a/packages/api/activity-controller/src/types.ts b/packages/api/activity-controller/src/types.ts new file mode 100644 index 0000000000..2116d4477b --- /dev/null +++ b/packages/api/activity-controller/src/types.ts @@ -0,0 +1,104 @@ +/** Browser-safe roster and observation vocabulary for the Activity Remote. */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ActivityId } from '@deepseek-ai/dsh-activity/brand' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' + +export type { ActivityId } from '@deepseek-ai/dsh-activity/brand' + +/** Lifecycle state carried on the wire; mirrors the registry's status union. */ +export type ActivityRowStatus = 'running' | 'completed' | 'failed' | 'killed' + +/** Links one roster row to the tool call and background job for the same work. */ +export interface ActivityRowCorrelation { + /** Tool call that started the work, when a tool produced it. */ + readonly callId?: CallId + /** Background job carrying the model-facing control surface, when one exists. */ + readonly jobId?: JobId +} + +/** + * Browser-safe activity row. `kind` is `string` on the wire because the kind + * map is merge-extensible by producer plugins; presentation falls through a + * documented default for an unrecognized kind. + */ +export interface ActivityRow { + readonly id: ActivityId + readonly kind: string + readonly label: string + /** Owning session for grouping; absent for an unowned activity, visible to every session. */ + readonly sessionId?: SessionId + readonly correlation?: ActivityRowCorrelation + readonly status: ActivityRowStatus + readonly detail?: string + readonly startedAt: number + readonly finishedAt?: number + /** Total UTF-8 bytes the activity has produced so far. */ + readonly outputTotal: number +} + +/** + * Roster stream frames. Each generation starts with exactly one complete + * baseline; later frames replace one owner bucket wholesale (absent + * `sessionId` = the unowned bucket), so start, settlement, removal, and a + * second browser tab all converge through one authoritative value. + */ +export type ActivityControlFrame = + | { readonly type: 'baseline'; readonly activities: readonly ActivityRow[] } + | { readonly type: 'rows'; readonly sessionId?: SessionId; readonly activities: readonly ActivityRow[] } + +/** Opens one observation stream over a single activity's retained output. */ +export interface ActivityObserveRequest { + readonly activityId: ActivityId + /** + * Absolute byte offset to resume from — a previous frame's `next`. Omitted + * starts at the oldest retained byte. + */ + readonly from?: number +} + +/** One retained output chunk on the wire. `channel` widens to string like `kind`. */ +export interface ActivityWireChunk { + /** Absolute offset of the chunk's first byte. */ + readonly at: number + readonly text: string + readonly channel?: string + /** Bytes immediately before this chunk were lost at the producer or to retention. */ + readonly gapBefore?: true +} + +/** + * Observation stream frames: one `opened` anchor, then coalesced `output` + * batches, then — once the activity has settled and its retained output is + * drained — one terminal `status`, after which the stream closes normally. + * Status rides the same stream as output so settlement can never race a + * still-open output channel. + */ +export type ActivityObserveFrame = + | { + readonly type: 'opened' + readonly activityId: ActivityId + /** Offset the first `output` frame continues from. */ + readonly from: number + /** Oldest retained byte at open time; `from < earliest` means the head is gone. */ + readonly earliest: number + /** Total bytes produced at open time. */ + readonly total: number + readonly status: ActivityRowStatus + readonly detail?: string + } + | { + readonly type: 'output' + readonly chunks: readonly ActivityWireChunk[] + /** Offset to resume from after this frame. */ + readonly next: number + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true + } + | { + readonly type: 'status' + readonly status: ActivityRowStatus + readonly detail?: string + readonly finishedAt?: number + } diff --git a/packages/api/activity-controller/tests/activity-controller.host.spec.ts b/packages/api/activity-controller/tests/activity-controller.host.spec.ts new file mode 100644 index 0000000000..757a0af115 --- /dev/null +++ b/packages/api/activity-controller/tests/activity-controller.host.spec.ts @@ -0,0 +1,306 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { ActivityId } from '@deepseek-ai/dsh-activity' +import { ActivityFeed } from '../src/feed.ts' +import { observeActivity } from '../src/observe.ts' +import type { ObserveOptions } from '../src/observe.ts' +import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' + +async function harness(withRegistry = true) { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + if (withRegistry) await ctx.plugin(LocalActivityRegistry, { retainBytes: 64, settledRetainBytes: 64 }) + return ctx +} + +function registerAgent(ctx: Context, rawId: string): Agent { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(rawId) + const session = Session.create(id) + const agent = { + id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scopeFiber.ctx, + } as unknown as Agent + ctx.agents.register(agent) + return agent +} + +/** Read frames until `count` arrive or the generator finishes, then abort. */ +async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +const OBSERVE: Omit = { flushMs: 5, maxFrameBytes: 1024 } + +describe('ActivityFeed roster', () => { + it('serves an empty baseline without the registry', async () => { + const ctx = await harness(false) + const feed = new ActivityFeed(ctx) + expect(feed.baseline()).toEqual([]) + }) + + it('projects owned and unowned rows into the baseline with correlation and no live objects', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + ctx.activities.open({ kind: 'bash', label: 'unowned build' }) + ctx.activities.open({ + kind: 'bash', + label: 'owned build', + owner, + correlation: { jobId: 'bash-7' as never }, + }) + const feed = new ActivityFeed(ctx) + const rows = feed.baseline() + expect(rows).toHaveLength(2) + const owned = rows.find(row => row.sessionId !== undefined) + expect(owned?.sessionId).toBe('alice') + expect(owned?.correlation).toEqual({ jobId: 'bash-7' }) + expect(owned?.status).toBe('running') + for (const row of rows) { + expect(Object.keys(row)).not.toContain('owner') + expect(row.outputTotal).toBe(0) + } + }) + + it('pushes whole-bucket frames for open, detail, settlement, and removal', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const feed = new ActivityFeed(ctx) + const abort = new AbortController() + const frames = collect(feed.follow(abort.signal), 4, abort) + await new Promise(resolve => setTimeout(resolve, 0)) + const handle = ctx.activities.open({ kind: 'bash', label: 'work', owner }) + handle.updateDetail('halfway') + handle.end({ status: 'completed', detail: 'exit code: 0' }) + const seen = await frames + expect(seen[0]).toEqual({ type: 'baseline', activities: [] }) + const rows = seen.slice(1) as Extract[] + expect(rows.every(frame => frame.sessionId === 'alice')).toBe(true) + expect(rows[0]?.activities[0]?.status).toBe('running') + expect(rows[1]?.activities[0]?.detail).toBe('halfway') + expect(rows[2]?.activities[0]?.status).toBe('completed') + expect(feed.ownerOf(String(rows[0]!.activities[0]!.id))).toBe(owner) + }) + + it('projects callId-only correlation and serves rosters without an agent registry', async () => { + const bare = new Context() + await bare.plugin(LocalActivityRegistry) + bare.activities.open({ kind: 'bash', label: 'lone', correlation: { callId: 'call-1' as never } }) + const feed = new ActivityFeed(bare) + expect(feed.baseline()[0]?.correlation).toEqual({ callId: 'call-1' }) + }) + + it('pushes an empty bucket frame when owner disposal removes the last row', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const feed = new ActivityFeed(ctx) + ctx.activities.open({ kind: 'bash', label: 'doomed', owner }) + const abort = new AbortController() + const frames = collect(feed.follow(abort.signal), 3, abort) + await new Promise(resolve => setTimeout(resolve, 0)) + await (owner.ctx as unknown as { fiber: { dispose(): Promise } }).fiber?.dispose?.() + // Owner-disposal removal reaches the feed as an emptied bucket. + await new Promise(resolve => setTimeout(resolve, 10)) + abort.abort() + const seen = await frames + const last = seen.at(-1) as Extract | undefined + if (last !== undefined && last.type === 'rows') expect(Array.isArray(last.activities)).toBe(true) + }) + + it('resolves undefined for unowned and unknown activities', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'shared' }) + expect(feed.ownerOf(String(handle.id))).toBeUndefined() + expect(feed.ownerOf('bash-999')).toBeUndefined() + }) +}) + +describe('observeActivity', () => { + async function observed(ctx: Context, feed: ActivityFeed, request: { activityId: ActivityId; from?: number }) { + const abort = new AbortController() + const frames: ActivityObserveFrame[] = [] + const stream = observeActivity( + ctx.activities, + request, + { ...OBSERVE, ownerOf: id => feed.ownerOf(id) }, + abort.signal, + ) + const done = (async () => { + for await (const frame of stream) frames.push(frame) + })() + return { abort, frames, done } + } + + it('anchors, streams coalesced output, and closes with the terminal status', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('early ') + const { frames, done } = await observed(ctx, feed, { activityId: handle.id }) + await new Promise(resolve => setTimeout(resolve, 20)) + handle.append('live', { channel: 'stderr' }) + await new Promise(resolve => setTimeout(resolve, 20)) + handle.end({ status: 'completed', detail: 'exit code: 0' }) + await done + + expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' }) + const outputs = frames.filter(frame => frame.type === 'output') + const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') + expect(text).toBe('early live') + expect(outputs.at(-1)?.next).toBe(10) + expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') + expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' }) + }) + + it('resumes from an explicit offset and flags an evicted resume lossy', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'spam' }) + handle.append('a'.repeat(60)) + handle.append('b'.repeat(60)) + handle.end({ status: 'completed' }) + + const fresh = await observed(ctx, feed, { activityId: handle.id, from: 0 }) + await fresh.done + const output = fresh.frames.find(frame => frame.type === 'output') + expect(output?.lossy).toBe(true) + expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60)) + + const resumed = await observed(ctx, feed, { activityId: handle.id, from: 120 }) + await resumed.done + expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false) + expect(resumed.frames.at(-1)?.type).toBe('status') + }) + + it('splits oversized reads along the frame budget', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const registry = ctx.activities + const handle = registry.open({ kind: 'bash', label: 'wide' }) + const abort = new AbortController() + handle.append('x'.repeat(30)) + handle.append('y'.repeat(30)) + handle.end({ status: 'completed' }) + const frames = await collect( + observeActivity(registry, { activityId: handle.id }, { + flushMs: 5, + maxFrameBytes: 30, + ownerOf: id => feed.ownerOf(id), + }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs).toHaveLength(2) + expect(outputs[0]?.next).toBe(30) + expect(outputs[1]?.next).toBe(60) + }) + + it('carries the lossy flag on the first frame of a split evicted read', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const registry = ctx.activities + const handle = registry.open({ kind: 'bash', label: 'evicted split' }) + handle.append('a'.repeat(40)) + handle.append('b'.repeat(40)) + handle.append('c'.repeat(40)) + handle.end({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeActivity(registry, { activityId: handle.id, from: 0 }, { + flushMs: 5, + maxFrameBytes: 40, + ownerOf: id => feed.ownerOf(id), + }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs[0]?.lossy).toBe(true) + expect(outputs[1]?.lossy).toBeUndefined() + }) + + it('reads owned activities through the roster owner and rejects bad offsets and unknown ids', async () => { + const ctx = await harness() + const owner = registerAgent(ctx, 'alice') + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'secret', owner }) + handle.append('classified') + handle.end({ status: 'completed' }) + const { frames, done } = await observed(ctx, feed, { activityId: handle.id }) + await done + expect(frames.some(frame => frame.type === 'output' + && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) + + const abort = new AbortController() + await expect(collect( + observeActivity(ctx.activities, { activityId: handle.id, from: -1 }, { + ...OBSERVE, + ownerOf: id => feed.ownerOf(id), + }, abort.signal), + 1, + abort, + )).rejects.toThrow(/invalid observe offset/) + + const abortUnknown = new AbortController() + await expect(collect( + observeActivity(ctx.activities, { activityId: ActivityId('bash-999') }, { + ...OBSERVE, + ownerOf: id => feed.ownerOf(id), + }, abortUnknown.signal), + 1, + abortUnknown, + )).rejects.toThrow(/unknown activity/) + }) + + it('ignores output signals for other activities while waiting', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const watched = ctx.activities.open({ kind: 'bash', label: 'watched' }) + const noisy = ctx.activities.open({ kind: 'bash', label: 'noisy' }) + const { abort, frames, done } = await observed(ctx, feed, { activityId: watched.id }) + await new Promise(resolve => setTimeout(resolve, 15)) + noisy.append('unrelated') + await new Promise(resolve => setTimeout(resolve, 15)) + watched.end({ status: 'completed' }) + await done + abort.abort() + expect(frames.filter(frame => frame.type === 'output')).toEqual([]) + }) + + it('stops cleanly on abort while waiting for output', async () => { + const ctx = await harness() + const feed = new ActivityFeed(ctx) + const handle = ctx.activities.open({ kind: 'bash', label: 'idle' }) + const { abort, frames, done } = await observed(ctx, feed, { activityId: handle.id }) + await new Promise(resolve => setTimeout(resolve, 20)) + abort.abort() + await done + expect(frames[0]?.type).toBe('opened') + expect(frames.at(-1)?.type).not.toBe('status') + }) +}) diff --git a/packages/api/activity-controller/tests/client-apply.client.spec.ts b/packages/api/activity-controller/tests/client-apply.client.spec.ts new file mode 100644 index 0000000000..231703ba13 --- /dev/null +++ b/packages/api/activity-controller/tests/client-apply.client.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' +import { apply, createActivityRosterStream } from '../src/client/index.ts' +import { ClientActivityModel } from '../src/client/model.ts' +import type { ActivityControlFrame, ActivityId, ActivityRow } from '../src/types.ts' + +function row(): ActivityRow { + return { + id: 'bash-1' as ActivityId, + kind: 'bash', + label: 'build', + status: 'running', + startedAt: 1, + outputTotal: 0, + } +} + +/** One scripted roster stream: frames pushed by the test, never reopened. */ +class FakeStream { + disposed = false + private readonly frames: ActivityControlFrame[] = [] + private waiter: (() => void) | undefined + private failure: Error | undefined + + push(frame: ActivityControlFrame): void { + this.frames.push(frame) + this.waiter?.() + } + + poison(error: Error): void { + this.failure = error + this.waiter?.() + } + + dispose(): Promise { + this.disposed = true + this.waiter?.() + return Promise.resolve() + } + + async *[Symbol.asyncIterator]() { + while (!this.disposed) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame === undefined) { + await new Promise((resolve) => { this.waiter = resolve }) + continue + } + yield { generation: 1, value: frame, signal: new AbortController().signal, accept: () => {} } + } + } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('activity-controller client plugin', () => { + it('installs ctx.activityFeed and folds roster frames from the control stream', async () => { + const ctx = new Context() + const streams: { options: { open: (signal: AbortSignal) => unknown; ended: (accepted: boolean) => Error }; stream: FakeStream }[] = [] + const controlCalls: AbortSignal[] = [] + ctx.provide('remote', { + $stream: (options: never) => { + const stream = new FakeStream() + streams.push({ options, stream }) + return stream + }, + activity: { + control: (signal: AbortSignal) => { + controlCalls.push(signal) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + observe: () => { throw new Error('not exercised') }, + }, + } as never) + const fiber = ctx.plugin({ inject: ['remote'], apply }) + await fiber + + streams[0]!.stream.push({ type: 'baseline', activities: [row()] }) + streams[0]!.stream.push({ type: 'rows', activities: [] }) + await tick() + const snapshot = ctx.activityFeed.state.getSnapshot() + expect(snapshot.phase).toBe('ready') + expect(snapshot.rowsBySession['']).toBeUndefined() + + // The generation opener and end classification belong to the domain. + streams[0]!.options.open(new AbortController().signal) + expect(controlCalls).toHaveLength(1) + expect(streams[0]!.options.ended(true)).toBeInstanceOf(RemoteStreamCarrierError) + expect(streams[0]!.options.ended(false)).toBeInstanceOf(Error) + + // A terminal roster failure keeps the last snapshot in place. + streams[0]!.stream.poison(new Error('roster stream broke')) + await tick() + expect(ctx.activityFeed.state.getSnapshot().phase).toBe('ready') + + await fiber.dispose() + expect(streams[0]!.stream.disposed).toBe(true) + }) + + it('keeps the last roster on a terminal stream failure', async () => { + const model = new ClientActivityModel() + const remote = { + $stream: () => new FakeStream(), + activity: { control: () => { throw new Error('unused') }, observe: () => { throw new Error('unused') } }, + } + const stream = createActivityRosterStream(remote as never, model) + model.replaceBaseline([row()]) + stream.start() + await stream.dispose() + expect(model.getSnapshot().rowsBySession['']?.[0]?.label).toBe('build') + }) +}) diff --git a/packages/api/activity-controller/tests/client-model.client.spec.ts b/packages/api/activity-controller/tests/client-model.client.spec.ts new file mode 100644 index 0000000000..59ec9067c5 --- /dev/null +++ b/packages/api/activity-controller/tests/client-model.client.spec.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { ClientActivityModel } from '../src/client/model.ts' +import type { ActivityId, ActivityRow } from '../src/types.ts' + +function row(over: Partial = {}): ActivityRow { + return { + id: 'bash-1' as ActivityId, + kind: 'bash', + label: 'build', + sessionId: 'alice' as ActivityRow['sessionId'], + status: 'running', + startedAt: 1, + outputTotal: 0, + ...over, + } as ActivityRow +} + +/** A row with no owner session, as the unowned bucket carries them. */ +function unownedRow(over: Partial = {}): ActivityRow { + const { sessionId: _drop, ...rest } = row(over) + return rest +} + +const ID = 'bash-1' as ActivityId + +function opened(model: ClientActivityModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { + model.observeOpened(ID, { + type: 'opened', + activityId: ID, + from: 0, + earliest: 0, + total: 0, + status: 'running', + ...over, + }) +} + +describe('ClientActivityModel roster', () => { + it('folds baselines into per-session buckets and reports readiness', () => { + const model = new ClientActivityModel() + expect(model.getSnapshot().phase).toBe('pending') + model.replaceBaseline([row(), unownedRow({ id: 'workflow-1' as ActivityId })]) + const snapshot = model.getSnapshot() + expect(snapshot.phase).toBe('ready') + expect(snapshot.rowsBySession['alice']).toHaveLength(1) + expect(snapshot.rowsBySession['']).toHaveLength(1) + }) + + it('replaces buckets wholesale and drops emptied keys', () => { + const model = new ClientActivityModel() + model.replaceBaseline([row()]) + model.replaceBucket('alice', [row({ status: 'completed', finishedAt: 2 })]) + expect(model.getSnapshot().rowsBySession['alice']?.[0]?.status).toBe('completed') + model.replaceBucket('alice', []) + expect('alice' in model.getSnapshot().rowsBySession).toBe(false) + model.replaceBucket(undefined, [unownedRow()]) + expect(model.getSnapshot().rowsBySession['']).toHaveLength(1) + }) + + it('keeps snapshot identity stable between changes and notifies subscribers', () => { + const model = new ClientActivityModel() + let notified = 0 + const unsubscribe = model.subscribe(() => { notified += 1 }) + const before = model.getSnapshot() + expect(model.getSnapshot()).toBe(before) + model.replaceBaseline([row()]) + expect(notified).toBe(1) + expect(model.getSnapshot()).not.toBe(before) + unsubscribe() + model.replaceBucket('alice', []) + expect(notified).toBe(1) + }) +}) + +describe('ClientActivityModel observation', () => { + it('accumulates output, advances the resume cursor, and settles on status', () => { + const model = new ClientActivityModel() + opened(model) + expect(model.cursorOf(ID)).toBe(0) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) + expect(model.cursorOf(ID)).toBe(3) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text).toBe('abc') + expect(view?.streaming).toBe(true) + model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' }) + const settled = model.getSnapshot().observed[String(ID)] + expect(settled?.streaming).toBe(false) + expect(settled?.status).toBe('completed') + expect(settled?.detail).toBe('exit code: 0') + }) + + it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { + const model = new ClientActivityModel() + opened(model, { from: 4, earliest: 8, total: 10 }) + expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const clean = new ClientActivityModel() + opened(clean) + clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) + expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const lossy = new ClientActivityModel() + opened(lossy) + lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) + expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('bounds the render tail without splitting a surrogate pair', () => { + const model = new ClientActivityModel() + opened(model) + const emoji = '😀'.repeat((64 * 1024) + 8) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.gapBefore).toBe(true) + expect(view!.text.length).toBeLessThanOrEqual(128 * 1024) + // The bound landed between pairs: the surviving text still round-trips. + expect(/^(?:😀)+$/u.test(view!.text)).toBe(true) + }) + + it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => { + const model = new ClientActivityModel() + model.observeOpened(ID, { + type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon', + }) + expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon') + const long = 'x'.repeat((128 * 1024) + 5) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text.length).toBe(128 * 1024) + expect(view?.gapBefore).toBe(true) + }) + + it('advances the cut past a low surrogate landing exactly on the bound', () => { + const model = new ClientActivityModel() + opened(model) + // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. + const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.text.length).toBe((128 * 1024) - 1) + expect(view?.text.startsWith('a')).toBe(true) + }) + + it('preserves accumulated text across a reconnect anchor and clears on stop', () => { + const model = new ClientActivityModel() + opened(model) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) + opened(model, { from: 4, earliest: 0, total: 4 }) + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept') + model.observeStopped(ID) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + expect(model.cursorOf(ID)).toBeUndefined() + // A second stop is inert. + model.observeStopped(ID) + }) + + it('records a terminal stream failure on the live view', () => { + const model = new ClientActivityModel() + opened(model) + model.observeFailed(ID, new Error('carrier gone')) + const view = model.getSnapshot().observed[String(ID)] + expect(view?.streaming).toBe(false) + expect(view?.error).toContain('carrier gone') + // A failure for an untracked id is inert. + model.observeFailed('bash-9' as ActivityId, new Error('ignored')) + }) +}) diff --git a/packages/api/activity-controller/tests/client-service.client.spec.ts b/packages/api/activity-controller/tests/client-service.client.spec.ts new file mode 100644 index 0000000000..c27e66cdd0 --- /dev/null +++ b/packages/api/activity-controller/tests/client-service.client.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { ClientActivityModel } from '../src/client/model.ts' +import { ClientActivityFeed } from '../src/client/service.ts' +import type { ActivityId, ActivityObserveFrame } from '../src/types.ts' + +const ID = 'bash-1' as ActivityId + +/** One scripted logical stream: frames are pushed by the test, never reopened. */ +class FakeStream { + disposed = false + /** Model a carrier whose disposal surfaces as an iterator throw. */ + throwOnDispose = false + private readonly frames: ActivityObserveFrame[] = [] + private waiter: (() => void) | undefined + private failure: Error | undefined + readonly accepts: number[] = [] + + push(frame: ActivityObserveFrame): void { + this.frames.push(frame) + this.waiter?.() + } + + poison(error: Error): void { + this.failure = error + this.waiter?.() + } + + dispose(): Promise { + if (this.throwOnDispose && this.failure === undefined) { + this.failure = new Error('carrier tore down') + } else { + this.disposed = true + } + this.waiter?.() + return Promise.resolve() + } + + async *[Symbol.asyncIterator]() { + let generation = 1 + while (!this.disposed) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame === undefined) { + await new Promise((resolve) => { this.waiter = resolve }) + continue + } + yield { + generation, + value: frame, + signal: new AbortController().signal, + accept: () => { this.accepts.push(generation) }, + } + generation = 1 + } + } +} + +function bench() { + const ctx = new Context() + const model = new ClientActivityModel() + const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = [] + const observeCalls: unknown[] = [] + const remote = { + $stream: (options: { open: (signal: AbortSignal) => unknown }) => { + const stream = new FakeStream() + streams.push({ options, stream }) + return stream + }, + activity: { + observe: (request: unknown) => { + observeCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + control: () => { throw new Error('not exercised') }, + }, + } + const feed = new ClientActivityFeed(ctx, remote as never, model) + return { ctx, model, feed, streams, observeCalls } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +describe('ClientActivityFeed observation streams', () => { + it('feeds anchor, output, and terminal frames into the model, then closes', async () => { + const { model, feed, streams } = bench() + feed.observe(ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') + expect(stream.accepts).toHaveLength(1) + + stream.push({ type: 'status', status: 'completed' }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) + expect(stream.disposed).toBe(true) + }) + + it('shares one stream across observers and stops after the last release', async () => { + const { model, feed, streams } = bench() + const first = feed.observe(ID) + const second = feed.observe(ID) + expect(streams).toHaveLength(1) + streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + + first() + first() + expect(streams[0]!.stream.disposed).toBe(false) + second() + await tick() + expect(streams[0]!.stream.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('opens a fresh stream for a re-observed activity and resumes from the model cursor', async () => { + const { feed, streams, observeCalls, model } = bench() + const stop = feed.observe(ID) + streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) + await tick() + // The generation opener reads the live cursor at open time. + streams[0]!.options.open(new AbortController().signal) + expect(observeCalls.at(-1)).toEqual({ activityId: ID, from: 3 }) + + stop() + await tick() + feed.observe(ID) + expect(streams).toHaveLength(2) + streams[1]!.options.open(new AbortController().signal) + // Stopped observation dropped the cursor: the fresh stream starts unanchored. + expect(observeCalls.at(-1)).toEqual({ activityId: ID }) + expect(model.cursorOf(ID)).toBeUndefined() + }) + + it('classifies a premature end as retryable only after the anchor', () => { + const { feed, streams } = bench() + feed.observe(ID) + const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error } + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('records a consumer failure on the live view', async () => { + const { model, feed, streams } = bench() + feed.observe(ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + stream.poison(new Error('frame decode broke')) + await tick() + const view = model.getSnapshot().observed[String(ID)] + expect(view?.error).toContain('frame decode broke') + expect(view?.streaming).toBe(false) + }) + + it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { + const { ctx, model, feed, streams } = bench() + const stop = feed.observe(ID) + const { stream } = streams[0]! + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + // Poison wakes the consumer, but the synchronous stop lands first, so the + // failure arrives on an already-stopped entry and stays silent. + stream.poison(new Error('after stop')) + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + + const second = feed.observe(ID) + await ctx.fiber.dispose() + // The disposal path already removed every entry; a live releaser whose + // entry is gone is inert. + second() + }) + + it('stays silent when the carrier teardown itself throws after a stop', async () => { + const { model, feed, streams } = bench() + const stop = feed.observe(ID) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + // The releaser marks the entry stopped, then its dispose surfaces as an + // iterator throw — which the consumer swallows for a stopped entry. + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() + }) + + it('records a terminal stream failure and service disposal closes live streams', async () => { + const { ctx, model, feed, streams } = bench() + feed.observe(ID) + const failing = streams[0]!.stream + failing.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) + await tick() + const iterator = failing[Symbol.asyncIterator]() + void iterator + // Simulate a terminal consumer failure by disposing the underlying stream: + // the consumer loop ends without a status frame and cleans up. + const spy = vi.spyOn(model, 'observeFailed') + await failing.dispose() + await tick() + + const other = 'bash-2' as ActivityId + feed.observe(other) + await ctx.fiber.dispose() + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/activity-controller/tests/controller.host.spec.ts b/packages/api/activity-controller/tests/controller.host.spec.ts new file mode 100644 index 0000000000..4c3262f093 --- /dev/null +++ b/packages/api/activity-controller/tests/controller.host.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { ActivityController } from '../src/index.ts' +import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts' + +async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +describe('ActivityController', () => { + it('closes live roster followers when the owning fiber unwinds', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) + const abort = new AbortController() + const iterator = controller.control(abort.signal)[Symbol.asyncIterator]() + expect((await iterator.next()).value).toMatchObject({ type: 'baseline' }) + const pending = iterator.next() + await ctx.fiber.dispose() + // The feed effect closed the follower, ending the generation cleanly. + expect((await pending).done).toBe(true) + }) + + it('coalesces bursts that land inside the flush window and carries the anchor detail', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const controller = new ActivityController(ctx, { flushMs: 30, maxFrameBytes: 1024 }) + const handle = ctx.activities.open({ kind: 'bash', label: 'burst' }) + handle.updateDetail('warming up') + const abort = new AbortController() + const frames: ActivityObserveFrame[] = [] + const done = (async () => { + for await (const frame of controller.observe({ activityId: handle.id }, abort.signal)) frames.push(frame) + })() + await new Promise(resolve => setTimeout(resolve, 10)) + handle.append('a') + await new Promise(resolve => setTimeout(resolve, 5)) + // Lands inside the flush sleep, so the waiter is already dirty at the next wait. + handle.append('b') + await new Promise(resolve => setTimeout(resolve, 80)) + handle.end({ status: 'completed' }) + await done + expect(frames[0]).toMatchObject({ type: 'opened', detail: 'warming up' }) + const text = frames.filter(frame => frame.type === 'output') + .flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') + expect(text).toBe('ab') + }) + + it('serves an empty baseline and refuses observation without the registry', async () => { + const ctx = new Context() + const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) + const abort = new AbortController() + const frames = await collect(controller.control(abort.signal), 1, abort) + expect(frames).toEqual([{ type: 'baseline', activities: [] }]) + expect(() => controller.observe({ activityId: 'bash-1' as never }, new AbortController().signal)) + .toThrow(/activity observation unavailable/) + }) + + it('streams the roster and one activity end to end over a live registry', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalActivityRegistry) + const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 }) + + const handle = ctx.activities.open({ kind: 'bash', label: 'echo' }) + handle.append('hi') + handle.end({ status: 'completed', detail: 'exit code: 0' }) + + const rosterAbort = new AbortController() + const roster = await collect(controller.control(rosterAbort.signal), 1, rosterAbort) + expect(roster[0]).toMatchObject({ type: 'baseline' }) + expect((roster[0] as Extract).activities[0]?.label).toBe('echo') + + const observeAbort = new AbortController() + const observed = await collect( + controller.observe({ activityId: handle.id }, observeAbort.signal), + 3, + observeAbort, + ) + expect(observed.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) +}) diff --git a/packages/api/activity-controller/tsconfig.client.json b/packages/api/activity-controller/tsconfig.client.json new file mode 100644 index 0000000000..d2934629b4 --- /dev/null +++ b/packages/api/activity-controller/tsconfig.client.json @@ -0,0 +1,46 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/client/service.ts", + "src/types.ts" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../gateway/tsconfig.client.json" + }, + { + "path": "../../client/connection/tsconfig.client.json" + }, + { + "path": "../../client/store" + }, + { + "path": "../../activity/activity" + }, + { + "path": "../../core/session" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../typert/protocol" + } + ] +} diff --git a/packages/api/activity-controller/tsconfig.host.json b/packages/api/activity-controller/tsconfig.host.json new file mode 100644 index 0000000000..4193d47109 --- /dev/null +++ b/packages/api/activity-controller/tsconfig.host.json @@ -0,0 +1,26 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/invariant.ts", + "src/types.ts", + "src/feed.ts", + "src/observe.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../activity/activity" }, + { "path": "../../core/agent" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../llm/llm" }, + { "path": "../../runtime-diagnostics/invariants" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/activity-controller/tsconfig.json b/packages/api/activity-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/activity-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/activity-controller/tsdown.config.ts b/packages/api/activity-controller/tsdown.config.ts new file mode 100644 index 0000000000..0552d1b1cf --- /dev/null +++ b/packages/api/activity-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-activity-controller', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0724012e77..05c8dbe032 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -61,6 +61,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", @@ -82,6 +83,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 528d78dc89..388d7e1fb6 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -10,6 +10,7 @@ import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' +import activityRemote from '@deepseek-ai/dsh-api-activity-controller/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -24,6 +25,8 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' +export type {} from '@deepseek-ai/dsh-api-activity-controller/remote' +export type * from '@deepseek-ai/dsh-api-activity-controller/types' export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. @@ -135,7 +138,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, - sessionRemote, workspaceRemote, + sessionRemote, workspaceRemote, activityRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 4d0256e162..7cd03cfd21 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -25,6 +25,7 @@ import type {} from '@deepseek-ai/dsh-settings/types' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-user-questions' export type {} from '@deepseek-ai/dsh-api-session-controller/types' +export type {} from '@deepseek-ai/dsh-api-activity-controller/types' export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' export type { ApiRemoteForwardedEvent } from './types.ts' diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index eb98174378..c24ed4bca5 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -63,6 +63,9 @@ { "path": "../session-controller/tsconfig.client.json" }, + { + "path": "../activity-controller/tsconfig.client.json" + }, { "path": "../workspace-controller/tsconfig.client.json" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 4dd513bdeb..0b0841c2b4 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -54,6 +54,9 @@ { "path": "../session-controller/tsconfig.host.json" }, + { + "path": "../activity-controller/tsconfig.host.json" + }, { "path": "../workspace-controller/tsconfig.host.json" }, diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 981e791fb4..3e2f48264a 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -81,6 +81,11 @@ - id: jobs name: '@deepseek-ai/dsh-jobs-local' + # Live-activity observation registry: non-consuming output streams for the + # optional activity plane (background bash/pwsh mirror into it when loaded). + - id: activity + name: '@deepseek-ai/dsh-activity-local' + - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 80f33257db..e6ad006201 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -41,11 +41,13 @@ "dependencies": { "@deepseek-ai/cordis-plugin-hmr": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/dsh-activity-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment-local": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-feedback": "workspace:^", @@ -55,19 +57,19 @@ "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", @@ -82,6 +84,7 @@ "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-prompt-llm": "workspace:^", "@deepseek-ai/dsh-settings-file": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-badge": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", @@ -92,13 +95,13 @@ "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-pwsh": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", @@ -106,7 +109,6 @@ "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tool-subagent-report": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", @@ -117,8 +119,7 @@ "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", - "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^" + "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 18a0d3911a..9fa12bc33f 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -109,6 +109,10 @@ - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' + # Live-activity roster and per-activity output observation over Typert Remote. + - id: activity-controller + name: '@deepseek-ai/dsh-api-activity-controller' + # The API gateway: the transport-agnostic dispatch face every client shape # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway @@ -273,6 +277,11 @@ - id: ui-jobs name: '@deepseek-ai/dsh-client-ui-jobs' + # Live activities: the session-header roster with on-demand streaming + # output panels over the activity feed. + - id: ui-activity + name: '@deepseek-ai/dsh-client-ui-activity' + # Goal surface: GoalBar in the input dock over the goal session projection. - id: ui-goal name: '@deepseek-ai/dsh-client-ui-goal' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 68c43da265..0837c9f6b1 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -45,75 +45,77 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-activity": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", - "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", + "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-reference": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", - "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", - "@deepseek-ai/dsh-client-ui-jobs": "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-user-questions": "workspace:^", + "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-web-frontend": "workspace:^", - "@deepseek-ai/dsh-host-frontend-static": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-frontend-static": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", diff --git a/packages/client/ui-activity/README.i18n.yaml b/packages/client/ui-activity/README.i18n.yaml new file mode 100644 index 0000000000..532ea19b10 --- /dev/null +++ b/packages/client/ui-activity/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-activity/README.md +README.md: 157e98e6b14bc1b556cb2aac8f6f90c43fbcb3f2 +README.zh.md: 392cc91c105caa2f79546f818700da0e6643b8a1 diff --git a/packages/client/ui-activity/README.md b/packages/client/ui-activity/README.md new file mode 100644 index 0000000000..157e98e6b1 --- /dev/null +++ b/packages/client/ui-activity/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-activity + +English | [中文](README.zh.md) + +Session-header live-activity list over `ctx.activityFeed`: one header action beside the background-job list renders the roster rows this session can see (owned plus unowned), and expanding a row opens that activity's observation stream into an embedded terminal panel. Observation follows visibility — the stream opens on expand and closes on collapse, unmount, or popover dismissal, so output only flows while someone is watching. Retention gaps and stream failures render as notices above the panel. + +The control renders nothing until the session can see at least one activity, so an ordinary conversation never grows a control for a capability it is not using; without the activity registry the roster stays empty and the entry point never appears. + +## Model Experience + +None, as this package renders host-observed live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). + +#### KV Cache effect + +None; the package never assembles or sends provider requests. + +## Known Limitations and Deferred Work + +- **No kill control** — cancellation stays with the model's `job_kill`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. +- **A background job appears in both header lists** — the job list carries control-facing status, this list carries output; the duplication mirrors the deliberate subagent-catalog precedent. diff --git a/packages/client/ui-activity/README.zh.md b/packages/client/ui-activity/README.zh.md new file mode 100644 index 0000000000..392cc91c10 --- /dev/null +++ b/packages/client/ui-activity/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-activity + +[English](README.md) | 中文 + +架在 `ctx.activityFeed` 上的会话头部实时活动列表:在后台任务列表旁提供一个头部 action,渲染本会话可见的 roster 行(有主 + 无主),展开某行即把该 activity 的观察流打开进内嵌终端面板。观察跟随可见性——展开开流,收起、卸载或关闭弹层即停流,只有有人在看时输出才流动。保留缺口与流失败以面板上方的提示呈现。 + +会话一个 activity 都看不到时该控件完全不渲染,普通会话不会为未使用的能力多出控件;没有 activity 注册表时 roster 恒空,入口永不出现。 + +## 模型体验 + +无:本包只为人渲染宿主观察到的实时输出,不触碰任何 prompt、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 + +#### KV Cache 影响 + +无;本包从不组装或发送 provider 请求。 + +## 已知限制与暂缓事项 + +- **没有 kill 控件**——取消仍走模型的 `job_kill`;人工 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。 +- **不渲染通道标签**——stdout 与 stderr chunk 连接成一条流;分通道着色是后续呈现优化。 +- **后台 job 会同时出现在两个头部列表**——job 列表承载控制侧状态,本列表承载输出;这一重复沿用 subagent catalog 的既有取舍。 diff --git a/packages/client/ui-activity/package.json b/packages/client/ui-activity/package.json new file mode 100644 index 0000000000..83395df003 --- /dev/null +++ b/packages/client/ui-activity/package.json @@ -0,0 +1,77 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-activity", + "description": "Session-header live-activity list: roster rows from the activity control stream with on-demand streaming output panels", + "version": "0.1.1-rc.2", + "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" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-activity" + }, + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-activity-controller": "workspace:^", + "@deepseek-ai/dsh-client-locale": "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-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-activity-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives" + ], + "platform": "web" + } + } +} diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-activity/src/client/ActivityListAction.module.css new file mode 100644 index 0000000000..2cf3af6e2f --- /dev/null +++ b/packages/client/ui-activity/src/client/ActivityListAction.module.css @@ -0,0 +1,155 @@ +.root { + position: relative; +} + +.trigger { + display: inline-flex; + align-items: center; + gap: 3px; + min-height: 28px; + padding: 3px 2px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.trigger:hover, +.trigger:focus-visible { + color: var(--dsw-alias-label-secondary); +} + +.trigger svg { + transition: transform 120ms ease; +} + +.triggerOpen { + transform: rotate(180deg); +} + +.triggerDot { + flex: none; +} + +.count { + margin: 0 5px; +} + +.menu { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 100; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1px; + width: 440px; + max-width: min(520px, calc(100vw - 32px)); + max-height: min(480px, calc(100vh - 140px)); + margin: 0; + padding: 4px; + overflow: auto; + list-style: none; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-specific-menu); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + box-shadow: var(--dsw-shadow-lv3); +} + +.item { + display: flex; + flex-direction: column; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 32px; + padding: 6px 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 18px; + text-align: left; + cursor: pointer; +} + +.row:hover, +.row:focus-visible { + background: var(--dsw-alias-fill-l1); +} + +.rowSettled { + color: var(--dsw-alias-label-tertiary); +} + +.rowDot { + flex: none; +} + +.kind { + flex: none; + padding: 0 6px; + border-radius: 5px; + background: var(--dsw-alias-fill-l2); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 18px; +} + +.label { + flex: 1; + min-width: 0; + overflow: hidden; + font-family: var(--dsw-font-mono); + white-space: nowrap; + text-overflow: ellipsis; +} + +/* A failed activity's detail is the producer's raw text, so it has no bound; + without this it widens the row past the menu instead of eliding like .label. */ +.status { + flex: none; + max-width: 40%; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 18px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.chevronOpen { + transform: rotate(180deg); +} + +.panel { + margin: 0 8px 8px; +} + +.notice { + margin: 0 8px 6px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +.noticeError { + color: var(--dsw-alias-label-error, var(--dsw-alias-label-secondary)); +} diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx new file mode 100644 index 0000000000..698815e171 --- /dev/null +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -0,0 +1,250 @@ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { + ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, +} from '@deepseek-ai/dsh-api-activity-controller/client' +import { + IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer, + type StateDotState, type TerminalBlockLabels, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import css from './ActivityListAction.module.css' + +/** Registration-side business face for the activity feed. */ +export interface ActivityListInjected { + hooks: { + /** Client activity snapshot bound by the renderer as useActivity. */ + activity: { + getSnapshot(): ActivityFeedSnapshot + subscribe(listener: () => void): () => void + } + } + /** + * Start observing one activity's live output; returns the stop function. + * Reference-counted by the client feed, so panels can overlap safely. + */ + observe: (id: ActivityId) => () => void +} + +/** Full props for the session-header live-activity action. */ +export type ActivityListActionProps = + PropsRuntime<'conversation.session.header.actions'> + & PropsLocale + & InjectFace + +/** Stable empty list so a session with no activities keeps one array identity. */ +const NO_ROWS: readonly ActivityRow[] = [] + +/** Height cap for one live output panel inside the popover. */ +const PANEL_MAX_LINES = 16 + +function isLive(row: ActivityRow): boolean { + return row.status === 'running' +} + +/** Closed-union exhaustiveness fence for the wire status set. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ +function assertNever(value: never): never { + throw new Error(`unhandled activity status: ${JSON.stringify(value)}`) +} + +function dotState(status: ActivityRow['status']): StateDotState { + switch (status) { + case 'running': return 'ongoing' + case 'completed': return 'done' + case 'killed': return 'warning' + case 'failed': return 'error' + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +function statusLabel(status: ActivityRow['status'], t: TranslateNS): string { + switch (status) { + case 'running': return t('status.running') + case 'completed': return t('status.completed') + case 'killed': return t('status.killed') + case 'failed': return t('status.failed') + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +/** Localized display copy for the embedded terminal panel. */ +function terminalLabels(t: TranslateNS): TerminalBlockLabels { + return { + // The labels contract requires exit-fact formatters, but this panel never + // passes exit facts, so TerminalBlock never invokes them. + /* v8 ignore next */ + signal: signal => t('terminal.signal', { signal }), + /* v8 ignore next */ + exitCode: code => t('terminal.exitCode', { code }), + running: t('terminal.running'), + failed: t('terminal.failed'), + done: t('terminal.done'), + copy: t('terminal.copy'), + copied: t('terminal.copied'), + noOutput: t('terminal.noOutput'), + collapseAria: t('terminal.collapseAria'), + collapse: t('terminal.collapse'), + expandAria: hidden => t('terminal.expandAria', { n: hidden }), + expand: hidden => t('terminal.expand', { n: hidden }), + } +} + +/* jscpd:ignore-start -- mirrors JobListAction's ordering and trigger chrome by design so the two header lists read as one family. */ +/** Live rows first in start order, then settled rows newest-first. */ +function ordered(rows: readonly ActivityRow[]): ActivityRow[] { + return [...rows].sort((left, right) => { + const liveLeft = isLive(left) + if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 + if (liveLeft) return left.startedAt - right.startedAt + const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt) + return finished !== 0 ? finished : left.startedAt - right.startedAt + }) +} +/* jscpd:ignore-end */ + +/** One expandable roster row plus its on-demand live output panel. */ +function ActivityItem({ row, view, expanded, onToggle, t }: { + row: ActivityRow + view: ObservedActivity | undefined + expanded: boolean + onToggle: () => void + t: TranslateNS +}) { + const live = isLive(row) + const status = statusLabel(row.status, t) + const labels = useMemo(() => terminalLabels(t), [t]) + return ( +
  • + + {expanded + ? ( +
    + {view?.gapBefore === true ?
    {t('output.gap')}
    : null} + {view?.error !== undefined + ?
    {t('output.error', { error: view.error })}
    + : null} + +
    + ) + : null} +
  • + ) +} + +/** + * Session-header entry point for this session's live activities. It renders + * nothing at all until the session can see at least one activity, expanding a + * row starts its observation stream, and collapsing (or closing the popover) + * stops it — output only flows while someone is watching. + * @param props - runtime slot currency, the activity snapshot hook, the + * observation control, and the namespace translator. + * @returns the trigger and its popover list, or null when there is nothing to show. + */ +export function ActivityListAction({ sessionId, useActivity, observe, t }: ActivityListActionProps) { + const owned = useActivity(state => state.rowsBySession[sessionId]) ?? NO_ROWS + const unowned = useActivity(state => state.rowsBySession['']) ?? NO_ROWS + const observedViews = useActivity(state => state.observed) + const [open, setOpen] = useState(false) + const [expandedId, setExpandedId] = useState(undefined) + const rootRef = useRef(null) + const triggerRef = useRef(null) + + const rows = useMemo(() => ordered([...owned, ...unowned]), [owned, unowned]) + const liveCount = useMemo(() => rows.filter(isLive).length, [rows]) + + useDismissOnOutsidePointer(rootRef, open, setOpen) + + // Observation follows visibility: the stream opens when a panel expands and + // closes when it collapses, unmounts, or the popover closes. + const activeId = open ? expandedId : undefined + useEffect(() => { + if (activeId === undefined) return + return observe(activeId as ActivityId) + }, [activeId, observe]) + + // The last activity disappearing removes this control; close first so focus + // does not vanish from an unmounting node. + useEffect(() => { + if (rows.length === 0 && open) setOpen(false) + }, [rows.length, open]) + + // An expanded row that left the roster (owner disposal) folds its panel. + useEffect(() => { + if (expandedId !== undefined && !rows.some(row => String(row.id) === expandedId)) { + setExpandedId(undefined) + } + }, [rows, expandedId]) + + if (rows.length === 0) return null + + const countKey = liveCount > 0 + ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') + : (rows.length === 1 ? 'count.idle.one' : 'count.idle.other') + const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : rows.length }) + + /* jscpd:ignore-start -- mirrors JobListAction's dismiss/trigger interaction by design. */ + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || !open) return + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } + + return ( +
    + + {/* jscpd:ignore-end */} + {open + ? ( +
      + {rows.map(row => ( + { + setExpandedId(current => current === String(row.id) ? undefined : String(row.id)) + }} + t={t} + /> + ))} +
    + ) + : null} +
    + ) +} diff --git a/packages/client/ui-activity/src/client/index.ts b/packages/client/ui-activity/src/client/index.ts new file mode 100644 index 0000000000..83ce0aa7bf --- /dev/null +++ b/packages/client/ui-activity/src/client/index.ts @@ -0,0 +1,48 @@ +/** + * Live-activity plugin, browser half: contributes one session-header action + * that renders the activity roster and, per expanded row, an on-demand + * observation stream's live output. All data arrives through the + * `activityFeed` client service; this plugin holds no transport state. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { ActivityListAction } from './ActivityListAction.tsx' +import type { ActivityListInjected } from './ActivityListAction.tsx' +import type {} from '@deepseek-ai/dsh-api-activity-controller/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import { en, NS, zh, type ActivityKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Live-activity list copy. */ + 'activity': ActivityKey + } +} + +export type { ActivityListActionProps, ActivityListInjected } from './ActivityListAction.tsx' + +/** Required services: the activity feed, the slot registry, and dictionaries. */ +export const inject = ['activityFeed', 'slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the header action. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-activity: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'activity-list', + // After the background-job list: job control reads before live output. + order: 30, + locale: NS, + inject: (): ActivityListInjected => ({ + hooks: { activity: ctx.activityFeed.state }, + observe: id => ctx.activityFeed.observe(id), + }), + }, ActivityListAction), + ) +} diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-activity/src/client/locales.ts new file mode 100644 index 0000000000..efea5126da --- /dev/null +++ b/packages/client/ui-activity/src/client/locales.ts @@ -0,0 +1,65 @@ +/** `activity` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'activity' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'count.live.one': '{count} 个活动进行中', + 'count.live.other': '{count} 个活动进行中', + 'count.idle.one': '{count} 个活动', + 'count.idle.other': '{count} 个活动', + 'list.aria': '实时活动', + 'row.expandAria': '展开 {label} 的实时输出', + 'row.collapseAria': '收起 {label} 的实时输出', + 'status.running': '运行中', + 'status.completed': '已完成', + 'status.killed': '已取消', + 'status.failed': '已失败', + 'output.gap': '……较早的输出已丢弃……', + 'output.error': '实时输出流中断:{error}', + 'terminal.signal': '信号 {signal}', + 'terminal.exitCode': '退出码 {code}', + 'terminal.running': '运行中', + 'terminal.failed': '已失败', + 'terminal.done': '已完成', + 'terminal.copy': '复制', + 'terminal.copied': '已复制', + 'terminal.noOutput': '(无输出)', + 'terminal.collapse': '收起', + 'terminal.collapseAria': '收起输出', + 'terminal.expand': '展开其余 {n} 行', + 'terminal.expandAria': '展开被折叠的 {n} 行输出', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'count.live.one': '{count} live activity', + 'count.live.other': '{count} live activities', + 'count.idle.one': '{count} activity', + 'count.idle.other': '{count} activities', + 'list.aria': 'Live activities', + 'row.expandAria': 'Show live output of {label}', + 'row.collapseAria': 'Hide live output of {label}', + 'status.running': 'running', + 'status.completed': 'completed', + 'status.killed': 'cancelled', + 'status.failed': 'failed', + 'output.gap': '… earlier output dropped …', + 'output.error': 'live output stream interrupted: {error}', + 'terminal.signal': 'signal {signal}', + 'terminal.exitCode': 'exit {code}', + 'terminal.running': 'running', + 'terminal.failed': 'failed', + 'terminal.done': 'done', + 'terminal.copy': 'Copy', + 'terminal.copied': 'Copied', + 'terminal.noOutput': '(no output)', + 'terminal.collapse': 'Collapse', + 'terminal.collapseAria': 'Collapse output', + 'terminal.expand': 'Show {n} more lines', + 'terminal.expandAria': 'Expand {n} collapsed output lines', +} + +/** Key domain of the `activity` namespace (zh is the source of truth). */ +export type ActivityKey = keyof typeof zh diff --git a/packages/client/ui-activity/src/css-modules.d.ts b/packages/client/ui-activity/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-activity/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-activity/src/index.ts b/packages/client/ui-activity/src/index.ts new file mode 100644 index 0000000000..f75578ddaf --- /dev/null +++ b/packages/client/ui-activity/src/index.ts @@ -0,0 +1,9 @@ +/** + * Node half of the live-activity list plugin. The browser half in + * `src/client/` owns every contribution; this entry exists so the package + * appears as an ordinary Loader row. + * @module @deepseek-ai/dsh-client-ui-activity + */ + +/** Loader-visible no-op body; the browser half carries the feature. */ +export function apply(): void {} diff --git a/packages/client/ui-activity/src/invariant.ts b/packages/client/ui-activity/src/invariant.ts new file mode 100644 index 0000000000..22eba24c42 --- /dev/null +++ b/packages/client/ui-activity/src/invariant.ts @@ -0,0 +1,27 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-activity`. + * @module @deepseek-ai/dsh-client-ui-activity/invariant + */ + +/* 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-activity' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-activity-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: pure presentation over the activity feed's client snapshot; the feed's own packages assert the data relations. */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx new file mode 100644 index 0000000000..96156c798d --- /dev/null +++ b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx @@ -0,0 +1,244 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { + ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, +} from '@deepseek-ai/dsh-api-activity-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ActivityListAction, type ActivityListActionProps } from '../src/client/ActivityListAction.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +const SESSION = 'session' as SessionId +const t: ActivityListActionProps['t'] = makeTranslate(zh) + +function row(over: Partial = {}): ActivityRow { + return { + id: 'bash-1' as ActivityId, + kind: 'bash', + label: 'pnpm run build', + sessionId: SESSION, + status: 'running', + startedAt: 1_700_000_000_000, + outputTotal: 0, + ...over, + } +} + +/** A row with no owner session, as the unowned bucket carries them. */ +function unownedRow(over: Partial = {}): ActivityRow { + const { sessionId: _drop, ...rest } = row(over) + return rest +} + +function props( + snapshot: Partial, + observe: ActivityListActionProps['observe'] = () => () => {}, +): ActivityListActionProps { + const state: ActivityFeedSnapshot = { + rowsBySession: {}, + observed: {}, + phase: 'ready', + ...snapshot, + } + function useActivity(select: (value: ActivityFeedSnapshot) => T): T { + return select(state) + } + return { sessionId: SESSION, useActivity, observe, t } as unknown as ActivityListActionProps +} + +function openList(): void { + fireEvent.click(screen.getAllByRole('button')[0]!) +} + +describe('ActivityListAction visibility', () => { + it('renders nothing while the session sees no activities', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('counts live activities on the trigger, merging owned and unowned rows', () => { + render() + expect(screen.getByRole('button', { name: '2 个活动进行中' })).toBeDefined() + }) + + it('falls back to the total when nothing is live and hides foreign sessions', () => { + render() + expect(screen.getByRole('button', { name: '1 个活动' })).toBeDefined() + }) +}) + +describe('ActivityListAction rows and observation', () => { + it('lists rows with kind, label, and detail-or-status', () => { + render() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + const items = within(list).getAllByRole('listitem') + expect(items).toHaveLength(2) + expect(items[0]?.textContent).toContain('pnpm run build') + expect(items[0]?.textContent).toContain(zh['status.running']) + expect(items[1]?.textContent).toContain('exit code: 3') + }) + + it('marks killed rows and expands an overflowing panel through the collapse controls', () => { + const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') + const view: ObservedActivity = { + activityId: 'bash-1' as ActivityId, + text: lines, + gapBefore: false, + status: 'killed', + streaming: false, + } + render() + openList() + expect(screen.getByText(zh['status.killed'])).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + const expand = screen.getByRole('button', { name: zh['terminal.expandAria'].replace('{n}', '8') }) + fireEvent.click(expand) + expect(screen.getByRole('button', { name: zh['terminal.collapseAria'] })).toBeDefined() + }) + + it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { + const settled = (id: string, label: string, startedAt: number, finishedAt?: number): ActivityRow => + row({ + id: id as ActivityId, + label, + status: 'completed', + startedAt, + ...finishedAt !== undefined ? { finishedAt } : {}, + }) + render() + openList() + const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) + .getAllByRole('listitem') + .map(item => item.querySelector('[title]')?.getAttribute('title')) + expect(labels).toEqual([ + 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e', + ]) + expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0) + }) + + it('ignores other keys while open and closes once the roster empties', () => { + const settledPair = [ + row({ id: 'bash-1' as ActivityId, status: 'completed', finishedAt: 2 }), + row({ id: 'bash-2' as ActivityId, status: 'completed', finishedAt: 3 }), + ] + const { rerender } = render() + expect(screen.getByRole('button', { name: '2 个活动' })).toBeDefined() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + fireEvent.keyDown(list, { key: 'a' }) + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() + // The roster emptying unmounts the control entirely; the open flag resets first. + rerender() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('starts observing on expand, renders the live text, and stops on collapse', () => { + const stop = vi.fn() + const observe = vi.fn(() => stop) + const view: ObservedActivity = { + activityId: 'bash-1' as ActivityId, + text: 'compiling…\n', + gapBefore: false, + status: 'running', + streaming: true, + } + render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(observe).toHaveBeenCalledWith('bash-1') + expect(screen.getByText('compiling…')).toBeDefined() + + fireEvent.click(screen.getByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'pnpm run build') })) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('surfaces retention gaps and stream failures above the panel', () => { + const view: ObservedActivity = { + activityId: 'bash-1' as ActivityId, + text: 'tail only', + gapBefore: true, + status: 'running', + streaming: false, + error: 'connection lost', + } + render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(screen.getByText(zh['output.gap'])).toBeDefined() + expect(screen.getByText('实时输出流中断:connection lost')).toBeDefined() + }) + + it('stops observation when the popover closes via Escape', () => { + const stop = vi.fn() + render( stop)} />) + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' }) + expect(stop).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull() + }) + + it('folds an expanded panel whose row left the roster', () => { + const stop = vi.fn() + // One stable observe identity across renders, as the inject face provides. + const observe = () => stop + const first = props({ rowsBySession: { [SESSION]: [row()] } }, observe) + const { rerender } = render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + rerender() + expect(stop).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull() + }) +}) diff --git a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..de6db2a069 --- /dev/null +++ b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts @@ -0,0 +1,115 @@ +/** + * ui-activity plugin halves: the browser entry's dictionary and header-slot + * registrations against the real SlotRegistry (with fiber teardown proving + * removal — HMR safety), the inert node entry, and the invariant companion's + * ownership reservation. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import * as ActivityInvariant from '../src/invariant.ts' +import { en, NS, zh } from '../src/client/locales.ts' + +/** Slot ledger reader: entry ids currently registered in the header list. */ +function headerEntryIds(ctx: Context): (string | undefined)[] { + return ctx.slots + .entries('conversation.session.header.actions') + .map(entry => entry.options.id) +} + +/** Ids handed to the stubbed feed's observe control. */ +const observed: string[] = [] + +/** Boot the browser half over a real slot tree that declares the header list. */ +async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + ctx.slots.register({ + name: 'root', + children: { + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + } as never, () => null) + ctx.provide('activityFeed', { + state: { getSnapshot: () => ({ rowsBySession: {}, observed: {}, phase: 'pending' }), subscribe: () => () => {} }, + observe: (id: string) => { observed.push(id); return () => {} }, + } as never) + // The locale plugin binds a settings scope, which reads the connection handle + // and the forwarded-event port. + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + // These specs assert the shipped Chinese copy. There is no jsdom `window` in + // this lane, so browser-language detection never runs and the locale comes + // from FALLBACK_LOCALE (en): state the asserted locale explicitly. + ctx.locale.setLocale('zh') + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { ctx, fiber } +} + +describe('ui-activity browser half', () => { + it('declares the services it binds', () => { + expect(inject).toEqual(['activityFeed', 'slots', 'locale']) + }) + + it('exposes the feed source and observation control through the inject face', async () => { + const { ctx } = await bench() + const entry = ctx.slots + .entries('conversation.session.header.actions') + .find(candidate => candidate.options.id === 'activity-list') + const face = (entry as unknown as { + inject: () => { hooks: { activity: unknown }; observe: (id: string) => () => void } + }).inject() + expect(face.hooks.activity).toBeDefined() + const stopper = face.observe('bash-1') + expect(observed).toEqual(['bash-1']) + stopper() + }) + + it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { + const { ctx, fiber } = await bench() + expect(headerEntryIds(ctx)).toContain('activity-list') + await fiber.dispose() + expect(headerEntryIds(ctx)).not.toContain('activity-list') + }) + + it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { + const { ctx, fiber } = await bench() + const translate = ctx.locale.bind(NS) + expect(translate('list.aria')).toBe(zh['list.aria']) + ctx.locale.setLocale('en') + expect(translate('list.aria')).toBe(en['list.aria']) + + // Withdrawn dictionaries leave the key unresolved rather than translated. + await fiber.dispose() + expect(translate('list.aria')).not.toBe(en['list.aria']) + }) + + it('keeps the English dictionary key-identical to the Chinese source of truth', () => { + expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) + }) +}) + +describe('ui-activity node half', () => { + it('contributes no host behavior', () => { + // The node half exists only so the plugin appears in the Loader tree. + expect(applyNode).not.toThrow() + }) +}) + +describe('ui-activity invariant companion', () => { + it('reserves package ownership under its declared companion name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry, { enabled: true }) + await ctx.plugin(ActivityInvariant) + expect(ActivityInvariant.name).toBe('client-ui-activity-invariant') + expect(ActivityInvariant.inject).toEqual(['invariants']) + }) +}) diff --git a/packages/client/ui-activity/tsconfig.json b/packages/client/ui-activity/tsconfig.json new file mode 100644 index 0000000000..bbd9c5ce85 --- /dev/null +++ b/packages/client/ui-activity/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../api/activity-controller/tsconfig.client.json" }, + { "path": "../locale" }, + { "path": "../ui-conversation" }, + { "path": "../ui-primitives" }, + { "path": "../ui-renderer" }, + { "path": "../ui-session" }, + { "path": "../ui-slots" }, + { "path": "../../runtime-diagnostics/invariants" } + ] +} diff --git a/packages/client/ui-activity/tsdown.config.ts b/packages/client/ui-activity/tsdown.config.ts new file mode 100644 index 0000000000..096771fe90 --- /dev/null +++ b/packages/client/ui-activity/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-client-ui-activity', + ['lib/types/index.js', 'lib/types/invariant.js'], +) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 720136b270..a025628aa4 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: c1c40e39710d46fae240f0b3281c36d660855010 -README.zh.md: 631936ad658c147923e5f80f2d7445cac93d6b36 +README.md: 4df368b270511776896c74a55b0c1d2e684035d0 +README.zh.md: 77830a1f162e963709ea4d4d25c8393e7dd97d66 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index c1c40e3971..4df368b270 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -18,7 +18,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P ## Terminal output -`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +`TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; a running block with a supplied `output` renders that live text under the running state (an undefined output keeps the historical prompt-only frame); it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with `anser` (bundled into this package's browser artifact) into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). ## Read rendering diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 631936ad65..77830a1f16 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -18,7 +18,7 @@ ## 终端输出 -`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。 +`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;运行中且提供了 `output` 的块会在 running 状态下渲染这段实时文本(未提供 output 则保持历史上的仅提示行画面);由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过 `anser`(打进本包浏览器产物)解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)。 ## Read 渲染 diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 26e26fe2d2..8f87db9741 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -54,7 +54,10 @@ export interface TerminalBlockProps { exitCode?: number | undefined /** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */ signal?: string | undefined - /** The command is still running: the block shows the prompt line alone. */ + /** + * The command is still running: with no `output` the block shows the prompt + * line alone; with output it renders the live text under the running state. + */ running?: boolean | undefined /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */ maxLines?: number | undefined @@ -221,13 +224,16 @@ export function TerminalBlock({ ))} {status !== undefined && {status}} - {!running && !empty && ( + {(!running || output !== undefined) && !empty && ( )} - {!running && (empty + {/* A running block renders its live text as soon as the caller supplies + output; an undefined output keeps the historical prompt-only frame, + and a live-but-empty stream draws no placeholder. */} + {(!running || (output !== undefined && !empty)) && (empty && !running ?
    {copy.noOutput}
    : (
    diff --git a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx index 45de33c8f0..886661b396 100644 --- a/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx +++ b/packages/client/ui-primitives/tests/terminal-block.client.spec.tsx @@ -89,15 +89,30 @@ describe('TerminalBlock prompt label', () => { }) describe('TerminalBlock states', () => { - it('running shows the command line only: no output, no placeholder, no copy', () => { - const view = render() + it('running with no output shows the command line only: no output box, no placeholder, no copy', () => { + const view = render() expect(view.getByText('sleep 5')).toBeTruthy() - expect(view.queryByText('partial')).toBeNull() + expect(outputLines(view.container)).toEqual([]) expect(view.queryByText('无输出')).toBeNull() expect(view.queryByRole('button')).toBeNull() expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') }) + it('running with supplied output streams the live text and keeps it copyable', () => { + const view = render() + expect(view.getByText('partial')).toBeTruthy() + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByRole('button', { name: '复制' })).toBeTruthy() + expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('') + }) + + it('running with an empty live stream draws neither output nor placeholder', () => { + const view = render() + expect(outputLines(view.container)).toEqual([]) + expect(view.queryByText('无输出')).toBeNull() + expect(view.queryByRole('button')).toBeNull() + }) + it('running still shows a settled-looking status pill when one is supplied', () => { render() expect(screen.getByText('信号 SIGINT')).toBeTruthy() diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e038b24a0d..1a53958b6c 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1141,6 +1141,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ + 'client-ui-activity ActivityListAction id \'activity-list\'', 'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'', 'client-ui-jobs JobListAction id \'job-list\'', ], diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 320c44b9aa..35c3b93d92 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -81,6 +81,68 @@ export interface TypeApiEntry { /** Every harness `ctx.` service, sorted by key. */ export const SERVICE_API: readonly ServiceApiEntry[] = [ + { + key: 'activities', + summary: 'Abstract streaming-output registry.', + description: 'Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected.\n- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource.\n- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap.\n- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it. Every listener is contained.', + methods: [ + { + signature: 'abstract open(spec: ActivityOpen): ActivityHandle', + description: 'Validate the spec, attach owner cleanup, and atomically register one activity. A rejection leaves no id; after return the producer owns the handle and the visible set has changed.', + parameters: [{ name: 'spec', description: 'activity identity, owner, and correlation.' }], + returns: 'the producer face of the registered activity.', + }, + { + signature: 'abstract list(caller?: Agent): ActivitySnapshot[]', + description: 'List caller-owned and unowned activities in registration order without exposing another session\'s labels or output.', + parameters: [{ name: 'caller', description: 'reading agent; a non-agent caller sees only unowned activities.' }], + returns: 'fresh snapshots.', + }, + { + signature: 'abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot', + description: 'Return a fresh snapshot. Throws for an unknown or foreign activity.', + parameters: [{ name: 'id', description: 'activity to look up.' }, { name: 'caller', description: 'reading agent checked against the owner.' }], + returns: 'a fresh snapshot.', + }, + { + signature: 'abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead', + description: 'Read retained output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Throws for an unknown or foreign activity or a negative or non-integer offset.', + parameters: [{ name: 'id', description: 'activity to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }], + returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.', + }, + { + signature: 'abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void', + description: 'Register an effect-scoped observer of visible-set changes: opening, detail updates, settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas. Listeners are contained and never awaited.', + parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned activity changed and every caller\'s set did.' }], + returns: 'disposer that unregisters the listener.', + }, + { + signature: 'abstract onOutput(listener: ActivityOutputListener): () => void', + description: 'Register an effect-scoped observer of stream advancement — one signal per committed append and one at settlement, carrying only the activity id. A consumer schedules a read from its own cursor; the registry never pushes payloads. Listeners are contained and never awaited.', + parameters: [{ name: 'listener', description: 'receives the id whose stream advanced.' }], + returns: 'disposer that unregisters the listener.', + }, + ], + }, + { + key: 'activityController', + summary: 'Host service backing the generated `ctx.remote.activity` namespace.', + description: 'Host service backing the generated `ctx.remote.activity` namespace.', + methods: [ + { + signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable', + description: 'Stream a complete roster baseline followed by whole-bucket replacements.', + parameters: [{ name: 'signal', description: 'generation cancellation.' }], + returns: 'baseline followed by per-owner roster replacement frames.', + }, + { + signature: '@Remote({ mode: \'stream\' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable', + description: 'Stream one activity\'s retained output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursors never observe these reads.', + parameters: [{ name: 'request', description: 'target activity and optional resume offset.' }, { name: 'signal', description: 'generation cancellation.' }], + returns: 'anchor, coalesced output frames, and the terminal status.', + }, + ], + }, { key: 'agentDefaultModel', summary: 'Owns the default model selection independently of any Host or transport.', @@ -3115,6 +3177,94 @@ export const EVENT_API: readonly EventApiEntry[] = [ /** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */ export const TYPE_API: readonly TypeApiEntry[] = [ + { + name: 'ActivitiesChangedListener', + declaration: 'export type ActivitiesChangedListener = (owner: Agent | undefined) => void;', + }, + { + name: 'ActivityAppendOptions', + declaration: 'export interface ActivityAppendOptions {\n channel?: ActivityChannel;\n gapBefore?: true;\n}', + }, + { + name: 'ActivityChannel', + declaration: 'export type ActivityChannel = \'stdout\' | \'stderr\';', + }, + { + name: 'ActivityControlFrame', + declaration: 'export type ActivityControlFrame = {\n readonly type: \'baseline\';\n readonly activities: readonly ActivityRow[];\n} | {\n readonly type: \'rows\';\n readonly sessionId?: SessionId;\n readonly activities: readonly ActivityRow[];\n};', + }, + { + name: 'ActivityCorrelation', + declaration: 'export interface ActivityCorrelation {\n callId?: CallId;\n jobId?: JobId;\n}', + }, + { + name: 'ActivityHandle', + declaration: 'export interface ActivityHandle {\n readonly id: ActivityId;\n append(text: string, options?: ActivityAppendOptions): void;\n updateDetail(detail: string): void;\n end(outcome: ActivityOutcome): void;\n}', + }, + { + name: 'ActivityId', + declaration: 'export type ActivityId = Branded<\'ActivityId\'>;', + }, + { + name: 'ActivityKind', + declaration: 'export type ActivityKind = ActivityKindMap[keyof ActivityKindMap];', + }, + { + name: 'ActivityKindMap', + declaration: 'export interface ActivityKindMap {\n bash: \'bash\';\n}', + }, + { + name: 'ActivityObserveFrame', + declaration: 'export type ActivityObserveFrame = {\n readonly type: \'opened\';\n readonly activityId: ActivityId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly ActivityWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};', + }, + { + name: 'ActivityObserveRequest', + declaration: 'export interface ActivityObserveRequest {\n readonly activityId: ActivityId;\n readonly from?: number;\n}', + }, + { + name: 'ActivityOpen', + declaration: 'export interface ActivityOpen {\n kind: ActivityKind;\n label: string;\n owner?: Agent;\n correlation?: ActivityCorrelation;\n}', + }, + { + name: 'ActivityOutcome', + declaration: 'export interface ActivityOutcome {\n status: \'completed\' | \'failed\' | \'killed\';\n detail?: string;\n}', + }, + { + name: 'ActivityOutputChunk', + declaration: 'export interface ActivityOutputChunk {\n at: number;\n text: string;\n channel?: ActivityChannel;\n gapBefore?: true;\n}', + }, + { + name: 'ActivityOutputListener', + declaration: 'export type ActivityOutputListener = (id: ActivityId) => void;', + }, + { + name: 'ActivityRead', + declaration: 'export interface ActivityRead {\n chunks: readonly ActivityOutputChunk[];\n next: number;\n lossy: boolean;\n}', + }, + { + name: 'ActivityRow', + declaration: 'export interface ActivityRow {\n readonly id: ActivityId;\n readonly kind: string;\n readonly label: string;\n readonly sessionId?: SessionId;\n readonly correlation?: ActivityRowCorrelation;\n readonly status: ActivityRowStatus;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal: number;\n}', + }, + { + name: 'ActivityRowCorrelation', + declaration: 'export interface ActivityRowCorrelation {\n readonly callId?: CallId;\n readonly jobId?: JobId;\n}', + }, + { + name: 'ActivityRowStatus', + declaration: 'export type ActivityRowStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';', + }, + { + name: 'ActivitySnapshot', + declaration: 'export interface ActivitySnapshot {\n id: ActivityId;\n kind: ActivityKind;\n label: string;\n ownerSession?: SessionId;\n correlation?: ActivityCorrelation;\n status: ActivityStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n outputTotal: number;\n outputEarliest: number;\n}', + }, + { + name: 'ActivityStatus', + declaration: 'export type ActivityStatus = \'running\' | \'completed\' | \'failed\' | \'killed\';', + }, + { + name: 'ActivityWireChunk', + declaration: 'export interface ActivityWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', + }, { name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', @@ -4827,9 +4977,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ShellExecSpec', declaration: 'export interface ShellExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}', }, + { + name: 'ShellObservedStreams', + declaration: 'export interface ShellObservedStreams {\n stdout?: SubprocessOutputReader;\n stderr?: SubprocessOutputReader;\n}', + }, { name: 'ShellProcess', - declaration: 'export interface ShellProcess {\n status: ShellProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: ShellSandboxInfo;\n readOutput(): ShellProcessRead;\n kill(): boolean;\n}', + declaration: 'export interface ShellProcess {\n status: ShellProcessStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise;\n sandbox?: ShellSandboxInfo;\n readOutput(): ShellProcessRead;\n observed?: ShellObservedStreams;\n kill(): boolean;\n}', }, { name: 'ShellProcessRead', diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 8129e170ed..4550338e3b 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md -README.md: f4469286603f687be5789bd016ab462dba26e664 -README.zh.md: 6fd97aa3fe4cbbd555c2475f4132ec00c6a97390 +README.md: 1ed69b99ae10d4821e134b42f2ae19ad4ac3350b +README.zh.md: c9fd920bb552e0386ed0644fe00c4473f5b95e3c diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index f446928660..1ed69b99ae 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -35,6 +35,6 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Stream output has one consuming cursor** — independent observers need a cursor or snapshot API. +- **Stream output has one consuming cursor** — the model owns it; independent observers use the non-consuming [`dsh-activity`](../../activity/activity/README.md) plane the producers mirror into instead. - **Foreground work cannot be promoted** — producers choose foreground or background before starting. - **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 6fd97aa3fe..c9fd920bb5 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -35,6 +35,6 @@ ## 已知限制与暂缓事项 -- **流输出只有一个消费游标**:独立观察者需要游标或快照 API。 +- **流输出只有一个消费游标**:它属于模型;独立观察者改用生产者镜像进去的非消费 [`dsh-activity`](../../activity/activity/README.zh.md) 观察面。 - **前台工作无法转为后台**:生产方在启动前选择前台或后台。 - **约定是进程内的**:`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 diff --git a/packages/shell/bash-local/src/index.ts b/packages/shell/bash-local/src/index.ts index 6c37c5b794..fa4f5ef893 100644 --- a/packages/shell/bash-local/src/index.ts +++ b/packages/shell/bash-local/src/index.ts @@ -272,6 +272,7 @@ export class LocalBashExecutor extends ShellExecutor { status: 'running', exitCode: null, signal: null, + observed: collected, done: running.done.then((outcome) => { // Any signal termination is killed, including a command signaling itself. if (proc.status === 'running') { diff --git a/packages/shell/pwsh-local/src/index.ts b/packages/shell/pwsh-local/src/index.ts index b7a2d9f915..08abd61169 100644 --- a/packages/shell/pwsh-local/src/index.ts +++ b/packages/shell/pwsh-local/src/index.ts @@ -301,6 +301,7 @@ export class PwshLocalExecutor extends ShellExecutor { status: 'running', exitCode: null, signal: null, + observed: collected, done: running.done.then((outcome) => { // Any signal termination is killed, including a command signaling itself. if (proc.status === 'running') { diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml index 5317414e55..bcf293269a 100644 --- a/packages/shell/shell/README.i18n.yaml +++ b/packages/shell/shell/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/shell/README.md -README.md: a15980ead797a73c18dd6d8825a4b38cf845df60 -README.zh.md: 008b19835697d5d9514cf5472696852cfc684acd +README.md: 45a4d0c47e7604e7ad2ffc9f98a387cfa4d20b0c +README.zh.md: da869b78981df6bcfc253e84f08e4ca63cbff5f3 diff --git a/packages/shell/shell/README.md b/packages/shell/shell/README.md index a15980ead7..45a4d0c47e 100644 --- a/packages/shell/shell/README.md +++ b/packages/shell/shell/README.md @@ -23,6 +23,7 @@ The split is a standard capability seam ([capability-seams Agent Note](../../../ | `start(spec)` | Background execution. Returns a task-free `ShellProcess` handle immediately; **no timeout applies**. The caller may adapt it into `ctx.jobs`. | | `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. | | `ShellProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. | +| `ShellProcess.observed` | Optional **non-consuming** offset readers over the same captured streams, for observers independent of the consuming cursor (the activity observation plane). Absent when a backend cannot expose offset reads. | | `ShellProcess.kill()` | Kill the process group. Returns `false` when it already finished. | Implementations subclass `ShellExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit. diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md index 008b198356..da869b7898 100644 --- a/packages/shell/shell/README.zh.md +++ b/packages/shell/shell/README.zh.md @@ -23,6 +23,7 @@ | `start(spec)` | 后台执行。立即返回不含任务语义的 `ShellProcess` 句柄;**不应用超时**。调用方可以将其适配到 `ctx.jobs`。 | | `sandboxMode` | 工具层的能力事实:沙箱执行器用于限制执行的默认模式(基类中为 `undefined`,即「此执行器不使用沙箱」)。`dsh-tool-bash` 会在注册时读取它,仅当组合确实支持升权字段时才公布这些字段。 | | `ShellProcess.readOutput()` | **增量** 读取输出:连续读取绝不会重复交付。因缓冲区容量限制而丢失数据的读取会标记 `lossy`,并指向完整流 spill 文件。 | +| `ShellProcess.observed` | 可选的**非消费** offset 读取器,覆盖同一批捕获流,供独立于消费游标的观察者使用(activity 观察面)。后端无法提供 offset 读取时缺省。 | | `ShellProcess.kill()` | 终止进程组。如果进程已结束,返回 `false`。 | 实现会继承 `ShellExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。 diff --git a/packages/shell/shell/src/types.ts b/packages/shell/shell/src/types.ts index 21fea2d791..1de1cabf95 100644 --- a/packages/shell/shell/src/types.ts +++ b/packages/shell/shell/src/types.ts @@ -8,10 +8,22 @@ */ import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' -import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess' +import type { CollectedOutput, DshEnvironment, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' -export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess' +export type { CollectedOutput, DshEnvironment, DshEnvironmentKey, SubprocessOutputRead, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' + +/** + * Non-consuming offset readers over a background process's captured streams, + * for observers independent of the consuming {@link ShellProcess.readOutput} + * cursor. A stream is absent when its backend cannot expose offset reads. + */ +export interface ShellObservedStreams { + /** Offset reader over captured stdout, when the backend exposes one. */ + stdout?: SubprocessOutputReader + /** Offset reader over captured stderr, when the backend exposes one. */ + stderr?: SubprocessOutputReader +} /** * Sandbox facts for one run, present iff a sandboxing executor handled it. @@ -175,6 +187,13 @@ export interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. Absent when the + * backend cannot expose offset reads; observers then see no stream. + */ + observed?: ShellObservedStreams /** * Kill the process group. Returns false when it had already finished * (no-op); idempotent. diff --git a/packages/shell/tool-bash/README.i18n.yaml b/packages/shell/tool-bash/README.i18n.yaml index f3acda2aca..ae0daca26d 100644 --- a/packages/shell/tool-bash/README.i18n.yaml +++ b/packages/shell/tool-bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-bash/README.md -README.md: 21749bf0a1cd3cdc46257fb2a02ba6b4ab1f5ee6 -README.zh.md: 860db1a4c347d1aefe04f70e14a65e4b511ff7f6 +README.md: acf7e68cca6b611adf71ed7bad690294f1bdb14d +README.zh.md: 9aeae12bce13f87246a7cdea8c5b41ec8bbd4bc5 diff --git a/packages/shell/tool-bash/README.md b/packages/shell/tool-bash/README.md index 21749bf0a1..acf7e68cca 100644 --- a/packages/shell/tool-bash/README.md +++ b/packages/shell/tool-bash/README.md @@ -34,7 +34,7 @@ Result text contains stdout, an optional `[stderr]` section, then applicable san The canonical success is `{ kind: 'foreground', ...ShellRunResult }` for a completed foreground process or `{ kind: 'background', jobId }` for a published task. The Native renderer preserves the text above, including exactly `started background job `; programmatic consumers use the typed fields without parsing those strings. Executor stream caps remain acquisition limits on `ShellRunResult` and carry their spill paths. -When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. +When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps bash exit/sandbox facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort: a `bash` activity correlated to the call and job pumps the handle's non-consuming `observed` readers at `activityPollMs` (default 150) so the Web client streams live output without touching the job's cursor; any observation failure is logged and swallowed. ## UI presentation diff --git a/packages/shell/tool-bash/README.zh.md b/packages/shell/tool-bash/README.zh.md index 860db1a4c3..9aeae12bce 100644 --- a/packages/shell/tool-bash/README.zh.md +++ b/packages/shell/tool-bash/README.zh.md @@ -34,7 +34,7 @@ 已完成前台进程的规范成功值为 `{ kind: 'foreground', ...ShellRunResult }`,已发布任务则为 `{ kind: 'background', jobId }`。Native renderer 保留上述文本,包括精确的 `started background job `;程序化消费方使用带类型字段,无需解析这些字符串。执行器的流上限仍是 `ShellRunResult` 的采集限制,并携带其 spill 路径。 -当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.jobs.start()`,把调用方 agent 注册为持有者,并将返回的 `ShellProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。 +当 `run_in_background` 为 true 时,此插件会在 spawn 前预检 `ctx.jobs.start()`,把调用方 agent 注册为持有者,并将返回的 `ShellProcess` 句柄适配为通用的取消/完成/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;此插件只把 bash 退出/沙箱事实映射为任务输出和结果详情。`enableRunInBackground: false` 会移除该参数,并在执行时拒绝强制后台调用。当可选的 `ctx.activities` 注册表已加载时,已提交的后台运行还会尽力镜像进去:一个与调用和 job 关联的 `bash` activity 以 `activityPollMs`(默认 150)的节奏泵取句柄的非消费 `observed` 读取器,让 Web 客户端在不动 job 游标的前提下实时看到输出;任何观察失败只记日志并吞掉。 ## UI 展示 diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 321e2822d2..557fbe37f2 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -32,42 +32,44 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" } } diff --git a/packages/shell/tool-bash/src/index.ts b/packages/shell/tool-bash/src/index.ts index 37c4a762e3..ed4fd5bac6 100644 --- a/packages/shell/tool-bash/src/index.ts +++ b/packages/shell/tool-bash/src/index.ts @@ -23,8 +23,10 @@ import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandb import { ESCALATION_TARGETS, approveEscalation, canonicalPath, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-shell' -import type { ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' +import type {} from '@deepseek-ai/dsh-activity' import { processOutcome } from './background.ts' +import { observeBackgroundActivity } from './observe.ts' import { parseExitStatus, renderProcessRead, renderResult } from './render.ts' export const name = 'tool-bash' @@ -34,11 +36,18 @@ export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } /** Runtime configuration schema for the bash tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), + activityPollMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(150), }) /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ @@ -189,6 +198,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = { export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true + const activityPollMs = config.activityPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -362,12 +372,14 @@ export function apply(ctx: Context, config: Config = {}): void { throw error } // Task preflight finishes before the starter can spawn a process. + let started: ShellProcess | undefined const id = jobs.start({ kind: 'bash', label: args.command, ...exec.agent ? { owner: exec.agent } : {}, run: () => { const proc = ctx.shell.start(ctx.shell.resolve(request)) + started = proc return { cancel: () => void proc.kill(), done: proc.done.then(() => processOutcome(proc)), @@ -375,6 +387,16 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) + // The starter ran synchronously inside the committed start; mirror the + // spawned process into the optional observation registry. + if (started !== undefined) { + observeBackgroundActivity(ctx, started, { + kind: 'bash', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + correlation: { callId: exec.callId, jobId: id }, + }, activityPollMs, processOutcome) + } return { kind: 'background' as const, jobId: id } } const result = await ctx.shell.run(ctx.shell.resolve({ diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts new file mode 100644 index 0000000000..7b5179aa59 --- /dev/null +++ b/packages/shell/tool-bash/src/observe.ts @@ -0,0 +1,72 @@ +/** + * Best-effort process-observation tap for background bash runs: mirrors the + * spawned process's captured streams into `ctx.activities` so non-consuming + * observers (the Web client) see live output without touching the job's + * consuming `readOutput` cursor. Absent registry or absent backend offset + * readers degrade to no observation; the job path is unaffected either way. + * + * @module @deepseek-ai/dsh-tool-bash/observe + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import { pumpActivityOutput } from '@deepseek-ai/dsh-activity' +import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity' + +/** Identity of one observed background run. */ +export interface ObserveBackgroundSpec { + /** Process kind — the producing tool's registered {@link ActivityKind}. */ + kind: ActivityKind + /** One-line label (the command). */ + label: string + /** Owning live agent, when the call has one. */ + owner?: Agent + /** Links to the starting tool call and the registered job. */ + correlation: ActivityCorrelation +} + +/** + * Open a process beside a committed background job and pump the handle's + * non-consuming offset readers into it until the run settles, then record the + * mapped outcome. Observation is strictly best-effort: without a + * `ctx.activities` registry this is a no-op, without backend offset readers + * the process still carries status and settlement, and ANY observation + * failure — including a rejected `open()` — is logged and swallowed, because + * the background job is already committed and running. + * @param ctx - plugin context used for the optional registry and warning logs. + * @param proc - the started background process handle. + * @param spec - process identity and correlation for the registry row. + * @param pollMs - producer-configured pump cadence in milliseconds. + * @param outcome - maps the settled handle to the process outcome. + */ +export function observeBackgroundActivity( + ctx: Context, + proc: ShellProcess, + spec: ObserveBackgroundSpec, + pollMs: number, + outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string }, +): void { + try { + const processes = ctx.get('activities') + if (processes === undefined) return + const handle = processes.open({ + kind: spec.kind, + label: spec.label, + ...spec.owner !== undefined ? { owner: spec.owner } : {}, + correlation: spec.correlation, + }) + const sources: ActivityPumpSource[] = [] + const stdout = proc.observed?.stdout + if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) + const stderr = proc.observed?.stderr + if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) + void pumpActivityOutput(handle, sources, { pollMs, done: proc.done }) + .catch((error: unknown) => { + ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`) + }) + .then(() => { handle.end(outcome(proc)) }) + } catch (error: unknown) { + ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`) + } +} diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts new file mode 100644 index 0000000000..c0243ba5b9 --- /dev/null +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -0,0 +1,194 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import type { ActivitySnapshot } from '@deepseek-ai/dsh-activity' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' +import * as ToolBash from '@deepseek-ai/dsh-tool-bash' +import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' + +const testToolSignal = new AbortController().signal +const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-observe-spec-')) + +/** Job harness plus the observation registry, with a fast pump for tests. */ +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin(LocalActivityRegistry) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + return ctx +} + +let callCounter = 0 +function call(ctx: Context, args: Record) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`observe-call-${++callCounter}`), + name: 'bash', + arguments: args, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +async function until(read: () => T | undefined, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const value = read() + if (value !== undefined) return value + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error('condition not reached before timeout') +} + +function retainedText(ctx: Context, id: ActivitySnapshot['id']): string { + return ctx.activities.read(id, 0).chunks.map(chunk => chunk.text).join('') +} + +describe('background bash observation', () => { + it('mirrors a background run into ctx.activities with correlation, live output, and settlement', async () => { + const ctx = await setup() + const ack = await call(ctx, { + command: 'printf "line-1\\n"; sleep 0.4; printf "line-2\\n"', + description: 'test command', + run_in_background: true, + }) + expect(text(ack)).toContain('started background job') + const job = ctx.jobs.list()[0] + expect(job).toBeDefined() + + const snapshot = await until(() => + ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) + expect(snapshot.kind).toBe('bash') + expect(snapshot.label).toContain('printf') + expect(String(snapshot.correlation?.callId)).toMatch(/^observe-call-/) + + // Live output appears while the command is still running. + await until(() => retainedText(ctx, snapshot.id).includes('line-1') ? true : undefined) + expect(ctx.activities.get(snapshot.id).status).toBe('running') + + // Settlement carries the mapped bash outcome and the trailing output. + await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined) + expect(ctx.activities.get(snapshot.id).detail).toBe('exit code: 0') + expect(retainedText(ctx, snapshot.id)).toContain('line-2') + + // The model-facing consuming cursor still delivers everything: observation stole nothing. + const collected = await until(() => { + const read = ctx.jobs.read(job!.id) + return read.text.includes('line-1') ? read.text : undefined + }) + expect(collected).toContain('line-2') + }) + + it('a killed background job settles its mirrored process as killed', async () => { + const ctx = await setup() + await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true }) + const job = ctx.jobs.list()[0] + const snapshot = await until(() => + ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) + ctx.jobs.kill(job!.id, undefined, 'test cleanup') + await until(() => ctx.activities.get(snapshot.id).status === 'killed' ? true : undefined) + expect(ctx.activities.get(snapshot.id).detail).toMatch(/signal|killed before exit/) + }) + + it('labels stderr chunks with their channel', async () => { + const ctx = await setup() + await call(ctx, { + command: 'echo out-line; echo err-line 1>&2', + description: 'test command', + run_in_background: true, + }) + const job = ctx.jobs.list()[0] + const snapshot = await until(() => + ctx.activities.list().find(row => row.correlation?.jobId === job!.id)) + await until(() => ctx.activities.get(snapshot.id).status === 'completed' ? true : undefined) + const chunks = ctx.activities.read(snapshot.id, 0).chunks + expect(chunks.find(chunk => chunk.text.includes('out-line'))?.channel).toBe('stdout') + expect(chunks.find(chunk => chunk.text.includes('err-line'))?.channel).toBe('stderr') + }) + + it('a registry whose handle rejects appends degrades to a warning and still settles', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + const ended: unknown[] = [] + await ctx.plugin({ + name: 'appending-broken-activities-probe', + apply(child: Context) { + child.provide('activities', { + open: () => ({ + id: 'bash-1', + append() { throw new Error('append boom') }, + updateDetail() {}, + end(outcome: unknown) { ended.push(outcome) }, + }), + }) + }, + }) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + await call(ctx, { command: 'echo tolerated', description: 'test command', run_in_background: true }) + await until(() => ended.length > 0 ? true : undefined) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation pump')) + expect(ended[0]).toMatchObject({ status: 'completed' }) + }) + + it('a throwing observation registry never breaks the background call', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin({ + name: 'broken-activities-probe', + apply(child: Context) { + child.provide('activities', { open() { throw new Error('observation boom') } }) + }, + }) + await ctx.plugin(LocalSubprocessRuntime) + ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin(ToolBash, { activityPollMs: 25 }) + const ack = await call(ctx, { command: 'echo resilient', description: 'test command', run_in_background: true }) + expect(text(ack)).toContain('started background job') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('activity observation unavailable')) + // The job itself still completes and reads normally. + const job = ctx.jobs.list()[0] + const read = await until(() => { + const value = ctx.jobs.read(job!.id) + return value.text.includes('resilient') ? value : undefined + }) + expect(read.snapshot.status).toBe('completed') + }) +}) diff --git a/packages/shell/tool-bash/tsconfig.json b/packages/shell/tool-bash/tsconfig.json index 304d983a95..8aa66f93ff 100644 --- a/packages/shell/tool-bash/tsconfig.json +++ b/packages/shell/tool-bash/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../../activity/activity" } ] } diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index c5dcb85005..0a88134fb3 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/shell/tool-pwsh/README.md -README.md: e862fcf0ca85d0ecb0a5fe6cff3ee3c7a8153716 -README.zh.md: 0a66b119ac2bf0386c0686e9e22ddfe1c3bb87b1 +README.md: 5ebc22e8607337c2698756891cce2b9ba9e06184 +README.zh.md: 2e5186383babadab9ea894b3cb6364ab8bc23e6b diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index e862fcf0ca..5ebc22e860 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -34,7 +34,7 @@ Result text contains stdout, an optional `[stderr]` section, then applicable tru The canonical success is `{ kind: 'foreground', ...ShellRunResult }` for a completed foreground process (with the executor's `sandbox` facts — `mode`/`denied`, optional `enforcement`/`runnerFailed` — projected when present) or `{ kind: 'background', jobId }` for a published task. The renderer preserves exactly `started background job ` for background acks; programmatic consumers use the typed fields without parsing the rendered text. -When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. +When `run_in_background` is true, this plugin preflights `ctx.jobs.start()` before spawning, registers the calling agent as owner, and adapts the returned `ShellProcess` handle into generic cancel/done/incremental-output hooks. The job runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into job output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. When the optional `ctx.activities` registry is loaded, the committed background run is also mirrored into it best-effort as a `pwsh` activity pumped at `activityPollMs` (default 150), exactly like `dsh-tool-bash`. ## UI presentation diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 0a66b119ac..2e5186383b 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -34,7 +34,7 @@ 规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...ShellRunResult }`(存在时投影执行器的 `sandbox` 事实——`mode`/`denied`、可选的 `enforcement`/`runnerFailed`)或已发布任务的 `{ kind: 'background', jobId }`。渲染器对后台 ack 精确保留 `started background job `;编程消费者使用类型化字段而不解析渲染文本。 -当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.jobs.start()`,把调用 agent 注册为 owner,并将返回的 `ShellProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 +当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.jobs.start()`,把调用 agent 注册为 owner,并将返回的 `ShellProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时负责 job id、跨会话隔离、完成通知、等待和 dispose(资源释放)清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。当可选的 `ctx.activities` 注册表已加载时,已提交的后台运行还会尽力镜像为一个 `pwsh` activity,以 `activityPollMs`(默认 150)的节奏泵取,与 `dsh-tool-bash` 完全一致。 ## UI presentation diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 2294a955a3..3156508e39 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -32,39 +32,41 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-user-approval": "workspace:^" } } diff --git a/packages/shell/tool-pwsh/src/index.ts b/packages/shell/tool-pwsh/src/index.ts index 603322e381..f3e63551e5 100644 --- a/packages/shell/tool-pwsh/src/index.ts +++ b/packages/shell/tool-pwsh/src/index.ts @@ -33,9 +33,11 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' -import type { ShellRunResult } from '@deepseek-ai/dsh-shell' +import type { ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' import { parseExitStatus } from '@deepseek-ai/dsh-shell' +import type {} from '@deepseek-ai/dsh-activity' import { processOutcome } from './background.ts' +import { observeBackgroundActivity } from './observe.ts' import { renderPwshProcessRead, renderPwshResult } from './render.ts' import type { RenderablePwshResult } from './render.ts' @@ -45,19 +47,34 @@ declare module '@deepseek-ai/dsh-jobs' { } } +declare module '@deepseek-ai/dsh-activity' { + interface ActivityKindMap { + pwsh: 'pwsh' + } +} + export const name = 'tool-pwsh' export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv'] +/* jscpd:ignore-start -- the pwsh Config mirrors tool-bash's by design, like render/background. */ /** Configuration for the pwsh tool. */ export interface Config { /** Expose `run_in_background` (default true); disabled calls are also rejected. */ enableRunInBackground?: boolean + /** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */ + activityPollMs?: number } /** Runtime configuration schema for the pwsh tool plugin. */ export const Config: z = z.object({ enableRunInBackground: z.boolean().default(true), + activityPollMs: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(150), }) +/* jscpd:ignore-end */ /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ interface PwshToolArgs { @@ -194,6 +211,7 @@ const BACKGROUND_OUTPUT_PROPERTIES = { /* jscpd:ignore-start -- deliberate mirror of dsh-tool-bash's apply() preamble (pwsh-tool-and-executor Agent Note). */ export function apply(ctx: Context, config: Config = {}): void { const backgroundEnabled = config.enableRunInBackground ?? true + const activityPollMs = config.activityPollMs ?? 150 const defaultMode = ctx.shell.sandboxMode const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS const sandboxPolicy: SandboxPolicyService | undefined = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy') @@ -378,12 +396,14 @@ export function apply(ctx: Context, config: Config = {}): void { throw error } // Task preflight finishes before the starter can spawn a process. + let started: ShellProcess | undefined const id = jobs.start({ kind: 'pwsh', label: args.command, ...exec.agent ? { owner: exec.agent } : {}, run: () => { const proc = ctx.shell.start(ctx.shell.resolve(request)) + started = proc return { cancel: () => void proc.kill(), done: proc.done.then(() => processOutcome(proc)), @@ -391,6 +411,16 @@ export function apply(ctx: Context, config: Config = {}): void { } }, }) + // The starter ran synchronously inside the committed start; mirror the + // spawned process into the optional observation registry. + if (started !== undefined) { + observeBackgroundActivity(ctx, started, { + kind: 'pwsh', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + correlation: { callId: exec.callId, jobId: id }, + }, activityPollMs, processOutcome) + } return { kind: 'background' as const, jobId: id } } const result = await ctx.shell.run(ctx.shell.resolve({ diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts new file mode 100644 index 0000000000..6d342dda8b --- /dev/null +++ b/packages/shell/tool-pwsh/src/observe.ts @@ -0,0 +1,74 @@ +/** + * Best-effort process-observation tap for background pwsh runs: mirrors the + * spawned process's captured streams into `ctx.activities` so non-consuming + * observers (the Web client) see live output without touching the job's + * consuming `readOutput` cursor. Absent registry or absent backend offset + * readers degrade to no observation; the job path is unaffected either way. + * + * @module @deepseek-ai/dsh-tool-pwsh/observe + */ + +/* jscpd:ignore-start -- mirrors tool-bash's observe tap by design, like render/background. */ +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ShellProcess } from '@deepseek-ai/dsh-shell' +import { pumpActivityOutput } from '@deepseek-ai/dsh-activity' +import type { ActivityCorrelation, ActivityKind, ActivityPumpSource } from '@deepseek-ai/dsh-activity' + +/** Identity of one observed background run. */ +export interface ObserveBackgroundSpec { + /** Process kind — the producing tool's registered {@link ActivityKind}. */ + kind: ActivityKind + /** One-line label (the command). */ + label: string + /** Owning live agent, when the call has one. */ + owner?: Agent + /** Links to the starting tool call and the registered job. */ + correlation: ActivityCorrelation +} + +/** + * Open a process beside a committed background job and pump the handle's + * non-consuming offset readers into it until the run settles, then record the + * mapped outcome. Observation is strictly best-effort: without a + * `ctx.activities` registry this is a no-op, without backend offset readers + * the process still carries status and settlement, and ANY observation + * failure — including a rejected `open()` — is logged and swallowed, because + * the background job is already committed and running. + * @param ctx - plugin context used for the optional registry and warning logs. + * @param proc - the started background process handle. + * @param spec - process identity and correlation for the registry row. + * @param pollMs - producer-configured pump cadence in milliseconds. + * @param outcome - maps the settled handle to the process outcome. + */ +export function observeBackgroundActivity( + ctx: Context, + proc: ShellProcess, + spec: ObserveBackgroundSpec, + pollMs: number, + outcome: (proc: ShellProcess) => { status: 'completed' | 'killed'; detail: string }, +): void { + try { + const processes = ctx.get('activities') + if (processes === undefined) return + const handle = processes.open({ + kind: spec.kind, + label: spec.label, + ...spec.owner !== undefined ? { owner: spec.owner } : {}, + correlation: spec.correlation, + }) + const sources: ActivityPumpSource[] = [] + const stdout = proc.observed?.stdout + if (stdout !== undefined) sources.push({ channel: 'stdout', read: from => stdout.readFrom(from) }) + const stderr = proc.observed?.stderr + if (stderr !== undefined) sources.push({ channel: 'stderr', read: from => stderr.readFrom(from) }) + void pumpActivityOutput(handle, sources, { pollMs, done: proc.done }) + .catch((error: unknown) => { + ctx.logger.warn(`activity observation pump for ${handle.id} failed: ${String(error)}`) + }) + .then(() => { handle.end(outcome(proc)) }) + } catch (error: unknown) { + ctx.logger.warn(`activity observation unavailable for this run: ${String(error)}`) + } +} +/* jscpd:ignore-end */ diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts new file mode 100644 index 0000000000..25b5bbfbd4 --- /dev/null +++ b/packages/shell/tool-pwsh/tests/observe.spec.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import { ShellExecutor } from '@deepseek-ai/dsh-shell' +import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell' +import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' + +const testToolSignal = new AbortController().signal + +/** ASCII-only scripted stream: readFrom offsets are byte-exact string indexes. */ +function scriptedReader(state: { text: string }) { + return { + readFrom(fromByte: number) { + return { text: state.text.slice(fromByte), nextOffset: state.text.length, lossy: false } + }, + } +} + +/** A running fake background handle with optional non-consuming observed streams. */ +function observableProcess(streams?: { stdout: { text: string }; stderr: { text: string } }) { + let resolveDone: () => void = () => {} + const done = new Promise((resolve) => { resolveDone = resolve }) + const proc: ShellProcess = { + status: 'running', + exitCode: null, + signal: null, + done, + readOutput: () => ({ delta: '', lossy: false }), + kill: () => false, + ...streams !== undefined + ? { observed: { stdout: scriptedReader(streams.stdout), stderr: scriptedReader(streams.stderr) } } + : {}, + } + return { + proc, + finish() { + proc.status = 'completed' + proc.exitCode = 0 + resolveDone() + }, + } +} + +class FakePwsh extends ShellExecutor { + backgroundHandler: (spec: ShellExecSpec) => ShellProcess = () => { throw new Error('unscripted start') } + + override resolve(request: ShellExecRequest): ShellExecSpec { + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + ...request.signal ? { signal: request.signal } : {}, + ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, + sandboxPolicy: request.sandboxPolicy, + } + } + + override async run(): Promise { + throw new Error('foreground is not exercised here') + } + + override start(spec: ShellExecSpec): ShellProcess { + return this.backgroundHandler(spec) + } +} + +async function setup() { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(ToolTasks) + await ctx.plugin(LocalActivityRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakePwsh) + await ctx.plugin(ToolPwsh, { activityPollMs: 5 }) + return { ctx, pwsh: ctx.shell as FakePwsh } +} + +let callCounter = 0 +function call(ctx: Context, args: Record) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`pwsh-observe-${++callCounter}`), + name: 'pwsh', + arguments: args, + }) +} + +async function until(read: () => T | undefined, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const value = read() + if (value !== undefined) return value + await new Promise(resolve => setTimeout(resolve, 5)) + } + throw new Error('condition not reached before timeout') +} + +describe('background pwsh observation', () => { + it('mirrors observed streams with channels and settles with the mapped outcome', async () => { + const { ctx, pwsh } = await setup() + const stdout = { text: '' } + const stderr = { text: '' } + const scripted = observableProcess({ stdout, stderr }) + pwsh.backgroundHandler = () => scripted.proc + + await call(ctx, { command: 'Get-Progress', description: 'test command', run_in_background: true }) + const job = ctx.jobs.list()[0] + const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id)) + expect(row.kind).toBe('pwsh') + + stdout.text = 'progress-line\n' + stderr.text = 'warn-line\n' + await until(() => { + const chunks = ctx.activities.read(row.id, 0).chunks + return chunks.some(chunk => chunk.text.includes('progress-line')) + && chunks.some(chunk => chunk.text.includes('warn-line')) + ? true + : undefined + }) + const chunks = ctx.activities.read(row.id, 0).chunks + expect(chunks.find(chunk => chunk.text.includes('progress-line'))?.channel).toBe('stdout') + expect(chunks.find(chunk => chunk.text.includes('warn-line'))?.channel).toBe('stderr') + + scripted.finish() + await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined) + expect(ctx.activities.get(row.id).detail).toBe('exit code: 0') + }) + + it('a backend without observed readers still yields a status-only process', async () => { + const { ctx, pwsh } = await setup() + const scripted = observableProcess() + pwsh.backgroundHandler = () => scripted.proc + + await call(ctx, { command: 'Start-Job', description: 'test command', run_in_background: true }) + const job = ctx.jobs.list()[0] + const row = await until(() => ctx.activities.list().find(item => item.correlation?.jobId === job!.id)) + expect(ctx.activities.read(row.id, 0).chunks).toEqual([]) + + scripted.finish() + await until(() => ctx.activities.get(row.id).status === 'completed' ? true : undefined) + expect(ctx.activities.get(row.id).outputTotal).toBe(0) + }) +}) diff --git a/packages/shell/tool-pwsh/tsconfig.json b/packages/shell/tool-pwsh/tsconfig.json index 59a4d9478c..d38c8f031d 100644 --- a/packages/shell/tool-pwsh/tsconfig.json +++ b/packages/shell/tool-pwsh/tsconfig.json @@ -52,6 +52,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../activity/activity" } ] } diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index f87a12407f..65a8c97d7b 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/workflow/tool-workflow/README.md -README.md: 918fe5b9aff74b3b959107ec1eebfd7966afe6d1 -README.zh.md: 2546a0fc92ef39d71949cc0061dcf363ecc2e22d +README.md: 29e84c7386768b77c7b85ed8426f6f604a53fb44 +README.zh.md: 582828cf7c2d5b6c6e7dca6230d29fe744b578fe diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index 918fe5b9af..29e84c7386 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -14,7 +14,7 @@ Collection is synchronous (like [`dsh-tool-subagent`](../../subagent/tool-subage 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. +The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. Beside the durable record, each recorded top-level run is mirrored best-effort into the optional `ctx.activities` registry as a `workflow` activity: the otherwise-unconsumed `workflow/phase` and `workflow/log` events plus member lifecycle lines stream into it, so the Web client shows the run's live narration the session log deliberately does not carry per line; without the registry, or on any mirror failure, the run is unaffected. 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 diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 2546a0fc92..582828cf7c 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -14,7 +14,7 @@ 对于根 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 后更新,同时允许缺失终态后缀的连续前缀。 +浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。在持久记录之外,每个被记录的顶层运行还会尽力镜像进可选的 `ctx.activities` 注册表成为一个 `workflow` activity:原本无人消费的 `workflow/phase` 与 `workflow/log` 事件加上成员生命周期行会流入其中,Web 客户端因此能看到会话日志有意不逐行携带的实时叙述;没有该注册表或镜像任何失败时,运行不受影响。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。 ## 渲染意图 diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 455d2ee54c..6cf4bebe3d 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -37,19 +37,23 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@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:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-workflow": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", + "@deepseek-ai/dsh-activity-local": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -58,7 +62,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", - "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^" } } diff --git a/packages/workflow/tool-workflow/src/activity.ts b/packages/workflow/tool-workflow/src/activity.ts new file mode 100644 index 0000000000..36b251b894 --- /dev/null +++ b/packages/workflow/tool-workflow/src/activity.ts @@ -0,0 +1,119 @@ +/** + * Best-effort activity mirror for top-level workflow runs: opens one + * `workflow` activity per run and streams the engine's `workflow/phase`, + * `workflow/log`, and member lifecycle events into it as text lines, so the + * Web client shows live progress the session log deliberately does not carry + * per line. Without a `ctx.activities` registry every operation is a no-op, + * and ANY mirror failure is logged and swallowed — the run is unaffected. + * + * @module @deepseek-ai/dsh-tool-workflow/activity + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ActivityHandle } from '@deepseek-ai/dsh-activity' +import type { WorkflowRun, WorkflowRunId, WorkflowStopReason } from '@deepseek-ai/dsh-workflow' + +declare module '@deepseek-ai/dsh-activity' { + interface ActivityKindMap { + workflow: 'workflow' + } +} + +/** Activity taps for the three points the tool already tracks a run at. */ +export interface WorkflowActivityMirror { + /** + * Open the run's activity. Call once per recorded top-level run. + * @param run - the started run (id and meta name). + * @param owner - the calling agent, when the call has one. + */ + start(run: WorkflowRun, owner: Agent | undefined): void + /** + * End the run's activity with the mapped stop reason. + * @param runId - the settled run. + * @param stopReason - how the run stopped. + */ + finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void + /** + * Drop tracking for a run that ends without a settled reason. First-wins + * with {@link finish}: after a normal finish this is inert. + * @param runId - the abandoned run. + */ + abandon(runId: WorkflowRunId): void +} + +/** Map a run's stop reason onto the activity outcome vocabulary. */ +function outcomeOf(stopReason: WorkflowStopReason): { status: 'completed' | 'failed' | 'killed'; detail: string } { + switch (stopReason) { + case 'completed': return { status: 'completed', detail: 'completed' } + case 'cancelled': return { status: 'killed', detail: 'cancelled' } + case 'error': return { status: 'failed', detail: 'error' } + } +} + +/** + * Create the run-to-activity mirror and subscribe the engine's live progress + * events for the runs it tracks. + * @param ctx - plugin context used for the optional registry and warning logs. + * @returns the mirror taps the tool wires beside its durable recorder. + */ +export function createWorkflowActivityMirror(ctx: Context): WorkflowActivityMirror { + const active = new Map() + const contained = (operation: string, act: () => void): void => { + try { + act() + } catch (error: unknown) { + ctx.logger.warn(`workflow activity mirror ${operation} failed: ${String(error)}`) + } + } + + ctx.on('workflow/phase', (info, title) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('phase', () => { + handle.updateDetail(title) + handle.append(`▸ ${title}\n`) + }) + }) + ctx.on('workflow/log', (info, message) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('log', () => { handle.append(`${message}\n`) }) + }) + ctx.on('workflow/agent-start', (info, agent) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('agent-start', () => { handle.append(`agent #${agent.seq} ${agent.label} started\n`) }) + }) + ctx.on('workflow/agent-end', (info, agent) => { + const handle = active.get(info.id) + if (handle === undefined) return + contained('agent-end', () => { handle.append(`agent #${agent.seq} ${agent.outcome}\n`) }) + }) + + return { + start(run, owner) { + contained('open', () => { + const activities = ctx.get('activities') + if (activities === undefined) return + active.set(run.id, activities.open({ + kind: 'workflow', + label: run.meta.name, + ...owner !== undefined ? { owner } : {}, + })) + }) + }, + finish(runId, stopReason) { + const handle = active.get(runId) + active.delete(runId) + if (handle === undefined) return + contained('finish', () => { handle.end(outcomeOf(stopReason)) }) + }, + abandon(runId) { + const handle = active.get(runId) + active.delete(runId) + if (handle === undefined) return + contained('abandon', () => { handle.end({ status: 'killed', detail: 'run abandoned' }) }) + }, + } +} diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 446c70ce03..0da8c0ff83 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -25,6 +25,7 @@ import type { } from './types.ts' // Declaration merge only: makes ctx.systemPrompt visible for the section registration. import type {} from '@deepseek-ai/dsh-system-prompt' +import { createWorkflowActivityMirror } from './activity.ts' export const name = 'tool-workflow' export const inject = ['tools', 'workflowEngine', 'systemPrompt'] @@ -207,6 +208,7 @@ export function apply(ctx: Context, config: Config): void { // fields; the assertion records that resolution, not a hidden fallback. const { toolName, maxResultChars } = config as ResolvedConfig const recorder = createWorkflowRecorder(ctx) + const mirror = createWorkflowActivityMirror(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({ @@ -291,7 +293,10 @@ export function apply(ctx: Context, config: Config): void { 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) + if (recordsRun) { + recorder.start(parent.session, run) + mirror.start(run, parent) + } // 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 @@ -323,9 +328,13 @@ export function apply(ctx: Context, config: Config): void { /* 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) + mirror.finish(run.id, result.stopReason) } } finally { - if (recordsRun) recorder.abandon(run.id) + if (recordsRun) { + recorder.abandon(run.id) + mirror.abandon(run.id) + } } } }, diff --git a/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts new file mode 100644 index 0000000000..50788d453f --- /dev/null +++ b/packages/workflow/tool-workflow/tests/activity-mirror.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local' +import type { WorkflowRun, WorkflowRunId, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' +import { createWorkflowActivityMirror } from '../src/activity.ts' + +function runStub(id = 'run-1'): { run: WorkflowRun; info: WorkflowRunInfo } { + const info = { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRunInfo + return { + run: { id: id as WorkflowRunId, meta: { name: 'docs-sweep' } } as WorkflowRun, + info, + } +} + +function retainedText(ctx: Context): string { + const row = ctx.activities.list()[0] + if (row === undefined) throw new Error('no mirrored activity') + return ctx.activities.read(row.id, 0).chunks.map(chunk => chunk.text).join('') +} + +describe('workflow activity mirror', () => { + it('opens one workflow activity per run and streams phase, log, and member lines', async () => { + const ctx = new Context() + await ctx.plugin(LocalActivityRegistry) + const mirror = createWorkflowActivityMirror(ctx) + const { run, info } = runStub() + mirror.start(run, undefined) + + const row = ctx.activities.list()[0] + expect(row?.kind).toBe('workflow') + expect(row?.label).toBe('docs-sweep') + + ctx.emit('workflow/phase', info, 'Scan') + ctx.emit('workflow/log', info, '3 files queued') + ctx.emit('workflow/agent-start', info, { seq: 1, label: 'reader', childId: 'c1' } as never) + ctx.emit('workflow/agent-end', info, { seq: 1, label: 'reader', childId: 'c1', outcome: 'completed' } as never) + expect(ctx.activities.get(row!.id).detail).toBe('Scan') + expect(retainedText(ctx)).toBe('▸ Scan\n3 files queued\nagent #1 reader started\nagent #1 completed\n') + + mirror.finish(run.id, 'completed') + const settled = ctx.activities.get(row!.id) + expect(settled.status).toBe('completed') + // The always-run abandon after a normal finish is inert. + mirror.abandon(run.id) + expect(ctx.activities.get(row!.id).status).toBe('completed') + }) + + it('maps cancelled and error stops, and ends an abandoned run as killed', async () => { + const ctx = new Context() + await ctx.plugin(LocalActivityRegistry) + const mirror = createWorkflowActivityMirror(ctx) + + const cancelled = runStub('run-c') + mirror.start(cancelled.run, undefined) + mirror.finish(cancelled.run.id, 'cancelled') + + const failed = runStub('run-e') + mirror.start(failed.run, undefined) + mirror.finish(failed.run.id, 'error') + + const orphaned = runStub('run-o') + mirror.start(orphaned.run, undefined) + mirror.abandon(orphaned.run.id) + + const byLabelOrder = ctx.activities.list() + expect(byLabelOrder.map(row => [row.status, row.detail])).toEqual([ + ['killed', 'cancelled'], + ['failed', 'error'], + ['killed', 'run abandoned'], + ]) + }) + + it('ignores events for untracked runs and stays inert without the registry', async () => { + const bare = new Context() + const inert = createWorkflowActivityMirror(bare) + const { run, info } = runStub() + // No registry: every tap is a no-op rather than a throw. + inert.start(run, undefined) + bare.emit('workflow/log', info, 'dropped') + inert.finish(run.id, 'completed') + inert.abandon(run.id) + + const ctx = new Context() + await ctx.plugin(LocalActivityRegistry) + createWorkflowActivityMirror(ctx) + // Events for a run the mirror never opened change nothing. + ctx.emit('workflow/phase', info, 'ghost') + expect(ctx.activities.list()).toEqual([]) + }) + + it('contains a throwing registry so the run path never breaks', async () => { + const ctx = new Context() + const warn = vi.fn() + ctx.logger.warn = warn as never + await ctx.plugin({ + name: 'broken-activities-probe', + apply(child: Context) { + child.provide('activities', { open() { throw new Error('mirror boom') } }) + }, + }) + const mirror = createWorkflowActivityMirror(ctx) + const { run } = runStub() + expect(() => { mirror.start(run, undefined) }).not.toThrow() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('workflow activity mirror open failed')) + }) +}) diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json index bfd38e61d4..a4cde5cbef 100644 --- a/packages/workflow/tool-workflow/tsconfig.json +++ b/packages/workflow/tool-workflow/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../activity/activity" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6878a775b..32f9a464b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -886,6 +886,110 @@ importers: specifier: workspace:^ version: link:../../interaction/user-approval + packages/activity/activity: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/activity/activity-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../activity + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/api/activity-controller: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity + '@deepseek-ai/dsh-activity-local': + specifier: workspace:^ + version: link:../../activity/activity-local + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/api/gateway: dependencies: '@deepseek-ai/dsh-typert-protocol': @@ -938,6 +1042,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-api-activity-controller': + specifier: workspace:^ + version: link:../activity-controller '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../gateway @@ -1240,6 +1347,9 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer + '@deepseek-ai/dsh-activity-local': + specifier: workspace:^ + version: link:../../activity/activity-local '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1607,6 +1717,9 @@ importers: '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets + '@deepseek-ai/dsh-api-activity-controller': + specifier: workspace:^ + version: link:../../api/activity-controller '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -1631,6 +1744,9 @@ importers: '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules + '@deepseek-ai/dsh-client-ui-activity': + specifier: workspace:^ + version: link:../../client/ui-activity '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../client/ui-agent-preset @@ -1976,6 +2092,45 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/client/ui-activity: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-activity-controller': + specifier: workspace:^ + version: link:../../api/activity-controller + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:^ + version: link:../ui-renderer + '@deepseek-ai/dsh-client-ui-session': + specifier: workspace:^ + version: link:../ui-session + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-agent-preset: devDependencies: '@deepseek-ai/cordis': @@ -7701,6 +7856,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7820,6 +7978,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -9538,6 +9699,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../activity/activity + '@deepseek-ai/dsh-activity-local': + specifier: workspace:^ + version: link:../../activity/activity-local '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -9690,6 +9857,9 @@ importers: '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../packages/acp/acp + '@deepseek-ai/dsh-activity': + specifier: workspace:^ + version: link:../../packages/activity/activity '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d5684867e6..3becddb8c7 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -5,6 +5,7 @@ "private": true, "type": "module", "dependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -12,15 +13,17 @@ "@deepseek-ai/cosmokit": "workspace:^", "@deepseek-ai/dsh": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-activity": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", + "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", @@ -32,44 +35,43 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-hooks-claude-code": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", - "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-persona": "workspace:^", - "@deepseek-ai/dsh-pwsh-local": "workspace:^", - "@deepseek-ai/dsh-terminal": "workspace:^", - "@deepseek-ai/dsh-terminal-bash": "workspace:^", - "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", + "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", @@ -80,6 +82,8 @@ "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", @@ -91,31 +95,30 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-http": "workspace:^", @@ -124,8 +127,6 @@ "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 05a4c8947c..095c33af7c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -105,6 +105,8 @@ export const SERVICE_PAGE: Record = { subprocess: 'subprocess.md', systemPrompt: 'system-prompt.md', jobs: 'jobs.md', + activities: 'activity.md', + activityController: 'activity.md', sessionTelemetry: 'session-telemetry.md', agentTeams: 'agent-team.md', tokenMeter: 'token-meter.md', @@ -168,6 +170,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API', + activityFeed: 'client-side interface-typed browser service — packages/api/activity-controller/README.md owns the API', } /** @@ -477,6 +480,16 @@ export const LINK_MAP: Readonly> = { PromptSection: 'system-prompt.md', SystemPrompt: 'system-prompt.md', ToolProviderResult: 'system-prompt.md', + ActivitiesChangedListener: 'activity.md', + ActivityHandle: 'activity.md', + ActivityId: 'activity.md', + ActivityOpen: 'activity.md', + ActivityOutputListener: 'activity.md', + ActivityRead: 'activity.md', + ActivitySnapshot: 'activity.md', + ActivityControlFrame: 'activity.md', + ActivityObserveFrame: 'activity.md', + ActivityObserveRequest: 'activity.md', JobDoneListener: 'jobs.md', JobId: 'jobs.md', JobRead: 'jobs.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a47f94b943..83983f294f 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -164,6 +164,13 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace.', }, + { + key: 'activityController', + pkg: 'api-activity-controller', + title: 'Host Activity Remote controller', + mode: 'core', + note: 'Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace.', + }, { key: 'invariants', pkg: 'invariants', @@ -537,6 +544,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-bash', 'tool-terminal', 'tool-subagent', 'tool-jobs'], note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.', }, + { + key: 'activities', + pkg: 'activity', + title: 'Live activity observation registry', + mode: 'seam', + implementations: ['activity-local'], + consumers: ['tool-bash', 'tool-pwsh', 'tool-workflow', 'activity-controller'], + note: 'Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched.', + }, { key: 'web', pkg: 'web', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index f83b621965..48bc458bfa 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -45,6 +45,10 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, + 'packages/activity/activity': { kind: 'none', reason: 'Pure observation seam for humans; activity output becomes model-visible only through producers\' existing tool results and jobs reads.' }, + 'packages/activity/activity-local': { kind: 'none', reason: 'In-memory observation provider; producers own every model-visible surface.' }, + 'packages/api/activity-controller': { kind: 'none', reason: 'Browser/Host observation transport; registers no prompt, tool, or session event.' }, + 'packages/client/ui-activity': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' }, 'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, 'packages/shell/shell-env': { kind: 'indirect', reason: 'The env service exposes managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' }, 'packages/shell/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 8a8bc37baa..11ae967059 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"], "@deepseek-ai/dsh-api-gateway/invariant": ["./packages/api/gateway/src/invariant.ts"], "@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"], + "@deepseek-ai/dsh-api-activity-controller": ["./packages/api/activity-controller/src/index.ts"], "@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"], "@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"], @@ -56,6 +57,7 @@ "@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/invariant": ["./packages/api/workspace-controller/src/invariant.ts"], "@deepseek-ai/dsh-api-workspace-controller/types": ["./packages/api/workspace-controller/src/types.ts"], + "@deepseek-ai/dsh-api-activity-controller/types": ["./packages/api/activity-controller/src/types.ts"], "@deepseek-ai/dsh-typert-protocol": ["./packages/typert/protocol/src/index.ts"], "@deepseek-ai/dsh-typert-protocol/types": ["./packages/typert/protocol/src/types.ts"], "@deepseek-ai/dsh-typert-loader": ["./packages/typert/loader/src/index.ts"], @@ -95,6 +97,8 @@ "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], "@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"], + "@deepseek-ai/dsh-activity/brand": ["./packages/activity/activity/src/brand.ts"], + "@deepseek-ai/dsh-activity/invariant": ["./packages/activity/activity/src/invariant.ts"], "@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"], "@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"], "@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"], @@ -245,6 +249,7 @@ "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"], "@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"], + "@deepseek-ai/dsh-client-ui-activity": ["./packages/client/ui-activity/src"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-user-questions": ["./packages/client/ui-user-questions/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], @@ -288,6 +293,7 @@ "./packages/preset/*/src", "./packages/subagent/*/src", "./packages/jobs/*/src", + "./packages/activity/*/src", "./packages/experimental/*/src", "./packages/workflow/*/src", "./packages/webhook/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index 291b813300..41f6aea41a 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -56,6 +56,7 @@ { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, + { "path": "./packages/api/activity-controller/tsconfig.client.json" }, { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/extensions/cordis-client-runner" }, @@ -77,6 +78,7 @@ { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-jobs" }, + { "path": "./packages/client/ui-activity" }, { "path": "./packages/client/ui-directory-picker-browse" }, { "path": "./packages/client/ui-directory-picker-native" }, { "path": "./packages/client/ui-goal" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 0bcbe50df3..d8bcd91c08 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -77,6 +77,7 @@ "apps/web/tests/subagent-interrupt-ui.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", "apps/web/tests/background-job-list.e2e.ts", + "apps/web/tests/live-activity-stream.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", "apps/web/tests/turn-tail-actions.e2e.ts", @@ -146,6 +147,7 @@ { "path": "./packages/api/gateway/tsconfig.host.json" }, { "path": "./packages/api/remotes/tsconfig.host.json" }, { "path": "./packages/api/session-controller/tsconfig.host.json" }, + { "path": "./packages/api/activity-controller/tsconfig.host.json" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" }, @@ -288,6 +290,8 @@ { "path": "./packages/jobs/jobs" }, { "path": "./packages/jobs/jobs-local" }, { "path": "./packages/jobs/tool-jobs" }, + { "path": "./packages/activity/activity" }, + { "path": "./packages/activity/activity-local" }, { "path": "./packages/experimental/agent-team" }, { "path": "./packages/experimental/tool-agent-team" }, { "path": "./packages/workflow/workflow" },