diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md new file mode 100644 index 0000000000..501cdcf801 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -0,0 +1,35 @@ +# Agent Note: The job registry absorbs the observation record; the standalone activity seam is removed + +Status: implemented + +English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md) + +## Problem + +The [activity observation seam](../feature/2026-08-24-activity-observation-seam.md) shipped live output streaming as a second registry beside `ctx.jobs`: producers registered the same work twice (a job for lifecycle, an activity for observation), kept the two terminal states consistent by hand (`observeBackgroundActivity` waited for `proc.done` before mapping the outcome so a pump failure could not freeze a wrong terminal state), correlated the rows through `ActivityCorrelation.jobId`, and wrapped every observation call in registry-absent degradation branches. The Web client maintained two rosters (session-control `jobs` frames and the activity control stream) and joined them per row. Measured before the merge: eight pairing call sites, and one producer population — background bash/pwsh (both registries), PTY sends and subagent delegations (jobs only), foreground workflow (activity only). + +The one activity without a job was the foreground workflow mirror. Everything else the split enabled — independent observers, absolute-offset reads, bounded retention — is a property of the record, not of the registry split. + +## Decision + +`ctx.jobs` owns the observation record; `packages/activity/`, `packages/api/activity-controller`, and the correlation vocabulary are removed. + +- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops. +- **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. +- **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. +- **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. +- **The wire moves to the session namespace.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable; `session.observeJob({sessionId?, jobId, from?})` streams anchor/output/status frames with the fenced read resolved from the request's session — the killJob authorization shape. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery moved into `session-controller` (`observe-job.ts`, client `ctx.jobOutput`). +- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `outputTotal` is present. The two-roster join is deleted. +- **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. + +## Rejected + +- **A `foreground: true` job mode** to keep the foreground workflow panel: it needs two coupled enforcement points (reported-at-birth and a `job_list` filter) whose divergence double-delivers or leaks rows to the model, for one edge feature replaceable by `run_in_background`. +- **Serving model reads from the record** (deleting `readOutput`): model reads are producer-formatted (truncation and spill notices, sandbox markers) and consuming; the record is raw, channel-labeled, and non-consuming. Unifying them moves producer-specific formatting into the registry or noise into the observer stream. +- **Keeping the split with shared implementation** (a common registry library): it removes the duplicated skeleton but keeps the real costs — double registration, terminal-state pairing, correlation, a second wire roster, and a second package family. + +The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argument against durable session events and control-stream framing for live output still holds and carries over unchanged: the record is process-local observation state, never a session event, so "model-visible ⟺ logged" is untouched. + +## Consequences + +Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `session-controller`. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md new file mode 100644 index 0000000000..40e795146c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -0,0 +1,35 @@ +# Agent Note:job 注册表吸收观测 record;独立的 activity seam 被移除 + +Status: implemented + +[English](2026-09-01-jobs-absorb-activity-record.md) | 中文 + +## 问题 + +[activity 观测 seam](../feature/2026-08-24-activity-observation-seam.zh.md) 把实时输出流做成了 `ctx.jobs` 旁边的第二个注册表:生产者要把同一份工作登记两次(job 管生命周期,activity 管观测),手工保持两个终态一致(`observeBackgroundActivity` 要等 `proc.done` 才映射 outcome,防止 pump 失败把错误终态冻上去),通过 `ActivityCorrelation.jobId` 关联两行,并给每个观测调用包上注册表缺失的降级分支。Web 客户端维护两份名册(session-control 的 `jobs` 帧与 activity control 流)并逐行做 join。合并前实测:八个配对调用点;生产者人口为——后台 bash/pwsh(双注册表)、PTY 发送与 subagent 委托(仅 jobs)、前台 workflow(仅 activity)。 + +唯一没有 job 的 activity 是前台 workflow 镜像。拆分带来的其他一切——独立观察者、绝对偏移读取、有界保留——都是 record 的性质,不是注册表拆分的性质。 + +## 决策 + +`ctx.jobs` 拥有观测 record;`packages/activity/`、`packages/api/activity-controller` 与 correlation 词表被移除。 + +- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。 +- **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 +- **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 +- **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 +- **wire 移入 session 命名空间。** `SessionJob.outputTotal`(恰在 record job 上存在)标记行可观测;`session.observeJob({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析——与 killJob 相同的授权形态。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制移入 `session-controller`(`observe-job.ts`,客户端 `ctx.jobOutput`)。 +- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。 +- **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 + +## 已否决 + +- **`foreground: true` job 模式**以保留前台 workflow 面板:它需要两个耦合的执行点(出生即 reported 与 `job_list` 过滤),两者一旦分叉就会双投递或向模型泄漏行,而它换来的只是一个可被 `run_in_background` 替代的边缘特性。 +- **用 record 服务模型读取**(删除 `readOutput`):模型读取是生产者格式化的(截断与 spill 提示、sandbox 标记)且消耗型;record 是原始、带 channel 标签、非消耗的。统一二者要么把生产者特有格式化搬进注册表,要么把噪声混入观察者流。 +- **保留拆分但共享实现**(公共注册表库):它消除重复骨架,但保留真正的成本——双重登记、终态配对、correlation、第二条 wire 名册与第二个包族。 + +[先前 seam note](../feature/2026-08-24-activity-observation-seam.zh.md) 反对用持久会话事件或 control 流承载实时输出的论证仍然成立并原样沿用:record 是进程本地观测状态,从不是会话事件,「模型可见 ⟺ 已记录」不受影响。 + +## 后果 + +每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `session-controller`。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 23074b5d38..3dd003c0e9 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -2,6 +2,8 @@ Status: implemented +Update: the output phase this note deferred now ships as the per-job observation record on `ctx.jobs` — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + English | [中文](2026-08-08-web-background-job-display.zh.md) ## Problem diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 06645267ee..5d1ee4bada 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -2,6 +2,8 @@ Status: implemented +Update:本文推迟的输出阶段现已作为 `ctx.jobs` 上的逐 job 观测 record 交付——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + [English](2026-08-08-web-background-job-display.md) | 中文 ## 问题 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 index 88109cb340..29ee08b1bb 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -2,6 +2,8 @@ Status: implemented +Superseded: the standalone seam described here was folded into `ctx.jobs` as the per-job observation record — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md); the durable-vs-live analysis below still holds. + English | [中文](2026-08-24-activity-observation-seam.zh.md) ## Problem 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 index b8aa938edf..97ac4f195c 100644 --- 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 @@ -2,6 +2,8 @@ 状态:implemented +Superseded:本文描述的独立 seam 已折入 `ctx.jobs`,成为逐 job 的观测 record——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md);下文关于持久化与实时通道的分析仍然成立。 + [English](2026-08-24-activity-observation-seam.md) | 中文 ## 问题 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md index e67ca84d96..daf6582bf2 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md @@ -2,6 +2,8 @@ Status: implemented +Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + English | [中文](2026-08-25-unified-task-list.zh.md) ## Problem diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md index 2a71b04ec3..624747396e 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md @@ -2,6 +2,8 @@ 状态:implemented +Superseded:本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + [English](2026-08-25-unified-task-list.md) | 中文 ## 问题 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 60ddefee1a..5386b94ae0 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -40,8 +40,6 @@ 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"] @@ -202,7 +200,6 @@ 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-job-stream.e2e.ts similarity index 84% rename from apps/web/tests/live-activity-stream.e2e.ts rename to apps/web/tests/live-job-stream.e2e.ts index 7c0ce1532b..65926f7463 100644 --- a/apps/web/tests/live-activity-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -1,5 +1,5 @@ -// Session-header live activities driven by a real background bash run: the -// roster row arrives over the activity control stream, expanding it opens the +// Session-header job list driven by a real background bash run: the job row +// arrives over the session control stream, expanding it opens the record // 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' @@ -19,11 +19,11 @@ import { import { newEnglishPage, saveFailureShot } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url)) -const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-activity-stream', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-job-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' +const SEED_ID = 'live-job-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" @@ -44,7 +44,7 @@ async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { +describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -69,7 +69,7 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { 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. + // job owner must be that exact live instance, never a second one. agent = await liveAgent(scaffold, SessionId(SEED_ID)) }, 120_000) @@ -79,13 +79,13 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { }) 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 task running' }) + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-streaming')) + const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ signal: new AbortController().signal, - callId: ToolCallId('live-activity-stream-e2e'), + callId: ToolCallId('live-job-stream-e2e'), name: 'bash', arguments: { command: COMMAND, description: 'Emit one live line then hold', run_in_background: true }, agent, @@ -95,11 +95,11 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { 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. + // The job row reaches the header over the session control stream. await trigger.waitFor({ timeout: 15_000 }) await trigger.click() - // Expanding the row opens the observation stream; the panel then renders + // Expanding the row opens the record 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 }) @@ -113,12 +113,12 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => { }, 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')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-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 task' }) + const idle = page.getByRole('button', { name: '1 background job' }) await idle.waitFor({ timeout: 20_000 }) await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-job-stream/settled.expected.md similarity index 100% rename from apps/web/tests/snapshots/live-activity-stream/settled.expected.md rename to apps/web/tests/snapshots/live-job-stream/settled.expected.md diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md similarity index 100% rename from apps/web/tests/snapshots/live-activity-stream/streaming.expected.md rename to apps/web/tests/snapshots/live-job-stream/streaming.expected.md diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 0f95b48551..38e021b1aa 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -95,7 +95,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/live-job-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.md b/docs/architecture.md index 8a2f9b772c..f36c956d9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,7 +129,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 | +| Stream live output to observers | declare `record: true` on the `ctx.jobs` start; 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 cc291fb018..67e2ae905c 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -133,7 +133,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` | | 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 | | 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 | -| 向观察者流式输出 | 打开一个 `ctx.activities` 句柄;Web 客户端负责渲染 | +| 向观察者流式输出 | 在 `ctx.jobs` 启动时声明 `record: true`;Web 客户端负责渲染 | | 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 | | 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 | | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6c0867eeb8..db2b8db956 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -45,8 +45,6 @@ flowchart LR pkg_api_workspace_controller["api-workspace-controller"] svc_workspaceController["ctx.workspaceController
Host Workspace Remote controller"] svc_directoryPickerController["ctx.directoryPickerController
Host directory-picking 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"] @@ -186,11 +184,6 @@ 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"] @@ -214,6 +207,7 @@ 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"] @@ -223,13 +217,10 @@ flowchart LR pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] - 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_api_activity_controller --> svc_activityController pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences @@ -342,10 +333,6 @@ 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_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_agent_spine_demo @@ -382,6 +369,7 @@ flowchart LR svc_invariants --> pkg_session svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs + svc_jobs --> pkg_tool_pwsh svc_jobs --> pkg_tool_subagent svc_jobs --> pkg_tool_terminal svc_llm --> pkg_agent_loop @@ -489,7 +477,6 @@ flowchart LR | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `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.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | -| `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. | @@ -535,8 +522,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` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | -| `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.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `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` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 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/config-catalog.md b/docs/config-catalog.md index 424eff1c5e..64b72e0ff7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -33,22 +33,6 @@ Depends on: `Stream` (`@agentclientprotocol/sdk`) Source: [`packages/acp/acp/src/index.ts:75`](../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` @@ -292,24 +276,6 @@ 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-gateway` @@ -339,10 +305,14 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean + /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number } ``` -Source: [`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) @@ -988,10 +958,14 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */ + settledRetainBytes?: number } ``` -Source: [`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) +Source: [`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts) @@ -2712,12 +2686,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 + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } ``` -Source: [`packages/shell/tool-bash/src/index.ts:35`](../packages/shell/tool-bash/src/index.ts) +Source: [`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts) @@ -2879,12 +2853,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 + /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */ + recordPollMs?: number } ``` -Source: [`packages/shell/tool-pwsh/src/index.ts:60`](../packages/shell/tool-pwsh/src/index.ts) +Source: [`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts) @@ -3158,7 +3132,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:32`](../packages/workflow/tool-workflow/src/index.ts) @@ -3454,7 +3428,6 @@ 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)) @@ -3468,6 +3441,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-input-trigger` ([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-jobs` ([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-message-feedback` ([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) @@ -3528,7 +3502,6 @@ 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/event-producer-consumer.md b/docs/event-producer-consumer.md index 48aacf9dc6..fc05e2ec6b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -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`) | [`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/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 218ec66b69..8727d17738 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: f1a2f29c9e61567a92fb8b98737446c2258087a9 -module-graph.zh.md: 86845aa00035ab18ead49e6f90a2e66c18984028 +module-graph.md: 6b69abd956658b5bf17f0b49158ec8f366888d94 +module-graph.zh.md: 4af048cc46db9bcf990a702d29970a67ff222949 diff --git a/docs/module-graph.md b/docs/module-graph.md index f1a2f29c9e..6b69abd956 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -107,12 +107,7 @@ flowchart TD subgraph group_acp["packages/acp"] pkg_acp["acp"] end - subgraph group_activity["packages/activity"] - pkg_activity["activity"] - pkg_activity_local["activity-local"] - end subgraph group_api["packages/api"] - pkg_api_activity_controller["api-activity-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -141,7 +136,6 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_store["client-store"] - pkg_client_ui_activity["client-ui-activity"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] @@ -154,6 +148,7 @@ flowchart TD pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] + pkg_client_ui_jobs["client-ui-jobs"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] @@ -727,12 +722,6 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_activity --> pkg_agent - pkg_activity --> pkg_brand - pkg_activity --> pkg_invariants - pkg_activity --> pkg_jobs - pkg_activity --> pkg_llm - pkg_activity --> pkg_session pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -873,20 +862,6 @@ flowchart TD pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools - pkg_activity_local --> pkg_activity - pkg_activity_local --> pkg_agent - pkg_activity_local --> pkg_invariants - pkg_activity_local --> pkg_scope - pkg_api_activity_controller --> pkg_activity - pkg_api_activity_controller --> pkg_agent - pkg_api_activity_controller --> pkg_api_gateway - pkg_api_activity_controller --> pkg_client_connection - pkg_api_activity_controller --> pkg_client_store - pkg_api_activity_controller --> pkg_invariants - pkg_api_activity_controller --> pkg_jobs - pkg_api_activity_controller --> pkg_llm - pkg_api_activity_controller --> pkg_session - pkg_api_activity_controller --> pkg_typert_protocol pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction pkg_command_compact --> pkg_invariants @@ -1017,7 +992,6 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session - pkg_tool_workflow --> pkg_activity pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1075,7 +1049,6 @@ flowchart TD pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol - pkg_tool_bash --> pkg_activity pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1087,7 +1060,6 @@ flowchart TD pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_activity pkg_tool_pwsh --> pkg_agent pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs @@ -1366,7 +1338,6 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | -| [`client-ui-activity`](../packages/client/ui-activity) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | @@ -1379,6 +1350,7 @@ flowchart TD | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | — | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — | @@ -1525,7 +1497,6 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1548,8 +1519,6 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) | -| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1572,7 +1541,7 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | @@ -1581,8 +1550,8 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 86845aa000..4af048cc46 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -109,12 +109,7 @@ flowchart TD subgraph group_acp["packages/acp"] pkg_acp["acp"] end - subgraph group_activity["packages/activity"] - pkg_activity["activity"] - pkg_activity_local["activity-local"] - end subgraph group_api["packages/api"] - pkg_api_activity_controller["api-activity-controller"] pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] @@ -143,7 +138,6 @@ flowchart TD pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] pkg_client_store["client-store"] - pkg_client_ui_activity["client-ui-activity"] pkg_client_ui_agent_preset["client-ui-agent-preset"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] @@ -156,6 +150,7 @@ flowchart TD pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_input_trigger["client-ui-input-trigger"] + pkg_client_ui_jobs["client-ui-jobs"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_message_feedback["client-ui-message-feedback"] pkg_client_ui_model_selection["client-ui-model-selection"] @@ -729,12 +724,6 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_activity --> pkg_agent - pkg_activity --> pkg_brand - pkg_activity --> pkg_invariants - pkg_activity --> pkg_jobs - pkg_activity --> pkg_llm - pkg_activity --> pkg_session pkg_headless --> pkg_agent pkg_headless --> pkg_agent_default_model pkg_headless --> pkg_invariants @@ -875,20 +864,6 @@ flowchart TD pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_session_projection pkg_hooks_codex --> pkg_tools - pkg_activity_local --> pkg_activity - pkg_activity_local --> pkg_agent - pkg_activity_local --> pkg_invariants - pkg_activity_local --> pkg_scope - pkg_api_activity_controller --> pkg_activity - pkg_api_activity_controller --> pkg_agent - pkg_api_activity_controller --> pkg_api_gateway - pkg_api_activity_controller --> pkg_client_connection - pkg_api_activity_controller --> pkg_client_store - pkg_api_activity_controller --> pkg_invariants - pkg_api_activity_controller --> pkg_jobs - pkg_api_activity_controller --> pkg_llm - pkg_api_activity_controller --> pkg_session - pkg_api_activity_controller --> pkg_typert_protocol pkg_command_compact --> pkg_commands pkg_command_compact --> pkg_compaction pkg_command_compact --> pkg_invariants @@ -1019,7 +994,6 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session - pkg_tool_workflow --> pkg_activity pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm @@ -1077,7 +1051,6 @@ flowchart TD pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol - pkg_tool_bash --> pkg_activity pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1089,7 +1062,6 @@ flowchart TD pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval - pkg_tool_pwsh --> pkg_activity pkg_tool_pwsh --> pkg_agent pkg_tool_pwsh --> pkg_invariants pkg_tool_pwsh --> pkg_jobs @@ -1368,7 +1340,6 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | -| [`client-ui-activity`](../packages/client/ui-activity) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | @@ -1381,6 +1352,7 @@ flowchart TD | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | — | | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — | +| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | — | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — | @@ -1527,7 +1499,6 @@ flowchart TD | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1550,8 +1521,6 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | -| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) | -| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1574,7 +1543,7 @@ flowchart TD | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | @@ -1583,8 +1552,8 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index d75bd368d5..f3340ebeb4 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -44,7 +44,6 @@ 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 589a72d81f..2e4523f03d 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -44,7 +44,6 @@ | [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.md b/docs/subsystems/activity.md deleted file mode 100644 index 000bdc30be..0000000000 --- a/docs/subsystems/activity.md +++ /dev/null @@ -1,141 +0,0 @@ -# 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 deleted file mode 100644 index 1fc79e9d59..0000000000 --- a/docs/subsystems/activity.zh.md +++ /dev/null @@ -1,141 +0,0 @@ -# 实时活动观察 - -[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/jobs.md b/docs/subsystems/jobs.md index 092da7b6f7..1b8e58f27b 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -23,7 +23,7 @@ interface JobKindMap { ## Producer contract -`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. +`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` with the job's producer face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. ```ts type-equiv /** @@ -38,7 +38,9 @@ interface JobStart { label: string /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of record + * retention: it bounds the consuming model surface, never + * {@link JobRegistry.readRecord}. */ outputLimitBytes?: number /** @@ -49,11 +51,22 @@ interface JobStart { */ owner?: Agent /** - * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * Declares an observable output record beside the model-facing surfaces: + * {@link RunningJob.append} retains chunks in a bounded ring that any number + * of observers read at absolute byte offsets through + * {@link JobRegistry.readRecord}, and snapshots carry + * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}. + * Without the declaration `append` logs and drops. */ - run(): JobHooks + record?: true + /** + * Start the work after preflight and synchronously return its hooks. Called + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the record append and live-detail writers. + */ + run(job: RunningJob): JobHooks } ``` @@ -71,13 +84,16 @@ interface JobHooks { * Resolves after the producer releases its resources, not merely when work * finishes. Must not reject; the runtime converts a rejection to `failed`. * If teardown cancellation throws, the runtime may force-fail only the - * registry record without claiming that the work stopped. + * registry record without claiming that the work stopped. Settlement also + * ends the record: fold any record-filling pump into this promise so the + * final drain lands before the registry trims and closes the stream. */ done: Promise /** * Consume output produced since the previous call. The producer formats * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. + * job has one consuming cursor. Independent of the record: this is the + * model-facing projection, {@link JobRegistry.readRecord} the observer one. */ readOutput?(): string } @@ -95,6 +111,70 @@ interface JobOutcome { } ``` +## Observation record + +A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. + +```ts type-equiv +/** + * Producer face of one registered job, handed to {@link JobStart.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — both methods log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +interface RunningJob { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one record chunk. Offsets advance by the chunk's UTF-8 byte + * length; an empty chunk is dropped without waking observers. Without a + * {@link JobStart.record} declaration the chunk is logged and dropped. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the snapshot's status detail with a live progress line (`3/10`). + * Works for every job, with or without a record. + * @param detail - the new detail line. + */ + updateDetail(detail: string): void +} +``` + +```ts type-equiv +/** One retained record chunk returned by {@link JobRegistry.readRecord}. */ +interface JobRecordChunk { + /** 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?: JobChannel + /** Producer-reported loss immediately before this chunk. */ + gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link JobRegistry.readRecord}. */ +interface JobRecordRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobRecordChunk[] + /** + * Offset to resume from — the record's current `outputTotal`. Always a + * chunk boundary: appends land whole and trimming only advances chunk + * starts, and consumers concatenate `chunks` under that assumption, so a + * provider serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean +} +``` + ## Consumer views Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state, including the teardown cancel that drains an owner or the service. @@ -121,7 +201,10 @@ interface JobSnapshot { ownerSession?: SessionId /** Current lifecycle state. */ status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ + /** + * Kind-specific status detail: live progress while the producer updates it + * through {@link RunningJob.updateDetail}, the terminal detail once settled. + */ detail?: string /** Epoch ms when the job was registered. */ startedAt: number @@ -135,6 +218,17 @@ interface JobSnapshot { * otherwise spend a model request per teardown layer. */ reported: boolean + /** + * Total UTF-8 bytes ever appended to the record — the offset the next chunk + * starts at. Present exactly when the job declared {@link JobStart.record}. + */ + outputTotal?: number + /** + * Offset of the oldest retained record byte. Greater than zero exactly when + * retention dropped the head; a reader starting below it gets a lossy read. + * Present exactly when the job declared {@link JobStart.record}. + */ + outputEarliest?: number } ``` @@ -154,7 +248,7 @@ interface JobRead { ## Service behavior -The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onJobDone` and `onJobsChanged` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. +The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, non-consuming `readRecord`, `kill`, bounded `wait`, failure-isolated `onJobDone`, `onJobsChanged`, and `onOutput` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity, and `retainBytes` (default 262144) with `settledRetainBytes` (default 16384) bound each declared record's live and settled retention. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. @@ -176,6 +270,8 @@ Implementations must honor these semantics: - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. - start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected. +- Record 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 — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle. ```ts cordis-catalog /** @@ -274,6 +370,31 @@ abstract onJobDone(listener: JobDoneListener): () => void */ abstract onJobsChanged(listener: JobsChangedListener): () => void +/** + * Read retained record 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`). + * Never marks the job reported. Throws for an unknown or foreign job, a job + * without a {@link JobStart.record} declaration, or a negative or + * non-integer offset. + * @param id - job 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 readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead + +/** + * Register an effect-scoped observer of record advancement — one signal per + * committed append and one at settlement, carrying only the job id. A + * consumer schedules a {@link readRecord} from its own cursor; the registry + * never pushes payloads. Delivery is owner-relative on the same terms as + * {@link onJobsChanged}. Listeners are contained and never awaited. + * @param listener - receives the id whose record advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: JobOutputListener): () => void + /** * Attach an effect-scoped controller that can read and stop jobs. It serves the * owners its registering context's scope covers, and {@link start} refuses an diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 9dc390eb30..5928c640c4 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -176,6 +176,8 @@ Implementations must honor these semantics: - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. - Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. - start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected. +- Record 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 — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle. ```ts cordis-catalog /** @@ -274,6 +276,31 @@ abstract onJobDone(listener: JobDoneListener): () => void */ abstract onJobsChanged(listener: JobsChangedListener): () => void +/** + * Read retained record 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`). + * Never marks the job reported. Throws for an unknown or foreign job, a job + * without a {@link JobStart.record} declaration, or a negative or + * non-integer offset. + * @param id - job 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 readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead + +/** + * Register an effect-scoped observer of record advancement — one signal per + * committed append and one at settlement, carrying only the job id. A + * consumer schedules a {@link readRecord} from its own cursor; the registry + * never pushes payloads. Delivery is owner-relative on the same terms as + * {@link onJobsChanged}. Listeners are contained and never awaited. + * @param listener - receives the id whose record advanced. + * @returns disposer that unregisters the listener. + */ +abstract onOutput(listener: JobOutputListener): () => void + /** * Attach an effect-scoped controller that can read and stop jobs. It serves the * owners its registering context's scope covers, and {@link start} refuses an diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 8ad0ddf475..3ec8288309 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/session.md -session.md: d35395fb80a6558e15b82cdb76559f59e492f425 -session.zh.md: d3bc750b5c198e38efa94c88f95dcb9406c9059b +session.md: da62600efed8b0cd5bb134fd664582634eb0d2c3 +session.zh.md: 448d173d694d17afc9e03cd689ae07a1369b155c diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index d35395fb80..da62600efe 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -740,6 +740,18 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * @returns one complete baseline followed by live replacement frames. */ @Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable ``` Types: [SessionHeader](persistence.md) · [SessionId](core.md) · [SessionSearchRequest](session-query.md) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d3bc750b5c..448d173d69 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -744,6 +744,18 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH * @returns one complete baseline followed by live replacement frames. */ @Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained record output from an absolute byte offset, + * then its terminal status once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session resolves the fenced-read caller; the registry rejects a + * job the session does not own, a job without a record, or an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable ``` Types: [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md) diff --git a/packages/activity/README.i18n.yaml b/packages/activity/README.i18n.yaml deleted file mode 100644 index 3030bc2d1d..0000000000 --- a/packages/activity/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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: bc9e67703b2888048eacd3a8e060aaa739948eac -README.zh.md: 26f339b8f262b90001355b690f736e2d1089cab5 diff --git a/packages/activity/README.md b/packages/activity/README.md deleted file mode 100644 index bc9e67703b..0000000000 --- a/packages/activity/README.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -description: "The activity package group: the non-consuming live-output observation plane under packages/activity/, for readers choosing or navigating the family." -kind: "package-group" ---- - -# activity/ — streaming-output observation family - -English | [中文](README.zh.md) - -## Summary - -This family lets any number of independent observers watch a long-running producer's live output without touching the model-facing paths: an activity is an append-only bounded output stream plus live status, read at absolute byte offsets that survive eviction. `activity` defines the registry contract and vocabulary; `activity-local` implements the in-memory ring-buffer registry. The plane is optional and invisible to the model — a composition without it loses only live observation. - -## Table of Contents - -- [Packages](#packages) -- [Related documentation](#related-documentation) -- [Dev Note](#dev-note) - ------ - - -## Packages - -The Service Definition and its local implementation: - -| 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` | - - -## Related documentation - -- [`dsh-api-activity-controller`](../api/activity-controller/README.md) — the Web transport and client model over this plane. -- [`dsh-client-ui-activity`](../client/ui-activity/README.md) — the session-header task list rendering it. -- [docs/subsystems/activity.md](../../docs/subsystems/activity.md) — the subsystem reference: offsets, retention, listener delivery. -- [Activity observation seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) — the design decision; background bash/pwsh and the workflow tool are the shipped producers. - - -## Dev Note - -None. diff --git a/packages/activity/README.zh.md b/packages/activity/README.zh.md deleted file mode 100644 index 26f339b8f2..0000000000 --- a/packages/activity/README.zh.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -description: "activity 包组:packages/activity/ 下的非消费实时输出观察面,供选择或导航该家族的读者使用。" -kind: "package-group" ---- - -# activity/ — 流式输出观察家族 - -[English](README.md) | 中文 - -## 概述 - -这个家族让任意数量的独立观察者在不触碰模型可见路径的前提下观看长时运行生产者的实时输出:一个 activity 是一条只追加、有界的输出流加实时状态,以跨淘汰仍有效的绝对字节偏移读取。`activity` 定义注册表契约与词汇;`activity-local` 实现内存环形缓冲注册表。该观察面是可选的且对模型不可见——没有它的组合只失去实时观察。 - -## 目录 - -- [包](#packages) -- [相关文档](#related-documentation) -- [开发备注](#dev-note) - ------ - - -## 包 - -Service Definition 与其本地实现: - -| 包 | 角色 | ctx 键 | -|---|---|---| -| [`activity/`](activity/README.zh.md) | 定义观察注册表与词汇 | `ctx.activities` | -| [`activity-local/`](activity-local/README.zh.md) | 实现内存环形缓冲注册表 | 注册在 `ctx.activities` | - - -## 相关文档 - -- [`dsh-api-activity-controller`](../api/activity-controller/README.zh.md)——架在该面之上的 Web 传输与客户端模型。 -- [`dsh-client-ui-activity`](../client/ui-activity/README.zh.md)——渲染它的会话头部任务列表。 -- [docs/subsystems/activity.md](../../docs/subsystems/activity.zh.md)——子系统参考:偏移、留存、监听器投递。 -- [activity 观察面 Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)——设计决策;后台 bash/pwsh 与 workflow 工具是已交付的生产者。 - - -## 开发备注 - -无。 diff --git a/packages/activity/activity-local/README.i18n.yaml b/packages/activity/activity-local/README.i18n.yaml deleted file mode 100644 index 9487171fa8..0000000000 --- a/packages/activity/activity-local/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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: ef010e1fe859d2ffd56d736b24e490d73bbb49a5 -README.zh.md: 7d9779fc64e7e7d6e47e3a2e78fb80ca097abb6c diff --git a/packages/activity/activity-local/README.md b/packages/activity/activity-local/README.md deleted file mode 100644 index ef010e1fe8..0000000000 --- a/packages/activity/activity-local/README.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: "The in-memory activity registry: configurable ring-buffer retention (retainBytes, settledRetainBytes), absolute offsets across eviction, owner-scoped cleanup, and layered listener delivery for live-output observation." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity-local - -English | [中文](README.zh.md) - -## Summary - -`dsh-activity-local` makes `ctx.activities` real: mount its one row and every producer's live output is retained in a bounded in-process ring readers address by absolute byte offsets, with fresh snapshots and chunk copies handed out — never live state. Retention is configurable per deployment, records outlive their producer fibers, and owner disposal cleans up exactly the disposed session's rows. Choose it for the single-Host web deployment this repository ships; durable or cross-process observation needs a different provider. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount one `cordis.yml` row (`name: '@deepseek-ai/dsh-activity-local'`) beside `dsh-agent`; producers and consumers then reach the registry as `ctx.activities`. - -### 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. | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-activity-local) is the exhaustive source for every accepted field. - -### 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. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -One process-wide `LocalActivityRegistry` keeps a map of tracked records, each holding its chunk ring and cursor facts. Listeners are layered by registration scope exactly like the jobs registry, so delivery stays owner-relative in one instance, and every listener is contained. Trimming walks whole chunks and only tail-cuts a single oversized chunk on a UTF-8 boundary. - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | The registry: rings, retention, fencing, scoped listener layers, disposal | -| [`src/invariant.ts`](src/invariant.ts) | Package invariant companion | - -
- ------ - - -## Further Exploration - -- [`dsh-activity`](../activity/README.md) — the contract this provider implements. -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — offsets, retention, and delivery in one reference. -- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the Remote streams reading this registry. - ------ - - -## 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 - - - -These limits define when the provider is a poor fit. They are current package constraints, not a task backlog. - -- **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. - - -### Dev Note - -
-Working context for maintainers — click to expand - -None. - -
diff --git a/packages/activity/activity-local/README.zh.md b/packages/activity/activity-local/README.zh.md deleted file mode 100644 index 7d9779fc64..0000000000 --- a/packages/activity/activity-local/README.zh.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: "内存 activity 注册表:可配置的环形缓冲留存(retainBytes、settledRetainBytes)、跨淘汰的绝对偏移、按 owner 作用域的清理,以及实时输出观察的分层监听器投递。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity-local - -[English](README.md) | 中文 - -## 概述 - -`dsh-activity-local` 让 `ctx.activities` 落地:挂载它的一行后,每个生产者的实时输出保留在有界的进程内环里,读者以绝对字节偏移寻址,拿到的是新鲜快照与 chunk 拷贝——从不是活状态。留存按部署可配置,记录比生产者 fiber 活得久,owner 释放只清理被释放会话的行。为本仓库交付的单 Host web 部署选择它;持久或跨进程观察需要别的 provider。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -在 `dsh-agent` 旁挂载一行 `cordis.yml`(`name: '@deepseek-ai/dsh-activity-local'`);生产者与消费者随即以 `ctx.activities` 触达注册表。 - -### 留存 - -每个 activity 保留一个偏移跨淘汰仍绝对的 chunk 环:丢弃头部会前移 `outputEarliest`,单个超过活跃上限的 chunk 只保留 UTF-8 安全的尾部并带 `gapBefore` 标记。结算把环裁到 settled 上限,让完成的 activity 仍展示尾部而不占活跃预算。 - -| 配置 | 默认 | 含义 | -|---|---|---| -| `retainBytes` | `262144` | 每个 activity 的活跃输出留存,UTF-8 字节。 | -| `settledRetainBytes` | `16384` | activity 结算后保留的留存量。 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-activity-local)是每个可接受字段的穷尽来源。 - -### 生命周期 - -记录比生产者 fiber 活得久。owner 的第一个 activity 在确切的 `Agent` 作用域上挂一个清理;owner 释放会终结仍开放的记录(`killed`、`owner disposed`)并移除它们、announce 该移除。服务释放终结一切、清空存储并 announce 每个被清空的 owner。两者都不等待生产者——注册表不拥有任何执行资源。 - ------ - - -## 理解实现 - -
-实现内幕——点击展开 - -一个进程级 `LocalActivityRegistry` 维护被跟踪记录的 map,每条持有自己的 chunk 环与游标事实。监听器按注册作用域分层,与 jobs 注册表完全一致,投递在单实例内保持 owner 相对,且每个监听器都被包住。裁剪按整 chunk 走,只对单个超大 chunk 在 UTF-8 边界上做尾部截取。 - -| 文件 | 角色 | -|---|---| -| [`src/index.ts`](src/index.ts) | 注册表:环、留存、栅栏、作用域监听层、释放 | -| [`src/invariant.ts`](src/invariant.ts) | 包不变式伴生 | - -
- ------ - - -## 进一步探索 - -- [`dsh-activity`](../activity/README.zh.md)——本 provider 实现的契约。 -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——偏移、留存与投递的单一参考。 -- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——读取本注册表的 Remote 流。 - ------ - - -## 模型体验 - -无:provider 只存储与提供观察状态;模型可见表面全部归生产者所有。 - -#### KV Cache 影响 - -无;本包从不组装或改动 provider 请求。 - -## 已知限制与延期工作 - - - -这些限制说明该 provider 何时不合适。它们是当前包约束,不是任务积压。 - -- **无主桶没有准入上限**——有主生产者已受 jobs 准入策略约束,但打开大量无主 activity 的插件会让存储一直增长到服务释放。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -无。 - -
diff --git a/packages/activity/activity-local/package.json b/packages/activity/activity-local/package.json deleted file mode 100644 index d3b08606ce..0000000000 --- a/packages/activity/activity-local/package.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-activity-local", - "description": "Process-local implementation of the DeepSeek Harness streaming-output observation seam", - "version": "0.1.2-alpha.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 deleted file mode 100644 index caabb5499e..0000000000 --- a/packages/activity/activity-local/src/index.ts +++ /dev/null @@ -1,438 +0,0 @@ -/** - * 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 deleted file mode 100644 index 7e15b7db09..0000000000 --- a/packages/activity/activity-local/src/invariant.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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 deleted file mode 100644 index 557046d617..0000000000 --- a/packages/activity/activity-local/tests/activity.spec.ts +++ /dev/null @@ -1,413 +0,0 @@ -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 deleted file mode 100644 index 4b341f7e16..0000000000 --- a/packages/activity/activity-local/tests/loader-composition.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 0bddbff854..0000000000 --- a/packages/activity/activity-local/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index 14d65fd03c..0000000000 --- a/packages/activity/activity/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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: 466bdf5aac8718b197495f477f012e6314e41dd6 -README.zh.md: 32dea93f72f12ae1f439220193b8fa5b994bbe45 diff --git a/packages/activity/activity/README.md b/packages/activity/activity/README.md deleted file mode 100644 index 466bdf5aac..0000000000 --- a/packages/activity/activity/README.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -description: "The streaming-output observation contract (ctx.activities): open/append/end handles, non-consuming offset reads, owner fencing, and the shared pull pump for producers and consumers of live activity output." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity - -English | [中文](README.zh.md) - -## Summary - -`dsh-activity` lets a long-running producer publish live output and status once, and any number of independent consumers read it without stealing bytes from the model: reads are non-consuming, addressed by absolute UTF-8 byte offsets that survive eviction, and fenced to the owning session. The registry is a pure observation plane — it starts nothing, cancels nothing, and is invisible to the model, so producers keep their execution resources and their existing model-facing surfaces. This package ships the contract only; load [`dsh-activity-local`](../activity-local/README.md) to get a working registry, and without one `ctx.activities` does not exist and observation silently degrades to none. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Use this package when you write a producer that mirrors live work, or a consumer that renders it; the composition mounts an implementation row (`@deepseek-ai/dsh-activity-local`), never this contract package directly. - -### Producing an activity - -`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. Producer plugins extend `ActivityKindMap` with their opaque id namespace and may link an activity to its tool call and background job through `ActivityCorrelation`. Access the registry with `ctx.get('activities')` and contain every observation failure: the plane is optional, and the observed work must never depend on it. - -### Consuming activities - -`get(id, caller?)` and `list(caller?)` return fresh snapshots; listing includes only caller-owned and unowned activities, 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; a reader below the oldest retained byte gets `lossy: true`, never an error. `onActivitiesChanged(listener)` observes visible-set changes so consumers re-read rather than accumulate deltas, and `onOutput(listener)` signals stream advancement carrying only the id; both deliver owner-relative and contain every listener. - -### Pumping a substrate - -`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. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -The package holds the abstract `ActivityRegistry` service (a direct mount throws, naming an implementation to load), the vocabulary types, the branded `ActivityId`, the pump, and the invariant companion. Offsets are absolute so an observer's cursor never depends on what other observers did, and eviction moves only the earliest retained byte — the lossy flag is how a late reader learns the head is gone. - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Abstract `ActivityRegistry` service and the direct-mount guard | -| [`src/types.ts`](src/types.ts) | Vocabulary: kinds, snapshots, handles, reads, listeners | -| [`src/brand.ts`](src/brand.ts) | Branded `ActivityId` (browser-safe leaf) | -| [`src/pump.ts`](src/pump.ts) | `pumpActivityOutput` pull-substrate pump | -| [`src/invariant.ts`](src/invariant.ts) | Snapshot invariants installed on the change feed | - -
- ------ - - -## Further Exploration - -- [`dsh-activity-local`](../activity-local/README.md) — the in-memory implementation and its retention config. -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — the subsystem reference: offsets, retention, listener delivery. -- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the Remote streams serving this plane to browsers. -- [Observation-seam Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) — why the plane is non-consuming and model-invisible. - ------ - - -## 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 - - - -These limits define when the contract is a poor fit. They are current package constraints, not a task backlog. - -- **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. - - -### Dev Note - -
-Working context for maintainers — click to expand - -None. - -
diff --git a/packages/activity/activity/README.zh.md b/packages/activity/activity/README.zh.md deleted file mode 100644 index 32dea93f72..0000000000 --- a/packages/activity/activity/README.zh.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -description: "流式输出观察契约(ctx.activities):open/append/end 句柄、非消费偏移读取、owner 栅栏,以及供实时活动输出生产者与消费者共用的拉取泵。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-activity - -[English](README.md) | 中文 - -## 概述 - -`dsh-activity` 让长时运行的生产者只发布一次实时输出与状态,任意数量的独立消费者读取它而不会偷走模型的字节:读取是非消费的,以跨淘汰仍有效的绝对 UTF-8 字节偏移寻址,并以归属会话为栅栏。注册表是纯观察面——它什么都不启动、什么都不取消、对模型不可见,因此生产者保留自己的执行资源与既有模型可见表面。本包只交付契约;加载 [`dsh-activity-local`](../activity-local/README.zh.md) 获得可用注册表,没有实现时 `ctx.activities` 不存在,观察静默退化为无。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -编写镜像实时工作的生产者、或渲染它的消费者时使用本包;组合挂载的是实现行(`@deepseek-ai/dsh-activity-local`),从不直接挂载这个契约包。 - -### 生产一个 activity - -`open(spec): ActivityHandle` 校验 kind、label 与确切的活跃 owner,然后原子注册记录并返回生产者面:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)`,以及先到先赢的 `end(outcome)`。在 `end` 之后——包括 teardown 强制收尾——`append` 与 `updateDetail` 记日志后丢弃而非抛出,因此生产者的尾部 flush 不会破坏自己的 teardown。生产者插件以自己的不透明 id 命名空间扩展 `ActivityKindMap`,并可通过 `ActivityCorrelation` 把 activity 关联到其工具调用与后台 job。用 `ctx.get('activities')` 访问注册表并包住每个观察失败:该面是可选的,被观察的工作绝不能依赖它。 - -### 消费 activities - -`get(id, caller?)` 与 `list(caller?)` 返回新鲜快照;列表只含调用方拥有与无主的 activity,可预测的 id 使该栅栏成为边界。`read(id, from, caller?)` 返回与 `[from, total)` 重叠的留存 chunk 加恢复偏移,不消费任何东西;读到最旧留存字节之前的位置得到 `lossy: true`,绝不报错。`onActivitiesChanged(listener)` 观察可见集变化,消费者据此重读而非累积增量;`onOutput(listener)` 以仅含 id 的信号提示流推进;两者按 owner 相对投递并包住每个监听器。 - -### 泵送一个底座 - -`pumpActivityOutput(handle, sources, { pollMs, done })` 是底座为非消费偏移读取器(subprocess 的 `readFrom(fromByte)` 家族)的生产者的共用泵:它以有界节奏复制带标签的增量,把有损的源读取标为 `gapBefore`,并在 `done` 结算后再排空一次,自己从不调用 `end()`。 - ------ - - -## 理解实现 - -
-实现内幕——点击展开 - -本包持有抽象 `ActivityRegistry` 服务(直接挂载会抛出并指名应加载的实现)、词汇类型、branded `ActivityId`、泵与 invariant 伴生。偏移是绝对的,观察者的游标从不依赖其他观察者做了什么;淘汰只前移最旧留存字节——lossy 标志就是迟到读者得知头部已丢的方式。 - -| 文件 | 角色 | -|---|---| -| [`src/index.ts`](src/index.ts) | 抽象 `ActivityRegistry` 服务与直接挂载防护 | -| [`src/types.ts`](src/types.ts) | 词汇:kind、快照、句柄、读取、监听器 | -| [`src/brand.ts`](src/brand.ts) | branded `ActivityId`(浏览器安全叶子) | -| [`src/pump.ts`](src/pump.ts) | `pumpActivityOutput` 拉取底座泵 | -| [`src/invariant.ts`](src/invariant.ts) | 装在变化流上的快照不变式 | - -
- ------ - - -## 进一步探索 - -- [`dsh-activity-local`](../activity-local/README.zh.md)——内存实现及其留存配置。 -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——子系统参考:偏移、留存、监听器投递。 -- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——把该面提供给浏览器的 Remote 流。 -- [观察面 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 deleted file mode 100644 index 89372dcf7d..0000000000 --- a/packages/activity/activity/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "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.2-alpha.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 deleted file mode 100644 index 8df786ffb9..0000000000 --- a/packages/activity/activity/src/brand.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * 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 deleted file mode 100644 index 4e3927abe5..0000000000 --- a/packages/activity/activity/src/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * 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 deleted file mode 100644 index 6753fa7b0e..0000000000 --- a/packages/activity/activity/src/invariant.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** 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 deleted file mode 100644 index 4cc1eae22d..0000000000 --- a/packages/activity/activity/src/pump.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 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 deleted file mode 100644 index 3ea1a42332..0000000000 --- a/packages/activity/activity/src/types.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionId } from '@deepseek-ai/dsh-session' -import type { ToolCallId } 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?: ToolCallId - /** 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`. Always a - * chunk boundary: appends land whole and trimming only advances chunk - * starts, and consumers concatenate `chunks` under that assumption, so a - * provider serving partial chunks would silently duplicate text. - */ - 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 deleted file mode 100644 index 306cd4533a..0000000000 --- a/packages/activity/activity/tests/invariant.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 2dd685c1d3..0000000000 --- a/packages/activity/activity/tests/service.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index f4deee6327..0000000000 --- a/packages/activity/activity/tsconfig.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "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 deleted file mode 100644 index 85ebf647c1..0000000000 --- a/packages/api/activity-controller/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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: ffdf97aa94ab808ea4f95a9b8b8a36e3e04f43ed -README.zh.md: 580d00a11d3e8743d606c9c9ca98c4553235cc50 diff --git a/packages/api/activity-controller/README.md b/packages/api/activity-controller/README.md deleted file mode 100644 index ffdf97aa94..0000000000 --- a/packages/api/activity-controller/README.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -description: "Activity Remote streams for browsers: the activity.control roster stream, per-activity activity.observe output streams with flushMs coalescing and maxFrameBytes budgets, and the ctx.activityFeed client model." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-api-activity-controller - -English | [中文](README.zh.md) - -## Summary - -`dsh-api-activity-controller` streams the live-activity plane to browsers: a client follows one roster stream for every visible task row and opens one observation stream per expanded panel, resuming across reconnects from its own cursor. 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. A composition without `ctx.activities` still serves an empty baseline, so the client degrades to no live rows rather than an error. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount the host row beside `dsh-typert-registry` and load the client half through the plugin manifest; UI features then consume `ctx.activityFeed` instead of raw streams. - -### The two Remote streams - -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. The Remote tier deliberately serves every session's rows to any connected browser, and `observe` satisfies the registry's owner fence with the mirrored owner: the per-session fence is an in-process contract, and this single-user local BFF reads across it exactly as the session control stream does for `jobsBySession`. -- `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 generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-activity-controller) is the exhaustive source for every accepted field. - -### The client feed - -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. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -The Host side mirrors the registry into owner buckets (`ActivityFeed`), fans control frames out to per-generation followers, and runs each observation as an async generator that subscribes before its first read so no append lands between anchor and stream. The client side keeps observation state per activity with a resume cursor; release closures bind the exact entry they were minted for, so a superseded observation's late release can neither tear down nor clear its successor. - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | `ActivityController`: the two Remote stream methods | -| [`src/feed.ts`](src/feed.ts) | Owner-bucket roster mirror and control-stream followers | -| [`src/observe.ts`](src/observe.ts) | Per-activity observation generator: anchor, coalescing, terminal status | -| [`src/client/model.ts`](src/client/model.ts) | Roster and observation state with the bounded render tail | -| [`src/client/service.ts`](src/client/service.ts) | `ctx.activityFeed`: reference-counted observation streams | -| [`src/client/index.ts`](src/client/index.ts) | Client plugin: roster stream wiring and the service install | - -
- ------ - - -## Further Exploration - -- [`dsh-activity`](../../activity/activity/README.md) — the observation contract this transport reads. -- [`dsh-client-ui-activity`](../../client/ui-activity/README.md) — the task list rendering `ctx.activityFeed`. -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — the plane end to end, wire shapes included. - ------ - - -## 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 - - - -These limits define when the transport is a poor fit. They are current package constraints, not a task backlog. - -- **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. - - -### Dev Note - -
-Working context for maintainers — click to expand - -None. - -
diff --git a/packages/api/activity-controller/README.zh.md b/packages/api/activity-controller/README.zh.md deleted file mode 100644 index 580d00a11d..0000000000 --- a/packages/api/activity-controller/README.zh.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -description: "面向浏览器的 Activity Remote 流:activity.control roster 流、带 flushMs 合并与 maxFrameBytes 预算的逐活动 activity.observe 输出流,以及 ctx.activityFeed 客户端模型。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-api-activity-controller - -[English](README.md) | 中文 - -## 概述 - -`dsh-api-activity-controller` 把实时活动面流式提供给浏览器:客户端跟随一条 roster 流获得每个可见任务行,并为每个展开的面板打开一条观察流,重连后从自己的游标续传。它是纯读表面——什么都不启动、什么都不取消、从不触碰模型可见的消费游标;线路路径只读注册表的非消费偏移。没有 `ctx.activities` 的组合仍提供空 baseline,客户端退化为没有实时行而非报错。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -在 `dsh-typert-registry` 旁挂载宿主行,并经插件清单加载客户端半侧;UI 功能随即消费 `ctx.activityFeed` 而非裸流。 - -### 两条 Remote 流 - -宿主 `ActivityController`(命名空间 `activity`)暴露两个 `@Remote({ mode: 'stream' })` 方法: - -- `control(signal)`——一份完整 roster baseline,之后每次注册表变化按 owner 会话推送整桶替换帧(与 session control 流承载 jobs 的自愈形态一致)。没有注册表的组合返回空 baseline。Remote 层有意把所有会话的行提供给任何已连接的浏览器,`observe` 以镜像到的 owner 替调用方满足注册表的 owner 栅栏:按会话的栅栏是进程内契约,这个单用户本地 BFF 跨栅栏读取,与 session control 流对 `jobsBySession` 的做法完全一致。 -- `observe({ activityId, from? }, signal)`——一帧 `opened` 锚点、合并后的 `output` 帧(`flushMs` 窗口,`maxFrameBytes` 软预算;单个更大的 chunk 整块发出),已结算活动排空后一帧终态 `status`,随后本代正常关闭。重连调用方传上一帧的 `next` 续传;落后于留存窗口的续传带 `lossy` 标记到达。status 与 output 走同一条流,结算绝不会与仍开放的输出通道赛跑。 - -| 配置 | 默认 | 含义 | -|---|---|---| -| `flushMs` | `100` | 新输出后进行一次观察读取前的合并窗口。 | -| `maxFrameBytes` | `65536` | 每个观察输出帧的软字节预算。 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-activity-controller)是每个可接受字段的穷尽来源。 - -### 客户端 feed - -客户端半侧(`./client`)安装 `ctx.activityFeed`:`ClientActivityModel` 镜像 roster(last-wins 桶、按代整体替换)并累积每个被观察活动的有界渲染尾部,`ClientActivityFeed.observe(id)` 打开引用计数的观察流,跨 carrier 代从模型游标续传。 - ------ - - -## 理解实现 - -
-实现内幕——点击展开 - -宿主侧把注册表镜像进 owner 桶(`ActivityFeed`),向每代 follower 扇出 control 帧,并把每个观察跑成先订阅再首读的 async generator,锚点与流之间不会漏 append。客户端侧按活动保存观察状态与续传游标;release 闭包绑定自己创建时的那个条目,被替代观察的迟到 release 既拆不掉也清不掉后继者。 - -| 文件 | 角色 | -|---|---| -| [`src/index.ts`](src/index.ts) | `ActivityController`:两个 Remote 流方法 | -| [`src/feed.ts`](src/feed.ts) | owner 桶 roster 镜像与 control 流 follower | -| [`src/observe.ts`](src/observe.ts) | 逐活动观察 generator:锚点、合并、终态 status | -| [`src/client/model.ts`](src/client/model.ts) | roster 与观察状态,含有界渲染尾部 | -| [`src/client/service.ts`](src/client/service.ts) | `ctx.activityFeed`:引用计数的观察流 | -| [`src/client/index.ts`](src/client/index.ts) | 客户端插件:roster 流接线与服务安装 | - -
- ------ - - -## 进一步探索 - -- [`dsh-activity`](../../activity/activity/README.zh.md)——本传输所读的观察契约。 -- [`dsh-client-ui-activity`](../../client/ui-activity/README.zh.md)——渲染 `ctx.activityFeed` 的任务列表。 -- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——端到端的观察面,含线路形态。 - ------ - - -## 模型体验 - -无:activity 传输是浏览器与宿主的观察状态,不注册任何 prompt、工具或会话事件。 - -#### KV Cache 影响 - -无;本包从不组装或改动 provider 请求。 - -## 已知限制与延期工作 - - - -这些限制说明该传输何时不合适。它们是当前包约束,不是任务积压。 - -- **注册表存在性在构造时采样**——controller 之后才挂载的注册表在 controller 重载前保持不被观察。 -- **roster 不命名水位**——观察流自行锚定;把 roster 行与观察偏移相关联是客户端策略。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -无。 - -
diff --git a/packages/api/activity-controller/package.json b/packages/api/activity-controller/package.json deleted file mode 100644 index a5250ad8ac..0000000000 --- a/packages/api/activity-controller/package.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "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.2-alpha.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 deleted file mode 100644 index 7338ae3b9b..0000000000 --- a/packages/api/activity-controller/src/client/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** 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 deleted file mode 100644 index 75354f1255..0000000000 --- a/packages/api/activity-controller/src/client/model.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** 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)) - // A fresh view anchored past offset zero starts after an evicted head - // (fresh observations anchor at the registry's earliest retained byte), so - // it owes the same gap mark a live observer earned from lossy reads. A - // resume that already accumulated text keeps its recorded gap state. - const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 - const view: ObservedActivity = { - activityId: id, - text: existing?.view.text ?? '', - gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead, - 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 deleted file mode 100644 index 2947e5bf04..0000000000 --- a/packages/api/activity-controller/src/client/service.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** 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, existing) - } - const entry = this.startObservation(id) - this.entries.set(key, entry) - return this.releaser(key, entry) - } - - /** - * Release closures bind the exact entry they were minted for, never the - * map's current occupant: a later `observe()` on the same id may have - * replaced a stopped entry, and decrementing or disposing through the key - * alone would tear down that newer stream's references. - */ - private releaser(key: string, entry: ObservationEntry): () => void { - let released = false - return () => { - if (released) return - released = true - entry.refs -= 1 - if (entry.refs > 0) return - if (this.entries.get(key) === entry) this.entries.delete(key) - entry.stopped = true - void entry.dispose().then(() => { - // Clear the view only while no successor observation holds the key: - // a re-expand inside the dispose round-trip already re-anchored the - // model, and a stale clear would blank its panel for good. - if (this.entries.has(key)) return - // 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 deleted file mode 100644 index 34091dad8d..0000000000 --- a/packages/api/activity-controller/src/feed.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** 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 deleted file mode 100644 index a2d62c4a33..0000000000 --- a/packages/api/activity-controller/src/index.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * 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 deleted file mode 100644 index f85e7460b8..0000000000 --- a/packages/api/activity-controller/src/invariant.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** 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 deleted file mode 100644 index f793b11496..0000000000 --- a/packages/api/activity-controller/src/observe.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** 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 deleted file mode 100644 index bc2e39808d..0000000000 --- a/packages/api/activity-controller/src/types.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** 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 { ToolCallId } 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?: ToolCallId - /** 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 deleted file mode 100644 index 757a0af115..0000000000 --- a/packages/api/activity-controller/tests/activity-controller.host.spec.ts +++ /dev/null @@ -1,306 +0,0 @@ -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 deleted file mode 100644 index 231703ba13..0000000000 --- a/packages/api/activity-controller/tests/client-apply.client.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 31f5d18c33..0000000000 --- a/packages/api/activity-controller/tests/client-model.client.spec.ts +++ /dev/null @@ -1,183 +0,0 @@ -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('marks a fresh observation anchored past the evicted head', () => { - // Fresh observations anchor at the registry's earliest retained byte, so - // from === earliest > 0 means the head was already discarded. - const fresh = new ClientActivityModel() - opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) - expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) - - // A retry that accumulated no text yet earns the mark the same way. - const retried = new ClientActivityModel() - opened(retried) - expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) - opened(retried, { from: 6, earliest: 6, total: 6 }) - expect(retried.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 deleted file mode 100644 index ba051d2e23..0000000000 --- a/packages/api/activity-controller/tests/client-service.client.spec.ts +++ /dev/null @@ -1,276 +0,0 @@ -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 - /** Hold the dispose promise open until {@link releaseDispose}. */ - deferDispose = false - private disposeRelease: (() => void) | undefined - private disposePending: Promise | undefined - 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?.() - if (this.deferDispose) { - // Repeat disposals (releaser plus the consumer's finally) share one - // deferred promise, so releaseDispose resumes every waiter. - this.disposePending ??= new Promise((resolve) => { this.disposeRelease = resolve }) - return this.disposePending - } - return Promise.resolve() - } - - releaseDispose(): void { - this.disposeRelease?.() - } - - 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('a late release of a superseded observation neither tears down nor clears its successor', async () => { - const { model, feed, streams } = bench() - const firstStop = feed.observe(ID) - const first = streams[0]!.stream - first.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - first.push({ type: 'status', status: 'completed' }) - await tick() - // A successor observation replaces the settled entry while the first - // observer still holds its releaser. - const secondStop = feed.observe(ID) - const second = streams[1]!.stream - second.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - firstStop() - await tick() - expect(second.disposed).toBe(false) - expect(model.getSnapshot().observed[String(ID)]).toBeDefined() - - secondStop() - await tick() - expect(second.disposed).toBe(true) - expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() - }) - - it('a re-observation inside the dispose round-trip keeps its fresh view', async () => { - const { model, feed, streams } = bench() - const stop = feed.observe(ID) - const first = streams[0]!.stream - first.deferDispose = true - first.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - stop() - // Re-expand while the previous generation's dispose is still in flight. - feed.observe(ID) - const second = streams[1]!.stream - second.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' }) - await tick() - - first.releaseDispose() - await tick() - // The stale post-dispose clear must not blank the successor's view. - expect(model.getSnapshot().observed[String(ID)]).toBeDefined() - second.push({ type: 'output', chunks: [{ at: 0, text: 'alive' }], next: 5 }) - await tick() - expect(model.getSnapshot().observed[String(ID)]?.text).toBe('alive') - }) - - 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 deleted file mode 100644 index 4c3262f093..0000000000 --- a/packages/api/activity-controller/tests/controller.host.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 deleted file mode 100644 index d2934629b4..0000000000 --- a/packages/api/activity-controller/tsconfig.client.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "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 deleted file mode 100644 index 4193d47109..0000000000 --- a/packages/api/activity-controller/tsconfig.host.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "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 deleted file mode 100644 index 2a0b0e33f7..0000000000 --- a/packages/api/activity-controller/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "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 deleted file mode 100644 index 0552d1b1cf..0000000000 --- a/packages/api/activity-controller/tsdown.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 f6f3b74990..a4334e0e75 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -66,7 +66,6 @@ "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-settings-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 baaccbbdbf..e7fc7757e9 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -13,7 +13,6 @@ import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/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' @@ -32,8 +31,6 @@ 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. @@ -149,7 +146,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, - subagentsRemote, sessionRemote, workspaceRemote, activityRemote, + subagentsRemote, sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index f19acea2c2..5a11bae49d 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -26,7 +26,6 @@ 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 1b4d4ca1ae..56f664d499 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -66,9 +66,6 @@ { "path": "../session-controller/tsconfig.client.json" }, - { - "path": "../activity-controller/tsconfig.client.json" - }, { "path": "../settings-controller" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index a0aab5ea93..65fca35ee0 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -60,9 +60,6 @@ { "path": "../session-controller/tsconfig.host.json" }, - { - "path": "../activity-controller/tsconfig.host.json" - }, { "path": "../workspace-controller/tsconfig.host.json" }, diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 2f2192cde2..2e5ca38bf5 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,6 +29,8 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. +A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record, and `session.observeJob` streams that record from an absolute byte offset — one `opened` anchor, coalesced `output` frames, then one terminal `status` — with the fenced read resolved from the request's session. Record reads are non-consuming: the model-facing cursor and completion-notice state never observe them. The Client adapter exposes `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views a task-list UI renders; observers share one stream per job and resume from their model cursor across reconnects. + The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. ----- @@ -40,6 +42,8 @@ The Session object also carries local submission echoes: `session.beginSubmissio |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | Maximum physical size of a cold Session artifact eligible for blankness verification; `0` disables probes | | `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | +| `observeFlushMs` | `100` | Coalescing window after new record output before a job-observation read, in milliseconds | +| `observeMaxFrameBytes` | `65,536` | Soft byte budget per job-observation output frame; one larger chunk ships whole | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index adec1a5b4b..eaa8457e88 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -81,11 +81,6 @@ - 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 50a3583899..a203607a5d 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -41,7 +41,6 @@ "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:^", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 510d0653f3..85b67e7d08 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -95,10 +95,6 @@ - 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' - - id: cordis-host-runner name: '@deepseek-ai/dsh-cordis-host-runner' @@ -258,10 +254,10 @@ name: '@deepseek-ai/dsh-client-ui-reference' - # 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' + # Background jobs: the session-header roster with on-demand streaming + # record panels over the session job-output service. + - id: ui-jobs + name: '@deepseek-ai/dsh-client-ui-jobs' # Read-only active Schedule catalog. The shipped Web graph resolves the # client package but leaves it disabled; the explicit Schedule overlay diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index b6e34b5a79..0a58a1a78c 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -45,7 +45,6 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "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-settings-controller": "workspace:^", @@ -55,7 +54,7 @@ "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-ui-activity": "workspace:^", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", diff --git a/packages/client/README.md b/packages/client/README.md index 3e45bac9b0..367a6d39ce 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -55,7 +55,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — | -| [`ui-activity/`](ui-activity/README.md) | Lists this session's tasks — jobs joined with their live-output activities | — | +| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — | | [`ui-schedule/`](ui-schedule/README.md) | Lists the current Session's active reminders in a read-only header catalog | — | | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces | — | | [`ui-permission-presets/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index e624cd5f87..05a0143ce4 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -55,7 +55,7 @@ kind: "package-group" | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent(子智能体)导航、子级 transcript(文本记录)状态与内联引用 | — | -| [`ui-activity/`](ui-activity/README.zh.md) | 列出本会话的任务——后台任务与其实时输出 activity 的合并视图 | — | +| [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — | | [`ui-schedule/`](ui-schedule/README.zh.md) | 在只读标题栏目录中列出当前 Session 的活动提醒 | — | | [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择 | — | | [`ui-permission-presets/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式 | — | diff --git a/packages/client/ui-activity/README.md b/packages/client/ui-activity/README.md deleted file mode 100644 index 6630558922..0000000000 --- a/packages/client/ui-activity/README.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -description: "The session-header task list: background jobs joined per row with their live-output activities, expandable streaming panels, running/finished sections, and the graceful no-registry degradation." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-client-ui-activity - -English | [中文](README.zh.md) - -## Summary - -`dsh-client-ui-activity` puts this session's running work in one header control: background jobs joined per row with their correlated live-output activities, plus standalone activities such as workflow runs. Expanding an observable row streams its real output into an embedded terminal panel while it runs; collapsing stops the stream, so output only flows while someone is watching. Live rows lead with a ticking duration over a kind-and-status second line, settled rows compact behind a section heading, and without the activity registry the job rows still render — the control degrades to a plain job list rather than disappearing. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Load the plugin through the web-app manifest; it renders nothing until the session can see at least one task, so an ordinary conversation never grows a control for a capability it is not using. - -### The per-row join - -Jobs and activities stay separate planes ([why](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md)); the join happens per row in this component. A job row keeps the job's lifecycle, duration, and model-visible `detail` from the `jobsBySession` mirror, and gains an expandable output panel when an activity carries its `correlation.jobId`; an activity without a job keeps its own row. Rows without an observable activity render as static rows with no expansion affordance. - -### The expanded panel - -Expanding an observable row opens that activity's observation stream from `ctx.activityFeed` into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.activityFeed` and the standard `useSessions` hook — the component holds no transport state. Observation follows visibility: one `useEffect` opens the stream for the expanded row's activity and closes it on collapse, unmount, or popover dismissal. - -| File | Role | -|---|---| -| [`src/client/ActivityListAction.tsx`](src/client/ActivityListAction.tsx) | The merged task list: join, sections, durations, panels | -| [`src/client/index.ts`](src/client/index.ts) | Slot registration and the dictionaries | -| [`src/client/locales.ts`](src/client/locales.ts) | The `activity` namespace copy (zh source of truth) | - -
- ------ - - -## Further Exploration - -- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the feed service and streams behind the panel. -- [Unified task list Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-unified-task-list.md) — why the join lives in the UI projection. -- [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. - ------ - - -## Model Experience - -None, as this package renders host-observed state and 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 - - - -These limits define current package constraints, not a task backlog. - -- **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. - - -### Dev Note - -
-Working context for maintainers — click to expand - -None. - -
diff --git a/packages/client/ui-activity/README.zh.md b/packages/client/ui-activity/README.zh.md deleted file mode 100644 index b02949f251..0000000000 --- a/packages/client/ui-activity/README.zh.md +++ /dev/null @@ -1,93 +0,0 @@ ---- -description: "会话头部任务列表:后台任务与其实时输出 activity 逐行合并、可展开的流式面板、进行中/已结束分组,以及无注册表时的优雅退化。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-client-ui-activity - -[English](README.md) | 中文 - -## 概述 - -`dsh-client-ui-activity` 把本会话正在跑的工作放进一个头部控件:后台任务与其关联的实时输出 activity 逐行合并,外加 workflow 运行等独立 activity。展开可观察的行,其真实输出在运行期间流入内嵌终端面板;收起即停流——只有有人在看时输出才流动。进行中的行以跳动时长为主行、kind 与状态为副行,已结束的行折叠在分组标题之后;没有 activity 注册表时 job 行照常渲染——控件退化为普通任务列表而不是消失。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -经 web-app 清单加载该插件;会话一个任务都看不到时它完全不渲染,普通会话不会为未使用的能力多出控件。 - -### 逐行 join - -jobs 与 activity 保持为两个独立的面([缘由](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md));合并逐行发生在本组件内。job 行保留来自 `jobsBySession` 镜像的生命周期、时长与模型可见 `detail`,当某个 activity 以 `correlation.jobId` 关联到它时获得可展开的输出面板;没有 job 的 activity 保留自己的行。没有可观察 activity 的行渲染为无展开交互的静态行。 - -### 展开的面板 - -展开可观察的行即把该 activity 在 `ctx.activityFeed` 上的观察流打开进内嵌终端面板。面板的复制控件复制命令(而非输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不画自己的运行状态点——上方的行已携带状态。保留缺口与流失败以面板上方的提示呈现。 - ------ - - -## 理解实现 - -
-实现内幕——点击展开 - -头部操作区里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹层;弹层通过测量锚点把自己贴合进视口。所有数据经 `ctx.activityFeed` 与标准 `useSessions` 钩子到达——组件不持有任何传输状态。观察跟随可见性:一个 `useEffect` 为展开行的 activity 开流,在收起、卸载或弹层关闭时关流。 - -| 文件 | 角色 | -|---|---| -| [`src/client/ActivityListAction.tsx`](src/client/ActivityListAction.tsx) | 合并任务列表:join、分组、时长、面板 | -| [`src/client/index.ts`](src/client/index.ts) | slot 注册与词典 | -| [`src/client/locales.ts`](src/client/locales.ts) | `activity` 命名空间文案(zh 为真源) | - -
- ------ - - -## 进一步探索 - -- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——面板背后的 feed 服务与流。 -- [统一任务列表 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md)——join 为何住在 UI 投影层。 -- [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md)——面板所配置的 `TerminalBlock` 表面。 - ------ - - -## 模型体验 - -无:本包只为人渲染宿主观察到的状态与实时输出,不触碰任何 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 连接成一条流;分通道着色是后续呈现优化。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -无。 - -
diff --git a/packages/client/ui-activity/src/client/index.ts b/packages/client/ui-activity/src/client/index.ts deleted file mode 100644 index 39f43df9e1..0000000000 --- a/packages/client/ui-activity/src/client/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Task-list plugin, browser half: contributes one session-header action that - * renders this session's tasks — background jobs joined with their correlated - * live-output activities, plus standalone activities such as workflow runs. - * Job rows arrive through the `jobsBySession` list mirror; activity rows and - * per-row observation streams arrive through the `activityFeed` client - * service. This plugin holds no transport state of its own. - */ -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: session and activity state, the slot registry, and dictionaries. */ -export const inject = ['sessions', '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 subagent catalog: session lineage reads before running work. - order: 20, - locale: NS, - inject: (): ActivityListInjected => ({ - hooks: { activity: ctx.activityFeed.state }, - observe: id => ctx.activityFeed.observe(id), - }), - }, ActivityListAction), - ) -} diff --git a/packages/client/ui-activity/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml similarity index 70% rename from packages/client/ui-activity/README.i18n.yaml rename to packages/client/ui-jobs/README.i18n.yaml index bae875ee0e..b968d83fab 100644 --- a/packages/client/ui-activity/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -1,6 +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: 6630558922cf9f1e2b772bcc6334a21f91591acb -README.zh.md: b02949f2518c85ba931e0adf76946c390b564d1a +# pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md +README.md: b93f6b6f9dcb0af26c077634181427105e0b3a1b +README.zh.md: 17809b2203b34bdc78b34984a71fb755d3f67a05 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md new file mode 100644 index 0000000000..b93f6b6f9d --- /dev/null +++ b/packages/client/ui-jobs/README.md @@ -0,0 +1,93 @@ +--- +description: "The session-header background-job list: expandable streaming record panels, running/finished sections, and static rows for jobs without a record." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-jobs + +English | [中文](README.zh.md) + +## Summary + +`dsh-client-ui-jobs` puts this session's background jobs in one header control. Every row carries the job's lifecycle, ticking duration, and model-visible `detail` from the `jobsBySession` mirror; a job that declared an observation record additionally offers an expandable panel that streams its real output while it runs. Collapsing stops the stream, so output only flows while someone is watching. Live rows lead with a ticking duration over a kind-and-status second line; settled rows compact behind a section heading; jobs without a record (subagent delegations, PTY sends) render as static rows with no expansion affordance. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Load the plugin through the web-app manifest; it renders nothing until the session can see at least one job, so an ordinary conversation never grows a control for a capability it is not using. + +### One row per job + +The session control stream's `jobsBySession` mirror is the single roster: each `SessionJob` row carries lifecycle, duration, and the model-visible `detail`, and its `outputTotal` field is present exactly when the job declared a record — that presence is what makes a row expandable. There is no second roster to join. + +### The expanded panel + +Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobOutput` and the standard `useSessions` hook — the component holds no transport state. Observation follows visibility: one `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover dismissal. + +| File | Role | +|---|---| +| [`src/client/JobListAction.tsx`](src/client/JobListAction.tsx) | The job list: sections, durations, panels | +| [`src/client/index.ts`](src/client/index.ts) | Slot registration and the dictionaries | +| [`src/client/locales.ts`](src/client/locales.ts) | The `job` namespace copy (zh source of truth) | + +
+ +----- + + +## Further Exploration + +- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` mirror and the `session.observeJob` stream behind the panel. +- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the record semantics. +- [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. + +----- + + +## Model Experience + +None, as this package renders host-observed state and 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 + + + +These limits define current package constraints, not a task backlog. + +- **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. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md new file mode 100644 index 0000000000..17809b2203 --- /dev/null +++ b/packages/client/ui-jobs/README.zh.md @@ -0,0 +1,93 @@ +--- +description: "会话头部后台任务列表:可展开的流式 record 面板、进行中/已结束分组,以及无 record 任务的静态行。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-jobs + +[English](README.md) | 中文 + +## 概述 + +`dsh-client-ui-jobs` 把本会话的后台任务放进一个头部控件。每一行都携带 `jobsBySession` 镜像里该 job 的生命周期、跳动时长与模型可见的 `detail`;声明了观测 record 的 job 额外提供可展开的面板,在运行期间流式呈现其真实输出。收起即停流——只有有人在看时输出才流动。进行中的行以跳动时长为主行、kind 与状态为副行;已结束的行折叠在分组标题之后;没有 record 的 job(subagent 委托、PTY 发送)渲染为无展开操作的静态行。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +通过 web-app 清单加载本插件;在会话看得到至少一个 job 之前它不渲染任何东西,因此普通对话不会为未使用的能力长出控件。 + +### 一 job 一行 + +session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJob` 行携带生命周期、时长与模型可见的 `detail`,其 `outputTotal` 字段恰在 job 声明了 record 时存在——这个存在性就是行可展开的依据。不存在需要 join 的第二份名册。 + +### 展开的面板 + +展开可观察的行会从 `ctx.jobOutput` 打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 + +----- + + +## 理解实现 + +
+实现内幕——点击展开 + +头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobOutput` 与标准 `useSessions` hook 到达——组件不持有任何传输状态。观测跟随可见性:一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 + +| 文件 | 角色 | +|---|---| +| [`src/client/JobListAction.tsx`](src/client/JobListAction.tsx) | 任务列表:分组、时长、面板 | +| [`src/client/index.ts`](src/client/index.ts) | slot 注册与词典 | +| [`src/client/locales.ts`](src/client/locales.ts) | `job` 命名空间文案(zh 为事实源) | + +
+ +----- + + +## 进一步探索 + +- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 面板背后的 `jobsBySession` 镜像与 `session.observeJob` 流。 +- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有 record 语义的注册表契约。 +- [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。 + +----- + + +## 模型体验 + +无。本包为人类渲染宿主观测到的状态与实时输出,不触碰任何提示词、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 + +#### KV 缓存影响 + +无;本包从不组装或发送 provider 请求。 + +## 已知限制与延期工作 + + + +这些限制界定当前包约束,不是任务清单。 + +- **没有 kill 控件**——取消仍归模型的 `job_kill`;人工 kill 卡在 [web 任务显示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题上。 +- **不渲染 channel 标签**——stdout 与 stderr 块拼接为一条流;按 channel 着色是展示层的后续工作。 + + +### 开发备注 + +
+维护者工作语境——点击展开 + +无。 + +
diff --git a/packages/client/ui-activity/package.json b/packages/client/ui-jobs/package.json similarity index 83% rename from packages/client/ui-activity/package.json rename to packages/client/ui-jobs/package.json index cd558c41d2..5b29f894b6 100644 --- a/packages/client/ui-activity/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-ui-activity", - "description": "Session-header task list: background jobs joined with their live-output activities, with on-demand streaming output panels", + "name": "@deepseek-ai/dsh-client-ui-jobs", + "description": "Session-header background-job list with on-demand streaming record panels", "version": "0.1.2-alpha.2", "type": "module", "main": "lib/index.js", @@ -29,7 +29,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/client/ui-activity" + "directory": "packages/client/ui-jobs" }, "publishConfig": { "access": "public" @@ -39,7 +39,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-activity-controller": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -49,6 +48,7 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, @@ -61,7 +61,7 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-api-activity-controller", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives" diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css similarity index 100% rename from packages/client/ui-activity/src/client/ActivityListAction.module.css rename to packages/client/ui-jobs/src/client/JobListAction.module.css diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx similarity index 61% rename from packages/client/ui-activity/src/client/ActivityListAction.tsx rename to packages/client/ui-jobs/src/client/JobListAction.tsx index 41f4d7a4b1..edac6497f6 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,8 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { - ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity, -} from '@deepseek-ai/dsh-api-activity-controller/client' +import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer, type StateDotState, type TerminalBlockLabels, @@ -10,74 +9,57 @@ import { 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' +import css from './JobListAction.module.css' -/** Registration-side business face for the activity feed. */ -export interface ActivityListInjected { +/** Registration-side business face for the job list. */ +export interface JobListInjected { hooks: { - /** Client activity snapshot bound by the renderer as useActivity. */ - activity: { - getSnapshot(): ActivityFeedSnapshot + /** Client job-output snapshot bound by the renderer as useJobOutput. */ + jobOutput: { + getSnapshot(): JobOutputSnapshot 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. + * Start observing one job's live record; returns the stop function. + * Reference-counted by the client service, so panels can overlap safely. */ - observe: (id: ActivityId) => () => void + observe: (sessionId: SessionId | undefined, id: SessionJob['id']) => () => void } -/** Full props for the session-header task-list action. */ -export type ActivityListActionProps = +/** Full props for the session-header job-list action. */ +export type JobListActionProps = PropsRuntime<'conversation.session.header.actions'> & PropsLocale - & InjectFace + & InjectFace -/** - * One merged task row. Jobs and activities are separate planes — the control - * plane the model interacts with, and the transient observation plane — so - * this list joins them per row: a job carries identity, lifecycle, and the - * model-visible detail; its correlated activity contributes the observable - * live output; an activity without a job (a workflow run) stands alone. - */ -interface TaskRow { - /** Stable row identity: the job id when one exists, else the activity id. */ - key: string - kind: string - label: string - /** Job lifecycle vocabulary; activity-only rows never reach `stopping`. */ - status: SessionJob['status'] - detail?: string - startedAt: number - finishedAt?: number - /** Present when this row's live output can be observed. */ - activityId?: ActivityId -} - -/** Stable empty lists so a session with no work keeps one array identity. */ -const NO_ROWS: readonly ActivityRow[] = [] +/** Stable empty list so a session with no jobs keeps one array identity. */ const NO_JOBS: readonly SessionJob[] = [] /** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ const VIEWPORT_MARGIN = 12 -function isLive(row: TaskRow): boolean { - return row.status === 'running' || row.status === 'stopping' +function isLive(job: SessionJob): boolean { + return job.status === 'running' || job.status === 'stopping' +} + +/** True exactly when the job declared an observation record. */ +function isObservable(job: SessionJob): boolean { + return job.outputTotal !== undefined } /** 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 task status: ${JSON.stringify(value)}`) + throw new Error(`unhandled job status: ${JSON.stringify(value)}`) } /** * Status marker semantics. `stopping` and `killed` share the attention color: * both mean the work ended (or is ending) on request rather than on its own. */ -function dotState(status: TaskRow['status']): StateDotState { +function dotState(status: SessionJob['status']): StateDotState { switch (status) { case 'running': return 'ongoing' case 'stopping': return 'warning' @@ -89,7 +71,7 @@ function dotState(status: TaskRow['status']): StateDotState { } } -function statusLabel(status: TaskRow['status'], t: TranslateNS): string { +function statusLabel(status: SessionJob['status'], t: TranslateNS): string { switch (status) { case 'running': return t('status.running') case 'stopping': return t('status.stopping') @@ -102,7 +84,7 @@ function statusLabel(status: TaskRow['status'], t: TranslateNS): stri } /** - * Elapsed time in at most two adjacent units. A task that outlives an hour is + * Elapsed time in at most two adjacent units. A job that outlives an hour is * already exceptional, so hours is the widest unit — beyond that the figure * stays in hours rather than growing a day/month vocabulary no producer * currently reaches. @@ -143,66 +125,13 @@ function terminalLabels(t: TranslateNS): TerminalBlockLabels { } } -/** Project one standalone activity into the merged row vocabulary. */ -function activityTask(activity: ActivityRow): TaskRow { - return { - key: String(activity.id), - kind: activity.kind, - label: activity.label, - status: activity.status, - ...activity.detail !== undefined ? { detail: activity.detail } : {}, - startedAt: activity.startedAt, - ...activity.finishedAt !== undefined ? { finishedAt: activity.finishedAt } : {}, - activityId: activity.id, - } -} - -/** - * Join this session's jobs with their correlated activities and append the - * activities that stand alone. A job row keeps the job's lifecycle and detail - * (what the model sees) and gains observability from its activity; an - * activity whose job is not in the projection keeps its own row rather than - * disappearing. - */ -function mergeRows( - jobs: readonly SessionJob[], - activities: readonly ActivityRow[], -): TaskRow[] { - const byJob = new Map() - const standalone: ActivityRow[] = [] - for (const activity of activities) { - const jobId = activity.correlation?.jobId - if (jobId === undefined) standalone.push(activity) - // One activity per job: every shipped producer opens at most one, so a - // duplicate jobId keeps only the newest row rather than growing the list. - else byJob.set(String(jobId), activity) - } - const rows: TaskRow[] = jobs.map((job) => { - const activity = byJob.get(String(job.id)) - byJob.delete(String(job.id)) - return { - key: String(job.id), - kind: job.kind, - label: job.label, - status: job.status, - ...job.detail !== undefined ? { detail: job.detail } : {}, - startedAt: job.startedAt, - ...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {}, - ...activity !== undefined ? { activityId: activity.id } : {}, - } - }) - for (const activity of byJob.values()) rows.push(activityTask(activity)) - for (const activity of standalone) rows.push(activityTask(activity)) - return rows -} - /** * Live rows first in start order, then settled rows newest-first. Two rows * that settled in the same millisecond fall back to start order, so the sort * never depends on the host's map iteration. */ -function ordered(rows: readonly TaskRow[]): TaskRow[] { - return [...rows].sort((left, right) => { +function ordered(jobs: readonly SessionJob[]): SessionJob[] { + return [...jobs].sort((left, right) => { const liveLeft = isLive(left) if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 if (liveLeft) return left.startedAt - right.startedAt @@ -211,21 +140,21 @@ function ordered(rows: readonly TaskRow[]): TaskRow[] { }) } -/** One task row plus, when observable and expanded, its live output panel. */ -function TaskItem({ row, view, expanded, now, onToggle, t }: { - row: TaskRow - view: ObservedActivity | undefined +/** One job row plus, when observable and expanded, its live output panel. */ +function JobItem({ job, view, expanded, now, onToggle, t }: { + job: SessionJob + view: ObservedJob | undefined expanded: boolean /** Clock sample live rows derive their running duration from. */ now: number onToggle: () => void t: TranslateNS }) { - const live = isLive(row) - const status = statusLabel(row.status, t) - const observable = row.activityId !== undefined + const live = isLive(job) + const status = statusLabel(job.status, t) + const observable = isObservable(job) const labels = useMemo(() => terminalLabels(t), [t]) - const elapsed = live ? now - row.startedAt : (row.finishedAt ?? row.startedAt) - row.startedAt + const elapsed = live ? now - job.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt const duration = formatDuration(elapsed, t) const durationCell = ( - + - {row.label} + {job.label} {durationCell} - {row.kind} - {row.detail ?? status} + {job.kind} + {job.detail ?? status} {observable ? : null} @@ -254,10 +183,10 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { ) : ( <> - - {row.kind} - {row.label} - {row.detail ?? status} + + {job.kind} + {job.label} + {job.detail ?? status} {durationCell} {observable ? : null} @@ -270,7 +199,7 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { type="button" className={live ? css.row : `${css.row} ${css.rowSettled}`} aria-expanded={expanded} - aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: row.label })} + aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: job.label })} onClick={onToggle} > {body} @@ -289,10 +218,10 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { ?
{t('output.error', { error: view.error })}
: null} state.jobsBySession[sessionId]) ?? NO_JOBS - const owned = useActivity(state => state.rowsBySession[sessionId]) ?? NO_ROWS - const unowned = useActivity(state => state.rowsBySession['']) ?? NO_ROWS - const observedViews = useActivity(state => state.observed) + const observedViews = useJobOutput(state => state.observed) const [open, setOpen] = useState(false) const [expandedKey, setExpandedKey] = useState(undefined) const [now, setNow] = useState(() => Date.now()) @@ -333,12 +259,9 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ // inside the viewport (the stylesheet alone cannot see the anchor offset). const [menuShift, setMenuShift] = useState(0) - const rows = useMemo( - () => ordered(mergeRows(jobs, [...owned, ...unowned])), - [jobs, owned, unowned], - ) + const rows = useMemo(() => ordered(jobs), [jobs]) const liveRows = useMemo(() => rows.filter(isLive), [rows]) - const settledRows = useMemo(() => rows.filter(row => !isLive(row)), [rows]) + const settledRows = useMemo(() => rows.filter(job => !isLive(job)), [rows]) useDismissOnOutsidePointer(rootRef, open, setOpen) @@ -378,15 +301,16 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ // Observation follows visibility: the stream opens when an observable panel // expands and closes when it collapses, unmounts, or the popover closes. - const activeActivity = open && expandedKey !== undefined - ? rows.find(row => row.key === expandedKey)?.activityId + const expandedRow = open && expandedKey !== undefined + ? rows.find(job => String(job.id) === expandedKey) : undefined + const activeJob = expandedRow !== undefined && isObservable(expandedRow) ? expandedRow.id : undefined useEffect(() => { - if (activeActivity === undefined) return - return observe(activeActivity) - }, [activeActivity, observe]) + if (activeJob === undefined) return + return observe(sessionId, activeJob) + }, [sessionId, activeJob, observe]) - // The last task disappearing removes this control; close first so focus + // The last job disappearing removes this control; close first so focus // does not vanish from an unmounting node. useEffect(() => { if (rows.length === 0 && open) setOpen(false) @@ -394,7 +318,7 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ // An expanded row that left the list (owner disposal) folds its panel. useEffect(() => { - if (expandedKey !== undefined && !rows.some(row => row.key === expandedKey)) { + if (expandedKey !== undefined && !rows.some(job => String(job.id) === expandedKey)) { setExpandedKey(undefined) } }, [rows, expandedKey]) @@ -413,15 +337,15 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ triggerRef.current?.focus() } - const item = (row: TaskRow) => ( - ( + { - setExpandedKey(current => current === row.key ? undefined : row.key) + setExpandedKey(current => current === String(job.id) ? undefined : String(job.id)) }} t={t} /> @@ -437,7 +361,7 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ aria-label={countLabel} onClick={() => { // Sample the clock in the same commit that opens the list: the - // mount-time value predates every task, so the first painted frame + // mount-time value predates every job, so the first painted frame // would otherwise clamp a long-running row to zero until the // open effect corrects it a frame later. setNow(Date.now()) diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts new file mode 100644 index 0000000000..164b6f6e3c --- /dev/null +++ b/packages/client/ui-jobs/src/client/index.ts @@ -0,0 +1,49 @@ +/** + * Background-job plugin, browser half: contributes one session-header action + * that renders this session's jobs. Job rows arrive through the + * `jobsBySession` list mirror; per-row observation streams arrive through the + * `jobOutput` client service. This plugin holds no transport state of its + * own. + */ +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import { JobListAction } from './JobListAction.tsx' +import type { JobListInjected } from './JobListAction.tsx' +import type {} from '@deepseek-ai/dsh-api-session-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 JobKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Background-job list copy. */ + 'job': JobKey + } +} + +export type { JobListActionProps, JobListInjected } from './JobListAction.tsx' + +/** Required services: session state, job observation, the slot registry, and dictionaries. */ +export const inject = ['sessions', 'jobOutput', '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-jobs: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'job-list', + // After the subagent catalog: session lineage reads before running work. + order: 20, + locale: NS, + inject: (): JobListInjected => ({ + hooks: { jobOutput: ctx.jobOutput.state }, + observe: (sessionId, id) => ctx.jobOutput.observe(sessionId, id), + }), + }, JobListAction), + ) +} diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts similarity index 78% rename from packages/client/ui-activity/src/client/locales.ts rename to packages/client/ui-jobs/src/client/locales.ts index d4fb08ec31..d2d36b74d1 100644 --- a/packages/client/ui-activity/src/client/locales.ts +++ b/packages/client/ui-jobs/src/client/locales.ts @@ -1,15 +1,15 @@ -/** `activity` namespace dictionaries. */ +/** `job` namespace dictionaries. */ /** Dictionary namespace owned by this plugin. */ -export const NS = 'activity' +export const NS = 'job' /** 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': '任务', + 'count.live.one': '{count} 个后台任务运行中', + 'count.live.other': '{count} 个后台任务运行中', + 'count.idle.one': '{count} 个后台任务', + 'count.idle.other': '{count} 个后台任务', + 'list.aria': '后台任务', 'section.live': '进行中', 'section.settled': '已结束', 'row.expandAria': '展开 {label} 的实时输出', @@ -41,12 +41,12 @@ export const zh = { } as const /** English dictionary, key-identical to the Chinese source of truth. */ -export const en: Record = { - 'count.live.one': '{count} task running', - 'count.live.other': '{count} tasks running', - 'count.idle.one': '{count} task', - 'count.idle.other': '{count} tasks', - 'list.aria': 'Tasks', +export const en: Record = { + 'count.live.one': '{count} background job running', + 'count.live.other': '{count} background jobs running', + 'count.idle.one': '{count} background job', + 'count.idle.other': '{count} background jobs', + 'list.aria': 'Background jobs', 'section.live': 'Running', 'section.settled': 'Finished', 'row.expandAria': 'Show live output of {label}', @@ -77,5 +77,5 @@ export const en: Record = { '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 +/** Key domain of the `job` namespace (zh is the source of truth). */ +export type JobKey = keyof typeof zh diff --git a/packages/client/ui-activity/src/css-modules.d.ts b/packages/client/ui-jobs/src/css-modules.d.ts similarity index 100% rename from packages/client/ui-activity/src/css-modules.d.ts rename to packages/client/ui-jobs/src/css-modules.d.ts diff --git a/packages/client/ui-activity/src/index.ts b/packages/client/ui-jobs/src/index.ts similarity index 66% rename from packages/client/ui-activity/src/index.ts rename to packages/client/ui-jobs/src/index.ts index f75578ddaf..457e7eeb9c 100644 --- a/packages/client/ui-activity/src/index.ts +++ b/packages/client/ui-jobs/src/index.ts @@ -1,8 +1,8 @@ /** - * Node half of the live-activity list plugin. The browser half in + * Node half of the background-job 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 + * @module @deepseek-ai/dsh-client-ui-jobs */ /** Loader-visible no-op body; the browser half carries the feature. */ diff --git a/packages/client/ui-activity/src/invariant.ts b/packages/client/ui-jobs/src/invariant.ts similarity index 70% rename from packages/client/ui-activity/src/invariant.ts rename to packages/client/ui-jobs/src/invariant.ts index 22eba24c42..51ae72f998 100644 --- a/packages/client/ui-activity/src/invariant.ts +++ b/packages/client/ui-jobs/src/invariant.ts @@ -1,20 +1,20 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-activity`. - * @module @deepseek-ai/dsh-client-ui-activity/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-jobs`. + * @module @deepseek-ai/dsh-client-ui-jobs/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' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-jobs' /** Cordis companion plugin name. */ -export const name = 'client-ui-activity-invariant' +export const name = 'client-ui-jobs-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. */ +/** No runtime invariant: pure presentation over client snapshots; the registry packages assert the data relations. */ const install: InvariantInstaller = () => {} /** diff --git a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts similarity index 73% rename from packages/client/ui-activity/tests/browser-plugin.client.spec.ts rename to packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index 3f84c2252d..8de72f8ccd 100644 --- a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -1,5 +1,5 @@ /** - * ui-activity plugin halves: the browser entry's dictionary and header-slot + * ui-jobs 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. @@ -12,7 +12,7 @@ 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 * as JobsInvariant from '../src/invariant.ts' import { en, NS, zh } from '../src/client/locales.ts' /** Slot ledger reader: entry ids currently registered in the header list. */ @@ -22,8 +22,8 @@ function headerEntryIds(ctx: Context): (string | undefined)[] { .map(entry => entry.options.id) } -/** Ids handed to the stubbed feed's observe control. */ -const observed: string[] = [] +/** Observation requests handed to the stubbed job-output control. */ +const observed: [string | undefined, string][] = [] /** Boot the browser half over a real slot tree that declares the header list. */ async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { @@ -36,9 +36,12 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) ctx.provide('sessions', {} as never) - ctx.provide('activityFeed', { - state: { getSnapshot: () => ({ rowsBySession: {}, observed: {}, phase: 'pending' }), subscribe: () => () => {} }, - observe: (id: string) => { observed.push(id); return () => {} }, + ctx.provide('jobOutput', { + state: { getSnapshot: () => ({ observed: {} }), subscribe: () => () => {} }, + observe: (sessionId: string | undefined, id: string) => { + observed.push([sessionId, id]) + return () => {} + }, } as never) // The locale plugin binds a settings scope, which reads the connection handle // and the forwarded-event port. @@ -55,30 +58,33 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType { +describe('ui-jobs browser half', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'activityFeed', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'jobOutput', 'slots', 'locale']) }) - it('exposes the feed source and observation control through the inject face', async () => { + it('exposes the output 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') + .find(candidate => candidate.options.id === 'job-list') const face = (entry as unknown as { - inject: () => { hooks: { activity: unknown }; observe: (id: string) => () => void } + inject: () => { + hooks: { jobOutput: unknown } + observe: (sessionId: string | undefined, id: string) => () => void + } }).inject() - expect(face.hooks.activity).toBeDefined() - const stopper = face.observe('bash-1') - expect(observed).toEqual(['bash-1']) + expect(face.hooks.jobOutput).toBeDefined() + const stopper = face.observe('session', 'bash-1') + expect(observed).toEqual([['session', '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') + expect(headerEntryIds(ctx)).toContain('job-list') await fiber.dispose() - expect(headerEntryIds(ctx)).not.toContain('activity-list') + expect(headerEntryIds(ctx)).not.toContain('job-list') }) it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { @@ -98,19 +104,19 @@ describe('ui-activity browser half', () => { }) }) -describe('ui-activity node half', () => { +describe('ui-jobs 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', () => { +describe('ui-jobs 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']) + await ctx.plugin(JobsInvariant) + expect(JobsInvariant.name).toBe('client-ui-jobs-invariant') + expect(JobsInvariant.inject).toEqual(['invariants']) }) }) diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx similarity index 59% rename from packages/client/ui-activity/tests/activity-list-action.client.spec.tsx rename to packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index ffb4ce5492..15bd8ec4a9 100644 --- a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -2,12 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, 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 { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { ActivityListAction, type ActivityListActionProps } from '../src/client/ActivityListAction.tsx' +import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' import { zh } from '../src/client/locales.ts' afterEach(() => { @@ -17,26 +15,7 @@ afterEach(() => { }) 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 -} +const t: JobListActionProps['t'] = makeTranslate(zh) function job(over: Partial = {}): SessionJob { return { @@ -49,117 +28,88 @@ function job(over: Partial = {}): SessionJob { } } +/** A job that declared an observation record, so its row offers a panel. */ +function recordJob(over: Partial = {}): SessionJob { + return job({ outputTotal: 0, ...over }) +} + function props( - snapshot: Partial, - observe: ActivityListActionProps['observe'] = () => () => {}, - jobs: readonly SessionJob[] = [], -): ActivityListActionProps { - const state: ActivityFeedSnapshot = { - rowsBySession: {}, - observed: {}, - phase: 'ready', - ...snapshot, - } - function useActivity(select: (value: ActivityFeedSnapshot) => T): T { - return select(state) + jobs: readonly SessionJob[], + observe: JobListActionProps['observe'] = () => () => {}, + observed: Readonly> = {}, +): JobListActionProps { + const outputState: JobOutputSnapshot = { observed } + function useJobOutput(select: (value: JobOutputSnapshot) => T): T { + return select(outputState) } const sessionsState = { jobsBySession: jobs.length > 0 ? { [SESSION]: jobs } : {} } function useSessions(select: (value: typeof sessionsState) => T): T { return select(sessionsState) } - return { sessionId: SESSION, useSessions, useActivity, observe, t } as unknown as ActivityListActionProps + return { sessionId: SESSION, useSessions, useJobOutput, observe, t } as unknown as JobListActionProps } function openList(): void { fireEvent.click(screen.getAllByRole('button')[0]!) } -describe('ActivityListAction visibility', () => { - it('renders nothing while the session sees no tasks', () => { - const { container } = render() +describe('JobListAction visibility', () => { + it('renders nothing while the session sees no jobs', () => { + const { container } = render() expect(container.innerHTML).toBe('') }) - it('counts live tasks on the trigger, merging owned and unowned activity rows', () => { - render() - expect(screen.getByRole('button', { name: '2 个任务进行中' })).toBeDefined() + it('counts live jobs on the trigger', () => { + 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() + it('falls back to the total when nothing is live', () => { + render() + expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() }) }) -describe('ActivityListAction merged rows', () => { - it('joins a job with its correlated activity: job lifecycle, activity output', () => { - const observe = vi.fn(() => () => {}) - render() - openList() - const list = screen.getByRole('list', { name: zh['list.aria'] }) - // One merged row: the job's label and lifecycle word, not the activity's. - expect(within(list).getAllByRole('listitem')).toHaveLength(1) - expect(within(list).getByText('pnpm run build')).toBeDefined() - expect(screen.queryByText('activity-side label')).toBeNull() - expect(within(list).getByText('winding down')).toBeDefined() - // Expansion observes the correlated activity's stream. - fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - expect(observe).toHaveBeenCalledWith('bash-act-7') - }) - - it('renders a bare job row without expansion affordances', () => { - render( { + it('renders a record-less job row without expansion affordances', () => { + render() - expect(screen.getByRole('button', { name: '1 个任务进行中' })).toBeDefined() + expect(screen.getByRole('button', { name: '1 个后台任务运行中' })).toBeDefined() openList() expect(screen.getByText('explore the repo')).toBeDefined() expect(screen.queryByRole('button', { name: zh['row.expandAria'].replace('{label}', 'explore the repo') })).toBeNull() }) - it('keeps an activity whose job is not in the projection', () => { - render() + it('shows the live detail line in place of the status word', () => { + render() openList() - expect(screen.getByText('pnpm run build')).toBeDefined() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + expect(within(list).getAllByRole('listitem')).toHaveLength(1) + expect(within(list).getByText('pnpm run build')).toBeDefined() + expect(within(list).getByText('winding down')).toBeDefined() }) it('labels each non-empty section and drops the heading of an empty one', () => { const settled = [job({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] - const { rerender } = render() + const { rerender } = render() openList() expect(screen.getByText(zh['section.live'])).toBeDefined() expect(screen.getByText(zh['section.settled'])).toBeDefined() // A live-only list keeps its own heading and drops the settled one. - rerender() + rerender() expect(screen.getByText(zh['section.live'])).toBeDefined() expect(screen.queryByText(zh['section.settled'])).toBeNull() }) it('shows a settled duration and ticks a live one', () => { vi.useFakeTimers({ now: 1_700_000_020_000 }) - render( { act(() => { vi.advanceTimersByTime(1_000) }) expect(screen.getByText('6秒')).toBeDefined() }) -}) -describe('ActivityListAction rows and observation', () => { it('lists rows with kind, label, and detail-or-status', () => { - render() + render() openList() const list = screen.getByRole('list', { name: zh['list.aria'] }) const items = within(list).getAllByRole('listitem') @@ -194,21 +138,52 @@ describe('ActivityListAction rows and observation', () => { expect(items[1]?.textContent).toContain('exit code: 3') }) + 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): SessionJob => + job({ + id: id as SessionJob['id'], + 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) + }) +}) + +describe('JobListAction observation', () => { it('marks killed rows, renders every output line unfolded, and copies the command', async () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') - const view: ObservedActivity = { - activityId: 'bash-1' as ActivityId, + const view: ObservedJob = { + jobId: 'bash-1' as SessionJob['id'], text: lines, gapBefore: false, status: 'killed', streaming: false, } - render() + render() openList() expect(screen.getByText(zh['status.killed'])).toBeDefined() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) @@ -223,45 +198,13 @@ describe('ActivityListAction rows and observation', () => { expect(writeText).toHaveBeenCalledWith('pnpm run build') }) - 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('shifts an overflowing popover back inside the viewport and follows resizes', () => { vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344 } as DOMRect) const originalWidth = window.innerWidth Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) try { - render() + render() openList() const menu = screen.getByRole('list', { name: zh['list.aria'] }) // 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin. @@ -282,7 +225,7 @@ describe('ActivityListAction rows and observation', () => { const originalWidth = window.innerWidth Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) try { - render() + render() openList() const menu = screen.getByRole('list', { name: zh['list.aria'] }) // max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin. @@ -294,39 +237,34 @@ describe('ActivityListAction rows and observation', () => { 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 }), + recordJob({ id: 'bash-1' as SessionJob['id'], status: 'completed', finishedAt: 2 }), + recordJob({ id: 'bash-2' as SessionJob['id'], status: 'completed', finishedAt: 3 }), ] - const { rerender } = render() - expect(screen.getByRole('button', { name: '2 个任务' })).toBeDefined() + 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() + rerender() expect(screen.queryByRole('button')).toBeNull() }) - it('starts observing on expand, renders the live text, and stops on collapse', () => { + it('starts observing on expand with the session identity, 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, + const view: ObservedJob = { + jobId: 'bash-1' as SessionJob['id'], text: 'compiling…\n', gapBefore: false, status: 'running', streaming: true, } - render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - expect(observe).toHaveBeenCalledWith('bash-1') + expect(observe).toHaveBeenCalledWith(SESSION, 'bash-1') expect(screen.getByText('compiling…')).toBeDefined() fireEvent.click(screen.getByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'pnpm run build') })) @@ -334,18 +272,15 @@ describe('ActivityListAction rows and observation', () => { }) it('surfaces retention gaps and stream failures above the panel', () => { - const view: ObservedActivity = { - activityId: 'bash-1' as ActivityId, + const view: ObservedJob = { + jobId: 'bash-1' as SessionJob['id'], text: 'tail only', gapBefore: true, status: 'running', streaming: false, error: 'connection lost', } - render() + render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) expect(screen.getByText(zh['output.gap'])).toBeDefined() @@ -354,9 +289,7 @@ describe('ActivityListAction rows and observation', () => { it('stops observation when the popover closes via Escape', () => { const stop = vi.fn() - render( stop)} />) + 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' }) @@ -368,13 +301,10 @@ describe('ActivityListAction rows and observation', () => { 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() + const { rerender } = render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - rerender() + rerender() expect(stop).toHaveBeenCalledTimes(1) expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull() }) diff --git a/packages/client/ui-activity/tsconfig.json b/packages/client/ui-jobs/tsconfig.json similarity index 89% rename from packages/client/ui-activity/tsconfig.json rename to packages/client/ui-jobs/tsconfig.json index 562e2c2228..4b8aae7886 100644 --- a/packages/client/ui-activity/tsconfig.json +++ b/packages/client/ui-jobs/tsconfig.json @@ -10,7 +10,6 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, - { "path": "../../api/activity-controller/tsconfig.client.json" }, { "path": "../../api/session-controller/tsconfig.client.json" }, { "path": "../locale" }, { "path": "../ui-conversation" }, diff --git a/packages/client/ui-activity/tsdown.config.ts b/packages/client/ui-jobs/tsdown.config.ts similarity index 76% rename from packages/client/ui-activity/tsdown.config.ts rename to packages/client/ui-jobs/tsdown.config.ts index 096771fe90..c2f3a27162 100644 --- a/packages/client/ui-activity/tsdown.config.ts +++ b/packages/client/ui-jobs/tsdown.config.ts @@ -1,6 +1,6 @@ import { clientBundle } from '../tsdown.client.ts' export default clientBundle( - '@deepseek-ai/dsh-client-ui-activity', + '@deepseek-ai/dsh-client-ui-jobs', ['lib/types/index.js', 'lib/types/invariant.js'], ) 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 066e930d6d..23b36a8440 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1144,8 +1144,8 @@ 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\'', 'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'', 'client-ui-subagent SubagentCatalogAction id \'subagent-catalog\'', 'experimental-client-ui-agent-team TeamAction id \'agent-team\'', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 21e7f10221..80cc02f219 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -81,68 +81,6 @@ 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.', @@ -1098,7 +1036,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'jobs', summary: 'Abstract background job registry.', - description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.', + description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.\n- Record 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 — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.', methods: [ { signature: 'abstract start(spec: JobStart): JobId', @@ -1148,6 +1086,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned job changed and every caller\'s set did.' }], returns: 'disposer that unregisters the listener.', }, + { + signature: 'abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead', + description: 'Read retained record 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`). Never marks the job reported. Throws for an unknown or foreign job, a job without a JobStart.record declaration, or a negative or non-integer offset.', + parameters: [{ name: 'id', description: 'job 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 onOutput(listener: JobOutputListener): () => void', + description: 'Register an effect-scoped observer of record advancement — one signal per committed append and one at settlement, carrying only the job id. A consumer schedules a readRecord from its own cursor; the registry never pushes payloads. Delivery is owner-relative on the same terms as onJobsChanged. Listeners are contained and never awaited.', + parameters: [{ name: 'listener', description: 'receives the id whose record advanced.' }], + returns: 'disposer that unregisters the listener.', + }, { signature: 'abstract attachController(name: string): () => void', description: 'Attach an effect-scoped controller that can read and stop jobs. It serves the owners its registering context\'s scope covers, and start refuses an owner no attached controller serves.', @@ -1505,6 +1455,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'one complete baseline followed by live replacement frames.', }, + { + signature: '@Remote({ mode: \'stream\' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable', + description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.', + parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], + returns: 'anchor, coalesced output frames, and the terminal status.', + }, ], }, { @@ -3473,94 +3429,6 @@ 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?: ToolCallId;\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?: ToolCallId;\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}', @@ -4349,6 +4217,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'InvokeRemoteRequest', declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly signal?: AbortSignal;\n}', }, + { + name: 'JobAppendOptions', + declaration: 'export interface JobAppendOptions {\n channel?: JobChannel;\n gapBefore?: true;\n}', + }, + { + name: 'JobChannel', + declaration: 'export type JobChannel = \'stdout\' | \'stderr\';', + }, { name: 'JobDoneListener', declaration: 'export type JobDoneListener = (snapshot: JobSnapshot, owner: Agent | undefined) => void | PromiseLike;', @@ -4373,21 +4249,33 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JobOutcome', declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}', }, + { + name: 'JobOutputListener', + declaration: 'export type JobOutputListener = (id: JobId) => void;', + }, { name: 'JobRead', declaration: 'export interface JobRead {\n text: string;\n snapshot: JobSnapshot;\n}', }, + { + name: 'JobRecordChunk', + declaration: 'export interface JobRecordChunk {\n at: number;\n text: string;\n channel?: JobChannel;\n gapBefore?: true;\n}', + }, + { + name: 'JobRecordRead', + declaration: 'export interface JobRecordRead {\n chunks: readonly JobRecordChunk[];\n next: number;\n lossy: boolean;\n}', + }, { name: 'JobsChangedListener', declaration: 'export type JobsChangedListener = (owner: Agent | undefined) => void;', }, { name: 'JobSnapshot', - declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}', + declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n outputTotal?: number;\n outputEarliest?: number;\n}', }, { name: 'JobStart', - declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n run(): JobHooks;\n}', + declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n record?: true;\n run(job: RunningJob): JobHooks;\n}', }, { name: 'JobStatus', @@ -4877,6 +4765,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RunnerFailureRule', declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', }, + { + name: 'RunningJob', + declaration: 'export interface RunningJob {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateDetail(detail: string): void;\n}', + }, { name: 'SandboxEnforcement', declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';', @@ -5103,7 +4995,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionJob', - declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n}', + declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal?: number;\n}', + }, + { + name: 'SessionJobWireChunk', + declaration: 'export interface SessionJobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}', }, { name: 'SessionLineageNode', @@ -5137,6 +5033,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionObservationOptions', declaration: 'export interface SessionObservationOptions {\n readonly signal?: AbortSignal;\n readonly projectionMode?: \'all\' | \'none\';\n}', }, + { + name: 'SessionObserveJobFrame', + declaration: 'export type SessionObserveJobFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly SessionJobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n readonly finishedAt?: number;\n};', + }, + { + name: 'SessionObserveJobRequest', + declaration: 'export interface SessionObserveJobRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}', + }, { name: 'SessionOpenWorkspacePathRequest', declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da5b3091d6..d938573ee5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -625,110 +625,6 @@ 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-deque': @@ -793,9 +689,6 @@ 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 @@ -1174,9 +1067,6 @@ 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 @@ -1571,9 +1461,6 @@ 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 @@ -1601,9 +1488,6 @@ 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 @@ -1643,6 +1527,9 @@ importers: '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../../client/ui-input-trigger + '@deepseek-ai/dsh-client-ui-jobs': + specifier: workspace:^ + version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../client/ui-layout @@ -1961,48 +1848,6 @@ 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-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-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': @@ -2723,6 +2568,48 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-jobs: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-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 + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-layout: devDependencies: '@deepseek-ai/cordis': @@ -8288,9 +8175,6 @@ 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 @@ -8416,9 +8300,6 @@ 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 @@ -10405,12 +10286,6 @@ 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 @@ -10575,9 +10450,6 @@ 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 2ae2320a08..f735144468 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -13,7 +13,6 @@ "@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:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 02eccc59d1..d477494d34 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -109,8 +109,6 @@ 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', @@ -175,7 +173,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', + jobOutput: 'client-side interface-typed browser service — packages/api/session-controller/README.md owns the API', } /** @@ -322,6 +320,8 @@ export const LINK_MAP: Readonly> = { SessionId: 'core.md', SessionListRequest: 'session.md', SessionListValue: 'session.md', + SessionObserveJobFrame: 'session.md', + SessionObserveJobRequest: 'session.md', ModelCatalog: 'session.md', SessionOpenWorkspacePathRequest: 'session.md', SessionOpenWorkspacePathValue: 'session.md', @@ -501,22 +501,18 @@ export const LINK_MAP: Readonly> = { PromptSectionOrderName: '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', JobSnapshot: 'jobs.md', JobStart: 'jobs.md', JobsChangedListener: 'jobs.md', + JobAppendOptions: 'jobs.md', + JobChannel: 'jobs.md', + JobOutputListener: 'jobs.md', + JobRecordChunk: 'jobs.md', + JobRecordRead: 'jobs.md', + RunningJob: 'jobs.md', CreateTeamTaskRequest: 'agent-team.md', SendTeamMessageRequest: 'agent-team.md', SendTeamMessageResult: 'agent-team.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 008991a00c..3638b0c002 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -198,13 +198,6 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'core', note: 'Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on.', }, - { - 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', @@ -583,17 +576,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Background job registry', mode: 'seam', implementations: ['jobs-local'], - 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.', + consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs'], + note: 'Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.', }, { key: 'web', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 15b481383c..63054fd183 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -5,7 +5,6 @@ const CLIENT_FACE_INCLUDE: readonly string[] = [] /** Packages exempted from automatic Client/Host treatment despite declaring `dsh.client`. */ const CLIENT_FACE_EXCLUDE: readonly string[] = [ - '@deepseek-ai/dsh-api-activity-controller', '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-api-workspace-controller', ] diff --git a/scripts/verify-package-dependencies.spec.ts b/scripts/verify-package-dependencies.spec.ts index 26263fe018..4294cff84b 100644 --- a/scripts/verify-package-dependencies.spec.ts +++ b/scripts/verify-package-dependencies.spec.ts @@ -123,7 +123,6 @@ function hostRuntimeFixture(): { describe('package dependency scope', () => { it('keeps the measured Host relay roster explicit', () => { expect(PACKAGE_DEPENDENCY_POLICY.clientFaceExclude).toEqual([ - '@deepseek-ai/dsh-api-activity-controller', '@deepseek-ai/dsh-api-session-controller', '@deepseek-ai/dsh-api-workspace-controller', ]) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index ec942708ea..a563dc46bd 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -46,10 +46,7 @@ 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/client/ui-jobs': { 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 c09254eec2..d8ba3b6830 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,13 +47,6 @@ "@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-activity-controller/client": ["./packages/api/activity-controller/src/client/index.ts"], - "@deepseek-ai/dsh-api-activity-controller/invariant": ["./packages/api/activity-controller/src/invariant.ts"], - "@deepseek-ai/dsh-api-activity-controller/types": ["./packages/api/activity-controller/src/types.ts"], - "@deepseek-ai/dsh-client-ui-activity": ["./packages/client/ui-activity/src"], - "@deepseek-ai/dsh-client-ui-activity/client": ["./packages/client/ui-activity/src/client/index.ts"], - "@deepseek-ai/dsh-client-ui-activity/invariant": ["./packages/client/ui-activity/src/invariant.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"], @@ -108,7 +101,6 @@ "@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-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"], @@ -212,6 +204,8 @@ "@deepseek-ai/dsh-experimental-client-ui-agent-team": ["./packages/experimental/client-ui-agent-team/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-jobs/client": ["./packages/client/ui-jobs/src/client/index.ts"], + "@deepseek-ai/dsh-client-ui-jobs/invariant": ["./packages/client/ui-jobs/src/invariant.ts"], "@deepseek-ai/dsh-client-ui-schedule": ["./packages/client/ui-schedule/src"], "@deepseek-ai/dsh-client-ui-schedule/client": ["./packages/client/ui-schedule/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-schedule/invariant": ["./packages/client/ui-schedule/src/invariant.ts"], @@ -255,10 +249,6 @@ "@deepseek-ai/dsh-acp/invariant": ["./packages/acp/acp/src/invariant.ts"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"], "@deepseek-ai/dsh-acp-app/invariant": ["./packages/bundle/acp-app/src/invariant.ts"], - "@deepseek-ai/dsh-activity": ["./packages/activity/activity/src"], - "@deepseek-ai/dsh-activity/invariant": ["./packages/activity/activity/src/invariant.ts"], - "@deepseek-ai/dsh-activity-local": ["./packages/activity/activity-local/src"], - "@deepseek-ai/dsh-activity-local/invariant": ["./packages/activity/activity-local/src/invariant.ts"], "@deepseek-ai/dsh-agent": ["./packages/core/agent/src"], "@deepseek-ai/dsh-agent-default-model": ["./packages/core/agent-default-model/src"], "@deepseek-ai/dsh-agent-default-model/invariant": ["./packages/core/agent-default-model/src/invariant.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 2426473861..bb6735bbc7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -57,7 +57,6 @@ { "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" }, @@ -78,7 +77,7 @@ { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, - { "path": "./packages/client/ui-activity" }, + { "path": "./packages/client/ui-jobs" }, { "path": "./packages/experimental/client-ui-agent-team" }, { "path": "./packages/client/ui-schedule" }, { "path": "./packages/client/ui-directory-picker-browse" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index b9fac3cdff..4bbbda019b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -81,7 +81,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/live-job-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", @@ -152,7 +152,6 @@ { "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/settings-controller" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, @@ -298,8 +297,6 @@ { "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/agent-team-profile" }, { "path": "./packages/experimental/tool-agent-team" },