From a2416dd357d6b35d53d19a2aab5e28b279b56e6e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 18:24:49 +0800 Subject: [PATCH 01/34] feat(schedule): remove every ended task from the automation page --- apps/web/tests/schedule-after.e2e.ts | 63 +++++++ docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/schedule.i18n.yaml | 4 +- docs/subsystems/schedule.md | 15 +- docs/subsystems/schedule.zh.md | 15 +- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 6 +- packages/client/ui-schedule/README.zh.md | 6 +- .../ui-schedule/src/client/DeleteToast.tsx | 69 ++++--- .../src/client/TaskManagerPage.module.css | 6 +- .../src/client/TaskManagerPage.tsx | 68 ++++++- .../ui-schedule/src/client/clear-ended.ts | 45 +++++ .../client/ui-schedule/src/client/index.ts | 16 ++ .../src/client/task-manager-locales.ts | 21 +++ .../tests/browser-plugin.client.spec.ts | 44 +++++ .../tests/clear-ended.client.spec.ts | 63 +++++++ .../tests/delete-toast.client.spec.tsx | 29 +++ .../tests/task-detail.client.spec.tsx | 1 + .../tests/task-manager-page.client.spec.tsx | 104 ++++++++++- .../expected/radius-exceptions.expected.json | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 ++ packages/schedule/schedule/README.i18n.yaml | 4 +- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/schedule/schedule/src/index.ts | 43 ++++- packages/schedule/schedule/src/types.ts | 6 + .../schedule/tests/delete-inactive.spec.ts | 170 ++++++++++++++++++ scripts/gen-cordis-catalog.ts | 1 + 32 files changed, 770 insertions(+), 61 deletions(-) create mode 100644 packages/client/ui-schedule/src/client/clear-ended.ts create mode 100644 packages/client/ui-schedule/tests/clear-ended.client.spec.ts create mode 100644 packages/schedule/schedule/tests/delete-inactive.spec.ts diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 89318180b1..d67aa3ab41 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -19,6 +19,7 @@ import type { ContextFormed, GenerateOptions, StreamChunk } from '@deepseek-ai/d import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { ScheduleId, + createAfterScheduleRecord, createEveryScheduleRecord, foldScheduleEvents, type EveryScheduleRecord, @@ -1258,4 +1259,66 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { await page.clock.setFixedTime(new Date(CATALOG_NOW)) } }, 60_000) + + it('removes every ended task from the page and keeps the active ones', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-clear-ended')) + const endedSession = SessionId('schedule-manager-ended-clear') + const domain = scaffold.ctx.storageDomain.get('schedule') + if (domain === undefined) throw new Error('Schedule domain was not opened') + const endedIds = [ScheduleId('schedule-ended-first'), ScheduleId('schedule-ended-second')] + const endedTitles = ['Ended release check', 'Ended metrics check'] as const + for (const [index, id] of endedIds.entries()) { + const title = endedTitles[index]! + const record = createAfterScheduleRecord(ScheduleId(id), title, 60, Date.now(), title) + const receipt = { + scheduledAt: record.scheduledAt, + deliveredAt: new Date(Date.parse(record.scheduledAt) + 1_000).toISOString(), + messageId: MessageId(`${id}-delivery`), + } + await domain.table('tasks').put(record.id, { + sessionId: endedSession, record, status: 'inactive', lastDelivery: receipt, + deliveryHistory: { records: [receipt], earlierRecordsUnavailable: false }, + }) + } + // A create is the Host's own committed change, so its notification is what + // makes the open page read the rows seeded above. + const kept = await scaffold.ctx.schedule.create(endedSession, { + prompt: 'Keep this running task', title: 'Keep this running task', at: '2099-08-25T12:08:00.000Z', + }) + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + // Earlier scenarios leave their own search text and status filter on this + // shared page; this scenario asserts the complete list. + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('') + await manager.getByRole('button', { name: 'All', exact: true }).click() + const clear = manager.getByRole('button', { name: 'Clear ended', exact: true }) + await clear.waitFor({ timeout: 15_000 }) + + // The confirmation states the Host-side target and removes nothing until it + // is confirmed. + await clear.click() + const confirmation = page.getByRole('dialog', { name: 'Clear ended automation tasks?' }) + await confirmation.getByText('2 ended tasks', { exact: true }).waitFor() + await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() + expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) + + await clear.click() + await confirmation.getByRole('button', { name: 'Clear tasks', exact: true }).click() + await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')) + .toEqual([]) + const remaining = await scaffold.ctx.schedule.catalog() + expect(remaining.some(task => endedIds.includes(task.id))).toBe(false) + expect(remaining.map(task => task.title)).toContain('Keep this running task') + await page.getByText('Cleared 2 ended tasks.', { exact: true }).waitFor({ timeout: 15_000 }) + // The page follows the acknowledged removal: no ended row or cleanup action + // remains, and the running task is still listed. + await expect.poll(() => manager.getByRole('button', { name: 'Clear ended', exact: true }).count(), { timeout: 15_000 }) + .toBe(0) + await manager.getByRole('button', { name: 'All', exact: true }).click() + await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' }) + .getByRole('button', { name: kept.title, exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + await scaffold.ctx.schedule.delete({ sessionId: endedSession, id: kept.id }) + }, 60_000) }) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 904ebe2ac7..9f7e72ea27 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -62,7 +62,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | -| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | +| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index a008173af6..05cefc1e33 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -64,7 +64,7 @@ | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` | -| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | +| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7aae4e7d05..4e6b8fb553 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -174,8 +174,8 @@ en: e5b55405bd2b69f5 zh: e5b55405bd2b69f5 /session-persistence-event-catalog/events/schedule/schedule-change-log-only: - en: 162584304e006c72 - zh: d8a6bacf60777aa9 + en: e73c202fb99159b6 + zh: b1f43bbd116f83da /session-persistence-event-catalog/events/session: en: a1ac8f2660a4f61e zh: a1ac8f2660a4f61e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 056aa2e5f9..c4cc4b461f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -813,7 +813,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s Types: [ScheduleChange](subsystems/schedule.md) -Source: [`packages/schedule/schedule/src/types.ts:365`](../packages/schedule/schedule/src/types.ts) +Source: [`packages/schedule/schedule/src/types.ts:371`](../packages/schedule/schedule/src/types.ts) ### `session/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 71fb808356..c52652cd5f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -815,7 +815,7 @@ export type SessionEvent = { 类型:[ScheduleChange](subsystems/schedule.zh.md) -来源:[`packages/schedule/schedule/src/types.ts:365`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:371`](../packages/schedule/schedule/src/types.ts) ### `session/*` diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index a6e7893cf3..79a3fb77bc 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 93798ad69f74ddd5 zh: 634126ad8accc356 /host-wide-schedule/web-catalog: - en: b5e5ac8125e65f33 - zh: 9ee064d833a4f838 + en: 630b9879dddd5972 + zh: bfaddb3674ba5c98 /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index ef24673f9b..b3f7fa31d2 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -337,7 +337,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. +The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended task across Sessions, with the saved delivery records each row carries, and answers `{ deleted }` with the count it removed; the management page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. The shipped Web composition mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. The Rules and Records tabs separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution. @@ -508,6 +508,19 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor */ @Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise +/** + * Delete every ended Host task, leaving active tasks armed. + * + * Ended tasks never schedule another delivery, so the removal changes no pending + * target and no timer is recomputed. Rows are enumerated and removed in one FIFO + * slot, so a create ordered behind this call is not removed by it. Each removed row + * is durable on its own: a failure partway through leaves the earlier removals + * committed and announced, then rejects rather than reporting a partial count as + * the whole. + * @returns Count of removed rows; zero when the Host held no ended task. + */ +@Remote('deleteInactive') async deleteInactive(): Promise + /** * Update the name, instruction, and timing of an active task within the original Session * binding without activating the Session or changing saved deliveries. diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index 2d140c3821..91e470890a 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -337,7 +337,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行任务,连同每行携带的已保存投递记录,并以它移除的计数答复 `{ deleted }`;自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 @@ -508,6 +508,19 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor */ @Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise +/** + * Delete every ended Host task, leaving active tasks armed. + * + * Ended tasks never schedule another delivery, so the removal changes no pending + * target and no timer is recomputed. Rows are enumerated and removed in one FIFO + * slot, so a create ordered behind this call is not removed by it. Each removed row + * is durable on its own: a failure partway through leaves the earlier removals + * committed and announced, then rejects rather than reporting a partial count as + * the whole. + * @returns Count of removed rows; zero when the Host held no ended task. + */ +@Remote('deleteInactive') async deleteInactive(): Promise + /** * Update the name, instruction, and timing of an active task within the original Session * binding without activating the Session or changing saved deliveries. diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 8a2a17dc02..cfbb03bf72 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: 7d44cd1640bebfad - zh: 1d6b6c28a3c27115 + en: 9f166d6ea79e3456 + zh: 02e18559999fc1e5 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 6f02ed050a..67effaa929 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,9 +25,9 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package -The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Once the catalog holds an ended task, a bulk cleanup action sits left of New and removes every ended task after a confirmation that states how many the Host will remove. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. -The page header keeps the title at its natural width and moves New to another line when the two no longer fit together. The action label stays on one line. +The page header keeps the title at its natural width and moves the actions to another line when the title and the actions no longer fit together. Each action label stays on one line. Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view. @@ -41,7 +41,7 @@ After the last saved page loads, a nonempty history with confirmed pruning shows A successful empty history page is distinct from loading, query failure, a missing task, or an invalid cursor. Failed requests retain previously loaded records with a warning and Retry delivery records; an invalid cursor offers Refresh delivery records to reload the newest page. Changing the latest `messageId` refreshes the newest page without switching tabs. Responses superseded by a refresh or received after changing tasks, leaving Records, or closing details do not replace the current view. -The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, and the header popover — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. Confirming a deletion closes the detail or the tab it was confirmed from, and each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. +The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, and the header popover — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N ended tasks.` with the count the Host answered, `No ended tasks to clear.` when the ended rows left between the confirmation and the request, and `Could not clear the ended tasks.` after a failure that leaves the remaining ended rows in place to retry. Confirming a deletion closes the detail or the tab it was confirmed from, and each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. An open Session renders an icon-only clock button among its header's right-aligned utilities, immediately left of the overflow menu, only once it has something to open: the first read mounts nothing, a read that returns no active reminder leaves the header without the button, a refresh keeps the last known reminders visible, and a failed read keeps the button with its retry. That rule prevents the clock from appearing and disappearing around the first answer. Its accessible name states the reminder count when the read is ready and the generic reminder label otherwise. With exactly one active reminder, the button opens that task's detail in the right Sidebar directly. With any other count it opens a popover that lists overdue reminders first, then future reminders by target time, with stored title, frequency, browser-local target time, relative time, and a Delete button; the title is a button that opens that task's detail in the right Sidebar. The task tab names the shown task's stored title on its chip and carries the same rule, delivery records, name, instruction, and run-time editing, confirmed deletion, and original-Session link as the Automation tasks page; it never activates a Session. Daily frequency text retains the saved local time and zone, and every next-target instant is shown as a device-zone clock with its relative duration in parentheses; the rule's own zone appears only in that frequency text. Escape closes the popover and returns focus to the button; an outside pointer press dismisses it. Deleting the final reminder removes the button, and an already open popover stays expandable until then. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index b7de6727ed..7e5aa98da1 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -25,9 +25,9 @@ kind: "package-reference" ## 使用本包 -随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在未运行任务后,“新建”左侧出现批量清理操作,经确认后移除全部未运行任务,确认提示说明 Host 将移除多少个。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 -页头按标题的自然宽度排布;标题和“新建”无法并排容纳时,将按钮移到下一行。操作文案保持单行。 +页头按标题的自然宽度排布;标题和操作无法并排容纳时,将操作移到下一行。每个操作文案保持单行。 从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 @@ -41,7 +41,7 @@ kind: "package-reference" 历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。 -自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 +自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;确认到请求之间未运行行已消失时显示「没有可清理的已结束任务」;失败时显示「无法清理已结束任务」,并保留尚未移除的未运行行以便重试。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 diff --git a/packages/client/ui-schedule/src/client/DeleteToast.tsx b/packages/client/ui-schedule/src/client/DeleteToast.tsx index af3da62aff..556f5a02bf 100644 --- a/packages/client/ui-schedule/src/client/DeleteToast.tsx +++ b/packages/client/ui-schedule/src/client/DeleteToast.tsx @@ -1,23 +1,26 @@ /** * Transient outcome notice for schedule deletions. * - * Every deletion — the Tasks page detail, the session task tab, and the - * Session-header popover — reports its settled outcome into one store, and one - * `shell.overlay` entry renders it as the app-wide banner. The surfaces keep no - * in-place deletion notice of their own, so an outcome outlives the panel or - * tab that asked for it. + * Every deletion — the Tasks page detail, the session task tab, the + * Session-header popover, and the Tasks page's removal of every ended task — + * reports its settled outcome into one store, and one `shell.overlay` entry + * renders it as the app-wide banner. The surfaces keep no in-place deletion + * notice of their own, so an outcome outlives the panel or tab that asked for + * it. */ import type { ReactNode } from 'react' import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import { assertNever } from '@deepseek-ai/dsh-util-values' import type { CatalogDeleteOutcome } from './catalog-source.ts' +import type { ClearEndedOutcome } from './clear-ended.ts' -/** One reported deletion outcome; `seq` keys the banner so a re-show restarts it. */ -export interface DeleteToastState { - readonly kind: 'deleted' | 'deleteFailed' - readonly seq: number -} +/** One reported outcome; `seq` keys the banner so a re-show restarts it. */ +export type DeleteToastState = + | { readonly kind: 'deleted' | 'deleteFailed'; readonly seq: number } + | { readonly kind: 'cleared'; readonly count: number; readonly seq: number } + | { readonly kind: 'clearFailed'; readonly seq: number } /** Store the deletion wiring reports into and the overlay entry reads. */ export interface DeleteToastSource { @@ -29,6 +32,11 @@ export interface DeleteToastSource { * @param outcome - resolution of `CatalogInjected.onDelete`. */ readonly report: (outcome: CatalogDeleteOutcome) => void + /** + * Show the notice of one settled bulk removal. + * @param outcome - resolution of the management page's bulk removal callback. + */ + readonly reportClear: (outcome: ClearEndedOutcome) => void /** Clear the shown notice. */ readonly dismiss: () => void } @@ -59,6 +67,11 @@ export function createDeleteToastSource(): DeleteToastSource { if (outcome === 'pending') return publish({ kind: outcome === 'deleted' ? 'deleted' : 'deleteFailed', seq: ++seq }) }, + reportClear: (outcome) => { + publish(outcome.cleared + ? { kind: 'cleared', count: outcome.count, seq: ++seq } + : { kind: 'clearFailed', seq: ++seq }) + }, dismiss: () => { publish(null) }, } } @@ -66,23 +79,37 @@ export function createDeleteToastSource(): DeleteToastSource { /** Props of the `shell.overlay` entry: the notice store and localized copy. */ export type ScheduleDeleteToastProps = PropsRuntime<'shell.overlay'> & PropsLocale<'schedule.manager'> - & InjectFace> + & InjectFace> /** - * Render the current deletion notice: a success banner for a confirmed - * deletion, a warning for one that could not be confirmed, or nothing. + * Render the current deletion notice: a success banner for a confirmed deletion + * or settled bulk removal, a warning for one that could not be confirmed, or + * nothing. * @param props - the notice hook, its dismissal, and the locale seat. * @returns the banner on display, or null. */ export function ScheduleDeleteToast({ useToast, dismiss, t }: ScheduleDeleteToastProps): ReactNode { const toast = useToast(current => current) if (toast === null) return null - return toast.kind === 'deleted' - ? - : } - onDone={dismiss} - /> + const key = `schedule-delete-${String(toast.seq)}` + switch (toast.kind) { + case 'deleted': + return + case 'deleteFailed': + return } onDone={dismiss} /> + case 'cleared': + // A removal that found nothing states that instead of a zero count: the + // ended rows can leave between the confirmation and the request. + return + case 'clearFailed': + return } onDone={dismiss} /> + /* v8 ignore next -- the store publishes only the union's members, so the default holds no reachable statement. */ + default: + return assertNever(toast) + } } diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index ebdb77b4b8..c76a57bc51 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -87,8 +87,10 @@ white-space: nowrap; } -/* The Plugins toolbar's Add-plugin step: a 32px capsule with the 13px label. */ -.newButton { +/* The Plugins toolbar's steps: 32px capsules with the 13px label. The New + action and the bulk cleanup share the geometry, so the pair reads as one + toolbar. */ +.headerAction { height: 32px; font-size: 13px; line-height: 20px; diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 7d18063ae8..841b559cf8 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconClockOutlineRegular, IconCloseOutlineRegular, IconPlusOutlineRegular, IconSearchOutlineRegular, Input, + Button, IconClockOutlineRegular, IconCloseOutlineRegular, IconPlusOutlineRegular, IconSearchOutlineRegular, Input, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -25,6 +25,16 @@ export interface TaskManagerInjected extends TaskDetailInjected { * schedule it. The page deliberately has no creation form of its own. */ readonly onNewTask: () => void + /** + * Remove every ended Host task, after this page has confirmed the action. + * + * The call requests no task ids, so the Host's ended set is the one removed; + * the page's rows are only a snapshot that a delivery can have moved. It + * resolves once the settled outcome has been reported through the app-wide + * notice, which is the only place the result is stated. + * @returns resolution after the outcome is reported. + */ + readonly onClearEnded: () => Promise } /** Root-scoped task catalog props derived from the framework and injected actions. */ @@ -40,12 +50,14 @@ type StatusFilter = 'all' | ScheduleCatalogEntry['status'] * @returns the searchable task list beside the selected task's detail. */ export function TaskManagerPage(props: TaskManagerPageProps) { - const { useCatalog, onNewTask, onRetry, t } = props + const { useCatalog, onNewTask, onClearEnded, onRetry, t } = props const catalog = useCatalog(snapshot => snapshot) const { records, status } = catalog const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState('all') const [selectedId, setSelectedId] = useState(null) + const [clearConfirm, setClearConfirm] = useState(false) + const [clearing, setClearing] = useState(false) // The rows state how long remains, so they read the shared ticking clock // rather than a value sampled at mount: a catalog refresh can move the target // this text describes. @@ -55,6 +67,12 @@ export function TaskManagerPage(props: TaskManagerPageProps) { const rowRef = useRef(null) const headingRef = useRef(null) const confirming = records.find(record => record.id === confirmId) + // The bulk action's target and count come from the complete catalog, not from + // the filtered rows: it removes every ended task, whatever the view shows. + const endedCount = useMemo( + () => records.filter(record => record.status === 'inactive').length, + [records], + ) const rows = useMemo(() => { const query = search.trim().toLowerCase() return records.filter(record => ( @@ -97,16 +115,25 @@ export function TaskManagerPage(props: TaskManagerPageProps) { setSelectedId(null) } + const confirmClearEnded = (): void => { + setClearing(true) + void onClearEnded().finally(() => { + setClearing(false) + setClearConfirm(false) + }) + } + return (
{ - // A dropdown that closed on this Escape has already consumed the key: the - // Menu primitive closes from the document capture phase and calls - // preventDefault there, before React dispatches this handler. - if (event.key !== 'Escape' || event.defaultPrevented || confirmId !== null || selectedId === null) return + // Escape belongs to the surface it closed: the Menu primitive consumes it + // during the document capture phase, and the Modal layer consumes it while + // a confirmation is open. This handler must not also close the detail. + if (event.key !== 'Escape' || event.defaultPrevented || confirmId !== null || clearConfirm + || selectedId === null) return event.preventDefault() event.stopPropagation() closeDetails() @@ -118,7 +145,13 @@ export function TaskManagerPage(props: TaskManagerPageProps) {

{t('title')}

- + {endedCount > 0 && } +
@@ -209,6 +242,27 @@ export function TaskManagerPage(props: TaskManagerPageProps) { onDeleted={closeDetails} onClose={closeDetails} />} + { if (!clearing) setClearConfirm(false) }} + footer={
+ + +
} + > +

{t('clear.count', { count: endedCount })}

+
) } diff --git a/packages/client/ui-schedule/src/client/clear-ended.ts b/packages/client/ui-schedule/src/client/clear-ended.ts new file mode 100644 index 0000000000..99a42f7ad2 --- /dev/null +++ b/packages/client/ui-schedule/src/client/clear-ended.ts @@ -0,0 +1,45 @@ +/** Host-wide removal of every ended task, as the management page's one action reports it. */ +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { ScheduleDeleteInactiveResult } from '@deepseek-ai/dsh-schedule/client' + +/** How one bulk removal settled; the wiring reports it as an app-wide notice. */ +export type ClearEndedOutcome = + | { readonly cleared: true; readonly count: number } + | { readonly cleared: false } + +/** Remote removal and the authoritative readback that follows it. */ +export interface ClearEndedDependencies { + /** Ask the Host to remove every ended task; it answers how many rows it removed. */ + readonly removeInactive: () => Promise> + /** + * Read the catalog again, naming the newest request ordinal so a read sent + * before the removal is superseded rather than reused. + * @returns resolution after the read result is published. + */ + readonly refresh: () => Promise +} + +/** + * Create the management page's bulk removal callback. + * + * The call names no task ids: the Host owns which rows are ended, and a + * delivery can have moved the catalog between the confirmation and the + * request. The readback follows the acknowledged removal, so the list the page + * renders next agrees with the count the notice states. + * @param deps - Remote removal and its catalog readback. + * @returns a callback that resolves once the settled outcome is available. + */ +export function createClearEnded(deps: ClearEndedDependencies): () => Promise { + return async () => { + let result: RemoteResult + try { + result = await deps.removeInactive() + } catch (_error: unknown) { + // A rejected request does not establish which rows changed. + return { cleared: false } + } + if (!result.ok) return { cleared: false } + await deps.refresh() + return { cleared: true, count: result.value.deleted } + } +} diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index 084d0edb8d..c203ce42e2 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -43,6 +43,7 @@ import type {} from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ScheduleCatalogEntry, ScheduleDeliveryHistoryRequest, ScheduleId, ScheduleUpdateRequest } from '@deepseek-ai/dsh-schedule/client' import { createCatalogSource, type CatalogDeleteOutcome, type CatalogInjected } from './catalog-source.ts' +import { createClearEnded } from './clear-ended.ts' import { createDeleteToastSource, ScheduleDeleteToast } from './DeleteToast.tsx' import { SCHEDULE_TASK_ID, SCHEDULE_TASK_KIND, scheduleTaskDefinition } from './definition.ts' import { ScheduleCatalogAction } from './ScheduleCatalogAction.tsx' @@ -137,6 +138,17 @@ export function apply(ctx: ClientContext): void { ctx.uiWorkspace.openSession(id) } } + /** + * Remove every ended Host task and read the catalog back. + * + * The readback names every request seen so far, so it supersedes a read sent + * before the removal instead of joining it. + * @returns the settled bulk-removal outcome. + */ + const clearEnded = createClearEnded({ + removeInactive: () => ctx.remote.schedule.deleteInactive(), + refresh: () => manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest), + }) const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = { hooks: manager.hooks, onDelete: reportedDelete(manager.onDelete), @@ -173,6 +185,10 @@ export function apply(ctx: ClientContext): void { ...detail, // The page has no creation form: a new reminder starts in a Session. onNewTask: () => { ctx.uiWorkspace.startSession() }, + onClearEnded: async () => { + const outcome = await clearEnded() + deleteToast.reportClear(outcome) + }, }), }, TaskManagerPage)) ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index 7439d9733b..056f8a3cbb 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -134,8 +134,19 @@ export const en = { 'delete.cancel': 'Cancel', 'delete.close': 'Close deletion confirmation', 'delete.pending': 'Deleting…', + 'clear.action': 'Clear ended', + 'clear.title': 'Clear ended automation tasks?', + 'clear.description': 'These tasks stop triggering and are deleted together with their saved delivery records. ' + + 'The original sessions and their messages remain.', + 'clear.count': '{count} ended tasks', + 'clear.confirm': 'Clear tasks', + 'clear.pending': 'Clearing…', + 'clear.close': 'Close clear confirmation', 'toast.deleted': 'Task deleted.', 'toast.deleteFailed': 'Could not delete the task.', + 'toast.cleared': 'Cleared {count} ended tasks.', + 'toast.clearedNone': 'No ended tasks to clear.', + 'toast.clearFailed': 'Could not clear the ended tasks.', 'card.open': 'Open', 'card.openLabel': 'Open task details: {title}', 'card.deleted': 'Deleted', @@ -276,8 +287,18 @@ export const zh: Record = { 'delete.cancel': '取消', 'delete.close': '关闭删除确认', 'delete.pending': '正在删除…', + 'clear.action': '清理已结束', + 'clear.title': '清理已结束的自动化任务?', + 'clear.description': '这些任务将停止触发,并连同其保存的运行记录一并删除。原会话及其消息仍然保留', + 'clear.count': '共 {count} 个已结束任务', + 'clear.confirm': '确认清理', + 'clear.pending': '正在清理…', + 'clear.close': '关闭清理确认', 'toast.deleted': '任务已删除', 'toast.deleteFailed': '无法删除任务', + 'toast.cleared': '已清理 {count} 个已结束任务', + 'toast.clearedNone': '没有可清理的已结束任务', + 'toast.clearFailed': '无法清理已结束任务', 'card.open': '打开', 'card.openLabel': '打开任务详情:{title}', 'card.deleted': '已删除', diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index 4fa73699b9..75b8f0c32d 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -475,6 +475,50 @@ describe('ui-schedule browser half', () => { } }) + it('clears every ended task from the global page, reads the catalog back, and reports the count', async () => { + const record: ScheduleCatalogEntry = { + id: 'host-ended' as ScheduleId, sessionId: 'cold-original' as SessionId, status: 'inactive', + kind: 'after', title: 'Ended review', prompt: 'Ended review', afterSeconds: 60, + scheduledAt: '2099-01-01T00:00:00.000Z', + } + const catalog = vi.fn() + .mockResolvedValueOnce({ ok: true, value: [record] }) + .mockResolvedValue({ ok: true, value: [] }) + const deleteInactive = vi.fn().mockResolvedValue({ ok: true, value: { deleted: 1 } }) + const ctx = await baseContext({ schedule: { catalog, deleteInactive }, $on: () => () => {} }) + const owner = ctx.slots.register({ + name: 'root', + children: { + main: { kind: 'keyed', scope: 'root' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, + }, + } as never, Empty) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + try { + const face = injectedFace(ctx.slots.entries('main')[0]!, undefined as never) as TaskManagerInjected + const toast = injectedFace(ctx.slots.entries('shell.overlay')[0]!) as Omit + const dispose = face.hooks.catalog.subscribe(vi.fn()) + try { + await vi.waitFor(() => { expect(face.hooks.catalog.getSnapshot().records).toEqual([record]) }) + const reads = catalog.mock.calls.length + await face.onClearEnded() + expect(deleteInactive).toHaveBeenCalledOnce() + // The acknowledged removal reads the catalog back instead of joining a + // read issued before it, so the page's rows follow the removal. + expect(catalog.mock.calls.length).toBe(reads + 1) + await vi.waitFor(() => { expect(face.hooks.catalog.getSnapshot().records).toEqual([]) }) + expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'cleared', count: 1, seq: 1 }) + } finally { + dispose() + } + } finally { + await fiber.dispose() + owner() + await ctx.fiber.dispose() + } + }) + it('registers the row mark and hover-card seats under the Session row declarations', async () => { const catalog = vi.fn().mockResolvedValue({ ok: true, value: [] }) const ctx = await baseContext({ schedule: { catalog }, $on: () => () => {} }) diff --git a/packages/client/ui-schedule/tests/clear-ended.client.spec.ts b/packages/client/ui-schedule/tests/clear-ended.client.spec.ts new file mode 100644 index 0000000000..d453deef50 --- /dev/null +++ b/packages/client/ui-schedule/tests/clear-ended.client.spec.ts @@ -0,0 +1,63 @@ +/** The management page's bulk removal: one request, one readback, one settled outcome. */ +import { describe, expect, it, vi } from 'vitest' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { ScheduleDeleteInactiveResult } from '@deepseek-ai/dsh-schedule/client' +import { createClearEnded } from '../src/client/clear-ended.ts' + +const failure: RemoteResult = { + ok: false, error: new RemoteError('gateway/internal', 'Unavailable', {}), +} + +/** + * Wire the helper to one fake Remote answer and one readback spy. + * @param answer - what the removal call answers, or the reason it rejects. + * @param refresh - the readback the helper calls after an acknowledged removal. + * @returns the callback under test with its dependencies' spies. + */ +function setup( + answer: RemoteResult | Error, + refresh = vi.fn(async () => {}), +) { + const removeInactive = vi.fn(async () => { + if (answer instanceof Error) throw answer + return answer + }) + return { clearEnded: createClearEnded({ removeInactive, refresh }), removeInactive, refresh } +} + +describe('createClearEnded', () => { + it('reads the catalog back after an acknowledged removal and reports how many rows went', async () => { + const { clearEnded, removeInactive, refresh } = setup({ ok: true, value: { deleted: 3 } }) + + await expect(clearEnded()).resolves.toEqual({ cleared: true, count: 3 }) + + expect(removeInactive).toHaveBeenCalledOnce() + expect(refresh).toHaveBeenCalledOnce() + // The readback is what makes the list agree with the count, so it settles + // before the outcome is published. + expect(removeInactive.mock.invocationCallOrder[0]) + .toBeLessThan(refresh.mock.invocationCallOrder[0]!) + }) + + it('reports an acknowledged removal that found nothing as a cleared outcome', async () => { + const { clearEnded, refresh } = setup({ ok: true, value: { deleted: 0 } }) + + await expect(clearEnded()).resolves.toEqual({ cleared: true, count: 0 }) + expect(refresh).toHaveBeenCalledOnce() + }) + + it('reports a refused removal and reads nothing back', async () => { + const { clearEnded, refresh } = setup(failure) + + await expect(clearEnded()).resolves.toEqual({ cleared: false }) + expect(refresh).not.toHaveBeenCalled() + }) + + it('reports a rejected removal and reads nothing back', async () => { + const { clearEnded, refresh } = setup(new Error('connection lost')) + + await expect(clearEnded()).resolves.toEqual({ cleared: false }) + expect(refresh).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx index 55f6535587..aadce87d0c 100644 --- a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx +++ b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx @@ -48,6 +48,17 @@ describe('delete toast source', () => { source.report('deleted') expect(listener).toHaveBeenCalledTimes(4) }) + + it('publishes each settled bulk removal with its count or failure', () => { + const source = createDeleteToastSource() + source.reportClear({ cleared: true, count: 3 }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'cleared', count: 3, seq: 1 }) + // A removal that found nothing keeps its own zero, so the banner can say so. + source.reportClear({ cleared: true, count: 0 }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'cleared', count: 0, seq: 2 }) + source.reportClear({ cleared: false }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'clearFailed', seq: 3 }) + }) }) describe('ScheduleDeleteToast', () => { @@ -68,4 +79,22 @@ describe('ScheduleDeleteToast', () => { mount({ kind: 'deleteFailed', seq: 2 }, zh) expect(screen.getByRole('alert').textContent).toBe(zh['toast.deleteFailed']) }) + + it('states how many ended tasks a bulk removal cleared', () => { + vi.useFakeTimers() + const { dismiss } = mount({ kind: 'cleared', count: 4, seq: 3 }) + expect(screen.getByRole('alert').textContent).toBe(en['toast.cleared'].replace('{count}', '4')) + act(() => { vi.runAllTimers() }) + expect(dismiss).toHaveBeenCalledOnce() + }) + + it('states that a bulk removal found nothing instead of a zero count', () => { + mount({ kind: 'cleared', count: 0, seq: 4 }, zh) + expect(screen.getByRole('alert').textContent).toBe(zh['toast.clearedNone']) + }) + + it('shows the warning banner for a bulk removal that failed', () => { + mount({ kind: 'clearFailed', seq: 5 }, zh) + expect(screen.getByRole('alert').textContent).toBe(zh['toast.clearFailed']) + }) }) diff --git a/packages/client/ui-schedule/tests/task-detail.client.spec.tsx b/packages/client/ui-schedule/tests/task-detail.client.spec.tsx index ff64d41b88..97bef63a33 100644 --- a/packages/client/ui-schedule/tests/task-detail.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-detail.client.spec.tsx @@ -122,6 +122,7 @@ function mount( onDelete: vi.fn(async () => 'deleted'), onRetry: vi.fn(async () => {}), onNewTask: vi.fn(), + onClearEnded: vi.fn(async () => {}), onUpdateTiming: vi.fn(async ({ expected }) => ({ ok: true, value: { id: expected.id, updated: false, record: expected }, })), diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index a7892edc19..dad39c52ba 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -178,6 +178,7 @@ function mount( onDelete: vi.fn(async () => 'deleted'), onRetry: vi.fn(async () => {}), onNewTask: vi.fn(), + onClearEnded: vi.fn(async () => {}), onUpdateTiming: vi.fn(async ({ expected }) => ({ ok: true, value: { id: expected.id, updated: false, record: expected }, })), @@ -942,6 +943,98 @@ describe('Task manager catalog', () => { }) }) +describe('clearing ended tasks', () => { + // A distinct identity: the module's `ended` case reuses the active row's id. + const endedTask: ScheduleCatalogEntry = { + ...ended, id: 'task-ended' as ScheduleId, title: 'Ended summary', prompt: 'Ended summary', + } + const anotherEnded: ScheduleCatalogEntry = { + ...ended, id: 'task-ended-two' as ScheduleId, title: 'Ended report', prompt: 'Ended report', + } + + /** The heading's action pair, left to right. */ + function creationActions(): HTMLElement { + return document.querySelector(`.${css.creationActions!}`)! + } + + it('offers the cleanup beside New only while the catalog holds an ended task', () => { + const h = mount() + expect(within(creationActions()).queryByRole('button', { name: en['clear.action'] })).toBeNull() + h.update({ records: [...records, endedTask] }) + // The cleanup sits left of the primary action, which keeps the row's end. + expect(within(creationActions()).getAllByRole('button').map(button => button.textContent)) + .toEqual([en['clear.action'], en['new.action']]) + // An ended task that the current view filters out still offers the cleanup. + fireEvent.click(screen.getByRole('button', { name: en['status.active'] })) + expect(screen.queryByRole('button', { name: 'Ended summary' })).toBeNull() + expect(within(creationActions()).getByRole('button', { name: en['clear.action'] })).toBeDefined() + }) + + it('states the complete ended set in the confirmation, not the filtered rows', () => { + mount({ records: [...records, endedTask, anotherEnded] }) + fireEvent.change(screen.getByRole('searchbox', { name: en['search.label'] }), { + target: { value: 'Send summary' }, + }) + expect(taskNames()).toEqual(['Send summary']) + fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + expect(within(dialog).getByText(en['clear.description'])).toBeDefined() + expect(within(dialog).getByText(en['clear.count'].replace('{count}', '2'))).toBeDefined() + }) + + it('removes nothing when the confirmation is dismissed', () => { + const h = mount({ records: [at, endedTask] }) + fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + fireEvent.click(screen.getByRole('button', { name: en['delete.cancel'] })) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(h.props.onClearEnded).not.toHaveBeenCalled() + }) + + it('keeps the confirmation open until the settled removal, then returns focus', async () => { + const pending = Promise.withResolvers() + const onClearEnded = vi.fn(() => pending.promise) + mount({ records: [at, endedTask] }, en, { onClearEnded }) + const trigger = screen.getByRole('button', { name: en['clear.action'] }) + // A pointer press focuses the control in the browser; jsdom leaves focus alone. + trigger.focus() + fireEvent.click(trigger) + const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + fireEvent.click(within(dialog).getByRole('button', { name: en['clear.confirm'] })) + expect(onClearEnded).toHaveBeenCalledOnce() + + const running = screen.getByRole('dialog', { name: en['clear.title'] }) + expect(within(running).getByRole('button', { name: en['clear.pending'] }).hasAttribute('disabled')).toBe(true) + expect(within(running).getByRole('button', { name: en['delete.cancel'] }).hasAttribute('disabled')).toBe(true) + // The confirmation is the only place that states the removal still runs, so + // the Escape that would close it waits for the outcome. + fireEvent.keyDown(running, { key: 'Escape' }) + expect(screen.getByRole('dialog', { name: en['clear.title'] })).toBe(running) + + await act(async () => { pending.resolve(undefined) }) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + it('closes only the confirmation on Escape while a task detail is open', () => { + mount({ records: [at, endedTask] }) + fireEvent.click(screen.getByRole('button', { name: 'Review release' })) + const detail = screen.getByRole('complementary', { name: en['detail.label'] }) + fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(screen.getByRole('complementary', { name: en['detail.label'] })).toBe(detail) + }) + + it('states the cleanup and its confirmation in Chinese', () => { + mount({ records: [at, endedTask] }, zh) + fireEvent.click(screen.getByRole('button', { name: zh['clear.action'] })) + const dialog = screen.getByRole('dialog', { name: zh['clear.title'] }) + expect(within(dialog).getByText(zh['clear.count'].replace('{count}', '1'))).toBeDefined() + expect(within(dialog).getByRole('button', { name: zh['clear.confirm'] })).toBeDefined() + }) +}) + describe.each([['English', en], ['Chinese', zh]] as const)('task detail views in %s', (_name, dictionary) => { it('opens Rules under the selected task and separates rule fields from saved deliveries', async () => { const labels = detailViewLabels(dictionary) @@ -3814,11 +3907,12 @@ describe('Mock detail metrics shared with the task list', () => { ) // The Plugins toolbar's Add-plugin capsule, repeated by the creation action. expect(rule('creationActions')).toMatch(/gap:\s*16px;/) - const newButton = rule('newButton') - expect(newButton).toMatch(/height:\s*32px;/) - expect(newButton).toMatch(/font-size:\s*13px;/) - expect(newButton).toMatch(/padding:\s*0 12px;/) - expect(newButton).toMatch(/border-radius:\s*16px;/) + // The New action and the bulk cleanup share this capsule geometry. + const headerAction = rule('headerAction') + expect(headerAction).toMatch(/height:\s*32px;/) + expect(headerAction).toMatch(/font-size:\s*13px;/) + expect(headerAction).toMatch(/padding:\s*0 12px;/) + expect(headerAction).toMatch(/border-radius:\s*16px;/) const headingTitle = /\.pageHeading h1\s*\{([^}]*)\}/.exec(stylesheet)?.[1] ?? '' // The first-level title step is the Plugins page's `pageTitle`, not the // mock's larger page heading; the equality case below pins that pair. diff --git a/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json b/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json index 274fc5b552..aee0023246 100644 --- a/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json +++ b/packages/client/ui-theme/tests/expected/radius-exceptions.expected.json @@ -29,7 +29,7 @@ ".leading: border-radius: 10px" ], "ui-schedule/src/client/TaskManagerPage.module.css": [ - ".newButton: border-radius: 16px", + ".headerAction: border-radius: 16px", ".filterTab: border-radius: 14px", ".searchField: border-radius: 12px", ".row: border-radius: 12px", diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index be518c2952..c14c966f8d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2200,6 +2200,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'Session and exact task identity.' }, { name: 'signal', description: 'Optional cancellation checked before persistence begins, including after FIFO waits.' }], returns: 'Whether that Session owned a deleted task. Cancellation does not roll back an in-flight write.', }, + { + signature: '@Remote(\'deleteInactive\') async deleteInactive(): Promise', + description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a create ordered behind this call is not removed by it. Each removed row is durable on its own: a failure partway through leaves the earlier removals committed and announced, then rejects rather than reporting a partial count as the whole.', + parameters: [], + returns: 'Count of removed rows; zero when the Host held no ended task.', + }, { signature: '@Remote(\'update\') async update(request: ScheduleUpdateRequest, signal?: AbortSignal): Promise', description: 'Update the name, instruction, and timing of an active task within the original Session binding without activating the Session or changing saved deliveries.\n\nEach supplied field replaces its stored value; an omitted field keeps it. A name or instruction change alone does not reset the committed target. A Session a delegated child owns returns the non-mutating `subagent_session` result, so an edit cannot re-arm a task bound to a Session delivery can never reach, and the Web editor can explain the refusal through the ordinary result it already renders.', @@ -7156,6 +7162,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ScheduleCreateRequest', declaration: 'export interface ScheduleCreateRequest {\n prompt: string;\n title: string;\n after_seconds?: number;\n at?: AtInput;\n every_seconds?: number;\n daily?: DailyInput;\n weekly?: WeeklyInput;\n cron?: CronInput;\n}', }, + { + name: 'ScheduleDeleteInactiveResult', + declaration: 'export interface ScheduleDeleteInactiveResult {\n readonly deleted: number;\n}', + }, { name: 'ScheduleDeleteRequest', declaration: 'export interface ScheduleDeleteRequest extends ScheduleListRequest {\n id: ScheduleId;\n}', diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 8e4f6e7355..373a697408 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: ec60f8eec2f735bc - zh: ed06b44badc93456 + en: 694ff524482a3ac0 + zh: 139e1814a270421c /deepseek-ai-dsh-schedule/understand-the-implementation: en: 02848e6fd6317b2a zh: 1807a423210f81ef diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index a14c95023e..0fe1d65bb7 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -44,7 +44,7 @@ Weekly input adds `weekdays`, a non-empty set of ISO weekday numbers from Monday Cron input carries `expression` and `time_zone`. The expression is the standard five-field Vixie form `minute hour day-of-month month day-of-week`: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, and day-of-week 0-7 where both `0` and `7` mean Sunday. Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. `L`, `W`, `#`, `JAN`/`MON` names, `@daily`-style macros, six-field expressions, out-of-range values, inverted ranges, zero steps, and empty fields are rejected with `invalid_rule` and a message naming the offending field. Because the dialect has five fields, the smallest interval is one minute. Creation stores a canonical expression: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step, so the day rule below survives storage. The durable decoder rejects a non-canonical stored expression, so the record always holds the canonical text. When either day-of-month or day-of-week is a star, a local date matches only when both fields match; when neither is a star, either field matching is enough. A field is a star when its text starts with `*`, independently of the values it matches, so a stepped star constrains alongside the other field instead of substituting for it. The first target is the first strictly future instant whose local date and time in that zone match, using the same gap skip and earlier-overlap rules as Daily. See [cron timing](../../../docs/subsystems/schedule.md#cron-wall-clock-input) for the full dialect and canonicalization rules. -A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. +A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every ended task across Sessions and answers how many rows it removed; an ended task never arms a timer, so that removal recomputes no target, and because rows are removed one at a time a failure partway through leaves the earlier removals committed and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. Archiving a Session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 73e744d038..5f938af66d 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -44,7 +44,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 cron 输入携带 `expression` 和 `time_zone`。表达式是标准五字段 Vixie 形式 `minute hour day-of-month month day-of-week`:minute 0-59、hour 0-23、day-of-month 1-31、month 1-12、day-of-week 0-7,其中 `0` 和 `7` 都表示周日。每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。`L`、`W`、`#`、`JAN`/`MON` 名称、`@daily` 风格宏、六字段表达式、超出范围的值、反向范围、零步长和空字段都会以 `invalid_rule` 拒绝,且错误信息会指出违规字段。由于该方言只有五个字段,最小间隔为一分钟。创建时存储规范化表达式:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长,因此下文的日规则在存储后保持不变。持久化解码器拒绝非规范化的已存储表达式,因此记录始终保存规范化文本。day-of-month 或 day-of-week 中任一为星号字段时,本地日期需两个字段都匹配;两者都不是星号字段时,匹配任一字段即可。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此步长星号字段与其他字段同时约束,而不是替代它。首个目标是该时区内本地日期与时间匹配的第一个严格未来时点,使用与 Daily 相同的缺口跳过与重叠取较早时点规则。完整方言与规范化规则见 [cron 时间语义](../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input)。 -提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 +提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且由于逐行移除,中途失败时先前已移除的行保持已移除,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 归档仍有活动提醒的会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index b9144c5347..844af460f3 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -19,9 +19,9 @@ import { scheduleTitle, } from './domain.ts' import type { - DeliveryRetentionBounds, ScheduleCatalogEntry, ScheduleCreateRequest, ScheduleDeleteRequest, ScheduleDeleteResult, - ScheduleDeliveryHistoryRequest, ScheduleDeliveryHistoryResult, ScheduleListRequest, ScheduleRecord, - ScheduleUpdateRequest, ScheduleUpdateResult, SubagentSessionError, + DeliveryRetentionBounds, ScheduleCatalogEntry, ScheduleCreateRequest, ScheduleDeleteInactiveResult, + ScheduleDeleteRequest, ScheduleDeleteResult, ScheduleDeliveryHistoryRequest, ScheduleDeliveryHistoryResult, + ScheduleListRequest, ScheduleRecord, ScheduleUpdateRequest, ScheduleUpdateResult, SubagentSessionError, } from './types.ts' export type * from './types.ts' @@ -366,6 +366,43 @@ export class ScheduleService extends TypertRemoteService { }) } + /** + * Delete every ended Host task, leaving active tasks armed. + * + * Ended tasks never schedule another delivery, so the removal changes no pending + * target and no timer is recomputed. Rows are enumerated and removed in one FIFO + * slot, so a create ordered behind this call is not removed by it. Each removed row + * is durable on its own: a failure partway through leaves the earlier removals + * committed and announced, then rejects rather than reporting a partial count as + * the whole. + * @returns Count of removed rows; zero when the Host held no ended task. + */ + @Remote('deleteInactive') + async deleteInactive(): Promise { + return this.serialize(async () => { + const tasks = (await this.getDomain()).table('tasks') + const ended = [...tasks.entries()] + .filter(([, task]) => task.status === 'inactive') + .map(([, task]) => task.record.id) + let deleted = 0 + let failure: Error | undefined + for (const id of ended) { + try { + // A row another queued write already removed is not this call's removal. + if (await tasks.delete(id)) deleted += 1 + } catch (error: unknown) { + // Every row is attempted, so one failed delete does not leave the rest + // of the ended rows behind. A rejection reason that carries no Error is + // wrapped before the first one is kept. + failure ??= error instanceof Error ? error : new Error(`schedule ended-task removal failed: ${String(error)}`) + } + } + if (deleted > 0) this.emitChanged() + if (failure !== undefined) throw failure + return { deleted } + }) + } + /** * Update the name, instruction, and timing of an active task within the original Session * binding without activating the Session or changing saved deliveries. diff --git a/packages/schedule/schedule/src/types.ts b/packages/schedule/schedule/src/types.ts index d072de64b8..27ebe667a2 100644 --- a/packages/schedule/schedule/src/types.ts +++ b/packages/schedule/schedule/src/types.ts @@ -349,6 +349,12 @@ export type ScheduleDeleteResult = /** Canonical `schedule_delete` value. */ export type ScheduleDeleteValue = ScheduleDeleteResult | ScheduleToolError +/** Successful Host-wide removal of every ended task, whether or not any remained. */ +export interface ScheduleDeleteInactiveResult { + /** Ended task rows this call removed; zero when the Host held none. */ + readonly deleted: number +} + declare module '@deepseek-ai/dsh-workspace/types' { interface SessionActivityKindMap { /** A scheduled follow-up for this session is still active. */ diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts new file mode 100644 index 0000000000..6080e57d77 --- /dev/null +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -0,0 +1,170 @@ +/** Host-wide removal of every ended task shares per-row deletion's durability rules. */ +import { SessionId } from '@deepseek-ai/dsh-session' +import { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createAfterScheduleRecord, ScheduleId } from '../src/domain.ts' +import { scheduleDomain, type ScheduleTask } from '../src/storage.ts' +import { MemoryMediaPool } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' +import { gatedScheduleBackend, harness } from './harness.ts' + +const tests: Awaited>[] = [] +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-16T00:00:00Z')) +}) +afterEach(async () => { + await Promise.all(tests.splice(0).map(test => test.ctx.fiber.dispose())) + vi.useRealTimers() +}) + +/** + * Build one ended task carrying one saved delivery. + * @param sessionId - Session the task was created in. + * @param id - stored task identity. + * @param title - stored task name. + * @returns the row a delivered one-shot leaves behind. + */ +function endedRow(sessionId: SessionId, id: string, title: string): ScheduleTask { + const receipt = { + scheduledAt: '2026-09-15T23:59:00.000Z', + deliveredAt: '2026-09-15T23:59:01.000Z', + messageId: MessageId(`${id}-delivery`), + } + return { + sessionId, + record: createAfterScheduleRecord(ScheduleId(id), title, 60, Date.now(), title), + status: 'inactive', + lastDelivery: receipt, + deliveryHistory: { records: [receipt], earlierRecordsUnavailable: false }, + } +} + +/** + * Boot a service whose store already holds the given rows. + * @param rows - task rows present before activation. + * @returns the started harness with its storage pool. + */ +async function seeded(rows: readonly ScheduleTask[]) { + const test = await harness({ beforeService: async (_ctx, facility) => { + const domain = await facility.open(scheduleDomain) + for (const row of rows) await domain.table('tasks').put(row.record.id, row) + await domain.close() + } }) + tests.push(test) + return test +} + +it('removes ended tasks across Sessions with their saved deliveries and keeps active ones', async () => { + const firstSession = SessionId('ended-first') + const secondSession = SessionId('ended-second') + const armedSession = SessionId('still-armed') + const endedFirst = endedRow(firstSession, 'ended-a', 'Ended first') + const endedSecond = endedRow(secondSession, 'ended-b', 'Ended second') + const armed = createAfterScheduleRecord(ScheduleId('armed'), 'Still armed', 3_600, Date.now(), 'Still armed') + const test = await seeded([endedFirst, endedSecond, { + sessionId: armedSession, record: armed, status: 'active', + }]) + const { ctx, service, resolve } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + expect(await service.deleteInactive()).toEqual({ deleted: 2 }) + + expect((await service.catalog()).map(entry => entry.title)).toEqual(['Still armed']) + expect(await service.list({ sessionId: firstSession })).toEqual([]) + // The saved delivery records went with their rows, so history no longer resolves them. + expect(await service.history({ sessionId: firstSession, id: endedFirst.record.id, limit: 20 })) + .toEqual({ id: endedFirst.record.id, code: 'schedule_not_found' }) + expect(await service.history({ sessionId: secondSession, id: endedSecond.record.id, limit: 20 })) + .toEqual({ id: endedSecond.record.id, code: 'schedule_not_found' }) + expect(changed).toHaveBeenCalledTimes(1) + expect(resolve).not.toHaveBeenCalled() + expect(ctx.agents.roots()).toEqual([]) +}) + +it('reports zero and announces nothing when the Host holds no ended task', async () => { + const sessionId = SessionId('only-active') + const test = await seeded([{ + sessionId, + record: createAfterScheduleRecord(ScheduleId('armed'), 'Still armed', 3_600, Date.now(), 'Still armed'), + status: 'active', + }]) + const { ctx, service } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + expect(await service.deleteInactive()).toEqual({ deleted: 0 }) + expect(changed).not.toHaveBeenCalled() + expect(await service.catalog()).toHaveLength(1) +}) + +it('keeps the removals that landed, announces them, and reports the failure', async () => { + const pool = new MemoryMediaPool() + let deletes = 0 + // The first ended row lands durably; the second row's physical delete rejects. + const backend = gatedScheduleBackend(pool, { + deleteRecord: async (table) => { + if (table !== 'tasks') return + deletes += 1 + if (deletes === 2) throw new Error('injected delete failure') + }, + }) + const test = await harness({ + pool, + backend, + beforeService: async (_ctx, facility) => { + const domain = await facility.open(scheduleDomain) + await domain.table('tasks').put(ScheduleId('ended-a'), endedRow(SessionId('partial-a'), 'ended-a', 'Partial first')) + await domain.table('tasks').put(ScheduleId('ended-b'), endedRow(SessionId('partial-b'), 'ended-b', 'Partial second')) + await domain.close() + }, + }) + tests.push(test) + const { ctx, service } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + const failure = await service.deleteInactive().then(() => undefined, (error: unknown) => error) + + expect(failure).toBeInstanceOf(Error) + expect(String(failure)).toContain('injected delete failure') + // The row removed before the failure is a durable change observers must hear. + expect(changed).toHaveBeenCalledTimes(1) + expect((await service.catalog()).map(entry => entry.title)).toEqual(['Partial second']) +}) + +it('attempts every row and reports a rejection reason that carries no Error', async () => { + const pool = new MemoryMediaPool() + let deletes = 0 + const backend = gatedScheduleBackend(pool, { + deleteRecord: async (table) => { + if (table !== 'tasks') return + deletes += 1 + throw undefined + }, + }) + const test = await harness({ + pool, + backend, + beforeService: async (_ctx, facility) => { + const domain = await facility.open(scheduleDomain) + await domain.table('tasks').put(ScheduleId('ended-a'), endedRow(SessionId('unreportable-a'), 'ended-a', 'Unreportable first')) + await domain.table('tasks').put(ScheduleId('ended-b'), endedRow(SessionId('unreportable-b'), 'ended-b', 'Unreportable second')) + await domain.close() + }, + }) + tests.push(test) + const { ctx, service } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + const failure = await service.deleteInactive().then(() => undefined, (error: unknown) => error) + + // A reason that carries no Error must not read as a successful removal. + expect(failure).toBeInstanceOf(Error) + expect(String(failure)).toContain('schedule ended-task removal failed') + expect(deletes).toBe(2) + expect(changed).not.toHaveBeenCalled() + expect((await service.catalog()).map(entry => entry.title).sort()) + .toEqual(['Unreportable first', 'Unreportable second']) +}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f5791d537d..f308acbfe0 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -545,6 +545,7 @@ export const LINK_MAP: Readonly> = { ScheduleListRequest: 'schedule.md', ScheduleDeleteRequest: 'schedule.md', ScheduleDeleteResult: 'schedule.md', + ScheduleDeleteInactiveResult: 'schedule.md', ScheduleTimingChange: 'schedule.md', ScheduleUpdateRequest: 'schedule.md', ScheduleUpdateResult: 'schedule.md', From 422aa724e32c2e91951d4e25c5a7f444270267f8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 18:41:33 +0800 Subject: [PATCH 02/34] fix(schedule): qualify the clear-ended copy and pin its tests --- apps/web/tests/schedule-after.e2e.ts | 85 +++++++++++-------- docs/subsystems/schedule.i18n.yaml | 4 +- docs/subsystems/schedule.md | 18 ++-- docs/subsystems/schedule.zh.md | 18 ++-- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 4 +- packages/client/ui-schedule/README.zh.md | 4 +- .../ui-schedule/src/client/DeleteToast.tsx | 4 +- .../src/client/TaskManagerPage.module.css | 5 +- .../src/client/TaskManagerPage.tsx | 2 +- .../src/client/task-manager-locales.ts | 16 ++-- .../tests/clear-ended.client.spec.ts | 23 +++-- .../tests/delete-toast.client.spec.tsx | 11 ++- .../tests/task-manager-page.client.spec.tsx | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/schedule/schedule/src/index.ts | 8 +- .../schedule/tests/delete-inactive.spec.ts | 14 ++- scripts/type-equiv.manifest.json | 5 ++ 18 files changed, 146 insertions(+), 85 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index d67aa3ab41..6b4bb9aa9a 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1267,9 +1267,12 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { if (domain === undefined) throw new Error('Schedule domain was not opened') const endedIds = [ScheduleId('schedule-ended-first'), ScheduleId('schedule-ended-second')] const endedTitles = ['Ended release check', 'Ended metrics check'] as const + // An earlier scenario may leave an ended row behind; the confirmation states + // the whole ended set, so the lane starts from an empty one. + await scaffold.ctx.schedule.deleteInactive() for (const [index, id] of endedIds.entries()) { const title = endedTitles[index]! - const record = createAfterScheduleRecord(ScheduleId(id), title, 60, Date.now(), title) + const record = createAfterScheduleRecord(id, title, 60, Date.now(), title) const receipt = { scheduledAt: record.scheduledAt, deliveredAt: new Date(Date.parse(record.scheduledAt) + 1_000).toISOString(), @@ -1280,45 +1283,55 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { deliveryHistory: { records: [receipt], earlierRecordsUnavailable: false }, }) } - // A create is the Host's own committed change, so its notification is what - // makes the open page read the rows seeded above. + // The running task is what the cleanup must leave alone. Creating it also + // announces a committed Host change, so the open page reads the rows seeded + // above without waiting for its own first catalog read. const kept = await scaffold.ctx.schedule.create(endedSession, { prompt: 'Keep this running task', title: 'Keep this running task', at: '2099-08-25T12:08:00.000Z', }) - await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() - const manager = page.getByTestId('task-manager-page') - // Earlier scenarios leave their own search text and status filter on this - // shared page; this scenario asserts the complete list. - await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('') - await manager.getByRole('button', { name: 'All', exact: true }).click() - const clear = manager.getByRole('button', { name: 'Clear ended', exact: true }) - await clear.waitFor({ timeout: 15_000 }) + try { + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + // Earlier scenarios leave their own search text and status filter on this + // shared page; this scenario asserts the complete list. + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('') + await manager.getByRole('button', { name: 'All', exact: true }).click() + const clear = manager.getByRole('button', { name: 'Clear ended', exact: true }) + await clear.waitFor({ timeout: 15_000 }) - // The confirmation states the Host-side target and removes nothing until it - // is confirmed. - await clear.click() - const confirmation = page.getByRole('dialog', { name: 'Clear ended automation tasks?' }) - await confirmation.getByText('2 ended tasks', { exact: true }).waitFor() - await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() - expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) + // The confirmation states the Host-side target and removes nothing until it + // is confirmed. + await clear.click() + const confirmation = page.getByRole('dialog', { name: 'Clear ended automation tasks?' }) + await confirmation.getByText('2 ended tasks', { exact: true }).waitFor() + await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() + expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) - await clear.click() - await confirmation.getByRole('button', { name: 'Clear tasks', exact: true }).click() - await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')) - .toEqual([]) - const remaining = await scaffold.ctx.schedule.catalog() - expect(remaining.some(task => endedIds.includes(task.id))).toBe(false) - expect(remaining.map(task => task.title)).toContain('Keep this running task') - await page.getByText('Cleared 2 ended tasks.', { exact: true }).waitFor({ timeout: 15_000 }) - // The page follows the acknowledged removal: no ended row or cleanup action - // remains, and the running task is still listed. - await expect.poll(() => manager.getByRole('button', { name: 'Clear ended', exact: true }).count(), { timeout: 15_000 }) - .toBe(0) - await manager.getByRole('button', { name: 'All', exact: true }).click() - await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' }) - .getByRole('button', { name: kept.title, exact: true }).count(), { timeout: 15_000 }).toBe(1) - expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toEqual([]) - expect(tripwire.pageErrors).toEqual([]) - await scaffold.ctx.schedule.delete({ sessionId: endedSession, id: kept.id }) + await clear.click() + await confirmation.getByRole('button', { name: 'Clear tasks', exact: true }).click() + await expect.poll( + async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive'), + { timeout: 15_000 }, + ).toEqual([]) + const remaining = await scaffold.ctx.schedule.catalog() + expect(remaining.some(task => endedIds.includes(task.id))).toBe(false) + expect(remaining.map(task => task.title)).toContain('Keep this running task') + await page.getByText('Cleared 2 ended tasks.', { exact: true }).waitFor({ timeout: 15_000 }) + // The page follows the acknowledged removal: no ended row or cleanup action + // remains, and the running task is still listed. + await expect.poll(() => manager.getByRole('button', { name: 'Clear ended', exact: true }).count(), { timeout: 15_000 }) + .toBe(0) + await manager.getByRole('button', { name: 'All', exact: true }).click() + await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' }) + .getByRole('button', { name: kept.title, exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + } finally { + // The shared page and Host outlive a failed assertion: remove every row + // this scenario introduced so the next one starts from the same state. + for (const id of [kept.id, ...endedIds]) { + await scaffold.ctx.schedule.delete({ sessionId: endedSession, id }) + } + } }, 60_000) }) diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index 79a3fb77bc..fdd931c7f2 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 93798ad69f74ddd5 zh: 634126ad8accc356 /host-wide-schedule/web-catalog: - en: 630b9879dddd5972 - zh: bfaddb3674ba5c98 + en: 30464765cbe9f81a + zh: eecf1ad0888b182f /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index b3f7fa31d2..8a794f8da9 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -309,6 +309,14 @@ type ScheduleDeleteResult = | { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' } ``` +```ts type-equiv +/** Successful Host-wide removal of every ended task, whether or not any remained. */ +interface ScheduleDeleteInactiveResult { + /** Ended task rows this call removed; zero when the Host held none. */ + readonly deleted: number +} +``` + ## Web catalog The Remote `schedule.catalog()` method returns active and inactive Host tasks with their original Session bindings, ordered by `scheduledAt` ascending and then lexicographically by `id`. It reads only the Schedule domain, without activating Sessions or reading their history. Browser consumers receive this browser-safe type: @@ -337,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended task across Sessions, with the saved delivery records each row carries, and answers `{ deleted }` with the count it removed; the management page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. +The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed, and a failure partway through rejects after the removals already committed and announced. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. The shipped Web composition mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. The Rules and Records tabs separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution. @@ -513,10 +521,10 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO - * slot, so a create ordered behind this call is not removed by it. Each removed row - * is durable on its own: a failure partway through leaves the earlier removals - * committed and announced, then rejects rather than reporting a partial count as - * the whole. + * slot, so a delivery ordered behind this call ends its task after the + * enumeration rather than during it. Each removed row is durable on its own: a + * failure partway through leaves the earlier removals committed and announced, + * then rejects rather than reporting a partial count as the whole. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index 91e470890a..10356c08d2 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -309,6 +309,14 @@ type ScheduleDeleteResult = | { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' } ``` +```ts type-equiv +/** Successful Host-wide removal of every ended task, whether or not any remained. */ +interface ScheduleDeleteInactiveResult { + /** Ended task rows this call removed; zero when the Host held none. */ + readonly deleted: number +} +``` + ## Web 目录 Remote 方法 `schedule.catalog()` 返回活动和未运行的宿主任务及其原始 Session 绑定,先按 `scheduledAt` 升序排列,再按 `id` 字典序排列。它只读取 Schedule domain,不激活 Session 或读取其历史。浏览器消费方接收以下可安全用于浏览器的类型: @@ -337,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行任务,连同每行携带的已保存投递记录,并以它移除的计数答复 `{ deleted }`;自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`,中途失败时先提交并通知已移除的行,再以拒绝结束。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 @@ -513,10 +521,10 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO - * slot, so a create ordered behind this call is not removed by it. Each removed row - * is durable on its own: a failure partway through leaves the earlier removals - * committed and announced, then rejects rather than reporting a partial count as - * the whole. + * slot, so a delivery ordered behind this call ends its task after the + * enumeration rather than during it. Each removed row is durable on its own: a + * failure partway through leaves the earlier removals committed and announced, + * then rejects rather than reporting a partial count as the whole. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index cfbb03bf72..bb1cd53097 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: 9f166d6ea79e3456 - zh: 02e18559999fc1e5 + en: 954dc495cfcce34d + zh: 9d861537037ff82c /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 67effaa929..1c6770f9e1 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,7 +25,7 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package -The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Once the catalog holds an ended task, a bulk cleanup action sits left of New and removes every ended task after a confirmation that states how many the Host will remove. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Once the catalog holds an ended (inactive) task, a Clear ended action sits left of New and removes every ended task after a confirmation that states how many ended tasks the catalog currently holds. The page's task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. The page header keeps the title at its natural width and moves the actions to another line when the title and the actions no longer fit together. Each action label stays on one line. @@ -41,7 +41,7 @@ After the last saved page loads, a nonempty history with confirmed pruning shows A successful empty history page is distinct from loading, query failure, a missing task, or an invalid cursor. Failed requests retain previously loaded records with a warning and Retry delivery records; an invalid cursor offers Refresh delivery records to reload the newest page. Changing the latest `messageId` refreshes the newest page without switching tabs. Responses superseded by a refresh or received after changing tasks, leaving Records, or closing details do not replace the current view. -The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, and the header popover — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N ended tasks.` with the count the Host answered, `No ended tasks to clear.` when the ended rows left between the confirmation and the request, and `Could not clear the ended tasks.` after a failure that leaves the remaining ended rows in place to retry. Confirming a deletion closes the detail or the tab it was confirmed from, and each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. +The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N ended tasks.` with the count the Host answered, `No ended tasks to clear.` when the Host reports that it removed none, and `Could not clear the ended tasks.` after a failure that leaves the remaining ended rows in place to retry. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. An open Session renders an icon-only clock button among its header's right-aligned utilities, immediately left of the overflow menu, only once it has something to open: the first read mounts nothing, a read that returns no active reminder leaves the header without the button, a refresh keeps the last known reminders visible, and a failed read keeps the button with its retry. That rule prevents the clock from appearing and disappearing around the first answer. Its accessible name states the reminder count when the read is ready and the generic reminder label otherwise. With exactly one active reminder, the button opens that task's detail in the right Sidebar directly. With any other count it opens a popover that lists overdue reminders first, then future reminders by target time, with stored title, frequency, browser-local target time, relative time, and a Delete button; the title is a button that opens that task's detail in the right Sidebar. The task tab names the shown task's stored title on its chip and carries the same rule, delivery records, name, instruction, and run-time editing, confirmed deletion, and original-Session link as the Automation tasks page; it never activates a Session. Daily frequency text retains the saved local time and zone, and every next-target instant is shown as a device-zone clock with its relative duration in parentheses; the rule's own zone appears only in that frequency text. Escape closes the popover and returns focus to the button; an outside pointer press dismisses it. Deleting the final reminder removes the button, and an already open popover stays expandable until then. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 7e5aa98da1..f8e1f1abf9 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在未运行任务后,“新建”左侧出现批量清理操作,经确认后移除全部未运行任务,确认提示说明 Host 将移除多少个。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在未运行任务后,“新建”左侧出现「清理已结束」操作,经确认后移除全部未运行任务,确认提示说明目录当前包含多少个未运行任务。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 页头按标题的自然宽度排布;标题和操作无法并排容纳时,将操作移到下一行。每个操作文案保持单行。 @@ -41,7 +41,7 @@ kind: "package-reference" 历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。 -自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;确认到请求之间未运行行已消失时显示「没有可清理的已结束任务」;失败时显示「无法清理已结束任务」,并保留尚未移除的未运行行以便重试。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 +自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理已结束任务」,并保留尚未移除的未运行行以便重试。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 diff --git a/packages/client/ui-schedule/src/client/DeleteToast.tsx b/packages/client/ui-schedule/src/client/DeleteToast.tsx index 556f5a02bf..064809e524 100644 --- a/packages/client/ui-schedule/src/client/DeleteToast.tsx +++ b/packages/client/ui-schedule/src/client/DeleteToast.tsx @@ -102,7 +102,9 @@ export function ScheduleDeleteToast({ useToast, dismiss, t }: ScheduleDeleteToas // ended rows can leave between the confirmation and the request. return diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index c76a57bc51..a36fc1ee32 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -87,9 +87,8 @@ white-space: nowrap; } -/* The Plugins toolbar's steps: 32px capsules with the 13px label. The New - action and the bulk cleanup share the geometry, so the pair reads as one - toolbar. */ +/* The Plugins toolbar's steps: New and the bulk cleanup share one 32px + capsule with a 13px label. */ .headerAction { height: 32px; font-size: 13px; diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 841b559cf8..afb83b2c18 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -261,7 +261,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) { } > -

{t('clear.count', { count: endedCount })}

+

{t(endedCount === 1 ? 'clear.count.one' : 'clear.count.other', { count: endedCount })}

) diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index 056f8a3cbb..cd17bb22a6 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -136,15 +136,17 @@ export const en = { 'delete.pending': 'Deleting…', 'clear.action': 'Clear ended', 'clear.title': 'Clear ended automation tasks?', - 'clear.description': 'These tasks stop triggering and are deleted together with their saved delivery records. ' + 'clear.description': 'Each task stops triggering and is deleted together with its saved delivery records. ' + 'The original sessions and their messages remain.', - 'clear.count': '{count} ended tasks', + 'clear.count.one': '{count} ended task', + 'clear.count.other': '{count} ended tasks', 'clear.confirm': 'Clear tasks', 'clear.pending': 'Clearing…', 'clear.close': 'Close clear confirmation', 'toast.deleted': 'Task deleted.', 'toast.deleteFailed': 'Could not delete the task.', - 'toast.cleared': 'Cleared {count} ended tasks.', + 'toast.cleared.one': 'Cleared {count} ended task.', + 'toast.cleared.other': 'Cleared {count} ended tasks.', 'toast.clearedNone': 'No ended tasks to clear.', 'toast.clearFailed': 'Could not clear the ended tasks.', 'card.open': 'Open', @@ -289,14 +291,16 @@ export const zh: Record = { 'delete.pending': '正在删除…', 'clear.action': '清理已结束', 'clear.title': '清理已结束的自动化任务?', - 'clear.description': '这些任务将停止触发,并连同其保存的运行记录一并删除。原会话及其消息仍然保留', - 'clear.count': '共 {count} 个已结束任务', + 'clear.description': '每个任务将停止触发,并连同其保存的运行记录一并删除。原会话及其消息仍然保留', + 'clear.count.one': '共 {count} 个已结束任务', + 'clear.count.other': '共 {count} 个已结束任务', 'clear.confirm': '确认清理', 'clear.pending': '正在清理…', 'clear.close': '关闭清理确认', 'toast.deleted': '任务已删除', 'toast.deleteFailed': '无法删除任务', - 'toast.cleared': '已清理 {count} 个已结束任务', + 'toast.cleared.one': '已清理 {count} 个已结束任务', + 'toast.cleared.other': '已清理 {count} 个已结束任务', 'toast.clearedNone': '没有可清理的已结束任务', 'toast.clearFailed': '无法清理已结束任务', 'card.open': '打开', diff --git a/packages/client/ui-schedule/tests/clear-ended.client.spec.ts b/packages/client/ui-schedule/tests/clear-ended.client.spec.ts index d453deef50..ef943688b0 100644 --- a/packages/client/ui-schedule/tests/clear-ended.client.spec.ts +++ b/packages/client/ui-schedule/tests/clear-ended.client.spec.ts @@ -27,15 +27,26 @@ function setup( } describe('createClearEnded', () => { - it('reads the catalog back after an acknowledged removal and reports how many rows went', async () => { - const { clearEnded, removeInactive, refresh } = setup({ ok: true, value: { deleted: 3 } }) + it('reads the catalog back before the outcome settles and reports how many rows went', async () => { + const readback = Promise.withResolvers() + const { clearEnded, removeInactive, refresh } = setup( + { ok: true, value: { deleted: 3 } }, + vi.fn(() => readback.promise), + ) + let settled = false + const outcome = clearEnded().then((value) => { + settled = true + return value + }) - await expect(clearEnded()).resolves.toEqual({ cleared: true, count: 3 }) + await vi.waitFor(() => { expect(refresh).toHaveBeenCalledOnce() }) + // The list must agree with the count before the notice states it, so the + // outcome waits for the readback rather than publishing alongside it. + expect(settled).toBe(false) + readback.resolve(undefined) + await expect(outcome).resolves.toEqual({ cleared: true, count: 3 }) expect(removeInactive).toHaveBeenCalledOnce() - expect(refresh).toHaveBeenCalledOnce() - // The readback is what makes the list agree with the count, so it settles - // before the outcome is published. expect(removeInactive.mock.invocationCallOrder[0]) .toBeLessThan(refresh.mock.invocationCallOrder[0]!) }) diff --git a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx index aadce87d0c..b7923087ad 100644 --- a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx +++ b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx @@ -80,12 +80,15 @@ describe('ScheduleDeleteToast', () => { expect(screen.getByRole('alert').textContent).toBe(zh['toast.deleteFailed']) }) - it('states how many ended tasks a bulk removal cleared', () => { + it('states how many ended tasks a bulk removal cleared, singular for one', () => { vi.useFakeTimers() - const { dismiss } = mount({ kind: 'cleared', count: 4, seq: 3 }) - expect(screen.getByRole('alert').textContent).toBe(en['toast.cleared'].replace('{count}', '4')) + const many = mount({ kind: 'cleared', count: 4, seq: 3 }) + expect(screen.getByRole('alert').textContent).toBe(en['toast.cleared.other'].replace('{count}', '4')) act(() => { vi.runAllTimers() }) - expect(dismiss).toHaveBeenCalledOnce() + expect(many.dismiss).toHaveBeenCalledOnce() + cleanup() + mount({ kind: 'cleared', count: 1, seq: 4 }) + expect(screen.getByRole('alert').textContent).toBe(en['toast.cleared.one'].replace('{count}', '1')) }) it('states that a bulk removal found nothing instead of a zero count', () => { diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index dad39c52ba..816e297421 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -979,7 +979,7 @@ describe('clearing ended tasks', () => { fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) expect(within(dialog).getByText(en['clear.description'])).toBeDefined() - expect(within(dialog).getByText(en['clear.count'].replace('{count}', '2'))).toBeDefined() + expect(within(dialog).getByText(en['clear.count.other'].replace('{count}', '2'))).toBeDefined() }) it('removes nothing when the confirmation is dismissed', () => { @@ -1030,7 +1030,7 @@ describe('clearing ended tasks', () => { mount({ records: [at, endedTask] }, zh) fireEvent.click(screen.getByRole('button', { name: zh['clear.action'] })) const dialog = screen.getByRole('dialog', { name: zh['clear.title'] }) - expect(within(dialog).getByText(zh['clear.count'].replace('{count}', '1'))).toBeDefined() + expect(within(dialog).getByText(zh['clear.count.one'].replace('{count}', '1'))).toBeDefined() expect(within(dialog).getByRole('button', { name: zh['clear.confirm'] })).toBeDefined() }) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c14c966f8d..8235ff2645 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2202,7 +2202,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'deleteInactive\') async deleteInactive(): Promise', - description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a create ordered behind this call is not removed by it. Each removed row is durable on its own: a failure partway through leaves the earlier removals committed and announced, then rejects rather than reporting a partial count as the whole.', + description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. Each removed row is durable on its own: a failure partway through leaves the earlier removals committed and announced, then rejects rather than reporting a partial count as the whole.', parameters: [], returns: 'Count of removed rows; zero when the Host held no ended task.', }, diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 844af460f3..1735f5eab2 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -371,10 +371,10 @@ export class ScheduleService extends TypertRemoteService { * * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO - * slot, so a create ordered behind this call is not removed by it. Each removed row - * is durable on its own: a failure partway through leaves the earlier removals - * committed and announced, then rejects rather than reporting a partial count as - * the whole. + * slot, so a delivery ordered behind this call ends its task after the + * enumeration rather than during it. Each removed row is durable on its own: a + * failure partway through leaves the earlier removals committed and announced, + * then rejects rather than reporting a partial count as the whole. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts index 6080e57d77..a0913f4608 100644 --- a/packages/schedule/schedule/tests/delete-inactive.spec.ts +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -102,11 +102,17 @@ it('keeps the removals that landed, announces them, and reports the failure', as const pool = new MemoryMediaPool() let deletes = 0 // The first ended row lands durably; the second row's physical delete rejects. + // Iteration order is a snapshot detail, so the case records which row failed + // instead of assuming which of the two that is. + let failedId: string | undefined const backend = gatedScheduleBackend(pool, { - deleteRecord: async (table) => { + deleteRecord: async (table, key) => { if (table !== 'tasks') return deletes += 1 - if (deletes === 2) throw new Error('injected delete failure') + if (deletes === 2) { + failedId = key + throw new Error('injected delete failure') + } }, }) const test = await harness({ @@ -130,7 +136,9 @@ it('keeps the removals that landed, announces them, and reports the failure', as expect(String(failure)).toContain('injected delete failure') // The row removed before the failure is a durable change observers must hear. expect(changed).toHaveBeenCalledTimes(1) - expect((await service.catalog()).map(entry => entry.title)).toEqual(['Partial second']) + expect(failedId).toBeDefined() + // The survivor is the row whose delete failed; the other row stayed removed. + expect((await service.catalog()).map(entry => entry.id)).toEqual([failedId]) }) it('attempts every row and reports a rejection reason that carries no Error', async () => { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index a7c0978c25..9a3b9d58a9 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -302,6 +302,11 @@ "symbol": "LegacyScheduleRecord", "source": "packages/schedule/schedule/src/types.ts" }, + { + "doc": "docs/subsystems/schedule.md", + "symbol": "ScheduleDeleteInactiveResult", + "source": "packages/schedule/schedule/src/types.ts" + }, { "doc": "docs/subsystems/schedule.md", "symbol": "LocalAtInput", From facfab5ce886a469ef1eddf5460026753573d61d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 18:48:46 +0800 Subject: [PATCH 03/34] fix(schedule): return focus when a cleanup removes its own trigger --- apps/web/tests/schedule-after.e2e.ts | 9 +++-- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 +- packages/client/ui-schedule/README.zh.md | 2 +- .../ui-schedule/src/client/DeleteToast.tsx | 21 ++++++----- .../src/client/TaskManagerPage.tsx | 28 ++++++++++++-- .../ui-schedule/src/client/clear-ended.ts | 4 +- .../src/client/task-manager-locales.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 37 ++++++++++++++++++- .../tests/task-manager-page.client.spec.tsx | 31 ++++++++++++++++ packages/schedule/schedule/src/index.ts | 3 +- 11 files changed, 117 insertions(+), 28 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 6b4bb9aa9a..4c4824a544 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1328,10 +1328,11 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(tripwire.pageErrors).toEqual([]) } finally { // The shared page and Host outlive a failed assertion: remove every row - // this scenario introduced so the next one starts from the same state. - for (const id of [kept.id, ...endedIds]) { - await scaffold.ctx.schedule.delete({ sessionId: endedSession, id }) - } + // this scenario introduced so the next one starts from the same state. A + // teardown rejection must not mask the assertion that failed first. + await Promise.allSettled( + [kept.id, ...endedIds].map(id => scaffold.ctx.schedule.delete({ sessionId: endedSession, id })), + ) } }, 60_000) }) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index bb1cd53097..543632f1be 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: 954dc495cfcce34d - zh: 9d861537037ff82c + en: c1556a0d3a5ea43b + zh: 3553ae81a0ef7410 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 1c6770f9e1..4edebb05ef 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -41,7 +41,7 @@ After the last saved page loads, a nonempty history with confirmed pruning shows A successful empty history page is distinct from loading, query failure, a missing task, or an invalid cursor. Failed requests retain previously loaded records with a warning and Retry delivery records; an invalid cursor offers Refresh delivery records to reload the newest page. Changing the latest `messageId` refreshes the newest page without switching tabs. Responses superseded by a refresh or received after changing tasks, leaving Records, or closing details do not replace the current view. -The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N ended tasks.` with the count the Host answered, `No ended tasks to clear.` when the Host reports that it removed none, and `Could not clear the ended tasks.` after a failure that leaves the remaining ended rows in place to retry. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. +The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N ended tasks.` with the count the Host answered, `No ended tasks to clear.` when the Host reports that it removed none, and `Could not clear every ended task.` after a failure that leaves the remaining ended rows in place to retry. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. An open Session renders an icon-only clock button among its header's right-aligned utilities, immediately left of the overflow menu, only once it has something to open: the first read mounts nothing, a read that returns no active reminder leaves the header without the button, a refresh keeps the last known reminders visible, and a failed read keeps the button with its retry. That rule prevents the clock from appearing and disappearing around the first answer. Its accessible name states the reminder count when the read is ready and the generic reminder label otherwise. With exactly one active reminder, the button opens that task's detail in the right Sidebar directly. With any other count it opens a popover that lists overdue reminders first, then future reminders by target time, with stored title, frequency, browser-local target time, relative time, and a Delete button; the title is a button that opens that task's detail in the right Sidebar. The task tab names the shown task's stored title on its chip and carries the same rule, delivery records, name, instruction, and run-time editing, confirmed deletion, and original-Session link as the Automation tasks page; it never activates a Session. Daily frequency text retains the saved local time and zone, and every next-target instant is shown as a device-zone clock with its relative duration in parentheses; the rule's own zone appears only in that frequency text. Escape closes the popover and returns focus to the button; an outside pointer press dismisses it. Deleting the final reminder removes the button, and an already open popover stays expandable until then. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index f8e1f1abf9..28e1dff63f 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。 -自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理已结束任务」,并保留尚未移除的未运行行以便重试。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 +自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理全部已结束任务」,并保留尚未移除的未运行行以便重试。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 diff --git a/packages/client/ui-schedule/src/client/DeleteToast.tsx b/packages/client/ui-schedule/src/client/DeleteToast.tsx index 064809e524..751b44c7a0 100644 --- a/packages/client/ui-schedule/src/client/DeleteToast.tsx +++ b/packages/client/ui-schedule/src/client/DeleteToast.tsx @@ -98,16 +98,17 @@ export function ScheduleDeleteToast({ useToast, dismiss, t }: ScheduleDeleteToas case 'deleteFailed': return } onDone={dismiss} /> case 'cleared': - // A removal that found nothing states that instead of a zero count: the - // ended rows can leave between the confirmation and the request. - return + // A removal that found nothing states that instead of a zero count, and + // without the success tone: the ended rows can leave between the + // confirmation and the request. + return toast.count === 0 + ? + : case 'clearFailed': return } onDone={dismiss} /> /* v8 ignore next -- the store publishes only the union's members, so the default holds no reachable statement. */ diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index afb83b2c18..893a2ecac1 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -66,6 +66,10 @@ export function TaskManagerPage(props: TaskManagerPageProps) { const { record: catalogRecord, task: selected, id: detailId, confirmId, setConfirmId, setTab } = detail const rowRef = useRef(null) const headingRef = useRef(null) + const clearTriggerRef = useRef(null) + // Set when the confirmation opens: its settled removal can hide the trigger + // before the Modal returns focus, and so can another client's cleanup. + const clearFocusPending = useRef(false) const confirming = records.find(record => record.id === confirmId) // The bulk action's target and count come from the complete catalog, not from // the filtered rows: it removes every ended task, whatever the view shows. @@ -115,6 +119,16 @@ export function TaskManagerPage(props: TaskManagerPageProps) { setSelectedId(null) } + // The Modal returns focus to the control that opened it only while that + // control still exists; a cleanup that removes every ended row unmounts the + // trigger before the confirmation closes, so the page heading takes the focus. + useEffect(() => { + if (clearConfirm || !clearFocusPending.current) return + clearFocusPending.current = false + if (clearTriggerRef.current?.isConnected === true) return + headingRef.current?.focus() + }, [clearConfirm]) + const confirmClearEnded = (): void => { setClearing(true) void onClearEnded().finally(() => { @@ -129,9 +143,11 @@ export function TaskManagerPage(props: TaskManagerPageProps) { aria-label={t('title')} data-testid="task-manager-page" onKeyDown={(event) => { - // Escape belongs to the surface it closed: the Menu primitive consumes it - // during the document capture phase, and the Modal layer consumes it while - // a confirmation is open. This handler must not also close the detail. + // An Escape that closed a dropdown is already consumed by the Menu's + // document capture listener. A confirmation's own Escape reaches the + // Modal's document bubble listener, which runs after this React handler, + // so the `clearConfirm` guard is what keeps this page from also closing + // the detail while the dialog closes. if (event.key !== 'Escape' || event.defaultPrevented || confirmId !== null || clearConfirm || selectedId === null) return event.preventDefault() @@ -146,10 +162,14 @@ export function TaskManagerPage(props: TaskManagerPageProps) {

{t('title')}

{endedCount > 0 && }
diff --git a/packages/client/ui-schedule/src/client/clear-ended.ts b/packages/client/ui-schedule/src/client/clear-ended.ts index 99a42f7ad2..007d2b18d0 100644 --- a/packages/client/ui-schedule/src/client/clear-ended.ts +++ b/packages/client/ui-schedule/src/client/clear-ended.ts @@ -24,8 +24,8 @@ export interface ClearEndedDependencies { * * The call names no task ids: the Host owns which rows are ended, and a * delivery can have moved the catalog between the confirmation and the - * request. The readback follows the acknowledged removal, so the list the page - * renders next agrees with the count the notice states. + * request. The readback follows the acknowledged removal and supersedes a read + * issued before it, so the list follows the removal rather than a stale read. * @param deps - Remote removal and its catalog readback. * @returns a callback that resolves once the settled outcome is available. */ diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index cd17bb22a6..0ae01e8ea6 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -148,7 +148,7 @@ export const en = { 'toast.cleared.one': 'Cleared {count} ended task.', 'toast.cleared.other': 'Cleared {count} ended tasks.', 'toast.clearedNone': 'No ended tasks to clear.', - 'toast.clearFailed': 'Could not clear the ended tasks.', + 'toast.clearFailed': 'Could not clear every ended task.', 'card.open': 'Open', 'card.openLabel': 'Open task details: {title}', 'card.deleted': 'Deleted', @@ -302,7 +302,7 @@ export const zh: Record = { 'toast.cleared.one': '已清理 {count} 个已结束任务', 'toast.cleared.other': '已清理 {count} 个已结束任务', 'toast.clearedNone': '没有可清理的已结束任务', - 'toast.clearFailed': '无法清理已结束任务', + 'toast.clearFailed': '无法清理全部已结束任务', 'card.open': '打开', 'card.openLabel': '打开任务详情:{title}', 'card.deleted': '已删除', diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index 75b8f0c32d..6719db65a8 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -11,7 +11,9 @@ import { apply as applyNode } from '../src/index.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' -import type { ScheduleId, ScheduleCatalogEntry, ScheduleUpdateRequest, ScheduleUpdateResult } from '@deepseek-ai/dsh-schedule/client' +import type { + ScheduleId, ScheduleCatalogEntry, ScheduleDeleteInactiveResult, ScheduleUpdateRequest, ScheduleUpdateResult, +} from '@deepseek-ai/dsh-schedule/client' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { CatalogInjected } from '../src/client/catalog-source.ts' @@ -519,6 +521,39 @@ describe('ui-schedule browser half', () => { } }) + it('reports a settled bulk removal after the page registration is gone', async () => { + const catalog = vi.fn().mockResolvedValue({ ok: true, value: [] }) + const removal = Promise.withResolvers<{ ok: true; value: ScheduleDeleteInactiveResult }>() + const deleteInactive = vi.fn(() => removal.promise) + const ctx = await baseContext({ schedule: { catalog, deleteInactive }, $on: () => () => {} }) + const owner = ctx.slots.register({ + name: 'root', + children: { + main: { kind: 'keyed', scope: 'root' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, + }, + } as never, Empty) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + let claimed = false + try { + const face = injectedFace(ctx.slots.entries('main')[0]!, undefined as never) as TaskManagerInjected + const toast = injectedFace(ctx.slots.entries('shell.overlay')[0]!) as Omit + const clearing = face.onClearEnded() + // The page panel can unmount while the Host removal is still in flight; + // the notice store belongs to the plugin, so the outcome still publishes. + owner() + claimed = true + removal.resolve({ ok: true, value: { deleted: 2 } }) + await clearing + expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'cleared', count: 2, seq: 1 }) + } finally { + if (!claimed) owner() + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + it('registers the row mark and hover-card seats under the Session row declarations', async () => { const catalog = vi.fn().mockResolvedValue({ ok: true, value: [] }) const ctx = await baseContext({ schedule: { catalog }, $on: () => () => {} }) diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index 816e297421..a8dd653c9d 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -1015,6 +1015,37 @@ describe('clearing ended tasks', () => { expect(document.activeElement).toBe(trigger) }) + it('falls back to the page heading when the settled cleanup hides its trigger', async () => { + const pending = Promise.withResolvers() + const onClearEnded = vi.fn(() => pending.promise) + const h = mount({ records: [at, endedTask] }, en, { onClearEnded }) + const trigger = screen.getByRole('button', { name: en['clear.action'] }) + trigger.focus() + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('button', { name: en['clear.confirm'] })) + // The acknowledged removal's readback drops the ended rows, and with them the + // trigger the Modal captured when it opened. + act(() => { h.update({ records: [at] }) }) + expect(screen.queryByRole('button', { name: en['clear.action'] })).toBeNull() + await act(async () => { pending.resolve(undefined) }) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) + }) + + it('falls back to the page heading when another cleanup removes the trigger', () => { + const h = mount({ records: [at, endedTask] }) + const trigger = screen.getByRole('button', { name: en['clear.action'] }) + trigger.focus() + fireEvent.click(trigger) + // Another client's cleanup removes the ended rows while this confirmation + // is open, so the control the Modal captured is unmounted too. + act(() => { h.update({ records: [at] }) }) + expect(screen.queryByRole('button', { name: en['clear.action'] })).toBeNull() + fireEvent.keyDown(screen.getByRole('dialog', { name: en['clear.title'] }), { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) + }) + it('closes only the confirmation on Escape while a task detail is open', () => { mount({ records: [at, endedTask] }) fireEvent.click(screen.getByRole('button', { name: 'Review release' })) diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 1735f5eab2..52cdcac68c 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -388,7 +388,8 @@ export class ScheduleService extends TypertRemoteService { let failure: Error | undefined for (const id of ended) { try { - // A row another queued write already removed is not this call's removal. + // Only a row this call removed is counted: the domain answers false + // when the row is already absent. if (await tasks.delete(id)) deleted += 1 } catch (error: unknown) { // Every row is attempted, so one failed delete does not leave the rest From 734812cdaddda2cb9b6ecc7eb3240b96eae2122c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 19:11:42 +0800 Subject: [PATCH 04/34] fix(schedule): align the clear copy with the inactive status and harden focus --- apps/web/tests/schedule-after.e2e.ts | 10 +-- packages/client/ui-schedule/README.i18n.yaml | 8 +- packages/client/ui-schedule/README.md | 4 +- packages/client/ui-schedule/README.zh.md | 14 ++-- .../src/client/TaskManagerPage.tsx | 20 +++-- .../src/client/task-manager-locales.ts | 20 ++--- .../tests/delete-toast.client.spec.tsx | 2 +- .../tests/task-manager-page.client.spec.tsx | 28 ++++++- packages/schedule/schedule/src/index.ts | 79 +++++++++---------- 9 files changed, 107 insertions(+), 78 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 4c4824a544..8ac84949c9 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1296,14 +1296,14 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { // shared page; this scenario asserts the complete list. await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('') await manager.getByRole('button', { name: 'All', exact: true }).click() - const clear = manager.getByRole('button', { name: 'Clear ended', exact: true }) + const clear = manager.getByRole('button', { name: 'Clear inactive', exact: true }) await clear.waitFor({ timeout: 15_000 }) // The confirmation states the Host-side target and removes nothing until it // is confirmed. await clear.click() - const confirmation = page.getByRole('dialog', { name: 'Clear ended automation tasks?' }) - await confirmation.getByText('2 ended tasks', { exact: true }).waitFor() + const confirmation = page.getByRole('dialog', { name: 'Clear inactive automation tasks?' }) + await confirmation.getByText('2 inactive tasks', { exact: true }).waitFor() await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) @@ -1316,10 +1316,10 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { const remaining = await scaffold.ctx.schedule.catalog() expect(remaining.some(task => endedIds.includes(task.id))).toBe(false) expect(remaining.map(task => task.title)).toContain('Keep this running task') - await page.getByText('Cleared 2 ended tasks.', { exact: true }).waitFor({ timeout: 15_000 }) + await page.getByText('Cleared 2 inactive tasks.', { exact: true }).waitFor({ timeout: 15_000 }) // The page follows the acknowledged removal: no ended row or cleanup action // remains, and the running task is still listed. - await expect.poll(() => manager.getByRole('button', { name: 'Clear ended', exact: true }).count(), { timeout: 15_000 }) + await expect.poll(() => manager.getByRole('button', { name: 'Clear inactive', exact: true }).count(), { timeout: 15_000 }) .toBe(0) await manager.getByRole('button', { name: 'All', exact: true }).click() await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' }) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 543632f1be..ca6c1b029b 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -10,19 +10,19 @@ zh: eb3e5d5a802da592 /deepseek-ai-dsh-client-ui-schedule/summary: en: 8dfc371c14bb4415 - zh: 9b3545c1a7468a7a + zh: 5e2fa588983524ed /deepseek-ai-dsh-client-ui-schedule/table-of-contents: en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: c1556a0d3a5ea43b - zh: 3553ae81a0ef7410 + en: 79522bdbf2a9aa32 + zh: 1a3f20fda3531151 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 6bd6882dbbbefcb0 zh: 03aa15f1a65d13dc /deepseek-ai-dsh-client-ui-schedule/understand-the-implementation: en: 959145f9a08c3913 - zh: caae642a82d2acd3 + zh: 7c0afb742790c474 /deepseek-ai-dsh-client-ui-schedule/further-exploration: en: 06e265120b05dbf5 zh: 08ab1ef979b8ea40 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 4edebb05ef..fc9242eeb5 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,7 +25,7 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package -The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Once the catalog holds an ended (inactive) task, a Clear ended action sits left of New and removes every ended task after a confirmation that states how many ended tasks the catalog currently holds. The page's task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Once the catalog holds an inactive task, a Clear inactive action sits left of New and removes every inactive task after a confirmation that states how many inactive tasks the catalog currently holds. The page's task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. The page header keeps the title at its natural width and moves the actions to another line when the title and the actions no longer fit together. Each action label stays on one line. @@ -41,7 +41,7 @@ After the last saved page loads, a nonempty history with confirmed pruning shows A successful empty history page is distinct from loading, query failure, a missing task, or an invalid cursor. Failed requests retain previously loaded records with a warning and Retry delivery records; an invalid cursor offers Refresh delivery records to reload the newest page. Changing the latest `messageId` refreshes the newest page without switching tabs. Responses superseded by a refresh or received after changing tasks, leaving Records, or closing details do not replace the current view. -The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N ended tasks.` with the count the Host answered, `No ended tasks to clear.` when the Host reports that it removed none, and `Could not clear every ended task.` after a failure that leaves the remaining ended rows in place to retry. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. +The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N inactive tasks.` with the count the Host answered, `No inactive tasks to clear.` when the Host reports that it removed none, and `Could not clear every inactive task.` after a failure that leaves the remaining inactive rows in place to retry. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. An open Session renders an icon-only clock button among its header's right-aligned utilities, immediately left of the overflow menu, only once it has something to open: the first read mounts nothing, a read that returns no active reminder leaves the header without the button, a refresh keeps the last known reminders visible, and a failed read keeps the button with its retry. That rule prevents the clock from appearing and disappearing around the first answer. Its accessible name states the reminder count when the read is ready and the generic reminder label otherwise. With exactly one active reminder, the button opens that task's detail in the right Sidebar directly. With any other count it opens a popover that lists overdue reminders first, then future reminders by target time, with stored title, frequency, browser-local target time, relative time, and a Delete button; the title is a button that opens that task's detail in the right Sidebar. The task tab names the shown task's stored title on its chip and carries the same rule, delivery records, name, instruction, and run-time editing, confirmed deletion, and original-Session link as the Automation tasks page; it never activates a Session. Daily frequency text retains the saved local time and zone, and every next-target instant is shown as a device-zone clock with its relative duration in parentheses; the rule's own zone appears only in that frequency text. Escape closes the popover and returns focus to the button; an outside pointer press dismisses it. Deleting the final reminder removes the button, and an already open popover stays expandable until then. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 28e1dff63f..2358b75de4 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在「自动化任务」页面和右侧栏的任务页签中管理各个会话的活动和未运行定时任务:搜索、筛选、修改名称、指令和运行时间、浏览任务运行记录、删除任务,或打开原会话。每个界面都用任务已存储的标题命名任务。已打开会话在存在活动提醒时,页头显示仅图标的提醒时钟,点击在右侧栏打开该任务详情;`schedule_create` 调用把创建的任务渲染为会话卡片;侧栏中带活动任务的未归档空闲会话行显示时钟标记,其悬停卡片列出这些任务。 +在「自动化任务」页面和右侧栏的任务页签中管理各个会话的活动和已结束定时任务:搜索、筛选、修改名称、指令和运行时间、浏览任务运行记录、删除任务,或打开原会话。每个界面都用任务已存储的标题命名任务。已打开会话在存在活动提醒时,页头显示仅图标的提醒时钟,点击在右侧栏打开该任务详情;`schedule_create` 调用把创建的任务渲染为会话卡片;侧栏中带活动任务的未归档空闲会话行显示时钟标记,其悬停卡片列出这些任务。 ## 目录 @@ -25,15 +25,15 @@ kind: "package-reference" ## 使用本包 -随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在未运行任务后,“新建”左侧出现「清理已结束」操作,经确认后移除全部未运行任务,确认提示说明目录当前包含多少个未运行任务。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在已结束任务后,“新建”左侧出现「清理已结束」操作,经确认后移除全部已结束任务,确认提示说明目录当前包含多少个已结束任务。列表支持文本搜索和一行“全部/已开启/已结束”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或已结束任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 页头按标题的自然宽度排布;标题和操作无法并排容纳时,将操作移到下一行。每个操作文案保持单行。 -从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 +从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,已结束任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 “规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron)的菜单,每周规则增加“星期”行,固定速率选项只修改它所属单位的间隔数量,一次性规则用独立的“日期”和“时间”两行编辑目标,自定义(cron)用一行“Cron 表达式”编辑按所选时区解释的 5 字段表达式。卡片在浏览器中解析该表达式,并在该行下方显示描述它的本地化语句;Host 会拒绝的表达式改为显示本地化校验信息,并在解析通过前阻止保存。已存储的每周规则若星期集合恰好为周一至周五,则选中“周一至周五”选项。“日期”行是只读触发器,以 `YYYY/MM/DD` 显示暂存的日期并展开带本地化星期表头、月份切换和今天标记的月历;草稿与提交文本保持 ISO 形态;“时间”行是只读触发器,显示统一的 24 小时时钟,并展开时、分、秒三列可滚动列表,当前值高亮并在打开时滚动到可见。在月历或时钟上点选即按与其他行相同的分阶段保存写入草稿;方向键在打开的列或网格内移动,Enter 选定当前值,Escape 关闭面板并把焦点还给所在行,被禁用的行无法打开面板。时钟行显示到秒,未改动的行在提交时保留已存储的毫秒值,编辑时间时按整秒精度提交。“时区”使用高度受限的可搜索菜单。顶部无标题区域保留浏览器本地最近选过的 5 个准确时区;首次使用按真实系统时区和 UTC 初始化,而不根据界面语言猜测所在地。系统时区带有标记,其余清单按当前 UTC 偏移、再按规范 IANA 标识排序。可见文案把 `UTC±HH:MM` 放在本地化时区名称之前;IANA 标识仅在内部使用,但可用于搜索。ICU 显示成相同偏移和本地化名称的多个时区只占一行;所有被合并的标识仍可用于搜索,任务已保存的标识也会继续作为选中值,不会被改写。时区清单来自 `Intl.supportedValuesOf('timeZone')`,名称来自 `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`:两者都是浏览器内置的 ICU/CLDR 数据,不是抓取或产品自维护的翻译表。因此没有需要刷新的爬虫或生成时区资源;第三方语言包只需提供常规的 `time.locale` 值,即可自动获得对应的 ICU 名称,仅需翻译选择器周边的 UI 文案。运行时无法枚举时改用简短的标识候选列表,已存储但不在其中的时区会保留。每天、每周和 cron 保留各自已存储的规则与时区,其频率行仅在时区与宿主系统时区不同时标出时区。一次性 After/At 以记录已存储的时区初始化日期和时间;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一瞬时;更改时区会保留输入的钟表值并改变时点。因选择种类而重新初始化的种类使用已提交发生时点的钟表值和该选项的时区,该时区取记录已存储的时区或本设备时区:每周的星期取自该发生时点在该时区下的星期,cron 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 60 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。 -行在保存进行中、目录加载中、删除期间以及任务未运行时禁用。仅当本地草稿与已存储任务不同时才出现保存栏:显示“有未保存的修改”及“取消”和“保存修改”,保存期间“保存修改”变为“保存中…”。“取消”恢复已存储的值。仅当草稿没有未保存修改时,目录刷新才会成为显示值,因此未保存的草稿会保留到权威刷新之后。保存失败保留草稿并显示本地化提示;冲突后针对刷新后的记录重试。任务从目录消失时,“删除”会禁用,直到任务重新出现。更新成功后刷新权威目录。更新被拒绝或无法确认与目录读取失败相互区分:“重试”重新加载目录,不将该更新显示为失败。若更新本身无法确认,应在重试前检查任务。切换任务或关闭详情后才到达的响应不会替换当前视图,任何失败都不会回滚宿主已开始的写入。 +行在保存进行中、目录加载中、删除期间以及任务已结束时禁用。仅当本地草稿与已存储任务不同时才出现保存栏:显示“有未保存的修改”及“取消”和“保存修改”,保存期间“保存修改”变为“保存中…”。“取消”恢复已存储的值。仅当草稿没有未保存修改时,目录刷新才会成为显示值,因此未保存的草稿会保留到权威刷新之后。保存失败保留草稿并显示本地化提示;冲突后针对刷新后的记录重试。任务从目录消失时,“删除”会禁用,直到任务重新出现。更新成功后刷新权威目录。更新被拒绝或无法确认与目录读取失败相互区分:“重试”重新加载目录,不将该更新显示为失败。若更新本身无法确认,应在重试前检查任务。切换任务或关闭详情后才到达的响应不会替换当前视图,任何失败都不会回滚宿主已开始的写入。 打开“任务运行记录”时才请求最新的 20 条已保存记录。“加载更早的记录”按追加顺序从新到旧增加一页,不受实际时间回拨影响。该视图中页签条就是整个页头:名称输入框和“下次运行”行只属于“规则”。每条“已保存的任务运行记录”以时钟图标和任务时区下的本地月份名与时钟字段开头(一次性记录使用浏览器时区),并在存在时显示实际保存的提示文本。超过两行的提示文本只显示前两行并提供“展开”,展开后可“收起”;面板宽度变化时,收起状态的提示文本会重新判断是否超过两行。旧回执没有提示文本时,绝不以当前指令代替。 @@ -41,7 +41,7 @@ kind: "package-reference" 历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。 -自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理全部已结束任务」,并保留尚未移除的未运行行以便重试。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 +自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理全部已结束任务」,并保留尚未移除的已结束行以便重试。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以已结束状态保存。显式删除前,通过全部或已结束筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;已结束状态和该回执均不表示模型执行完成。 已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 @@ -49,7 +49,7 @@ kind: "package-reference" 调用 `schedule_create` 时,创建的任务会以卡片形式渲染在 transcript 中,包含时钟图标、已存储的标题、频率,以及打开该任务右侧栏页签的“打开”按钮。卡片先按该调用持久化的结果 JSON 收窄出任务,并从该结果读取 `title`;当一次在卡片出现之后成功结束的目录读取解析到该任务时,卡片改为跟随目录显示当前名称与频率,若该读取中不含该记录,则以“已删除”代替频率显示。在已存储字段存在之前录制的结果回退为指令首行。仍在运行的调用、创建失败,或结果中没有完整任务时,卡片保留该行,并在结果为单个非空文本块时显示原始结果文本,不提供“打开”操作。卡片不增加 Host 字段,也不改变 Session 日志格式,因此早于该卡片录制的 Session 也会渲染它。 -侧栏的 Session 行会标记有定时任务的会话:当某行处于空闲状态、未归档且其会话至少有一个活动任务时,该行显示时钟标记;归档行该格留空,其实时状态只在悬浮卡片中显示。对存在活动任务的会话长按悬停时,无论该行是否空闲,都会在其已有的悬浮卡片内增加自动化区段,最多列出两个任务(图标、已存储的标题、频率与下次运行文本),还有更多任务被隐藏时显示遗漏行。处于更高优先级状态的行——有待处理交互、活动仍在进行,或有未查看的完成提醒——保留自身状态点且不渲染标记,两者不会同时出现。该标记和悬停区段读取共享的活动与未运行 Host 任务目录,并从中筛选本会话的活动任务;它们不会激活、保留或取消归档任何会话,也不读取 Session 日志。 +侧栏的 Session 行会标记有定时任务的会话:当某行处于空闲状态、未归档且其会话至少有一个活动任务时,该行显示时钟标记;归档行该格留空,其实时状态只在悬浮卡片中显示。对存在活动任务的会话长按悬停时,无论该行是否空闲,都会在其已有的悬浮卡片内增加自动化区段,最多列出两个任务(图标、已存储的标题、频率与下次运行文本),还有更多任务被隐藏时显示遗漏行。处于更高优先级状态的行——有待处理交互、活动仍在进行,或有未查看的完成提醒——保留自身状态点且不渲染标记,两者不会同时出现。该标记和悬停区段读取共享的活动与已结束 Host 任务目录,并从中筛选本会话的活动任务;它们不会激活、保留或取消归档任何会话,也不读取 Session 日志。 ----- @@ -63,7 +63,7 @@ kind: "package-reference"
实现内部细节 — 点击展开 -浏览器入口注册 `schedules` 主面板及对应的侧栏入口、Session 页头右侧功能位中位于 `order: -5` 且紧邻更多操作菜单左侧的 `schedule-catalog` 入口、经由 `ctx.uiConversation.events` 与 ui-chat 的 list seat `conversation.chat.turnTail` 在轮次层级注册的 `schedule_create` transcript 卡片(因此它渲染在该轮收尾的 assistant 文本之后,并在工具调用组折叠时仍然可见)、root 作用域的两个 Session 行 seat `sidebar.session.row.leading` 与 `sidebar.session.row.hover`,以及 kind 为 `scheduleTask` 的右侧栏页类型 `@deepseek-ai/dsh-client-ui-schedule/task`。该类型分两阶段注册:先把其定义注册进 `ctx.sidebarRightTabs`,再按该定义的 id 把正文与 chip 注册进带键 seat `sidebar.right.pane.tab` 与 `sidebar.right.pane.tab.title`。从 Session 入口打开任务时调用 `ctx.sidebarRight.openTab`,传入该入口的 Session id 与所选任务 id,详情因此出现在该入口所属 Session 的右侧栏中;自动化任务页面保留自己的列表并共用同一个详情组件。全局页面、任务页签、行标记与行悬停区段都从同一个 `schedule.catalog` 源读取活动与未运行任务;页头入口保留自己的按 Session `schedule.list` 读取,只返回该 Session 的活动任务。每个面都使用同一套可观察的查询生命周期,并在 `schedule/changed` 或连接重置后刷新。transcript 卡片从该调用持久化的结果 JSON 收窄出创建的任务,而不是依赖 Host 追加字段;该调用自身的工具调用组单元格由 ui-tool 的通用键控视图渲染,且卡片只对产生完整任务的调用出现。该对齐是因果判定而非时钟比较:目录快照带有 `readRequest`(最新被请求的读取序号)与 `readSettled`(产出当前记录的读取序号);Turn 尾部挂载时锚定 `readRequest` 并请求一次读取,仅在 `readSettled` 大于该锚点后才用其保留记录判定,且源把一次提交内的请求合并为单次 `list()`,只把该读取共享给尚未观察到它的调用者。比较浏览器时钟与 Host 时钟无法确立这一顺序。共用的删除回调与注入的 `onUpdateTiming` 保留所选记录的原 Session 绑定,而不使用当前打开的对话。详情保留当前任务的本地草稿、用于比较的权威值以及等待中或失败的保存;保存等待中或失败期间以及删除确认后,属主保留所显示的任务,使目录刷新在丢弃该行时既不能关闭已呈现的失败,也不能关闭陈述删除的详情。保存发出一次携带完整预期 `ScheduleRecord` 和分阶段变更的 `schedule.update` 请求;`onUpdateTiming` 刷新目录,而不把回读失败变成修改失败。 +浏览器入口注册 `schedules` 主面板及对应的侧栏入口、Session 页头右侧功能位中位于 `order: -5` 且紧邻更多操作菜单左侧的 `schedule-catalog` 入口、经由 `ctx.uiConversation.events` 与 ui-chat 的 list seat `conversation.chat.turnTail` 在轮次层级注册的 `schedule_create` transcript 卡片(因此它渲染在该轮收尾的 assistant 文本之后,并在工具调用组折叠时仍然可见)、root 作用域的两个 Session 行 seat `sidebar.session.row.leading` 与 `sidebar.session.row.hover`,以及 kind 为 `scheduleTask` 的右侧栏页类型 `@deepseek-ai/dsh-client-ui-schedule/task`。该类型分两阶段注册:先把其定义注册进 `ctx.sidebarRightTabs`,再按该定义的 id 把正文与 chip 注册进带键 seat `sidebar.right.pane.tab` 与 `sidebar.right.pane.tab.title`。从 Session 入口打开任务时调用 `ctx.sidebarRight.openTab`,传入该入口的 Session id 与所选任务 id,详情因此出现在该入口所属 Session 的右侧栏中;自动化任务页面保留自己的列表并共用同一个详情组件。全局页面、任务页签、行标记与行悬停区段都从同一个 `schedule.catalog` 源读取活动与已结束任务;页头入口保留自己的按 Session `schedule.list` 读取,只返回该 Session 的活动任务。每个面都使用同一套可观察的查询生命周期,并在 `schedule/changed` 或连接重置后刷新。transcript 卡片从该调用持久化的结果 JSON 收窄出创建的任务,而不是依赖 Host 追加字段;该调用自身的工具调用组单元格由 ui-tool 的通用键控视图渲染,且卡片只对产生完整任务的调用出现。该对齐是因果判定而非时钟比较:目录快照带有 `readRequest`(最新被请求的读取序号)与 `readSettled`(产出当前记录的读取序号);Turn 尾部挂载时锚定 `readRequest` 并请求一次读取,仅在 `readSettled` 大于该锚点后才用其保留记录判定,且源把一次提交内的请求合并为单次 `list()`,只把该读取共享给尚未观察到它的调用者。比较浏览器时钟与 Host 时钟无法确立这一顺序。共用的删除回调与注入的 `onUpdateTiming` 保留所选记录的原 Session 绑定,而不使用当前打开的对话。详情保留当前任务的本地草稿、用于比较的权威值以及等待中或失败的保存;保存等待中或失败期间以及删除确认后,属主保留所显示的任务,使目录刷新在丢弃该行时既不能关闭已呈现的失败,也不能关闭陈述删除的详情。保存发出一次携带完整预期 `ScheduleRecord` 和分阶段变更的 `schedule.update` 请求;`onUpdateTiming` 刷新目录,而不把回读失败变成修改失败。 刷新恢复出的任务标签没有导航参数,因为 Sidebar 只持久化标签的布局记录,不持久化打开方传入的内容。因此该页面类型自行保存从 Session 加布局记录 id 到该记录最后显示任务的 Session 和 id 的关联:每个 Session 一个 `dsh.schedule.task-tab.v1.` localStorage 键,按 tab id 一条记录,且从不保存任务内容。正文和 chip 只对没有导航参数的记录读取该条目,在标签出现之后成功的一次读取无法解析时将其删除,且不代替关联声称任务缺失。这样的一次读取无法为该标签给出任务标识时,正文陈述该状态,而不呈现永不结束的加载。 diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 893a2ecac1..ccaba8d9d1 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -119,17 +119,21 @@ export function TaskManagerPage(props: TaskManagerPageProps) { setSelectedId(null) } - // The Modal returns focus to the control that opened it only while that - // control still exists; a cleanup that removes every ended row unmounts the - // trigger before the confirmation closes, so the page heading takes the focus. + // The Modal restores focus to the element it captured when it opened; the + // trigger can be removed or rebuilt while the confirmation is open, so the + // page points focus at the current control, or the heading when it is gone. useEffect(() => { if (clearConfirm || !clearFocusPending.current) return clearFocusPending.current = false - if (clearTriggerRef.current?.isConnected === true) return - headingRef.current?.focus() + const trigger = clearTriggerRef.current + const target = trigger?.isConnected === true ? trigger : headingRef.current + target?.focus() }, [clearConfirm]) const confirmClearEnded = (): void => { + // The confirmation button stays focusable while the removal runs, so a + // second press has to be ignored here instead of by `disabled`. + if (clearing) return setClearing(true) void onClearEnded().finally(() => { setClearing(false) @@ -276,7 +280,11 @@ export function TaskManagerPage(props: TaskManagerPageProps) { - } diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index 0ae01e8ea6..eec5c8b1c3 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -134,21 +134,21 @@ export const en = { 'delete.cancel': 'Cancel', 'delete.close': 'Close deletion confirmation', 'delete.pending': 'Deleting…', - 'clear.action': 'Clear ended', - 'clear.title': 'Clear ended automation tasks?', - 'clear.description': 'Each task stops triggering and is deleted together with its saved delivery records. ' + 'clear.action': 'Clear inactive', + 'clear.title': 'Clear inactive automation tasks?', + 'clear.description': 'Each task is deleted together with its saved delivery records. ' + 'The original sessions and their messages remain.', - 'clear.count.one': '{count} ended task', - 'clear.count.other': '{count} ended tasks', + 'clear.count.one': '{count} inactive task', + 'clear.count.other': '{count} inactive tasks', 'clear.confirm': 'Clear tasks', 'clear.pending': 'Clearing…', 'clear.close': 'Close clear confirmation', 'toast.deleted': 'Task deleted.', 'toast.deleteFailed': 'Could not delete the task.', - 'toast.cleared.one': 'Cleared {count} ended task.', - 'toast.cleared.other': 'Cleared {count} ended tasks.', - 'toast.clearedNone': 'No ended tasks to clear.', - 'toast.clearFailed': 'Could not clear every ended task.', + 'toast.cleared.one': 'Cleared {count} inactive task.', + 'toast.cleared.other': 'Cleared {count} inactive tasks.', + 'toast.clearedNone': 'No inactive tasks to clear.', + 'toast.clearFailed': 'Could not clear every inactive task.', 'card.open': 'Open', 'card.openLabel': 'Open task details: {title}', 'card.deleted': 'Deleted', @@ -291,7 +291,7 @@ export const zh: Record = { 'delete.pending': '正在删除…', 'clear.action': '清理已结束', 'clear.title': '清理已结束的自动化任务?', - 'clear.description': '每个任务将停止触发,并连同其保存的运行记录一并删除。原会话及其消息仍然保留', + 'clear.description': '每个任务连同其保存的运行记录一并删除。原会话及其消息仍然保留', 'clear.count.one': '共 {count} 个已结束任务', 'clear.count.other': '共 {count} 个已结束任务', 'clear.confirm': '确认清理', diff --git a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx index b7923087ad..7112670eea 100644 --- a/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx +++ b/packages/client/ui-schedule/tests/delete-toast.client.spec.tsx @@ -80,7 +80,7 @@ describe('ScheduleDeleteToast', () => { expect(screen.getByRole('alert').textContent).toBe(zh['toast.deleteFailed']) }) - it('states how many ended tasks a bulk removal cleared, singular for one', () => { + it('states how many inactive tasks a bulk removal cleared, singular for one', () => { vi.useFakeTimers() const many = mount({ kind: 'cleared', count: 4, seq: 3 }) expect(screen.getByRole('alert').textContent).toBe(en['toast.cleared.other'].replace('{count}', '4')) diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index a8dd653c9d..c30fa5953d 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -943,7 +943,7 @@ describe('Task manager catalog', () => { }) }) -describe('clearing ended tasks', () => { +describe('clearing inactive tasks', () => { // A distinct identity: the module's `ended` case reuses the active row's id. const endedTask: ScheduleCatalogEntry = { ...ended, id: 'task-ended' as ScheduleId, title: 'Ended summary', prompt: 'Ended summary', @@ -999,12 +999,21 @@ describe('clearing ended tasks', () => { trigger.focus() fireEvent.click(trigger) const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) - fireEvent.click(within(dialog).getByRole('button', { name: en['clear.confirm'] })) + const confirm = within(dialog).getByRole('button', { name: en['clear.confirm'] }) + confirm.focus() + fireEvent.click(confirm) expect(onClearEnded).toHaveBeenCalledOnce() const running = screen.getByRole('dialog', { name: en['clear.title'] }) - expect(within(running).getByRole('button', { name: en['clear.pending'] }).hasAttribute('disabled')).toBe(true) + const runningButton = within(running).getByRole('button', { name: en['clear.pending'] }) + // The pressed control keeps the focus and reads as busy; disabling it would + // hand focus to the document body mid-removal. + expect(runningButton.getAttribute('aria-disabled')).toBe('true') + expect(document.activeElement).toBe(runningButton) expect(within(running).getByRole('button', { name: en['delete.cancel'] }).hasAttribute('disabled')).toBe(true) + // A second press while the removal runs must not start another request. + fireEvent.click(runningButton) + expect(onClearEnded).toHaveBeenCalledOnce() // The confirmation is the only place that states the removal still runs, so // the Escape that would close it waits for the outcome. fireEvent.keyDown(running, { key: 'Escape' }) @@ -1046,6 +1055,19 @@ describe('clearing ended tasks', () => { expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) }) + it('focuses the rebuilt trigger when the dialog closes', () => { + const h = mount({ records: [at, endedTask] }) + fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + // Another client clears the ended rows, then a delivery ends one more while + // the confirmation stays open: the trigger unmounts and a new one mounts. + act(() => { h.update({ records: [at] }) }) + act(() => { h.update({ records: [at, anotherEnded] }) }) + const rebuilt = screen.getByRole('button', { name: en['clear.action'] }) + fireEvent.keyDown(screen.getByRole('dialog', { name: en['clear.title'] }), { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(document.activeElement).toBe(rebuilt) + }) + it('closes only the confirmation on Escape while a task detail is open', () => { mount({ records: [at, endedTask] }) fireEvent.click(screen.getByRole('button', { name: 'Review release' })) diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 52cdcac68c..a6b83d3f99 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -384,23 +384,7 @@ export class ScheduleService extends TypertRemoteService { const ended = [...tasks.entries()] .filter(([, task]) => task.status === 'inactive') .map(([, task]) => task.record.id) - let deleted = 0 - let failure: Error | undefined - for (const id of ended) { - try { - // Only a row this call removed is counted: the domain answers false - // when the row is already absent. - if (await tasks.delete(id)) deleted += 1 - } catch (error: unknown) { - // Every row is attempted, so one failed delete does not leave the rest - // of the ended rows behind. A rejection reason that carries no Error is - // wrapped before the first one is kept. - failure ??= error instanceof Error ? error : new Error(`schedule ended-task removal failed: ${String(error)}`) - } - } - if (deleted > 0) this.emitChanged() - if (failure !== undefined) throw failure - return { deleted } + return { deleted: await this.removeRows(ended, 'schedule ended-task removal failed', false) } }) } @@ -500,32 +484,47 @@ export class ScheduleService extends TypertRemoteService { .filter(([, task]) => task.sessionId === sessionId && task.status === 'active') .map(([, task]) => task.record.id) if (active.length === 0) return - // One row per write: the domain table has no batch delete, and each row is - // durable on its own, so a failure partway through leaves the rows already - // removed committed. Observers hear about that change before the failure - // reaches the caller, which still receives it. - let removed = false - let failure: Error | undefined - for (const id of active) { - try { - await tasks.delete(id) - removed = true - } catch (error: unknown) { - // Every row is attempted: the archive has already landed, so a row this - // stop skipped would stay armed in a Session whose runtime then refuses - // its model steps. A rejection reason that carries no Error still has to - // report a failure, so it is wrapped before the first one is kept. - failure ??= error instanceof Error ? error : new Error(`schedule stop failed: ${String(error)}`) - } - } - if (removed) { - this.emitChanged() - this.runtime?.requestDrive() - } - if (failure !== undefined) throw failure + await this.removeRows(active, 'schedule stop failed', true) }) } + /** + * Remove rows one at a time inside the current queue slot. + * + * One row per write: the domain table has no batch delete, and each row is + * durable on its own, so a failure partway through leaves the rows already + * removed committed. Observers hear about that change before the failure + * reaches the caller, which still receives it. Every row is attempted after a + * failure, so one unreadable delete does not leave the rest of the set behind; + * a rejection reason that carries no Error is wrapped before the first one is + * kept. + * @param ids - rows to remove. + * @param label - failure message prefix naming the operation that owned them. + * @param rearm - whether the rows were armed, so the pending timer must be recomputed. + * @returns the number of rows this call removed. + */ + private async removeRows(ids: readonly ScheduleRecord['id'][], label: string, rearm: boolean): Promise { + const tasks = (await this.getDomain()).table('tasks') + let removed = 0 + let failure: Error | undefined + for (const id of ids) { + try { + // Only a row this call removed is counted. The domain answers false when + // the row is absent; that is defensive here because every task write + // shares this queue slot. + if (await tasks.delete(id)) removed += 1 + } catch (error: unknown) { + failure ??= error instanceof Error ? error : new Error(`${label}: ${String(error)}`) + } + } + if (removed > 0) { + this.emitChanged() + if (rearm) this.runtime?.requestDrive() + } + if (failure !== undefined) throw failure + return removed + } + private serialize(work: () => Promise): Promise { if (this.stopping) return Promise.reject(new Error('Schedule service is stopping')) const pending = this.chain.then(work) From ac4f99652e737d5b2f07864dfb1b9b6106f339ff Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 19:27:38 +0800 Subject: [PATCH 05/34] fix(schedule): keep confirmation focus while its detail closes --- apps/web/tests/schedule-after.e2e.ts | 5 +++++ .../ui-schedule/src/client/TaskManagerPage.tsx | 6 ++++-- .../tests/task-manager-page.client.spec.tsx | 17 +++++++++++++++++ packages/schedule/schedule/README.i18n.yaml | 4 ++-- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 8ac84949c9..32fdd747ee 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1317,6 +1317,11 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(remaining.some(task => endedIds.includes(task.id))).toBe(false) expect(remaining.map(task => task.title)).toContain('Keep this running task') await page.getByText('Cleared 2 inactive tasks.', { exact: true }).waitFor({ timeout: 15_000 }) + // The trigger left with the rows it removed, so the page heading owns the + // focus after the confirmation closes (a browser-level check: jsdom does + // not move focus to the document body when a focused control unmounts). + await expect.poll(() => page.evaluate(() => document.activeElement?.tagName), { timeout: 15_000 }) + .toBe('H1') // The page follows the acknowledged removal: no ended row or cleanup action // remains, and the running task is still listed. await expect.poll(() => manager.getByRole('button', { name: 'Clear inactive', exact: true }).count(), { timeout: 15_000 }) diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index ccaba8d9d1..6940f448a8 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -101,13 +101,15 @@ export function TaskManagerPage(props: TaskManagerPageProps) { useEffect(() => { // The detail focuses its own panel; with nothing selected, focus returns // to the row that opened it, or to the page heading once that row is gone. - if (selectedId !== null) return + // An open confirmation owns the focus until it closes, even when a refresh + // drops the selected task behind it. + if (selectedId !== null || clearConfirm) return if (rowRef.current !== null) { const target = rowRef.current.isConnected ? rowRef.current : headingRef.current target?.focus() rowRef.current = null } - }, [selectedId]) + }, [selectedId, clearConfirm]) useEffect(() => { if (status !== 'ready') return diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index c30fa5953d..23fb019d07 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -1041,6 +1041,23 @@ describe('clearing inactive tasks', () => { expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) }) + it('keeps focus in the confirmation when the selected task disappears behind it', () => { + const h = mount({ records: [at, endedTask] }) + fireEvent.click(screen.getByRole('button', { name: 'Ended summary' })) + expect(screen.getByRole('complementary', { name: en['detail.label'] })).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + // A refresh drops the selected task while the confirmation is open; closing + // the detail must not take the focus out of the dialog. + act(() => { h.update({ records: [at] }) }) + expect(screen.queryByRole('complementary', { name: en['detail.label'] })).toBeNull() + expect(dialog.contains(document.activeElement)).toBe(true) + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + // The row that held the focus is gone with the detail, so the heading takes it. + expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) + }) + it('falls back to the page heading when another cleanup removes the trigger', () => { const h = mount({ records: [at, endedTask] }) const trigger = screen.getByRole('button', { name: en['clear.action'] }) diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 373a697408..974438a04a 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: 694ff524482a3ac0 - zh: 139e1814a270421c + en: e3d0f91b86885185 + zh: 06f34ec76ff4f57a /deepseek-ai-dsh-schedule/understand-the-implementation: en: 02848e6fd6317b2a zh: 1807a423210f81ef diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index 0fe1d65bb7..75703af3c5 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -44,7 +44,7 @@ Weekly input adds `weekdays`, a non-empty set of ISO weekday numbers from Monday Cron input carries `expression` and `time_zone`. The expression is the standard five-field Vixie form `minute hour day-of-month month day-of-week`: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, and day-of-week 0-7 where both `0` and `7` mean Sunday. Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. `L`, `W`, `#`, `JAN`/`MON` names, `@daily`-style macros, six-field expressions, out-of-range values, inverted ranges, zero steps, and empty fields are rejected with `invalid_rule` and a message naming the offending field. Because the dialect has five fields, the smallest interval is one minute. Creation stores a canonical expression: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step, so the day rule below survives storage. The durable decoder rejects a non-canonical stored expression, so the record always holds the canonical text. When either day-of-month or day-of-week is a star, a local date matches only when both fields match; when neither is a star, either field matching is enough. A field is a star when its text starts with `*`, independently of the values it matches, so a stepped star constrains alongside the other field instead of substituting for it. The first target is the first strictly future instant whose local date and time in that zone match, using the same gap skip and earlier-overlap rules as Daily. See [cron timing](../../../docs/subsystems/schedule.md#cron-wall-clock-input) for the full dialect and canonicalization rules. -A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every ended task across Sessions and answers how many rows it removed; an ended task never arms a timer, so that removal recomputes no target, and because rows are removed one at a time a failure partway through leaves the earlier removals committed and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. +A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and because rows are removed one at a time a failure partway through leaves the earlier removals committed and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. Archiving a Session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 5f938af66d..b7dfc48719 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -44,7 +44,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 cron 输入携带 `expression` 和 `time_zone`。表达式是标准五字段 Vixie 形式 `minute hour day-of-month month day-of-week`:minute 0-59、hour 0-23、day-of-month 1-31、month 1-12、day-of-week 0-7,其中 `0` 和 `7` 都表示周日。每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。`L`、`W`、`#`、`JAN`/`MON` 名称、`@daily` 风格宏、六字段表达式、超出范围的值、反向范围、零步长和空字段都会以 `invalid_rule` 拒绝,且错误信息会指出违规字段。由于该方言只有五个字段,最小间隔为一分钟。创建时存储规范化表达式:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长,因此下文的日规则在存储后保持不变。持久化解码器拒绝非规范化的已存储表达式,因此记录始终保存规范化文本。day-of-month 或 day-of-week 中任一为星号字段时,本地日期需两个字段都匹配;两者都不是星号字段时,匹配任一字段即可。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此步长星号字段与其他字段同时约束,而不是替代它。首个目标是该时区内本地日期与时间匹配的第一个严格未来时点,使用与 Daily 相同的缺口跳过与重叠取较早时点规则。完整方言与规范化规则见 [cron 时间语义](../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input)。 -提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且由于逐行移除,中途失败时先前已移除的行保持已移除,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 +提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且由于逐行移除,中途失败时先前已移除的行保持已移除,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 归档仍有活动提醒的会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 From 8d0540af50ddfb53f40de010d58fff6730f81f53 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 20 Oct 2026 19:47:46 +0800 Subject: [PATCH 06/34] fix(schedule): hand focus to the heading when a late readback lands --- apps/web/tests/schedule-after.e2e.ts | 4 +-- .../src/client/TaskManagerPage.tsx | 29 +++++++++++++++---- .../tests/task-manager-page.client.spec.tsx | 18 ++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 32fdd747ee..aa089709c3 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1318,8 +1318,8 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(remaining.map(task => task.title)).toContain('Keep this running task') await page.getByText('Cleared 2 inactive tasks.', { exact: true }).waitFor({ timeout: 15_000 }) // The trigger left with the rows it removed, so the page heading owns the - // focus after the confirmation closes (a browser-level check: jsdom does - // not move focus to the document body when a focused control unmounts). + // focus after the confirmation closes; this confirms the heading fallback + // in a real browser. await expect.poll(() => page.evaluate(() => document.activeElement?.tagName), { timeout: 15_000 }) .toBe('H1') // The page follows the acknowledged removal: no ended row or cleanup action diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 6940f448a8..0d38531291 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -70,6 +70,9 @@ export function TaskManagerPage(props: TaskManagerPageProps) { // Set when the confirmation opens: its settled removal can hide the trigger // before the Modal returns focus, and so can another client's cleanup. const clearFocusPending = useRef(false) + // Set when the removal settles: a readback superseded by `schedule/changed` + // can publish its rows after the dialog closed, removing the trigger then. + const clearSettled = useRef(false) const confirming = records.find(record => record.id === confirmId) // The bulk action's target and count come from the complete catalog, not from // the filtered rows: it removes every ended task, whatever the view shows. @@ -124,13 +127,26 @@ export function TaskManagerPage(props: TaskManagerPageProps) { // The Modal restores focus to the element it captured when it opened; the // trigger can be removed or rebuilt while the confirmation is open, so the // page points focus at the current control, or the heading when it is gone. + // A settled removal whose readback lands later unmounts the trigger after the + // dialog closed, which drops focus to the document body; that handoff is + // caught by the same effect watching the ended count. useEffect(() => { - if (clearConfirm || !clearFocusPending.current) return - clearFocusPending.current = false - const trigger = clearTriggerRef.current - const target = trigger?.isConnected === true ? trigger : headingRef.current - target?.focus() - }, [clearConfirm]) + if (clearConfirm) return + if (clearFocusPending.current) { + clearFocusPending.current = false + const trigger = clearTriggerRef.current + if (trigger?.isConnected === true) { + trigger.focus() + return + } + clearSettled.current = false + headingRef.current?.focus() + return + } + if (!clearSettled.current || endedCount > 0 || document.activeElement !== document.body) return + clearSettled.current = false + headingRef.current?.focus() + }, [clearConfirm, endedCount]) const confirmClearEnded = (): void => { // The confirmation button stays focusable while the removal runs, so a @@ -138,6 +154,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) { if (clearing) return setClearing(true) void onClearEnded().finally(() => { + clearSettled.current = true setClearing(false) setClearConfirm(false) }) diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index 23fb019d07..ae5eaae4ca 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -1085,6 +1085,24 @@ describe('clearing inactive tasks', () => { expect(document.activeElement).toBe(rebuilt) }) + it('moves focus to the heading when a late readback removes the trigger', async () => { + const pending = Promise.withResolvers() + const onClearEnded = vi.fn(() => pending.promise) + const h = mount({ records: [at, endedTask] }, en, { onClearEnded }) + const trigger = screen.getByRole('button', { name: en['clear.action'] }) + trigger.focus() + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('button', { name: en['clear.confirm'] })) + // The removal settles while the catalog still lists the ended row: the + // dialog closes and focus returns to the trigger. + await act(async () => { pending.resolve(undefined) }) + expect(document.activeElement).toBe(trigger) + // A readback superseded by `schedule/changed` publishes afterwards and takes + // the trigger with its row; focus must not stay on the document body. + act(() => { h.update({ records: [at] }) }) + expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) + }) + it('closes only the confirmation on Escape while a task detail is open', () => { mount({ records: [at, endedTask] }) fireEvent.click(screen.getByRole('button', { name: 'Review release' })) From 3c5ab55d16dada8802e3fd2384ba38b7fc0a31af Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 21 Oct 2026 12:21:11 +0800 Subject: [PATCH 07/34] perf(storage): delete task rows as one durable batch --- docs/subsystems/schedule.i18n.yaml | 4 +- docs/subsystems/schedule.md | 7 +-- docs/subsystems/schedule.zh.md | 7 +-- docs/subsystems/storage.i18n.yaml | 8 +-- docs/subsystems/storage.md | 4 +- docs/subsystems/storage.zh.md | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/schedule/schedule/README.i18n.yaml | 4 +- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/schedule/schedule/src/index.ts | 43 +++++--------- .../schedule/tests/delete-inactive.spec.ts | 39 ++++++------- packages/schedule/schedule/tests/harness.ts | 5 ++ .../schedule/schedule/tests/plugin.spec.ts | 56 +++++++++---------- .../storage/storage-domain/README.i18n.yaml | 4 +- packages/storage/storage-domain/README.md | 2 +- packages/storage/storage-domain/README.zh.md | 2 +- packages/storage/storage-domain/src/domain.ts | 32 +++++++++++ .../storage-domain/tests/domain.spec.ts | 39 +++++++++++++ .../tests/helpers/memory-backend.ts | 8 +++ .../storage/storage-json/README.i18n.yaml | 4 +- packages/storage/storage-json/README.md | 4 +- packages/storage/storage-json/README.zh.md | 4 +- .../storage-json/src/per-record-unit.ts | 39 ++++++++++++- .../storage/storage-json/src/single-unit.ts | 21 ++++++- .../storage-json/tests/json-backend.spec.ts | 34 +++++++++++ .../storage/storage-sqlite/README.i18n.yaml | 2 +- packages/storage/storage-sqlite/README.zh.md | 2 +- packages/storage/storage-sqlite/src/unit.ts | 30 ++++++++-- .../tests/sqlite-backend.spec.ts | 17 ++++++ packages/storage/storage/src/backend.ts | 16 ++++++ packages/storage/storage/tests/contract.ts | 19 +++++++ .../workspace/tests/workspace.spec.ts | 1 + 33 files changed, 346 insertions(+), 125 deletions(-) diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index fdd931c7f2..85cdab2a17 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 93798ad69f74ddd5 zh: 634126ad8accc356 /host-wide-schedule/web-catalog: - en: 30464765cbe9f81a - zh: eecf1ad0888b182f + en: a4f9de4d4f88f018 + zh: 228a2e8fcc726f4c /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index 8a794f8da9..7e1459483d 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -345,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed, and a failure partway through rejects after the removals already committed and announced. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. +The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed, and a failed batch rejects with nothing removed and nothing announced. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. The shipped Web composition mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. The Rules and Records tabs separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution. @@ -522,9 +522,8 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. Each removed row is durable on its own: a - * failure partway through leaves the earlier removals committed and announced, - * then rejects rather than reporting a partial count as the whole. + * enumeration rather than during it. The batch is one durable write: a failure + * removes nothing and rejects, so the caller never receives a partial removal. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index 10356c08d2..d11c1eec8f 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -345,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`,中途失败时先提交并通知已移除的行,再以拒绝结束。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;该批删除是一次持久写入,失败时不删除任何行,也不发通知,调用以拒绝结束。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 @@ -522,9 +522,8 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. Each removed row is durable on its own: a - * failure partway through leaves the earlier removals committed and announced, - * then rejects rather than reporting a partial count as the whole. + * enumeration rather than during it. The batch is one durable write: a failure + * removes nothing and rejects, so the caller never receives a partial removal. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 632e0788df..2c1b8df95a 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -9,14 +9,14 @@ en: b11e06e7c0e0fd13 zh: 4585f26ff416cb41 /storage/the-backend-contract: - en: 2316787902c6a94e - zh: e3bda06e1c33acac + en: 8b8cc57b66f4ff87 + zh: aecf14a900014fc1 /storage/declaring-a-domain: en: 775cce941500f01f zh: 78acbc67f48574cb /storage/the-open-domain: - en: 2468b5df66c49b51 - zh: 1587f460e9769db2 + en: 65b94c4c8d240a77 + zh: 337d9e36a9bce113 /storage/the-domain-facility-ctx-storagedomain: en: deed74fcd4506ef4 zh: 8e3e6e5d2dc2e989 diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 674221a263..1fcd252e80 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is durably absent, and a rejection leaves the batch unapplied. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain @@ -123,7 +123,7 @@ interface Domain { } ``` -Reads are synchronous from authoritative in-memory state: `KvTable` exposes `get`/`entries`/`keys`/`size` (snapshot iterators that stay stable while queued writes land), and the global handle's `get()` serves the spec's `initial` until the first `set` materializes the slot on the medium. Every write — `put`, `delete`, `update`, `global.set` — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits `domain/changed`; a rejected backend write leaves memory untouched, so reads never diverge from the medium. `update(key, fn)` is an atomic read-modify-write at its chain slot (a missing key rejects `missing-key`); `delete` of an absent key resolves `false` with no write and no event. Returned records are the stored objects themselves, not copies — replace via `put`/`update`, never mutate in place. +Reads are synchronous from authoritative in-memory state: `KvTable` exposes `get`/`entries`/`keys`/`size` (snapshot iterators that stay stable while queued writes land), and the global handle's `get()` serves the spec's `initial` until the first `set` materializes the slot on the medium. Every write — `put`, `delete`, `deleteMany`, `update`, `global.set` — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits `domain/changed`; a rejected backend write leaves memory untouched, so reads never diverge from the medium. `update(key, fn)` is an atomic read-modify-write at its chain slot (a missing key rejects `missing-key`); `delete` of an absent key resolves `false` with no write and no event. `deleteMany(keys)` removes the present keys as one backend batch in one chain slot, resolves the removed count, and emits one `domain/changed` per removed key; a rejected backend write leaves every record in place and emits nothing. Returned records are the stored objects themselves, not copies — replace via `put`/`update`, never mutate in place. ## The domain facility: `ctx.storageDomain` diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index 2a4366c84e..62afa47835 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都持久缺失后才 resolve,拒绝时整批未应用。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 @@ -123,7 +123,7 @@ interface Domain { } ``` -读取是同步的,来自权威的内存态:`KvTable` 暴露 `get`/`entries`/`keys`/`size`(快照迭代器,在排队写入落地期间保持稳定),global 句柄的 `get()` 在第一次 `set` 将 slot 物化到介质之前一直返回 spec 的 `initial`。每次写入——`put`、`delete`、`update`、`global.set`——都在同一条逐领域写链上排队,先在后端完成持久化,再更新内存,最后发出 `domain/changed`;后端写入被拒时内存原样不动,因此读取绝不会偏离介质。`update(key, fn)` 在其写链 slot 上是一次原子的读-改-写(键缺失时拒绝 `missing-key`);`delete` 一个不存在的键 resolve 为 `false`,不产生写入也不产生事件。返回的记录就是存储的对象本身,不是副本——请经 `put`/`update` 整体替换,绝不要就地修改。 +读取是同步的,来自权威的内存态:`KvTable` 暴露 `get`/`entries`/`keys`/`size`(快照迭代器,在排队写入落地期间保持稳定),global 句柄的 `get()` 在第一次 `set` 将 slot 物化到介质之前一直返回 spec 的 `initial`。每次写入——`put`、`delete`、`deleteMany`、`update`、`global.set`——都在同一条逐领域写链上排队,先在后端完成持久化,再更新内存,最后发出 `domain/changed`;后端写入被拒时内存原样不动,因此读取绝不会偏离介质。`update(key, fn)` 在其写链 slot 上是一次原子的读-改-写(键缺失时拒绝 `missing-key`);`delete` 一个不存在的键 resolve 为 `false`,不产生写入也不产生事件。`deleteMany(keys)` 在一个写链 slot 内把存在的键作为一次后端批量删除移除,resolve 为移除数量,并为每个被移除的键发一次 `domain/changed`;后端写入被拒时所有记录保持原样,也不发事件。返回的记录就是存储的对象本身,不是副本——请经 `put`/`update` 整体替换,绝不要就地修改。 ## 领域 facility:`ctx.storageDomain` diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8235ff2645..985df528c0 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2202,7 +2202,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'deleteInactive\') async deleteInactive(): Promise', - description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. Each removed row is durable on its own: a failure partway through leaves the earlier removals committed and announced, then rejects rather than reporting a partial count as the whole.', + description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. The batch is one durable write: a failure removes nothing and rejects, so the caller never receives a partial removal.', parameters: [], returns: 'Count of removed rows; zero when the Host held no ended task.', }, @@ -6116,11 +6116,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'KvTable', - declaration: 'export interface KvTable {\n get(key: K): V | undefined;\n entries(): IterableIterator<[\n K,\n V\n ]>;\n keys(): IterableIterator;\n readonly size: number;\n put(key: K, value: V): Promise;\n delete(key: K): Promise;\n update(key: K, fn: (current: V) => V): Promise;\n}', + declaration: 'export interface KvTable {\n get(key: K): V | undefined;\n entries(): IterableIterator<[\n K,\n V\n ]>;\n keys(): IterableIterator;\n readonly size: number;\n put(key: K, value: V): Promise;\n delete(key: K): Promise;\n deleteMany(keys: readonly K[]): Promise;\n update(key: K, fn: (current: V) => V): Promise;\n}', }, { name: 'KvUnit', - declaration: 'export interface KvUnit {\n loadAll(): Promise<{\n tables: Record>;\n global: unknown;\n }>;\n putRecord(table: string, key: string, value: unknown): Promise;\n deleteRecord(table: string, key: string): Promise;\n backupRecord?(table: string, key: string): Promise;\n setGlobal(value: unknown): Promise;\n close(): Promise;\n}', + declaration: 'export interface KvUnit {\n loadAll(): Promise<{\n tables: Record>;\n global: unknown;\n }>;\n putRecord(table: string, key: string, value: unknown): Promise;\n deleteRecord(table: string, key: string): Promise;\n deleteRecords(table: string, keys: readonly string[]): Promise;\n backupRecord?(table: string, key: string): Promise;\n setGlobal(value: unknown): Promise;\n close(): Promise;\n}', }, { name: 'KvUnitDescriptor', diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 974438a04a..94e9eaafde 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: e3d0f91b86885185 - zh: 06f34ec76ff4f57a + en: a4521f1f145b2dc7 + zh: 690d626671678f93 /deepseek-ai-dsh-schedule/understand-the-implementation: en: 02848e6fd6317b2a zh: 1807a423210f81ef diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index 75703af3c5..c76e76b6e8 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -44,7 +44,7 @@ Weekly input adds `weekdays`, a non-empty set of ISO weekday numbers from Monday Cron input carries `expression` and `time_zone`. The expression is the standard five-field Vixie form `minute hour day-of-month month day-of-week`: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, and day-of-week 0-7 where both `0` and `7` mean Sunday. Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. `L`, `W`, `#`, `JAN`/`MON` names, `@daily`-style macros, six-field expressions, out-of-range values, inverted ranges, zero steps, and empty fields are rejected with `invalid_rule` and a message naming the offending field. Because the dialect has five fields, the smallest interval is one minute. Creation stores a canonical expression: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step, so the day rule below survives storage. The durable decoder rejects a non-canonical stored expression, so the record always holds the canonical text. When either day-of-month or day-of-week is a star, a local date matches only when both fields match; when neither is a star, either field matching is enough. A field is a star when its text starts with `*`, independently of the values it matches, so a stepped star constrains alongside the other field instead of substituting for it. The first target is the first strictly future instant whose local date and time in that zone match, using the same gap skip and earlier-overlap rules as Daily. See [cron timing](../../../docs/subsystems/schedule.md#cron-wall-clock-input) for the full dialect and canonicalization rules. -A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and because rows are removed one at a time a failure partway through leaves the earlier removals committed and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. +A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and the batch is one durable write, so a failure removes nothing and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. Archiving a Session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index b7dfc48719..e1a650358b 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -44,7 +44,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 cron 输入携带 `expression` 和 `time_zone`。表达式是标准五字段 Vixie 形式 `minute hour day-of-month month day-of-week`:minute 0-59、hour 0-23、day-of-month 1-31、month 1-12、day-of-week 0-7,其中 `0` 和 `7` 都表示周日。每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。`L`、`W`、`#`、`JAN`/`MON` 名称、`@daily` 风格宏、六字段表达式、超出范围的值、反向范围、零步长和空字段都会以 `invalid_rule` 拒绝,且错误信息会指出违规字段。由于该方言只有五个字段,最小间隔为一分钟。创建时存储规范化表达式:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长,因此下文的日规则在存储后保持不变。持久化解码器拒绝非规范化的已存储表达式,因此记录始终保存规范化文本。day-of-month 或 day-of-week 中任一为星号字段时,本地日期需两个字段都匹配;两者都不是星号字段时,匹配任一字段即可。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此步长星号字段与其他字段同时约束,而不是替代它。首个目标是该时区内本地日期与时间匹配的第一个严格未来时点,使用与 Daily 相同的缺口跳过与重叠取较早时点规则。完整方言与规范化规则见 [cron 时间语义](../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input)。 -提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且由于逐行移除,中途失败时先前已移除的行保持已移除,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 +提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且该批删除是一次持久写入,失败时不删除任何行,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 归档仍有活动提醒的会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index a6b83d3f99..b5dd3b8617 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -372,9 +372,8 @@ export class ScheduleService extends TypertRemoteService { * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. Each removed row is durable on its own: a - * failure partway through leaves the earlier removals committed and announced, - * then rejects rather than reporting a partial count as the whole. + * enumeration rather than during it. The batch is one durable write: a failure + * removes nothing and rejects, so the caller never receives a partial removal. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') @@ -489,39 +488,27 @@ export class ScheduleService extends TypertRemoteService { } /** - * Remove rows one at a time inside the current queue slot. + * Remove rows as one durable batch inside the current queue slot. * - * One row per write: the domain table has no batch delete, and each row is - * durable on its own, so a failure partway through leaves the rows already - * removed committed. Observers hear about that change before the failure - * reaches the caller, which still receives it. Every row is attempted after a - * failure, so one unreadable delete does not leave the rest of the set behind; - * a rejection reason that carries no Error is wrapped before the first one is - * kept. + * The batch is one domain write: either every present row is removed and + * announced, or the removal rejects with every row still stored. A rejection + * reason that carries no Error is wrapped with the operation label. * @param ids - rows to remove. * @param label - failure message prefix naming the operation that owned them. * @param rearm - whether the rows were armed, so the pending timer must be recomputed. * @returns the number of rows this call removed. */ private async removeRows(ids: readonly ScheduleRecord['id'][], label: string, rearm: boolean): Promise { - const tasks = (await this.getDomain()).table('tasks') - let removed = 0 - let failure: Error | undefined - for (const id of ids) { - try { - // Only a row this call removed is counted. The domain answers false when - // the row is absent; that is defensive here because every task write - // shares this queue slot. - if (await tasks.delete(id)) removed += 1 - } catch (error: unknown) { - failure ??= error instanceof Error ? error : new Error(`${label}: ${String(error)}`) - } + if (ids.length === 0) return 0 + let removed: number + try { + removed = await (await this.getDomain()).table('tasks').deleteMany(ids) + } catch (error: unknown) { + throw error instanceof Error ? error : new Error(`${label}: ${String(error)}`) } - if (removed > 0) { - this.emitChanged() - if (rearm) this.runtime?.requestDrive() - } - if (failure !== undefined) throw failure + // Rows enumerated in this slot are present, so a non-empty batch always removed rows. + this.emitChanged() + if (rearm) this.runtime?.requestDrive() return removed } diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts index a0913f4608..d1222d010d 100644 --- a/packages/schedule/schedule/tests/delete-inactive.spec.ts +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -98,21 +98,14 @@ it('reports zero and announces nothing when the Host holds no ended task', async expect(await service.catalog()).toHaveLength(1) }) -it('keeps the removals that landed, announces them, and reports the failure', async () => { +it('removes nothing and reports the batch failure', async () => { const pool = new MemoryMediaPool() - let deletes = 0 - // The first ended row lands durably; the second row's physical delete rejects. - // Iteration order is a snapshot detail, so the case records which row failed - // instead of assuming which of the two that is. - let failedId: string | undefined + const batches: string[][] = [] const backend = gatedScheduleBackend(pool, { - deleteRecord: async (table, key) => { + deleteRecords: async (table, keys) => { if (table !== 'tasks') return - deletes += 1 - if (deletes === 2) { - failedId = key - throw new Error('injected delete failure') - } + batches.push([...keys]) + throw new Error('injected batch failure') }, }) const test = await harness({ @@ -133,21 +126,21 @@ it('keeps the removals that landed, announces them, and reports the failure', as const failure = await service.deleteInactive().then(() => undefined, (error: unknown) => error) expect(failure).toBeInstanceOf(Error) - expect(String(failure)).toContain('injected delete failure') - // The row removed before the failure is a durable change observers must hear. - expect(changed).toHaveBeenCalledTimes(1) - expect(failedId).toBeDefined() - // The survivor is the row whose delete failed; the other row stayed removed. - expect((await service.catalog()).map(entry => entry.id)).toEqual([failedId]) + expect(String(failure)).toContain('injected batch failure') + // One write carried both rows; the rejection left both stored and announced nothing. + expect(batches).toEqual([['ended-a', 'ended-b']]) + expect(changed).not.toHaveBeenCalled() + expect((await service.catalog()).map(entry => entry.title).sort()) + .toEqual(['Partial first', 'Partial second']) }) -it('attempts every row and reports a rejection reason that carries no Error', async () => { +it('reports a rejection reason that carries no Error as a labeled Error', async () => { const pool = new MemoryMediaPool() - let deletes = 0 + let batches = 0 const backend = gatedScheduleBackend(pool, { - deleteRecord: async (table) => { + deleteRecords: async (table) => { if (table !== 'tasks') return - deletes += 1 + batches += 1 throw undefined }, }) @@ -171,7 +164,7 @@ it('attempts every row and reports a rejection reason that carries no Error', as // A reason that carries no Error must not read as a successful removal. expect(failure).toBeInstanceOf(Error) expect(String(failure)).toContain('schedule ended-task removal failed') - expect(deletes).toBe(2) + expect(batches).toBe(1) expect(changed).not.toHaveBeenCalled() expect((await service.catalog()).map(entry => entry.title).sort()) .toEqual(['Unreportable first', 'Unreportable second']) diff --git a/packages/schedule/schedule/tests/harness.ts b/packages/schedule/schedule/tests/harness.ts index 62c8e33dfa..612c2cd61b 100644 --- a/packages/schedule/schedule/tests/harness.ts +++ b/packages/schedule/schedule/tests/harness.ts @@ -29,6 +29,7 @@ export function gatedScheduleBackend( traps: { putRecord?: (table: string, key: string) => Promise | undefined deleteRecord?: (table: string, key: string) => Promise | undefined + deleteRecords?: (table: string, keys: readonly string[]) => Promise | undefined } = {}, ): StorageBackend { const memory = new MemoryStorageBackend(pool) @@ -49,6 +50,10 @@ export function gatedScheduleBackend( await traps.deleteRecord?.(table, key) await unit.deleteRecord(table, key) }, + deleteRecords: async (table, keys) => { + await traps.deleteRecords?.(table, keys) + await unit.deleteRecords(table, keys) + }, setGlobal: value => unit.setGlobal(value), close: () => unit.close(), ...(backup === undefined ? {} : { backupRecord: backup }), diff --git a/packages/schedule/schedule/tests/plugin.spec.ts b/packages/schedule/schedule/tests/plugin.spec.ts index 9daf1ed31f..a507b5181f 100644 --- a/packages/schedule/schedule/tests/plugin.spec.ts +++ b/packages/schedule/schedule/tests/plugin.spec.ts @@ -171,14 +171,14 @@ describe('shared Schedule management', () => { expect(await service.catalog()).toEqual([]) }) - it('notifies observers when a stop removes part of the set before a delete fails', async () => { + it('reports a stop failure without announcing an unapplied batch', async () => { const pool = new MemoryMediaPool() - let deletes = 0 - // The first row lands durably; the second row's write rejects. + let batches = 0 const backend = gatedScheduleBackend(pool, { - deleteRecord: async (table) => { - deletes += 1 - if (table === 'tasks' && deletes === 2) throw new Error('injected delete failure') + deleteRecords: async (table) => { + if (table !== 'tasks') return + batches += 1 + throw new Error('injected batch failure') }, }) const test = await harness({ pool, backend }) @@ -189,21 +189,25 @@ describe('shared Schedule management', () => { await service.create(sessionId, { prompt: 'second', after_seconds: 60, title: 'Second' }) const changed = vi.fn() ctx.on('schedule/changed', changed) - // The first row is durably gone, so observers must hear about it even though - // the second delete failed, and the caller must still receive that failure. + // The batch was never applied, so observers hear nothing and the caller still + // receives the failure. const failure = await ctx.parallel('workspace/session-stop', { sessionId }) .then(() => undefined, (error: unknown) => error) expect(failure).toBeInstanceOf(AggregateError) - expect(changed).toHaveBeenCalled() - expect((await service.catalog()).map(entry => entry.title)).toEqual(['Second']) + expect(batches).toBe(1) + expect(changed).not.toHaveBeenCalled() + expect((await service.catalog()).map(entry => entry.title).sort()).toEqual(['First', 'Second']) }) it('reports a stop failure whose rejection reason is not an Error', async () => { const pool = new MemoryMediaPool() + let batches = 0 const backend = gatedScheduleBackend(pool, { // A reason that is not an Error is still a rejection the caller must see. - deleteRecord: async (table) => { - if (table === 'tasks') throw undefined + deleteRecords: async (table) => { + if (table !== 'tasks') return + batches += 1 + throw undefined }, }) const test = await harness({ pool, backend }) @@ -218,37 +222,29 @@ describe('shared Schedule management', () => { // Nothing landed, and a reason that carries no Error must not read as success // with the reminder still armed. expect(failure).toBeInstanceOf(AggregateError) + expect(batches).toBe(1) expect(changed).not.toHaveBeenCalled() expect((await service.catalog()).map(entry => entry.title)).toEqual(['First']) }) - it('attempts every row and notifies nothing when the first stop delete fails', async () => { + it('removes the whole stop set in one batch write', async () => { const pool = new MemoryMediaPool() - let deletes = 0 + const batches: string[][] = [] const backend = gatedScheduleBackend(pool, { - deleteRecord: async (table) => { - if (table !== 'tasks') return - deletes += 1 - throw new Error('injected delete failure') + deleteRecords: async (table, keys) => { + if (table === 'tasks') batches.push([...keys]) }, }) const test = await harness({ pool, backend }) tests.push(test) const { ctx, service } = test - const sessionId = SessionId('stop-failed') + const sessionId = SessionId('stop-one-batch') await service.create(sessionId, { prompt: 'first', after_seconds: 60, title: 'First' }) await service.create(sessionId, { prompt: 'second', after_seconds: 60, title: 'Second' }) - const changed = vi.fn() - ctx.on('schedule/changed', changed) - const failure = await ctx.parallel('workspace/session-stop', { sessionId }) - .then(() => undefined, (error: unknown) => error) - // Nothing landed, so nothing is announced; every row was still attempted, - // because a row this stop skipped would stay armed in an archived Session. - expect(failure).toBeInstanceOf(AggregateError) - expect(deletes).toBe(2) - expect(changed).not.toHaveBeenCalled() - // The catalog sorts by target then id, so compare the surviving set, not its order. - expect((await service.catalog()).map(entry => entry.title).sort()).toEqual(['First', 'Second']) + await ctx.parallel('workspace/session-stop', { sessionId }) + expect(batches).toHaveLength(1) + expect(batches[0]).toHaveLength(2) + expect(await service.catalog()).toEqual([]) }) it('catalogs persisted reminders across cold Sessions in target and identity order', async () => { diff --git a/packages/storage/storage-domain/README.i18n.yaml b/packages/storage/storage-domain/README.i18n.yaml index cfcfaac6df..29d392dac9 100644 --- a/packages/storage/storage-domain/README.i18n.yaml +++ b/packages/storage/storage-domain/README.i18n.yaml @@ -36,8 +36,8 @@ en: 1cc8b33834457c38 zh: c549afc7c4960e9e /deepseek-ai-dsh-storage-domain/understand-the-implementation/design-concept: - en: 1e063b13eced114a - zh: f65c910b124fd595 + en: 899fd5b09d9eb3c4 + zh: 771eb56d143cca5b /deepseek-ai-dsh-storage-domain/understand-the-implementation/open-sequence: en: 052da5d6c45205ca zh: 724c875e3c431fee diff --git a/packages/storage/storage-domain/README.md b/packages/storage/storage-domain/README.md index 7869de5671..fc9d206aac 100644 --- a/packages/storage/storage-domain/README.md +++ b/packages/storage/storage-domain/README.md @@ -86,7 +86,7 @@ The domain layer is a single implementation, not an abstracted seam: consumers d - **The spec object is the single source of truth.** `defineDomain` pins the spec's literal types and validates its fields at the owning package's module load, before any medium is touched. Record schemas are zod, so `z.infer` avoids duplicating consumer types; plugin `Config` stays schemastery. - **Memory is authoritative; the medium is the durable projection.** Reads are synchronous from validated in-memory state. Every write queues on one per-domain write chain: backend durability first, then memory mutation, then `domain/changed` — a rejected backend write leaves memory untouched, so reads never diverge from the medium. -- **One write chain per domain.** `put`, `delete`, `update`, and `global.set` all queue on it; `update`'s transform runs at its chain slot, so concurrent updates never interleave. Records are plain immutable data — returned values are the stored objects themselves and must not be mutated in place. +- **One write chain per domain.** `put`, `delete`, `deleteMany`, `update`, and `global.set` all queue on it; `update`'s transform runs at its chain slot, so concurrent updates never interleave. Records are plain immutable data — returned values are the stored objects themselves and must not be mutated in place. - **Writes emit after the commit point.** `domain/changed` is a notification, not a transaction participant: a throwing listener is contained with a logged warning rather than rejecting the already-durable write. ### Open sequence diff --git a/packages/storage/storage-domain/README.zh.md b/packages/storage/storage-domain/README.zh.md index db7cc41dd1..44c89f1769 100644 --- a/packages/storage/storage-domain/README.zh.md +++ b/packages/storage/storage-domain/README.zh.md @@ -86,7 +86,7 @@ domain.table('workspaces').update(id, (r) => ({ ...r, path: newPath })) - **spec 对象是唯一真源。** `defineDomain` 固定 spec 的字面类型,并在所属包的模块加载时、任何介质被触碰之前校验其字段。记录 schema 使用 zod,因此 `z.infer` 可避免重复定义消费方类型;插件 `Config` 仍由 schemastery 负责。 - **内存具有最终决定权;介质是持久投影。** 读取同步取自经过校验的内存状态。每次写入都在每个领域一条的写入链上排队:先到达后端持久状态,再变更内存,然后发出 `domain/changed`——被拒绝的后端写入不会触碰内存,因此读取永远不会与介质分叉。 -- **每个领域一条写入链。** `put`、`delete`、`update` 与 `global.set` 都在其上排队;`update` 的变换在链上自己的槽位运行,因此并发更新绝不会交错。记录是普通不可变数据——返回值就是已存对象本身,绝不能原地修改。 +- **每个领域一条写入链。** `put`、`delete`、`deleteMany`、`update` 与 `global.set` 都在其上排队;`update` 的变换在链上自己的槽位运行,因此并发更新绝不会交错。记录是普通不可变数据——返回值就是已存对象本身,绝不能原地修改。 - **写入在提交点之后发出。** `domain/changed` 是通知,不是事务参与者:监听器抛出异常时,系统会隔离该异常并记录警告,而不会让已经持久的写入被拒绝。 ### 打开顺序 diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index c4087d6dd2..be8f6b0298 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -79,6 +79,18 @@ export interface KvTable { */ delete(key: K): Promise + /** + * Delete several records durably in one write slot. + * + * The batch is one job on the domain's write chain: it runs at its slot, not + * at call time, so an earlier queued write of the same domain is visible to + * it and no other write interleaves. Missing keys are ignored; a backend + * failure rejects with the batch unapplied, leaving every record in place. + * @param keys - Record keys; duplicates are harmless. + * @returns the number of keys that existed and were removed. + */ + deleteMany(keys: readonly K[]): Promise + /** * Atomic read-modify-write on the domain's write chain: `fn` sees the * value current at its queue slot, so concurrent updates never interleave. @@ -329,6 +341,26 @@ class KvTableImpl implements KvTable { }) } + deleteMany(keys: readonly K[]): Promise { + return this.host.enqueue(async () => { + // Existence and the backend write happen at this job's chain slot, so an + // earlier queued put of a key is observed and the batch is one unit call. + const present = [...new Set(keys)].filter(key => this.records.has(key)) + if (present.length === 0) return 0 + await this.host.unit.deleteRecords(this.tableName, present) + for (const key of present) { + this.records.delete(key) + this.host.emitChanged({ + domain: this.host.domainName, + table: this.tableName, + key, + operation: 'deleted', + }) + } + return present.length + }) + } + update(key: K, fn: (current: V) => V): Promise { return this.host.enqueue(async () => { if (!this.records.has(key)) { diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index bf328d9977..9762682eb2 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -131,6 +131,7 @@ describe('DomainFacility.open', () => { loadAll: async () => ({ tables: {}, global: null }), putRecord: async () => {}, deleteRecord: async () => {}, + deleteRecords: async () => {}, setGlobal: async () => {}, close: async () => {}, }), @@ -275,6 +276,25 @@ describe('KvTable writes', () => { await expect(table.delete('a')).resolves.toBe(false) }) + it('deleteMany removes the existing keys in one write slot and reports the count', async () => { + const { facility, changes } = await harness() + const domain = await facility.open(spec) + const table = domain.table('items') + await table.put('a', { label: 'x', count: 1 }) + await table.put('b', { label: 'y', count: 2 }) + await table.put('c', { label: 'z', count: 3 }) + const seen = changes.length + await expect(table.deleteMany(['a', 'c', 'missing', 'a'])).resolves.toBe(2) + expect([...table.entries()]).toEqual([['b', { label: 'y', count: 2 }]]) + expect(changes.slice(seen)).toEqual([ + { domain: 'demo', table: 'items', key: 'a', operation: 'deleted' }, + { domain: 'demo', table: 'items', key: 'c', operation: 'deleted' }, + ]) + // A batch without existing keys is a no-op: no backend write and no event. + await expect(table.deleteMany(['ghost'])).resolves.toBe(0) + expect(changes).toHaveLength(seen + 2) + }) + it('emits domain/changed per durable write, in order, with tombstones and global marker', async () => { const { facility, changes } = await harness() const domain = await facility.open(spec) @@ -317,6 +337,25 @@ describe('durability failure', () => { expect(table.get('a')).toEqual({ label: 'x', count: 2 }) }) + it('leaves every record in place when a batch delete fails durability', async () => { + const pool = new MemoryMediaPool() + const { facility, changes } = await harness({ pool }) + const table = (await facility.open(spec)).table('items') + await table.put('a', { label: 'x', count: 1 }) + await table.put('b', { label: 'y', count: 2 }) + const seen = changes.length + + pool.failNextWrites = 1 + await expect(table.deleteMany(['a', 'b'])).rejects.toThrow(/injected/) + + expect([...table.entries()]).toEqual([ + ['a', { label: 'x', count: 1 }], + ['b', { label: 'y', count: 2 }], + ]) + expect(pool.media.get('demo')!.tables.get('items')!.get('b')).toEqual({ label: 'y', count: 2 }) + expect(changes).toHaveLength(seen) + }) + it('keeps serving initial when the first global set fails durability', async () => { const pool = new MemoryMediaPool() const { facility } = await harness({ pool }) diff --git a/packages/storage/storage-domain/tests/helpers/memory-backend.ts b/packages/storage/storage-domain/tests/helpers/memory-backend.ts index 3c8547d280..a081fbf355 100644 --- a/packages/storage/storage-domain/tests/helpers/memory-backend.ts +++ b/packages/storage/storage-domain/tests/helpers/memory-backend.ts @@ -96,6 +96,14 @@ class MemoryKvUnit implements KvUnit { this.medium.tables.get(table)?.delete(key) } + async deleteRecords(table: string, keys: readonly string[]): Promise { + this.assertOpen() + this.pool.consumeInjectedFailure() + const records = this.medium.tables.get(table) + if (records === undefined) return + for (const key of new Set(keys)) records.delete(key) + } + async setGlobal(value: unknown): Promise { this.assertOpen() this.pool.consumeInjectedFailure() diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index 6102e84e8f..3c292a3da1 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/README.i18n.yaml @@ -30,8 +30,8 @@ en: b7d222309b3e5438 zh: 807c7314ee341d94 /deepseek-ai-dsh-storage-json/understand-the-implementation/design-concept: - en: a5a4e3cf05597661 - zh: 539ad4b4045ff572 + en: 3139c95cf26c40e2 + zh: 215e88a688869cbe /deepseek-ai-dsh-storage-json/understand-the-implementation/file-formats: en: b3de0a697db4f916 zh: 56b26f7823392bd5 diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 3fd03738ee..3d8665bb34 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -69,8 +69,8 @@ The two layouts share atomic publication but assign state ownership differently. ### Design concept -- **`single` keeps memory authoritative.** Each write changes the in-memory unit, serializes its complete state, and atomically replaces `.json`. A failed publish restores the prior in-memory value. -- **`per-record` keeps the directory authoritative.** Each put or delete changes one `//.json` document, and `loadAll()` rereads the tree. Each document stamps the unit version and carries one record value. +- **`single` keeps memory authoritative.** Each write changes the in-memory unit, serializes its complete state, and atomically replaces `.json`. A failed publish restores the prior in-memory value. A batch delete is one such publish: the whole batch is applied or restored together. +- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. Each document stamps the unit version and carries one record value. - **Publication is durable per call.** A write uses a temporary file, fsync, atomic `rename()` replacement, and a parent-directory fsync on POSIX. The domain layer's write chain supplies ordering across calls. ### File formats diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index d504694763..d611a65666 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -69,8 +69,8 @@ kind: "package-reference" ### 设计理念 -- **`single` 以内存为权威状态。** 每次写入都会更改内存单元、序列化其完整状态,并以原子方式替换 `.json`。发布失败会恢复先前的内存值。 -- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。 +- **`single` 以内存为权威状态。** 每次写入都会更改内存单元、序列化其完整状态,并以原子方式替换 `.json`。发布失败会恢复先前的内存值。批量删除就是这样一次发布:整批一起应用或一起恢复。 +- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本。 - **每次调用都持久发布。** 写入过程使用临时文件、fsync、原子 `rename()` 替换,并在 POSIX 上 fsync 父目录。领域层写入链负责安排跨调用的顺序。 ### 文件格式 diff --git a/packages/storage/storage-json/src/per-record-unit.ts b/packages/storage/storage-json/src/per-record-unit.ts index 3d064411ee..b0a86537c5 100644 --- a/packages/storage/storage-json/src/per-record-unit.ts +++ b/packages/storage/storage-json/src/per-record-unit.ts @@ -2,8 +2,9 @@ * One opened JSON unit in `per-record` layout: the unit is a directory at * `dir`, holding one document per record under `/
/.json` * plus `global.json` for the global slot. The directory is the state — this - * unit holds NO in-memory state of its own: `loadAll` re-reads the tree and - * every write is one durable file operation. The domain layer owns the live + * unit holds NO in-memory state of its own: `loadAll` re-reads the tree, each + * put or delete touches one durable document, and a batch delete renames its + * documents out of the readable set before unlinking them. The domain layer owns the live * in-memory tables (seeded by the open-time `loadAll`) and serializes writes * through its write chain, so this unit never mutates memory and needs no * rollback — a failed write simply leaves both the file and the domain's @@ -228,6 +229,40 @@ export class PerRecordJsonUnit implements KvUnit { await this.tracked(rm(join(this.tableDir(table), `${key}.json`), { force: true })) } + /** + * Durably delete many records as one batch. Documents are renamed out of the + * readable set first; only after the whole batch is staged are the staged + * copies unlinked. A failure while staging restores the documents it moved, + * so a rejection leaves the batch unapplied. A staged document a crash leaves + * behind is not readable (`open` reads `*.json` documents only). + */ + async deleteRecords(table: string, keys: readonly string[]): Promise { + this.assertOpen() + const dir = this.tableDir(table) + const unique = [...new Set(keys)] + for (const key of unique) assertSafeKey(this.descriptor.name, key) + const staged: Array<{ from: string; to: string }> = [] + try { + for (const key of unique) { + const from = join(dir, `${key}.json`) + const to = `${from}.deleting` + try { + await this.tracked(rename(from, to)) + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue + throw error + } + staged.push({ from, to }) + } + } catch (error: unknown) { + await Promise.allSettled(staged.map(({ from, to }) => rename(to, from))) + throw error + } + // The renames already removed the batch from the readable set; unlinking is + // cleanup, and a failed unlink leaves an unreadable file rather than an entry. + await Promise.allSettled(staged.map(({ to }) => this.tracked(rm(to, { force: true })))) + } + /** * Move one record's document aside as `.json.bak.`. The * moved file no longer ends in `.json`, so every later read ignores it; the diff --git a/packages/storage/storage-json/src/single-unit.ts b/packages/storage/storage-json/src/single-unit.ts index 085b37cfc0..ed044f61b7 100644 --- a/packages/storage/storage-json/src/single-unit.ts +++ b/packages/storage/storage-json/src/single-unit.ts @@ -1,7 +1,8 @@ /** * One opened JSON unit in `single` layout: the whole unit is one document at * `/.json`. The in-memory state is authoritative; every write - * primitive mutates it and republishes the whole file atomically. Writes are + * primitive mutates it and republishes the whole file atomically, including a + * batch delete, which changes several records but publishes once. Writes are * NOT queued here — per the backend contract, write ordering belongs to the * caller (the domain layer's write chain); this unit only guarantees that * each single call publishes a complete, durable file. The `per-record` @@ -98,6 +99,24 @@ class SingleJsonUnit implements KvUnit { }) } + async deleteRecords(table: string, keys: readonly string[]): Promise { + this.assertOpen() + const records = this.records(table) + const removed = new Map() + for (const key of new Set(keys)) { + if (!records.has(key)) continue + removed.set(key, records.get(key)) + records.delete(key) + } + if (removed.size === 0) return + // One atomic publish applies the whole batch; a failed publish restores + // every removed entry so memory and the medium stay together. + await this.publish().catch((error: unknown) => { + for (const [key, value] of removed) records.set(key, value) + throw error + }) + } + async setGlobal(value: unknown): Promise { this.assertOpen() if (!this.descriptor.hasGlobal) { diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index 0e738131a7..51c5825e35 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -94,6 +94,7 @@ describe('json backend specifics', () => { await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow() await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow() await expect(unit.deleteRecord('t', 'k')).rejects.toThrow() + await expect(unit.deleteRecords('t', ['k'])).rejects.toThrow() await expect(unit.setGlobal({ g: 'rejected' })).rejects.toThrow() await rm(path, { recursive: true }) await rename(backup, path) @@ -285,6 +286,39 @@ describe('per-record layout', () => { await backend.close() }) + it('deletes a batch of documents as one staged set', async () => { + const root = await freshRoot() + const backend = new JsonStorageBackend(root) + const unit = await backend.kv.open(descriptor) + await unit.putRecord('t', 'k1', { v: 1 }) + await unit.putRecord('t', 'k2', { v: 2 }) + await unit.putRecord('t', 'k3', { v: 3 }) + await unit.deleteRecords('t', ['k1', 'k3', 'missing', 'k1']) + expect(await unit.loadAll()).toEqual({ tables: { t: { k2: { v: 2 } } }, global: null }) + // The staged documents are unlinked rather than left in the tree. + expect((await readdir(join(root, 'recs', 't'))).sort()).toEqual(['k2.json']) + await backend.close() + }) + + it('restores a batch when a staged rename fails, and rejects unsafe keys first', async () => { + const root = await freshRoot() + const backend = new JsonStorageBackend(root) + const unit = await backend.kv.open(descriptor) + await unit.putRecord('t', 'k1', { v: 1 }) + await unit.putRecord('t', 'k2', { v: 2 }) + // A non-empty directory at the second staged path makes its rename fail + // after the first document was staged out of the readable set. + const blocked = `${recordPath(root, 'k2')}.deleting` + await mkdir(blocked) + await writeFile(join(blocked, 'occupied'), '', 'utf8') + await expect(unit.deleteRecords('t', ['k1', 'k2'])).rejects.toThrow() + expect(await unit.loadAll()).toEqual({ tables: { t: { k1: { v: 1 }, k2: { v: 2 } } }, global: null }) + await rm(blocked, { recursive: true }) + // An unsafe key rejects before any document is renamed. + await expect(unit.deleteRecords('t', ['ok', 'a/b'])).rejects.toThrow(/not path-safe/) + await backend.close() + }) + it('rejects unsafe keys and undeclared tables, and enforces the closed guard', async () => { const root = await freshRoot() const backend = new JsonStorageBackend(root) diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml index da99dbb2f5..0795b556f9 100644 --- a/packages/storage/storage-sqlite/README.i18n.yaml +++ b/packages/storage/storage-sqlite/README.i18n.yaml @@ -31,7 +31,7 @@ zh: 187d5d61980c8fe7 /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/design-concept: en: 54ee8d9d7746e82d - zh: 2cc9f29ebcc29adc + zh: 510c8bca88d2495c /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/open-sequence: en: 50d85e8d8e8b5bc1 zh: 2a7067bd7fb47b35 diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md index fb15673c73..89743bbd9e 100644 --- a/packages/storage/storage-sqlite/README.zh.md +++ b/packages/storage/storage-sqlite/README.zh.md @@ -69,7 +69,7 @@ kind: "package-reference" ### 设计理念 - **每行一份文档。** 每个单元表都变成一张物理 STRICT 表 `u__
(key TEXT PRIMARY KEY, value TEXT)`,其 `value` 列保存记录的 JSON 文本;全局单例存放在共享的 `unit_globals` 表中。一个键的更新恰好触碰一行——这就是把高频变更领域路由到这里的原因。 -- **单语句原子性。** 每个写入原语都是一条预处理语句,因此 SQLite 的逐语句原子性无需显式事务即可满足 KV 约定;写入顺序仍由调用方负责(领域层的写入链)。 +- **语句原子性。** 每个单记录写入都是一条预处理语句;批量删除在一个显式事务中执行其删除,因此整批一起提交或回滚。写入顺序仍由调用方负责(领域层的写入链)。 - **名称在 DDL 之前校验。** 单元名与表名在进入 DDL 之前必须匹配 `UNIT_NAME_RE`,因此任何外部输入都不会被插值进 SQL 标识符。 - **版本明确报错。** 物理布局版本存放在 `PRAGMA user_version`(全新数据库最后盖戳);单元格式版本存放在 `units` 表中。任何其他已标记值都会被拒绝——不做迁移。 diff --git a/packages/storage/storage-sqlite/src/unit.ts b/packages/storage/storage-sqlite/src/unit.ts index e8c01dc632..f43988cdf4 100644 --- a/packages/storage/storage-sqlite/src/unit.ts +++ b/packages/storage/storage-sqlite/src/unit.ts @@ -1,9 +1,10 @@ /** * One opened SQLite KV unit: prepared per-table statements over the * `u__
` record tables plus this unit's row in the shared - * `unit_globals` table. Each primitive is a single statement, so atomicity - * comes from SQLite itself — no explicit transactions, and no write queue - * (write ordering is the caller's responsibility per the KV contract). + * `unit_globals` table. Each single-record primitive is one statement, so + * SQLite's per-statement atomicity applies; a batch delete runs its deletes in + * one explicit transaction. There is no write queue — write ordering is the + * caller's responsibility per the KV contract. * @module @deepseek-ai/dsh-storage-sqlite/unit */ @@ -36,7 +37,7 @@ export class SqliteKvUnit implements KvUnit { * @param onClose - Backend callback releasing this unit's open-name slot. */ constructor( - db: DatabaseSync, + private readonly db: DatabaseSync, private readonly descriptor: KvUnitDescriptor, private readonly onClose: () => void, ) { @@ -108,6 +109,27 @@ export class SqliteKvUnit implements KvUnit { }) } + deleteRecords(table: string, keys: readonly string[]): Promise { + return this.settle(() => { + const remove = this.statementsFor(table).remove + this.db.exec('BEGIN IMMEDIATE') + try { + for (const key of new Set(keys)) remove.run(key) + this.db.exec('COMMIT') + } catch (error: unknown) { + try { + this.db.exec('ROLLBACK') + } catch (rollbackError: unknown) { + // A failed rollback leaves the connection's state to its own next + // statement; both failures are reported, neither is discarded. + /* v8 ignore next -- ROLLBACK fails only on an unusable connection; the next statement reports it. */ + throw new AggregateError([error, rollbackError], `kv unit '${this.descriptor.name}' batch delete failed and could not roll back`) + } + throw error + } + }) + } + setGlobal(value: unknown): Promise { return this.settle(() => { if (this.globalUpsert === undefined) { diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index c5f5dd4ee3..795d832d05 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -50,6 +50,23 @@ describe('sqlite backend specifics', () => { await backend.close() }) + it('rolls back a batch delete whose transaction fails', async () => { + const path = await freshDbPath() + const backend = backendAt(path) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('records', 'a', { n: 1 }) + await unit.putRecord('records', 'b', { n: 2 }) + const db = new DatabaseSync(path) + db.exec('CREATE TRIGGER block_b BEFORE DELETE ON "u_specimen_records"' + + " WHEN old.key = 'b' BEGIN SELECT RAISE(ABORT, 'blocked'); END") + await expect(unit.deleteRecords('records', ['a', 'b'])).rejects.toThrow(/blocked/) + db.exec('DROP TRIGGER block_b') + db.close() + // The transaction rolled back, so the key deleted before the failing row is back. + expect((await unit.loadAll()).tables['records']).toEqual({ a: { n: 1 }, b: { n: 2 } }) + await backend.close() + }) + it('materializes STRICT record tables and stamps the schema version', async () => { const path = await freshDbPath() const backend = backendAt(path) diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index 9c92a42b04..a483b4dc59 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -109,6 +109,22 @@ export interface KvUnit { */ deleteRecord(table: string, key: string): Promise + /** + * Delete several records durably as one batch. Idempotent: missing keys are + * no-ops. Resolves once every listed record is absent from the readable + * state and durable; a rejection leaves the batch unapplied, so no listed + * record was removed. A backend whose medium can apply a whole batch in one + * operation (the `single` document, a row store) must do that instead of + * repeating {@link deleteRecord} per key. + * @param table - Declared table name. + * @param keys - Record keys; duplicates are harmless. In the `per-record` + * layout every key becomes a path segment and must match `[a-zA-Z0-9_-]+` + * (an unsafe key rejects before any deletion); in the `single` layout keys + * stay opaque. + * @returns resolution after durability. + */ + deleteRecords(table: string, keys: readonly string[]): Promise + /** * Move one record's stored document out of the unit's readable set, * preserving its bytes for inspection instead of deleting them. Backends diff --git a/packages/storage/storage/tests/contract.ts b/packages/storage/storage/tests/contract.ts index d173918dce..4a2c872ac8 100644 --- a/packages/storage/storage/tests/contract.ts +++ b/packages/storage/storage/tests/contract.ts @@ -71,6 +71,24 @@ export function runKvBackendContract(label: string, create: () => Promise { + const harness = await create() + const unit = await harness.backend.kv!.open(DESCRIPTOR) + await unit.putRecord('alpha', 'k1', { n: 1 }) + await unit.putRecord('alpha', 'k2', { n: 2 }) + await unit.putRecord('alpha', 'k3', { n: 3 }) + await unit.putRecord('beta', 'b1', { n: 4 }) + await unit.deleteRecords('alpha', ['k1', 'k3', 'missing', 'k1']) + await unit.deleteRecords('alpha', []) + expect((await unit.loadAll()).tables).toEqual({ alpha: { k2: { n: 2 } }, beta: { b1: { n: 4 } } }) + await harness.backend.close() + + const reopened = await harness.reopen() + const unit2 = await reopened.kv!.open(DESCRIPTOR) + expect((await unit2.loadAll()).tables).toEqual({ alpha: { k2: { n: 2 } }, beta: { b1: { n: 4 } } }) + await reopened.close() + }) + it('rejects a version mismatch on reopen without touching the data', async () => { const harness = await create() const unit = await harness.backend.kv!.open(DESCRIPTOR) @@ -95,6 +113,7 @@ export function runKvBackendContract(label: string, create: () => Promise unit.deleteRecords(table, keys), setGlobal: async (value) => { globals += 1 const failAt = Array.isArray(failure.globalAt) ? failure.globalAt : [failure.globalAt] From ca8cc623192fdf217e61f9d7930b26db9dd6b6c5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 12:40:13 +0800 Subject: [PATCH 08/34] fix(web): align subagent switcher rows without a sidebar button Refs #6803 --- .../src/client/SubagentHeaderLineage.module.css | 8 ++++++-- .../ui-subagent/src/client/SubagentHeaderLineage.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 94b87097d1..842c33fd7f 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -285,12 +285,16 @@ grid-row: 2; } +.sidebarButton, +.sidebarSpace { + flex: none; + width: 28px; +} + .sidebarButton { display: inline-flex; - flex: none; align-items: center; justify-content: center; - width: 28px; height: 28px; margin: 4px 0; padding: 6px; diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 6cd38d6d93..48b514bbd3 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -353,7 +353,7 @@ function CatalogRows({ )} )} - {!isCurrent && !external && ( + {isCurrent || external ? : ( - - )} + {isCurrent || external + ? + : ( + + + + )} {isExpanded && ( diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 24ecf8fb28..e18096a6b7 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -1098,6 +1098,8 @@ describe('SubagentHeaderLineage', () => { const current = screen.getByRole('treeitem', { name: /worker/ }) expect(current.getAttribute('aria-current')).toBe('true') expect(within(current).getByText('worker').className).toContain('currentLabel') + expect(within(current).queryByRole('button', { name: '在侧边栏打开 worker' })).toBeNull() + expect(within(screen.getByRole('treeitem', { name: /reviewer/ })).getByRole('button', { name: '在侧边栏打开 reviewer' })).toBeTruthy() fireEvent.click(screen.getByRole('treeitem', { name: /reviewer/ })) expect(input.openChild).toHaveBeenCalledWith({ parentSessionId: PARENT, From 500426d4bd801ae121025c84f489ffaf7d5ec55d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 21 Oct 2026 13:38:20 +0800 Subject: [PATCH 10/34] test(schedule): cover a large ended set and repeated bulk removals --- .../schedule/tests/delete-inactive.perf.ts | 181 ++++++++++++++++++ .../schedule/tests/delete-inactive.spec.ts | 70 ++++++- 2 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 packages/schedule/schedule/tests/delete-inactive.perf.ts diff --git a/packages/schedule/schedule/tests/delete-inactive.perf.ts b/packages/schedule/schedule/tests/delete-inactive.perf.ts new file mode 100644 index 0000000000..3ac989c1f0 --- /dev/null +++ b/packages/schedule/schedule/tests/delete-inactive.perf.ts @@ -0,0 +1,181 @@ +/** + * Threshold-free measurement of one `deleteInactive` call over the default + * whole-unit JSON medium. Seeds the medium by writing the unit file directly + * (seeding through the API would publish the growing file once per row), then + * boots the production plugin and times the removal, the whole-unit publish it + * triggers, and the batch-call count the storage layer sees. + */ + +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import Storage from '@deepseek-ai/dsh-storage' +import type { StorageBackend } from '@deepseek-ai/dsh-storage' +import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' +import { JsonStorageBackend } from '@deepseek-ai/dsh-storage-json' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import SessionStore from '@deepseek-ai/dsh-session' +import ScheduleService from '../src/index.ts' +import { createAfterScheduleRecord, createEveryScheduleRecord, ScheduleId } from '../src/domain.ts' + +interface Args { + ended: number + active: number + mode: 'batch' | 'per-row' +} + +function parseArgs(argv: readonly string[]): Args { + const value = (name: string, fallback: number): number => { + const entry = argv.find(argument => argument.startsWith(`--${name}=`)) + if (entry === undefined) return fallback + const parsed = Number(entry.slice(name.length + 3)) + if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error(`--${name} must be a non-negative integer`) + return parsed + } + const mode = argv.find(argument => argument.startsWith('--mode='))?.slice('--mode='.length) ?? 'batch' + if (mode !== 'batch' && mode !== 'per-row') throw new Error('--mode must be batch or per-row') + return { ended: value('ended', 500_000), active: value('active', 500_000), mode } +} + +/** Serialized schedule unit with the requested ended and active rows. */ +function seedText(ended: number, active: number): string { + const now = Date.now() + const tasks: Record = {} + for (let index = 0; index < ended; index += 1) { + const id = `ended-${index}` + tasks[id] = { + sessionId: `ended-session-${index}`, + record: createAfterScheduleRecord(ScheduleId(id), `Ended ${index}`, 60, now - 5 * 60_000, `Ended ${index}`), + status: 'inactive', + } + } + for (let index = 0; index < active; index += 1) { + const id = `active-${index}` + tasks[id] = { + sessionId: `active-session-${index}`, + record: createEveryScheduleRecord(ScheduleId(id), `Active ${index}`, 3_600, now, `Active ${index}`), + status: 'active', + } + } + return JSON.stringify({ unit: { name: 'schedule', version: 1 }, global: null, tables: { tasks } }) +} + +/** Backend façade counting how many deletions reach the medium. */ +function counted(inner: StorageBackend): { + backend: StorageBackend + batches: () => number + singles: () => number +} { + let batches = 0 + let singles = 0 + const kv = inner.kv + if (kv === undefined) throw new Error('json backend is missing its kv facet') + return { + batches: () => batches, + singles: () => singles, + backend: { + close: () => inner.close(), + kv: { + open: async (descriptor) => { + const unit = await kv.open(descriptor) + const backup = unit.backupRecord?.bind(unit) + return { + loadAll: () => unit.loadAll(), + putRecord: (table, key, value) => unit.putRecord(table, key, value), + deleteRecord: async (table, key) => { + singles += 1 + await unit.deleteRecord(table, key) + }, + deleteRecords: async (table, keys) => { + batches += 1 + await unit.deleteRecords(table, keys) + }, + setGlobal: value => unit.setGlobal(value), + close: () => unit.close(), + ...(backup === undefined ? {} : { backupRecord: backup }), + } + }, + }, + }, + } +} + +async function measure(args: Args) { + const root = await mkdtemp(join(tmpdir(), 'dsh-schedule-clear-perf-')) + try { + const path = join(root, 'schedule.json') + const seedStart = performance.now() + await writeFile(path, seedText(args.ended, args.active)) + const seedMs = performance.now() - seedStart + const seedBytes = (await stat(path)).size + + const json = new JsonStorageBackend(root) + const { backend, batches, singles } = counted(json) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SystemPrompt, {}) + await ctx.plugin(ToolRuntime) + await ctx.plugin(Storage) + ctx.effect(() => ctx.storage.backend.register('fixture', backend)) + const facility = new DomainFacility(ctx, { backend: 'fixture' }) + ctx.effect(() => { + const unmount = ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + return async () => { + await facility.closeAll() + unmount() + } + }) + ctx.provide('sessionController', { + resolveAgent: async () => { throw new Error('perf measurement does not activate Sessions') }, + } as never) + ctx.provide('sessionPersistence', {} as never) + ctx.on('session/flush', async () => {}) + + const openStart = performance.now() + await ctx.plugin(ScheduleService, {}) + const openMs = performance.now() - openStart + const start = performance.now() + const result = args.mode === 'batch' + ? await ctx.schedule.deleteInactive() + : await (async () => { + const table = facility.get('schedule')?.table('tasks') + if (table === undefined) throw new Error('schedule domain is not open') + for (let index = 0; index < args.ended; index += 1) { + await table.delete(ScheduleId(`ended-${index}`)) + } + return { deleted: args.ended } + })() + const deleteMs = performance.now() - start + const afterBytes = (await stat(path)).size + const rss = process.memoryUsage().rss / (1024 * 1024) + await ctx.fiber.dispose() + return { + node: process.version, + platform: `${process.platform}-${process.arch}`, + ended: args.ended, + active: args.active, + mode: args.mode, + seedMs: Math.round(seedMs), + seedBytes, + openMs: Math.round(openMs), + deleteMs: Math.round(deleteMs), + afterBytes, + deleted: result.deleted, + deleteRecordsCalls: batches(), + deleteRecordCalls: singles(), + rssMB: Math.round(rss), + } + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +process.stdout.write(`${JSON.stringify(await measure(parseArgs(process.argv.slice(2))), null, 2)}\n`) diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts index d1222d010d..707a20a9b7 100644 --- a/packages/schedule/schedule/tests/delete-inactive.spec.ts +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -2,7 +2,8 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { MessageId } from '@deepseek-ai/dsh-llm/brand' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { createAfterScheduleRecord, ScheduleId } from '../src/domain.ts' +import { createAfterScheduleRecord, createEveryScheduleRecord, ScheduleId } from '../src/domain.ts' +import type { DomainFacility } from '@deepseek-ai/dsh-storage-domain' import { scheduleDomain, type ScheduleTask } from '../src/storage.ts' import { MemoryMediaPool } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' import { gatedScheduleBackend, harness } from './harness.ts' @@ -98,6 +99,73 @@ it('reports zero and announces nothing when the Host holds no ended task', async expect(await service.catalog()).toHaveLength(1) }) +it('clears a large ended set in one batch and stays idempotent across repeated cleanups', async () => { + const pool = new MemoryMediaPool() + const batches: string[][] = [] + const backend = gatedScheduleBackend(pool, { + deleteRecords: async (table, keys) => { + if (table === 'tasks') batches.push([...keys]) + }, + }) + // Half the rows are ended one-shots and half stay armed, so the scan has to + // separate them and the batch has to leave the active rows untouched. + const endedCount = 5_000 + const activeCount = 5_000 + let facilityRef: DomainFacility | undefined + const test = await harness({ + pool, + backend, + beforeService: async (_ctx, facility) => { + facilityRef = facility + const domain = await facility.open(scheduleDomain) + const tasks = domain.table('tasks') + const now = Date.now() + for (let index = 0; index < endedCount; index += 1) { + const id = `ended-${index}` + await tasks.put(ScheduleId(id), endedRow(SessionId(`ended-session-${index}`), id, `Ended ${index}`)) + } + for (let index = 0; index < activeCount; index += 1) { + const id = `active-${index}` + await tasks.put(ScheduleId(id), { + sessionId: SessionId(`active-session-${index}`), + record: createEveryScheduleRecord(ScheduleId(id), `Active ${index}`, 3_600, now, `Active ${index}`), + status: 'active', + }) + } + await domain.close() + }, + }) + tests.push(test) + const { ctx, service } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + expect(await service.deleteInactive()).toEqual({ deleted: endedCount }) + expect(batches).toHaveLength(1) + expect(batches[0]).toHaveLength(endedCount) + expect(changed).toHaveBeenCalledTimes(1) + expect(await service.catalog()).toHaveLength(activeCount) + expect((await service.catalog()).every(entry => entry.status === 'active')).toBe(true) + + // Repeated cleanups reseed a small ended set through the live domain table, + // so each round exercises the same single-batch path without a growing file. + const reseeded = 100 + const rounds = 24 + const table = facilityRef?.get('schedule')?.table('tasks') + expect(table).toBeDefined() + for (let round = 0; round < rounds; round += 1) { + for (let index = 0; index < reseeded; index += 1) { + const id = `again-${round}-${index}` + await table!.put(ScheduleId(id), endedRow(SessionId(`again-session-${round}-${index}`), id, `Again ${round}-${index}`)) + } + expect(await service.deleteInactive()).toEqual({ deleted: reseeded }) + expect(await service.catalog()).toHaveLength(activeCount) + } + expect(batches).toHaveLength(rounds + 1) + expect(batches.every((keys, index) => keys.length === (index === 0 ? endedCount : reseeded))).toBe(true) + expect(changed).toHaveBeenCalledTimes(rounds + 1) +}) + it('removes nothing and reports the batch failure', async () => { const pool = new MemoryMediaPool() const batches: string[][] = [] From c8d537db438bb453597bf7f7e42554c9583ae743 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 21 Oct 2026 17:12:57 +0800 Subject: [PATCH 11/34] fix(client): complete the Japanese dictionaries after merging master --- .../client/ui-conversation/src/client/locales.ts | 1 + .../ui-schedule/src/client/task-manager-locales.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f908a94233..36edb38e56 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -772,6 +772,7 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'quote.remove': '引用を削除', 'shortcut.newline': '改行', 'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用', 'shortcut.slash': 'コマンドメニューを開く', diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index 642ddda69a..3cf581f238 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -440,8 +440,20 @@ export const ja = { 'delete.cancel': 'キャンセル', 'delete.close': '削除確認を閉じる', 'delete.pending': '削除中…', + 'clear.action': '終了済みをクリア', + 'clear.title': '終了した自動化タスクをクリアしますか?', + 'clear.description': '各タスクは保存済みの実行履歴とともに削除されます。元のセッションとそのメッセージは残ります。', + 'clear.count.one': '終了したタスク {count} 件', + 'clear.count.other': '終了したタスク {count} 件', + 'clear.confirm': 'クリアする', + 'clear.pending': 'クリア中…', + 'clear.close': 'クリア確認を閉じる', 'toast.deleted': 'タスクを削除しました。', 'toast.deleteFailed': 'タスクを削除できませんでした。', + 'toast.cleared.one': '終了したタスクを {count} 件クリアしました。', + 'toast.cleared.other': '終了したタスクを {count} 件クリアしました。', + 'toast.clearedNone': 'クリアする終了済みタスクはありません。', + 'toast.clearFailed': '終了したタスクをすべてクリアできませんでした。', 'card.open': '開く', 'card.openLabel': 'タスクの詳細を開く: {title}', 'card.deleted': '削除済み', From ac465000d78444dd3e8311889f9ae6d3b034bde6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 21 Oct 2026 17:33:43 +0800 Subject: [PATCH 12/34] fix(client): complete the merged locale and snapshot fixtures --- packages/session/message-quotes/src/client/index.ts | 4 ++-- packages/session/message-quotes/src/client/locales.ts | 5 +++++ .../sdk/background-wait-steer/tool-schemas.expected.json | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/session/message-quotes/src/client/index.ts b/packages/session/message-quotes/src/client/index.ts index 09e352267e..b0748db925 100644 --- a/packages/session/message-quotes/src/client/index.ts +++ b/packages/session/message-quotes/src/client/index.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { QuoteSelectionAction } from './QuoteSelection.tsx' import { QuotedUserText } from './QuotedUserText.tsx' import { quoteRule } from './quote-rule.ts' -import { en, NS, zh, type MessageQuotesKey } from './locales.ts' +import { en, ja, NS, zh, type MessageQuotesKey } from './locales.ts' export type {} from './quote-rule.ts' @@ -30,7 +30,7 @@ export const inject = ['slots', 'locale', 'sessions', 'uiConversation'] * @param ctx - Client root context owning every Quote contribution. */ export function apply(ctx: Context): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-quotes: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'message-quotes: dictionaries') ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'message-quotes: UserText rule') ctx.effect(() => ctx.uiConversation.interactions.register({ id: 'quote', diff --git a/packages/session/message-quotes/src/client/locales.ts b/packages/session/message-quotes/src/client/locales.ts index c36e0d3599..a712197cd5 100644 --- a/packages/session/message-quotes/src/client/locales.ts +++ b/packages/session/message-quotes/src/client/locales.ts @@ -15,3 +15,8 @@ export type MessageQuotesKey = keyof typeof zh export const en: Record = { 'quote.add': 'Quote text', } + +/** Japanese dictionary. */ +export const ja: Record = { + 'quote.add': 'テキストを引用', +} diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json index 90228febab..c9fb03068a 100644 --- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json +++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", + "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", "parameters": { "type": "object", "properties": { From 11e1580b829e03a281b0b828570de82ae836299c Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 22:45:28 +0800 Subject: [PATCH 13/34] fix(ui-conversation): keep the active tab bar visible over Trajectory and widen tab targets Co-authored-by: Shigma <33423008+shigma@users.noreply.github.com> --- apps/web/tests/window-drag-coverage.e2e.ts | 4 +++- .../skeleton/ConversationRoot.module.css | 19 +++++++++++++------ .../tests/header-styles.client.spec.ts | 6 ++++-- .../ui-trajectory/src/client/views.module.css | 6 +++++- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/window-drag-coverage.e2e.ts b/apps/web/tests/window-drag-coverage.e2e.ts index cb6df06778..142fcb7431 100644 --- a/apps/web/tests/window-drag-coverage.e2e.ts +++ b/apps/web/tests/window-drag-coverage.e2e.ts @@ -276,7 +276,9 @@ describe('web e2e: macOS window drag coverage', () => { { row: 'conversation header', what: 'the blank run right of the view tabs, on the tab strip row', - at: () => [tabs.x + tabs.width + 8, tabs.y + tabs.height / 2], + // The last tab's press target overhangs the tablist by 10px and the + // More launcher starts 20px past it; probe the run between them. + at: () => [tabs.x + tabs.width + 15, tabs.y + tabs.height / 2], drag: true, }, { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index cf92656162..ae797ac695 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -212,12 +212,19 @@ min-height: 25px; } -/* The 16px text line retains 9px below it. The active bar reaches 1px past - the tab box to sit over the header rule. */ +/* The 16px text line retains 9px below it. The press target extends 10px + above the text into the strip's top margin and 10px to each side, as the + Sidebar tab chips do (ui-dockkit `.tab`); negative margins keep the layout + at the text box. The active bar spans the text and reaches 1px past the tab + box to sit over the header rule. */ .tab { + --dsh-tab-hit-inline: 10px; + position: relative; - min-height: 25px; - padding: 0 0 9px; + box-sizing: border-box; + min-height: 35px; + margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0; + padding: 10px var(--dsh-tab-hit-inline) 9px; border: none; background: transparent; font-size: 13px; @@ -230,9 +237,9 @@ .tab::after { content: ''; position: absolute; - right: 0; + right: var(--dsh-tab-hit-inline); bottom: -1px; - left: 0; + left: var(--dsh-tab-hit-inline); height: 2px; border-radius: 2px; background: transparent; diff --git a/packages/client/ui-conversation/tests/header-styles.client.spec.ts b/packages/client/ui-conversation/tests/header-styles.client.spec.ts index c3abd2bcf4..3c964950d0 100644 --- a/packages/client/ui-conversation/tests/header-styles.client.spec.ts +++ b/packages/client/ui-conversation/tests/header-styles.client.spec.ts @@ -8,8 +8,10 @@ describe('conversation header geometry', () => { it('keeps the single-line header at the shared 76px height', () => { expect(css.match(/\.header\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 76px') expect(css.match(/\.viewTabs\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 25px') - expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 25px') - expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 0 0 9px') + // The tab's 35px press target sits 10px above its 25px row through a negative top margin. + expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 35px') + expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0') + expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 10px var(--dsh-tab-hit-inline) 9px') }) it('uses 32px between view labels without changing the action gap', () => { diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 687f4a4657..7915da5cc1 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,7 +1,11 @@ -/* Full-bleed, fixed-height host for the trajectory ledger. */ +/* Full-bleed, fixed-height host for the trajectory ledger. Isolation keeps the + sticky toolbar's z-index inside this view, so the conversation tab row + (ui-conversation `.tabs`, z-index 1) still paints its active bar over the + view's top edge. */ .root { --dsh-trajectory-toolbar-height: 32px; + isolation: isolate; display: flex; flex-direction: column; overflow: hidden; From d229d1cf758869ce06e24b6babe95a73285482c0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 22:48:12 +0800 Subject: [PATCH 14/34] docs(ui-conversation): state the tab press target comments exactly Co-authored-by: Shigma <33423008+shigma@users.noreply.github.com> --- .../src/client/skeleton/ConversationRoot.module.css | 9 +++++---- .../client/ui-trajectory/src/client/views.module.css | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index ae797ac695..0ce8d1b4b6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -213,10 +213,11 @@ } /* The 16px text line retains 9px below it. The press target extends 10px - above the text into the strip's top margin and 10px to each side, as the - Sidebar tab chips do (ui-dockkit `.tab`); negative margins keep the layout - at the text box. The active bar spans the text and reaches 1px past the tab - box to sit over the header rule. */ + above the text into the strip's top margin and 10px to each side, matching + the inline padding of the Sidebar tab chips (ui-dockkit `.tab`); negative + margins keep the layout box at its 25px row and text width. The active bar + spans the text and reaches 1px past the tab box to sit over the header + rule. */ .tab { --dsh-tab-hit-inline: 10px; diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 7915da5cc1..c7c963a5b2 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,7 +1,7 @@ /* Full-bleed, fixed-height host for the trajectory ledger. Isolation keeps the sticky toolbar's z-index inside this view, so the conversation tab row - (ui-conversation `.tabs`, z-index 1) still paints its active bar over the - view's top edge. */ + stacked above the view still paints its active bar over the view's top + edge. */ .root { --dsh-trajectory-toolbar-height: 32px; From d343cd216236d74c40f5113ef2328f1ee5f022b2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 22:51:47 +0800 Subject: [PATCH 15/34] fix(ui-primitives): keep header menus next to their trigger on macOS desktop The desktop top clearance pushed every portalled overlay at least 68px from the viewport top, so menus opened from Conversation header controls inside that strip landed about 30px below their trigger. Cap the clearance at the position directly below a visible anchor. Refs #6886 --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../client/ui-primitives/src/HoverCard.tsx | 4 +- packages/client/ui-primitives/src/Menu.tsx | 2 +- .../ui-primitives/src/overlay-top-margin.ts | 11 ++-- .../ui-primitives/src/useAnchoredPosition.ts | 2 +- .../tests/hover-card.client.spec.tsx | 4 ++ .../tests/menu-top-clearance.client.spec.tsx | 50 +++++++++++++++++++ .../use-anchored-position.client.spec.tsx | 16 ++++-- 10 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9a13d1d0a6..6fcc3211c3 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: cb874a0ca53a6747 zh: 31179193dd1d6e12 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: fff68b60f4abdc7e - zh: 5cf5952ec78f7f91 + en: cac48e14b2746247 + zh: b768225ef33a44cb /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 629f57d60fe5cce1 zh: 5263447a14c21d61 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index ecd3fd4e29..1756a70c39 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -88,7 +88,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers; an overlay opened below an anchor inside that top strip stays next to its anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3de121ab2c..d5bd8a1e58 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -88,7 +88,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;在该顶部区域内的锚点下方打开的浮层紧贴其锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 157cf35f06..e009ac98cb 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -178,7 +178,7 @@ export function HoverCard({ if (variant === 'preview') { const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r const width = Math.max(0, Math.min(bounds.width - PREVIEW_INSET * 2, window.innerWidth - VIEWPORT_MARGIN * 2)) - const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const topMargin = overlayTopMargin(VIEWPORT_MARGIN, r, ANCHOR_GAP) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) @@ -195,7 +195,7 @@ export function HoverCard({ const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) const maxWidth = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) const width = fitContent ? Math.min(cardRef.current?.offsetWidth ?? maxWidth, maxWidth) : maxWidth - const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const topMargin = overlayTopMargin(VIEWPORT_MARGIN, r, ANCHOR_GAP) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 0f1225fc42..ded3d67afd 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -279,7 +279,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI } if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) - if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN)), vh - lh - MARGIN) + if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, 4)), vh - lh - MARGIN) setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y }) } diff --git a/packages/client/ui-primitives/src/overlay-top-margin.ts b/packages/client/ui-primitives/src/overlay-top-margin.ts index 02ed679280..bb12ff729c 100644 --- a/packages/client/ui-primitives/src/overlay-top-margin.ts +++ b/packages/client/ui-primitives/src/overlay-top-margin.ts @@ -3,11 +3,16 @@ /** * Resolve the top margin an overlay keeps from the viewport edge. * @param min - the overlay's own viewport margin in px, used as the floor. - * @returns the larger of `min` and the frame clearance plus 20px; fullscreen keeps only the 20px gap. + * @param anchor - the overlay's anchor rect in viewport coordinates. While the anchor's top edge is inside the + * viewport, the margin never exceeds `anchor.bottom + gap`, so an overlay opened from a control inside the top strip + * stays next to it. + * @param gap - the distance the overlay keeps below `anchor`. + * @returns the frame clearance plus 20px (20px in fullscreen), capped by a visible `anchor`, but never below `min`. */ -export function overlayTopMargin(min: number): number { +export function overlayTopMargin(min: number, anchor?: { readonly top: number; readonly bottom: number }, gap = 0): number { const root = document.documentElement const clearance = Number.parseFloat(getComputedStyle(root).getPropertyValue('--dsh-frame-top-clearance')) if (Number.isNaN(clearance)) return min - return Math.max(min, (root.hasAttribute('data-fullscreen') ? 0 : clearance) + 20) + const cap = anchor !== undefined && anchor.top >= 0 ? anchor.bottom + gap : Number.POSITIVE_INFINITY + return Math.max(min, Math.min((root.hasAttribute('data-fullscreen') ? 0 : clearance) + 20, cap)) } diff --git a/packages/client/ui-primitives/src/useAnchoredPosition.ts b/packages/client/ui-primitives/src/useAnchoredPosition.ts index f87e4d0697..11256ae00a 100644 --- a/packages/client/ui-primitives/src/useAnchoredPosition.ts +++ b/packages/client/ui-primitives/src/useAnchoredPosition.ts @@ -56,7 +56,7 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper let left = align === 'end' ? rect.right - width : rect.left let top = side === 'top' ? rect.top - gap - height : rect.bottom + gap if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin) - if (height > 0) top = Math.min(Math.max(top, overlayTopMargin(margin)), window.innerHeight - height - margin) + if (height > 0) top = Math.min(Math.max(top, overlayTopMargin(margin, rect, gap)), window.innerHeight - height - margin) /* v8 ignore stop */ setPosition({ left, top }) } diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 0f8a67c08b..2e0ba4e709 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -258,6 +258,10 @@ describe('HoverCard', () => { fireEvent(window, new Event('resize')) expect(card.style.top).toBe('120px') expect(card.style.maxHeight).toBe('322px') + // An anchor inside the top strip keeps its preview directly below it. + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 10, width: 600, height: 32 }) + fireEvent(window, new Event('resize')) + expect(card.style.top).toBe('50px') } finally { if (prior === '') document.documentElement.style.removeProperty('--dsh-frame-top-clearance') else document.documentElement.style.setProperty('--dsh-frame-top-clearance', prior, priority) diff --git a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx new file mode 100644 index 0000000000..08bc55e623 --- /dev/null +++ b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom +/** Portal menus keep the desktop frame's top clearance without leaving a gap below an anchor inside it. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { Menu } from '../src/Menu.tsx' + +const root = document.documentElement +let prior = '' + +beforeEach(() => { + prior = root.style.getPropertyValue('--dsh-frame-top-clearance') + root.style.setProperty('--dsh-frame-top-clearance', '48px') +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + if (prior === '') root.style.removeProperty('--dsh-frame-top-clearance') + else root.style.setProperty('--dsh-frame-top-clearance', prior) +}) + +/** + * Open an 80px-tall portal menu from an anchor with the stated rect. + * @param anchor - the anchor rect in viewport coordinates. + * @param side - which anchor edge the menu opens from. + * @returns the render result. + */ +function openMenu(anchor: DOMRect, side: 'top' | 'bottom') { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(anchor) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(120) + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(80) + return render(Open} items={[{ id: 'one', label: 'One' }]} onClose={vi.fn()} />) +} + +describe('Menu top clearance', () => { + it('opens directly below an anchor that sits inside the top strip', () => { + const view = openMenu(new DOMRect(300, 14, 18, 22), 'bottom') + expect(view.getByRole('menu').style.top).toBe('40px') + }) + + it('keeps a menu that does not fit above its anchor below the top strip', () => { + const view = openMenu(new DOMRect(300, 100, 18, 22), 'top') + expect(view.getByRole('menu').style.top).toBe('68px') + }) + + it('keeps the top strip clear when the anchor has scrolled above the viewport', () => { + const view = openMenu(new DOMRect(300, -30, 18, 22), 'bottom') + expect(view.getByRole('menu').style.top).toBe('68px') + }) +}) diff --git a/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx b/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx index a3083af168..5dfb8d1e7e 100644 --- a/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx +++ b/packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx @@ -51,10 +51,12 @@ function stubResizeObserver(): Recorded[] { * @param props - whether the panel is open. * @returns the anchor and, while open, the panel carrying the position. */ -function Host({ open, align }: { open: boolean; align?: 'start' | 'end' }) { +function Host({ open, align, side }: { open: boolean; align?: 'start' | 'end'; side?: 'top' | 'bottom' }) { const anchorRef = useRef(null) const panelRef = useRef(null) - const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12, ...align === undefined ? {} : { align } }) + const position = useAnchoredPosition({ + open, anchorRef, panelRef, gap: 4, margin: 12, ...align === undefined ? {} : { align }, ...side === undefined ? {} : { side }, + }) return ( <> @@ -64,7 +66,11 @@ function Host({ open, align }: { open: boolean; align?: 'start' | 'end' }) { } describe('useAnchoredPosition', () => { - it.each([{ fullscreen: false, top: 60 }, { fullscreen: true, top: 20 }])('keeps an anchored panel at $top with fullscreen=$fullscreen', ({ fullscreen, top }) => { + it.each([ + { name: 'a panel that does not fit above its anchor', side: 'top', anchor: new DOMRect(100, 100, 200, 10), fullscreen: false, top: 60 }, + { name: 'a panel that does not fit above its anchor', side: 'top', anchor: new DOMRect(100, 100, 200, 10), fullscreen: true, top: 20 }, + { name: 'a panel below an anchor inside the top strip', side: 'bottom', anchor: new DOMRect(100, 0, 200, 10), fullscreen: false, top: 14 }, + ] as const)('keeps $name at $top with fullscreen=$fullscreen', ({ side, anchor, fullscreen, top }) => { const root = document.documentElement const previous = root.getAttribute('style') const priorFullscreen = root.getAttribute('data-fullscreen') @@ -77,10 +83,10 @@ describe('useAnchoredPosition', () => { }) root.style.setProperty('--dsh-frame-top-clearance', '40px') root.toggleAttribute('data-fullscreen', fullscreen) - vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 0, 200, 10)) + vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(anchor) vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(400) vi.stubGlobal('innerHeight', 600) - const { getByTestId } = render() + const { getByTestId } = render() expect(getByTestId('panel').style.top).toBe(`${top}px`) }) From 4f7ebe8d3423e0e404acc816a901e62b48602622 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 23:15:02 +0800 Subject: [PATCH 16/34] test(web): cover conversation view tab hit testing in the browser Co-authored-by: Shigma <33423008+shigma@users.noreply.github.com> --- apps/web/tests/conversation-view-tabs.e2e.ts | 110 +++++++++++++++++++ apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 3 files changed, 112 insertions(+) create mode 100644 apps/web/tests/conversation-view-tabs.e2e.ts diff --git a/apps/web/tests/conversation-view-tabs.e2e.ts b/apps/web/tests/conversation-view-tabs.e2e.ts new file mode 100644 index 0000000000..3995e03b45 --- /dev/null +++ b/apps/web/tests/conversation-view-tabs.e2e.ts @@ -0,0 +1,110 @@ +// Browser hit testing for the conversation view tabs: each tab accepts presses +// beyond its label, and the selected tab's bar stays above the Trajectory +// view's sticky toolbar where the two meet. +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = createChatScrollFixture({ + markerPrefix: 'VIEW_TABS', + title: 'VIEW_TABS short session', + turns: 4, +}) +const SEED_ID = 'conversation-view-tabs-web-e2e' + +/** Label and button rectangles of one tab, and which element owns each probe point. */ +interface TabProbe { + readonly label: { left: number; top: number; right: number; bottom: number } + readonly button: { left: number; top: number; right: number; bottom: number } + readonly ownsAboveLabel: boolean + readonly ownsBesideLabel: boolean + readonly ownsBarOverhang: boolean +} + +/** + * Probe one tab: the run above and beside its label, and the bar's lowest + * half pixel, below the button box. + * @param page - the page under test. + * @param name - the tab's accessible name. + * @returns the tab's rectangles and probe owners. + */ +async function probeTab(page: Page, name: string): Promise { + return page.getByRole('tab', { name, exact: true }).evaluate((tab) => { + const range = document.createRange() + range.selectNodeContents(tab) + const label = range.getBoundingClientRect() + const button = tab.getBoundingClientRect() + const owns = (x: number, y: number): boolean => document.elementFromPoint(x, y)?.closest('[role="tab"]') === tab + const centerX = (label.left + label.right) / 2 + return { + label: { left: label.left, top: label.top, right: label.right, bottom: label.bottom }, + button: { left: button.left, top: button.top, right: button.right, bottom: button.bottom }, + ownsAboveLabel: owns(centerX, label.top - 8), + ownsBesideLabel: owns(label.left - 8, (label.top + label.bottom) / 2), + ownsBarOverhang: owns(centerX, button.bottom + 0.5), + } + }) +} + +async function openSeededSession(page: Page): Promise { + const searchButton = page.getByRole('button', { name: 'Search sessions' }) + if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click() + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(FIXTURE.markers.user(1)) + const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + const deadline = Date.now() + 60_000 + while (await results.count() !== 1) { + if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results') + await page.waitForTimeout(200) + } + await results.click() +} + +describe('web e2e: conversation view tabs', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, FIXTURE.log, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser, 900) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await openSeededSession(page) + await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last() + .waitFor({ timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('takes presses above and beside the label', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-view-tabs-target')) + const chat = await probeTab(page, 'Chat') + expect(chat.button.top).toBeLessThan(chat.label.top - 8) + expect(chat.button.left).toBeLessThan(chat.label.left - 8) + expect(chat.ownsAboveLabel).toBe(true) + expect(chat.ownsBesideLabel).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('keeps the selected bar above the Trajectory toolbar', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-view-tabs-trajectory')) + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + const toolbar = page.getByRole('toolbar', { name: 'Trajectory toolbar' }) + await toolbar.waitFor({ timeout: 30_000 }) + const trajectory = await probeTab(page, 'Trajectory') + // The toolbar must start within the bar's overhang, or the probe cannot fail. + expect((await toolbar.boundingBox())!.y).toBeLessThanOrEqual(trajectory.button.bottom + 1) + expect(trajectory.ownsBarOverhang).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1647c9c331..1e4c7e8dca 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -219,6 +219,7 @@ "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", "tests/composer-tab-geometry.e2e.ts", + "tests/conversation-view-tabs.e2e.ts", "tests/window-drag-coverage.e2e.ts", "tests/complex-history.perf.ts", "tests/pwsh-terminal.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index d3feb855de..5b38fda293 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -206,6 +206,7 @@ "apps/web/tests/chat-long-interactions.e2e.ts", "apps/web/tests/chat-continuous-conversation.e2e.ts", "apps/web/tests/composer-tab-geometry.e2e.ts", + "apps/web/tests/conversation-view-tabs.e2e.ts", "apps/web/tests/composer-shortcuts.e2e.ts", "apps/web/tests/window-drag-coverage.e2e.ts", "apps/web/tests/complex-history.perf.ts", From 36f0bae72e94d49a0fb88d592ca2df3916627343 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 23:45:37 +0800 Subject: [PATCH 17/34] fix(ui-primitives): document the top-strip cap for hover cards Name the Menu anchor gap once, describe the HoverCard exception in the README and JSDoc, and cover a top-side menu from an anchor in the strip. --- packages/client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/HoverCard.tsx | 3 ++- packages/client/ui-primitives/src/Menu.tsx | 9 +++++---- .../ui-primitives/tests/hover-card.client.spec.tsx | 2 +- .../tests/menu-top-clearance.client.spec.tsx | 5 +++++ 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6fcc3211c3..a9afc5d10e 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: cb874a0ca53a6747 zh: 31179193dd1d6e12 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: cac48e14b2746247 - zh: b768225ef33a44cb + en: 726d68a7e995fddb + zh: 81812e868f7610b4 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 629f57d60fe5cce1 zh: 5263447a14c21d61 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 1756a70c39..7d179ad458 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -88,7 +88,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers; an overlay opened below an anchor inside that top strip stays next to its anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers; while an anchor is inside that top strip, its overlay, including a `HoverCard`, is never pushed lower than the anchor's bottom edge plus the overlay's gap. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance except below an anchor inside the top strip, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index d5bd8a1e58..86e120d4b5 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -88,7 +88,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;在该顶部区域内的锚点下方打开的浮层紧贴其锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距;锚点位于该顶部区域内时,其浮层(包括 `HoverCard`)不会被推到低于锚点底边加浮层间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index e009ac98cb..f11c6bad30 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -33,7 +33,8 @@ interface CardPosition { * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.variant - compact card beside the anchor, or a preview above/below it - * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. + * with 24px side insets, a 420px height cap, the frame-top clearance (capped below an anchor inside the top strip), + * and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * Its hover region connects the anchor and preview across the visual gap. diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ded3d67afd..8bf40e9ad7 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -255,6 +255,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI } if (r === null) return const MARGIN = 12 + const GAP = 4 const vw = window.innerWidth const vh = window.innerHeight const listEl = listRef.current @@ -268,18 +269,18 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI let x: number let y: number if (side === 'right') { - x = r.right + 4 + x = r.right + GAP y = r.top } else if (align === 'start') { x = r.left - y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP } else { x = r.right - lw - y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4 + y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP } if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN) - if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, 4)), vh - lh - MARGIN) + if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN) setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y }) } diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 2e0ba4e709..ddc6718159 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -243,7 +243,7 @@ describe('HoverCard', () => { expect(vi.getTimerCount()).toBe(0) }) - it('keeps previews below the desktop frame clearance', () => { + it('keeps previews clear of the frame top strip except below an anchor inside it', () => { const prior = document.documentElement.style.getPropertyValue('--dsh-frame-top-clearance') const priority = document.documentElement.style.getPropertyPriority('--dsh-frame-top-clearance') document.documentElement.style.setProperty('--dsh-frame-top-clearance', '100px') diff --git a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx index 08bc55e623..6e1700e7f9 100644 --- a/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx +++ b/packages/client/ui-primitives/tests/menu-top-clearance.client.spec.tsx @@ -43,6 +43,11 @@ describe('Menu top clearance', () => { expect(view.getByRole('menu').style.top).toBe('68px') }) + it('places a menu that does not fit above an anchor inside the top strip directly below it', () => { + const view = openMenu(new DOMRect(300, 14, 18, 22), 'top') + expect(view.getByRole('menu').style.top).toBe('40px') + }) + it('keeps the top strip clear when the anchor has scrolled above the viewport', () => { const view = openMenu(new DOMRect(300, -30, 18, 22), 'bottom') expect(view.getByRole('menu').style.top).toBe('68px') From 4b1d4c814e8b1b8a2e452513c62d1f4e334c347d Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 22 Oct 2026 14:32:35 +0800 Subject: [PATCH 18/34] fix(web): speed up lazy bundle loads in the Web preview The Web preview Worker converted multi-megabyte combo scripts and source maps with the buffer package's JavaScript UTF-8 codec, and the page re-encoded each source map to Base64 in JavaScript before running the bundle. Use TextEncoder/TextDecoder for whole-buffer UTF-8 conversions and FileReader for the inline source-map data URL. --- .../webworker-runtime/src/client/client.ts | 22 ++++---- .../builtin_modules/implemented/buffer.ts | 32 +++++++++++ .../tests/client/load-bundle.spec.ts | 2 +- .../tests/node/buffer.spec.ts | 53 +++++++++++++++++++ 4 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 packages/experimental/webworker-runtime/tests/node/buffer.spec.ts diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 1ac598759d..1fc91eea98 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -109,16 +109,18 @@ const REFUSAL_STATUS = 500 const encoder = new TextEncoder() const SOURCE_MAP_TRAILER = /\/\/# sourceMappingURL=([^\r\n]+)\s*$/ -const BASE64_CHUNK_BYTES = 32 * 1024 -/** Encode UTF-8 text for an inline data URL without a call-stack-sized spread. */ -function base64(value: string): string { - const bytes = encoder.encode(value) - let binary = '' - for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_BYTES) { - binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_BYTES)) - } - return btoa(binary) +/** + * Encode source-map bytes as a Base64 data URL with the browser's native encoder. + * DevTools cannot load a `blob:` source map, so the map must be inline. + */ +function sourceMapDataUrl(bytes: ArrayBuffer): Promise { + const reader = new FileReader() + return new Promise((resolve, reject) => { + reader.addEventListener('load', () => { resolve(reader.result as string) }, { once: true }) + reader.addEventListener('error', () => { reject(reader.error ?? new Error('source map encoding failed')) }, { once: true }) + reader.readAsDataURL(new Blob([bytes], { type: 'application/json;charset=utf-8' })) + }) } /** Replace a tunnel-only map reference with a self-contained Base64 data URL. */ @@ -128,7 +130,7 @@ async function localizeSourceMap(source: string, bundleUrl: string, fetch: Tunne try { const response = await fetch(new URL(match[1], new URL(bundleUrl, globalThis.location.origin))) if (!response.ok) return source.replace(SOURCE_MAP_TRAILER, '') - const dataUrl = `data:application/json;charset=utf-8;base64,${base64(await response.text())}` + const dataUrl = await sourceMapDataUrl(await response.arrayBuffer()) return source.replace(SOURCE_MAP_TRAILER, `//# sourceMappingURL=${dataUrl}`) } catch { // A source map is diagnostic-only; its transport failure must not prevent diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts index 931a582252..6ba298029e 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts @@ -2,9 +2,41 @@ * `node:buffer` for the worker, backed by the `buffer` npm package (feross), and * the matching `globalThis.Buffer` install. Node code treats Buffer as ambient, * so the global must exist before any host module evaluates. + * + * Whole-buffer UTF-8 conversions, `Buffer.from(string)` and `buf.toString()`, + * use the browser's `TextEncoder` and `TextDecoder`: they apply Node's U+FFFD + * replacement rules and run several times faster than the package's JavaScript + * codec on multi-megabyte bundles and source maps. Other encodings and ranged + * `toString` calls keep the package's implementation. */ import { Buffer, kMaxLength } from 'buffer' +const encoder = new TextEncoder() +// Node keeps a leading byte order mark in the decoded string. +const decoder = new TextDecoder('utf-8', { ignoreBOM: true }) +const UTF8 = /^utf-?8$/i +const packageFrom = Buffer.from.bind(Buffer) +const bufferPrototype = Buffer.prototype as Buffer +// oxlint-disable-next-line typescript/unbound-method -- Reflect.apply supplies the Buffer receiver. +const packageToString = bufferPrototype.toString + +Buffer.from = function from(value: unknown, encodingOrOffset?: unknown, length?: unknown): Buffer { + if (typeof value === 'string' && (encodingOrOffset === undefined || (typeof encodingOrOffset === 'string' && UTF8.test(encodingOrOffset)))) { + const bytes = encoder.encode(value) + return packageFrom(bytes.buffer, bytes.byteOffset, bytes.byteLength) + } + return Reflect.apply(packageFrom, Buffer, [value, encodingOrOffset, length]) as Buffer +} as typeof Buffer.from + +bufferPrototype.toString = function toString(this: Buffer, encoding?: BufferEncoding, start?: number, end?: number): string { + if (start === undefined && end === undefined && (encoding === undefined || UTF8.test(encoding))) { + return decoder.decode(this) + } + return Reflect.apply(packageToString, this, [encoding, start, end]) +} +// oxlint-disable-next-line typescript/unbound-method -- Installed as a Buffer method; callers supply the receiver. +Reflect.set(bufferPrototype, 'toLocaleString', bufferPrototype.toString) + Object.defineProperty(globalThis, 'Buffer', { value: Buffer, writable: true, configurable: true }) export { Buffer, kMaxLength } diff --git a/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts b/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts index bd0ebf2f06..bbd222c435 100644 --- a/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts +++ b/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts @@ -68,7 +68,7 @@ it('loads a combo map through the tunnel and embeds it in the blob script', asyn }) await vi.waitFor(() => { expect(sent).toHaveLength(2) }) expect(sent[1]?.url).toBe(`http://localhost:3000/plugins/${mapUrl}`) - const map = '{"version":3,"sections":[]}' + const map = '{"version":3,"names":["中文"],"sections":[]}' deliver({ t: 'res', id: 2, diff --git a/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts b/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts new file mode 100644 index 0000000000..7dc59916a6 --- /dev/null +++ b/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts @@ -0,0 +1,53 @@ +/** + * Differential check of the worker's `node:buffer` UTF-8 conversions against Node. + */ +import { Buffer as NodeBuffer } from 'node:buffer' +import { afterAll, expect, it } from 'vitest' + +const ambient = globalThis.Buffer +const { Buffer: WorkerBuffer } = await import('@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/buffer.ts') +afterAll(() => { + Object.defineProperty(globalThis, 'Buffer', { value: ambient, writable: true, configurable: true }) +}) + +const STRINGS = ['', 'a', '中文字符串', '\uFEFFbom', 'lone \uD800 high', 'lone \uDC00 low', '😀 pair', 'x'.repeat(100_000)] +const BYTES = [ + [0xEF, 0xBB, 0xBF, 0x61], + [0xC3], + [0xE4, 0xB8], + [0xF0, 0x9F, 0x98], + [0xED, 0xA0, 0x80], + [0xC0, 0x80], + [0xFF, 0xFE, 0x61], + [0x61, 0xF4, 0x90, 0x80, 0x80, 0x62], +] +// Node matches encoding names case-insensitively; its type lists lower-case names only. +const UTF8_NAMES = [undefined, 'utf8', 'utf-8', 'UTF-8'].map(name => name as BufferEncoding | undefined) + +it('encodes UTF-8 strings to the bytes Node produces', () => { + for (const value of STRINGS) { + for (const encoding of UTF8_NAMES) { + expect(WorkerBuffer.isBuffer(WorkerBuffer.from(value, encoding))).toBe(true) + expect([...WorkerBuffer.from(value, encoding)]).toEqual([...NodeBuffer.from(value, encoding)]) + } + } +}) + +it('decodes UTF-8 bytes to the string Node produces', () => { + for (const bytes of [...BYTES, ...STRINGS.map(value => [...NodeBuffer.from(value)])]) { + const buffer = WorkerBuffer.from(bytes) + for (const encoding of UTF8_NAMES) { + expect(buffer.toString(encoding)).toBe(NodeBuffer.from(bytes).toString(encoding)) + } + expect(buffer.toLocaleString()).toBe(NodeBuffer.from(bytes).toString()) + } +}) + +it('keeps the package conversions for other encodings and ranges', () => { + expect([...WorkerBuffer.from('ÿa', 'latin1')]).toEqual([0xFF, 0x61]) + expect([...WorkerBuffer.from('ff61', 'hex')]).toEqual([0xFF, 0x61]) + expect(WorkerBuffer.from('中文').toString('hex')).toBe(NodeBuffer.from('中文').toString('hex')) + expect(WorkerBuffer.from('a中b').toString('utf8', 1, 4)).toBe('中') + const view = new Uint8Array([0x61, 0x62, 0x63]).buffer + expect(WorkerBuffer.from(view, 1, 1).toString()).toBe('b') +}) From da7996de07c0ce4cfe1902ae0648cf52ef3c9891 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 14:56:35 +0800 Subject: [PATCH 19/34] fix(schedule): report bulk removal outcomes from the page --- packages/client/ui-schedule/src/client/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index b3d98b6bee..080fed2305 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -192,7 +192,10 @@ export function apply(ctx: ClientContext): void { ...detail, // The page has no creation form: a new reminder starts in a Session. onNewTask: () => { ctx.uiWorkspace.startSession() }, - onClearEnded: clearEnded, + onClearEnded: async () => { + const outcome = await clearEnded() + deleteToast.reportClear(outcome) + }, }), }, TaskManagerPage)) yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ From 39d973322b2650d81472c92a38bd0f9679c36cba Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 22 Oct 2026 15:13:05 +0800 Subject: [PATCH 20/34] test(web): compare the worker Buffer with an unmodified Node reference Vitest resolves the shim's `buffer` import to Node's built-in module, so the differential spec patched and then compared the same implementation. Mock node:buffer with the npm package the Worker bundle uses. Also keep the UTF-8 encoding check symmetric, call the package methods directly, and document that the patch applies to every Buffer user in the Worker. --- .../builtin_modules/implemented/buffer.ts | 30 +++++++++++-------- .../tests/node/buffer.spec.ts | 15 ++++++++-- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts index 6ba298029e..64aefd9cb5 100644 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts @@ -3,11 +3,12 @@ * the matching `globalThis.Buffer` install. Node code treats Buffer as ambient, * so the global must exist before any host module evaluates. * - * Whole-buffer UTF-8 conversions, `Buffer.from(string)` and `buf.toString()`, - * use the browser's `TextEncoder` and `TextDecoder`: they apply Node's U+FFFD - * replacement rules and run several times faster than the package's JavaScript - * codec on multi-megabyte bundles and source maps. Other encodings and ranged - * `toString` calls keep the package's implementation. + * Loading this module replaces `Buffer.from`, `Buffer.prototype.toString`, and + * `toLocaleString` on the package's class, so every Buffer user in the Worker, + * including bundled third-party code, gets the same conversions. Whole-buffer + * UTF-8 conversions use `TextEncoder` and `TextDecoder` with Node's U+FFFD + * replacement and BOM retention; other encodings and ranged `toString` calls + * keep the package's implementation. */ import { Buffer, kMaxLength } from 'buffer' @@ -15,24 +16,27 @@ const encoder = new TextEncoder() // Node keeps a leading byte order mark in the decoded string. const decoder = new TextDecoder('utf-8', { ignoreBOM: true }) const UTF8 = /^utf-?8$/i -const packageFrom = Buffer.from.bind(Buffer) +const packageFrom = Buffer.from.bind(Buffer) as (value: unknown, encodingOrOffset?: unknown, length?: unknown) => Buffer const bufferPrototype = Buffer.prototype as Buffer -// oxlint-disable-next-line typescript/unbound-method -- Reflect.apply supplies the Buffer receiver. +// oxlint-disable-next-line typescript/unbound-method -- Called with .call and a Buffer receiver. const packageToString = bufferPrototype.toString +/** Whether an omitted or string encoding argument names UTF-8. */ +function isUtf8(encoding: unknown): boolean { + return encoding === undefined || (typeof encoding === 'string' && UTF8.test(encoding)) +} + Buffer.from = function from(value: unknown, encodingOrOffset?: unknown, length?: unknown): Buffer { - if (typeof value === 'string' && (encodingOrOffset === undefined || (typeof encodingOrOffset === 'string' && UTF8.test(encodingOrOffset)))) { + if (typeof value === 'string' && isUtf8(encodingOrOffset)) { const bytes = encoder.encode(value) return packageFrom(bytes.buffer, bytes.byteOffset, bytes.byteLength) } - return Reflect.apply(packageFrom, Buffer, [value, encodingOrOffset, length]) as Buffer + return packageFrom(value, encodingOrOffset, length) } as typeof Buffer.from bufferPrototype.toString = function toString(this: Buffer, encoding?: BufferEncoding, start?: number, end?: number): string { - if (start === undefined && end === undefined && (encoding === undefined || UTF8.test(encoding))) { - return decoder.decode(this) - } - return Reflect.apply(packageToString, this, [encoding, start, end]) + if (start === undefined && end === undefined && isUtf8(encoding)) return decoder.decode(this) + return packageToString.call(this, encoding, start, end) } // oxlint-disable-next-line typescript/unbound-method -- Installed as a Buffer method; callers supply the receiver. Reflect.set(bufferPrototype, 'toLocaleString', bufferPrototype.toString) diff --git a/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts b/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts index 7dc59916a6..20ee6c9f77 100644 --- a/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/buffer.spec.ts @@ -1,8 +1,14 @@ /** * Differential check of the worker's `node:buffer` UTF-8 conversions against Node. */ -import { Buffer as NodeBuffer } from 'node:buffer' -import { afterAll, expect, it } from 'vitest' +import { createRequire } from 'node:module' +import { afterAll, expect, it, vi } from 'vitest' + +// The worker bundle resolves `buffer` to the npm package, while Vitest maps it to Node's built-in module. +// Mock that module so the shim patches the npm package and Node's own Buffer stays the unmodified reference. +const require = createRequire(import.meta.url) +const NodeBuffer = (require('node:buffer') as typeof import('node:buffer')).Buffer +vi.mock('node:buffer', () => require('buffer/') as object) const ambient = globalThis.Buffer const { Buffer: WorkerBuffer } = await import('@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/buffer.ts') @@ -10,6 +16,11 @@ afterAll(() => { Object.defineProperty(globalThis, 'Buffer', { value: ambient, writable: true, configurable: true }) }) +it('tests the npm package against an unmodified Node reference', () => { + expect(WorkerBuffer).not.toBe(NodeBuffer) + expect(NodeBuffer.from('\uFEFF').toString()).toBe('\uFEFF') +}) + const STRINGS = ['', 'a', '中文字符串', '\uFEFFbom', 'lone \uD800 high', 'lone \uDC00 low', '😀 pair', 'x'.repeat(100_000)] const BYTES = [ [0xEF, 0xBB, 0xBF, 0x61], From 6db50418120068f320580a19a370a3eae6a13ab3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 17:54:21 +0800 Subject: [PATCH 21/34] fix(storage): keep batch deletion tracked and consistent --- docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 2 +- docs/subsystems/storage.zh.md | 2 +- packages/storage/storage-domain/src/domain.ts | 11 +- .../storage-domain/tests/domain.spec.ts | 13 +++ .../storage/storage-json/README.i18n.yaml | 4 +- packages/storage/storage-json/README.md | 2 +- packages/storage/storage-json/README.zh.md | 2 +- .../storage-json/src/per-record-unit.ts | 45 ++++++-- .../tests/per-record-batch.spec.ts | 107 ++++++++++++++++++ packages/storage/storage/src/backend.ts | 9 +- packages/storage/storage/src/error.ts | 1 + 12 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 packages/storage/storage-json/tests/per-record-batch.spec.ts diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 2c1b8df95a..7c01f2b64d 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -9,8 +9,8 @@ en: b11e06e7c0e0fd13 zh: 4585f26ff416cb41 /storage/the-backend-contract: - en: 8b8cc57b66f4ff87 - zh: aecf14a900014fc1 + en: b83300f10cc822f2 + zh: 61835181022a7ba6 /storage/declaring-a-domain: en: 775cce941500f01f zh: 78acbc67f48574cb diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 1fcd252e80..165be01812 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is durably absent, and a rejection leaves the batch unapplied. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is durably absent, and a rejection leaves the batch unapplied; a backend that cannot restore staged records rejects with `unknown-state` and then rejects every later operation on that unit. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index 62afa47835..03d03b08a7 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都持久缺失后才 resolve,拒绝时整批未应用。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都持久缺失后才 resolve,拒绝时整批未应用;无法恢复暂存记录的批量删除会以 `unknown-state` 拒绝,并让该 unit 之后的所有操作都拒绝。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index be8f6b0298..38ac7b7bfe 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -84,8 +84,11 @@ export interface KvTable { * * The batch is one job on the domain's write chain: it runs at its slot, not * at call time, so an earlier queued write of the same domain is visible to - * it and no other write interleaves. Missing keys are ignored; a backend - * failure rejects with the batch unapplied, leaving every record in place. + * it and no other write interleaves. Missing keys are ignored. The in-memory + * records reach the committed state before any `domain/changed` listener + * runs. A backend failure rejects with the batch unapplied, leaving every + * record in place; a backend that cannot restore its staged records rejects + * with `unknown-state` and keeps its unit unusable. * @param keys - Record keys; duplicates are harmless. * @returns the number of keys that existed and were removed. */ @@ -348,8 +351,10 @@ class KvTableImpl implements KvTable { const present = [...new Set(keys)].filter(key => this.records.has(key)) if (present.length === 0) return 0 await this.host.unit.deleteRecords(this.tableName, present) + // Memory reaches the committed batch state before the first listener runs, + // so every `domain/changed` observes the same removed set as the medium. + for (const key of present) this.records.delete(key) for (const key of present) { - this.records.delete(key) this.host.emitChanged({ domain: this.host.domainName, table: this.tableName, diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 9762682eb2..9c99cd0fab 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -295,6 +295,19 @@ describe('KvTable writes', () => { expect(changes).toHaveLength(seen + 2) }) + it('applies the whole batch to memory before announcing it', async () => { + const { ctx, facility } = await harness() + const domain = await facility.open(spec) + const table = domain.table('items') + await table.put('a', { label: 'x', count: 1 }) + await table.put('b', { label: 'y', count: 2 }) + const observed: string[][] = [] + ctx.on('domain/changed', () => { observed.push([...table.keys()].sort()) }) + await table.deleteMany(['a', 'b']) + // Every listener run sees the committed batch state, not a prefix of it. + expect(observed).toEqual([[], []]) + }) + it('emits domain/changed per durable write, in order, with tombstones and global marker', async () => { const { facility, changes } = await harness() const domain = await facility.open(spec) diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index 3c292a3da1..cfff00f5b3 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/README.i18n.yaml @@ -30,8 +30,8 @@ en: b7d222309b3e5438 zh: 807c7314ee341d94 /deepseek-ai-dsh-storage-json/understand-the-implementation/design-concept: - en: 3139c95cf26c40e2 - zh: 215e88a688869cbe + en: d4f84e0e5d2b3770 + zh: 87eca52475ea2fe6 /deepseek-ai-dsh-storage-json/understand-the-implementation/file-formats: en: b3de0a697db4f916 zh: 56b26f7823392bd5 diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 3d8665bb34..55f50d666e 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -70,7 +70,7 @@ The two layouts share atomic publication but assign state ownership differently. ### Design concept - **`single` keeps memory authoritative.** Each write changes the in-memory unit, serializes its complete state, and atomically replaces `.json`. A failed publish restores the prior in-memory value. A batch delete is one such publish: the whole batch is applied or restored together. -- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. Each document stamps the unit version and carries one record value. +- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. If that restoration fails, the unit rejects with `unknown-state` and every later operation on it rejects. Each document stamps the unit version and carries one record value. - **Publication is durable per call.** A write uses a temporary file, fsync, atomic `rename()` replacement, and a parent-directory fsync on POSIX. The domain layer's write chain supplies ordering across calls. ### File formats diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index d611a65666..b59d7dbdea 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -70,7 +70,7 @@ kind: "package-reference" ### 设计理念 - **`single` 以内存为权威状态。** 每次写入都会更改内存单元、序列化其完整状态,并以原子方式替换 `.json`。发布失败会恢复先前的内存值。批量删除就是这样一次发布:整批一起应用或一起恢复。 -- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本。 +- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本;恢复本身失败时该 unit 以 `unknown-state` 拒绝,之后对它的所有操作都会拒绝。 - **每次调用都持久发布。** 写入过程使用临时文件、fsync、原子 `rename()` 替换,并在 POSIX 上 fsync 父目录。领域层写入链负责安排跨调用的顺序。 ### 文件格式 diff --git a/packages/storage/storage-json/src/per-record-unit.ts b/packages/storage/storage-json/src/per-record-unit.ts index b0a86537c5..f4debdbc44 100644 --- a/packages/storage/storage-json/src/per-record-unit.ts +++ b/packages/storage/storage-json/src/per-record-unit.ts @@ -195,6 +195,8 @@ async function readRecord(path: string, versions: readonly number[]): Promise>() @@ -233,21 +235,35 @@ export class PerRecordJsonUnit implements KvUnit { * Durably delete many records as one batch. Documents are renamed out of the * readable set first; only after the whole batch is staged are the staged * copies unlinked. A failure while staging restores the documents it moved, - * so a rejection leaves the batch unapplied. A staged document a crash leaves - * behind is not readable (`open` reads `*.json` documents only). + * so a rejection normally leaves the batch unapplied. If that restoration + * itself fails, the readable state is unknown: the unit rejects with + * `unknown-state` and every later operation on it rejects, while `close` + * still releases it. A staged document a crash leaves behind is not readable + * (`open` reads `*.json` documents only). */ async deleteRecords(table: string, keys: readonly string[]): Promise { this.assertOpen() const dir = this.tableDir(table) const unique = [...new Set(keys)] for (const key of unique) assertSafeKey(this.descriptor.name, key) + // One tracked promise covers staging, restoration, and cleanup, so close() + // cannot release the unit while any part of the batch is still running. + await this.tracked(this.removeDocuments(dir, unique)) + } + + /** + * Remove one batch's documents under a single tracked promise. + * @param dir - Declared table directory. + * @param keys - Validated, de-duplicated record keys. + */ + private async removeDocuments(dir: string, keys: readonly string[]): Promise { const staged: Array<{ from: string; to: string }> = [] try { - for (const key of unique) { + for (const key of keys) { const from = join(dir, `${key}.json`) const to = `${from}.deleting` try { - await this.tracked(rename(from, to)) + await rename(from, to) } catch (error: unknown) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue throw error @@ -255,12 +271,26 @@ export class PerRecordJsonUnit implements KvUnit { staged.push({ from, to }) } } catch (error: unknown) { - await Promise.allSettled(staged.map(({ from, to }) => rename(to, from))) - throw error + const restoreFailures: unknown[] = [] + for (const { from, to } of staged) { + try { + await rename(to, from) + } catch (restoreError: unknown) { + restoreFailures.push(restoreError) + } + } + if (restoreFailures.length === 0) throw error + this.unusable = new StorageError( + 'unknown-state', + `unit '${this.descriptor.name}' could not restore ${restoreFailures.length} staged document(s)` + + ' after a failed batch delete; its readable state is unknown', + { cause: new AggregateError([error, ...restoreFailures], 'batch delete failed and restoration failed') }, + ) + throw this.unusable } // The renames already removed the batch from the readable set; unlinking is // cleanup, and a failed unlink leaves an unreadable file rather than an entry. - await Promise.allSettled(staged.map(({ to }) => this.tracked(rm(to, { force: true })))) + await Promise.allSettled(staged.map(({ to }) => rm(to, { force: true }))) } /** @@ -304,6 +334,7 @@ export class PerRecordJsonUnit implements KvUnit { if (this.closed) { throw new StorageError('closed', `unit '${this.descriptor.name}' is closed`) } + if (this.unusable !== undefined) throw this.unusable } /* jscpd:ignore-end */ diff --git a/packages/storage/storage-json/tests/per-record-batch.spec.ts b/packages/storage/storage-json/tests/per-record-batch.spec.ts new file mode 100644 index 0000000000..884d992347 --- /dev/null +++ b/packages/storage/storage-json/tests/per-record-batch.spec.ts @@ -0,0 +1,107 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { JsonStorageBackend } from '../src/index.ts' + +/** Rename hook installed by a case; returning true means the case handled the call. */ +const gates = vi.hoisted(() => ({ + renameOverride: null as ((from: string, to: string) => Promise) | null, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async ( + from: Parameters[0], + to: Parameters[1], + ): Promise => { + const override = gates.renameOverride + if (override !== null && await override(String(from), String(to))) return + await actual.rename(from, to) + }, + } +}) + +const DESCRIPTOR = { name: 'batch', version: 1, layout: 'per-record' as const, tables: ['t'], hasGlobal: false } + +const roots: string[] = [] +async function freshRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-per-record-batch-')) + roots.push(root) + return root +} + +afterEach(async () => { + gates.renameOverride = null + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + +/** A filesystem denial the batch must treat as an inability to restore. */ +function denied(): NodeJS.ErrnoException { + return Object.assign(new Error('denied'), { code: 'EACCES' }) +} + +it('waits for the whole batch before close releases the unit', async () => { + const root = await freshRoot() + const backend: JsonStorageBackend = new JsonStorageBackend(root) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('t', 'a', { v: 1 }) + await unit.putRecord('t', 'b', { v: 2 }) + + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + gates.renameOverride = async (_from, to) => { + if (to.endsWith('/b.json.deleting')) { + entered.resolve(undefined) + await release.promise + } + return false + } + + const batch = unit.deleteRecords('t', ['a', 'b']) + await entered.promise + let closed = false + const closing = unit.close().then(() => { closed = true }) + await new Promise(resolve => setTimeout(resolve, 25)) + // The batch still holds the unit: its rename is blocked inside the call. + expect(closed).toBe(false) + + release.resolve(undefined) + await batch + await closing + expect(closed).toBe(true) + + // The batch finished before release, so the reopened unit sees no records. + const reopened = await backend.kv.open(DESCRIPTOR) + expect(await reopened.loadAll()).toEqual({ tables: { t: {} }, global: null }) + await backend.close() +}) + +it('rejects with unknown-state when a failed batch cannot restore its staged documents', async () => { + const root = await freshRoot() + const backend: JsonStorageBackend = new JsonStorageBackend(root) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('t', 'a', { v: 1 }) + await unit.putRecord('t', 'b', { v: 2 }) + + gates.renameOverride = async (from, to) => { + // The second document cannot be staged, and the first cannot be restored. + if (to.endsWith('/b.json.deleting')) throw denied() + if (from.endsWith('/a.json.deleting')) throw denied() + return false + } + + await expect(unit.deleteRecords('t', ['a', 'b'])).rejects.toMatchObject({ + name: 'StorageError', + code: 'unknown-state', + }) + // The unit reports the unknown state instead of claiming the batch is unapplied. + await expect(unit.loadAll()).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.putRecord('t', 'c', { v: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.deleteRecord('t', 'a')).rejects.toMatchObject({ code: 'unknown-state' }) + // Releasing the handle stays possible. + await unit.close() + await backend.close() +}) diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index a483b4dc59..7dc1d1a9ba 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -113,9 +113,12 @@ export interface KvUnit { * Delete several records durably as one batch. Idempotent: missing keys are * no-ops. Resolves once every listed record is absent from the readable * state and durable; a rejection leaves the batch unapplied, so no listed - * record was removed. A backend whose medium can apply a whole batch in one - * operation (the `single` document, a row store) must do that instead of - * repeating {@link deleteRecord} per key. + * record was removed. A backend that stages records and then cannot restore + * them rejects with `unknown-state` instead: the readable state is then + * unknown and every later operation on that unit rejects with the same + * error, while {@link close} still releases the unit. A backend whose medium + * can apply a whole batch in one operation (the `single` document, a row + * store) must do that instead of repeating {@link deleteRecord} per key. * @param table - Declared table name. * @param keys - Record keys; duplicates are harmless. In the `per-record` * layout every key becomes a path segment and must match `[a-zA-Z0-9_-]+` diff --git a/packages/storage/storage/src/error.ts b/packages/storage/storage/src/error.ts index 9e3424db55..90065fa90e 100644 --- a/packages/storage/storage/src/error.ts +++ b/packages/storage/storage/src/error.ts @@ -11,6 +11,7 @@ export type StorageErrorCode = | 'duplicate-mount' | 'version-mismatch' | 'malformed-medium' + | 'unknown-state' | 'closed' /** From 0397bd3f04d6783d5991cfdb6f776c4a473f2f4f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 18:21:16 +0800 Subject: [PATCH 22/34] fix(storage): quarantine domains after unknown-state batch failures --- docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 2 +- docs/subsystems/storage.zh.md | 2 +- .../storage/storage-domain/README.i18n.yaml | 4 +- packages/storage/storage-domain/README.md | 2 +- packages/storage/storage-domain/README.zh.md | 2 +- packages/storage/storage-domain/src/domain.ts | 23 +++++++-- .../storage-domain/tests/domain.spec.ts | 47 +++++++++++++++++-- .../tests/per-record-batch.spec.ts | 43 +++++++++++------ 9 files changed, 99 insertions(+), 30 deletions(-) diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 7c01f2b64d..5a59dee93e 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -15,8 +15,8 @@ en: 775cce941500f01f zh: 78acbc67f48574cb /storage/the-open-domain: - en: 65b94c4c8d240a77 - zh: 337d9e36a9bce113 + en: b69b25bc06f1c052 + zh: 8aca58718da520c7 /storage/the-domain-facility-ctx-storagedomain: en: deed74fcd4506ef4 zh: 8e3e6e5d2dc2e989 diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 165be01812..1a86fc83ba 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -123,7 +123,7 @@ interface Domain { } ``` -Reads are synchronous from authoritative in-memory state: `KvTable` exposes `get`/`entries`/`keys`/`size` (snapshot iterators that stay stable while queued writes land), and the global handle's `get()` serves the spec's `initial` until the first `set` materializes the slot on the medium. Every write — `put`, `delete`, `deleteMany`, `update`, `global.set` — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits `domain/changed`; a rejected backend write leaves memory untouched, so reads never diverge from the medium. `update(key, fn)` is an atomic read-modify-write at its chain slot (a missing key rejects `missing-key`); `delete` of an absent key resolves `false` with no write and no event. `deleteMany(keys)` removes the present keys as one backend batch in one chain slot, resolves the removed count, and emits one `domain/changed` per removed key; a rejected backend write leaves every record in place and emits nothing. Returned records are the stored objects themselves, not copies — replace via `put`/`update`, never mutate in place. +Reads are synchronous from authoritative in-memory state: `KvTable` exposes `get`/`entries`/`keys`/`size` (snapshot iterators that stay stable while queued writes land), and the global handle's `get()` serves the spec's `initial` until the first `set` materializes the slot on the medium. Every write — `put`, `delete`, `deleteMany`, `update`, `global.set` — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits `domain/changed`; a rejected backend write leaves memory untouched, so reads never diverge from the medium; a backend that reports `unknown-state` instead quarantines the domain, and its reads and later writes reject until `close()` releases it. `update(key, fn)` is an atomic read-modify-write at its chain slot (a missing key rejects `missing-key`); `delete` of an absent key resolves `false` with no write and no event. `deleteMany(keys)` removes the present keys as one backend batch in one chain slot, resolves the removed count, and emits one `domain/changed` per removed key; a rejected backend write leaves every record in place and emits nothing; an `unknown-state` rejection quarantines the domain. Returned records are the stored objects themselves, not copies — replace via `put`/`update`, never mutate in place. ## The domain facility: `ctx.storageDomain` diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index 03d03b08a7..e0461f91ce 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -123,7 +123,7 @@ interface Domain { } ``` -读取是同步的,来自权威的内存态:`KvTable` 暴露 `get`/`entries`/`keys`/`size`(快照迭代器,在排队写入落地期间保持稳定),global 句柄的 `get()` 在第一次 `set` 将 slot 物化到介质之前一直返回 spec 的 `initial`。每次写入——`put`、`delete`、`deleteMany`、`update`、`global.set`——都在同一条逐领域写链上排队,先在后端完成持久化,再更新内存,最后发出 `domain/changed`;后端写入被拒时内存原样不动,因此读取绝不会偏离介质。`update(key, fn)` 在其写链 slot 上是一次原子的读-改-写(键缺失时拒绝 `missing-key`);`delete` 一个不存在的键 resolve 为 `false`,不产生写入也不产生事件。`deleteMany(keys)` 在一个写链 slot 内把存在的键作为一次后端批量删除移除,resolve 为移除数量,并为每个被移除的键发一次 `domain/changed`;后端写入被拒时所有记录保持原样,也不发事件。返回的记录就是存储的对象本身,不是副本——请经 `put`/`update` 整体替换,绝不要就地修改。 +读取是同步的,来自权威的内存态:`KvTable` 暴露 `get`/`entries`/`keys`/`size`(快照迭代器,在排队写入落地期间保持稳定),global 句柄的 `get()` 在第一次 `set` 将 slot 物化到介质之前一直返回 spec 的 `initial`。每次写入——`put`、`delete`、`deleteMany`、`update`、`global.set`——都在同一条逐领域写链上排队,先在后端完成持久化,再更新内存,最后发出 `domain/changed`;后端写入被拒时内存原样不动,因此读取绝不会偏离介质;若后端报告 `unknown-state`,领域会被隔离,读取与后续写入都会拒绝,直到 `close()` 释放它。`update(key, fn)` 在其写链 slot 上是一次原子的读-改-写(键缺失时拒绝 `missing-key`);`delete` 一个不存在的键 resolve 为 `false`,不产生写入也不产生事件。`deleteMany(keys)` 在一个写链 slot 内把存在的键作为一次后端批量删除移除,resolve 为移除数量,并为每个被移除的键发一次 `domain/changed`;后端写入被拒时所有记录保持原样,也不发事件;`unknown-state` 拒绝会隔离整个领域。返回的记录就是存储的对象本身,不是副本——请经 `put`/`update` 整体替换,绝不要就地修改。 ## 领域 facility:`ctx.storageDomain` diff --git a/packages/storage/storage-domain/README.i18n.yaml b/packages/storage/storage-domain/README.i18n.yaml index 29d392dac9..ff5b580abf 100644 --- a/packages/storage/storage-domain/README.i18n.yaml +++ b/packages/storage/storage-domain/README.i18n.yaml @@ -36,8 +36,8 @@ en: 1cc8b33834457c38 zh: c549afc7c4960e9e /deepseek-ai-dsh-storage-domain/understand-the-implementation/design-concept: - en: 899fd5b09d9eb3c4 - zh: 771eb56d143cca5b + en: 4dffdacca3d2cd9c + zh: b41875243c4a1c0b /deepseek-ai-dsh-storage-domain/understand-the-implementation/open-sequence: en: 052da5d6c45205ca zh: 724c875e3c431fee diff --git a/packages/storage/storage-domain/README.md b/packages/storage/storage-domain/README.md index fc9d206aac..56e9069bd2 100644 --- a/packages/storage/storage-domain/README.md +++ b/packages/storage/storage-domain/README.md @@ -85,7 +85,7 @@ The domain layer is a single implementation, not an abstracted seam: consumers d ### Design concept - **The spec object is the single source of truth.** `defineDomain` pins the spec's literal types and validates its fields at the owning package's module load, before any medium is touched. Record schemas are zod, so `z.infer` avoids duplicating consumer types; plugin `Config` stays schemastery. -- **Memory is authoritative; the medium is the durable projection.** Reads are synchronous from validated in-memory state. Every write queues on one per-domain write chain: backend durability first, then memory mutation, then `domain/changed` — a rejected backend write leaves memory untouched, so reads never diverge from the medium. +- **Memory is authoritative; the medium is the durable projection.** Reads are synchronous from validated in-memory state. Every write queues on one per-domain write chain: backend durability first, then memory mutation, then `domain/changed` — a rejected backend write leaves memory untouched, so reads never diverge from the medium; a backend that reports `unknown-state` quarantines the domain, whose reads and later writes then reject until it is closed. - **One write chain per domain.** `put`, `delete`, `deleteMany`, `update`, and `global.set` all queue on it; `update`'s transform runs at its chain slot, so concurrent updates never interleave. Records are plain immutable data — returned values are the stored objects themselves and must not be mutated in place. - **Writes emit after the commit point.** `domain/changed` is a notification, not a transaction participant: a throwing listener is contained with a logged warning rather than rejecting the already-durable write. diff --git a/packages/storage/storage-domain/README.zh.md b/packages/storage/storage-domain/README.zh.md index 44c89f1769..466db6ea24 100644 --- a/packages/storage/storage-domain/README.zh.md +++ b/packages/storage/storage-domain/README.zh.md @@ -85,7 +85,7 @@ domain.table('workspaces').update(id, (r) => ({ ...r, path: newPath })) ### 设计理念 - **spec 对象是唯一真源。** `defineDomain` 固定 spec 的字面类型,并在所属包的模块加载时、任何介质被触碰之前校验其字段。记录 schema 使用 zod,因此 `z.infer` 可避免重复定义消费方类型;插件 `Config` 仍由 schemastery 负责。 -- **内存具有最终决定权;介质是持久投影。** 读取同步取自经过校验的内存状态。每次写入都在每个领域一条的写入链上排队:先到达后端持久状态,再变更内存,然后发出 `domain/changed`——被拒绝的后端写入不会触碰内存,因此读取永远不会与介质分叉。 +- **内存具有最终决定权;介质是持久投影。** 读取同步取自经过校验的内存状态。每次写入都在每个领域一条的写入链上排队:先到达后端持久状态,再变更内存,然后发出 `domain/changed`——被拒绝的后端写入不会触碰内存,因此读取永远不会与介质分叉;若后端报告 `unknown-state`,领域会被隔离,其读取与后续写入都会拒绝,直到领域关闭。 - **每个领域一条写入链。** `put`、`delete`、`deleteMany`、`update` 与 `global.set` 都在其上排队;`update` 的变换在链上自己的槽位运行,因此并发更新绝不会交错。记录是普通不可变数据——返回值就是已存对象本身,绝不能原地修改。 - **写入在提交点之后发出。** `domain/changed` 是通知,不是事务参与者:监听器抛出异常时,系统会隔离该异常并记录警告,而不会让已经持久的写入被拒绝。 diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index 38ac7b7bfe..d61eedf8a9 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -10,7 +10,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { KvUnit } from '@deepseek-ai/dsh-storage' +import { StorageError, type KvUnit } from '@deepseek-ai/dsh-storage' import { DomainError } from './error.ts' import type { DomainSpec, DomainGlobalSpec, TableKeyOf, TableValueOf } from './spec.ts' import type { DomainChanged } from './events.ts' @@ -87,8 +87,9 @@ export interface KvTable { * it and no other write interleaves. Missing keys are ignored. The in-memory * records reach the committed state before any `domain/changed` listener * runs. A backend failure rejects with the batch unapplied, leaving every - * record in place; a backend that cannot restore its staged records rejects - * with `unknown-state` and keeps its unit unusable. + * record in place. A backend that cannot restore its staged records rejects + * with `unknown-state` and quarantines the domain: synchronous reads and + * every later write reject with that error, while `close()` still works. * @param keys - Record keys; duplicates are harmless. * @returns the number of keys that existed and were removed. */ @@ -164,6 +165,8 @@ export class DomainImpl { private chain: Promise = Promise.resolve() /** Set when close begins: new writes reject while already-queued writes drain. */ private disposing = false + /** Set when the unit reports `unknown-state`: reads and writes reject with it. */ + private failure?: StorageError /** Set when close finishes (chain drained, unit closed): reads reject from here on. */ private closed = false private disposal?: Promise @@ -279,12 +282,24 @@ export class DomainImpl { if (this.disposing) { return Promise.reject(new DomainError('closed', `domain '${this.name}' is closed`)) } - const result = this.chain.then(job) + const run = async (): Promise => { + // A poisoned unit makes in-memory state unverifiable: reject before the + // job runs, including jobs queued before the failure surfaced. + if (this.failure !== undefined) throw this.failure + try { + return await job() + } catch (error: unknown) { + if (error instanceof StorageError && error.code === 'unknown-state') this.failure = error + throw error + } + } + const result = this.chain.then(run) this.chain = result.then(noop, noop) return result } private assertReadable(): void { + if (this.failure !== undefined) throw this.failure if (this.closed) { throw new DomainError('closed', `domain '${this.name}' is closed`) } diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 9c99cd0fab..b130e0cbcd 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' -import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' +import Storage, { StorageError, storageBackendServiceKey } from '@deepseek-ai/dsh-storage' +import type { KvUnit, StorageBackend } from '@deepseek-ai/dsh-storage' import { apply, defineDomain, descriptorOf, DomainFacility, domainTable } from '../src/index.ts' import type { Config } from '../src/index.ts' import type { DomainChanged } from '../src/events.ts' @@ -25,11 +26,11 @@ const bareSpec = defineDomain({ tables: { rows: domainTable(itemSchema) }, }) -/** Boot a context with the storage hub, one memory backend, and a facility over it. */ -async function harness(options?: { pool?: MemoryMediaPool; config?: Partial }) { +/** Boot a context with the storage hub, one KV backend, and a facility over it. */ +async function harness(options?: { pool?: MemoryMediaPool; config?: Partial; backend?: StorageBackend }) { const ctx = new Context() await ctx.plugin(Storage) - const backend = new MemoryStorageBackend(options?.pool) + const backend = options?.backend ?? new MemoryStorageBackend(options?.pool) ctx.storage.backend.register('memory', backend) const facility = new DomainFacility(ctx, { backend: 'memory', routes: {}, ...options?.config }) ctx.storage.mount('domain', facility) @@ -369,6 +370,44 @@ describe('durability failure', () => { expect(changes).toHaveLength(seen) }) + it('quarantines the domain after a batch reports unknown-state', async () => { + const inner = new MemoryStorageBackend() + const poisoned: StorageBackend = { + close: () => inner.close(), + kv: { + open: async (descriptor) => { + const kv = inner.kv + if (kv === undefined) throw new Error('memory backend is missing its kv facet') + const unit: KvUnit = await kv.open(descriptor) + return { + loadAll: () => unit.loadAll(), + putRecord: (table, key, value) => unit.putRecord(table, key, value), + deleteRecord: (table, key) => unit.deleteRecord(table, key), + deleteRecords: () => Promise.reject(new StorageError('unknown-state', 'unknown-state injected by the test')), + setGlobal: value => unit.setGlobal(value), + close: () => unit.close(), + } + }, + }, + } + const { facility } = await harness({ backend: poisoned }) + const domain = await facility.open(spec) + const table = domain.table('items') + await table.put('a', { label: 'x', count: 1 }) + + await expect(table.deleteMany(['a'])).rejects.toMatchObject({ code: 'unknown-state' }) + // Synchronous reads and later chain jobs see the quarantined domain. + expect(() => table.get('a')).toThrowError(/unknown-state/) + expect(() => [...table.keys()]).toThrowError(/unknown-state/) + expect(() => table.size).toThrowError(/unknown-state/) + await expect(table.deleteMany(['missing'])).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(table.put('b', { label: 'y', count: 2 })).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(domain.global.set({ theme: 'dark' })).rejects.toMatchObject({ code: 'unknown-state' }) + expect(() => domain.global.get()).toThrowError(/unknown-state/) + // Releasing the domain stays possible. + await expect(domain.close()).resolves.toBeUndefined() + }) + it('keeps serving initial when the first global set fails durability', async () => { const pool = new MemoryMediaPool() const { facility } = await harness({ pool }) diff --git a/packages/storage/storage-json/tests/per-record-batch.spec.ts b/packages/storage/storage-json/tests/per-record-batch.spec.ts index 884d992347..9372fc4d31 100644 --- a/packages/storage/storage-json/tests/per-record-batch.spec.ts +++ b/packages/storage/storage-json/tests/per-record-batch.spec.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { JsonStorageBackend } from '../src/index.ts' @@ -50,25 +50,40 @@ it('waits for the whole batch before close releases the unit', async () => { await unit.putRecord('t', 'a', { v: 1 }) await unit.putRecord('t', 'b', { v: 2 }) - const entered = Promise.withResolvers() - const release = Promise.withResolvers() + const firstEntered = Promise.withResolvers() + const firstRelease = Promise.withResolvers() + const secondEntered = Promise.withResolvers() + const secondRelease = Promise.withResolvers() gates.renameOverride = async (_from, to) => { - if (to.endsWith('/b.json.deleting')) { - entered.resolve(undefined) - await release.promise + const name = basename(to) + if (name === 'a.json.deleting') { + firstEntered.resolve(undefined) + await firstRelease.promise + } + if (name === 'b.json.deleting') { + secondEntered.resolve(undefined) + await secondRelease.promise } return false } const batch = unit.deleteRecords('t', ['a', 'b']) - await entered.promise + await firstEntered.promise let closed = false const closing = unit.close().then(() => { closed = true }) - await new Promise(resolve => setTimeout(resolve, 25)) - // The batch still holds the unit: its rename is blocked inside the call. - expect(closed).toBe(false) - - release.resolve(undefined) + // Start close while the first document is still staged, then let the batch + // reach its second document. Per-rename tracking would let close() return + // here because the second rename was not registered before the snapshot. + firstRelease.resolve(undefined) + try { + await secondEntered.promise + await new Promise(resolve => setTimeout(resolve, 25)) + expect(closed).toBe(false) + } finally { + // Release the blocked batch even when the assertion fails, so teardown + // never waits on an abandoned rename. + secondRelease.resolve(undefined) + } await batch await closing expect(closed).toBe(true) @@ -88,8 +103,8 @@ it('rejects with unknown-state when a failed batch cannot restore its staged doc gates.renameOverride = async (from, to) => { // The second document cannot be staged, and the first cannot be restored. - if (to.endsWith('/b.json.deleting')) throw denied() - if (from.endsWith('/a.json.deleting')) throw denied() + if (basename(to) === 'b.json.deleting') throw denied() + if (basename(from) === 'a.json.deleting') throw denied() return false } From 3c7cfb8cf867d478bd05dc5aed4a1cccc2da5654 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 18:31:12 +0800 Subject: [PATCH 23/34] fix(storage): propagate unknown-state through domains and clients --- docs/subsystems/schedule.i18n.yaml | 4 +- docs/subsystems/schedule.md | 4 +- docs/subsystems/schedule.zh.md | 4 +- docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 2 +- docs/subsystems/storage.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/schedule/schedule/README.i18n.yaml | 4 +- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/schedule/schedule/src/index.ts | 6 +++ .../schedule/tests/delete-inactive.spec.ts | 46 ++++++++++++++-- .../schedule/schedule/tests/plugin.spec.ts | 7 ++- packages/storage/storage-domain/src/domain.ts | 10 ++-- .../storage-domain/tests/domain.spec.ts | 49 ++++++++++++++++- .../storage/storage-json/README.i18n.yaml | 4 +- packages/storage/storage-json/README.md | 2 +- packages/storage/storage-json/README.zh.md | 2 +- .../tests/per-record-batch.spec.ts | 11 +++- .../storage/storage-sqlite/README.i18n.yaml | 4 +- packages/storage/storage-sqlite/README.md | 2 +- packages/storage/storage-sqlite/README.zh.md | 2 +- packages/storage/storage-sqlite/src/unit.ts | 26 ++++++++-- .../tests/sqlite-backend.spec.ts | 52 ++++++++++++++++++- packages/storage/storage/src/backend.ts | 18 ++++--- 25 files changed, 225 insertions(+), 46 deletions(-) diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index 85cdab2a17..84b5e5bdd0 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 93798ad69f74ddd5 zh: 634126ad8accc356 /host-wide-schedule/web-catalog: - en: a4f9de4d4f88f018 - zh: 228a2e8fcc726f4c + en: 4d8f28a57062ce02 + zh: d8fe886d103b3d97 /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index 7e1459483d..e2c9af9cf1 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -345,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed, and a failed batch rejects with nothing removed and nothing announced. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. +The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed; a normal failed batch rejects with nothing removed, and an `unknown-state` failure may have removed rows, so the service announces `schedule/changed` before rejecting and clients re-read. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. The shipped Web composition mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. The Rules and Records tabs separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution. @@ -524,6 +524,8 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * slot, so a delivery ordered behind this call ends its task after the * enumeration rather than during it. The batch is one durable write: a failure * removes nothing and rejects, so the caller never receives a partial removal. + * A backend that reports `unknown-state` may have removed rows, so the service + * announces the change before rejecting and clients re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index d11c1eec8f..187a742b07 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -345,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;该批删除是一次持久写入,失败时不删除任何行,也不发通知,调用以拒绝结束。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;普通失败时不删除任何行,后端报告 `unknown-state` 时可能已移除部分行,服务会先发出 `schedule/changed` 再拒绝,客户端重新读取。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 @@ -524,6 +524,8 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * slot, so a delivery ordered behind this call ends its task after the * enumeration rather than during it. The batch is one durable write: a failure * removes nothing and rejects, so the caller never receives a partial removal. + * A backend that reports `unknown-state` may have removed rows, so the service + * announces the change before rejecting and clients re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 5a59dee93e..4956c7bb90 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -9,8 +9,8 @@ en: b11e06e7c0e0fd13 zh: 4585f26ff416cb41 /storage/the-backend-contract: - en: b83300f10cc822f2 - zh: 61835181022a7ba6 + en: e9427832bd1dd9c7 + zh: 41176b1431ec965c /storage/declaring-a-domain: en: 775cce941500f01f zh: 78acbc67f48574cb diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 1a86fc83ba..076281e174 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is durably absent, and a rejection leaves the batch unapplied; a backend that cannot restore staged records rejects with `unknown-state` and then rejects every later operation on that unit. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is absent from the readable state and the backend accepted the batch, and a rejection normally leaves the batch unapplied; a backend that cannot establish or restore the batch's state rejects with `unknown-state`, after which every later operation on that unit (except `close()`) rejects with the same error. A `single`-layout failure after the atomic replacement (a directory-fsync error) can leave the new file on the medium while the caller sees a rejection; the unit keeps its memory consistent and the next publish rewrites the file. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index e0461f91ce..f8cdb43904 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都持久缺失后才 resolve,拒绝时整批未应用;无法恢复暂存记录的批量删除会以 `unknown-state` 拒绝,并让该 unit 之后的所有操作都拒绝。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都从可读状态消失且后端接受该批后才 resolve,拒绝时整批未应用;无法建立或恢复批次状态的批量删除会以 `unknown-state` 拒绝,之后该 unit 的所有操作(`close()` 除外)都以同一错误拒绝。`single` 布局在原子替换之后失败(目录 fsync 错误)时,新文件可能已落在介质上而调用方看到拒绝;unit 的内存保持一致,下一次发布会重写该文件。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9691f2cb38..cddcf77b31 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2194,7 +2194,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'deleteInactive\') async deleteInactive(): Promise', - description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. The batch is one durable write: a failure removes nothing and rejects, so the caller never receives a partial removal.', + description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. The batch is one durable write: a failure removes nothing and rejects, so the caller never receives a partial removal. A backend that reports `unknown-state` may have removed rows, so the service announces the change before rejecting and clients re-read the catalog.', parameters: [], returns: 'Count of removed rows; zero when the Host held no ended task.', }, diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 94e9eaafde..58982064cf 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: a4521f1f145b2dc7 - zh: 690d626671678f93 + en: e632295a37b7399d + zh: 3daae4e585e387c6 /deepseek-ai-dsh-schedule/understand-the-implementation: en: 02848e6fd6317b2a zh: 1807a423210f81ef diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index c76e76b6e8..e4fe6146cb 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -44,7 +44,7 @@ Weekly input adds `weekdays`, a non-empty set of ISO weekday numbers from Monday Cron input carries `expression` and `time_zone`. The expression is the standard five-field Vixie form `minute hour day-of-month month day-of-week`: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, and day-of-week 0-7 where both `0` and `7` mean Sunday. Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. `L`, `W`, `#`, `JAN`/`MON` names, `@daily`-style macros, six-field expressions, out-of-range values, inverted ranges, zero steps, and empty fields are rejected with `invalid_rule` and a message naming the offending field. Because the dialect has five fields, the smallest interval is one minute. Creation stores a canonical expression: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step, so the day rule below survives storage. The durable decoder rejects a non-canonical stored expression, so the record always holds the canonical text. When either day-of-month or day-of-week is a star, a local date matches only when both fields match; when neither is a star, either field matching is enough. A field is a star when its text starts with `*`, independently of the values it matches, so a stepped star constrains alongside the other field instead of substituting for it. The first target is the first strictly future instant whose local date and time in that zone match, using the same gap skip and earlier-overlap rules as Daily. See [cron timing](../../../docs/subsystems/schedule.md#cron-wall-clock-input) for the full dialect and canonicalization rules. -A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and the batch is one durable write, so a failure removes nothing and rejects instead of reporting a count. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. +A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and the batch is one durable write, so a normal failure removes nothing and rejects instead of reporting a count; a backend `unknown-state` failure may have removed rows, so the service announces `schedule/changed` before rejecting and clients re-read. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. Archiving a Session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index e1a650358b..9c3d0b79e1 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -44,7 +44,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 cron 输入携带 `expression` 和 `time_zone`。表达式是标准五字段 Vixie 形式 `minute hour day-of-month month day-of-week`:minute 0-59、hour 0-23、day-of-month 1-31、month 1-12、day-of-week 0-7,其中 `0` 和 `7` 都表示周日。每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。`L`、`W`、`#`、`JAN`/`MON` 名称、`@daily` 风格宏、六字段表达式、超出范围的值、反向范围、零步长和空字段都会以 `invalid_rule` 拒绝,且错误信息会指出违规字段。由于该方言只有五个字段,最小间隔为一分钟。创建时存储规范化表达式:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长,因此下文的日规则在存储后保持不变。持久化解码器拒绝非规范化的已存储表达式,因此记录始终保存规范化文本。day-of-month 或 day-of-week 中任一为星号字段时,本地日期需两个字段都匹配;两者都不是星号字段时,匹配任一字段即可。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此步长星号字段与其他字段同时约束,而不是替代它。首个目标是该时区内本地日期与时间匹配的第一个严格未来时点,使用与 Daily 相同的缺口跳过与重叠取较早时点规则。完整方言与规范化规则见 [cron 时间语义](../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input)。 -提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且该批删除是一次持久写入,失败时不删除任何行,调用以拒绝结束而不返回计数。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 +提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且该批删除是一次持久写入,普通失败时不删除任何行,调用以拒绝结束而不返回计数;后端报告 `unknown-state` 时可能已移除部分行,服务会先发出 `schedule/changed` 再拒绝,客户端重新读取。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 归档仍有活动提醒的会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index b5dd3b8617..82015ff24f 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -374,6 +374,8 @@ export class ScheduleService extends TypertRemoteService { * slot, so a delivery ordered behind this call ends its task after the * enumeration rather than during it. The batch is one durable write: a failure * removes nothing and rejects, so the caller never receives a partial removal. + * A backend that reports `unknown-state` may have removed rows, so the service + * announces the change before rejecting and clients re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') @@ -504,6 +506,10 @@ export class ScheduleService extends TypertRemoteService { try { removed = await (await this.getDomain()).table('tasks').deleteMany(ids) } catch (error: unknown) { + // A rejection never establishes which rows landed, and the backend can + // report `unknown-state` after removing some of them. Announce the change + // so observers re-read instead of trusting their stale catalog. + this.emitChanged() throw error instanceof Error ? error : new Error(`${label}: ${String(error)}`) } // Rows enumerated in this slot are present, so a non-empty batch always removed rows. diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts index 707a20a9b7..4e15abee18 100644 --- a/packages/schedule/schedule/tests/delete-inactive.spec.ts +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { createAfterScheduleRecord, createEveryScheduleRecord, ScheduleId } from '../src/domain.ts' import type { DomainFacility } from '@deepseek-ai/dsh-storage-domain' import { scheduleDomain, type ScheduleTask } from '../src/storage.ts' +import { StorageError } from '@deepseek-ai/dsh-storage' import { MemoryMediaPool } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' import { gatedScheduleBackend, harness } from './harness.ts' @@ -195,13 +196,52 @@ it('removes nothing and reports the batch failure', async () => { expect(failure).toBeInstanceOf(Error) expect(String(failure)).toContain('injected batch failure') - // One write carried both rows; the rejection left both stored and announced nothing. + // One write carried both rows; the rejection may have landed some of them, so + // observers are told to re-read even though both rows remain in this catalog. expect(batches).toEqual([['ended-a', 'ended-b']]) - expect(changed).not.toHaveBeenCalled() + expect(changed).toHaveBeenCalledTimes(1) expect((await service.catalog()).map(entry => entry.title).sort()) .toEqual(['Partial first', 'Partial second']) }) +it('quarantines the Host domain when the backend reports unknown-state', async () => { + const pool = new MemoryMediaPool() + let batches = 0 + const backend = gatedScheduleBackend(pool, { + deleteRecords: async (table) => { + if (table !== 'tasks') return + batches += 1 + throw new StorageError('unknown-state', 'injected unknown-state') + }, + }) + const test = await harness({ + pool, + backend, + beforeService: async (_ctx, facility) => { + const domain = await facility.open(scheduleDomain) + await domain.table('tasks').put(ScheduleId('ended-a'), endedRow(SessionId('quarantine-a'), 'ended-a', 'Quarantine first')) + await domain.table('tasks').put(ScheduleId('ended-b'), endedRow(SessionId('quarantine-b'), 'ended-b', 'Quarantine second')) + await domain.close() + }, + }) + tests.push(test) + const { ctx, service } = test + const changed = vi.fn() + ctx.on('schedule/changed', changed) + + const failure = await service.deleteInactive().then(() => undefined, (error: unknown) => error) + expect(failure).toMatchObject({ name: 'StorageError', code: 'unknown-state' }) + expect(batches).toBe(1) + expect(changed).toHaveBeenCalledTimes(1) + // The quarantined domain rejects reads instead of serving stale rows. + await expect(service.catalog()).rejects.toMatchObject({ code: 'unknown-state' }) + // Later batches are rejected before reaching the backend again. + await expect(service.deleteInactive()).rejects.toMatchObject({ code: 'unknown-state' }) + expect(batches).toBe(1) + // The durable rows are still there; only the in-process domain is quarantined. + expect(pool.media.get('schedule')?.tables.get('tasks')?.size).toBe(2) +}) + it('reports a rejection reason that carries no Error as a labeled Error', async () => { const pool = new MemoryMediaPool() let batches = 0 @@ -233,7 +273,7 @@ it('reports a rejection reason that carries no Error as a labeled Error', async expect(failure).toBeInstanceOf(Error) expect(String(failure)).toContain('schedule ended-task removal failed') expect(batches).toBe(1) - expect(changed).not.toHaveBeenCalled() + expect(changed).toHaveBeenCalledTimes(1) expect((await service.catalog()).map(entry => entry.title).sort()) .toEqual(['Unreportable first', 'Unreportable second']) }) diff --git a/packages/schedule/schedule/tests/plugin.spec.ts b/packages/schedule/schedule/tests/plugin.spec.ts index a507b5181f..ad2195c712 100644 --- a/packages/schedule/schedule/tests/plugin.spec.ts +++ b/packages/schedule/schedule/tests/plugin.spec.ts @@ -195,7 +195,9 @@ describe('shared Schedule management', () => { .then(() => undefined, (error: unknown) => error) expect(failure).toBeInstanceOf(AggregateError) expect(batches).toBe(1) - expect(changed).not.toHaveBeenCalled() + // The batch may have landed some rows under unknown-state, so the service + // announces the change before the rejection reaches the caller. + expect(changed).toHaveBeenCalledTimes(1) expect((await service.catalog()).map(entry => entry.title).sort()).toEqual(['First', 'Second']) }) @@ -222,8 +224,9 @@ describe('shared Schedule management', () => { // Nothing landed, and a reason that carries no Error must not read as success // with the reminder still armed. expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors.map(String).some(text => text.includes('schedule stop failed'))).toBe(true) expect(batches).toBe(1) - expect(changed).not.toHaveBeenCalled() + expect(changed).toHaveBeenCalledTimes(1) expect((await service.catalog()).map(entry => entry.title)).toEqual(['First']) }) diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index d61eedf8a9..4f240d4c99 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -4,8 +4,9 @@ * from memory; every write queues on the chain, awaits backend durability * FIRST, then mutates memory, then emits `domain/changed` — a rejected * backend write leaves memory untouched (no divergence between reads and the - * medium), and events carry values that equal the in-memory state at - * emission, in write order. + * medium) unless the backend reports `unknown-state`, which quarantines the + * domain: reads and later writes reject until close. Events carry values that + * equal the in-memory state at emission, in write order. * @module @deepseek-ai/dsh-storage-domain/src/domain */ @@ -140,7 +141,7 @@ interface TableHost { readonly unit: KvUnit /** Queue one job on the domain's single write chain. */ enqueue(job: () => Promise): Promise - /** Throw `closed` once the domain has fully closed (reads stay valid while draining). */ + /** Throw `closed` after teardown, or the recorded `unknown-state` while quarantined. */ assertReadable(): void /** Emit `domain/changed` for one durably landed write. */ emitChanged(change: DomainChanged): void @@ -299,10 +300,11 @@ export class DomainImpl { } private assertReadable(): void { - if (this.failure !== undefined) throw this.failure + // Close wins: the KvUnit contract reports `closed` for calls after teardown. if (this.closed) { throw new DomainError('closed', `domain '${this.name}' is closed`) } + if (this.failure !== undefined) throw this.failure } } diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index b130e0cbcd..40edc985e4 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -309,6 +309,43 @@ describe('KvTable writes', () => { expect(observed).toEqual([[], []]) }) + it('calls the backend batch primitive exactly once per deleteMany', async () => { + const inner = new MemoryStorageBackend() + let batches = 0 + let singles = 0 + const counting: StorageBackend = { + close: () => inner.close(), + kv: { + open: async (descriptor) => { + const kv = inner.kv + if (kv === undefined) throw new Error('memory backend is missing its kv facet') + const unit = await kv.open(descriptor) + return { + loadAll: () => unit.loadAll(), + putRecord: (table, key, value) => unit.putRecord(table, key, value), + deleteRecord: async (table, key) => { + singles += 1 + return unit.deleteRecord(table, key) + }, + deleteRecords: async (table, keys) => { + batches += 1 + return unit.deleteRecords(table, keys) + }, + setGlobal: value => unit.setGlobal(value), + close: () => unit.close(), + } + }, + }, + } + const { facility } = await harness({ backend: counting }) + const table = (await facility.open(spec)).table('items') + await table.put('a', { label: 'x', count: 1 }) + await table.put('b', { label: 'y', count: 2 }) + await expect(table.deleteMany(['a', 'b', 'missing'])).resolves.toBe(2) + expect(batches).toBe(1) + expect(singles).toBe(0) + }) + it('emits domain/changed per durable write, in order, with tombstones and global marker', async () => { const { facility, changes } = await harness() const domain = await facility.open(spec) @@ -368,6 +405,10 @@ describe('durability failure', () => { ]) expect(pool.media.get('demo')!.tables.get('items')!.get('b')).toEqual({ label: 'y', count: 2 }) expect(changes).toHaveLength(seen) + + // The write chain keeps serving after a rejected batch. + await table.put('c', { label: 'z', count: 3 }) + expect(table.get('c')).toEqual({ label: 'z', count: 3 }) }) it('quarantines the domain after a batch reports unknown-state', async () => { @@ -395,13 +436,17 @@ describe('durability failure', () => { const table = domain.table('items') await table.put('a', { label: 'x', count: 1 }) - await expect(table.deleteMany(['a'])).rejects.toMatchObject({ code: 'unknown-state' }) + // A later job queued before the failure surfaces is rejected too. + const failing = table.deleteMany(['a']) + const queued = table.put('b', { label: 'y', count: 2 }) + await expect(failing).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(queued).rejects.toMatchObject({ code: 'unknown-state' }) // Synchronous reads and later chain jobs see the quarantined domain. expect(() => table.get('a')).toThrowError(/unknown-state/) expect(() => [...table.keys()]).toThrowError(/unknown-state/) expect(() => table.size).toThrowError(/unknown-state/) await expect(table.deleteMany(['missing'])).rejects.toMatchObject({ code: 'unknown-state' }) - await expect(table.put('b', { label: 'y', count: 2 })).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(table.put('c', { label: 'z', count: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) await expect(domain.global.set({ theme: 'dark' })).rejects.toMatchObject({ code: 'unknown-state' }) expect(() => domain.global.get()).toThrowError(/unknown-state/) // Releasing the domain stays possible. diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index cfff00f5b3..96ca9927da 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/README.i18n.yaml @@ -30,8 +30,8 @@ en: b7d222309b3e5438 zh: 807c7314ee341d94 /deepseek-ai-dsh-storage-json/understand-the-implementation/design-concept: - en: d4f84e0e5d2b3770 - zh: 87eca52475ea2fe6 + en: 7baa51f75f15a237 + zh: 1415a2b73940e41d /deepseek-ai-dsh-storage-json/understand-the-implementation/file-formats: en: b3de0a697db4f916 zh: 56b26f7823392bd5 diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 55f50d666e..7362b334be 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -70,7 +70,7 @@ The two layouts share atomic publication but assign state ownership differently. ### Design concept - **`single` keeps memory authoritative.** Each write changes the in-memory unit, serializes its complete state, and atomically replaces `.json`. A failed publish restores the prior in-memory value. A batch delete is one such publish: the whole batch is applied or restored together. -- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. If that restoration fails, the unit rejects with `unknown-state` and every later operation on it rejects. Each document stamps the unit version and carries one record value. +- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. If that restoration fails, the unit rejects with `unknown-state` and every later operation on it rejects. The batch's renames and unlinks are not followed by a directory fsync, so crash durability follows the filesystem's entry-update guarantees: a crash can resurrect a staged document or leave `.deleting` residue. Each document stamps the unit version and carries one record value. - **Publication is durable per call.** A write uses a temporary file, fsync, atomic `rename()` replacement, and a parent-directory fsync on POSIX. The domain layer's write chain supplies ordering across calls. ### File formats diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index b59d7dbdea..b3d5f1c96f 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -70,7 +70,7 @@ kind: "package-reference" ### 设计理念 - **`single` 以内存为权威状态。** 每次写入都会更改内存单元、序列化其完整状态,并以原子方式替换 `.json`。发布失败会恢复先前的内存值。批量删除就是这样一次发布:整批一起应用或一起恢复。 -- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本;恢复本身失败时该 unit 以 `unknown-state` 拒绝,之后对它的所有操作都会拒绝。 +- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本;恢复本身失败时该 unit 以 `unknown-state` 拒绝,之后对它的所有操作都会拒绝。批量删除的改名与删除后不做目录 fsync,因此崩溃后的持久性取决于文件系统的目录项更新保证:崩溃可能让暂存文档重新出现,或留下 `.deleting` 残留。 - **每次调用都持久发布。** 写入过程使用临时文件、fsync、原子 `rename()` 替换,并在 POSIX 上 fsync 父目录。领域层写入链负责安排跨调用的顺序。 ### 文件格式 diff --git a/packages/storage/storage-json/tests/per-record-batch.spec.ts b/packages/storage/storage-json/tests/per-record-batch.spec.ts index 9372fc4d31..319898470b 100644 --- a/packages/storage/storage-json/tests/per-record-batch.spec.ts +++ b/packages/storage/storage-json/tests/per-record-batch.spec.ts @@ -24,7 +24,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { } }) -const DESCRIPTOR = { name: 'batch', version: 1, layout: 'per-record' as const, tables: ['t'], hasGlobal: false } +const DESCRIPTOR = { name: 'batch', version: 1, layout: 'per-record' as const, tables: ['t'], hasGlobal: true } const roots: string[] = [] async function freshRoot(): Promise { @@ -49,6 +49,8 @@ it('waits for the whole batch before close releases the unit', async () => { const unit = await backend.kv.open(DESCRIPTOR) await unit.putRecord('t', 'a', { v: 1 }) await unit.putRecord('t', 'b', { v: 2 }) + // An empty batch is a settled no-op that never touches the medium. + await expect(unit.deleteRecords('t', [])).resolves.toBeUndefined() const firstEntered = Promise.withResolvers() const firstRelease = Promise.withResolvers() @@ -116,7 +118,14 @@ it('rejects with unknown-state when a failed batch cannot restore its staged doc await expect(unit.loadAll()).rejects.toMatchObject({ code: 'unknown-state' }) await expect(unit.putRecord('t', 'c', { v: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) await expect(unit.deleteRecord('t', 'a')).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.setGlobal({ g: 1 })).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.backupRecord('t', 'b')).rejects.toMatchObject({ code: 'unknown-state' }) // Releasing the handle stays possible. await unit.close() + // A reopened unit reads the partial tree: the staged document is unreadable + // residue and the second record is still present. + const reopened = await backend.kv.open(DESCRIPTOR) + expect(await reopened.loadAll()).toEqual({ tables: { t: { b: { v: 2 } } }, global: null }) + await reopened.close() await backend.close() }) diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml index 0795b556f9..f7f6e9e2c8 100644 --- a/packages/storage/storage-sqlite/README.i18n.yaml +++ b/packages/storage/storage-sqlite/README.i18n.yaml @@ -30,8 +30,8 @@ en: 6867b52cbb94e572 zh: 187d5d61980c8fe7 /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/design-concept: - en: 54ee8d9d7746e82d - zh: 510c8bca88d2495c + en: af6e4b3df6d321cd + zh: 9922901704a6780f /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/open-sequence: en: 50d85e8d8e8b5bc1 zh: 2a7067bd7fb47b35 diff --git a/packages/storage/storage-sqlite/README.md b/packages/storage/storage-sqlite/README.md index 940a464490..b58eec9813 100644 --- a/packages/storage/storage-sqlite/README.md +++ b/packages/storage/storage-sqlite/README.md @@ -69,7 +69,7 @@ The backend is a document-per-row layout over one `node:sqlite` connection, desi ### Design concept - **Document per row.** Each unit table becomes a physical STRICT table `u__
(key TEXT PRIMARY KEY, value TEXT)` whose `value` column holds the record's JSON text; the global singleton lives in a shared `unit_globals` table. One key update touches exactly one row — the reason to route a high-churn domain here. -- **Single-statement atomicity.** Every write primitive is one prepared statement, so SQLite's per-statement atomicity satisfies the KV contract without explicit transactions; write ordering stays the caller's responsibility (the domain layer's write chain). +- **Statement atomicity.** Every single-record write is one prepared statement; a batch delete runs its deletes in one explicit transaction, so the batch commits or rolls back together. A batch that cannot open or roll back its transaction rejects with `unknown-state`, after which every later operation on the unit rejects; write ordering stays the caller's responsibility (the domain layer's write chain). - **Names validated before DDL.** Unit and table names must match `UNIT_NAME_RE` before they reach DDL, so no external input is ever interpolated into SQL identifiers. - **Versions fail loud.** The physical layout version lives in `PRAGMA user_version` (fresh databases stamp it last); unit format versions live in the `units` table. Any other stamped value rejects — no migrations. diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md index 89743bbd9e..40262a66c5 100644 --- a/packages/storage/storage-sqlite/README.zh.md +++ b/packages/storage/storage-sqlite/README.zh.md @@ -69,7 +69,7 @@ kind: "package-reference" ### 设计理念 - **每行一份文档。** 每个单元表都变成一张物理 STRICT 表 `u__
(key TEXT PRIMARY KEY, value TEXT)`,其 `value` 列保存记录的 JSON 文本;全局单例存放在共享的 `unit_globals` 表中。一个键的更新恰好触碰一行——这就是把高频变更领域路由到这里的原因。 -- **语句原子性。** 每个单记录写入都是一条预处理语句;批量删除在一个显式事务中执行其删除,因此整批一起提交或回滚。写入顺序仍由调用方负责(领域层的写入链)。 +- **语句原子性。** 每个单记录写入都是一条预处理语句;批量删除在一个显式事务中执行其删除,因此整批一起提交或回滚。无法开启或回滚事务的批量删除以 `unknown-state` 拒绝,之后对该 unit 的所有操作都会拒绝;写入顺序仍由调用方负责(领域层的写入链)。 - **名称在 DDL 之前校验。** 单元名与表名在进入 DDL 之前必须匹配 `UNIT_NAME_RE`,因此任何外部输入都不会被插值进 SQL 标识符。 - **版本明确报错。** 物理布局版本存放在 `PRAGMA user_version`(全新数据库最后盖戳);单元格式版本存放在 `units` 表中。任何其他已标记值都会被拒绝——不做迁移。 diff --git a/packages/storage/storage-sqlite/src/unit.ts b/packages/storage/storage-sqlite/src/unit.ts index f43988cdf4..05e4771192 100644 --- a/packages/storage/storage-sqlite/src/unit.ts +++ b/packages/storage/storage-sqlite/src/unit.ts @@ -30,6 +30,8 @@ export class SqliteKvUnit implements KvUnit { private readonly globalUpsert: StatementSync | undefined private readonly globalSelect: StatementSync | undefined private closed = false + /** Set when a batch transaction could not be opened or rolled back. */ + private unusable: StorageError | undefined /** * @param db - Open database handle owned by the backend (never closed here). @@ -112,7 +114,18 @@ export class SqliteKvUnit implements KvUnit { deleteRecords(table: string, keys: readonly string[]): Promise { return this.settle(() => { const remove = this.statementsFor(table).remove - this.db.exec('BEGIN IMMEDIATE') + try { + this.db.exec('BEGIN IMMEDIATE') + } catch (error: unknown) { + // An unopened transaction can only mean the connection is not in the + // state this unit expects; later statements would run in an unknown one. + this.unusable = new StorageError( + 'unknown-state', + `kv unit '${this.descriptor.name}' could not open a batch transaction; its readable state is unknown`, + { cause: error }, + ) + throw this.unusable + } try { for (const key of new Set(keys)) remove.run(key) this.db.exec('COMMIT') @@ -120,10 +133,12 @@ export class SqliteKvUnit implements KvUnit { try { this.db.exec('ROLLBACK') } catch (rollbackError: unknown) { - // A failed rollback leaves the connection's state to its own next - // statement; both failures are reported, neither is discarded. - /* v8 ignore next -- ROLLBACK fails only on an unusable connection; the next statement reports it. */ - throw new AggregateError([error, rollbackError], `kv unit '${this.descriptor.name}' batch delete failed and could not roll back`) + this.unusable = new StorageError( + 'unknown-state', + `kv unit '${this.descriptor.name}' batch delete failed and could not roll back; its readable state is unknown`, + { cause: new AggregateError([error, rollbackError], 'batch delete failed and rollback failed') }, + ) + throw this.unusable } throw error } @@ -166,6 +181,7 @@ export class SqliteKvUnit implements KvUnit { if (this.closed) { throw new StorageError('closed', `kv unit '${this.descriptor.name}' is closed`) } + if (this.unusable !== undefined) throw this.unusable } private statementsFor(table: string): TableStatements { diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index 795d832d05..9078fb7dfe 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -67,6 +67,56 @@ describe('sqlite backend specifics', () => { await backend.close() }) + it('reports unknown-state when a failed batch cannot roll back', async () => { + const path = await freshDbPath() + const backend = backendAt(path) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('records', 'a', { n: 1 }) + await unit.putRecord('records', 'b', { n: 2 }) + const db = new DatabaseSync(path) + db.exec('CREATE TRIGGER block_b BEFORE DELETE ON "u_specimen_records"' + + " WHEN old.key = 'b' BEGIN SELECT RAISE(ABORT, 'blocked'); END") + const original = DatabaseSync.prototype.exec + const execSpy = vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: DatabaseSync, sql: string) { + if (sql === 'ROLLBACK') throw new Error('rollback denied') + return original.call(this, sql) + }) + try { + await expect(unit.deleteRecords('records', ['a', 'b'])).rejects.toMatchObject({ code: 'unknown-state' }) + // Later statements would run inside the still-open transaction. + await expect(unit.putRecord('records', 'c', { n: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.loadAll()).rejects.toMatchObject({ code: 'unknown-state' }) + } finally { + execSpy.mockRestore() + db.close() + } + // The denied rollback leaves the unit's connection in a transaction; the + // backend close is what releases it, and this test's file is discarded by + // afterEach. + await expect(unit.close()).resolves.toBeUndefined() + await backend.close() + }) + + it('reports unknown-state when a batch cannot open its transaction', async () => { + const path = await freshDbPath() + const backend = backendAt(path) + const unit = await backend.kv.open(DESCRIPTOR) + await unit.putRecord('records', 'a', { n: 1 }) + const original = DatabaseSync.prototype.exec + const execSpy = vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: DatabaseSync, sql: string) { + if (sql.startsWith('BEGIN')) throw new Error('begin denied') + return original.call(this, sql) + }) + try { + await expect(unit.deleteRecords('records', ['a'])).rejects.toMatchObject({ code: 'unknown-state' }) + await expect(unit.deleteRecord('records', 'a')).rejects.toMatchObject({ code: 'unknown-state' }) + } finally { + execSpy.mockRestore() + } + await expect(unit.close()).resolves.toBeUndefined() + await backend.close() + }) + it('materializes STRICT record tables and stamps the schema version', async () => { const path = await freshDbPath() const backend = backendAt(path) diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index 7dc1d1a9ba..1dfe6a3c03 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -112,13 +112,17 @@ export interface KvUnit { /** * Delete several records durably as one batch. Idempotent: missing keys are * no-ops. Resolves once every listed record is absent from the readable - * state and durable; a rejection leaves the batch unapplied, so no listed - * record was removed. A backend that stages records and then cannot restore - * them rejects with `unknown-state` instead: the readable state is then - * unknown and every later operation on that unit rejects with the same - * error, while {@link close} still releases the unit. A backend whose medium - * can apply a whole batch in one operation (the `single` document, a row - * store) must do that instead of repeating {@link deleteRecord} per key. + * state and the backend accepted the batch; a rejection leaves the batch + * unapplied, so no listed record was removed. A backend that cannot + * establish or restore the batch's state rejects with `unknown-state` + * instead: the readable state is then unknown, every later operation on + * that unit rejects with the same error, and only {@link close} still + * releases it. On the `single` layout a failure after the atomic replacement + * (a directory-fsync error) can leave the new file on the medium while the + * caller sees a rejection; the unit keeps its memory consistent and the next + * publish rewrites the file. A backend whose medium can apply a whole batch + * in one operation (the `single` document, a row store) must do that instead + * of repeating {@link deleteRecord} per key. * @param table - Declared table name. * @param keys - Record keys; duplicates are harmless. In the `per-record` * layout every key becomes a path segment and must match `[a-zA-Z0-9_-]+` From 35fce127c00e129981de8452028107065458f7b6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 18:33:38 +0800 Subject: [PATCH 24/34] test(storage): guard the optional backupRecord probe --- packages/storage/storage-json/tests/per-record-batch.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/storage/storage-json/tests/per-record-batch.spec.ts b/packages/storage/storage-json/tests/per-record-batch.spec.ts index 319898470b..b6c8746a97 100644 --- a/packages/storage/storage-json/tests/per-record-batch.spec.ts +++ b/packages/storage/storage-json/tests/per-record-batch.spec.ts @@ -119,7 +119,9 @@ it('rejects with unknown-state when a failed batch cannot restore its staged doc await expect(unit.putRecord('t', 'c', { v: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) await expect(unit.deleteRecord('t', 'a')).rejects.toMatchObject({ code: 'unknown-state' }) await expect(unit.setGlobal({ g: 1 })).rejects.toMatchObject({ code: 'unknown-state' }) - await expect(unit.backupRecord('t', 'b')).rejects.toMatchObject({ code: 'unknown-state' }) + const backup = unit.backupRecord?.bind(unit) + if (backup === undefined) throw new Error('per-record unit must expose backupRecord') + await expect(backup('t', 'b')).rejects.toMatchObject({ code: 'unknown-state' }) // Releasing the handle stays possible. await unit.close() // A reopened unit reads the partial tree: the staged document is unreadable From ed7d73098f034fd6062655f85206b7ddb66a059c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 18:38:09 +0800 Subject: [PATCH 25/34] docs(storage): align unknown-state contracts and failure wording --- docs/subsystems/schedule.i18n.yaml | 4 +-- docs/subsystems/schedule.md | 12 +++++---- docs/subsystems/schedule.zh.md | 12 +++++---- docs/subsystems/storage.i18n.yaml | 4 +-- docs/subsystems/storage.md | 2 +- docs/subsystems/storage.zh.md | 2 +- packages/client/ui-schedule/README.i18n.yaml | 4 +-- packages/client/ui-schedule/README.md | 2 +- packages/client/ui-schedule/README.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/schedule/schedule/README.i18n.yaml | 4 +-- packages/schedule/schedule/README.md | 2 +- packages/schedule/schedule/README.zh.md | 2 +- packages/schedule/schedule/src/index.ts | 18 ++++++++----- .../schedule/tests/delete-inactive.spec.ts | 2 +- packages/storage/storage-domain/src/domain.ts | 9 ++++--- .../storage/storage-json/README.i18n.yaml | 12 ++++----- packages/storage/storage-json/README.md | 5 ++-- packages/storage/storage-json/README.zh.md | 5 ++-- .../storage/storage-sqlite/README.i18n.yaml | 8 +++--- packages/storage/storage-sqlite/README.md | 4 +-- packages/storage/storage-sqlite/README.zh.md | 4 +-- packages/storage/storage/README.i18n.yaml | 4 +-- packages/storage/storage/README.md | 2 +- packages/storage/storage/README.zh.md | 2 +- packages/storage/storage/src/backend.ts | 27 ++++++++++--------- 26 files changed, 84 insertions(+), 72 deletions(-) diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index 84b5e5bdd0..63354f0fd0 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -27,8 +27,8 @@ en: 93798ad69f74ddd5 zh: 634126ad8accc356 /host-wide-schedule/web-catalog: - en: 4d8f28a57062ce02 - zh: d8fe886d103b3d97 + en: 3377a013bf8cbfb7 + zh: c5666dc3f28b087f /host-wide-schedule/timing-edits: en: ce5d2095a3ad2e82 zh: 167b3715f8eaffc1 diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index e2c9af9cf1..13f1752a31 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -345,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed; a normal failed batch rejects with nothing removed, and an `unknown-state` failure may have removed rows, so the service announces `schedule/changed` before rejecting and clients re-read. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. +The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed; a normal failed batch rejects with nothing removed; an `unknown-state` failure may have removed rows and quarantines the Host schedule domain, so later schedule operations reject until it is reopened. Any rejection announces `schedule/changed`, and clients then re-read. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. The shipped Web composition mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. The Rules and Records tabs separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution. @@ -522,10 +522,12 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. The batch is one durable write: a failure - * removes nothing and rejects, so the caller never receives a partial removal. - * A backend that reports `unknown-state` may have removed rows, so the service - * announces the change before rejecting and clients re-read the catalog. + * enumeration rather than during it. The batch is one domain write: a normal + * failure rejects with every listed row still stored, while a backend + * `unknown-state` failure may have removed rows and quarantines the Host + * domain, so later schedule operations reject until the domain is reopened. + * Every rejection announces `schedule/changed` because it does not establish + * which rows landed, and clients then re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index 187a742b07..320ae8e743 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -345,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & { } ``` -Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;普通失败时不删除任何行,后端报告 `unknown-state` 时可能已移除部分行,服务会先发出 `schedule/changed` 再拒绝,客户端重新读取。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 +Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;普通失败时不删除任何行;后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,之后该领域的操作都会拒绝,直到重新打开。任何拒绝都会先发出 `schedule/changed`,客户端重新读取。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。 @@ -522,10 +522,12 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. The batch is one durable write: a failure - * removes nothing and rejects, so the caller never receives a partial removal. - * A backend that reports `unknown-state` may have removed rows, so the service - * announces the change before rejecting and clients re-read the catalog. + * enumeration rather than during it. The batch is one domain write: a normal + * failure rejects with every listed row still stored, while a backend + * `unknown-state` failure may have removed rows and quarantines the Host + * domain, so later schedule operations reject until the domain is reopened. + * Every rejection announces `schedule/changed` because it does not establish + * which rows landed, and clients then re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') async deleteInactive(): Promise diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 4956c7bb90..5f4126783c 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -9,8 +9,8 @@ en: b11e06e7c0e0fd13 zh: 4585f26ff416cb41 /storage/the-backend-contract: - en: e9427832bd1dd9c7 - zh: 41176b1431ec965c + en: 225e25b9b4b08865 + zh: 53bd3554da610607 /storage/declaring-a-domain: en: 775cce941500f01f zh: 78acbc67f48574cb diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 076281e174..8dddb5818e 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is absent from the readable state and the backend accepted the batch, and a rejection normally leaves the batch unapplied; a backend that cannot establish or restore the batch's state rejects with `unknown-state`, after which every later operation on that unit (except `close()`) rejects with the same error. A `single`-layout failure after the atomic replacement (a directory-fsync error) can leave the new file on the medium while the caller sees a rejection; the unit keeps its memory consistent and the next publish rewrites the file. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. +A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are opaque strings in the `single` layout and match `[a-zA-Z0-9_-]+` path segments in the `per-record` layout. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium; durability holds for single-record writes and whole-file publishes, while a `per-record` batch deletion relies on the filesystem persisting directory-entry updates. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is absent from the readable state and the backend accepted the batch, and a rejection normally leaves the batch unapplied; a backend that cannot establish or restore the batch's state rejects with `unknown-state`, after which every later operation on that unit rejects with the same error until `close()` releases it (calls after close reject `closed`). A `single`-layout failure after the atomic replacement (a directory-fsync error) can leave the new file on the medium while the caller sees a rejection; the unit keeps its memory consistent and the next publish rewrites the file. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks the shared clauses against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data. ## Declaring a domain diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index f8cdb43904..7411fa632b 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -44,7 +44,7 @@ interface StorageBackend { } ``` -一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都从可读状态消失且后端接受该批后才 resolve,拒绝时整批未应用;无法建立或恢复批次状态的批量删除会以 `unknown-state` 拒绝,之后该 unit 的所有操作(`close()` 除外)都以同一错误拒绝。`single` 布局在原子替换之后失败(目录 fsync 错误)时,新文件可能已落在介质上而调用方看到拒绝;unit 的内存保持一致,下一次发布会重写该文件。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 +一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键在 `single` 布局中是不透明字符串,在 `per-record` 布局中则是匹配 `[a-zA-Z0-9_-]+` 的路径片段。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的;单记录写入与整文件发布在 resolve 后即已持久,`per-record` 批量删除则依赖文件系统对目录项更新的持久化。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都从可读状态消失且后端接受该批后才 resolve,拒绝时通常整批未应用;无法建立或恢复批次状态的批量删除会以 `unknown-state` 拒绝,之后该 unit 的所有操作都以同一错误拒绝,直到 `close()` 释放它(关闭后的调用以 `closed` 拒绝)。`single` 布局在原子替换之后失败(目录 fsync 错误)时,新文件可能已落在介质上而调用方看到拒绝;unit 的内存保持一致,下一次发布会重写该文件。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查共享条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。 ## 声明领域 diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 4d8d777289..c887fb21cf 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: e4c481487bb8abc2 - zh: 2f14b2ea13172c80 + en: 0dfe5bbe9826aaa6 + zh: 9f480110c7652f1b /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 47cd16c142e82385 zh: 181849a3c7a167fd diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index c02c19d11d..1e6463cbf3 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -41,7 +41,7 @@ After the last saved page loads, a nonempty history with confirmed pruning shows A successful empty history page is distinct from loading, query failure, a missing task, or an invalid cursor. Failed requests retain previously loaded records with a warning and Retry delivery records; an invalid cursor offers Refresh delivery records to reload the newest page. Changing the latest `messageId` refreshes the newest page without switching tabs. Responses superseded by a refresh or received after changing tasks, leaving Records, or closing details do not replace the current view. -The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N inactive tasks.` with the count the Host answered, `No inactive tasks to clear.` when the Host reports that it removed none, and `Could not clear every inactive task.` after a failure that leaves the remaining inactive rows in place to retry. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. +The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N inactive tasks.` with the count the Host answered, `No inactive tasks to clear.` when the Host reports that it removed none, and `Could not clear every inactive task.` after a failure; a normal failure leaves the remaining inactive rows for a retry, while a backend `unknown-state` failure may have removed some rows and quarantines the Host's schedule domain until it is reopened. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. An open Session renders an icon-only clock button among its header's right-aligned utilities, immediately left of the overflow menu, only once it has something to open: the first read mounts nothing, a read that returns no active reminder leaves the header without the button, a refresh keeps the last known reminders visible, and a failed read keeps the button with its retry. That rule prevents the clock from appearing and disappearing around the first answer. Its accessible name states the reminder count when the read is ready and the generic reminder label otherwise. With exactly one active reminder, the button opens that task's detail in the right Sidebar directly. With any other count it opens a popover that lists overdue reminders first, then future reminders by target time, with stored title, frequency, browser-local target time, relative time, and a Delete button; the title is a button that opens that task's detail in the right Sidebar. The task tab names the shown task's stored title on its chip and carries the same rule, delivery records, name, instruction, and run-time editing, confirmed deletion, and original-Session link as the Automation tasks page; it never activates a Session. Daily frequency text retains the saved local time and zone, and every next-target instant is shown as a device-zone clock with its relative duration in parentheses; the rule's own zone appears only in that frequency text. Escape closes the popover and returns focus to the button; an outside pointer press dismisses it. Deleting the final reminder removes the button, and an already open popover stays expandable until then. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index edba0baca6..76fad49770 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。 -自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理全部已结束任务」,并保留尚未移除的已结束行以便重试。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以已结束状态保存。显式删除前,通过全部或已结束筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;已结束状态和该回执均不表示模型执行完成。 +自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理全部已结束任务」;普通失败会保留尚未移除的已结束行以便重试,后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,直到重新打开。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以已结束状态保存。显式删除前,通过全部或已结束筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;已结束状态和该回执均不表示模型执行完成。 已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index cddcf77b31..7ce4c2c3ac 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2194,7 +2194,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'deleteInactive\') async deleteInactive(): Promise', - description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. The batch is one durable write: a failure removes nothing and rejects, so the caller never receives a partial removal. A backend that reports `unknown-state` may have removed rows, so the service announces the change before rejecting and clients re-read the catalog.', + description: 'Delete every ended Host task, leaving active tasks armed.\n\nEnded tasks never schedule another delivery, so the removal changes no pending target and no timer is recomputed. Rows are enumerated and removed in one FIFO slot, so a delivery ordered behind this call ends its task after the enumeration rather than during it. The batch is one domain write: a normal failure rejects with every listed row still stored, while a backend `unknown-state` failure may have removed rows and quarantines the Host domain, so later schedule operations reject until the domain is reopened. Every rejection announces `schedule/changed` because it does not establish which rows landed, and clients then re-read the catalog.', parameters: [], returns: 'Count of removed rows; zero when the Host held no ended task.', }, diff --git a/packages/schedule/schedule/README.i18n.yaml b/packages/schedule/schedule/README.i18n.yaml index 58982064cf..9b096b0a45 100644 --- a/packages/schedule/schedule/README.i18n.yaml +++ b/packages/schedule/schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: 23386452a66b2fbd zh: 4a8d91b2f313fead /deepseek-ai-dsh-schedule/use-this-package: - en: e632295a37b7399d - zh: 3daae4e585e387c6 + en: 8de4642089a0ca4f + zh: 5d5fe5ab846a476f /deepseek-ai-dsh-schedule/understand-the-implementation: en: 02848e6fd6317b2a zh: 1807a423210f81ef diff --git a/packages/schedule/schedule/README.md b/packages/schedule/schedule/README.md index e4fe6146cb..fc76fe3513 100644 --- a/packages/schedule/schedule/README.md +++ b/packages/schedule/schedule/README.md @@ -44,7 +44,7 @@ Weekly input adds `weekdays`, a non-empty set of ISO weekday numbers from Monday Cron input carries `expression` and `time_zone`. The expression is the standard five-field Vixie form `minute hour day-of-month month day-of-week`: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, and day-of-week 0-7 where both `0` and `7` mean Sunday. Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. `L`, `W`, `#`, `JAN`/`MON` names, `@daily`-style macros, six-field expressions, out-of-range values, inverted ranges, zero steps, and empty fields are rejected with `invalid_rule` and a message naming the offending field. Because the dialect has five fields, the smallest interval is one minute. Creation stores a canonical expression: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step, so the day rule below survives storage. The durable decoder rejects a non-canonical stored expression, so the record always holds the canonical text. When either day-of-month or day-of-week is a star, a local date matches only when both fields match; when neither is a star, either field matching is enough. A field is a star when its text starts with `*`, independently of the values it matches, so a stepped star constrains alongside the other field instead of substituting for it. The first target is the first strictly future instant whose local date and time in that zone match, using the same gap skip and earlier-overlap rules as Daily. See [cron timing](../../../docs/subsystems/schedule.md#cron-wall-clock-input) for the full dialect and canonicalization rules. -A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and the batch is one durable write, so a normal failure removes nothing and rejects instead of reporting a count; a backend `unknown-state` failure may have removed rows, so the service announces `schedule/changed` before rejecting and clients re-read. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. +A reminder is bound to the calling Agent's Session. The shared `schedule` Remote namespace exposes `catalog` for active and inactive Host tasks with their original Session ids, and `list`, `history`, `update`, and `delete` with an explicit Session id. It also exposes `deleteInactive`, which removes every inactive task across Sessions and answers how many rows it removed; an inactive task never arms a timer, so that removal recomputes no target, and the batch is one durable write, so a normal failure removes nothing and rejects instead of reporting a count; a backend `unknown-state` failure may have removed rows and quarantines the Host schedule domain, so later schedule operations reject until it is reopened, and any rejection announces `schedule/changed` so clients re-read. Remote `list` and model `schedule_list` return only active tasks. Catalog entries retain only the latest receipt in `lastDelivery`; catalog and list responses omit saved delivery history. None of these operations activates an Agent or reads Session logs. Explicit deletion stops future delivery, leaves the original Session and already queued messages intact, and removes the stored task row together with its saved delivery records: the task leaves `list` and `catalog`, never schedules again, and `history` answers `schedule_not_found` for the same `(sessionId, id)`. Archiving a Session with active reminders is refused until they stop, and choosing to stop them deletes every active reminder; unarchiving does not bring them back. diff --git a/packages/schedule/schedule/README.zh.md b/packages/schedule/schedule/README.zh.md index 9c3d0b79e1..cc8866fdbd 100644 --- a/packages/schedule/schedule/README.zh.md +++ b/packages/schedule/schedule/README.zh.md @@ -44,7 +44,7 @@ Schedule 将一次性、固定周期、按每日、按每周以及 cron 本地 cron 输入携带 `expression` 和 `time_zone`。表达式是标准五字段 Vixie 形式 `minute hour day-of-month month day-of-week`:minute 0-59、hour 0-23、day-of-month 1-31、month 1-12、day-of-week 0-7,其中 `0` 和 `7` 都表示周日。每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。`L`、`W`、`#`、`JAN`/`MON` 名称、`@daily` 风格宏、六字段表达式、超出范围的值、反向范围、零步长和空字段都会以 `invalid_rule` 拒绝,且错误信息会指出违规字段。由于该方言只有五个字段,最小间隔为一分钟。创建时存储规范化表达式:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长,因此下文的日规则在存储后保持不变。持久化解码器拒绝非规范化的已存储表达式,因此记录始终保存规范化文本。day-of-month 或 day-of-week 中任一为星号字段时,本地日期需两个字段都匹配;两者都不是星号字段时,匹配任一字段即可。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此步长星号字段与其他字段同时约束,而不是替代它。首个目标是该时区内本地日期与时间匹配的第一个严格未来时点,使用与 Daily 相同的缺口跳过与重叠取较早时点规则。完整方言与规范化规则见 [cron 时间语义](../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input)。 -提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且该批删除是一次持久写入,普通失败时不删除任何行,调用以拒绝结束而不返回计数;后端报告 `unknown-state` 时可能已移除部分行,服务会先发出 `schedule/changed` 再拒绝,客户端重新读取。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 +提醒绑定到调用 Agent 的会话。共享的 `schedule` Remote namespace 提供 `catalog`,返回活动和未运行的宿主任务及其原始会话 id,并提供带明确会话 id 的 `list`、`history`、`update` 和 `delete`。它还提供 `deleteInactive`,删除所有会话中全部未运行(界面显示为「已结束」)任务并返回移除的行数;未运行任务从不会安排定时器,因此该删除不重算任何目标,且该批删除是一次持久写入,普通失败时不删除任何行,调用以拒绝结束而不返回计数;后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,之后该领域的操作都会拒绝,直到重新打开;任何拒绝都会先发出 `schedule/changed`,客户端重新读取。Remote `list` 和模型 `schedule_list` 仅返回活动任务。目录条目的 `lastDelivery` 仅保留最近一次回执;目录和列表响应均不包含已保存的发送历史。这些操作均不激活 Agent 或读取会话日志。显式删除会阻止后续投递,保留原会话和已经入队的消息,并移除已存储的任务行及其已保存的发送记录:任务从 `list` 和 `catalog` 中消失、不再调度,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`。 归档仍有活动提醒的会话会被拒绝,直到这些提醒停止;选择停止它们会删除全部活动提醒,取消归档不会把它们带回来。 diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 82015ff24f..13e9409598 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -372,10 +372,12 @@ export class ScheduleService extends TypertRemoteService { * Ended tasks never schedule another delivery, so the removal changes no pending * target and no timer is recomputed. Rows are enumerated and removed in one FIFO * slot, so a delivery ordered behind this call ends its task after the - * enumeration rather than during it. The batch is one durable write: a failure - * removes nothing and rejects, so the caller never receives a partial removal. - * A backend that reports `unknown-state` may have removed rows, so the service - * announces the change before rejecting and clients re-read the catalog. + * enumeration rather than during it. The batch is one domain write: a normal + * failure rejects with every listed row still stored, while a backend + * `unknown-state` failure may have removed rows and quarantines the Host + * domain, so later schedule operations reject until the domain is reopened. + * Every rejection announces `schedule/changed` because it does not establish + * which rows landed, and clients then re-read the catalog. * @returns Count of removed rows; zero when the Host held no ended task. */ @Remote('deleteInactive') @@ -492,9 +494,11 @@ export class ScheduleService extends TypertRemoteService { /** * Remove rows as one durable batch inside the current queue slot. * - * The batch is one domain write: either every present row is removed and - * announced, or the removal rejects with every row still stored. A rejection - * reason that carries no Error is wrapped with the operation label. + * The batch is one domain write: on success every present row is removed and + * announced. Any rejection announces `schedule/changed` because it does not + * establish which rows landed; a normal failure leaves every row stored, and + * an `unknown-state` failure may have removed some. A rejection reason that + * carries no Error is wrapped with the operation label. * @param ids - rows to remove. * @param label - failure message prefix naming the operation that owned them. * @param rearm - whether the rows were armed, so the pending timer must be recomputed. diff --git a/packages/schedule/schedule/tests/delete-inactive.spec.ts b/packages/schedule/schedule/tests/delete-inactive.spec.ts index 4e15abee18..e46eacca3e 100644 --- a/packages/schedule/schedule/tests/delete-inactive.spec.ts +++ b/packages/schedule/schedule/tests/delete-inactive.spec.ts @@ -167,7 +167,7 @@ it('clears a large ended set in one batch and stays idempotent across repeated c expect(changed).toHaveBeenCalledTimes(rounds + 1) }) -it('removes nothing and reports the batch failure', async () => { +it('reports the batch failure', async () => { const pool = new MemoryMediaPool() const batches: string[][] = [] const backend = gatedScheduleBackend(pool, { diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index 4f240d4c99..d10131b482 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -87,10 +87,11 @@ export interface KvTable { * at call time, so an earlier queued write of the same domain is visible to * it and no other write interleaves. Missing keys are ignored. The in-memory * records reach the committed state before any `domain/changed` listener - * runs. A backend failure rejects with the batch unapplied, leaving every - * record in place. A backend that cannot restore its staged records rejects - * with `unknown-state` and quarantines the domain: synchronous reads and - * every later write reject with that error, while `close()` still works. + * runs. A backend failure normally rejects with the batch unapplied, leaving + * every record in place. A backend that cannot establish or restore the + * batch's state rejects with `unknown-state` and quarantines the domain: + * synchronous reads and every later write reject with that error, while + * `close()` still works. * @param keys - Record keys; duplicates are harmless. * @returns the number of keys that existed and were removed. */ diff --git a/packages/storage/storage-json/README.i18n.yaml b/packages/storage/storage-json/README.i18n.yaml index 96ca9927da..b38a077951 100644 --- a/packages/storage/storage-json/README.i18n.yaml +++ b/packages/storage/storage-json/README.i18n.yaml @@ -24,14 +24,14 @@ en: 7144aa3b5af76874 zh: dfc528d027f86037 /deepseek-ai-dsh-storage-json/use-this-package/observable-behavior: - en: 44e1279b83c7d086 - zh: b7b920b737e9bfe0 + en: 75b7e8c8ae917439 + zh: 30ff8fc276621b7a /deepseek-ai-dsh-storage-json/understand-the-implementation: en: b7d222309b3e5438 zh: 807c7314ee341d94 /deepseek-ai-dsh-storage-json/understand-the-implementation/design-concept: - en: 7baa51f75f15a237 - zh: 1415a2b73940e41d + en: 6cd51dcd7e255715 + zh: 8c4514893f89064a /deepseek-ai-dsh-storage-json/understand-the-implementation/file-formats: en: b3de0a697db4f916 zh: 56b26f7823392bd5 @@ -57,8 +57,8 @@ en: c13c59d13a5f1651 zh: 5aab838ae3ff5324 /deepseek-ai-dsh-storage-json/known-limitations-and-deferred-work: - en: 76b327dc0d93f6ac - zh: f40530debdcd1fb7 + en: 07d386decf38f247 + zh: c3e866b4d8e3372a /deepseek-ai-dsh-storage-json/known-limitations-and-deferred-work/dev-note: en: dd40dd4cb9109aea zh: 3fd79d2242eccadb diff --git a/packages/storage/storage-json/README.md b/packages/storage/storage-json/README.md index 7362b334be..682a63d527 100644 --- a/packages/storage/storage-json/README.md +++ b/packages/storage/storage-json/README.md @@ -53,7 +53,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Observable behavior -A missing `single` file or `per-record` directory opens as an empty unit and materializes on the first write. In `single`, malformed content rejects with `malformed-medium`, and a different stored version rejects with `version-mismatch`. In `per-record`, each malformed or unreadable document, and each document whose version is outside the descriptor's current and compatible versions, reads as an absent record, so one bad document does not reject the unit. Record keys must match `[a-zA-Z0-9_-]+`; an unsafe key rejects before any file operation. Every resolved write is durable, and operations after close reject with `closed`. +A missing `single` file or `per-record` directory opens as an empty unit and materializes on the first write. In `single`, malformed content rejects with `malformed-medium`, and a different stored version rejects with `version-mismatch`. In `per-record`, each malformed or unreadable document, and each document whose version is outside the descriptor's current and compatible versions, reads as an absent record, so one bad document does not reject the unit. In `per-record`, record keys must match `[a-zA-Z0-9_-]+`; an unsafe key rejects before any file operation, while the `single` layout keeps keys opaque. Single-record writes and `single`-layout publishes are durable once resolved; a `per-record` batch deletion depends on the filesystem persisting directory-entry updates (see Known Limitations). A batch that cannot establish or restore its state rejects with `unknown-state`, after which every later operation except `close()` rejects; calls after close reject with `closed`. An empty `per-record` tree can initialize its declared tables from a valid `/.json` whole-unit document only when the source unit name matches and its version is current or declared compatible. The backend leaves that source file unchanged and stamps migrated records with the current version. A source version outside the accepted set leaves the new tree empty. Any document path in a declared table, or a declared `global.json`, suppresses this initialization for the complete unit, even if that document is unreadable or stale. @@ -70,7 +70,7 @@ The two layouts share atomic publication but assign state ownership differently. ### Design concept - **`single` keeps memory authoritative.** Each write changes the in-memory unit, serializes its complete state, and atomically replaces `.json`. A failed publish restores the prior in-memory value. A batch delete is one such publish: the whole batch is applied or restored together. -- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. If that restoration fails, the unit rejects with `unknown-state` and every later operation on it rejects. The batch's renames and unlinks are not followed by a directory fsync, so crash durability follows the filesystem's entry-update guarantees: a crash can resurrect a staged document or leave `.deleting` residue. Each document stamps the unit version and carries one record value. +- **`per-record` keeps the directory authoritative.** Each put or delete changes one `/
/.json` document, and `loadAll()` rereads the tree. A batch delete renames the batch's documents out of the readable set, restores them if a rename fails, and unlinks the staged copies afterwards. If that restoration fails, the unit rejects with `unknown-state` and every later operation on it except `close()` rejects. Each document stamps the unit version and carries one record value. - **Publication is durable per call.** A write uses a temporary file, fsync, atomic `rename()` replacement, and a parent-directory fsync on POSIX. The domain layer's write chain supplies ordering across calls. ### File formats @@ -140,6 +140,7 @@ These limits define when this backend is a poor fit or needs special operational - **`single` rewrites the whole unit** — each write republishes the complete unit file; use `per-record` or route the domain to SQLite when this cost is too high. - **No cross-process write locking** — two processes writing the same unit can interleave replacements; writes to the same file use last-completion wins. - **Windows rename without explicit write-through** — durability relies on libuv's `rename()` (`MoveFileExW` with replacement); the stricter Win32 write-through publish helper from the session-log backend is planned to move down here when the `log` facet lands. +- **`per-record` batch delete is not directory-fsynced** — the batch's renames and unlinks rely on the filesystem persisting directory-entry updates; a crash can restore a deleted document under its original key or leave `.deleting` residue. `single` publishes fsync the parent directory. ### Dev Note diff --git a/packages/storage/storage-json/README.zh.md b/packages/storage/storage-json/README.zh.md index b3d5f1c96f..38923f9bfe 100644 --- a/packages/storage/storage-json/README.zh.md +++ b/packages/storage/storage-json/README.zh.md @@ -53,7 +53,7 @@ kind: "package-reference" ### 可观察行为 -缺失的 `single` 文件或 `per-record` 目录会作为空单元打开,并在第一次写入时物化。在 `single` 中,畸形内容以 `malformed-medium` 拒绝,不同的已存版本以 `version-mismatch` 拒绝。在 `per-record` 中,每份畸形或不可读的文档,以及版本不在描述符的当前版本和兼容版本内的文档,都读作记录不存在,因此单个坏文档不会使单元被拒绝。记录键必须匹配 `[a-zA-Z0-9_-]+`;不安全的键在任何文件操作前被拒绝。每次已完成的写入都已持久化,关闭后的操作以 `closed` 拒绝。 +缺失的 `single` 文件或 `per-record` 目录会作为空单元打开,并在第一次写入时物化。在 `single` 中,畸形内容以 `malformed-medium` 拒绝,不同的已存版本以 `version-mismatch` 拒绝。在 `per-record` 中,每份畸形或不可读的文档,以及版本不在描述符的当前版本和兼容版本内的文档,都读作记录不存在,因此单个坏文档不会使单元被拒绝。在 `per-record` 中记录键必须匹配 `[a-zA-Z0-9_-]+`,不安全的键在任何文件操作前被拒绝;`single` 布局保持键不透明。单记录写入与 `single` 布局的发布在 resolve 后即已持久;`per-record` 的批量删除依赖文件系统对目录项更新的持久化(见已知限制)。无法建立或恢复状态的批量删除以 `unknown-state` 拒绝,之后除 `close()` 外对它的所有操作都会拒绝;关闭后的调用以 `closed` 拒绝。 只有当源单元名称匹配,且源版本为当前版本或已声明的兼容版本时,空的 `per-record` 目录树才可以从有效的 `/.json` 整单元文档初始化其已声明表。后端保持该源文件不变,并为迁移的记录写入当前版本戳。接受集合之外的源版本会使新目录树保持为空。已声明表中只要存在任意文档路径,或存在已声明的 `global.json`,就会对整个单元禁止该初始化,即使该文档不可读或版本陈旧。 @@ -70,7 +70,7 @@ kind: "package-reference" ### 设计理念 - **`single` 以内存为权威状态。** 每次写入都会更改内存单元、序列化其完整状态,并以原子方式替换 `.json`。发布失败会恢复先前的内存值。批量删除就是这样一次发布:整批一起应用或一起恢复。 -- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本;恢复本身失败时该 unit 以 `unknown-state` 拒绝,之后对它的所有操作都会拒绝。批量删除的改名与删除后不做目录 fsync,因此崩溃后的持久性取决于文件系统的目录项更新保证:崩溃可能让暂存文档重新出现,或留下 `.deleting` 残留。 +- **`per-record` 以目录为权威状态。** 每次 put 或 delete 都会更改一个 `/
/.json` 文档,`loadAll()` 则重新读取目录树。每份文档都带有单元版本戳与一条记录值。批量删除先把整批文档改名移出可读集合,任一次改名失败就恢复,之后删除暂存副本;恢复本身失败时该 unit 以 `unknown-state` 拒绝,之后除 `close()` 外对它的所有操作都会拒绝。 - **每次调用都持久发布。** 写入过程使用临时文件、fsync、原子 `rename()` 替换,并在 POSIX 上 fsync 父目录。领域层写入链负责安排跨调用的顺序。 ### 文件格式 @@ -140,6 +140,7 @@ kind: "package-reference" - **`single` 会重写整个单元**——每次写入都重新发布完整单元文件;当此成本过高时,使用 `per-record` 或把领域路由到 SQLite。 - **没有跨进程写锁**——两个进程写入同一单元时可能交错执行替换;对同一文件的写入以最后完成者为准。 - **Windows rename 没有显式 write-through**——持久性依赖 libuv 的 `rename()`(`MoveFileExW` 并启用替换);`log` 分面落地时,计划把会话日志后端更严格的 Win32 write-through 发布辅助函数下移到此处。 +- **`per-record` 批量删除不做目录 fsync**——批量删除的改名与删除依赖文件系统对目录项更新的持久化;崩溃可能让被删记录以原键重新出现,或留下 `.deleting` 残留。`single` 发布会 fsync 父目录。 ### 开发备注 diff --git a/packages/storage/storage-sqlite/README.i18n.yaml b/packages/storage/storage-sqlite/README.i18n.yaml index f7f6e9e2c8..46a2c6628b 100644 --- a/packages/storage/storage-sqlite/README.i18n.yaml +++ b/packages/storage/storage-sqlite/README.i18n.yaml @@ -24,14 +24,14 @@ en: e21da50c3c40bcb8 zh: 0ed58b20e4a32ce9 /deepseek-ai-dsh-storage-sqlite/use-this-package/observable-behavior: - en: b260d7bdf7971f9d - zh: 22dd2f633f507545 + en: eaf8cbb16e426ebe + zh: 7e089537588adb05 /deepseek-ai-dsh-storage-sqlite/understand-the-implementation: en: 6867b52cbb94e572 zh: 187d5d61980c8fe7 /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/design-concept: - en: af6e4b3df6d321cd - zh: 9922901704a6780f + en: 895f7ff7b86a72f2 + zh: 8db4c7702ee4145e /deepseek-ai-dsh-storage-sqlite/understand-the-implementation/open-sequence: en: 50d85e8d8e8b5bc1 zh: 2a7067bd7fb47b35 diff --git a/packages/storage/storage-sqlite/README.md b/packages/storage/storage-sqlite/README.md index b58eec9813..cdf75a86c6 100644 --- a/packages/storage/storage-sqlite/README.md +++ b/packages/storage/storage-sqlite/README.md @@ -54,7 +54,7 @@ Two fields: the database path and the journal mode. `:memory:` opens an in-proce ### Observable behavior -Missing directories and database files are created owner-only (`0o700`/`0o600`); an existing database keeps its modes. A unit whose stored format version differs from its descriptor rejects `version-mismatch`, and a database stamped with a physical layout version other than the current one rejects outright — no migration, pre-release stance. Failures carry stable `StorageError` codes, and writes are durable once resolved. +Missing directories and database files are created owner-only (`0o700`/`0o600`); an existing database keeps its modes. A unit whose stored format version differs from its descriptor rejects `version-mismatch`, and a database stamped with a physical layout version other than the current one rejects outright — no migration, pre-release stance. Failures carry stable `StorageError` codes (`version-mismatch`, `malformed-medium`, `unknown-state`, `closed`); single-record writes and committed batches are durable once resolved. A batch that cannot open or roll back its transaction rejects with `unknown-state`, after which every later operation except `close()` rejects, and calls after close reject `closed`. ----- @@ -69,7 +69,7 @@ The backend is a document-per-row layout over one `node:sqlite` connection, desi ### Design concept - **Document per row.** Each unit table becomes a physical STRICT table `u__
(key TEXT PRIMARY KEY, value TEXT)` whose `value` column holds the record's JSON text; the global singleton lives in a shared `unit_globals` table. One key update touches exactly one row — the reason to route a high-churn domain here. -- **Statement atomicity.** Every single-record write is one prepared statement; a batch delete runs its deletes in one explicit transaction, so the batch commits or rolls back together. A batch that cannot open or roll back its transaction rejects with `unknown-state`, after which every later operation on the unit rejects; write ordering stays the caller's responsibility (the domain layer's write chain). +- **Statement atomicity.** Every single-record write is one prepared statement; a batch delete runs its deletes in one explicit transaction, so the batch commits or rolls back together. A batch that cannot open or roll back its transaction rejects with `unknown-state`, after which every later operation on the unit except `close()` rejects; write ordering stays the caller's responsibility (the domain layer's write chain). - **Names validated before DDL.** Unit and table names must match `UNIT_NAME_RE` before they reach DDL, so no external input is ever interpolated into SQL identifiers. - **Versions fail loud.** The physical layout version lives in `PRAGMA user_version` (fresh databases stamp it last); unit format versions live in the `units` table. Any other stamped value rejects — no migrations. diff --git a/packages/storage/storage-sqlite/README.zh.md b/packages/storage/storage-sqlite/README.zh.md index 40262a66c5..f73d47b32d 100644 --- a/packages/storage/storage-sqlite/README.zh.md +++ b/packages/storage/storage-sqlite/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" ### 可观察行为 -缺失的目录与数据库文件会以仅所有者可访问的权限创建(`0o700`/`0o600`);已有数据库保持其既有模式。已存格式版本与描述符不同的单元拒绝 `version-mismatch`,盖有非当前物理布局版本的数据库会直接拒绝——不做迁移,预发布立场。失败携带稳定的 `StorageError` 代码,写入操作完成后即已持久化。 +缺失的目录与数据库文件会以仅所有者可访问的权限创建(`0o700`/`0o600`);已有数据库保持其既有模式。已存格式版本与描述符不同的单元拒绝 `version-mismatch`,盖有非当前物理布局版本的数据库会直接拒绝——不做迁移,预发布立场。失败携带稳定的 `StorageError` 代码(`version-mismatch`、`malformed-medium`、`unknown-state`、`closed`);单记录写入与已提交批次一旦 resolve 即已持久。无法开启或回滚事务的批量删除以 `unknown-state` 拒绝,之后除 `close()` 外对该 unit 的所有操作都会拒绝;关闭后的调用以 `closed` 拒绝。 ----- @@ -69,7 +69,7 @@ kind: "package-reference" ### 设计理念 - **每行一份文档。** 每个单元表都变成一张物理 STRICT 表 `u__
(key TEXT PRIMARY KEY, value TEXT)`,其 `value` 列保存记录的 JSON 文本;全局单例存放在共享的 `unit_globals` 表中。一个键的更新恰好触碰一行——这就是把高频变更领域路由到这里的原因。 -- **语句原子性。** 每个单记录写入都是一条预处理语句;批量删除在一个显式事务中执行其删除,因此整批一起提交或回滚。无法开启或回滚事务的批量删除以 `unknown-state` 拒绝,之后对该 unit 的所有操作都会拒绝;写入顺序仍由调用方负责(领域层的写入链)。 +- **语句原子性。** 每个单记录写入都是一条预处理语句;批量删除在一个显式事务中执行其删除,因此整批一起提交或回滚。无法开启或回滚事务的批量删除以 `unknown-state` 拒绝,之后除 `close()` 外对该 unit 的所有操作都会拒绝;写入顺序仍由调用方负责(领域层的写入链)。 - **名称在 DDL 之前校验。** 单元名与表名在进入 DDL 之前必须匹配 `UNIT_NAME_RE`,因此任何外部输入都不会被插值进 SQL 标识符。 - **版本明确报错。** 物理布局版本存放在 `PRAGMA user_version`(全新数据库最后盖戳);单元格式版本存放在 `units` 表中。任何其他已标记值都会被拒绝——不做迁移。 diff --git a/packages/storage/storage/README.i18n.yaml b/packages/storage/storage/README.i18n.yaml index 67a8db628b..b9c2bfd431 100644 --- a/packages/storage/storage/README.i18n.yaml +++ b/packages/storage/storage/README.i18n.yaml @@ -36,8 +36,8 @@ en: bbdc4833afb85dad zh: 7494de01d5a4aecf /deepseek-ai-dsh-storage/understand-the-implementation/the-backend-contract: - en: 28b6c4e8efb4ffd1 - zh: 4726c72b925c333f + en: f03b47b38dee85dc + zh: 0fe0fc1e580d6ab9 /deepseek-ai-dsh-storage/understand-the-implementation/source-map: en: 506e29aac47a1913 zh: b71dcbf4080e2c49 diff --git a/packages/storage/storage/README.md b/packages/storage/storage/README.md index 4b5f40b5f9..c21c8972e1 100644 --- a/packages/storage/storage/README.md +++ b/packages/storage/storage/README.md @@ -76,7 +76,7 @@ The hub is a pure registration table with two faces, designed so backends and da ### The backend contract -[`src/backend.ts`](src/backend.ts) is the normative contract for backend implementers, checked clause by clause by the shared conformance suite in `tests/contract.ts`. A backend owns exactly one medium and exposes optional data-shape facets; `kv` is the only facet, and opening a unit yields a versioned, globally-singleton schema handle whose single calls are atomic and durable once resolved. Unit and table names must match `UNIT_NAME_RE`; record keys are arbitrary strings that never reach file paths. The unit does not serialize concurrent writes — ordering belongs to the caller — and a stored version differing from the descriptor rejects `version-mismatch` (no migration). +[`src/backend.ts`](src/backend.ts) is the normative contract for backend implementers, checked for the shared clauses by the shared conformance suite in `tests/contract.ts`. A backend owns exactly one medium and exposes optional data-shape facets; `kv` is the only facet, and opening a unit yields a versioned, globally-singleton schema handle whose single calls are atomic, with durability scoped per layout. Unit and table names must match `UNIT_NAME_RE`; record keys are opaque in the `single` layout and path-safe segments in the `per-record` layout. The unit does not serialize concurrent writes — ordering belongs to the caller — and a stored version differing from the descriptor rejects `version-mismatch` (no migration). ### Source map diff --git a/packages/storage/storage/README.zh.md b/packages/storage/storage/README.zh.md index b0bc512148..31f8c76847 100644 --- a/packages/storage/storage/README.zh.md +++ b/packages/storage/storage/README.zh.md @@ -76,7 +76,7 @@ kind: "package-reference" ### 后端约定 -[`src/backend.ts`](src/backend.ts) 是后端实现者的规范性约定,由 `tests/contract.ts` 中的共享一致性套件逐条款检查。一个后端只拥有一种介质,并暴露可选的数据形状分面;`kv` 是唯一的分面,打开单元即可获得一个带版本、全局单例的 schema 句柄,其每次调用均具备原子性,并在完成时保证持久化。单元名与表名必须匹配 `UNIT_NAME_RE`;记录键是任意字符串,绝不进入文件路径。单元不对并发写入做串行化——顺序由调用方负责——介质上记录的版本与描述符不同时拒绝 `version-mismatch`(不做迁移)。 +[`src/backend.ts`](src/backend.ts) 是后端实现者的规范性约定,由 `tests/contract.ts` 中的共享一致性套件检查共享条款。一个后端只拥有一种介质,并暴露可选的数据形状分面;`kv` 是唯一的分面,打开单元即可获得一个带版本、全局单例的 schema 句柄,其每次调用均具备原子性,持久性保证按布局区分。单元名与表名必须匹配 `UNIT_NAME_RE`;记录键在 `single` 布局中不透明,在 `per-record` 布局中则是路径安全片段。单元不对并发写入做串行化——顺序由调用方负责——介质上记录的版本与描述符不同时拒绝 `version-mismatch`(不做迁移)。 ### 源码地图 diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts index 1dfe6a3c03..9a8a58a5e2 100644 --- a/packages/storage/storage/src/backend.ts +++ b/packages/storage/storage/src/backend.ts @@ -77,10 +77,11 @@ export interface KvUnitDescriptor { * One opened unit. Values are opaque JSON to this layer: no schema, no * events, no domain meaning. The unit does NOT serialize concurrent writes — * write ordering is the caller's responsibility (the domain layer runs one - * write chain per unit); the unit only guarantees that each single call is - * atomic on the medium and durable once resolved (a crash after resolution - * followed by a re-open observes the write). Any call after {@link close} - * rejects with `closed`. + * write chain per unit); each single call is atomic on the medium. Durability + * holds for single-record writes and whole-file publishes. In the + * `per-record` layout batch deletions rely on the filesystem persisting + * directory-entry updates, so a crash can restore a deleted document. Any call + * after {@link close} rejects with `closed`. */ export interface KvUnit { /** @@ -112,15 +113,15 @@ export interface KvUnit { /** * Delete several records durably as one batch. Idempotent: missing keys are * no-ops. Resolves once every listed record is absent from the readable - * state and the backend accepted the batch; a rejection leaves the batch - * unapplied, so no listed record was removed. A backend that cannot + * state and the backend accepted the batch. A rejection normally leaves the + * batch unapplied, so no listed record was removed. A backend that cannot * establish or restore the batch's state rejects with `unknown-state` - * instead: the readable state is then unknown, every later operation on - * that unit rejects with the same error, and only {@link close} still - * releases it. On the `single` layout a failure after the atomic replacement - * (a directory-fsync error) can leave the new file on the medium while the - * caller sees a rejection; the unit keeps its memory consistent and the next - * publish rewrites the file. A backend whose medium can apply a whole batch + * instead: the readable state is then unknown, every later operation on that + * unit rejects with the same error until {@link close} releases it, and + * calls after close reject `closed`. On the `single` layout a + * directory-fsync failure can arrive after the atomic replacement: the new + * file may already hold the batch while the caller sees a rejection, and the + * next publish rewrites it. A backend whose medium can apply a whole batch * in one operation (the `single` document, a row store) must do that instead * of repeating {@link deleteRecord} per key. * @param table - Declared table name. @@ -128,7 +129,7 @@ export interface KvUnit { * layout every key becomes a path segment and must match `[a-zA-Z0-9_-]+` * (an unsafe key rejects before any deletion); in the `single` layout keys * stay opaque. - * @returns resolution after durability. + * @returns resolution after the backend accepted the batch. */ deleteRecords(table: string, keys: readonly string[]): Promise From a0d20b8af01912d1c079fa5224c5a89015c25349 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 19:23:59 +0800 Subject: [PATCH 26/34] test(storage): use the supported throw assertion --- packages/storage/storage-domain/tests/domain.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 40edc985e4..a281104e05 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -442,13 +442,13 @@ describe('durability failure', () => { await expect(failing).rejects.toMatchObject({ code: 'unknown-state' }) await expect(queued).rejects.toMatchObject({ code: 'unknown-state' }) // Synchronous reads and later chain jobs see the quarantined domain. - expect(() => table.get('a')).toThrowError(/unknown-state/) - expect(() => [...table.keys()]).toThrowError(/unknown-state/) - expect(() => table.size).toThrowError(/unknown-state/) + expect(() => table.get('a')).toThrow(/unknown-state/) + expect(() => [...table.keys()]).toThrow(/unknown-state/) + expect(() => table.size).toThrow(/unknown-state/) await expect(table.deleteMany(['missing'])).rejects.toMatchObject({ code: 'unknown-state' }) await expect(table.put('c', { label: 'z', count: 3 })).rejects.toMatchObject({ code: 'unknown-state' }) await expect(domain.global.set({ theme: 'dark' })).rejects.toMatchObject({ code: 'unknown-state' }) - expect(() => domain.global.get()).toThrowError(/unknown-state/) + expect(() => domain.global.get()).toThrow(/unknown-state/) // Releasing the domain stays possible. await expect(domain.close()).resolves.toBeUndefined() }) From 358d1c096dfc7a1fc17a853b4e082a9b99f1f35b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 19:35:23 +0800 Subject: [PATCH 27/34] feat(schedule): lighten the bulk cleanup and clarify its copy --- apps/web/tests/schedule-after.e2e.ts | 4 +-- .../src/client/TaskManagerPage.module.css | 3 +++ .../src/client/TaskManagerPage.tsx | 5 ++-- .../src/client/task-manager-locales.ts | 26 +++++++++---------- .../tests/task-manager-page.client.spec.tsx | 18 +++++++++++++ 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 973401d897..c6ec427d03 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1303,8 +1303,8 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { // The confirmation states the Host-side target and removes nothing until it // is confirmed. await clear.click() - const confirmation = page.getByRole('dialog', { name: 'Clear inactive automation tasks?' }) - await confirmation.getByText('2 inactive tasks', { exact: true }).waitFor() + const confirmation = page.getByRole('dialog', { name: 'Clear inactive tasks?' }) + await confirmation.getByText('2 tasks', { exact: true }).waitFor() await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index a84a2289d2..815181c2fc 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -1466,6 +1466,9 @@ .deleteButton { color: var(--dsw-alias-state-error-primary); + /* The outlined confirmation button keeps its border in the danger colour so + the destructive action reads as a bordered control, not bare text. */ + --dsh-button-outline-color: var(--dsw-alias-state-error-primary); } .page :focus-visible { diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 0d38531291..6498d74073 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -54,7 +54,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) { const catalog = useCatalog(snapshot => snapshot) const { records, status } = catalog const [search, setSearch] = useState('') - const [statusFilter, setStatusFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('active') const [selectedId, setSelectedId] = useState(null) const [clearConfirm, setClearConfirm] = useState(false) const [clearing, setClearing] = useState(false) @@ -186,7 +186,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
{endedCount > 0 && + {/* The open dialog already shows every figure; a tooltip would cover it. */} + + + {open && createPortal(
0 ? decodeSpeed(stats, t) : null const icon = - if (mode === 'compact') return speed === null ? null : + if (mode === 'compact') return speed === null ? null : if (stats.steps === 0) return null const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps }) - const content: PillContent = { stat: 'activity', icon, label: joined(counts, speed) } + const content: PillContent = { stat: 'activity', icon, ...reading(counts, speed) } // A window without one timed figure has no dialog rows to show, so the pill // stays a plain reading instead of a button opening an empty dialog. if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && speed === null) return return ( - +
{stats.llmMs > 0 && ( <> @@ -289,7 +320,7 @@ export const UsagePill = memo(function UsagePill({ useProjection, usePerformance const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null const icon = if (mode === 'compact') { - return cacheHitText === null ? null : + return cacheHitText === null ? null : } // Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output. const total = billedInputTokens(usage) + usage.outputTokens @@ -298,8 +329,7 @@ export const UsagePill = memo(function UsagePill({ useProjection, usePerformance diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index 79281d39d1..8f95e8d863 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -375,6 +375,30 @@ describe('composer stats pills', () => { expect(usageDialog.textContent).toContain('未缓存输入5 tok') }) + it.each([false, true])('shows the full reading on hover only while the label is cut off: %s', (overflow) => { + vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(overflow ? 200 : 80) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80) + const { source } = makeSource({ nodes: [timedStep()] }) + const view = render() + const usagePill = view.getAllByRole('button')[1]! + fireEvent.pointerEnter(usagePill) + expect(view.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? '105 tok · Cache hit 90%' : null) + // The open dialog carries every figure, so the tooltip yields to it. + fireEvent.click(usagePill) + fireEvent.pointerEnter(usagePill) + expect(view.queryByRole('tooltip')).toBeNull() + fireEvent.keyDown(document, { key: 'Escape' }) + fireEvent.pointerLeave(usagePill) + view.rerender( selector('compact')} />) + const plainPill = view.getByText('Cache hit 90%').parentElement! + fireEvent.pointerEnter(plainPill) + expect(view.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? 'Cache hit 90%' : null) + // A tap pins the static reading's tooltip, since no dialog shows it. + fireEvent.pointerLeave(plainPill) + fireEvent.click(plainPill) + expect(view.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? 'Cache hit 90%' : null) + }) + it('keeps the durable usage pill after the visible step window is empty', () => { const { source } = makeSource() const view = render( Date: Thu, 22 Oct 2026 21:18:15 +0800 Subject: [PATCH 30/34] refactor(ui-primitives): measure tooltip clipping when the bubble shows Co-authored-by: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> --- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/src/client/chat/StatsPills.tsx | 36 +++------- .../ui-chat/tests/chat-stats.client.spec.tsx | 69 ++++++++++++------- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/Tooltip.tsx | 11 ++- .../tests/tooltip.client.spec.tsx | 24 +++++++ 10 files changed, 98 insertions(+), 58 deletions(-) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 3dfaab3857..7cc337dbb2 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -24,8 +24,8 @@ en: 41dcd70e14ebe9cc zh: ededead572b59037 /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: 29a7d857b6b1b295 - zh: e17114e48628dd7d + en: cfdfe3d99d3f4e09 + zh: 9330bad3d3f7a689 /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: 382821ca3ffa60dd zh: 9840ffa07c13082a diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 23efe8de09..85b3e65c8d 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -70,7 +70,7 @@ A completed Turn shows an expandable usage row only when the loaded window inclu Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact. -The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. When an ellipsis cuts off a reading, a dialog control shows the full reading in a tooltip on hover or keyboard focus while its dialog is closed, and a reading without a dialog shows it on hover, click, or tap. +The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. When an ellipsis cuts off a reading, a tooltip shows the full reading. A dialog control shows it on hover or keyboard focus, but not while its dialog is open; a reading without a dialog shows it on hover, click, or tap. The session statistics dialog heads its rows with the turn and step counts. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value, so a refused or pending write keeps the selected mode until the Host answers. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index c960b4375e..486da291e6 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -70,7 +70,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, 设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 -输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。读数被省略号截断时,带对话框的控件在对话框未打开时于悬停或键盘聚焦时以提示气泡显示完整读数,没有对话框的读数在悬停、点击或轻触时显示。 +输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。读数被省略号截断时,提示气泡会显示完整读数。带对话框的控件在悬停或键盘聚焦时显示,对话框打开期间不显示;没有对话框的读数在悬停、点击或轻触时显示。会话统计对话框的标题行显示轮次数与步骤数。 明确选择会立即更新所有使用方;Host 接受的设置会校正实时值,因此写入被拒绝或尚未应答时保持所选模式,直到 Host 应答。 diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.tsx b/packages/client/ui-chat/src/client/chat/StatsPills.tsx index 5ffea03e5b..7108c44719 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx @@ -4,7 +4,7 @@ // token totals, and click-open dialogs. Settled-node identity prevents // stream-delta updates from rerendering the pills. -import { memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { memo, useMemo, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { IconDatabaseOutlineRegular, IconGaugeOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' @@ -159,24 +159,6 @@ function reading(first: string, second: string | null): Pick(null) - const [truncated, setTruncated] = useState(false) - useLayoutEffect(() => { - const el = ref.current - /* v8 ignore next -- the ref is attached before layout effects run. */ - if (el === null) return - const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) } - measure() - const observer = new ResizeObserver(measure) - observer.observe(el) - return () => { observer.disconnect() } - }, [text]) - return { ref, truncated } -} - // Every figure rides the durable sessionStats projection, so paging and // compaction cannot change any of them; an assembly without the unit falls // back to the window-scoped fold wholesale (same field names), paid only @@ -193,16 +175,18 @@ function decodeSpeed(stats: WindowStats, t: Translate): string { }) } +/** The full reading above a pill, only while an ellipsis cuts its label; the delay matches the dock's context meter. */ +const FULL_READING_TOOLTIP = { side: 'top', portal: true, delayMs: 200, onlyWhenClipped: true } as const + /** A static reading: used when the pill has no dialog rows or the mode is Compact. */ function PlainPill({ stat, icon, label, text }: PillContent) { - const { ref, truncated } = useLabelTruncated(text) return ( {/* No dialog carries a static reading, so a tap also pins the full text. */} - + {icon} - {label} + {label} @@ -220,11 +204,10 @@ function DialogPill({ stat, icon, label, text, title, titleValue, children }: Pi children: ReactNode }) { const { open, setOpen, rootRef, panelRef, pos } = useStatDialog() - const { ref, truncated } = useLabelTruncated(text) return ( {/* The open dialog already shows every figure; a tooltip would cover it. */} - + {open && createPortal( @@ -278,7 +261,8 @@ export const ActivityPill = memo(function ActivityPill({ useChat, useProjection, // stays a plain reading instead of a button opening an empty dialog. if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && speed === null) return return ( - + // The counts head the dialog, so a cut-off reading stays complete once the dialog opens. +
{stats.llmMs > 0 && ( <> diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index 8f95e8d863..8d63088052 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -268,7 +268,7 @@ describe('composer stats pills', () => { expect(dialog.getAttribute('aria-label')).toBe('Session statistics') // Portaled out of the composer dock. expect(dialog.parentElement).toBe(document.body) - expect(dialog.firstChild?.textContent).toBe('Session statistics') + expect(dialog.firstChild?.textContent).toBe('Session statistics1 turns 1 steps') const details = dialog.querySelector('[data-session-stats-details]') as HTMLElement expect(details).toBeTruthy() expect(details.textContent).toContain('LLM time3.8s') @@ -375,28 +375,51 @@ describe('composer stats pills', () => { expect(usageDialog.textContent).toContain('未缓存输入5 tok') }) - it.each([false, true])('shows the full reading on hover only while the label is cut off: %s', (overflow) => { - vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(overflow ? 200 : 80) - vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80) - const { source } = makeSource({ nodes: [timedStep()] }) - const view = render() - const usagePill = view.getAllByRole('button')[1]! - fireEvent.pointerEnter(usagePill) - expect(view.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? '105 tok · Cache hit 90%' : null) - // The open dialog carries every figure, so the tooltip yields to it. - fireEvent.click(usagePill) - fireEvent.pointerEnter(usagePill) - expect(view.queryByRole('tooltip')).toBeNull() - fireEvent.keyDown(document, { key: 'Escape' }) - fireEvent.pointerLeave(usagePill) - view.rerender( selector('compact')} />) - const plainPill = view.getByText('Cache hit 90%').parentElement! - fireEvent.pointerEnter(plainPill) - expect(view.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? 'Cache hit 90%' : null) - // A tap pins the static reading's tooltip, since no dialog shows it. - fireEvent.pointerLeave(plainPill) - fireEvent.click(plainPill) - expect(view.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? 'Cache hit 90%' : null) + describe('full reading of a cut-off pill', () => { + const FULL = '105 tok · Cache hit 90%' + + function renderCut(overflow: boolean, mode: 'detailed' | 'compact' = 'detailed') { + vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(overflow ? 200 : 80) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80) + vi.useFakeTimers() + const { source } = makeSource({ nodes: [timedStep()] }) + return render( selector(mode)} />) + } + const tooltipText = (view: ReturnType) => view.queryByRole('tooltip')?.textContent ?? null + afterEach(() => { vi.useRealTimers() }) + + it.each([false, true])('shows on hover after the dock delay only while the label is cut off: %s', (overflow) => { + const view = renderCut(overflow) + fireEvent.pointerEnter(view.getAllByRole('button')[1]!) + expect(tooltipText(view)).toBeNull() + act(() => { vi.advanceTimersByTime(200) }) + expect(tooltipText(view)).toBe(overflow ? FULL : null) + }) + + it('shows on keyboard focus of a dialog pill', () => { + const view = renderCut(true) + fireEvent.keyDown(document.body, { key: 'Tab' }) + fireEvent.focus(view.getAllByRole('button')[1]!) + expect(tooltipText(view)).toBe(FULL) + }) + + it('yields to the open dialog, whose head carries the counts', () => { + const view = renderCut(true) + const [timePill] = view.getAllByRole('button') as [HTMLElement] + fireEvent.click(timePill) + fireEvent.pointerEnter(timePill) + act(() => { vi.advanceTimersByTime(200) }) + expect(tooltipText(view)).toBeNull() + expect(view.getByRole('dialog').firstChild?.textContent).toBe('Session statistics1 turns 1 steps') + }) + + it('pins a cut-off static reading on tap', () => { + const view = renderCut(true, 'compact') + const plainPill = view.getByText('Cache hit 90%').parentElement! + fireEvent.pointerDown(plainPill, { pointerType: 'touch' }) + fireEvent.click(plainPill) + expect(tooltipText(view)).toBe('Cache hit 90%') + }) }) it('keeps the durable usage pill after the visible step window is empty', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 7795512361..2b1135f82c 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: 75e1681dcfc61a14 zh: 3eda3143730f83e1 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: dbd7f0e74c45a762 - zh: 7efbfbf5500cc835 + en: cb384a76c254a84e + zh: e043178010ba9a11 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 02f310acbae29955 zh: 1e41393d5d714b2d diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 637ff7f55c..32b064ed4f 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -98,7 +98,7 @@ Writing your own component in your own package is fine when the need is genuinel The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. -`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. +`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. Opt-in `onlyWhenClipped` shows the bubble only when the anchor or an HTML element inside it overflows horizontally when the bubble is about to show, so a label that repeats ellipsis-cut text appears only while it adds information. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index e10743c8f9..5e90d43f69 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -98,7 +98,7 @@ kind: "package-library" 上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 -`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 +`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。可选的 `onlyWhenClipped` 只在气泡即将显示时锚点或其内部某个 HTML 元素横向溢出才显示气泡,因此重复被省略号截断文本的标签只在能补充信息时出现。 diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 37ee6aa831..573afa4515 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -33,6 +33,11 @@ interface AnchorProps { type TooltipLabel = string | (() => string) +function clipped(anchor: HTMLElement): boolean { + return [anchor, ...anchor.querySelectorAll('*')] + .some(el => el instanceof HTMLElement && el.scrollWidth > el.clientWidth) +} + /** * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text, or a resolver evaluated only while visible; an empty string shows only shortcut keys. @@ -54,6 +59,9 @@ type TooltipLabel = string | (() => string) * half-viewport cap would render a slab wider than the surface the anchor sits on; always bounded by viewport margins. * @param props.openOnClick - clicking or tapping also pins the bubble for reading; another click, Escape, * Tab, or an outside pointerdown dismisses it. Defaults to false for ordinary action tooltips. + * @param props.onlyWhenClipped - show the bubble only when the anchor or an HTML element inside it + * overflows horizontally, measured when the bubble is about to show; for labels that repeat + * text an ellipsis may cut off. Defaults to false. * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. * @returns the cloned anchor plus a fixed-position bubble, optionally portaled to the body. * The bubble stays hidden until ResizeObserver supplies its size for viewport fitting; clicking the @@ -62,7 +70,7 @@ type TooltipLabel = string | (() => string) * tooltips; tapping still invokes the child's action and can open an openOnClick explanation. * When no side fits, the viewport-bounded fallback may overlap the anchor; oversized content is scrollable when pinned. */ -export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, openOnClick = false, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; openOnClick?: boolean; children: ReactElement }) { +export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, openOnClick = false, onlyWhenClipped = false, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; openOnClick?: boolean; onlyWhenClipped?: boolean; children: ReactElement }) { const id = useId() const [pinned, setPinned] = useState(false) const anchor = useRef(null) @@ -156,6 +164,7 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', const el = anchor.current /* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */ if (el === null) return + if (onlyWhenClipped && !clipped(el)) return setPos(el.getBoundingClientRect()) announce(true) } diff --git a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx index e4b7891e30..b2e445e080 100644 --- a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx @@ -765,6 +765,30 @@ describe('Tooltip', () => { expect(screen.getByRole('tooltip')).toBeTruthy() }) + it('shows a clipped-only bubble when the anchor or a descendant overflows at show time', () => { + render( + + Full… + , + ) + const anchor = screen.getByRole('button') + const label = screen.getByText('Full…') + const overflowing = new Set() + vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) { return overflowing.has(this) ? 120 : 80 }) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80) + fireEvent.pointerEnter(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.pointerLeave(anchor) + overflowing.add(label) + fireEvent.pointerEnter(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Full label') + fireEvent.pointerLeave(anchor) + overflowing.clear() + overflowing.add(anchor) + fireEvent.pointerEnter(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Full label') + }) + it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => { render( From d82234116b9144aa31c4159db581928833381f4f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 21:22:11 +0800 Subject: [PATCH 31/34] feat(ui-schedule): align bulk cleanup with the search-row More menu --- apps/web/tests/schedule-after.e2e.ts | 15 ++- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 +- packages/client/ui-schedule/README.zh.md | 2 +- .../src/client/TaskManagerPage.module.css | 31 ++++- .../src/client/TaskManagerPage.tsx | 79 +++++++---- .../src/client/task-manager-locales.ts | 31 ++--- .../tests/task-manager-page.client.spec.tsx | 123 ++++++++++-------- 8 files changed, 177 insertions(+), 110 deletions(-) diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index c6ec427d03..b400dbecd9 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1297,18 +1297,19 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { // shared page; this scenario asserts the complete list. await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('') await manager.getByRole('button', { name: 'All', exact: true }).click() - const clear = manager.getByRole('button', { name: 'Clear inactive', exact: true }) - await clear.waitFor({ timeout: 15_000 }) + const more = manager.getByRole('button', { name: 'More task actions', exact: true }) + await more.waitFor({ timeout: 15_000 }) // The confirmation states the Host-side target and removes nothing until it // is confirmed. - await clear.click() - const confirmation = page.getByRole('dialog', { name: 'Clear inactive tasks?' }) - await confirmation.getByText('2 tasks', { exact: true }).waitFor() + await more.click() + await page.getByRole('menuitem', { name: 'Clear all inactive tasks', exact: true }).click() + const confirmation = page.getByRole('dialog', { name: 'Clear 2 inactive tasks?' }) await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2) - await clear.click() + await more.click() + await page.getByRole('menuitem', { name: 'Clear all inactive tasks', exact: true }).click() await confirmation.getByRole('button', { name: 'Clear tasks', exact: true }).click() await expect.poll( async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive'), @@ -1325,7 +1326,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { .toBe('H1') // The page follows the acknowledged removal: no ended row or cleanup action // remains, and the running task is still listed. - await expect.poll(() => manager.getByRole('button', { name: 'Clear inactive', exact: true }).count(), { timeout: 15_000 }) + await expect.poll(() => manager.getByRole('button', { name: 'More task actions', exact: true }).count(), { timeout: 15_000 }) .toBe(0) await manager.getByRole('button', { name: 'All', exact: true }).click() await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' }) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index c887fb21cf..638fe7d415 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: 0dfe5bbe9826aaa6 - zh: 9f480110c7652f1b + en: 88358bb539a44e0f + zh: 76b9022eef7ac660 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 47cd16c142e82385 zh: 181849a3c7a167fd diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 1e6463cbf3..ae0dbf1cf9 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -25,7 +25,7 @@ Manage active and inactive tasks from the Automation tasks page and the right Si ## Use this package -The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Once the catalog holds an inactive task, a Clear inactive action sits left of New and removes every inactive task after a confirmation that states how many inactive tasks the catalog currently holds. The page's task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. +The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Once the catalog holds an inactive task, the search row's More task actions menu offers Clear all inactive tasks, which removes every inactive task after a confirmation that states how many inactive tasks the catalog currently holds. The page's task list supports text search and one Enabled, Inactive, and All status filter row, starting on Enabled; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation. The page header keeps the title at its natural width and moves the actions to another line when the title and the actions no longer fit together. Each action label stays on one line. On a coarse pointer the list's search box and the detail's instruction and run-time fields take a 16px step — iOS Safari zooms the visual viewport for a focused field below that size — while the name control keeps its 20px step and a fine pointer keeps the compact steps. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 76fad49770..19ab5ce49a 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在已结束任务后,“新建”左侧出现「清理已结束」操作,经确认后移除全部已结束任务,确认提示说明目录当前包含多少个已结束任务。列表支持文本搜索和一行“全部/已开启/已结束”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或已结束任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 +随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在已结束任务后,搜索行右侧的「更多任务操作」菜单提供「清除全部已结束任务」,经确认后移除全部已结束任务,确认提示说明目录当前包含多少个已结束任务。列表支持文本搜索和一行“已开启/已结束/全部”状态筛选,默认停在“已开启”;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或已结束任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 页头按标题的自然宽度排布;标题和操作无法并排容纳时,将操作移到下一行。每个操作文案保持单行。粗指针设备上,列表的搜索框以及详情的指令和运行时间输入框采用 16px 字号——iOS Safari 会放大聚焦到更小字号输入框时的视觉视口——而名称输入框保留 20px、细指针设备保留紧凑字号。 diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index 815181c2fc..7c4acf5a3d 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -149,11 +149,36 @@ neutral hairline, the 10px radius, and the mock's `--muted` leading icon. The mock brightens the border on hover and takes the brand stroke on focus rather than drawing a ring around the control. */ -.searchField { +.searchTools { + --task-search-height: 36px; display: flex; align-items: center; - height: 36px; - margin: 0 0 16px; + gap: 8px; + margin-bottom: 16px; +} + +.catalogActions { + flex: none; +} + +.catalogMore { + width: var(--task-search-height); + height: var(--task-search-height); + padding: 0; +} + +.catalogMore[aria-expanded='true'] { + background: var(--dsw-alias-interactive-bg-hover); +} + +.searchField { + box-sizing: border-box; + display: flex; + flex: 1; + min-width: 0; + align-items: center; + height: var(--task-search-height); + margin: 0; padding: 0 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 12px; diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx index 6498d74073..3c330c9f26 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.tsx +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.tsx @@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconClockOutlineRegular, IconCloseOutlineRegular, IconPlusOutlineRegular, IconSearchOutlineRegular, Input, Modal, + Button, IconClockOutlineRegular, IconCloseOutlineRegular, IconEllipsisOutlineRegular, IconPlusOutlineRegular, + IconSearchOutlineRegular, IconTrashOutlineRegular, Input, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -57,6 +58,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) { const [statusFilter, setStatusFilter] = useState('active') const [selectedId, setSelectedId] = useState(null) const [clearConfirm, setClearConfirm] = useState(false) + const [clearMenu, setClearMenu] = useState(false) const [clearing, setClearing] = useState(false) // The rows state how long remains, so they read the shared ticking clock // rather than a value sampled at mount: a catalog refresh can move the target @@ -184,22 +186,12 @@ export function TaskManagerPage(props: TaskManagerPageProps) {

{t('title')}

- {endedCount > 0 && }
- {(['all', 'active', 'inactive'] as const).map(value => ( + {(['active', 'inactive', 'all'] as const).map(value => (
-
- } - aria-label={t('search.label')} - placeholder={t('search.placeholder')} - value={search} - onChange={(event) => { setSearch(event.target.value) }} - /> - {search !== '' && } +
+
+ } + aria-label={t('search.label')} + placeholder={t('search.placeholder')} + value={search} + onChange={(event) => { setSearch(event.target.value) }} + /> + {search !== '' && } +
+ {statusFilter !== 'active' && endedCount > 0 && { setClearMenu(false) }} + align="end" + portal + items={[{ + id: 'clear-inactive', label: t(clearing ? 'clear.pending' : 'clear.action'), + icon: , danger: true, + disabled: clearing || status !== 'ready', + }]} + onSelect={() => { + setClearMenu(false) + clearFocusPending.current = true + setClearConfirm(true) + }} + anchor={ + + } + />}
{selected === undefined && 0} onRetry={onRetry} t={t} />} @@ -289,7 +313,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) { className={css.confirmDialog as string} contentClassName={css.confirmContent as string} open={clearConfirm} - title={t('clear.title')} + title={t('clear.title', { count: endedCount })} description={t('clear.description')} closeLabel={t('clear.close')} // Dismissal waits for the settled removal: the confirmation is the only @@ -308,9 +332,8 @@ export function TaskManagerPage(props: TaskManagerPageProps) { {t(clearing ? 'clear.pending' : 'clear.confirm')}
} - > -

{t(endedCount === 1 ? 'clear.count.one' : 'clear.count.other', { count: endedCount })}

- + /> + ) } diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index d420f7248b..8b253d198b 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -134,12 +134,11 @@ export const en = { 'delete.cancel': 'Cancel', 'delete.close': 'Close deletion confirmation', 'delete.pending': 'Deleting…', - 'clear.action': 'Clear inactive', - 'clear.title': 'Clear inactive tasks?', - 'clear.description': 'Tasks are deleted with their saved delivery records; ' - + 'the original sessions and their messages remain.', - 'clear.count.one': '{count} task', - 'clear.count.other': '{count} tasks', + 'list.more': 'More task actions', + 'clear.action': 'Clear all inactive tasks', + 'clear.title': 'Clear {count} inactive tasks?', + 'clear.description': 'These tasks and their saved delivery records will be permanently deleted. ' + + 'Enabled tasks, original sessions, and queued messages are not affected.', 'clear.confirm': 'Clear tasks', 'clear.pending': 'Clearing…', 'clear.close': 'Close clear confirmation', @@ -289,12 +288,11 @@ export const zh: Record = { 'delete.cancel': '取消', 'delete.close': '关闭删除确认', 'delete.pending': '正在删除…', - 'clear.action': '清理已结束', - 'clear.title': '清理已结束任务?', - 'clear.description': '任务及其保存的运行记录将被删除,原会话与其消息保留。', - 'clear.count.one': '共 {count} 个', - 'clear.count.other': '共 {count} 个', - 'clear.confirm': '确认清理', + 'list.more': '更多任务操作', + 'clear.action': '清除全部已结束任务', + 'clear.title': '清除 {count} 个已结束任务?', + 'clear.description': '这些任务及其运行记录将被永久删除,已开启任务、原会话和已排队消息等不受影响。', + 'clear.confirm': '确认清除', 'clear.pending': '正在清理…', 'clear.close': '关闭清理确认', 'toast.deleted': '任务已删除', @@ -440,11 +438,10 @@ export const ja = { 'delete.cancel': 'キャンセル', 'delete.close': '削除確認を閉じる', 'delete.pending': '削除中…', - 'clear.action': '終了済みをクリア', - 'clear.title': '終了したタスクをクリアしますか?', - 'clear.description': 'タスクは保存済みの実行履歴とともに削除されます。元のセッションとそのメッセージは残ります。', - 'clear.count.one': 'タスク {count} 件', - 'clear.count.other': 'タスク {count} 件', + 'list.more': 'その他のタスク操作', + 'clear.action': '終了済みタスクをすべてクリア', + 'clear.title': '終了済みタスク {count} 件をクリアしますか?', + 'clear.description': 'これらのタスクと実行履歴は完全に削除されます。実行中のタスク、元のセッション、キューに入ったメッセージなどは影響を受けません。', 'clear.confirm': 'クリアする', 'clear.pending': 'クリア中…', 'clear.close': 'クリア確認を閉じる', diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index 8802e61fe0..909489804e 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -647,9 +647,11 @@ describe('Task manager catalog', () => { expect(within(filters).getAllByRole('group')).toHaveLength(1) const statuses = within(filters).getByRole('group', { name: en['statusFilter.label'] }) expect(statuses.parentElement).toBe(filters) - expect(within(statuses).getAllByRole('button')[0]?.textContent).toBe(en['statusFilter.all']) + expect(within(statuses).getAllByRole('button')[0]?.textContent).toBe(en['status.active']) // The mock's `.searchbox`: the same field carries the leading glyph, the // placeholder, and — once a value exists — the inline clear control. + const searchTools = page.querySelector(`.${css.searchTools!}`)! + expect(searchTools.contains(page.querySelector(`.${css.searchField!}`))).toBe(true) const field = page.querySelector(`.${css.searchField!}`)! const input = screen.getByRole('searchbox', { name: en['search.label'] }) expect(field.contains(input)).toBe(true) @@ -970,22 +972,43 @@ describe('clearing inactive tasks', () => { ...ended, id: 'task-ended-two' as ScheduleId, title: 'Ended report', prompt: 'Ended report', } - /** The heading's action pair, left to right. */ + /** The heading's actions; only the primary New control remains there. */ function creationActions(): HTMLElement { return document.querySelector(`.${css.creationActions!}`)! } - it('offers the cleanup beside New only while the catalog holds an ended task', () => { + /** The search row's 36px More trigger. */ + function moreTrigger(dictionary: typeof en | typeof zh = en): HTMLElement { + return screen.getByRole('button', { name: dictionary['list.more'] }) + } + + /** + * Open the cleanup confirmation through the More menu. + * @param dictionary - locale under test. + * @param count - ended rows the page's complete catalog holds. + * @returns the confirmation dialog. + */ + function openClear(dictionary: typeof en | typeof zh = en, count = 1): HTMLElement { + fireEvent.click(moreTrigger(dictionary)) + fireEvent.click(screen.getByRole('menuitem', { name: dictionary['clear.action'] })) + return screen.getByRole('dialog', { + name: dictionary['clear.title'].replace('{count}', String(count)), + }) + } + + it('offers the cleanup in the search row only while an ended task is visible', () => { const h = mount() - expect(within(creationActions()).queryByRole('button', { name: en['clear.action'] })).toBeNull() + expect(creationActions().textContent).toBe(en['new.action']) + expect(screen.queryByRole('button', { name: en['list.more'] })).toBeNull() h.update({ records: [...records, endedTask] }) - // The cleanup sits left of the primary action, which keeps the row's end. - expect(within(creationActions()).getAllByRole('button').map(button => button.textContent)) - .toEqual([en['clear.action'], en['new.action']]) - // An ended task that the current view filters out still offers the cleanup. + // The cleanup is the search row's More menu, not a second heading action. + expect(document.querySelector(`.${css.searchTools!}`)?.contains(moreTrigger())).toBe(true) + expect(within(creationActions()).getAllByRole('button')).toHaveLength(1) + // The Active view hides the cleanup even though the catalog holds ended rows. fireEvent.click(screen.getByRole('button', { name: en['status.active'] })) - expect(screen.queryByRole('button', { name: 'Ended summary' })).toBeNull() - expect(within(creationActions()).getByRole('button', { name: en['clear.action'] })).toBeDefined() + expect(screen.queryByRole('button', { name: en['list.more'] })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: en['statusFilter.all'] })) + expect(moreTrigger()).toBeDefined() }) it('states the complete ended set in the confirmation, not the filtered rows', () => { @@ -994,17 +1017,15 @@ describe('clearing inactive tasks', () => { target: { value: 'Send summary' }, }) expect(taskNames()).toEqual(['Send summary']) - fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) - const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + const dialog = openClear(en, 2) expect(within(dialog).getByText(en['clear.description'])).toBeDefined() - expect(within(dialog).getByText(en['clear.count.other'].replace('{count}', '2'))).toBeDefined() }) it('removes nothing when the confirmation is dismissed', () => { const h = mount({ records: [at, endedTask] }) - fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + openClear() fireEvent.click(screen.getByRole('button', { name: en['delete.cancel'] })) - expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() expect(h.props.onClearEnded).not.toHaveBeenCalled() }) @@ -1012,17 +1033,15 @@ describe('clearing inactive tasks', () => { const pending = Promise.withResolvers() const onClearEnded = vi.fn(() => pending.promise) mount({ records: [at, endedTask] }, en, { onClearEnded }) - const trigger = screen.getByRole('button', { name: en['clear.action'] }) - // A pointer press focuses the control in the browser; jsdom leaves focus alone. + const trigger = moreTrigger() trigger.focus() - fireEvent.click(trigger) - const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + const dialog = openClear() const confirm = within(dialog).getByRole('button', { name: en['clear.confirm'] }) confirm.focus() fireEvent.click(confirm) expect(onClearEnded).toHaveBeenCalledOnce() - const running = screen.getByRole('dialog', { name: en['clear.title'] }) + const running = screen.getByRole('dialog') const runningButton = within(running).getByRole('button', { name: en['clear.pending'] }) // The pressed control keeps the focus and reads as busy; disabling it would // hand focus to the document body mid-removal. @@ -1035,10 +1054,10 @@ describe('clearing inactive tasks', () => { // The confirmation is the only place that states the removal still runs, so // the Escape that would close it waits for the outcome. fireEvent.keyDown(running, { key: 'Escape' }) - expect(screen.getByRole('dialog', { name: en['clear.title'] })).toBe(running) + expect(screen.getByRole('dialog')).toBe(running) await act(async () => { pending.resolve(undefined) }) - expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(trigger) }) @@ -1046,16 +1065,16 @@ describe('clearing inactive tasks', () => { const pending = Promise.withResolvers() const onClearEnded = vi.fn(() => pending.promise) const h = mount({ records: [at, endedTask] }, en, { onClearEnded }) - const trigger = screen.getByRole('button', { name: en['clear.action'] }) + const trigger = moreTrigger() trigger.focus() - fireEvent.click(trigger) - fireEvent.click(screen.getByRole('button', { name: en['clear.confirm'] })) + const dialog = openClear() + fireEvent.click(within(dialog).getByRole('button', { name: en['clear.confirm'] })) // The acknowledged removal's readback drops the ended rows, and with them the // trigger the Modal captured when it opened. act(() => { h.update({ records: [at] }) }) - expect(screen.queryByRole('button', { name: en['clear.action'] })).toBeNull() + expect(screen.queryByRole('button', { name: en['list.more'] })).toBeNull() await act(async () => { pending.resolve(undefined) }) - expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) }) @@ -1063,43 +1082,42 @@ describe('clearing inactive tasks', () => { const h = mount({ records: [at, endedTask] }) fireEvent.click(screen.getByRole('button', { name: 'Ended summary' })) expect(screen.getByRole('complementary', { name: en['detail.label'] })).toBeDefined() - fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) - const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + const dialog = openClear() // A refresh drops the selected task while the confirmation is open; closing // the detail must not take the focus out of the dialog. act(() => { h.update({ records: [at] }) }) expect(screen.queryByRole('complementary', { name: en['detail.label'] })).toBeNull() expect(dialog.contains(document.activeElement)).toBe(true) fireEvent.keyDown(dialog, { key: 'Escape' }) - expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() // The row that held the focus is gone with the detail, so the heading takes it. expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) }) it('falls back to the page heading when another cleanup removes the trigger', () => { const h = mount({ records: [at, endedTask] }) - const trigger = screen.getByRole('button', { name: en['clear.action'] }) + const trigger = moreTrigger() trigger.focus() - fireEvent.click(trigger) + const dialog = openClear() // Another client's cleanup removes the ended rows while this confirmation // is open, so the control the Modal captured is unmounted too. act(() => { h.update({ records: [at] }) }) - expect(screen.queryByRole('button', { name: en['clear.action'] })).toBeNull() - fireEvent.keyDown(screen.getByRole('dialog', { name: en['clear.title'] }), { key: 'Escape' }) - expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(screen.queryByRole('button', { name: en['list.more'] })).toBeNull() + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1 })) }) it('focuses the rebuilt trigger when the dialog closes', () => { const h = mount({ records: [at, endedTask] }) - fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) + const dialog = openClear() // Another client clears the ended rows, then a delivery ends one more while // the confirmation stays open: the trigger unmounts and a new one mounts. act(() => { h.update({ records: [at] }) }) act(() => { h.update({ records: [at, anotherEnded] }) }) - const rebuilt = screen.getByRole('button', { name: en['clear.action'] }) - fireEvent.keyDown(screen.getByRole('dialog', { name: en['clear.title'] }), { key: 'Escape' }) - expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + const rebuilt = moreTrigger() + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(rebuilt) }) @@ -1107,10 +1125,10 @@ describe('clearing inactive tasks', () => { const pending = Promise.withResolvers() const onClearEnded = vi.fn(() => pending.promise) const h = mount({ records: [at, endedTask] }, en, { onClearEnded }) - const trigger = screen.getByRole('button', { name: en['clear.action'] }) + const trigger = moreTrigger() trigger.focus() - fireEvent.click(trigger) - fireEvent.click(screen.getByRole('button', { name: en['clear.confirm'] })) + const dialog = openClear() + fireEvent.click(within(dialog).getByRole('button', { name: en['clear.confirm'] })) // The removal settles while the catalog still lists the ended row: the // dialog closes and focus returns to the trigger. await act(async () => { pending.resolve(undefined) }) @@ -1125,18 +1143,16 @@ describe('clearing inactive tasks', () => { mount({ records: [at, endedTask] }) fireEvent.click(screen.getByRole('button', { name: 'Review release' })) const detail = screen.getByRole('complementary', { name: en['detail.label'] }) - fireEvent.click(screen.getByRole('button', { name: en['clear.action'] })) - const dialog = screen.getByRole('dialog', { name: en['clear.title'] }) + const dialog = openClear() fireEvent.keyDown(dialog, { key: 'Escape' }) - expect(screen.queryByRole('dialog', { name: en['clear.title'] })).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() expect(screen.getByRole('complementary', { name: en['detail.label'] })).toBe(detail) }) it('states the cleanup and its confirmation in Chinese', () => { mount({ records: [at, endedTask] }, zh) - fireEvent.click(screen.getByRole('button', { name: zh['clear.action'] })) - const dialog = screen.getByRole('dialog', { name: zh['clear.title'] }) - expect(within(dialog).getByText(zh['clear.count.one'].replace('{count}', '1'))).toBeDefined() + const dialog = openClear(zh, 1) + expect(within(dialog).getByText(zh['clear.description'])).toBeDefined() expect(within(dialog).getByRole('button', { name: zh['clear.confirm'] })).toBeDefined() }) }) @@ -1750,7 +1766,7 @@ describe('Task manager details', () => { expect(screen.getByRole('heading', { level: 1, name: '自动化任务' })).toBeDefined() expect(screen.getByRole('searchbox', { name: '搜索任务' })).toBeDefined() const statuses = screen.getByRole('group', { name: '任务状态' }) - expect(within(statuses).getAllByRole('button')[0]?.textContent).toBe('全部') + expect(within(statuses).getAllByRole('button')[0]?.textContent).toBe('已开启') fireEvent.click(screen.getByRole('button', { name: 'Check metrics' })) const detail = screen.getByRole('complementary', { name: '任务详情' }) expect(within(detail).getByText(zh['rule.everySeconds'])).toBeDefined() @@ -4034,9 +4050,14 @@ describe('Mock detail metrics shared with the task list', () => { expect(tab).toMatch(/font-size:\s*14px;/) // The selected chip keeps the chip's own weight; only the fill marks it. expect(rule('filterTabActive')).not.toMatch(/font-weight/) - // The mock's `.searchbox` keeps its hairline at the repository's 0.5px. + // The search row fixes the mock's 36px control height for the field and the + // square More trigger, and keeps the hairline at the repository's 0.5px. + expect(rule('searchTools')).toMatch(/--task-search-height:\s*36px;/) + expect(rule('catalogMore')).toMatch(/width:\s*var\(--task-search-height\);/) + expect(rule('catalogMore')).toMatch(/height:\s*var\(--task-search-height\);/) + expect(rule('catalogMore')).toMatch(/padding:\s*0;/) const field = rule('searchField') - expect(field).toMatch(/height:\s*36px;/) + expect(field).toMatch(/height:\s*var\(--task-search-height\);/) expect(field).toMatch(/padding:\s*0 10px;/) expect(field).toMatch(/border:\s*0\.5px solid var\(--dsw-alias-border-l3\);/) expect(field).toMatch(/border-radius:\s*12px;/) From 1c20d3fe364aff0b1a8328fdc28aaf512a143b59 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 21:29:38 +0800 Subject: [PATCH 32/34] fix(ui-schedule): align cleanup notices with the shared copy --- packages/client/ui-schedule/README.i18n.yaml | 4 ++-- packages/client/ui-schedule/README.md | 2 +- packages/client/ui-schedule/README.zh.md | 2 +- .../ui-schedule/src/client/task-manager-locales.ts | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index 638fe7d415..6af57d26c8 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: 88358bb539a44e0f - zh: 76b9022eef7ac660 + en: fccffd577fcf93f2 + zh: 4719236f7d89e0a8 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: en: 47cd16c142e82385 zh: 181849a3c7a167fd diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index ae0dbf1cf9..ee91c4aa00 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -41,7 +41,7 @@ After the last saved page loads, a nonempty history with confirmed pruning shows A successful empty history page is distinct from loading, query failure, a missing task, or an invalid cursor. Failed requests retain previously loaded records with a warning and Retry delivery records; an invalid cursor offers Refresh delivery records to reload the newest page. Changing the latest `messageId` refreshes the newest page without switching tabs. Responses superseded by a refresh or received after changing tasks, leaving Records, or closing details do not replace the current view. -The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N inactive tasks.` with the count the Host answered, `No inactive tasks to clear.` when the Host reports that it removed none, and `Could not clear every inactive task.` after a failure; a normal failure leaves the remaining inactive rows for a retry, while a backend `unknown-state` failure may have removed some rows and quarantines the Host's schedule domain until it is reopened. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. +The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N inactive tasks.` with the count the Host answered, `No inactive tasks to clear.` when the Host reports that it removed none, and `Could not clear tasks.` after a failure; a normal failure leaves the remaining inactive rows for a retry, while a backend `unknown-state` failure may have removed some rows and quarantines the Host's schedule domain until it is reopened. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed. An open Session renders an icon-only clock button among its header's right-aligned utilities, immediately left of the overflow menu, only once it has something to open: the first read mounts nothing, a read that returns no active reminder leaves the header without the button, a refresh keeps the last known reminders visible, and a failed read keeps the button with its retry. That rule prevents the clock from appearing and disappearing around the first answer. Its accessible name states the reminder count when the read is ready and the generic reminder label otherwise. With exactly one active reminder, the button opens that task's detail in the right Sidebar directly. With any other count it opens a popover that lists overdue reminders first, then future reminders by target time, with stored title, frequency, browser-local target time, relative time, and a Delete button; the title is a button that opens that task's detail in the right Sidebar. The task tab names the shown task's stored title on its chip and carries the same rule, delivery records, name, instruction, and run-time editing, confirmed deletion, and original-Session link as the Automation tasks page; it never activates a Session. Daily frequency text retains the saved local time and zone, and every next-target instant is shown as a device-zone clock with its relative duration in parentheses; the rule's own zone appears only in that frequency text. Escape closes the popover and returns focus to the button; an outside pointer press dismisses it. Deleting the final reminder removes the button, and an already open popover stays expandable until then. diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index 19ab5ce49a..c89225104e 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。 -自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「无法清理全部已结束任务」;普通失败会保留尚未移除的已结束行以便重试,后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,直到重新打开。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以已结束状态保存。显式删除前,通过全部或已结束筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;已结束状态和该回执均不表示模型执行完成。 +自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「清除任务失败」;普通失败会保留尚未移除的已结束行以便重试,后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,直到重新打开。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以已结束状态保存。显式删除前,通过全部或已结束筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;已结束状态和该回执均不表示模型执行完成。 已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 diff --git a/packages/client/ui-schedule/src/client/task-manager-locales.ts b/packages/client/ui-schedule/src/client/task-manager-locales.ts index 8b253d198b..f416195b6e 100644 --- a/packages/client/ui-schedule/src/client/task-manager-locales.ts +++ b/packages/client/ui-schedule/src/client/task-manager-locales.ts @@ -147,7 +147,7 @@ export const en = { 'toast.cleared.one': 'Cleared {count} inactive task.', 'toast.cleared.other': 'Cleared {count} inactive tasks.', 'toast.clearedNone': 'No inactive tasks to clear.', - 'toast.clearFailed': 'Could not clear every inactive task.', + 'toast.clearFailed': 'Could not clear tasks.', 'card.open': 'Open', 'card.openLabel': 'Open task details: {title}', 'card.deleted': 'Deleted', @@ -297,10 +297,10 @@ export const zh: Record = { 'clear.close': '关闭清理确认', 'toast.deleted': '任务已删除', 'toast.deleteFailed': '无法删除任务', - 'toast.cleared.one': '已清理 {count} 个已结束任务', - 'toast.cleared.other': '已清理 {count} 个已结束任务', + 'toast.cleared.one': '已清除 {count} 个已结束任务', + 'toast.cleared.other': '已清除 {count} 个已结束任务', 'toast.clearedNone': '没有可清理的已结束任务', - 'toast.clearFailed': '无法清理全部已结束任务', + 'toast.clearFailed': '清除任务失败', 'card.open': '打开', 'card.openLabel': '打开任务详情:{title}', 'card.deleted': '已删除', @@ -450,7 +450,7 @@ export const ja = { 'toast.cleared.one': '終了したタスクを {count} 件クリアしました。', 'toast.cleared.other': '終了したタスクを {count} 件クリアしました。', 'toast.clearedNone': 'クリアする終了済みタスクはありません。', - 'toast.clearFailed': '終了したタスクをすべてクリアできませんでした。', + 'toast.clearFailed': 'タスクをクリアできませんでした。', 'card.open': '開く', 'card.openLabel': 'タスクの詳細を開く: {title}', 'card.deleted': '削除済み', From 583f6b529c6f43dfbfc90621b2bf7d2f46ff57c2 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 22 Oct 2026 21:46:09 +0800 Subject: [PATCH 33/34] test(ui-schedule): cover closing the cleanup menu --- .../ui-schedule/tests/task-manager-page.client.spec.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index 909489804e..e185f2d136 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -1021,6 +1021,15 @@ describe('clearing inactive tasks', () => { expect(within(dialog).getByText(en['clear.description'])).toBeDefined() }) + it('closes the cleanup menu without opening the confirmation', () => { + mount({ records: [at, endedTask] }) + fireEvent.click(moreTrigger()) + expect(screen.getByRole('menuitem', { name: en['clear.action'] })).toBeDefined() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menuitem', { name: en['clear.action'] })).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() + }) + it('removes nothing when the confirmation is dismissed', () => { const h = mount({ records: [at, endedTask] }) openClear() From 57195d1934f06c67e064bed09b6913760c948538 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:48:18 -0700 Subject: [PATCH 34/34] refactor(experimental): drop experimental from the card package names The four packages keep their directories under packages/experimental and are registered as experimental name exceptions: - @deepseek-ai/dsh-action-cards - @deepseek-ai/dsh-client-ui-action-cards - @deepseek-ai/dsh-creation-recommendations - @deepseek-ai/dsh-client-ui-creation-recommendations Loader row ids, plugin record names, message source kinds, the Remote service name and locale namespaces are unchanged. Generated catalogs are regenerated, and the page card Web fixture records the renamed bundle in the skill's plugin catalog. --- apps/cli/package.json | 2 +- apps/web/tests/plugin-manager.e2e.ts | 2 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 14 ++--- docs/capability-seams.zh.md | 14 ++--- docs/config-catalog.md | 36 ++++++------- docs/config-catalog.zh.md | 36 ++++++------- docs/event-producer-consumer.md | 8 +-- docs/event-producer-consumer.zh.md | 8 +-- ...experimental-persistence-catalog.i18n.yaml | 4 +- docs/experimental-persistence-catalog.md | 2 +- docs/experimental-persistence-catalog.zh.md | 2 +- docs/module-graph.md | 32 ++++++------ docs/module-graph.zh.md | 32 ++++++------ docs/tool-catalog.i18n.yaml | 16 +++--- docs/tool-catalog.md | 6 +-- docs/tool-catalog.zh.md | 6 +-- package.json | 2 +- packages/boot/official-bundles/src/bundles.ts | 2 +- .../src/client/PluginManagerPage.tsx | 2 +- .../src/client/presentation.ts | 2 +- .../tests/components.client.spec.tsx | 4 +- .../action-cards/README.i18n.yaml | 52 +++++++++---------- packages/experimental/action-cards/README.md | 6 +-- .../experimental/action-cards/README.zh.md | 6 +-- .../experimental/action-cards/package.json | 2 +- .../client-ui-action-cards/README.i18n.yaml | 26 +++++----- .../client-ui-action-cards/README.md | 2 +- .../client-ui-action-cards/README.zh.md | 2 +- .../client-ui-action-cards/package.json | 4 +- .../src/client/Card.tsx | 2 +- .../src/client/index.ts | 2 +- .../src/client/mount.ts | 4 +- .../src/client/registry.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 2 +- .../tests/card.client.spec.tsx | 2 +- .../README.i18n.yaml | 26 +++++----- .../README.md | 2 +- .../README.zh.md | 2 +- .../package.json | 10 ++-- .../src/client/Card.tsx | 4 +- .../src/client/mount.ts | 2 +- .../tests/card.client.spec.tsx | 4 +- .../tests/mount.client.spec.tsx | 4 +- .../creation-recommendations/README.i18n.yaml | 32 ++++++------ .../creation-recommendations/README.md | 2 +- .../creation-recommendations/README.zh.md | 2 +- .../creation-recommendations/cordis.patch.yml | 8 +-- .../creation-recommendations/package.json | 8 +-- .../creation-recommendations/src/index.ts | 8 +-- .../tests/creation.spec.ts | 6 +-- .../creation-recommendations/tests/fixture.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../src/client/slot-catalog.ts | 4 +- .../references/packages.md | 8 +-- pnpm-lock.yaml | 38 +++++++------- scripts/check-workspace-constraints.ts | 2 +- scripts/experimental-package-policy.ts | 10 +++- scripts/gen-doc-graphs.ts | 4 +- scripts/gen-tool-catalog.ts | 4 +- scripts/release/families.spec.ts | 8 +-- .../creation-recommendations/session.v4.jsonl | 2 +- tsconfig.base.json | 16 +++--- 63 files changed, 289 insertions(+), 281 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index a6eaef3486..87cd423cb0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -108,7 +108,7 @@ "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", "@deepseek-ai/dsh-cua-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", - "@deepseek-ai/dsh-experimental-creation-recommendations": "workspace:*" + "@deepseek-ai/dsh-creation-recommendations": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-skill-badge": "workspace:*", diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 230787c3e4..22fe113d45 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -863,7 +863,7 @@ describe('web e2e: plugin manager', () => { ['@deepseek-ai/dsh-experimental-voice-input-bundle', 1], ['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1], ['@deepseek-ai/dsh-experimental-auto-review', 1], - ['@deepseek-ai/dsh-experimental-creation-recommendations', 1], + ['@deepseek-ai/dsh-creation-recommendations', 1], ['@deepseek-ai/dsh-experimental-inspector-profile', 1], ['@deepseek-ai/dsh-cua-bundle', 1], ['@deepseek-ai/dsh-experimental-tool-worktree', 1], diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 08c9fe308b..22bba6039a 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 02cd44eb5166d665 - zh: de0ee0e2ccd07a2a + en: 4b788523038eedcc + zh: 13a97385e756a031 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a67489f4a9..2276e687c6 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -7,10 +7,10 @@ A service can be a core spine service, a swappable capability seam, a bundle/com ```mermaid flowchart LR - pkg_experimental_action_cards["experimental-action-cards"] + pkg_action_cards["action-cards"] svc_actionCards["ctx.actionCards
Optional action cards"] - pkg_experimental_creation_recommendations["experimental-creation-recommendations"] - pkg_experimental_client_ui_action_cards["experimental-client-ui-action-cards"] + pkg_creation_recommendations["creation-recommendations"] + pkg_client_ui_action_cards["client-ui-action-cards"] pkg_headless["headless"] svc_headlessStartup["ctx.headlessStartup
Headless invocation options"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] @@ -302,6 +302,7 @@ 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_action_cards --> svc_actionCards pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop @@ -345,7 +346,6 @@ flowchart LR pkg_deepseek_account --> svc_deepseekAccount pkg_deepseek_account_platform --> svc_deepseekAccount pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_experimental_action_cards --> svc_actionCards pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_api_speech_to_text --> svc_speechController pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse @@ -462,8 +462,8 @@ flowchart LR pkg_working_directory --> svc_workingDirectory pkg_workspace --> svc_workspaceRegistry pkg_workspace_changes --> svc_workspaceChanges - svc_actionCards --> pkg_experimental_client_ui_action_cards - svc_actionCards --> pkg_experimental_creation_recommendations + svc_actionCards --> pkg_client_ui_action_cards + svc_actionCards --> pkg_creation_recommendations svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base @@ -626,7 +626,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | -| `ctx.actionCards` | `service` | [`experimental-action-cards`](../packages/experimental/action-cards) | - | [`experimental-creation-recommendations`](../packages/experimental/creation-recommendations), [`experimental-client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | - | Owns the card tool, card state, user actions and status notices; card kinds register their actions and handlers. | +| `ctx.actionCards` | `service` | [`action-cards`](../packages/experimental/action-cards) | - | [`creation-recommendations`](../packages/experimental/creation-recommendations), [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | - | Owns the card tool, card state, user actions and status notices; card kinds register their actions and handlers. | | `ctx.headlessStartup` | `core` | [`headless`](../packages/bundle/headless) | - | [`headless`](../packages/bundle/headless) | - | Supplies parsed task and run options to the headless runner and its sandbox-policy configuration. | | `ctx.cotTranslation` | `service` | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | - | - | Serves reasoning translations, reuses saved results, and joins ordinary Session activation for uncached inactive Sessions. | | `ctx.translator` | `service` | [`experimental-translator`](../packages/experimental/translator) | - | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | Resolves Google or Bing requests and retains shared experimental records through the active Session writer. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index b626e08f92..76540e1d94 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -9,10 +9,10 @@ ```mermaid flowchart LR - pkg_experimental_action_cards["experimental-action-cards"] + pkg_action_cards["action-cards"] svc_actionCards["ctx.actionCards
Optional action cards"] - pkg_experimental_creation_recommendations["experimental-creation-recommendations"] - pkg_experimental_client_ui_action_cards["experimental-client-ui-action-cards"] + pkg_creation_recommendations["creation-recommendations"] + pkg_client_ui_action_cards["client-ui-action-cards"] pkg_headless["headless"] svc_headlessStartup["ctx.headlessStartup
Headless invocation options"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] @@ -304,6 +304,7 @@ 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_action_cards --> svc_actionCards pkg_agent --> svc_agents pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop @@ -347,7 +348,6 @@ flowchart LR pkg_deepseek_account --> svc_deepseekAccount pkg_deepseek_account_platform --> svc_deepseekAccount pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_experimental_action_cards --> svc_actionCards pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_api_speech_to_text --> svc_speechController pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse @@ -464,8 +464,8 @@ flowchart LR pkg_working_directory --> svc_workingDirectory pkg_workspace --> svc_workspaceRegistry pkg_workspace_changes --> svc_workspaceChanges - svc_actionCards --> pkg_experimental_client_ui_action_cards - svc_actionCards --> pkg_experimental_creation_recommendations + svc_actionCards --> pkg_client_ui_action_cards + svc_actionCards --> pkg_creation_recommendations svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base @@ -628,7 +628,7 @@ flowchart LR | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 | | --- | --- | --- | --- | --- | --- | --- | -| `ctx.actionCards` | `service` | [`experimental-action-cards`](../packages/experimental/action-cards) | - | [`experimental-creation-recommendations`](../packages/experimental/creation-recommendations), [`experimental-client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | - | 负责卡片工具、卡片状态、用户操作和状态通知;各卡片种类注册自己的动作和处理器。 | +| `ctx.actionCards` | `service` | [`action-cards`](../packages/experimental/action-cards) | - | [`creation-recommendations`](../packages/experimental/creation-recommendations), [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | - | 负责卡片工具、卡片状态、用户操作和状态通知;各卡片种类注册自己的动作和处理器。 | | `ctx.headlessStartup` | `core` | [`headless`](../packages/bundle/headless) | - | [`headless`](../packages/bundle/headless) | - | 将解析后的任务和运行选项提供给无界面运行器及其 sandbox-policy 配置。 | | `ctx.cotTranslation` | `service` | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | - | - | 为展开的思考内容提供有界的 Session 翻译请求,不改变模型可见历史。 | | `ctx.translator` | `service` | [`experimental-translator`](../packages/experimental/translator) | - | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | - | 解析 Google 或 Bing 请求,并可选保留统一的实验性 Session 记录。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8b4ff11f12..2683e8f73a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -33,6 +33,21 @@ export interface AcpConfig { ``` + + + +## `@deepseek-ai/dsh-action-cards` + +- `inject`: `tools` · `agents` · `sessionProjections` · `remoteAccess` +- `refs`: `z` (`zod`) +- `source`: [`packages/experimental/action-cards/src/index.ts:23`](../packages/experimental/action-cards/src/index.ts) + +```ts config-catalog +/** Validated card limits after schema defaults. */ +export type Config = z.infer +``` + + @@ -843,21 +858,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-experimental-action-cards` - -- `inject`: `tools` · `agents` · `sessionProjections` · `remoteAccess` -- `refs`: `z` (`zod`) -- `source`: [`packages/experimental/action-cards/src/index.ts:23`](../packages/experimental/action-cards/src/index.ts) - -```ts config-catalog -/** Validated card limits after schema defaults. */ -export type Config = z.infer -``` - - @@ -4704,6 +4704,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-locale` | — | [`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts) | | `@deepseek-ai/dsh-client-modules` | `loader` | [`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts) | | `@deepseek-ai/dsh-client-resources` | — | [`packages/client/resources/src/index.ts`](../packages/client/resources/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-action-cards` | — | [`packages/experimental/client-ui-action-cards/src/index.ts`](../packages/experimental/client-ui-action-cards/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) | @@ -4712,6 +4713,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) | | `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | | `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deliverables` | `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) | | `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) | @@ -4763,17 +4765,15 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-computer-use` | — | [`packages/computer-use/computer-use/src/index.ts`](../packages/computer-use/computer-use/src/index.ts) | | `@deepseek-ai/dsh-config-editor` | `loader` · `profileContext` | [`packages/boot/config-editor/src/index.ts`](../packages/boot/config-editor/src/index.ts) | | `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) | +| `@deepseek-ai/dsh-creation-recommendations` | `tools` · `pluginManager` · `agents` · `subagents` · `profileContext` · `skills` · `actionCards` | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) | | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) | | `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) | | `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) | -| `@deepseek-ai/dsh-experimental-client-ui-action-cards` | — | [`packages/experimental/client-ui-action-cards/src/index.ts`](../packages/experimental/client-ui-action-cards/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) | -| `@deepseek-ai/dsh-experimental-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) | | `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | `computerUse` · `tools` · `systemPrompt` | [`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts) | -| `@deepseek-ai/dsh-experimental-creation-recommendations` | `tools` · `pluginManager` · `agents` · `subagents` · `profileContext` · `skills` · `actionCards` | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-experimental-session-inspector` | — | [`packages/experimental/session-inspector/src/index.ts`](../packages/experimental/session-inspector/src/index.ts) | | `@deepseek-ai/dsh-experimental-tool-worktree` | `tools` · `worktrees` | [`packages/experimental/tool-worktree/src/index.ts`](../packages/experimental/tool-worktree/src/index.ts) | | `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 48c700cbfe..2dfc76edb9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -35,6 +35,21 @@ export interface AcpConfig { ``` + + + +## `@deepseek-ai/dsh-action-cards` + +- `inject`: `tools` · `agents` · `sessionProjections` · `remoteAccess` +- `refs`: `z` (`zod`) +- `source`: [`packages/experimental/action-cards/src/index.ts:23`](../packages/experimental/action-cards/src/index.ts) + +```ts config-catalog +/** Validated card limits after schema defaults. */ +export type Config = z.infer +``` + + @@ -845,21 +860,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-experimental-action-cards` - -- `inject`: `tools` · `agents` · `sessionProjections` · `remoteAccess` -- `refs`: `z` (`zod`) -- `source`: [`packages/experimental/action-cards/src/index.ts:23`](../packages/experimental/action-cards/src/index.ts) - -```ts config-catalog -/** Validated card limits after schema defaults. */ -export type Config = z.infer -``` - - @@ -4706,6 +4706,7 @@ export interface Config { | `@deepseek-ai/dsh-client-locale` | — | [`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts) | | `@deepseek-ai/dsh-client-modules` | `loader` | [`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts) | | `@deepseek-ai/dsh-client-resources` | — | [`packages/client/resources/src/index.ts`](../packages/client/resources/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-action-cards` | — | [`packages/experimental/client-ui-action-cards/src/index.ts`](../packages/experimental/client-ui-action-cards/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) | @@ -4714,6 +4715,7 @@ export interface Config { | `@deepseek-ai/dsh-client-ui-commands` | — | [`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts) | | `@deepseek-ai/dsh-client-ui-conversation` | — | [`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts) | | `@deepseek-ai/dsh-client-ui-cordis` | — | [`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deepseek-model-label-1017` | — | [`packages/client/ui-deepseek-model-label-1017/src/index.ts`](../packages/client/ui-deepseek-model-label-1017/src/index.ts) | | `@deepseek-ai/dsh-client-ui-deliverables` | `connection` · `sessionQuery` · `sessionController` · `workspaceFiles` · `fs` · `sandboxPolicy` · `workspaceChanges` | [`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts) | | `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) | @@ -4765,17 +4767,15 @@ export interface Config { | `@deepseek-ai/dsh-computer-use` | — | [`packages/computer-use/computer-use/src/index.ts`](../packages/computer-use/computer-use/src/index.ts) | | `@deepseek-ai/dsh-config-editor` | `loader` · `profileContext` | [`packages/boot/config-editor/src/index.ts`](../packages/boot/config-editor/src/index.ts) | | `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) | +| `@deepseek-ai/dsh-creation-recommendations` | `tools` · `pluginManager` · `agents` · `subagents` · `profileContext` · `skills` · `actionCards` | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-cua` | — | [`packages/experimental/cua/src/index.ts`](../packages/experimental/cua/src/index.ts) | | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) | | `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) | | `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) | -| `@deepseek-ai/dsh-experimental-client-ui-action-cards` | — | [`packages/experimental/client-ui-action-cards/src/index.ts`](../packages/experimental/client-ui-action-cards/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) | -| `@deepseek-ai/dsh-experimental-client-ui-creation-recommendations` | — | [`packages/experimental/client-ui-creation-recommendations/src/index.ts`](../packages/experimental/client-ui-creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) | | `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | `computerUse` · `tools` · `systemPrompt` | [`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts) | -| `@deepseek-ai/dsh-experimental-creation-recommendations` | `tools` · `pluginManager` · `agents` · `subagents` · `profileContext` · `skills` · `actionCards` | [`packages/experimental/creation-recommendations/src/index.ts`](../packages/experimental/creation-recommendations/src/index.ts) | | `@deepseek-ai/dsh-experimental-session-inspector` | — | [`packages/experimental/session-inspector/src/index.ts`](../packages/experimental/session-inspector/src/index.ts) | | `@deepseek-ai/dsh-experimental-tool-worktree` | `tools` · `worktrees` | [`packages/experimental/tool-worktree/src/index.ts`](../packages/experimental/tool-worktree/src/index.ts) | | `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) | diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e5796e4e90..47056513f2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -11,13 +11,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, `creation-recommendations`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | `action-cards`, [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, `creation-recommendations`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | `action-cards`, [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | `action-cards`, [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 37b4ad08cc..dc555041ff 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -13,13 +13,13 @@ | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, `creation-recommendations`, [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | `action-cards`, [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, `creation-recommendations`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | `action-cards`, [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -82,7 +82,7 @@ | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | `action-cards`, [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `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) | diff --git a/docs/experimental-persistence-catalog.i18n.yaml b/docs/experimental-persistence-catalog.i18n.yaml index 3d57ef63ea..7dd24c2aa4 100644 --- a/docs/experimental-persistence-catalog.i18n.yaml +++ b/docs/experimental-persistence-catalog.i18n.yaml @@ -6,5 +6,5 @@ en: a11f106978c9dce7 zh: a11f106978c9dce7 /experimental-plugin-record-catalog: - en: 292d2b1448650f82 - zh: c4c7118a82026e12 + en: cb931db3b4bcbe33 + zh: aca9cfecfdee34c0 diff --git a/docs/experimental-persistence-catalog.md b/docs/experimental-persistence-catalog.md index 722ae78dc4..b6d3a73de4 100644 --- a/docs/experimental-persistence-catalog.md +++ b/docs/experimental-persistence-catalog.md @@ -13,6 +13,6 @@ Shared Session event declarations and resolved schemas appear in the [Session pe | Record | Owner | Purpose | Payload and source | |---|---|---|---| -| `plugin:action-cards/decision` | `@deepseek-ai/dsh-experimental-action-cards` | Dismissals and kind-defined progress of action cards. | ActionCardDecision · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) | +| `plugin:action-cards/decision` | `@deepseek-ai/dsh-action-cards` | Dismissals and kind-defined progress of action cards. | ActionCardDecision · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/experimental-persistence-catalog.zh.md b/docs/experimental-persistence-catalog.zh.md index 15a66a87e2..c32cdcabd0 100644 --- a/docs/experimental-persistence-catalog.zh.md +++ b/docs/experimental-persistence-catalog.zh.md @@ -13,6 +13,6 @@ | 记录 | 所属包 | 用途 | 载荷与源码 | |---|---|---|---| -| `plugin:action-cards/decision` | `@deepseek-ai/dsh-experimental-action-cards` | Dismissals and kind-defined progress of action cards. | ActionCardDecision · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) | +| `plugin:action-cards/decision` | `@deepseek-ai/dsh-action-cards` | Dismissals and kind-defined progress of action cards. | ActionCardDecision · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/module-graph.md b/docs/module-graph.md index c9e7ec1f3c..d01e34ca6f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -254,9 +254,12 @@ flowchart TD pkg_office_to_pdf["office-to-pdf"] end subgraph group_experimental["packages/experimental"] + pkg_action_cards["action-cards"] + pkg_client_ui_action_cards["client-ui-action-cards"] + pkg_client_ui_creation_recommendations["client-ui-creation-recommendations"] + pkg_creation_recommendations["creation-recommendations"] pkg_cua["cua"] pkg_cua_bundle["cua-bundle"] - pkg_experimental_action_cards["experimental-action-cards"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"] @@ -267,16 +270,13 @@ flowchart TD pkg_experimental_browser_use_runtime["experimental-browser-use-runtime"] pkg_experimental_browser_use_stagehand_native["experimental-browser-use-stagehand-native"] pkg_experimental_claude_code_mods["experimental-claude-code-mods"] - pkg_experimental_client_ui_action_cards["experimental-client-ui-action-cards"] pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] - pkg_experimental_client_ui_creation_recommendations["experimental-client-ui-creation-recommendations"] pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"] - pkg_experimental_creation_recommendations["experimental-creation-recommendations"] pkg_experimental_cua_macos["experimental-cua-macos"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_inspector_profile["experimental-inspector-profile"] @@ -928,10 +928,14 @@ flowchart TD pkg_workspace_changes --> pkg_session pkg_workspace_changes --> pkg_subprocess pkg_workspace_changes --> pkg_tools - pkg_experimental_action_cards --> pkg_llm - pkg_experimental_action_cards --> pkg_session - pkg_experimental_action_cards --> pkg_tools - pkg_experimental_action_cards --> pkg_typert_protocol + pkg_action_cards --> pkg_llm + pkg_action_cards --> pkg_session + pkg_action_cards --> pkg_tools + pkg_action_cards --> pkg_typert_protocol + pkg_creation_recommendations --> pkg_llm + pkg_creation_recommendations --> pkg_session + pkg_creation_recommendations --> pkg_skill + pkg_creation_recommendations --> pkg_tools pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_agent pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_browser_use pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_system_prompt @@ -949,10 +953,6 @@ flowchart TD pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_native --> pkg_tools - pkg_experimental_creation_recommendations --> pkg_llm - pkg_experimental_creation_recommendations --> pkg_session - pkg_experimental_creation_recommendations --> pkg_skill - pkg_experimental_creation_recommendations --> pkg_tools pkg_experimental_cua_macos --> pkg_agent pkg_experimental_cua_macos --> pkg_computer_use pkg_experimental_cua_macos --> pkg_subprocess @@ -1572,13 +1572,13 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | — | | [`credentials`](../packages/credentials/credentials) | `credentials` | — | | [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | +| [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — | +| [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | | [`cua-bundle`](../packages/experimental/cua-bundle) | `experimental` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-badge-skill-bundle`](../packages/experimental/badge-skill-bundle) | `experimental` | — | -| [`experimental-client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | — | | [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | — | -| [`experimental-client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | @@ -1744,12 +1744,12 @@ flowchart TD | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | -| [`experimental-action-cards`](../packages/experimental/action-cards) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`action-cards`](../packages/experimental/action-cards) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 1f8883bf03..778cc80781 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -256,9 +256,12 @@ flowchart TD pkg_office_to_pdf["office-to-pdf"] end subgraph group_experimental["packages/experimental"] + pkg_action_cards["action-cards"] + pkg_client_ui_action_cards["client-ui-action-cards"] + pkg_client_ui_creation_recommendations["client-ui-creation-recommendations"] + pkg_creation_recommendations["creation-recommendations"] pkg_cua["cua"] pkg_cua_bundle["cua-bundle"] - pkg_experimental_action_cards["experimental-action-cards"] pkg_experimental_agent_team["experimental-agent-team"] pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"] @@ -269,16 +272,13 @@ flowchart TD pkg_experimental_browser_use_runtime["experimental-browser-use-runtime"] pkg_experimental_browser_use_stagehand_native["experimental-browser-use-stagehand-native"] pkg_experimental_claude_code_mods["experimental-claude-code-mods"] - pkg_experimental_client_ui_action_cards["experimental-client-ui-action-cards"] pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] - pkg_experimental_client_ui_creation_recommendations["experimental-client-ui-creation-recommendations"] pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"] - pkg_experimental_creation_recommendations["experimental-creation-recommendations"] pkg_experimental_cua_macos["experimental-cua-macos"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_inspector_profile["experimental-inspector-profile"] @@ -930,10 +930,14 @@ flowchart TD pkg_workspace_changes --> pkg_session pkg_workspace_changes --> pkg_subprocess pkg_workspace_changes --> pkg_tools - pkg_experimental_action_cards --> pkg_llm - pkg_experimental_action_cards --> pkg_session - pkg_experimental_action_cards --> pkg_tools - pkg_experimental_action_cards --> pkg_typert_protocol + pkg_action_cards --> pkg_llm + pkg_action_cards --> pkg_session + pkg_action_cards --> pkg_tools + pkg_action_cards --> pkg_typert_protocol + pkg_creation_recommendations --> pkg_llm + pkg_creation_recommendations --> pkg_session + pkg_creation_recommendations --> pkg_skill + pkg_creation_recommendations --> pkg_tools pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_agent pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_browser_use pkg_experimental_browser_use_chrome_devtools_mcp --> pkg_system_prompt @@ -951,10 +955,6 @@ flowchart TD pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_native --> pkg_tools - pkg_experimental_creation_recommendations --> pkg_llm - pkg_experimental_creation_recommendations --> pkg_session - pkg_experimental_creation_recommendations --> pkg_skill - pkg_experimental_creation_recommendations --> pkg_tools pkg_experimental_cua_macos --> pkg_agent pkg_experimental_cua_macos --> pkg_computer_use pkg_experimental_cua_macos --> pkg_subprocess @@ -1574,13 +1574,13 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | — | | [`credentials`](../packages/credentials/credentials) | `credentials` | — | | [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — | +| [`client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — | +| [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | | [`cua-bundle`](../packages/experimental/cua-bundle) | `experimental` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-badge-skill-bundle`](../packages/experimental/badge-skill-bundle) | `experimental` | — | -| [`experimental-client-ui-action-cards`](../packages/experimental/client-ui-action-cards) | `experimental` | — | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | — | | [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | — | -| [`experimental-client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | @@ -1746,12 +1746,12 @@ flowchart TD | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-present`](../packages/deliverables/tool-present) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | -| [`experimental-action-cards`](../packages/experimental/action-cards) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`action-cards`](../packages/experimental/action-cards) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`experimental-creation-recommendations`](../packages/experimental/creation-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/experimental/hooks-codex) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 103ee05d4d..e3b0413830 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: 3b44fba80c4b6baf - zh: 45f8962c849cdc20 + en: 23cebf61cc4df18a + zh: ba1f442d0f6fdbc8 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -54,12 +54,12 @@ en: 4910ab327694ee97 zh: 17e79e4366cb636d /tool-schema-catalog/deepseek-ai-dsh-experimental-browser-use-stagehand-native/stagehand-tabs: - en: c65d58eac812b03c - zh: d4259095ce06e457 -/tool-schema-catalog/deepseek-ai-dsh-experimental-action-cards: - en: 94f08f350e306557 - zh: 94f08f350e306557 -/tool-schema-catalog/deepseek-ai-dsh-experimental-action-cards/propose-action: + en: a0d5b9b891358120 + zh: 25793a62709770b5 +/tool-schema-catalog/deepseek-ai-dsh-action-cards: + en: 60c9df6ffda98a1d + zh: 60c9df6ffda98a1d +/tool-schema-catalog/deepseek-ai-dsh-action-cards/propose-action: en: b2afbd46036c0eb1 zh: 0983902a9034482b /tool-schema-catalog/deepseek-ai-dsh-tool-ask-user: diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 1560977921..e8fad70461 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-plugin-market` | `plugin_market` | `ctx.tools`, `ctx.pluginMarket` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - | -| `@deepseek-ai/dsh-experimental-action-cards` | `propose_action` | `ctx.tools`, `ctx.agents`, `ctx.sessionProjections`, `ctx.remoteAccess` | `tool/call`, `tool/result`, `plugin:action-cards/decision`, `user/message` | - | Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized pages bundle supplies the only shipped kind. | +| `@deepseek-ai/dsh-action-cards` | `propose_action` | `ctx.tools`, `ctx.agents`, `ctx.sessionProjections`, `ctx.remoteAccess` | `tool/call`, `tool/result`, `plugin:action-cards/decision`, `user/message` | - | Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized pages bundle supplies the only shipped kind. | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after an answer or timeout`, `late user/message` | - | ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. | | `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agents`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | @@ -498,9 +498,9 @@ List, create, select, or close a Stagehand browser tab. Source: [`packages/experimental/browser-use-stagehand-native/src/index.ts`](../packages/experimental/browser-use-stagehand-native/src/index.ts) - + -## `@deepseek-ai/dsh-experimental-action-cards` +## `@deepseek-ai/dsh-action-cards` ### `propose_action` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 17130e9d13..c722787360 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -23,7 +23,7 @@ | `@deepseek-ai/dsh-plugin-market` | `plugin_market` | `ctx.tools`, `ctx.pluginMarket` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - | -| `@deepseek-ai/dsh-experimental-action-cards` | `propose_action` | `ctx.tools`、`ctx.agents`、`ctx.sessionProjections`、`ctx.remoteAccess` | `tool/call`、`tool/result`、`plugin:action-cards/decision`、`user/message` | - | 只向加载过已注册卡片种类 Skill 的 Agent 声明;各种类的包注册自己的动作,目前只有个性化页面 Bundle 提供种类。 | +| `@deepseek-ai/dsh-action-cards` | `propose_action` | `ctx.tools`、`ctx.agents`、`ctx.sessionProjections`、`ctx.remoteAccess` | `tool/call`、`tool/result`、`plugin:action-cards/decision`、`user/message` | - | 只向加载过已注册卡片种类 Skill 的 Agent 声明;各种类的包注册自己的动作,目前只有个性化页面 Bundle 提供种类。 | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after an answer or timeout`、`late user/message` | - | ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 | | `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agents`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | @@ -502,9 +502,9 @@ 来源:[`packages/experimental/browser-use-stagehand-native/src/index.ts`](../packages/experimental/browser-use-stagehand-native/src/index.ts) - + -## `@deepseek-ai/dsh-experimental-action-cards` +## `@deepseek-ai/dsh-action-cards` ### `propose_action` diff --git a/package.json b/package.json index 78523eabd9..f3eb2e4a6d 100644 --- a/package.json +++ b/package.json @@ -301,6 +301,6 @@ "@deepseek-ai/dsh-experimental-subagent-oar": "workspace:*", "@deepseek-ai/dsh-experimental-tool-subagent-oar": "workspace:*", "@deepseek-ai/dsh-experimental-cua-macos": "workspace:*", - "@deepseek-ai/dsh-experimental-action-cards": "workspace:*" + "@deepseek-ai/dsh-action-cards": "workspace:*" } } diff --git a/packages/boot/official-bundles/src/bundles.ts b/packages/boot/official-bundles/src/bundles.ts index 74446c2793..c6de2661f7 100644 --- a/packages/boot/official-bundles/src/bundles.ts +++ b/packages/boot/official-bundles/src/bundles.ts @@ -53,7 +53,7 @@ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-bas * [admission](../../../../.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md)). */ export const OPTIONAL_BUNDLES: readonly string[] = [ - '@deepseek-ai/dsh-experimental-creation-recommendations', + '@deepseek-ai/dsh-creation-recommendations', '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle', diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index a0f129b53d..36500e3a91 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -121,7 +121,7 @@ const EXTENSION_BUNDLES = [ ] /** The optional bundles the main list holds back until selected; the More list includes eligible entries. */ const MORE_BUNDLES = [ - '@deepseek-ai/dsh-experimental-creation-recommendations', + '@deepseek-ai/dsh-creation-recommendations', '@deepseek-ai/dsh-experimental-inspector-profile', '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-tool-worktree', diff --git a/packages/client/ui-plugin-manager/src/client/presentation.ts b/packages/client/ui-plugin-manager/src/client/presentation.ts index c0129a5a1a..9495c33be4 100644 --- a/packages/client/ui-plugin-manager/src/client/presentation.ts +++ b/packages/client/ui-plugin-manager/src/client/presentation.ts @@ -110,7 +110,7 @@ export function shortName(name: string): string { /** Official bundles that display the Experimental badge. */ const EXPERIMENTAL_BUNDLES: readonly string[] = [ - '@deepseek-ai/dsh-experimental-creation-recommendations', + '@deepseek-ai/dsh-creation-recommendations', '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-agent-team-profile', '@deepseek-ai/dsh-experimental-voice-input-bundle', diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index b9afb9a7f2..163fa9d28f 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -1239,7 +1239,7 @@ describe('PluginManagerPage', () => { it.each([ '@deepseek-ai/dsh-experimental-session-titles-bundle', - '@deepseek-ai/dsh-experimental-creation-recommendations', + '@deepseek-ai/dsh-creation-recommendations', ])('returns %s details to the secondary page and retains it across React remounts', (name) => { const b = renderTab({ packages: [pkg({ name, official: true, enabled: false })] }) fireEvent.click(screen.getByRole('button', { name: en.moreAction })) @@ -1257,7 +1257,7 @@ describe('PluginManagerPage', () => { it.each([ '@deepseek-ai/dsh-experimental-tool-worktree', - '@deepseek-ai/dsh-experimental-creation-recommendations', + '@deepseek-ai/dsh-creation-recommendations', ])('pins enabled %s onto the main list and removes it when disabled', (first) => { const team = '@deepseek-ai/dsh-experimental-agent-team-profile' const second = '@deepseek-ai/dsh-experimental-cot-translation-bundle' diff --git a/packages/experimental/action-cards/README.i18n.yaml b/packages/experimental/action-cards/README.i18n.yaml index dea014e2a2..249e844e43 100644 --- a/packages/experimental/action-cards/README.i18n.yaml +++ b/packages/experimental/action-cards/README.i18n.yaml @@ -5,63 +5,63 @@ /: en: 1a68a7a733a2676d zh: 01f72eb61a0626b7 -/deepseek-ai-dsh-experimental-action-cards: - en: e57ce09e58e6fabc - zh: 8a727dbd6f925363 -/deepseek-ai-dsh-experimental-action-cards/summary: +/deepseek-ai-dsh-action-cards: + en: 63bf9077e164f6fe + zh: 8b696bb8c3d6fcd4 +/deepseek-ai-dsh-action-cards/summary: en: 7621088773d073f4 zh: 57674e6dcee15997 -/deepseek-ai-dsh-experimental-action-cards/table-of-contents: +/deepseek-ai-dsh-action-cards/table-of-contents: en: d152484eb41ac6b4 zh: b00dddf4c88f0a45 -/deepseek-ai-dsh-experimental-action-cards/use-this-package: - en: f8dd030ecb0470fa - zh: a0df8bd22cdf6600 -/deepseek-ai-dsh-experimental-action-cards/use-this-package/register-a-card-kind: +/deepseek-ai-dsh-action-cards/use-this-package: + en: 5fb9910bf02f3713 + zh: 67c8384d876e7fb2 +/deepseek-ai-dsh-action-cards/use-this-package/register-a-card-kind: en: 8432c02ef18eda11 zh: 6d43a0a32edd7e30 -/deepseek-ai-dsh-experimental-action-cards/use-this-package/card-rules: +/deepseek-ai-dsh-action-cards/use-this-package/card-rules: en: 2f5b3ccb91f3b30e zh: 9bd877d9d92615da -/deepseek-ai-dsh-experimental-action-cards/understand-the-implementation: +/deepseek-ai-dsh-action-cards/understand-the-implementation: en: d92243d71d2106c1 zh: 47f0840fa723bb31 -/deepseek-ai-dsh-experimental-action-cards/further-exploration: +/deepseek-ai-dsh-action-cards/further-exploration: en: db407bb756ee5641 zh: af5d25f4f2351ee6 -/deepseek-ai-dsh-experimental-action-cards/model-experience: +/deepseek-ai-dsh-action-cards/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 -/deepseek-ai-dsh-experimental-action-cards/model-experience/card-tool: +/deepseek-ai-dsh-action-cards/model-experience/card-tool: en: 4925bc5f7153825a zh: 3a7bfb2210d9c6aa -/deepseek-ai-dsh-experimental-action-cards/model-experience/card-tool/what-the-model-sees: - en: 58a76216203226f7 - zh: 0a12b579f6b28f29 -/deepseek-ai-dsh-experimental-action-cards/model-experience/card-tool/token-effect: +/deepseek-ai-dsh-action-cards/model-experience/card-tool/what-the-model-sees: + en: 58521d3992ce42ec + zh: 45681f7b43e67613 +/deepseek-ai-dsh-action-cards/model-experience/card-tool/token-effect: en: 7efaaad09be98a07 zh: 86c7fa13d60c3588 -/deepseek-ai-dsh-experimental-action-cards/model-experience/card-tool/kv-cache-effect: +/deepseek-ai-dsh-action-cards/model-experience/card-tool/kv-cache-effect: en: d4192e35d47b5fa3 zh: c786828e123de657 -/deepseek-ai-dsh-experimental-action-cards/model-experience/card-status-notice: +/deepseek-ai-dsh-action-cards/model-experience/card-status-notice: en: 407ebca5eb94992e zh: 779819ea83974103 -/deepseek-ai-dsh-experimental-action-cards/model-experience/card-status-notice/what-the-model-sees: +/deepseek-ai-dsh-action-cards/model-experience/card-status-notice/what-the-model-sees: en: a14013ca3ddb5ee3 zh: 18cd7ffc62fd6590 -/deepseek-ai-dsh-experimental-action-cards/model-experience/card-status-notice/what-the-model-sees/notice-preamble: +/deepseek-ai-dsh-action-cards/model-experience/card-status-notice/what-the-model-sees/notice-preamble: en: 04dd9469bfaff052 zh: 35110b231b8e3928 -/deepseek-ai-dsh-experimental-action-cards/model-experience/card-status-notice/token-effect: +/deepseek-ai-dsh-action-cards/model-experience/card-status-notice/token-effect: en: 4e73a2cff60b5e88 zh: 4887212815e7425d -/deepseek-ai-dsh-experimental-action-cards/model-experience/card-status-notice/kv-cache-effect: +/deepseek-ai-dsh-action-cards/model-experience/card-status-notice/kv-cache-effect: en: 58785543e2428dba zh: 4b082f77b3b2f482 -/deepseek-ai-dsh-experimental-action-cards/known-limitations-and-deferred-work: +/deepseek-ai-dsh-action-cards/known-limitations-and-deferred-work: en: 2d7ed01235b52b61 zh: 611c6dfb3104827b -/deepseek-ai-dsh-experimental-action-cards/known-limitations-and-deferred-work/dev-note: +/deepseek-ai-dsh-action-cards/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: bd8a8936229e035e diff --git a/packages/experimental/action-cards/README.md b/packages/experimental/action-cards/README.md index 146fec20b5..7a01c0d762 100644 --- a/packages/experimental/action-cards/README.md +++ b/packages/experimental/action-cards/README.md @@ -3,7 +3,7 @@ description: "Shared Host service of optional action cards: the propose_action t kind: "package-reference" --- -# @deepseek-ai/dsh-experimental-action-cards +# @deepseek-ai/dsh-action-cards English | [中文](README.zh.md) @@ -31,7 +31,7 @@ The [Personalized pages bundle](../creation-recommendations/README.md) inserts t |---|---|---| | `queuedMessageLimit` | `2` | Queued next-turn user messages at or above which `propose_action` refuses the call, so no card is offered. | -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-action-cards) is the exhaustive source for accepted fields. +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-action-cards) is the exhaustive source for accepted fields. ### Register a card kind @@ -85,7 +85,7 @@ The Remote `act(sessionId, callId, verb, payload?)` checks the owner, the live r #### What the model sees -The [`propose_action` declaration](../../../docs/tool-catalog.md#deepseek-aidsh-experimental-action-cards) appears only to a live root Agent whose own direct call loaded the skill of a registered kind in this Session, from the next step on, for that Agent's lifetime. Subagents never see it, and neither do PTC presets, whose skill loads are dispatched from code, so they show no cards. A kind's skill names the action and the fields of `details`. An accepted call returns `Card shown below your answer. Do not restate it. `, where `` is the kind's one- or two-sentence guidance on inviting the user to the card in the user's language, so the model writes an invitation only after a card exists. A refused call carries no invitation and returns its reason, for example `Load skill first, then call propose_action.`, `One card per turn; the earlier card stands.`, `The user has queued more messages; show no card and answer briefly.`, `Cards are offered in the main conversation only.`, `Unknown action "". Registered actions: .`, where the list reads `none` once the last kind unregistered, or the failing `details` fields. +The [`propose_action` declaration](../../../docs/tool-catalog.md#deepseek-aidsh-action-cards) appears only to a live root Agent whose own direct call loaded the skill of a registered kind in this Session, from the next step on, for that Agent's lifetime. Subagents never see it, and neither do PTC presets, whose skill loads are dispatched from code, so they show no cards. A kind's skill names the action and the fields of `details`. An accepted call returns `Card shown below your answer. Do not restate it. `, where `` is the kind's one- or two-sentence guidance on inviting the user to the card in the user's language, so the model writes an invitation only after a card exists. A refused call carries no invitation and returns its reason, for example `Load skill first, then call propose_action.`, `One card per turn; the earlier card stands.`, `The user has queued more messages; show no card and answer briefly.`, `Cards are offered in the main conversation only.`, `Unknown action "". Registered actions: .`, where the list reads `none` once the last kind unregistered, or the failing `details` fields. #### Token effect diff --git a/packages/experimental/action-cards/README.zh.md b/packages/experimental/action-cards/README.zh.md index c80125077a..934c23e995 100644 --- a/packages/experimental/action-cards/README.zh.md +++ b/packages/experimental/action-cards/README.zh.md @@ -3,7 +3,7 @@ description: "可选操作卡片的共享 Host 服务:propose_action 工具、 kind: "package-reference" --- -# @deepseek-ai/dsh-experimental-action-cards +# @deepseek-ai/dsh-action-cards [English](README.md) | 中文 @@ -31,7 +31,7 @@ kind: "package-reference" |---|---|---| | `queuedMessageLimit` | `2` | 排队到后续轮次的用户消息达到该数量时,`propose_action` 拒绝调用,因此不会展示卡片。 | -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-action-cards)是可接受字段的完整来源。 +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-action-cards)是可接受字段的完整来源。 ### 注册卡片种类 @@ -85,7 +85,7 @@ Remote `act(sessionId, callId, verb, payload?)` 先检查服务所有者、在 #### 模型看到什么 -[`propose_action` 声明](../../../docs/tool-catalog.zh.md#deepseek-aidsh-experimental-action-cards)只对在本会话中自己直接调用加载过已注册种类 Skill 的在线根 Agent 出现,从下一步开始,持续到该 Agent 被释放。subagent 看不到它;PTC 预设的 Skill 加载由代码发起,也看不到它,因此不会展示卡片。种类的 Skill 给出动作名和 `details` 的字段。被接受的调用返回 `Card shown below your answer. Do not restate it. `,其中 `` 是种类给出的一两句指引,说明如何用用户的语言邀请用户使用卡片,因此模型只会在卡片已经存在之后写邀请。被拒绝的调用不带邀请,只返回原因,例如 `Load skill first, then call propose_action.`、`One card per turn; the earlier card stands.`、`The user has queued more messages; show no card and answer briefly.`、`Cards are offered in the main conversation only.`、`Unknown action "". Registered actions: .`(最后一个种类注销后列表为 `none`),或未通过校验的 `details` 字段。 +[`propose_action` 声明](../../../docs/tool-catalog.zh.md#deepseek-aidsh-action-cards)只对在本会话中自己直接调用加载过已注册种类 Skill 的在线根 Agent 出现,从下一步开始,持续到该 Agent 被释放。subagent 看不到它;PTC 预设的 Skill 加载由代码发起,也看不到它,因此不会展示卡片。种类的 Skill 给出动作名和 `details` 的字段。被接受的调用返回 `Card shown below your answer. Do not restate it. `,其中 `` 是种类给出的一两句指引,说明如何用用户的语言邀请用户使用卡片,因此模型只会在卡片已经存在之后写邀请。被拒绝的调用不带邀请,只返回原因,例如 `Load skill first, then call propose_action.`、`One card per turn; the earlier card stands.`、`The user has queued more messages; show no card and answer briefly.`、`Cards are offered in the main conversation only.`、`Unknown action "". Registered actions: .`(最后一个种类注销后列表为 `none`),或未通过校验的 `details` 字段。 #### Token 影响 diff --git a/packages/experimental/action-cards/package.json b/packages/experimental/action-cards/package.json index 0e8a9ba151..160e8156a0 100644 --- a/packages/experimental/action-cards/package.json +++ b/packages/experimental/action-cards/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-experimental-action-cards", + "name": "@deepseek-ai/dsh-action-cards", "description": "Optional action cards below answers: one shared proposal tool, card state, user actions and status notices", "version": "0.2.1-rc.2", "publishConfig": { diff --git a/packages/experimental/client-ui-action-cards/README.i18n.yaml b/packages/experimental/client-ui-action-cards/README.i18n.yaml index 046da265fe..5f6a93d165 100644 --- a/packages/experimental/client-ui-action-cards/README.i18n.yaml +++ b/packages/experimental/client-ui-action-cards/README.i18n.yaml @@ -5,36 +5,36 @@ /: en: eec43de9002f4ab1 zh: 0d2fddca5a1f993a -/deepseek-ai-dsh-experimental-client-ui-action-cards: - en: 6ed1f2bf10bc2f1b - zh: e3edea71ec4373c7 -/deepseek-ai-dsh-experimental-client-ui-action-cards/summary: +/deepseek-ai-dsh-client-ui-action-cards: + en: c36a554629f96f79 + zh: 0ea837da87938e52 +/deepseek-ai-dsh-client-ui-action-cards/summary: en: 89ea57734c42a481 zh: c7a4f5f0b59b78ad -/deepseek-ai-dsh-experimental-client-ui-action-cards/table-of-contents: +/deepseek-ai-dsh-client-ui-action-cards/table-of-contents: en: d152484eb41ac6b4 zh: b00dddf4c88f0a45 -/deepseek-ai-dsh-experimental-client-ui-action-cards/use-this-package: +/deepseek-ai-dsh-client-ui-action-cards/use-this-package: en: 09d6dec2adb9699a zh: b6ae739fe314df54 -/deepseek-ai-dsh-experimental-client-ui-action-cards/use-this-package/register-a-card-body: +/deepseek-ai-dsh-client-ui-action-cards/use-this-package/register-a-card-body: en: 9ec9f9f8f3165d3e zh: 2d8154ec2fe0c9ab -/deepseek-ai-dsh-experimental-client-ui-action-cards/understand-the-implementation: +/deepseek-ai-dsh-client-ui-action-cards/understand-the-implementation: en: 168ae39a83d6f4eb zh: 312cc12876bca834 -/deepseek-ai-dsh-experimental-client-ui-action-cards/further-exploration: +/deepseek-ai-dsh-client-ui-action-cards/further-exploration: en: c24d6ca9095832e7 zh: b2ee0066a865dc5f -/deepseek-ai-dsh-experimental-client-ui-action-cards/model-experience: +/deepseek-ai-dsh-client-ui-action-cards/model-experience: en: f80a4b656c108f22 zh: a3e30f46fa7136ac -/deepseek-ai-dsh-experimental-client-ui-action-cards/model-experience/kv-cache-effect: +/deepseek-ai-dsh-client-ui-action-cards/model-experience/kv-cache-effect: en: 9cb7131bde146341 zh: 1c7d1af00f58004d -/deepseek-ai-dsh-experimental-client-ui-action-cards/known-limitations-and-deferred-work: +/deepseek-ai-dsh-client-ui-action-cards/known-limitations-and-deferred-work: en: df94821940e4e2b8 zh: 490441ab06b153c9 -/deepseek-ai-dsh-experimental-client-ui-action-cards/known-limitations-and-deferred-work/dev-note: +/deepseek-ai-dsh-client-ui-action-cards/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: bd8a8936229e035e diff --git a/packages/experimental/client-ui-action-cards/README.md b/packages/experimental/client-ui-action-cards/README.md index e84e4980a4..d5b4b23e29 100644 --- a/packages/experimental/client-ui-action-cards/README.md +++ b/packages/experimental/client-ui-action-cards/README.md @@ -3,7 +3,7 @@ description: "Browser half of optional action cards: the card frame below comple kind: "package-reference" --- -# @deepseek-ai/dsh-experimental-client-ui-action-cards +# @deepseek-ai/dsh-client-ui-action-cards English | [中文](README.zh.md) diff --git a/packages/experimental/client-ui-action-cards/README.zh.md b/packages/experimental/client-ui-action-cards/README.zh.md index 441666645b..aa76aabfb3 100644 --- a/packages/experimental/client-ui-action-cards/README.zh.md +++ b/packages/experimental/client-ui-action-cards/README.zh.md @@ -3,7 +3,7 @@ description: "可选操作卡片的浏览器端:已完成回答下方的卡片 kind: "package-reference" --- -# @deepseek-ai/dsh-experimental-client-ui-action-cards +# @deepseek-ai/dsh-client-ui-action-cards [English](README.md) | 中文 diff --git a/packages/experimental/client-ui-action-cards/package.json b/packages/experimental/client-ui-action-cards/package.json index ff574cdbe9..2430ec1b50 100644 --- a/packages/experimental/client-ui-action-cards/package.json +++ b/packages/experimental/client-ui-action-cards/package.json @@ -7,7 +7,7 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "license": "MIT", - "name": "@deepseek-ai/dsh-experimental-client-ui-action-cards", + "name": "@deepseek-ai/dsh-client-ui-action-cards", "description": "Action cards below completed answers: the shared card frame, kind bodies and the compact tool row", "repository": { "type": "git", @@ -57,7 +57,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-tool": "workspace:*", "react": "^18.2.0", - "@deepseek-ai/dsh-experimental-action-cards": "workspace:*", + "@deepseek-ai/dsh-action-cards": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*" diff --git a/packages/experimental/client-ui-action-cards/src/client/Card.tsx b/packages/experimental/client-ui-action-cards/src/client/Card.tsx index 49bc7a982e..20e6f75700 100644 --- a/packages/experimental/client-ui-action-cards/src/client/Card.tsx +++ b/packages/experimental/client-ui-action-cards/src/client/Card.tsx @@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { ActionCardDecision, ActionCardFields, ActionCardProposal, ActionCardResult, ActionCardView, -} from '@deepseek-ai/dsh-experimental-action-cards/types' +} from '@deepseek-ai/dsh-action-cards/types' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type { ActionCardKey } from './locales.ts' import type { FoldedTextProps, RegisteredBody } from './registry.ts' diff --git a/packages/experimental/client-ui-action-cards/src/client/index.ts b/packages/experimental/client-ui-action-cards/src/client/index.ts index bfb1caae06..d8c3a96173 100644 --- a/packages/experimental/client-ui-action-cards/src/client/index.ts +++ b/packages/experimental/client-ui-action-cards/src/client/index.ts @@ -1,6 +1,6 @@ /** Bind generated Remote descriptors to the source-tested browser lifecycle. */ import type { Context } from '@deepseek-ai/cordis' -import remote from '@deepseek-ai/dsh-experimental-action-cards/remote' +import remote from '@deepseek-ai/dsh-action-cards/remote' import { mountActionCards } from './mount.ts' export { inject } from './mount.ts' export type { ActionCardBodyProps, ActionCardDescriptor, ActionCardRegistry, FoldedTextProps } from './registry.ts' diff --git a/packages/experimental/client-ui-action-cards/src/client/mount.ts b/packages/experimental/client-ui-action-cards/src/client/mount.ts index aab0bd85f0..6bc23b4aaa 100644 --- a/packages/experimental/client-ui-action-cards/src/client/mount.ts +++ b/packages/experimental/client-ui-action-cards/src/client/mount.ts @@ -6,7 +6,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-tool/client' -import type {} from '@deepseek-ai/dsh-experimental-action-cards/remote' +import type {} from '@deepseek-ai/dsh-action-cards/remote' import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' import { ActionCardTail, type TailInjected } from './Card.tsx' import { ProposeRow } from './ProposeRow.tsx' @@ -46,7 +46,7 @@ export async function mountActionCards(ctx: Context, contribution: TypertRemoteC function registerUi(ctx: Context, registry: ActionCardRegistry): void { ctx.effect(() => ctx.locale.register('actionCards', { en, zh, ja }), 'action-cards: locale') ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ - name: 'conversation.chat.turnTail', id: '@deepseek-ai/dsh-experimental-client-ui-action-cards', locale: 'actionCards', + name: 'conversation.chat.turnTail', id: '@deepseek-ai/dsh-client-ui-action-cards', locale: 'actionCards', inject: (sessionId: SessionId): TailInjected => ({ sessionId, hooks: { bodies: registry.source }, diff --git a/packages/experimental/client-ui-action-cards/src/client/registry.ts b/packages/experimental/client-ui-action-cards/src/client/registry.ts index fa025f167c..4933ee8dbb 100644 --- a/packages/experimental/client-ui-action-cards/src/client/registry.ts +++ b/packages/experimental/client-ui-action-cards/src/client/registry.ts @@ -5,7 +5,7 @@ import type { HostObservable, LocaleNamespaceMap, Translate, TranslateNS } from import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ActionCardDecision, ActionCardFields, ActionCardProposal, ActionCardResult, -} from '@deepseek-ai/dsh-experimental-action-cards/types' +} from '@deepseek-ai/dsh-action-cards/types' /** Props of the frame's folded text, which bodies reuse for their own long fields. */ export interface FoldedTextProps { diff --git a/packages/experimental/client-ui-action-cards/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-action-cards/tests/browser-plugin.client.spec.ts index cebcd21fbe..87f32a57c3 100644 --- a/packages/experimental/client-ui-action-cards/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-action-cards/tests/browser-plugin.client.spec.ts @@ -10,7 +10,7 @@ import { mountActionCards, inject } from '../src/client/mount.ts' import { apply as hostApply } from '../src/index.ts' import type { TailInjected } from '../src/client/Card.tsx' -const apply = (ctx: Context) => mountActionCards(ctx, { package: '@deepseek-ai/dsh-experimental-action-cards', descriptors: [] }) +const apply = (ctx: Context) => mountActionCards(ctx, { package: '@deepseek-ai/dsh-action-cards', descriptors: [] }) const Body = () => null it('binds actions to the displayed session, serves registered bodies and removes registrations on unload', async () => { diff --git a/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx b/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx index 5fa565121c..c286a52937 100644 --- a/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx +++ b/packages/experimental/client-ui-action-cards/tests/card.client.spec.tsx @@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' -import type { ActionCardDecision, ActionCardResult, ActionCardView } from '@deepseek-ai/dsh-experimental-action-cards/types' +import type { ActionCardDecision, ActionCardResult, ActionCardView } from '@deepseek-ai/dsh-action-cards/types' import { ActionCard, ActionCardTail, FoldedText } from '../src/client/Card.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en, zh } from '../src/client/locales.ts' diff --git a/packages/experimental/client-ui-creation-recommendations/README.i18n.yaml b/packages/experimental/client-ui-creation-recommendations/README.i18n.yaml index 0a87350055..7abb687876 100644 --- a/packages/experimental/client-ui-creation-recommendations/README.i18n.yaml +++ b/packages/experimental/client-ui-creation-recommendations/README.i18n.yaml @@ -5,36 +5,36 @@ /: en: e332dc3625021ad9 zh: a6637d7d8696ba5a -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations: - en: dc8e9b2fafade59a - zh: fab88019b957e3d9 -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/summary: +/deepseek-ai-dsh-client-ui-creation-recommendations: + en: b4af39c5d085f16e + zh: 19fd311580db7e2b +/deepseek-ai-dsh-client-ui-creation-recommendations/summary: en: c2b93e3c50f80ecd zh: da8aa68e686abddd -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/table-of-contents: +/deepseek-ai-dsh-client-ui-creation-recommendations/table-of-contents: en: d152484eb41ac6b4 zh: b00dddf4c88f0a45 -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/use-this-package: +/deepseek-ai-dsh-client-ui-creation-recommendations/use-this-package: en: 5f3f7e9eea09ae0c zh: 378acca8f793cf34 -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/use-this-package/outcomes-and-recovery: +/deepseek-ai-dsh-client-ui-creation-recommendations/use-this-package/outcomes-and-recovery: en: 5a3df9cd58832f91 zh: 2068dca0a62394fe -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/understand-the-implementation: +/deepseek-ai-dsh-client-ui-creation-recommendations/understand-the-implementation: en: 26b049c5e3f38846 zh: 15768ae54aec3511 -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/further-exploration: +/deepseek-ai-dsh-client-ui-creation-recommendations/further-exploration: en: 6543c44016da2cdf zh: fb0d365a5e411b04 -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/model-experience: +/deepseek-ai-dsh-client-ui-creation-recommendations/model-experience: en: 176de8a02931b042 zh: b1f1228f011ab056 -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/model-experience/kv-cache-effect: +/deepseek-ai-dsh-client-ui-creation-recommendations/model-experience/kv-cache-effect: en: 6a708541b49367ae zh: 5722b25a6a8e6724 -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/known-limitations-and-deferred-work: +/deepseek-ai-dsh-client-ui-creation-recommendations/known-limitations-and-deferred-work: en: 93594f90162451d5 zh: ca27b35469b537be -/deepseek-ai-dsh-experimental-client-ui-creation-recommendations/known-limitations-and-deferred-work/dev-note: +/deepseek-ai-dsh-client-ui-creation-recommendations/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 8ec3e73528ddb1a6 diff --git a/packages/experimental/client-ui-creation-recommendations/README.md b/packages/experimental/client-ui-creation-recommendations/README.md index 4ae876389c..a5dd0e2f33 100644 --- a/packages/experimental/client-ui-creation-recommendations/README.md +++ b/packages/experimental/client-ui-creation-recommendations/README.md @@ -3,7 +3,7 @@ description: "Show completed-answer creation cards with location choice, Keep an kind: "package-reference" --- -# @deepseek-ai/dsh-experimental-client-ui-creation-recommendations +# @deepseek-ai/dsh-client-ui-creation-recommendations English | [中文](README.zh.md) diff --git a/packages/experimental/client-ui-creation-recommendations/README.zh.md b/packages/experimental/client-ui-creation-recommendations/README.zh.md index fcdab22964..8a562dc213 100644 --- a/packages/experimental/client-ui-creation-recommendations/README.zh.md +++ b/packages/experimental/client-ui-creation-recommendations/README.zh.md @@ -3,7 +3,7 @@ description: "在完整回答末尾显示支持位置选择、保留和撤销的 kind: "package-reference" --- -# @deepseek-ai/dsh-experimental-client-ui-creation-recommendations +# @deepseek-ai/dsh-client-ui-creation-recommendations [English](README.md) | 中文 diff --git a/packages/experimental/client-ui-creation-recommendations/package.json b/packages/experimental/client-ui-creation-recommendations/package.json index c74b82745d..4d50999cda 100644 --- a/packages/experimental/client-ui-creation-recommendations/package.json +++ b/packages/experimental/client-ui-creation-recommendations/package.json @@ -7,7 +7,7 @@ "main": "lib/index.js", "types": "lib/types/index.d.ts", "license": "MIT", - "name": "@deepseek-ai/dsh-experimental-client-ui-creation-recommendations", + "name": "@deepseek-ai/dsh-client-ui-creation-recommendations", "description": "Body of the personalized page card: location choice, creation progress, Keep, Undo and Restore", "repository": { "type": "git", @@ -32,7 +32,7 @@ "platform": "web", "inject": [ "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-experimental-client-ui-action-cards" + "@deepseek-ai/dsh-client-ui-action-cards" ] } }, @@ -46,10 +46,10 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "react": "^18.2.0", - "@deepseek-ai/dsh-experimental-creation-recommendations": "workspace:*", + "@deepseek-ai/dsh-creation-recommendations": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", - "@deepseek-ai/dsh-experimental-client-ui-action-cards": "workspace:*", - "@deepseek-ai/dsh-experimental-action-cards": "workspace:*", + "@deepseek-ai/dsh-client-ui-action-cards": "workspace:*", + "@deepseek-ai/dsh-action-cards": "workspace:*", "zod": "4.4.3", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx b/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx index ade786148f..5fffa80e65 100644 --- a/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx +++ b/packages/experimental/client-ui-creation-recommendations/src/client/Card.tsx @@ -4,8 +4,8 @@ import { z } from 'zod' import { Button, IconChevronDownOutlineRegular, IconWarningTriangleOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { ActionCardBodyProps } from '@deepseek-ai/dsh-experimental-client-ui-action-cards/client' -import type { CreationSlot, PageData, PageDetails, PageVerb } from '@deepseek-ai/dsh-experimental-creation-recommendations/types' +import type { ActionCardBodyProps } from '@deepseek-ai/dsh-client-ui-action-cards/client' +import type { CreationSlot, PageData, PageDetails, PageVerb } from '@deepseek-ai/dsh-creation-recommendations/types' import type { RecommendationKey } from './locales.ts' import css from './Card.module.css' diff --git a/packages/experimental/client-ui-creation-recommendations/src/client/mount.ts b/packages/experimental/client-ui-creation-recommendations/src/client/mount.ts index 25fb1aa17b..ade99d3bbd 100644 --- a/packages/experimental/client-ui-creation-recommendations/src/client/mount.ts +++ b/packages/experimental/client-ui-creation-recommendations/src/client/mount.ts @@ -2,7 +2,7 @@ import { createElement } from 'react' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' -import type {} from '@deepseek-ai/dsh-experimental-client-ui-action-cards/client' +import type {} from '@deepseek-ai/dsh-client-ui-action-cards/client' import { PageCardBody } from './Card.tsx' import { en, ja, zh, type RecommendationKey } from './locales.ts' import { createCreationInfoStore } from './info-store.ts' diff --git a/packages/experimental/client-ui-creation-recommendations/tests/card.client.spec.tsx b/packages/experimental/client-ui-creation-recommendations/tests/card.client.spec.tsx index 482d72117d..c16bcff594 100644 --- a/packages/experimental/client-ui-creation-recommendations/tests/card.client.spec.tsx +++ b/packages/experimental/client-ui-creation-recommendations/tests/card.client.spec.tsx @@ -4,8 +4,8 @@ import { afterEach, expect, it, vi } from 'vitest' import { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import { SessionId } from '@deepseek-ai/dsh-session/types' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { FoldedText } from '@deepseek-ai/dsh-experimental-client-ui-action-cards/src/client/Card.tsx' -import type { ActionCardFields, ActionCardResult } from '@deepseek-ai/dsh-experimental-action-cards/types' +import { FoldedText } from '@deepseek-ai/dsh-client-ui-action-cards/src/client/Card.tsx' +import type { ActionCardFields, ActionCardResult } from '@deepseek-ai/dsh-action-cards/types' import { PageCardBody, type PageCardProps } from '../src/client/Card.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en, zh } from '../src/client/locales.ts' diff --git a/packages/experimental/client-ui-creation-recommendations/tests/mount.client.spec.tsx b/packages/experimental/client-ui-creation-recommendations/tests/mount.client.spec.tsx index b6b1461b8f..c5d1ae9fe7 100644 --- a/packages/experimental/client-ui-creation-recommendations/tests/mount.client.spec.tsx +++ b/packages/experimental/client-ui-creation-recommendations/tests/mount.client.spec.tsx @@ -5,8 +5,8 @@ import { Context } from '@deepseek-ai/cordis' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import { SessionId } from '@deepseek-ai/dsh-session/types' -import type { ActionCardDescriptor } from '@deepseek-ai/dsh-experimental-client-ui-action-cards/client' -import { FoldedText } from '@deepseek-ai/dsh-experimental-client-ui-action-cards/src/client/Card.tsx' +import type { ActionCardDescriptor } from '@deepseek-ai/dsh-client-ui-action-cards/client' +import { FoldedText } from '@deepseek-ai/dsh-client-ui-action-cards/src/client/Card.tsx' import { afterEach, expect, it, onTestFinished, vi } from 'vitest' import { apply, inject } from '../src/client/mount.ts' import { apply as hostApply } from '../src/index.ts' diff --git a/packages/experimental/creation-recommendations/README.i18n.yaml b/packages/experimental/creation-recommendations/README.i18n.yaml index af5f672a6f..dd18faa25d 100644 --- a/packages/experimental/creation-recommendations/README.i18n.yaml +++ b/packages/experimental/creation-recommendations/README.i18n.yaml @@ -5,45 +5,45 @@ /: en: 08688503db441fbf zh: 855dbe0b9ce53ab9 -/deepseek-ai-dsh-experimental-creation-recommendations: - en: f4b9d65da579ef83 - zh: 6525d553f7758ac2 -/deepseek-ai-dsh-experimental-creation-recommendations/summary: +/deepseek-ai-dsh-creation-recommendations: + en: f2cefc8c9ad1d38f + zh: 9d3a5dc7ac8e2329 +/deepseek-ai-dsh-creation-recommendations/summary: en: 3c8dec4a44f298a4 zh: 6d6bd77206942c76 -/deepseek-ai-dsh-experimental-creation-recommendations/table-of-contents: +/deepseek-ai-dsh-creation-recommendations/table-of-contents: en: d152484eb41ac6b4 zh: b00dddf4c88f0a45 -/deepseek-ai-dsh-experimental-creation-recommendations/use-this-package: +/deepseek-ai-dsh-creation-recommendations/use-this-package: en: cf57a8f8347c7aba zh: f8a2e23006c8c09d -/deepseek-ai-dsh-experimental-creation-recommendations/use-this-package/outcomes-and-recovery: +/deepseek-ai-dsh-creation-recommendations/use-this-package/outcomes-and-recovery: en: 0ab58b8291b06ba8 zh: 25ca08bc167d1e4a -/deepseek-ai-dsh-experimental-creation-recommendations/understand-the-implementation: +/deepseek-ai-dsh-creation-recommendations/understand-the-implementation: en: 825aa7414c0a8d90 zh: 082dd0334a10dca0 -/deepseek-ai-dsh-experimental-creation-recommendations/further-exploration: +/deepseek-ai-dsh-creation-recommendations/further-exploration: en: 056d3e9b67fd628e zh: 71db00e1a971d495 -/deepseek-ai-dsh-experimental-creation-recommendations/model-experience: +/deepseek-ai-dsh-creation-recommendations/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 -/deepseek-ai-dsh-experimental-creation-recommendations/model-experience/creation-recommendations: +/deepseek-ai-dsh-creation-recommendations/model-experience/creation-recommendations: en: b950fbe9f29693d4 zh: 2dd7edb2d1c72941 -/deepseek-ai-dsh-experimental-creation-recommendations/model-experience/creation-recommendations/what-the-model-sees: +/deepseek-ai-dsh-creation-recommendations/model-experience/creation-recommendations/what-the-model-sees: en: 8911f9d135dbfe34 zh: c3cb68ecd4dd9308 -/deepseek-ai-dsh-experimental-creation-recommendations/model-experience/creation-recommendations/token-effect: +/deepseek-ai-dsh-creation-recommendations/model-experience/creation-recommendations/token-effect: en: bd78818f4a9f5422 zh: 0f54dbd0f41c5622 -/deepseek-ai-dsh-experimental-creation-recommendations/model-experience/creation-recommendations/kv-cache-effect: +/deepseek-ai-dsh-creation-recommendations/model-experience/creation-recommendations/kv-cache-effect: en: c2046060f1ce1f08 zh: 08e37f9f758939bc -/deepseek-ai-dsh-experimental-creation-recommendations/known-limitations-and-deferred-work: +/deepseek-ai-dsh-creation-recommendations/known-limitations-and-deferred-work: en: 7f6c43730b74d1f5 zh: 8fdd41c9abba4190 -/deepseek-ai-dsh-experimental-creation-recommendations/known-limitations-and-deferred-work/dev-note: +/deepseek-ai-dsh-creation-recommendations/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 8ec3e73528ddb1a6 diff --git a/packages/experimental/creation-recommendations/README.md b/packages/experimental/creation-recommendations/README.md index 1286824ca6..c9314cf8ec 100644 --- a/packages/experimental/creation-recommendations/README.md +++ b/packages/experimental/creation-recommendations/README.md @@ -3,7 +3,7 @@ description: "Create persistent removable interface plugins after an explicit us kind: "package-bundle" --- -# @deepseek-ai/dsh-experimental-creation-recommendations +# @deepseek-ai/dsh-creation-recommendations English | [中文](README.zh.md) diff --git a/packages/experimental/creation-recommendations/README.zh.md b/packages/experimental/creation-recommendations/README.zh.md index d1d48ec416..aaf53b3b13 100644 --- a/packages/experimental/creation-recommendations/README.zh.md +++ b/packages/experimental/creation-recommendations/README.zh.md @@ -3,7 +3,7 @@ description: "经用户明确选择后创建可持久保留、可移除的界面 kind: "package-bundle" --- -# @deepseek-ai/dsh-experimental-creation-recommendations +# @deepseek-ai/dsh-creation-recommendations [English](README.md) | 中文 diff --git a/packages/experimental/creation-recommendations/cordis.patch.yml b/packages/experimental/creation-recommendations/cordis.patch.yml index a9569baae7..6bb3a67260 100644 --- a/packages/experimental/creation-recommendations/cordis.patch.yml +++ b/packages/experimental/creation-recommendations/cordis.patch.yml @@ -1,9 +1,9 @@ - insert: - id: action-cards - name: '@deepseek-ai/dsh-experimental-action-cards' + name: '@deepseek-ai/dsh-action-cards' - id: client-ui-action-cards - name: '@deepseek-ai/dsh-experimental-client-ui-action-cards' + name: '@deepseek-ai/dsh-client-ui-action-cards' - id: creation-recommendations - name: '@deepseek-ai/dsh-experimental-creation-recommendations' + name: '@deepseek-ai/dsh-creation-recommendations' - id: client-ui-creation-recommendations - name: '@deepseek-ai/dsh-experimental-client-ui-creation-recommendations' + name: '@deepseek-ai/dsh-client-ui-creation-recommendations' diff --git a/packages/experimental/creation-recommendations/package.json b/packages/experimental/creation-recommendations/package.json index dc93e700ad..d8ea6d23d3 100644 --- a/packages/experimental/creation-recommendations/package.json +++ b/packages/experimental/creation-recommendations/package.json @@ -1,5 +1,5 @@ { - "name": "@deepseek-ai/dsh-experimental-creation-recommendations", + "name": "@deepseek-ai/dsh-creation-recommendations", "description": "Create persistent removable interface plugins from explicit conversation needs", "version": "0.2.1-rc.2", "publishConfig": { @@ -56,9 +56,9 @@ "yaml": "^2.9.0", "resolve.exports": "^2.0.3", "zod": "4.4.3", - "@deepseek-ai/dsh-experimental-client-ui-creation-recommendations": "workspace:*", - "@deepseek-ai/dsh-experimental-action-cards": "workspace:*", - "@deepseek-ai/dsh-experimental-client-ui-action-cards": "workspace:*" + "@deepseek-ai/dsh-client-ui-creation-recommendations": "workspace:*", + "@deepseek-ai/dsh-action-cards": "workspace:*", + "@deepseek-ai/dsh-client-ui-action-cards": "workspace:*" }, "files": [ "icon.svg", diff --git a/packages/experimental/creation-recommendations/src/index.ts b/packages/experimental/creation-recommendations/src/index.ts index 5c6dce292b..31f96aec1b 100644 --- a/packages/experimental/creation-recommendations/src/index.ts +++ b/packages/experimental/creation-recommendations/src/index.ts @@ -9,8 +9,8 @@ import { HarnessError, type ToolCallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-tools' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' -import { isPluginStopping } from '@deepseek-ai/dsh-experimental-action-cards' -import type { ActionCardFields, ActionCardProposalOf, ActionCardResult } from '@deepseek-ai/dsh-experimental-action-cards' +import { isPluginStopping } from '@deepseek-ai/dsh-action-cards' +import type { ActionCardFields, ActionCardProposalOf, ActionCardResult } from '@deepseek-ai/dsh-action-cards' import type {} from '@deepseek-ai/dsh-plugin-manager' import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-app-boot' @@ -274,11 +274,11 @@ class PersonalizedPages { directory: sourceDirectory, packageName: bundleName, packaging: 'Write a package.json with the exact reserved name, version, and dsh.bundle.patch="./cordis.patch.yml". Write cordis.patch.yml as a plain YAML or JSON array containing only {insert:[...]} operations. Do not use groups, aliases, custom tags or includes. Every inserted id must start with the package name plus a colon, and each module name must be this package or one of its exports. Use existing DSH slots and theme tokens. Build and export all runtime entry points before reporting ready. Symlinks must be relative and remain inside this package; the installed copy must work without the source directory.', ...(card.customLocation === undefined ? { - uiReference: fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-experimental-creation-recommendations/references/' + uiReference: fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-creation-recommendations/references/' + { 'sidebar-right': 'sidebar-right.md', page: 'session-tab.md', 'sidebar-left': 'sidebar-entry.md' }[card.slot])), locationPolicy: 'location and uiReference are the user’s final choice in the card. They override any earlier location described in brief; preserve the requested behavior and data scope while adapting the interface to this chosen location.', } : { - uiReferences: ['sidebar-right.md', 'session-tab.md', 'sidebar-entry.md'].map(file => fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-experimental-creation-recommendations/references/' + file))), + uiReferences: ['sidebar-right.md', 'session-tab.md', 'sidebar-entry.md'].map(file => fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-creation-recommendations/references/' + file))), locationPolicy: 'location.request is the user’s final placement instruction, overriding earlier placement in brief. Resolve it using existing documented DSH extension points; the references describe common placements, not a new custom slot. Preserve the requested behavior and data scope. Do not change the application shell or install anything outside this removable plugin. If the placement cannot be implemented, report ready:false instead of silently substituting another location.', }), lifecycle: 'Read the relevant absolute uiReference or uiReferences bundled with this plugin before implementation; they supplement the cordis-plugin-development skill. Follow their registration, persistence and teardown guidance. Unloading must remove only this plugin’s UI and tabs, preserving unrelated content. Generate and build only; the caller installs and validates the live result.', diff --git a/packages/experimental/creation-recommendations/tests/creation.spec.ts b/packages/experimental/creation-recommendations/tests/creation.spec.ts index a7a2148a85..68434b30aa 100644 --- a/packages/experimental/creation-recommendations/tests/creation.spec.ts +++ b/packages/experimental/creation-recommendations/tests/creation.spec.ts @@ -57,7 +57,7 @@ describe('interface creation', () => { const spec = await f.complete() expect(spec).toMatchObject({ provider: 'spawn', delivery: 'caller', request: { agentPreset: 'cordis' } }) expect(spec.request.parent).toBe(f.agent) - const guide = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-experimental-creation-recommendations/references/session-tab.md')) + const guide = fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-creation-recommendations/references/session-tab.md')) expect(spec.request.prompt.find(block => block.type === 'text')?.text).toContain(JSON.stringify(guide)) expect(await readFile(guide, 'utf8')).toContain('conversation.view') expect(spec.request.cwd!.startsWith(join(f.agent.session.header.cwd!, '.dsh-created-plugins'))).toBe(true) @@ -90,7 +90,7 @@ describe('interface creation', () => { await f.act('create', 'sidebar-left') const spec = await f.complete() const instructions: unknown = JSON.parse(spec.request.prompt.find(block => block.type === 'text')!.text) - expect(instructions).toMatchObject({ location: 'sidebar-left', uiReference: fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-experimental-creation-recommendations/references/sidebar-entry.md')), + expect(instructions).toMatchObject({ location: 'sidebar-left', uiReference: fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-creation-recommendations/references/sidebar-entry.md')), locationPolicy: 'location and uiReference are the user’s final choice in the card. They override any earlier location described in brief; preserve the requested behavior and data scope while adapting the interface to this chosen location.', }) expect(f.current()).toMatchObject({ phase: 'installed', slot: 'sidebar-left' }) @@ -143,7 +143,7 @@ describe('interface creation', () => { const spec = await f.complete() const instructions: unknown = JSON.parse(spec.request.prompt.find(block => block.type === 'text')!.text) const references = ['sidebar-right.md', 'session-tab.md', 'sidebar-entry.md'].map(file => - fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-experimental-creation-recommendations/references/' + file))) + fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-creation-recommendations/references/' + file))) expect(instructions).toMatchObject({ location: { request: '左侧入口打开全屏日历' }, uiReferences: references }) expect(instructions).toHaveProperty('locationPolicy', expect.stringContaining('instead of silently substituting another location')) expect(instructions).not.toHaveProperty('uiReference') diff --git a/packages/experimental/creation-recommendations/tests/fixture.ts b/packages/experimental/creation-recommendations/tests/fixture.ts index c960acd7dc..8f31beac35 100644 --- a/packages/experimental/creation-recommendations/tests/fixture.ts +++ b/packages/experimental/creation-recommendations/tests/fixture.ts @@ -10,7 +10,7 @@ import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deep import Skills from '@deepseek-ai/dsh-skill' import * as SkillTool from '@deepseek-ai/dsh-tool-skill' import RemoteAccess from '@deepseek-ai/dsh-api-remote-access' -import ActionCards from '@deepseek-ai/dsh-experimental-action-cards' +import ActionCards from '@deepseek-ai/dsh-action-cards' import type { Agent } from '@deepseek-ai/dsh-agent' import type { BundleInfo, ChangeResult } from '@deepseek-ai/dsh-plugin-manager/types' import type { SubagentActivationSpec, SubagentResult } from '@deepseek-ai/dsh-subagent' diff --git a/packages/experimental/creation-recommendations/tests/loader-composition.spec.ts b/packages/experimental/creation-recommendations/tests/loader-composition.spec.ts index 2cbccf6944..2fd2154a02 100644 --- a/packages/experimental/creation-recommendations/tests/loader-composition.spec.ts +++ b/packages/experimental/creation-recommendations/tests/loader-composition.spec.ts @@ -21,7 +21,7 @@ import * as NativeExecutor from '@deepseek-ai/dsh-tool-executor-native' import FsLocal from '@deepseek-ai/dsh-fs-local' import WorkingDirectory from '@deepseek-ai/dsh-working-directory' import Access from '@deepseek-ai/dsh-api-remote-access' -import * as ActionCards from '@deepseek-ai/dsh-experimental-action-cards' +import * as ActionCards from '@deepseek-ai/dsh-action-cards' import type { SubagentActivation, SubagentActivationSpec } from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as Recommendations from '../src/index.ts' 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 dd4be0eabf..d1b8158fb4 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -554,7 +554,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-deliverables DeliverablesTail id \'@deepseek-ai/dsh-client-ui-deliverables\'', 'client-ui-plan PlanCards', 'client-ui-schedule ScheduleTurnCard id \'schedule-created\'', - 'experimental-client-ui-action-cards ActionCardTail id \'@deepseek-ai/dsh-experimental-client-ui-action-cards\'', + 'client-ui-action-cards ActionCardTail id \'@deepseek-ai/dsh-client-ui-action-cards\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', @@ -5063,7 +5063,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-tool TodoRow key \'todo_write\'', 'client-ui-tool WebRow key \'web_search\'', 'client-ui-tool WebRow key \'web_fetch\'', - 'experimental-client-ui-action-cards ProposeRow key \'propose_action\'', + 'client-ui-action-cards ProposeRow key \'propose_action\'', 'client-ui-cordis CordisDefineRow key \'cordis_define\'', 'client-ui-cordis CordisRunRow key \'cordis_run\'', 'client-ui-cordis CordisActionRow key \'cordis_stop\'', diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 0f524f52e7..f264fcb5d9 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -189,8 +189,11 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | Package | Config | Description | |---|---|---| +| `@deepseek-ai/dsh-action-cards` | yes | Optional action cards below answers: one shared proposal tool, card state, user actions and status notices | +| `@deepseek-ai/dsh-client-ui-action-cards` | no | Action cards below completed answers: the shared card frame, kind bodies and the compact tool row | +| `@deepseek-ai/dsh-client-ui-creation-recommendations` | no | Body of the personalized page card: location choice, creation progress, Keep, Undo and Restore | +| `@deepseek-ai/dsh-creation-recommendations` | no | Create persistent removable interface plugins from explicit conversation needs | | `@deepseek-ai/dsh-cua` | no | Shared Computer Use application objects, authorization, and logged tool execution | -| `@deepseek-ai/dsh-experimental-action-cards` | yes | Optional action cards below answers: one shared proposal tool, card state, user actions and status notices | | `@deepseek-ai/dsh-experimental-agent-team` | yes | Implicit-root Agent Teams roster, direct peer messaging, and shared task DAG | | `@deepseek-ai/dsh-experimental-api-speech-to-text` | yes | Authenticated experimental speech transcription for browser clients | | `@deepseek-ai/dsh-experimental-auto-review` | no | Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset | @@ -198,15 +201,12 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-experimental-browser-use-playwright-mcp` | yes | Experimental per-Session Chromium browser tools through @playwright/mcp | | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | yes | Experimental Stagehand browser tools with separately configured native models | | `@deepseek-ai/dsh-experimental-claude-code-mods` | yes | Experimental bridge: load Claude Code mods (hooks modules) and run their hook chains on DeepSeek Harness extension points | -| `@deepseek-ai/dsh-experimental-client-ui-action-cards` | no | Action cards below completed answers: the shared card frame, kind bodies and the compact tool row | | `@deepseek-ai/dsh-experimental-client-ui-agent-team` | no | Web Agent Teams roster, task board, and teammate navigation | | `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | no | Web band above the prompt for Claude Code mods: draws each session's mod tree and sends button presses back to the bridge | | `@deepseek-ai/dsh-experimental-client-ui-cot-translation` | yes | Optional machine translation of expanded reasoning, preserving original Session text | -| `@deepseek-ai/dsh-experimental-client-ui-creation-recommendations` | no | Body of the personalized page card: location choice, creation progress, Keep, Undo and Restore | | `@deepseek-ai/dsh-experimental-client-ui-voice-input` | no | Record speech and insert editable text into the conversation draft | | `@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp` | yes | Experimental computer use through an installed Cua Driver MCP executable | | `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | no | Experimental computer-use provider embedding the Cua Driver native npm SDK | -| `@deepseek-ai/dsh-experimental-creation-recommendations` | no | Create persistent removable interface plugins from explicit conversation needs | | `@deepseek-ai/dsh-experimental-cua-macos` | yes | macOS native provider for shared Computer Use application and window objects | | `@deepseek-ai/dsh-experimental-inspector` | yes | Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection | | `@deepseek-ai/dsh-experimental-ptc-runtime-python` | yes | CPython subprocess implementation of the DeepSeek Harness PTC execution seam | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83c166dd58..ea5889d242 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: .: devDependencies: + '@deepseek-ai/dsh-action-cards': + specifier: workspace:* + version: link:packages/experimental/action-cards '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:packages/core/agent @@ -40,9 +43,6 @@ importers: '@deepseek-ai/dsh-cua-bundle': specifier: workspace:* version: link:packages/experimental/cua-bundle - '@deepseek-ai/dsh-experimental-action-cards': - specifier: workspace:* - version: link:packages/experimental/action-cards '@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp': specifier: workspace:* version: link:packages/experimental/browser-use-chrome-devtools-mcp @@ -325,6 +325,9 @@ importers: '@deepseek-ai/dsh-cordis-client-runner': specifier: workspace:* version: link:../../packages/extensions/cordis-client-runner + '@deepseek-ai/dsh-creation-recommendations': + specifier: workspace:* + version: link:../../packages/experimental/creation-recommendations '@deepseek-ai/dsh-cua-bundle': specifier: workspace:* version: link:../../packages/experimental/cua-bundle @@ -340,9 +343,6 @@ importers: '@deepseek-ai/dsh-experimental-cot-translation-bundle': specifier: workspace:* version: link:../../packages/experimental/cot-translation-bundle - '@deepseek-ai/dsh-experimental-creation-recommendations': - specifier: workspace:* - version: link:../../packages/experimental/creation-recommendations '@deepseek-ai/dsh-experimental-inspector-profile': specifier: workspace:* version: link:../../packages/experimental/inspector-profile @@ -8674,6 +8674,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-action-cards': + specifier: workspace:* + version: link:../action-cards '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -8704,9 +8707,6 @@ importers: '@deepseek-ai/dsh-client-ui-tool': specifier: workspace:* version: link:../../client/ui-tool - '@deepseek-ai/dsh-experimental-action-cards': - specifier: workspace:* - version: link:../action-cards '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm @@ -8915,6 +8915,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-action-cards': + specifier: workspace:* + version: link:../action-cards '@deepseek-ai/dsh-client-locale': specifier: workspace:* version: link:../../client/locale @@ -8924,6 +8927,9 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:* version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-action-cards': + specifier: workspace:* + version: link:../client-ui-action-cards '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:* version: link:../../client/ui-primitives @@ -8933,13 +8939,7 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:* version: link:../../client/ui-slots - '@deepseek-ai/dsh-experimental-action-cards': - specifier: workspace:* - version: link:../action-cards - '@deepseek-ai/dsh-experimental-client-ui-action-cards': - specifier: workspace:* - version: link:../client-ui-action-cards - '@deepseek-ai/dsh-experimental-creation-recommendations': + '@deepseek-ai/dsh-creation-recommendations': specifier: workspace:* version: link:../creation-recommendations '@deepseek-ai/dsh-llm': @@ -9155,13 +9155,13 @@ importers: packages/experimental/creation-recommendations: dependencies: - '@deepseek-ai/dsh-experimental-action-cards': + '@deepseek-ai/dsh-action-cards': specifier: workspace:* version: link:../action-cards - '@deepseek-ai/dsh-experimental-client-ui-action-cards': + '@deepseek-ai/dsh-client-ui-action-cards': specifier: workspace:* version: link:../client-ui-action-cards - '@deepseek-ai/dsh-experimental-client-ui-creation-recommendations': + '@deepseek-ai/dsh-client-ui-creation-recommendations': specifier: workspace:* version: link:../client-ui-creation-recommendations resolve.exports: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index fae411e059..a7c9794de6 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -218,7 +218,7 @@ const packageFileExtras: Readonly> = { // Creator's composition guidance travels with the declaration package. '@deepseek-ai/dsh-agent-preset': ['skills'], // Personalized pages ship the location guidance used by their Creator. - '@deepseek-ai/dsh-experimental-creation-recommendations': ['references/*.md', 'skills/*.md'], + '@deepseek-ai/dsh-creation-recommendations': ['references/*.md', 'skills/*.md'], // The Web Host mounts the default-off settings owner independently of the // delegation-tool instances. '@deepseek-ai/dsh-tool-subagent': ['lib/model-selection-settings.js'], diff --git a/scripts/experimental-package-policy.ts b/scripts/experimental-package-policy.ts index 44fbd52ebe..26dfe1f4bd 100644 --- a/scripts/experimental-package-policy.ts +++ b/scripts/experimental-package-policy.ts @@ -1,6 +1,10 @@ /** Experimental package identities, publication policy, and retained npm names. */ -/** Explicit npm identities retained across experimental classification and package-role moves. */ +/** + * Experimental packages whose npm names omit the experimental prefix, by directory: identities retained across + * experimental classification and package-role moves, and packages named without the prefix so that leaving + * experimental status keeps their names. + */ export const EXPERIMENTAL_PACKAGE_NAME_EXCEPTIONS: Readonly> = { 'packages/experimental/cua': '@deepseek-ai/dsh-cua', 'packages/experimental/cua-bundle': '@deepseek-ai/dsh-cua-bundle', @@ -14,6 +18,10 @@ export const EXPERIMENTAL_PACKAGE_NAME_EXCEPTIONS: Readonly /** npm namespace for experimental packages without retained-name exceptions. */ diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 95e97fc683..b028c16607 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -109,10 +109,10 @@ const GROUP_ORDER = [ const SERVICE_ROLES: ServiceRole[] = [ { key: 'actionCards', - pkg: 'experimental-action-cards', + pkg: 'action-cards', title: 'Optional action cards', mode: 'service', - consumers: ['experimental-creation-recommendations', 'experimental-client-ui-action-cards'], + consumers: ['creation-recommendations', 'client-ui-action-cards'], note: 'Owns the card tool, card state, user actions and status notices; card kinds register their actions and handlers.', }, { diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index fb581b79b2..bde5e8925f 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -43,7 +43,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' import SkillRegistry from '@deepseek-ai/dsh-skill' -import ActionCards from '@deepseek-ai/dsh-experimental-action-cards' +import ActionCards from '@deepseek-ai/dsh-action-cards' import RemoteAccess from '@deepseek-ai/dsh-api-remote-access' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -242,7 +242,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ }, }, { - pkg: '@deepseek-ai/dsh-experimental-action-cards', + pkg: '@deepseek-ai/dsh-action-cards', dir: 'action-cards', source: 'packages/experimental/action-cards/src/index.ts', requires: ['ctx.tools', 'ctx.agents', 'ctx.sessionProjections', 'ctx.remoteAccess'], diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index aa6647f9e7..894242b772 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -49,7 +49,7 @@ describe('release families', () => { expect(members .filter(member => member.directory.startsWith('packages/experimental/')) .map(member => member.name)).toEqual([ - '@deepseek-ai/dsh-experimental-action-cards', + '@deepseek-ai/dsh-action-cards', '@deepseek-ai/dsh-experimental-agent-team-profile', '@deepseek-ai/dsh-experimental-agent-team', '@deepseek-ai/dsh-experimental-api-speech-to-text', @@ -60,16 +60,16 @@ describe('release families', () => { '@deepseek-ai/dsh-experimental-browser-use-runtime', '@deepseek-ai/dsh-experimental-browser-use-stagehand-native', '@deepseek-ai/dsh-experimental-claude-code-mods', - '@deepseek-ai/dsh-experimental-client-ui-action-cards', + '@deepseek-ai/dsh-client-ui-action-cards', '@deepseek-ai/dsh-experimental-client-ui-agent-team', '@deepseek-ai/dsh-experimental-client-ui-claude-code-mods', '@deepseek-ai/dsh-experimental-client-ui-cot-translation', - '@deepseek-ai/dsh-experimental-client-ui-creation-recommendations', + '@deepseek-ai/dsh-client-ui-creation-recommendations', '@deepseek-ai/dsh-experimental-client-ui-voice-input', '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp', '@deepseek-ai/dsh-experimental-computer-use-cua-driver-native', '@deepseek-ai/dsh-experimental-cot-translation-bundle', - '@deepseek-ai/dsh-experimental-creation-recommendations', + '@deepseek-ai/dsh-creation-recommendations', '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-cua-macos', '@deepseek-ai/dsh-cua', diff --git a/snapshots/web/creation-recommendations/session.v4.jsonl b/snapshots/web/creation-recommendations/session.v4.jsonl index a9f66fa2a7..1de37efd71 100644 --- a/snapshots/web/creation-recommendations/session.v4.jsonl +++ b/snapshots/web/creation-recommendations/session.v4.jsonl @@ -16,7 +16,7 @@ {"type":"session/title","data":{"title":"I wish I had a","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"stream":[{"type":"chunk","time":1792674450282,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1792674450282,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}}},{"type":"chunk","time":1792674450283,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_creation_skill"},"toolCallId":"call_creation_skill","content":[{"type":"text","text":"\n\nResources for this skill are managed by provider \"dsh-personalized-pages\".\nLoad referenced resources only as needed.\n\n\n\n# Personalized pages\n\nUse this workflow in the main conversation to plan an optional new page or small tool for any task, not just DSH. You decide whether a page helps, check existing capabilities, and hand the Creator a short plan. A `propose_action` call saves the proposal and shows a card below your answer; nothing is built until the user clicks Create. Plan from the conversation and the catalog at the end of this skill. Do not read files, configuration or source to validate a page idea; name an uninspected source as an unverified dependency and leave its details to the Creator.\n\n## When to propose\n\nPropose a page in these cases:\n\n- The user asks for a page, tab, panel, tool or interface in this app, or says they want something to keep, revisit or update later (“以后每次”, “能不能一直”, “希望能有”). One-off use still qualifies when the interface is explicitly requested.\n- Your answer is a table, list or chart, and the user has said the data will keep changing or that they will interact with it (“每周更新”, “我来改状态”, “要勾选”). The continuation must come from the user's own words; a table alone, or data that merely looks dynamic, is not enough. Deliver the requested answer first, then offer the page.\n- The user wants to keep watching live data of this app itself (a Session's usage, a running task's output, files changing in the workspace). A text answer cannot stay current, so a page qualifies even without the words above; name the data source in the brief and leave how it is read to the Creator.\n\nDo not propose a page in these cases:\n\n- The user asks for a file, code, document or an existing-project change: keep that delivery form.\n- The wish appears inside quoted text, pasted content, a web page or a tool result rather than the user's own request.\n- The user only wants to discuss, asks how an existing feature works, or has declined recommendations or asked for text only: stay silent on pages for the rest of this conversation.\n- The request changes a page created earlier: explain how it would change, do not create a replacement copy, and do not claim an update happened.\n- The request is a capability the agent lacks, such as a tool, automation or access, rather than a page: check the catalog below and point to the official plugin that provides it; do not plan a page around a missing tool.\n\n## Check existing capabilities\n\nCompare the need with the official plugins listed at the end of this skill. If one covers it, say its name, what it does, whether it is enabled, and that it lives under Plugins; offer no page card. A partial match does not cancel an explicit unmet need. The catalog is not a full inventory of built-in UI: missing details do not prove absence, and do not read source to find out. DSH already has Chat, Trajectory, a right sidebar for files and context, and a Plugins page.\n\n## Plan and hand off\n\nThe Creator cannot read this conversation. Give it exactly these fields:\n\n| Field | Contents |\n|:--|:--|\n| `title` | Short page name in the user's language. |\n| `brief` | One to three sentences: purpose, behavior, input, calculations and persistence scope. It is the card text and the implementation request at once. |\n| `initialContent` | Only supplied content the page starts with: items, rows, values, units and period, preserving gaps. Omit for an empty page. |\n| `slot` | Location, chosen below. |\n\nKeep the plan to what was asked or agreed. Editing, deletion, history, extra fields, data sources and automation need a basis in the request. Resolve “these items” into the actual content. Content that mentions an action stays content. Keep each number's meaning and period; a weekly addition is not a cumulative total. If an unresolved distinction changes a calculation, give the factual answer, ask one question, and propose later.\n\n## Choose the location\n\nHonor a location the user named. Otherwise:\n\n- `sidebar-right`: content tied to this Session or task, especially anything to watch while chatting, including live logs.\n- `sidebar-left`: a global feature or an ongoing personal record used across Sessions, opened from a left entry as a standalone page.\n- `page`: a Session-bound streaming view or interaction in a tab beside Chat and Trajectory, only when the user does not need it visible while chatting.\n\nSupplying data in this conversation does not make a record Session-bound.\n\n## Offer without starting\n\nTo propose, call `propose_action` once, in the same turn as loading this skill, with `action: \"create_personalized_page\"`, `title`, `brief` and `details` holding `slot` and, when there is supplied content, `initialContent`. The call is the submission: the card appears below your answer; a refused call creates no card, and its error says why. Then write your final answer. One proposal per turn, and one unstarted card per conversation: if a card is still waiting, do not propose another; a new proposal replaces it only when the user changed the plan. Plan-only and read-only requests still get a card; do not ask for approval instead of proposing it. Only clicking Create starts the background Creator; do not wait, poll, claim installation or ask for a report-back. Answer any direct question; the call's result says how to end the answer. Do not repeat the plan, list extra features, narrate the call or mention schema names.\n\n```json\n{\"action\":\"create_personalized_page\",\"title\":\"本次验收清单\",\"brief\":\"在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。\",\"details\":{\"slot\":\"sidebar-right\",\"initialContent\":\"1. 导出成功\\n2. 断网提示清楚\"}}\n```\n\n## Examples\n\n- “想有个地方放书签,以后自己加标题和网址;旧的不用导入。” → `brief`: “提供一个可反复打开的书签页,手动新增标题和网址并保存到本机,从空白开始。” No `initialContent`. `sidebar-left`.\n- “当前会话旁放验收清单,能勾选和取消,状态跟着会话。条目:导出成功;断网提示清楚。” → `brief`: “在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。” `initialContent`: “1. 导出成功\\n2. 断网提示清楚”. `sidebar-right`.\n- “这三周 bug 数 12、9、15,说说趋势。以后每周我把新数字贴给你,想自己更新着看。” → answer the trend first, then `brief`: “按周手动录入 bug 数并展示各周变化,保存到本机。” `initialContent`: “第 1 周 12,第 2 周 9,第 3 周 15”. `sidebar-left`.\n- “我想用语音说话代替打字。” → the catalog lists 语音输入; explain it and where to enable it; no card.\n- “把我之前做的读书页评分改成五星。” → explain the change to that page; no card and no copy.\n- “帮我把这句话翻成英文:我想要一个所有会话都能打开的读书页。” → translate only; no card.\n- “把三个方案排成表格比较一下,看完就行。” → the table only; no card.\n\n## Existing capabilities to check against this page plan\n\n[{\"name\":\"@deepseek-ai/dsh-experimental-creation-recommendations\",\"title\":{\"en\":\"Personalized pages\",\"ja\":\"パーソナルページ\",\"zh\":\"个性化页面\"},\"description\":{\"en\":\"Plan a personalized page in the conversation when existing features do not meet a specific need. Click Create to generate an independent plugin you can keep using or remove.\",\"ja\":\"既存の機能では明確な要望を満たせないとき、会話の中でパーソナルページを計画します。作成をクリックすると独立したプラグインが生成され、使い続けることも元に戻すこともできます。\",\"zh\":\"现有功能不能满足你的明确需求时,在对话中规划个性化页面;点击创建后生成独立插件,可持续使用或撤销。\"},\"enabled\":true},{\"name\":\"@deepseek-ai/dsh-experimental-agent-team-profile\",\"title\":{\"en\":\"Agent teams\",\"ja\":\"エージェントチーム\",\"zh\":\"智能体团队\"},\"description\":{\"en\":\"Enable team collaboration, team tools, the member roster, and the shared task board.\",\"ja\":\"チーム連携、チームツール、メンバー一覧、共有タスクボードを有効にします。\",\"zh\":\"启用团队协作、团队工具、成员列表和共享任务看板。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-inspector-profile\",\"title\":{\"en\":\"Developer tools\",\"ja\":\"開発者ツール\",\"zh\":\"开发者工具\"},\"description\":{\"en\":\"Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.\",\"ja\":\"セッションの生データ、グループ化されたチャットメッセージ、NodeJS バックエンドを確認、デバッグします。\",\"zh\":\"查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\",\"title\":{\"en\":\"Voice input\",\"ja\":\"音声入力\",\"zh\":\"语音输入\"},\"description\":{\"en\":\"Transcribe recordings locally with SenseVoice; first use requires installing dependencies.\",\"ja\":\"ローカルの SenseVoice で録音を文字に起こします。初回利用時には依存関係のインストールが必要です。\",\"zh\":\"在本机使用 SenseVoice 转写录音,首次使用需安装依赖。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-auto-review\",\"title\":{\"en\":\"Auto authorization review\",\"ja\":\"自動認可レビュー\",\"zh\":\"自动授权审查\"},\"description\":{\"en\":\"Add an Auto review permission mode that uses the model to assess authorization before each tool call.\",\"ja\":\"各ツール呼び出しの前にモデルが認可を判断する、自動認可レビュー権限モードを提供します。\",\"zh\":\"提供自动审查权限模式,由模型在每次工具调用前判断是否授权。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-cot-translation-bundle\",\"title\":{\"en\":\"Machine translation of reasoning\",\"ja\":\"思考内容の機械翻訳\",\"zh\":\"思考过程机器翻译\"},\"description\":{\"en\":\"Machine translation of expanded reasoning; source text is sent to the selected service.\",\"ja\":\"展開した思考内容を機械翻訳します。原文は選択したサービスに送信されます。\",\"zh\":\"机器翻译展开的思考内容;原文会发送给所选服务\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-badge-skill-bundle\",\"title\":{\"en\":\"DSH badge skill\",\"ja\":\"DSH バッジスキル\",\"zh\":\"DSH 徽章技能\"},\"description\":{\"en\":\"Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.\",\"ja\":\"エージェントが文書や PR などの成果物に「powered by dsh」バッジを追加できます。\",\"zh\":\"让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-ralph-bundle\",\"title\":{\"en\":\"Ralph loops\",\"ja\":\"Ralph ループ\",\"zh\":\"Ralph 循环\"},\"description\":{\"en\":\"Run explicitly requested repeated work with fresh agents.\",\"ja\":\"ユーザーが明示的に依頼した繰り返し作業を、新しいエージェントで実行します。\",\"zh\":\"使用新智能体执行用户明确要求的循环工作。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-titles-bundle\",\"title\":{\"en\":\"Conversation-following titles\",\"ja\":\"会話に追従するタイトル\",\"zh\":\"随对话更新的标题\"},\"description\":{\"en\":\"Refresh session titles as new human prompts arrive.\",\"ja\":\"ユーザーから新しい指示を受け取ると、セッションのタイトルを更新します。\",\"zh\":\"在收到新的用户提示时更新会话标题。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-terminal-bundle\",\"title\":{\"en\":\"Persistent terminals\",\"ja\":\"永続ターミナル\",\"zh\":\"持久终端\"},\"description\":{\"en\":\"Keep shell processes alive across tool calls.\",\"ja\":\"複数のツール呼び出しの間もシェルプロセスを実行し続けます。\",\"zh\":\"让 shell 进程在多次工具调用之间保持运行。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-search\",\"title\":{\"en\":\"Session search\",\"ja\":\"セッション検索\",\"zh\":\"会话搜索\"},\"description\":{\"en\":\"Give agents tools to search and read earlier sessions from the current workspace.\",\"ja\":\"現在のワークスペースの過去のセッションを検索、読み取りするツールをエージェントに提供します。\",\"zh\":\"为智能体提供搜索和读取当前工作区历史会话的工具。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-tool-worktree\",\"title\":{\"en\":\"Git worktrees\",\"ja\":\"Git ワークツリー\",\"zh\":\"Git 工作树\"},\"description\":{\"en\":\"Let the agent create a new Git branch and worktree, then continue there.\",\"ja\":\"エージェントが新しい Git ブランチとワークツリーを作成し、そこで作業を続けられます。\",\"zh\":\"让智能体创建新的 Git 分支与工作树,并在其中继续工作。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-cua-bundle\",\"title\":{\"en\":\"Computer use\",\"ja\":\"Computer Use\",\"zh\":\"Computer Use\"},\"description\":{\"en\":\"Currently supports macOS; Windows support is in development. Loads computer use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.\",\"ja\":\"現在は macOS のみ対応し、Windows 対応は開発中です。既存および新規の PTC セッションに Computer Use を読み込みます。PTC モードに切り替え、アクセシビリティと画面収録の権限を許可して使用してください。\",\"zh\":\"目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-plugin-market-bundle\",\"title\":{\"en\":\"Custom plugin indexes\",\"ja\":\"カスタムプラグインインデックス\",\"zh\":\"自定义插件索引\"},\"description\":{\"en\":\"Adds a Discover tab to Plugins for browsing and installing bundles from plugin indexes you add.\",\"ja\":\"プラグインページに「見つける」タブを追加し、登録したプラグインインデックスからプラグインを参照、インストールします。\",\"zh\":\"在插件页添加「发现」标签页,从你添加的插件索引浏览和安装插件。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-claude-code\",\"title\":{\"en\":\"Claude Code subagent\",\"ja\":\"Claude Code サブエージェント\",\"zh\":\"Claude Code 子智能体\"},\"description\":{\"en\":\"Delegate one task to Claude Code using its native authentication and settings.\",\"ja\":\"Claude Code のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"zh\":\"使用 Claude Code 原生认证和设置委派单个任务。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-codex\",\"title\":{\"en\":\"Codex subagent\",\"ja\":\"Codex サブエージェント\",\"zh\":\"Codex 子智能体\"},\"description\":{\"en\":\"Delegate one task to Codex using its native authentication and settings.\",\"ja\":\"Codex のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"zh\":\"使用 Codex 原生认证和设置委派单个任务。\"},\"enabled\":false}]\n\n"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_creation_skill"},"toolCallId":"call_creation_skill","content":[{"type":"text","text":"\n\nResources for this skill are managed by provider \"dsh-personalized-pages\".\nLoad referenced resources only as needed.\n\n\n\n# Personalized pages\n\nUse this workflow in the main conversation to plan an optional new page or small tool for any task, not just DSH. You decide whether a page helps, check existing capabilities, and hand the Creator a short plan. A `propose_action` call saves the proposal and shows a card below your answer; nothing is built until the user clicks Create. Plan from the conversation and the catalog at the end of this skill. Do not read files, configuration or source to validate a page idea; name an uninspected source as an unverified dependency and leave its details to the Creator.\n\n## When to propose\n\nPropose a page in these cases:\n\n- The user asks for a page, tab, panel, tool or interface in this app, or says they want something to keep, revisit or update later (“以后每次”, “能不能一直”, “希望能有”). One-off use still qualifies when the interface is explicitly requested.\n- Your answer is a table, list or chart, and the user has said the data will keep changing or that they will interact with it (“每周更新”, “我来改状态”, “要勾选”). The continuation must come from the user's own words; a table alone, or data that merely looks dynamic, is not enough. Deliver the requested answer first, then offer the page.\n- The user wants to keep watching live data of this app itself (a Session's usage, a running task's output, files changing in the workspace). A text answer cannot stay current, so a page qualifies even without the words above; name the data source in the brief and leave how it is read to the Creator.\n\nDo not propose a page in these cases:\n\n- The user asks for a file, code, document or an existing-project change: keep that delivery form.\n- The wish appears inside quoted text, pasted content, a web page or a tool result rather than the user's own request.\n- The user only wants to discuss, asks how an existing feature works, or has declined recommendations or asked for text only: stay silent on pages for the rest of this conversation.\n- The request changes a page created earlier: explain how it would change, do not create a replacement copy, and do not claim an update happened.\n- The request is a capability the agent lacks, such as a tool, automation or access, rather than a page: check the catalog below and point to the official plugin that provides it; do not plan a page around a missing tool.\n\n## Check existing capabilities\n\nCompare the need with the official plugins listed at the end of this skill. If one covers it, say its name, what it does, whether it is enabled, and that it lives under Plugins; offer no page card. A partial match does not cancel an explicit unmet need. The catalog is not a full inventory of built-in UI: missing details do not prove absence, and do not read source to find out. DSH already has Chat, Trajectory, a right sidebar for files and context, and a Plugins page.\n\n## Plan and hand off\n\nThe Creator cannot read this conversation. Give it exactly these fields:\n\n| Field | Contents |\n|:--|:--|\n| `title` | Short page name in the user's language. |\n| `brief` | One to three sentences: purpose, behavior, input, calculations and persistence scope. It is the card text and the implementation request at once. |\n| `initialContent` | Only supplied content the page starts with: items, rows, values, units and period, preserving gaps. Omit for an empty page. |\n| `slot` | Location, chosen below. |\n\nKeep the plan to what was asked or agreed. Editing, deletion, history, extra fields, data sources and automation need a basis in the request. Resolve “these items” into the actual content. Content that mentions an action stays content. Keep each number's meaning and period; a weekly addition is not a cumulative total. If an unresolved distinction changes a calculation, give the factual answer, ask one question, and propose later.\n\n## Choose the location\n\nHonor a location the user named. Otherwise:\n\n- `sidebar-right`: content tied to this Session or task, especially anything to watch while chatting, including live logs.\n- `sidebar-left`: a global feature or an ongoing personal record used across Sessions, opened from a left entry as a standalone page.\n- `page`: a Session-bound streaming view or interaction in a tab beside Chat and Trajectory, only when the user does not need it visible while chatting.\n\nSupplying data in this conversation does not make a record Session-bound.\n\n## Offer without starting\n\nTo propose, call `propose_action` once, in the same turn as loading this skill, with `action: \"create_personalized_page\"`, `title`, `brief` and `details` holding `slot` and, when there is supplied content, `initialContent`. The call is the submission: the card appears below your answer; a refused call creates no card, and its error says why. Then write your final answer. One proposal per turn, and one unstarted card per conversation: if a card is still waiting, do not propose another; a new proposal replaces it only when the user changed the plan. Plan-only and read-only requests still get a card; do not ask for approval instead of proposing it. Only clicking Create starts the background Creator; do not wait, poll, claim installation or ask for a report-back. Answer any direct question; the call's result says how to end the answer. Do not repeat the plan, list extra features, narrate the call or mention schema names.\n\n```json\n{\"action\":\"create_personalized_page\",\"title\":\"本次验收清单\",\"brief\":\"在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。\",\"details\":{\"slot\":\"sidebar-right\",\"initialContent\":\"1. 导出成功\\n2. 断网提示清楚\"}}\n```\n\n## Examples\n\n- “想有个地方放书签,以后自己加标题和网址;旧的不用导入。” → `brief`: “提供一个可反复打开的书签页,手动新增标题和网址并保存到本机,从空白开始。” No `initialContent`. `sidebar-left`.\n- “当前会话旁放验收清单,能勾选和取消,状态跟着会话。条目:导出成功;断网提示清楚。” → `brief`: “在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。” `initialContent`: “1. 导出成功\\n2. 断网提示清楚”. `sidebar-right`.\n- “这三周 bug 数 12、9、15,说说趋势。以后每周我把新数字贴给你,想自己更新着看。” → answer the trend first, then `brief`: “按周手动录入 bug 数并展示各周变化,保存到本机。” `initialContent`: “第 1 周 12,第 2 周 9,第 3 周 15”. `sidebar-left`.\n- “我想用语音说话代替打字。” → the catalog lists 语音输入; explain it and where to enable it; no card.\n- “把我之前做的读书页评分改成五星。” → explain the change to that page; no card and no copy.\n- “帮我把这句话翻成英文:我想要一个所有会话都能打开的读书页。” → translate only; no card.\n- “把三个方案排成表格比较一下,看完就行。” → the table only; no card.\n\n## Existing capabilities to check against this page plan\n\n[{\"name\":\"@deepseek-ai/dsh-creation-recommendations\",\"title\":{\"en\":\"Personalized pages\",\"ja\":\"パーソナルページ\",\"zh\":\"个性化页面\"},\"description\":{\"en\":\"Plan a personalized page in the conversation when existing features do not meet a specific need. Click Create to generate an independent plugin you can keep using or remove.\",\"ja\":\"既存の機能では明確な要望を満たせないとき、会話の中でパーソナルページを計画します。作成をクリックすると独立したプラグインが生成され、使い続けることも元に戻すこともできます。\",\"zh\":\"现有功能不能满足你的明确需求时,在对话中规划个性化页面;点击创建后生成独立插件,可持续使用或撤销。\"},\"enabled\":true},{\"name\":\"@deepseek-ai/dsh-experimental-agent-team-profile\",\"title\":{\"en\":\"Agent teams\",\"ja\":\"エージェントチーム\",\"zh\":\"智能体团队\"},\"description\":{\"en\":\"Enable team collaboration, team tools, the member roster, and the shared task board.\",\"ja\":\"チーム連携、チームツール、メンバー一覧、共有タスクボードを有効にします。\",\"zh\":\"启用团队协作、团队工具、成员列表和共享任务看板。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-inspector-profile\",\"title\":{\"en\":\"Developer tools\",\"ja\":\"開発者ツール\",\"zh\":\"开发者工具\"},\"description\":{\"en\":\"Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.\",\"ja\":\"セッションの生データ、グループ化されたチャットメッセージ、NodeJS バックエンドを確認、デバッグします。\",\"zh\":\"查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\",\"title\":{\"en\":\"Voice input\",\"ja\":\"音声入力\",\"zh\":\"语音输入\"},\"description\":{\"en\":\"Transcribe recordings locally with SenseVoice; first use requires installing dependencies.\",\"ja\":\"ローカルの SenseVoice で録音を文字に起こします。初回利用時には依存関係のインストールが必要です。\",\"zh\":\"在本机使用 SenseVoice 转写录音,首次使用需安装依赖。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-auto-review\",\"title\":{\"en\":\"Auto authorization review\",\"ja\":\"自動認可レビュー\",\"zh\":\"自动授权审查\"},\"description\":{\"en\":\"Add an Auto review permission mode that uses the model to assess authorization before each tool call.\",\"ja\":\"各ツール呼び出しの前にモデルが認可を判断する、自動認可レビュー権限モードを提供します。\",\"zh\":\"提供自动审查权限模式,由模型在每次工具调用前判断是否授权。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-cot-translation-bundle\",\"title\":{\"en\":\"Machine translation of reasoning\",\"ja\":\"思考内容の機械翻訳\",\"zh\":\"思考过程机器翻译\"},\"description\":{\"en\":\"Machine translation of expanded reasoning; source text is sent to the selected service.\",\"ja\":\"展開した思考内容を機械翻訳します。原文は選択したサービスに送信されます。\",\"zh\":\"机器翻译展开的思考内容;原文会发送给所选服务\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-badge-skill-bundle\",\"title\":{\"en\":\"DSH badge skill\",\"ja\":\"DSH バッジスキル\",\"zh\":\"DSH 徽章技能\"},\"description\":{\"en\":\"Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.\",\"ja\":\"エージェントが文書や PR などの成果物に「powered by dsh」バッジを追加できます。\",\"zh\":\"让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-ralph-bundle\",\"title\":{\"en\":\"Ralph loops\",\"ja\":\"Ralph ループ\",\"zh\":\"Ralph 循环\"},\"description\":{\"en\":\"Run explicitly requested repeated work with fresh agents.\",\"ja\":\"ユーザーが明示的に依頼した繰り返し作業を、新しいエージェントで実行します。\",\"zh\":\"使用新智能体执行用户明确要求的循环工作。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-titles-bundle\",\"title\":{\"en\":\"Conversation-following titles\",\"ja\":\"会話に追従するタイトル\",\"zh\":\"随对话更新的标题\"},\"description\":{\"en\":\"Refresh session titles as new human prompts arrive.\",\"ja\":\"ユーザーから新しい指示を受け取ると、セッションのタイトルを更新します。\",\"zh\":\"在收到新的用户提示时更新会话标题。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-terminal-bundle\",\"title\":{\"en\":\"Persistent terminals\",\"ja\":\"永続ターミナル\",\"zh\":\"持久终端\"},\"description\":{\"en\":\"Keep shell processes alive across tool calls.\",\"ja\":\"複数のツール呼び出しの間もシェルプロセスを実行し続けます。\",\"zh\":\"让 shell 进程在多次工具调用之间保持运行。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-search\",\"title\":{\"en\":\"Session search\",\"ja\":\"セッション検索\",\"zh\":\"会话搜索\"},\"description\":{\"en\":\"Give agents tools to search and read earlier sessions from the current workspace.\",\"ja\":\"現在のワークスペースの過去のセッションを検索、読み取りするツールをエージェントに提供します。\",\"zh\":\"为智能体提供搜索和读取当前工作区历史会话的工具。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-tool-worktree\",\"title\":{\"en\":\"Git worktrees\",\"ja\":\"Git ワークツリー\",\"zh\":\"Git 工作树\"},\"description\":{\"en\":\"Let the agent create a new Git branch and worktree, then continue there.\",\"ja\":\"エージェントが新しい Git ブランチとワークツリーを作成し、そこで作業を続けられます。\",\"zh\":\"让智能体创建新的 Git 分支与工作树,并在其中继续工作。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-cua-bundle\",\"title\":{\"en\":\"Computer use\",\"ja\":\"Computer Use\",\"zh\":\"Computer Use\"},\"description\":{\"en\":\"Currently supports macOS; Windows support is in development. Loads computer use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.\",\"ja\":\"現在は macOS のみ対応し、Windows 対応は開発中です。既存および新規の PTC セッションに Computer Use を読み込みます。PTC モードに切り替え、アクセシビリティと画面収録の権限を許可して使用してください。\",\"zh\":\"目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-plugin-market-bundle\",\"title\":{\"en\":\"Custom plugin indexes\",\"ja\":\"カスタムプラグインインデックス\",\"zh\":\"自定义插件索引\"},\"description\":{\"en\":\"Adds a Discover tab to Plugins for browsing and installing bundles from plugin indexes you add.\",\"ja\":\"プラグインページに「見つける」タブを追加し、登録したプラグインインデックスからプラグインを参照、インストールします。\",\"zh\":\"在插件页添加「发现」标签页,从你添加的插件索引浏览和安装插件。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-claude-code\",\"title\":{\"en\":\"Claude Code subagent\",\"ja\":\"Claude Code サブエージェント\",\"zh\":\"Claude Code 子智能体\"},\"description\":{\"en\":\"Delegate one task to Claude Code using its native authentication and settings.\",\"ja\":\"Claude Code のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"zh\":\"使用 Claude Code 原生认证和设置委派单个任务。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-codex\",\"title\":{\"en\":\"Codex subagent\",\"ja\":\"Codex サブエージェント\",\"zh\":\"Codex 子智能体\"},\"description\":{\"en\":\"Delegate one task to Codex using its native authentication and settings.\",\"ja\":\"Codex のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"zh\":\"使用 Codex 原生认证和设置委派单个任务。\"},\"enabled\":false}]\n\n"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["ask_user_question","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","propose_action","read","read_image","schedule_create","schedule_delete","schedule_list","schedule_update","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}} diff --git a/tsconfig.base.json b/tsconfig.base.json index 8cf044e7c4..7c090409a9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -55,14 +55,14 @@ "@deepseek-ai/dsh-experimental-translator": ["./packages/experimental/translator/src/index.ts"], "@deepseek-ai/dsh-experimental-translator/types": ["./packages/experimental/translator/src/types.ts"], "@deepseek-ai/dsh-experimental-client-ui-cot-translation": ["./packages/experimental/client-ui-cot-translation/src/index.ts"], - "@deepseek-ai/dsh-experimental-action-cards": ["./packages/experimental/action-cards/src/index.ts"], - "@deepseek-ai/dsh-experimental-action-cards/types": ["./packages/experimental/action-cards/src/types.ts"], - "@deepseek-ai/dsh-experimental-client-ui-action-cards": ["./packages/experimental/client-ui-action-cards/src/index.ts"], - "@deepseek-ai/dsh-experimental-client-ui-action-cards/client": ["./packages/experimental/client-ui-action-cards/src/client/index.ts"], - "@deepseek-ai/dsh-experimental-creation-recommendations": ["./packages/experimental/creation-recommendations/src/index.ts"], - "@deepseek-ai/dsh-experimental-creation-recommendations/types": ["./packages/experimental/creation-recommendations/src/types.ts"], - "@deepseek-ai/dsh-experimental-client-ui-creation-recommendations": ["./packages/experimental/client-ui-creation-recommendations/src/index.ts"], - "@deepseek-ai/dsh-experimental-client-ui-creation-recommendations/client": ["./packages/experimental/client-ui-creation-recommendations/src/client/index.ts"], + "@deepseek-ai/dsh-action-cards": ["./packages/experimental/action-cards/src/index.ts"], + "@deepseek-ai/dsh-action-cards/types": ["./packages/experimental/action-cards/src/types.ts"], + "@deepseek-ai/dsh-client-ui-action-cards": ["./packages/experimental/client-ui-action-cards/src/index.ts"], + "@deepseek-ai/dsh-client-ui-action-cards/client": ["./packages/experimental/client-ui-action-cards/src/client/index.ts"], + "@deepseek-ai/dsh-creation-recommendations": ["./packages/experimental/creation-recommendations/src/index.ts"], + "@deepseek-ai/dsh-creation-recommendations/types": ["./packages/experimental/creation-recommendations/src/types.ts"], + "@deepseek-ai/dsh-client-ui-creation-recommendations": ["./packages/experimental/client-ui-creation-recommendations/src/index.ts"], + "@deepseek-ai/dsh-client-ui-creation-recommendations/client": ["./packages/experimental/client-ui-creation-recommendations/src/client/index.ts"], "@deepseek-ai/dsh-experimental-client-ui-cot-translation/types": ["./packages/experimental/client-ui-cot-translation/src/types.ts"], "@deepseek-ai/dsh-experimental-client-ui-cot-translation/client": ["./packages/experimental/client-ui-cot-translation/src/client/index.ts"], "@deepseek-ai/dsh-experimental-cot-translation-bundle": ["./packages/experimental/cot-translation-bundle/src/index.ts"],