diff --git a/apps/desktop/e2e/parent-session-deletion.spec.ts b/apps/desktop/e2e/parent-session-deletion.spec.ts index cdee277d19..629022dfe4 100644 --- a/apps/desktop/e2e/parent-session-deletion.spec.ts +++ b/apps/desktop/e2e/parent-session-deletion.spec.ts @@ -43,6 +43,10 @@ test('deleting a parent task archives its linked subagent task', async ({ name: `删除 "${PARENT_REMOVAL_PARENT_NAME}"`, }); await expect(confirm).toBeVisible(); + // The confirm warns that the linked subtask is kept and archived rather than + // destroyed, so the archived row that appears next is not a surprise. It names + // no count — the Host owns the exact number and reports it in the toast. + await expect(confirm.getByText(/子任务.*归档/)).toBeVisible(); await confirm.getByRole('button', { name: '删除', exact: true }).click(); await expect(parentRow).toHaveCount(0); diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 40dfc58ecc..082b89dd08 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -309,7 +309,10 @@ test('abandons a remove whose task was restored under it', async () => { { kind: 'removed' }, ]); - assert.equal(await client.removeSession('session-1', { requireArchived: true }), 'restored'); + assert.deepEqual(await client.removeSession('session-1', { requireArchived: true }), { + disposition: 'restored', + archivedSubtaskCount: 0, + }); assert.deepEqual( requests.map(({ operation }) => operation), ['session.catalog.query', 'session.remove', 'session.catalog.query'], @@ -323,10 +326,14 @@ test('retries a remove through revision churn that left the task archived', asyn { kind: 'session', session: session('session-1', 4, { isArchived: true }) }, { kind: 'revision_conflict', expectedRevision: 4, actualRevision: 5 }, { kind: 'session', session: session('session-1', 5, { isArchived: true }) }, - { kind: 'removed' }, + // The Host reports what it archived; the client surfaces it verbatim. + { kind: 'removed', archivedSubtaskCount: 2 }, ]); - assert.equal(await client.removeSession('session-1', { requireArchived: true }), 'removed'); + assert.deepEqual(await client.removeSession('session-1', { requireArchived: true }), { + disposition: 'removed', + archivedSubtaskCount: 2, + }); assert.deepEqual( requests.filter(({ operation }) => operation === 'session.remove').map(({ input }) => input), [ @@ -344,7 +351,10 @@ test('removes a task that was never archived when no premise was stated', async { kind: 'removed' }, ]); - assert.equal(await client.removeSession('session-1'), 'removed'); + assert.deepEqual(await client.removeSession('session-1'), { + disposition: 'removed', + archivedSubtaskCount: 0, + }); }); test('rebuilds a Runtime Policy mutation from each fresh CAS projection', async () => { diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts index 887a852605..5d417d26a5 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-uds.test.ts @@ -293,13 +293,16 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn // A purge sweep asks for the task it saw archived. Restored under it, the // deletion is called off rather than replayed at the fresh revision (#3050). restoreUnderNextRemove = true; - assert.equal( + assert.deepEqual( await ipc.invoke('sessions:remove', 'session-ipc', { revisionFamily: true, requireArchived: true }), - 'restored', + { disposition: 'restored', archivedSubtaskCount: 0 }, ); assert.equal((await ipc.invoke('sessions:list') as Array<{ isArchived: boolean }>)[0]?.isArchived, false); await ipc.invoke('sessions:archive', 'session-ipc'); - assert.equal(await ipc.invoke('sessions:remove', 'session-ipc'), 'removed'); + assert.deepEqual(await ipc.invoke('sessions:remove', 'session-ipc'), { + disposition: 'removed', + archivedSubtaskCount: 0, + }); assert.deepEqual(await ipc.invoke('sessions:list'), []); // Nothing was retired for the restored task: no `deleted` between the two // archives, and the renderer keeps everything it holds for it. diff --git a/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts index 324065fe05..7e7a59f9ef 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-row-actions-revisions.test.ts @@ -40,7 +40,15 @@ function summary(id: string, overrides: Partial = {}): SessionSu }; } -function createService(calls: string[]) { +function createService( + calls: string[], + opts: { + disposition?: 'removed' | 'restored'; + archivedSubtaskCount?: number; + preview?: { count?: number; throws?: boolean }; + } = {}, +) { + const { disposition = 'removed', archivedSubtaskCount = 0, preview = {} } = opts; return { list: async () => [], setFlagged: async (id: string, value: boolean, options: { revisionFamily: true }) => { @@ -60,7 +68,12 @@ function createService(calls: string[]) { options: { revisionFamily: true; requireArchived: boolean }, ) => { calls.push(`remove:${id}:${options.revisionFamily}:${options.requireArchived}`); - return 'removed' as const; + return { disposition, archivedSubtaskCount }; + }, + previewRemoval: async (id: string) => { + calls.push(`preview:${id}`); + if (preview.throws) throw new Error('preview failed'); + return preview.count ?? 0; }, }; } @@ -104,6 +117,9 @@ describe('revision-family session row actions', () => { 'flag:version:true:true', 'rename:branch:Independent branch:true', 'archive:version:true', + // The delete asks the Host how many subtasks it would archive before the + // confirm, then removes. + 'preview:root', // `root` is not archived, so the delete states no archived premise — // requiring one would refuse every delete from the rail. 'remove:root:true:false', @@ -112,3 +128,109 @@ describe('revision-family session row actions', () => { assert.deepEqual(cleared, ['root', 'version', 'root', 'version']); }); }); + +function deleteHarness( + sessions: readonly SessionSummary[], + disposition: 'removed' | 'restored' = 'removed', + archivedSubtaskCount = 0, + preview: { count?: number; throws?: boolean } = {}, +) { + const calls: string[] = []; + const confirms: Array<{ title: string; description: string }> = []; + const successes: Array<{ title: string; description?: string }> = []; + const actions = createSessionNavigationRowActions({ + uiLocale: 'en', + activeIdRef: { current: undefined }, + clearActiveMessages: () => undefined, + clearSessionRendererState: () => undefined, + pendingSessionRowActionsRef: { current: new Set() }, + refreshSessions: async () => [...sessions], + service: createService(calls, { disposition, archivedSubtaskCount, preview }), + sessionsRef: { current: [...sessions] }, + setActiveId: () => undefined, + toastApi: { + success: (title, description) => { successes.push({ title, description }); }, + error: () => undefined, + confirm: async (options) => { confirms.push({ title: options.title, description: options.description }); return true; }, + }, + }); + return { actions, calls, confirms, successes }; +} + +describe('delete confirm warns off the Host preview, toast reports the Host count', () => { + it('warns when the Host preview reports subtasks, and the toast reports the executed count', async () => { + const parent = summary('parent', { name: 'hi' }); + // The confirm warns off the Host preview (1); the toast reports the Host's + // executed count (2). Neither is a renderer estimate, and the two Host reads + // are independent — the confirm never leaks the executed number. + const { actions, calls, confirms, successes } = deleteHarness( + [parent], + 'removed', + 2, + { count: 1 }, + ); + + await actions.deleteSession('parent'); + + // Preview runs before the remove. + assert.deepEqual( + calls.filter((c) => c.startsWith('preview:') || c.startsWith('remove:')), + ['preview:parent', 'remove:parent:true:false'], + ); + assert.equal(confirms.length, 1); + assert.match(confirms[0].description, /kept and moved to Archived/); + assert.doesNotMatch(confirms[0].description, /\d/); + assert.deepEqual(successes, [{ title: 'Deleted hi', description: '2 subtasks moved to Archived' }]); + }); + + it('shows no subtask note when the Host preview reports zero', async () => { + // e.g. a parent whose only children are graph operators: the renderer can't + // tell from its projection, but the Host preview says 0, so no false promise. + const { actions, confirms, successes } = deleteHarness( + [summary('parent', { name: 'hi' })], + 'removed', + 0, + { count: 0 }, + ); + + await actions.deleteSession('parent'); + + assert.equal(confirms.length, 1); + assert.doesNotMatch(confirms[0].description, /subtask/); + assert.deepEqual(successes, [{ title: 'Deleted hi', description: undefined }]); + }); + + it('warns with uncertainty and still deletes when the preview call fails', async () => { + const { actions, calls, confirms, successes } = deleteHarness( + [summary('parent', { name: 'hi' })], + 'removed', + 0, + { throws: true }, + ); + + await actions.deleteSession('parent'); + + // Fail-open would hide the warning; instead the confirm hedges so it never + // silently omits that subtasks may survive. + assert.match(confirms[0].description, /if any.*kept and moved to Archived/); + // The delete is not blocked by a preview failure. + assert.ok(calls.includes('remove:parent:true:false')); + assert.deepEqual(successes, [{ title: 'Deleted hi', description: undefined }]); + }); + + it('stays silent on the toast when a concurrent restore calls the delete off', async () => { + const { actions, confirms, successes } = deleteHarness( + [summary('parent', { name: 'hi' })], + 'restored', + 0, + { count: 1 }, + ); + + await actions.deleteSession('parent'); + + // The confirm still warns — the person is deciding before the race resolves. + assert.match(confirms[0].description, /kept and moved to Archived/); + // But nothing was deleted, so nothing moved to the archive. + assert.deepEqual(successes, [{ title: 'hi was restored, so it was kept', description: undefined }]); + }); +}); diff --git a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts index 3281407947..44f863b727 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts @@ -78,9 +78,16 @@ function installService( * against whatever the renderer last saw. */ catalog?: readonly SessionSummary[]; + /** Subtasks the Host archives per removed id, summed into the outcome. */ + archivedByRemoval?: Record; } = {}, ): SessionNavigationSessionService { return { + list: async () => { + harness.listCalls += 1; + if (!options.surviving) throw new Error('catalog unavailable'); + return [...options.surviving]; + }, setFlagged: async () => undefined, archive: async () => undefined, unarchive: async () => undefined, @@ -92,16 +99,14 @@ function installService( } if (options.rejectIds?.includes(id)) throw new Error(`busy:${id}`); const target = options.catalog?.find((session) => session.id === id); - if (removeOptions.requireArchived && target && !target.isArchived) return 'restored'; + if (removeOptions.requireArchived && target && !target.isArchived) { + return { disposition: 'restored', archivedSubtaskCount: 0 }; + } harness.removed.push(id); options.onRemove?.(id); - return 'removed'; - }, - list: async () => { - harness.listCalls += 1; - if (!options.surviving) throw new Error('catalog unavailable'); - return [...options.surviving]; + return { disposition: 'removed', archivedSubtaskCount: options.archivedByRemoval?.[id] ?? 0 }; }, + previewRemoval: async () => 0, }; } @@ -166,6 +171,7 @@ describe('purgeSessions', () => { assert.deepEqual(h.removed, ['a-v2', 'b']); assert.deepEqual(outcome, { removed: 2, + archivedSubtasks: 0, remaining: [], restored: [], verified: true, @@ -184,6 +190,20 @@ describe('purgeSessions', () => { assert.equal(h.listCalls, 0); }); + it('sums the linked subtasks the Host archived across the sweep', async () => { + const h = harness(); + const sessions = [summary('p1'), summary('p2'), summary('p3')]; + const activeIdRef = { current: undefined as string | undefined }; + // p1 archives 2 subtasks, p3 archives 1; p2 archives none. + const service = installService(h, { archivedByRemoval: { p1: 2, p3: 1 } }); + const actions = createActions({ harness: h, sessions, activeIdRef, service }); + + const outcome = await actions.purgeSessions(['p1', 'p2', 'p3']); + + assert.equal(outcome.removed, 3); + assert.equal(outcome.archivedSubtasks, 3); + }); + it('reports a task restored before the sweep reached it, rather than dropping it', async () => { // The confirm named a set. One restored from another surface while the // dialog was up has left it, and a sweep that deleted it anyway would be diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ff9e34e11b..f2d98a6f9d 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -151,6 +151,17 @@ export type DesktopSessionConfigurationPatch = Partial; */ export type SessionRemoveDisposition = "removed" | "restored"; +/** + * How a remove settled together with what it archived. `archivedSubtaskCount` + * is the Host's executed count of ordinary linked subtasks moved to the archive + * — 0 when the delete was called off (`restored`) or archived nothing — so the + * renderer's toast reports a fact rather than a renderer-side estimate. + */ +export interface SessionRemoveOutcome { + readonly disposition: SessionRemoveDisposition; + readonly archivedSubtaskCount: number; +} + export type DesktopRuntimeHostClientErrorCode = | "catalog_unstable" | "client_closed" @@ -957,19 +968,34 @@ export class DesktopRuntimeHostClient { async removeSession( sessionId: string, options: { requireArchived?: boolean } = {}, - ): Promise { + ): Promise { for (let attempt = 0; attempt < MAX_SESSION_REVISION_ATTEMPTS; attempt += 1) { const current = await this.#requireSession(sessionId); - if (options.requireArchived && !current.isArchived) return "restored"; + if (options.requireArchived && !current.isArchived) { + return { disposition: "restored", archivedSubtaskCount: 0 }; + } const result = await this.request("session.remove", { sessionId, expectedRevision: current.revision, }); - if (result.kind === "removed") return "removed"; + if (result.kind === "removed") { + return { disposition: "removed", archivedSubtaskCount: result.archivedSubtaskCount ?? 0 }; + } } throw revisionConflict("remove", sessionId); } + /** + * How many linked subtasks a delete of this parent would move to the archive, + * per the Host's own removal plan. The delete confirm warns off this so the + * renderer never re-derives the plan from a catalog projection that omits the + * operator marker and copy state. + */ + async previewSessionRemoval(sessionId: string): Promise { + const result = await this.request("session.remove.preview", { sessionId }); + return result.archivableSubtaskCount; + } + async removeSessionCopy(sessionId: string): Promise<'removed' | 'retained'> { try { const current = await this.#requireSession(sessionId); diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index c2ccd258d8..a3d5a9b413 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -51,6 +51,7 @@ type RuntimeHostSessionCatalogClient = Pick< DesktopRuntimeHostClient, | 'createSession' | 'listSessions' + | 'previewSessionRemoval' | 'removeSession' | 'setSessionLifecycle' | 'updateSessionConfiguration' @@ -213,11 +214,15 @@ export function registerRuntimeHostSessionCatalogIpc( const ids = await actionIds(sessionId, { revisionFamily: true }); // A task restored under the caller's decision is left alone, and nothing // downstream of the deletion runs for it. - const disposition = await deps.client.removeSession(sessionId, { + const outcome = await deps.client.removeSession(sessionId, { requireArchived: requiresArchivedSession(options), }); - if (disposition === 'removed') await finishSessionRetirement(deps, ids, 'deleted'); - return disposition; + if (outcome.disposition === 'removed') await finishSessionRetirement(deps, ids, 'deleted'); + return outcome; + }); + ipcMain.handle('sessions:removePreview', async (_event, sessionId: string) => { + // Read-only: how many subtasks the delete would archive, for the confirm. + return deps.client.previewSessionRemoval(sessionId); }); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f2aa6d8817..2001f1446e 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -961,12 +961,20 @@ export interface MakaBridge { setThinkingLevel(sessionId: string, level: ThinkingLevel | undefined | null): Promise; /** * `requireArchived` holds the caller's premise through the deletion: a task - * restored meanwhile answers `restored` and is kept. + * restored meanwhile answers `restored` and is kept. `archivedSubtaskCount` + * is the Host's executed count of ordinary linked subtasks moved to the + * archive — 0 when restored or when nothing was archived. */ remove( sessionId: string, options?: { revisionFamily?: boolean; requireArchived?: boolean }, - ): Promise<'removed' | 'restored'>; + ): Promise<{ disposition: 'removed' | 'restored'; archivedSubtaskCount: number }>; + /** + * How many linked subtasks a delete of this parent would move to the + * archive, per the Host's removal plan. The confirm warns off this instead + * of estimating from the catalog projection. + */ + previewRemoval(sessionId: string): Promise; cleanupSessionCopy(sessionId: string): Promise; abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7fc166f717..68e148e17d 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1966,9 +1966,12 @@ const makaBridge = { remove( sessionId: string, options?: { revisionFamily?: boolean; requireArchived?: boolean }, - ): Promise<'removed' | 'restored'> { + ): Promise<{ disposition: 'removed' | 'restored'; archivedSubtaskCount: number }> { return invokeSessionRuntimeHost('sessions:remove', sessionId, options); }, + previewRemoval(sessionId: string): Promise { + return invokeSessionRuntimeHost('sessions:removePreview', sessionId); + }, cleanupSessionCopy(sessionId: string): Promise { return invokeSessionRuntimeHost('sessions:cleanupSessionCopy', sessionId); }, diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts index aca2d257f1..6b13529764 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts @@ -28,6 +28,16 @@ type RefBox = { current: T }; /** What `sessions.remove` settled on. `restored` means the task is still there. */ type SessionRemoveDisposition = 'removed' | 'restored'; +/** + * How a delete settled together with the count the Host actually archived. + * `archivedSubtaskCount` is the Host's executed number — 0 when the delete was + * called off (`restored`) — so the toast reports a fact, not a renderer guess. + */ +type SessionRemoveOutcome = { + disposition: SessionRemoveDisposition; + archivedSubtaskCount: number; +}; + type ToastApi = { success(title: string, description?: string): void; error( @@ -52,6 +62,12 @@ type ToastApi = { export interface SessionPurgeOutcome { /** Tasks confirmed gone. */ removed: number; + /** + * Linked subtasks the Host moved to the archive across the sweep, summed from + * each removal's executed count. Reported so a bulk purge does not silently + * archive active subtasks. + */ + archivedSubtasks: number; /** Tasks the catalog still reports. Empty when `verified` is false. */ remaining: string[]; /** @@ -164,9 +180,30 @@ export function createSessionNavigationRowActions(deps: { return runSessionRowAction(sessionId, 'delete', copy.deleteFailedTitle, async () => { const session = sessionsRef.current.find((entry) => entry.id === sessionId); const name = session?.name ?? copy.currentConversation; + // Ask the Host how many subtasks the delete would archive. It owns the + // removal plan; the renderer's catalog projection lacks the operator + // marker and copy state, so a renderer estimate would over-promise (e.g. + // claim archival for a parent whose only children are graph operators). + // A preview failure is not silence: fall back to an uncertain warning so + // the confirm never hides that subtasks may survive. The toast still + // reports the real executed count afterwards. + let previewSubtaskCount: number | undefined; + try { + previewSubtaskCount = await service.previewRemoval(sessionId); + } catch { + previewSubtaskCount = undefined; + } + const subtaskNote = + previewSubtaskCount === undefined + ? copy.deleteSubtaskNoteUncertain() + : previewSubtaskCount > 0 + ? copy.deleteSubtaskNote() + : undefined; const ok = await toastApi.confirm({ title: copy.deleteTitle(name), - description: copy.deleteDescription, + description: subtaskNote + ? `${copy.deleteDescription} ${subtaskNote}` + : copy.deleteDescription, confirmLabel: copy.deleteLabel, cancelLabel: copy.cancelLabel, destructive: true, @@ -174,12 +211,18 @@ export function createSessionNavigationRowActions(deps: { if (!ok) return; // The confirm named an archived task, so a restore revokes it. An active // task has no such premise to lose. - const disposition = await removeSessionFamily(sessionId, { + const { disposition, archivedSubtaskCount } = await removeSessionFamily(sessionId, { requireArchived: session?.isArchived === true, }); await refreshSessions(); + // `restored` means nothing was deleted, so no subtask moved either. On a + // real delete the count is the Host's executed number, not an estimate. if (disposition === 'restored') toastApi.success(copy.deleteRestoredTitle(name)); - else toastApi.success(copy.deletedTitle(name)); + else + toastApi.success( + copy.deletedTitle(name), + archivedSubtaskCount > 0 ? copy.deletedSubtaskNote(archivedSubtaskCount) : undefined, + ); }); } @@ -193,21 +236,21 @@ export function createSessionNavigationRowActions(deps: { async function removeSessionFamily( sessionId: string, options: { requireArchived: boolean }, - ): Promise { + ): Promise { // Read before the write: the family comes off the live catalog, which no // longer lists it afterwards. const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); - const disposition = await service.remove(sessionId, { + const outcome = await service.remove(sessionId, { revisionFamily: true, requireArchived: options.requireArchived, }); - if (disposition === 'restored') return disposition; + if (outcome.disposition === 'restored') return outcome; if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { setActiveId(undefined); clearActiveMessages(); } for (const id of familyIds) clearSessionRendererState(id); - return disposition; + return outcome; } /** @@ -239,6 +282,7 @@ export function createSessionNavigationRowActions(deps: { const restored: string[] = []; let firstFailure: SessionPurgeOutcome['firstFailure']; let removed = 0; + let archivedSubtasks = 0; for (const sessionId of sessionIds) { const key = `${sessionId}:delete`; if ( @@ -251,9 +295,14 @@ export function createSessionNavigationRowActions(deps: { } pendingSessionRowActionsRef.current.add(key); try { - const disposition = await removeSessionFamily(sessionId, { requireArchived: true }); + const { disposition, archivedSubtaskCount } = await removeSessionFamily(sessionId, { + requireArchived: true, + }); if (disposition === 'restored') restored.push(sessionId); - else removed += 1; + else { + removed += 1; + archivedSubtasks += archivedSubtaskCount; + } } catch (error) { unsettled.push(sessionId); firstFailure ??= { error, sessionId }; @@ -265,6 +314,7 @@ export function createSessionNavigationRowActions(deps: { await refreshSessions(); return { removed, + archivedSubtasks, remaining: [], restored, verified: true, @@ -281,6 +331,7 @@ export function createSessionNavigationRowActions(deps: { if (!listed) { return { removed, + archivedSubtasks, remaining: [], restored, verified: false, @@ -291,6 +342,7 @@ export function createSessionNavigationRowActions(deps: { const remaining = unsettled.filter((sessionId) => present.has(sessionId)); return { removed: removed + (unsettled.length - remaining.length), + archivedSubtasks, remaining, restored, verified: true, diff --git a/apps/desktop/src/renderer/features/session-navigation/ports.ts b/apps/desktop/src/renderer/features/session-navigation/ports.ts index 60c45db690..1bd6d1497d 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ports.ts +++ b/apps/desktop/src/renderer/features/session-navigation/ports.ts @@ -21,6 +21,16 @@ import type { SessionSummary } from '@maka/core/session'; export type SessionNavigationRemoveDisposition = 'removed' | 'restored'; +/** + * How a delete settled together with the count the Host actually archived. + * `archivedSubtaskCount` is the Host's executed number — 0 when the delete was + * called off (`restored`) — so the toast reports a fact, not a renderer guess. + */ +export interface SessionNavigationRemoveOutcome { + readonly disposition: SessionNavigationRemoveDisposition; + readonly archivedSubtaskCount: number; +} + export interface SessionNavigationSession extends SessionSummary { readonly profileId: string; readonly profileName: string; @@ -51,7 +61,13 @@ export interface SessionNavigationSessionService { remove( sessionId: string, options: { revisionFamily: true; requireArchived: boolean }, - ): Promise; + ): Promise; + /** + * How many linked subtasks a delete of this parent would move to the archive, + * per the Host's removal plan. The delete confirm warns off this instead of + * estimating from the catalog projection. + */ + previewRemoval(sessionId: string): Promise; } export interface SessionNavigationServices { diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index f39ec85855..2e320ccf31 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -52,7 +52,8 @@ export function createFakeSessionNavigationServices( archive: async () => undefined, unarchive: async () => undefined, rename: async () => undefined, - remove: async () => 'removed', + remove: async () => ({ disposition: 'removed', archivedSubtaskCount: 0 }), + previewRemoval: async () => 0, }, ...overrides, }; diff --git a/apps/desktop/src/renderer/locales/settings-tasks-copy.ts b/apps/desktop/src/renderer/locales/settings-tasks-copy.ts index cc7ac4e296..b0917f10bd 100644 --- a/apps/desktop/src/renderer/locales/settings-tasks-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-tasks-copy.ts @@ -34,8 +34,12 @@ export type SettingsTasksCopy = { purgeAllConfirmTitle(count: number): string; purgeMatchesConfirmTitle(count: number): string; purgeConfirmBody: string; + /** Appended to the purge confirm: a bulk delete keeps linked subtasks. */ + purgeSubtaskNote: string; purgeConfirmAction: string; purgedToast(count: number): string; + /** Toast suffix after a purge that moved linked subtasks to the archive. */ + purgedSubtaskNote(count: number): string; /** * Tasks a sweep kept because they were restored while it ran. Reads after * either outcome, so a sweep never has to choose between reporting a failure @@ -66,8 +70,10 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = { purgeAllConfirmTitle: (count: number) => `清空全部 ${count} 条已归档任务?`, purgeMatchesConfirmTitle: (count: number) => `删除搜索到的 ${count} 条任务?`, purgeConfirmBody: '这些任务及其全部消息会被永久删除,无法撤销。', + purgeSubtaskNote: '其关联的子任务不会被删除,将保留并移入归档。', purgeConfirmAction: '永久删除', purgedToast: (count: number) => `已删除 ${count} 条任务`, + purgedSubtaskNote: (count: number) => `${count} 个子任务已移入归档`, purgeKeptRestored: (count: number) => `另有 ${count} 条在此期间被恢复,已保留。`, purgeFailedTitle: '删除任务失败', purgeFailedBody: (count: number) => `${count} 条仍在,请重试。`, @@ -94,8 +100,11 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = { count === 1 ? 'Delete the 1 task you searched for?' : `Delete the ${count} tasks you searched for?`, purgeConfirmBody: 'The tasks and all of their messages are removed permanently. This cannot be undone.', + purgeSubtaskNote: 'Any linked subtasks are kept and moved to Archived.', purgeConfirmAction: 'Delete permanently', purgedToast: (count: number) => (count === 1 ? 'Deleted 1 task' : `Deleted ${count} tasks`), + purgedSubtaskNote: (count: number) => + count === 1 ? '1 subtask moved to Archived' : `${count} subtasks moved to Archived`, purgeKeptRestored: (count: number) => count === 1 ? '1 more was restored meanwhile and kept.' diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index a858a39464..cbcc12e180 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -268,6 +268,12 @@ type ShellCopy = { deletedTitle(name: string): string; /** The task was restored elsewhere, so the delete was called off. */ deleteRestoredTitle(name: string): string; + /** Appended to the delete confirm when the task has linked subagent subtasks. */ + deleteSubtaskNote(): string; + /** Appended to the delete confirm when the subtask preview could not be read. */ + deleteSubtaskNoteUncertain(): string; + /** Toast description after deleting a task that had linked subagent subtasks. */ + deletedSubtaskNote(count: number): string; }; skillActions: { refreshSkillsFailedTitle: string; @@ -892,6 +898,9 @@ const SHELL_COPY_BY_LOCALE = { cancelLabel: '取消', deletedTitle: (name: string) => `已删除 ${name}`, deleteRestoredTitle: (name: string) => `${name} 已被恢复,未删除`, + deleteSubtaskNote: () => '其链接的子任务不会被删除,将保留并移入归档。', + deleteSubtaskNoteUncertain: () => '其链接的子任务(如有)不会被删除,将保留并移入归档。', + deletedSubtaskNote: (count: number) => `${count} 个子任务已移入归档`, }, skillActions: { refreshSkillsFailedTitle: '刷新技能失败', @@ -1417,6 +1426,11 @@ const SHELL_COPY_BY_LOCALE = { cancelLabel: 'Cancel', deletedTitle: (name: string) => `Deleted ${name}`, deleteRestoredTitle: (name: string) => `${name} was restored, so it was kept`, + deleteSubtaskNote: () => 'Its linked subtasks will be kept and moved to Archived.', + deleteSubtaskNoteUncertain: () => + 'Its linked subtasks, if any, will be kept and moved to Archived.', + deletedSubtaskNote: (count: number) => + count === 1 ? '1 subtask moved to Archived' : `${count} subtasks moved to Archived`, }, skillActions: { refreshSkillsFailedTitle: 'Could not refresh Skills', diff --git a/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts b/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts index de49032287..241c0cd656 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-session-navigation-services.ts @@ -39,6 +39,7 @@ export function createDesktopSessionNavigationServices( bridge.sessions.rename(sessionId, name, options), remove: (sessionId, options) => bridge.sessions.remove(sessionId, options), + previewRemoval: (sessionId) => bridge.sessions.previewRemoval(sessionId), }, }; } diff --git a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx index 32fe6a9e70..6bf387adf3 100644 --- a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx @@ -124,7 +124,7 @@ export function TasksSettingsPage(props: ArchivedTasksBridge) { title: isSearching ? copy.purgeMatchesConfirmTitle(ids.length) : copy.purgeAllConfirmTitle(ids.length), - description: copy.purgeConfirmBody, + description: `${copy.purgeConfirmBody} ${copy.purgeSubtaskNote}`, confirmLabel: copy.purgeConfirmAction, cancelLabel: getSettingsSharedCopy(locale).cancel, destructive: true, @@ -139,6 +139,14 @@ export function TasksSettingsPage(props: ArchivedTasksBridge) { // dropping the other is how a count quietly stops adding up. const kept = outcome.restored.length > 0 ? copy.purgeKeptRestored(outcome.restored.length) : undefined; + // A bulk purge of parents archives their linked subtasks; say how many so + // the archived rows that appear next are not a surprise. + const moved = + outcome.archivedSubtasks > 0 ? copy.purgedSubtaskNote(outcome.archivedSubtasks) : undefined; + const detail = (...parts: Array) => { + const text = parts.filter(Boolean).join(' '); + return text.length > 0 ? text : undefined; + }; if (!outcome.verified || outcome.remaining.length > 0) { // A reason beats a count: a task refuses to retire while its turn is // still running, and "N still there" gives the reader nothing to do. @@ -149,14 +157,14 @@ export function TasksSettingsPage(props: ArchivedTasksBridge) { : copy.purgeFailedBody(outcome.remaining.length); toast.error( copy.purgeFailedTitle, - kept ? `${reason} ${kept}` : reason, + detail(reason, moved, kept), undefined, outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, ); } else { - toast.success(copy.purgedToast(outcome.removed), kept); + toast.success(copy.purgedToast(outcome.removed), detail(moved, kept)); } } finally { if (mountedRef.current) setPurging(false); diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index ae7910e74b..b45abad9f4 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -1306,6 +1306,7 @@ function useArchivedTasksStoryBridge(seed: readonly SessionSummary[]): ArchivedT drop(sessionIds); return { removed: sessionIds.length, + archivedSubtasks: 0, remaining: [], restored: [], verified: true, diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index 4edfcf2188..a75df6913c 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -199,6 +199,14 @@ describe('Host Session retirement coordinator', () => { } const target = await harness.store.readHeaderRecordSnapshot(harness.revisionId); + // The read-only preview reports the same deduped count the confirm warns + // off, before the delete executes. + const preview = await harness.coordinator.handlers['session.remove.preview']( + { sessionId: harness.revisionId }, + CONNECTION_CONTEXT, + ); + assert.deepEqual(preview, { ok: true, result: { archivableSubtaskCount: 32 } }); + const removed = await harness.coordinator.handlers['session.remove']( { sessionId: harness.revisionId, expectedRevision: target.revision }, CONNECTION_CONTEXT, @@ -206,7 +214,9 @@ describe('Host Session retirement coordinator', () => { assert.deepEqual(removed, { ok: true, - result: { kind: 'removed', sessionId: harness.revisionId }, + // Each of the 32 subagent children is a distinct subtask family, so the + // executed count the renderer reports is 32. + result: { kind: 'removed', sessionId: harness.revisionId, archivedSubtaskCount: 32 }, }); for (const sessionId of harness.familyIds) { assert.deepEqual(await harness.store.probeSessionRemoval(sessionId), { kind: 'removed' }); @@ -394,6 +404,13 @@ describe('Host Session retirement coordinator', () => { } const target = await harness.store.readHeaderRecordSnapshot(harness.revisionId); + // Graph operators retire with the root rather than archive, so the delete + // preview promises nothing — the renderer must not warn about them. + const preview = await harness.coordinator.handlers['session.remove.preview']( + { sessionId: harness.revisionId }, + CONNECTION_CONTEXT, + ); + assert.deepEqual(preview, { ok: true, result: { archivableSubtaskCount: 0 } }); const removed = await harness.coordinator.handlers['session.remove']( { sessionId: harness.revisionId, expectedRevision: target.revision }, CONNECTION_CONTEXT, diff --git a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts index ac131e064a..49dabb00bb 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts @@ -108,6 +108,69 @@ describe('Session retirement protocol', () => { }, ); }); + + test('carries the archived-subtask count on a removed result and rejects a malformed one', () => { + const withCount = { + requestId: 'request-remove', + operation: 'session.remove' as const, + ok: true as const, + result: { kind: 'removed' as const, sessionId: 'session-1', archivedSubtaskCount: 3 }, + }; + assert.deepEqual(decodeHostFrame(withCount), withCount); + // Absent when nothing was archived — the common delete keeps its old shape. + const withoutCount = { + requestId: 'request-remove', + operation: 'session.remove' as const, + ok: true as const, + result: { kind: 'removed' as const, sessionId: 'session-1' }, + }; + assert.deepEqual(decodeHostFrame(withoutCount), withoutCount); + assert.throws( + () => + decodeHostFrame({ + requestId: 'request-remove', + operation: 'session.remove', + ok: true, + result: { kind: 'removed', sessionId: 'session-1', archivedSubtaskCount: -1 }, + }), + isInvalidFrame, + ); + }); + + test('round-trips the removal preview query and rejects a malformed count', () => { + const request = { + requestId: 'request-preview', + operation: 'session.remove.preview' as const, + input: { sessionId: 'session-1' }, + }; + assert.deepEqual(decodeClientFrame(request), request); + const response = { + requestId: 'request-preview', + operation: 'session.remove.preview' as const, + ok: true as const, + result: { archivableSubtaskCount: 4 }, + }; + assert.deepEqual(decodeHostFrame(response), response); + assert.throws( + () => + decodeHostFrame({ + requestId: 'request-preview', + operation: 'session.remove.preview', + ok: true, + result: { archivableSubtaskCount: -1 }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-preview', + operation: 'session.remove.preview', + input: { sessionId: 'session-1', expectedRevision: 2 }, + }), + isInvalidFrame, + ); + }); }); function projection(overrides: Partial = {}): SessionCatalogProjection { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 30f01b2cb7..94341acf87 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 50 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const; +// 51: Session removal reports how many linked subtasks it archived, and adds a +// `session.remove.preview` query for that count before the delete. Older peers +// reject the extra removed-result field and the unknown operation. // 50: WorkHub can append durable coordination summaries and admit tool-free // answers through its reserved Coordination Session authority. // 49: WorkHub resolves one durable Coordination Session per Runtime Host. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 9d95dec0bf..1ac10fa30f 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -301,6 +301,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'session.read_marker.set', 'session.recap.generate', 'session.remove', + 'session.remove.preview', 'session.revision.abandon', 'session.revision.create', 'session.transcript.page', diff --git a/packages/runtime-host/src/protocol/session-retirement.ts b/packages/runtime-host/src/protocol/session-retirement.ts index 5ffb9b6105..0f601e9fe5 100644 --- a/packages/runtime-host/src/protocol/session-retirement.ts +++ b/packages/runtime-host/src/protocol/session-retirement.ts @@ -18,7 +18,13 @@ */ import { decodeSessionCatalogItem, type SessionCatalogItem } from './session-catalog.js'; -import { requireEntityId, requireExactRecord, requireRecord } from './codec.js'; +import { + requireCount, + requireEntityId, + requireExactRecord, + requireRecord, + requireShapedRecord, +} from './codec.js'; import { invalidProtocolFrame } from './errors.js'; import { defineOperation } from './operation-spec.js'; @@ -46,8 +52,32 @@ export interface SessionRemoveInput { readonly expectedRevision: number; } +export interface SessionRemovePreviewInput { + readonly sessionId: string; +} + +export interface SessionRemovePreviewResult { + /** + * How many ordinary linked subagent subtasks a delete of this parent would + * move to the archive rather than destroy, deduplicated by revision family. + * The Host owns the removal plan, so the confirm warns off this rather than + * re-deriving it from a catalog projection that lacks the operator marker. + */ + readonly archivableSubtaskCount: number; +} + export type SessionRemoveResult = - | { readonly kind: 'removed'; readonly sessionId: string } + | { + readonly kind: 'removed'; + readonly sessionId: string; + /** + * How many ordinary linked subagent subtasks this removal moved to the + * archive rather than destroyed, deduplicated by revision family. Absent + * when it archived none — the common case. This is the Host's executed + * count, so the renderer reports it verbatim instead of estimating. + */ + readonly archivedSubtaskCount?: number; + } | { readonly kind: 'revision_conflict'; readonly expectedRevision: number; @@ -98,6 +128,17 @@ export const SESSION_RETIREMENT_OPERATION_SPECS = { } }, }), + 'session.remove.preview': defineOperation< + SessionRemovePreviewInput, + SessionRemovePreviewResult, + (typeof LIFECYCLE_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: LIFECYCLE_ERRORS, + decodeInput: decodeSessionRemovePreviewInput, + decodeOutput: decodeSessionRemovePreviewResult, + }), } as const; export function decodeSessionLifecycleSetInput(value: unknown): SessionLifecycleSetInput { @@ -122,11 +163,38 @@ export function decodeSessionRemoveInput(value: unknown): SessionRemoveInput { }; } +export function decodeSessionRemovePreviewInput(value: unknown): SessionRemovePreviewInput { + const input = requireExactRecord(value, 'Session remove preview input', ['sessionId']); + return { sessionId: requireEntityId(input.sessionId, 'sessionId') }; +} + +export function decodeSessionRemovePreviewResult(value: unknown): SessionRemovePreviewResult { + const result = requireExactRecord(value, 'Session remove preview result', [ + 'archivableSubtaskCount', + ]); + return { + archivableSubtaskCount: requireCount(result.archivableSubtaskCount, 'archivableSubtaskCount'), + }; +} + export function decodeSessionRemoveResult(value: unknown): SessionRemoveResult { const result = requireRecord(value, 'Session remove result'); if (result.kind === 'removed') { - const exact = requireExactRecord(result, 'Removed Session result', ['kind', 'sessionId']); - return { kind: 'removed', sessionId: requireEntityId(exact.sessionId, 'sessionId') }; + const exact = requireShapedRecord( + result, + 'Removed Session result', + ['kind', 'sessionId'], + ['archivedSubtaskCount'], + ); + return { + kind: 'removed', + sessionId: requireEntityId(exact.sessionId, 'sessionId'), + ...(exact.archivedSubtaskCount === undefined + ? {} + : { + archivedSubtaskCount: requireCount(exact.archivedSubtaskCount, 'archivedSubtaskCount'), + }), + }; } if (result.kind !== 'revision_conflict') { throw invalidProtocolFrame('Invalid Session remove result kind'); diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 208befbb28..b249aff0a1 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -112,7 +112,7 @@ export type SessionRevisionOperationKey = Extract< >; export type SessionRetirementOperationKey = Extract< OperationKey, - 'session.lifecycle.set' | 'session.remove' + 'session.lifecycle.set' | 'session.remove' | 'session.remove.preview' >; export type SessionEffectOperationKey = Extract; export type SessionCatalogOperationKey = Exclude< diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index 8d9e713a78..aa3d13b5fb 100644 --- a/packages/runtime-host/src/server/session-retirement-coordinator.ts +++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts @@ -37,6 +37,7 @@ import { type SessionCatalogItem, type SessionLifecycleSetInput, type SessionRemoveInput, + type SessionRemovePreviewInput, type SessionRemoveResult, } from '../protocol/index.js'; import { @@ -173,6 +174,7 @@ export class HostSessionRetirementCoordinator { readonly handlers: SessionRetirementOperationHandlerMap = { 'session.lifecycle.set': (input) => this.#setLifecycle(input), 'session.remove': (input) => this.#remove(input), + 'session.remove.preview': (input) => this.#previewRemoval(input), }; readonly #stores: RetirementStores; @@ -354,7 +356,15 @@ export class HostSessionRetirementCoordinator { this.#messages.retireSessions(allSessionIds); await this.#continuity.retireSessions(plan.remove.sessionIds, plan.remove.admission); await this.#refreshFamily(plan.archive); - return removeSuccess(input.sessionId); + // What the confirm warned about, now executed: the distinct subtasks + // (deduplicated by revision family) this removal moved to the archive. + // The renderer reports this verbatim rather than re-deriving the plan. + const archivedSubtaskCount = new Set( + plan.archive.sessionIds.map((id) => + sessionRevisionFamilyId(requireFamilyRecord(plan.archive, id).header), + ), + ).size; + return removeSuccess(input.sessionId, archivedSubtaskCount); } catch (error) { if (committed) return this.#uncertainRemove(); archiveHandles?.goal.rollback(); @@ -369,11 +379,50 @@ export class HostSessionRetirementCoordinator { } } + /** + * Read-only preview of how many subtasks a delete of this parent would move + * to the archive — the confirm warns off this so the renderer never has to + * re-derive the plan from a catalog projection that lacks the operator marker + * and the copy state. Absent or already-removed targets, and Agent Graph + * operators (which retire with their root rather than archive), preview zero. + */ + async #previewRemoval( + input: SessionRemovePreviewInput, + ): Promise> { + let probe; + try { + probe = await this.#stores.probeSessionRemoval(input.sessionId); + } catch { + return previewFailure('persistence_failed', 'Session removal state is unavailable'); + } + if (probe.kind !== 'present') return previewSuccess(0); + try { + const plan = await this.#readRemovalPlanSessionIds(input.sessionId); + return previewSuccess(plan.archivableSubtaskCount); + } catch (error) { + // A graph operator has no independent delete and archives nothing; a + // target that vanished mid-read has nothing left to archive either. + if ( + error instanceof SessionMetadataConflictError || + error instanceof SessionRetirementMissingSessionError + ) { + return previewSuccess(0); + } + return previewFailure('persistence_failed', 'Session removal plan is unavailable'); + } + } + async #withStableRemovalPlan( sessionId: string, operation: (plan: StableRemovalPlan) => Promise, ): Promise { - let planIds = await this.#readRemovalPlanSessionIds(sessionId); + // Only the id sets stabilize here; the archivable-subtask count is a + // preview-only read, so it is intentionally not threaded through the retry. + let planIds: { + removeSessionIds: readonly string[]; + archiveSessionIds: readonly string[]; + archiveGuardSessionIds: readonly string[]; + } = await this.#readRemovalPlanSessionIds(sessionId); for (let attempt = 0; attempt < FAMILY_STABILIZATION_ATTEMPTS; attempt += 1) { const allSessionIds = [ ...planIds.removeSessionIds, @@ -501,6 +550,7 @@ export class HostSessionRetirementCoordinator { removeSessionIds: readonly string[]; archiveSessionIds: readonly string[]; archiveGuardSessionIds: readonly string[]; + archivableSubtaskCount: number; }> { const removeSessionIds = await this.#readFamilySessionIds(sessionId); const removeIds = new Set(removeSessionIds); @@ -527,9 +577,8 @@ export class HostSessionRetirementCoordinator { !removeIds.has(header.id) && childFamilyIds.has(sessionRevisionFamilyId(header)), ); - const archiveSessionIds = childSessionHeaders - .filter((header) => !header.isArchived) - .map((header) => header.id); + const archiveHeaders = childSessionHeaders.filter((header) => !header.isArchived); + const archiveSessionIds = archiveHeaders.map((header) => header.id); const archiveGuardSessionIds = childSessionHeaders .filter((header) => header.isArchived) .map((header) => header.id); @@ -537,6 +586,10 @@ export class HostSessionRetirementCoordinator { removeSessionIds: [...removeIds].sort(), archiveSessionIds: [...new Set(archiveSessionIds)].sort(), archiveGuardSessionIds: [...new Set(archiveGuardSessionIds)].sort(), + // Distinct subtasks (by revision family) that a delete would move to the + // archive — the count the confirm warns off, matching what `#remove` + // reports afterwards. + archivableSubtaskCount: new Set(archiveHeaders.map(sessionRevisionFamilyId)).size, }; } @@ -829,8 +882,15 @@ function lifecycleFailure( return { ok: false, error: { code, message } }; } -function removeSuccess(sessionId: string): OperationOutcome<'session.remove'> { - return removeOutcome({ kind: 'removed', sessionId }); +function removeSuccess( + sessionId: string, + archivedSubtaskCount = 0, +): OperationOutcome<'session.remove'> { + return removeOutcome( + archivedSubtaskCount > 0 + ? { kind: 'removed', sessionId, archivedSubtaskCount } + : { kind: 'removed', sessionId }, + ); } function removeOutcome(result: SessionRemoveResult): OperationOutcome<'session.remove'> { @@ -843,3 +903,16 @@ function removeFailure( ): Extract, { ok: false }> { return { ok: false, error: { code, message } }; } + +function previewSuccess( + archivableSubtaskCount: number, +): OperationOutcome<'session.remove.preview'> { + return { ok: true, result: { archivableSubtaskCount } }; +} + +function previewFailure( + code: Extract, { ok: false }>['error']['code'], + message: string, +): Extract, { ok: false }> { + return { ok: false, error: { code, message } }; +}