From 311b8cf88040f3a00c3bf39fd0530a5626b6f5c8 Mon Sep 17 00:00:00 2001 From: liugddx Date: Thu, 1 Oct 2026 01:18:33 +0800 Subject: [PATCH 1/3] feat(desktop): clean up archived tasks by age and project Settings > Archived tasks could only search by name and delete the whole matched set, and its confirm stated a count and fixed copy about subtasks without asking the Host what the delete would take with it. Add "archived more than 7/30/90 days" and project filters ("All projects", each project with archived tasks, "No project"), combined with the search box. The age filter reads archivedAt only: tasks with an unknown archive time are left out while it is on, and the page says how many. The bulk delete always acts on exactly the rows on screen and reads "Delete N shown" whenever anything narrows the list; unnarrowed it keeps "Clear all". The confirm opens at once with the count and fills in a Host preview: child tasks deleted with them (Agent Graph operators), subagent worktrees retired, linked subtasks kept and archived, and an estimate of the bytes stored, measured with M1's per-Session footprint reader over every Session the removals delete. Delete waits for the preview; if it fails the dialog still names the count and can be cancelled or confirmed. The ids it deletes are the ones it previewed, frozen at the click. Execution is unchanged: one session.remove per task with requireArchived, so a task restored meanwhile is kept. session.remove.preview now takes a list of 1-25 unique sessionIds (the storage.usage.sessions.query cap) and unions the same removal plans session.remove executes, adding removedSubtaskCount, worktreeCount and bytes. The preload pages a selection per Host into bounded requests and rejects rather than report a partial sum. Protocol epoch 201 -> 202; there is no compatible-change declaration, so an older Host fails admission. The filter state and the confirm live in a new archived-task-cleanup feature slice; the legacy Settings page loses its own search and purge state (two useState and useMountedRef) and renders the rows it is handed. Refs #5825 Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/renderer-architecture.json | 6 +- .../archived-task-scope-surface.test.ts | 265 ++++++++++++++ .../__tests__/archived-task-scope.test.ts | 346 ++++++++++++++++++ .../__tests__/runtime-host-client-uds.test.ts | 20 + .../__tests__/session-removal-preview.test.ts | 95 +++++ .../main/__tests__/task-catalog-rows.test.ts | 23 +- apps/desktop/src/main/runtime-host-client.ts | 14 +- .../runtime-host-session-catalog-ipc-main.ts | 10 +- apps/desktop/src/preload/bridge-contract.d.ts | 8 + apps/desktop/src/preload/preload.ts | 20 +- .../src/preload/session-removal-preview.ts | 84 +++++ .../composition/desktop-feature-services.tsx | 9 +- .../features/archived-task-cleanup/index.ts | 26 ++ .../model/archived-task-scope.ts | 187 ++++++++++ .../model/purge-confirmation.ts | 138 +++++++ .../features/archived-task-cleanup/ports.ts | 32 ++ .../services-context.tsx | 41 +++ .../features/archived-task-cleanup/testing.ts | 37 ++ .../ui/archived-task-scope-surface.tsx | 229 ++++++++++++ .../renderer/locales/settings-tasks-copy.ts | 89 ++++- .../create-archived-task-cleanup-services.ts | 33 ++ .../renderer/settings/task-catalog-rows.ts | 19 - .../renderer/settings/tasks-settings-page.tsx | 301 +++++++-------- docs/astryx-surface-file-inventory.md | 6 +- docs/astryx-surface-file-inventory.paths | 2 + .../session-retirement-coordinator.test.ts | 113 +++++- .../session-retirement-protocol.test.ts | 71 +++- packages/runtime-host/src/protocol/index.ts | 6 +- .../src/protocol/session-retirement.ts | 57 ++- .../src/server/execution-composition.ts | 1 + .../server/session-retirement-coordinator.ts | 119 ++++-- 31 files changed, 2112 insertions(+), 295 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/archived-task-scope-surface.test.ts create mode 100644 apps/desktop/src/main/__tests__/archived-task-scope.test.ts create mode 100644 apps/desktop/src/main/__tests__/session-removal-preview.test.ts create mode 100644 apps/desktop/src/preload/session-removal-preview.ts create mode 100644 apps/desktop/src/renderer/features/archived-task-cleanup/index.ts create mode 100644 apps/desktop/src/renderer/features/archived-task-cleanup/model/archived-task-scope.ts create mode 100644 apps/desktop/src/renderer/features/archived-task-cleanup/model/purge-confirmation.ts create mode 100644 apps/desktop/src/renderer/features/archived-task-cleanup/ports.ts create mode 100644 apps/desktop/src/renderer/features/archived-task-cleanup/services-context.tsx create mode 100644 apps/desktop/src/renderer/features/archived-task-cleanup/testing.ts create mode 100644 apps/desktop/src/renderer/features/archived-task-cleanup/ui/archived-task-scope-surface.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-archived-task-cleanup-services.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index f6582ab142..91cf93edde 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3122,8 +3122,6 @@ "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { - "useMountedRef": 1, - "useState": 2, "useToast": 1, "useUiLocale": 1 }, @@ -3131,16 +3129,14 @@ "unresolvedDependencies": 0, "actionFactories": [], "dependencyPaths": { + "../features/archived-task-cleanup/index.js": 1, "../features/storage-usage/index.js": 1, - "../locales/settings-shared-copy.js": 1, "../locales/settings-tasks-copy.js": 1, "../locales/storage-usage-copy.js": 1, "./settings-error-copy": 1, "./settings-section": 1, "./task-catalog-rows": 1, - "@astryxdesign/core": 1, "@astryxdesign/core/List": 1, - "@astryxdesign/core/TextInput": 1, "@maka/core/relative-time": 1, "@maka/runtime-host/profile-kind": 1, "@maka/ui": 1, diff --git a/apps/desktop/src/main/__tests__/archived-task-scope-surface.test.ts b/apps/desktop/src/main/__tests__/archived-task-scope-surface.test.ts new file mode 100644 index 0000000000..a0ae7e87cd --- /dev/null +++ b/apps/desktop/src/main/__tests__/archived-task-scope-surface.test.ts @@ -0,0 +1,265 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { deferred } from '@maka/core/test-only/async-primitives'; +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { SessionSummary } from '@maka/core/session'; +import type { SessionRemovePreviewResult } from '@maka/runtime-host/protocol'; +import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; +import { + ArchivedTaskCleanupServicesProvider, + ArchivedTaskScopeSurface, +} from '../../renderer/features/archived-task-cleanup/index.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + Event: globalThis.Event, + Node: globalThis.Node, + CSS: globalThis.CSS, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +const PREVIEW: SessionRemovePreviewResult = { + archivableSubtaskCount: 1, + removedSubtaskCount: 2, + worktreeCount: 1, + bytes: 2048, +}; + +test('deletes exactly the shown tasks the confirm previewed', async () => { + const answer = deferred(); + const harness = installSurface((ids) => { + harness.previewed.push([...ids]); + return answer.promise; + }); + await harness.render(); + assert.deepEqual(harness.shownIds(), ['alpha-old', 'alpha-new', 'loose']); + assert.ok(harness.findButton('Clear all'), 'nothing narrowed keeps the whole-list label'); + + await harness.search('alpha'); + assert.deepEqual(harness.shownIds(), ['alpha-old', 'alpha-new']); + await harness.click('Delete 2 shown'); + + assert.deepEqual(harness.previewed, [['alpha-old', 'alpha-new']]); + assert.match(harness.dialogText(), /Delete the 2 tasks shown\?/); + assert.match(harness.dialogText(), /Working out what else will be removed/); + // The action shows as busy until the preview arrives, and deletes nothing. + assert.equal(harness.dialogAction()?.disabled, true); + await act(async () => harness.dialogAction()?.click()); + assert.deepEqual(harness.purged, [], 'no delete before the preview arrives'); + + // The list widening under the open dialog does not widen the delete. + await harness.search(''); + assert.deepEqual(harness.shownIds(), ['alpha-old', 'alpha-new', 'loose']); + await act(async () => answer.resolve(PREVIEW)); + assert.equal(harness.dialogAction()?.disabled, false); + assert.match(harness.dialogText(), /Also deleted: 2 child tasks and 1 subagent worktree\./); + assert.match(harness.dialogText(), /About 2\.0 KB of task data \(an estimate\)\./); + assert.match(harness.dialogText(), /1 ordinary subtask is kept and moved to Archived\./); + + await harness.click('Delete permanently'); + assert.deepEqual(harness.purged, [['alpha-old', 'alpha-new']]); + assert.equal(harness.dialogText(), '', 'the confirm closes once the delete starts'); +}); + +test('a failed preview still names the count and lets the reader cancel or delete', async () => { + const harness = installSurface(async () => { + throw new Error('Runtime Host unavailable'); + }); + await harness.render(); + await harness.click('Clear all'); + assert.match(harness.dialogText(), /Clear all 3 archived tasks\?/); + assert.match(harness.dialogText(), /Could not work out what else will be removed\./); + assert.doesNotMatch(harness.dialogText(), /child task|of task data/); + + await harness.click('Cancel'); + assert.equal(harness.dialogText(), ''); + assert.deepEqual(harness.purged, []); + + await harness.click('Clear all'); + await harness.click('Delete permanently'); + assert.deepEqual(harness.purged, [['alpha-old', 'alpha-new', 'loose']]); +}); + +function row(id: string, overrides: Partial): SessionSummary { + return { + id, + name: id, + isFlagged: false, + isArchived: true, + labels: [], + hasUnread: false, + status: 'active', + backend: 'fake', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test', + permissionMode: 'ask', + ...overrides, + }; +} + +function installSurface( + previewRemovals: (sessionIds: readonly string[]) => Promise, +) { + const { document, window } = parseHTML('
'); + const matchMedia = (media: string) => ({ + matches: false, + media, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => false, + }); + Object.assign(window, { matchMedia, scrollTo() {} }); + Object.assign(window.HTMLElement.prototype, { + showModal(this: HTMLElement) { + this.setAttribute('open', ''); + }, + close(this: HTMLElement) { + this.removeAttribute('open'); + }, + }); + Object.assign(globalThis, { + document, + window, + matchMedia, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, + Node: window.Node, + CSS: { escape: (value: string) => value }, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0), + cancelAnimationFrame: (handle: number) => clearTimeout(handle), + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + const rows = [ + row('alpha-old', { projectId: 'alpha', archivedAt: Date.now() - 40 * 86_400_000 }), + row('alpha-new', { projectId: 'alpha', archivedAt: Date.now() - 86_400_000 }), + row('loose', {}), + ]; + const projectOf = (session: SessionSummary) => + session.projectId ? { key: session.projectId, label: 'Alpha project' } : null; + const previewed: string[][] = []; + const purged: string[][] = []; + const findButton = (label: string) => + [...document.querySelectorAll('button')].find( + (candidate) => candidate.textContent?.trim() === label, + ) as HTMLButtonElement | undefined; + return { + previewed, + purged, + findButton, + async render() { + await act(async () => { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ArchivedTaskCleanupServicesProvider, { + services: { previewRemovals }, + children: createElement(ArchivedTaskScopeSurface, { + rows, + projectOf, + onPurge: async (ids) => { + purged.push([...ids]); + }, + children: ({ visible }) => + createElement( + 'ul', + { 'data-testid': 'shown' }, + visible.map((session) => + createElement('li', { key: session.id, 'data-id': session.id }), + ), + ), + }), + }), + }), + }), + ); + await Promise.resolve(); + }); + }, + shownIds() { + return [...document.querySelectorAll('[data-testid="shown"] li')].map((item) => + item.getAttribute('data-id'), + ); + }, + dialogAction() { + return [...document.querySelectorAll('[role="alertdialog"] button')].find((button) => + button.textContent?.includes('Delete permanently'), + ) as HTMLButtonElement | undefined; + }, + dialogText() { + return [...document.querySelectorAll('[role="alertdialog"]')] + .map((dialog) => dialog.textContent ?? '') + .join(''); + }, + async search(value: string) { + const input = document.querySelector('input[placeholder="Search archived tasks"]') as + | HTMLInputElement + | null; + assert.ok(input, 'missing search box'); + await act(async () => { + input.value = value; + const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); + assert.ok(propsKey, 'missing React props on input'); + const props = (input as unknown as Record)[propsKey] as { + onChange?: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void; + }; + assert.ok(props.onChange, 'missing React change handler'); + props.onChange({ target: input, defaultPrevented: false }); + await Promise.resolve(); + }); + }, + async click(label: string) { + const button = findButton(label); + assert.ok(button, `missing button: ${label}`); + await act(async () => { + button.click(); + await Promise.resolve(); + }); + }, + }; +} diff --git a/apps/desktop/src/main/__tests__/archived-task-scope.test.ts b/apps/desktop/src/main/__tests__/archived-task-scope.test.ts new file mode 100644 index 0000000000..7e76eff226 --- /dev/null +++ b/apps/desktop/src/main/__tests__/archived-task-scope.test.ts @@ -0,0 +1,346 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { type Deferred, deferred } from '@maka/core/test-only/async-primitives'; +import { describe, it } from 'node:test'; +import type { SessionSummary } from '@maka/core/session'; +import type { SessionRemovePreviewResult } from '@maka/runtime-host/protocol'; +import { getSettingsTasksCopy } from '../../renderer/locales/settings-tasks-copy.js'; +import { + archivedProjectOptions, + availableProjectFilter, + createPurgeConfirmationController, + describePurgeConfirmation, + isArchivedTaskScopeNarrowed, + type ArchivedTaskScope, + type PurgeConfirmation, + scopeArchivedTasks, + UNSCOPED_ARCHIVED_TASKS, +} from '../../renderer/features/archived-task-cleanup/testing.js'; + +const DAY = 24 * 60 * 60 * 1000; +const NOW = 1_000 * DAY; + +function task(id: string, overrides: Partial = {}): SessionSummary { + return { + id, + name: id, + isFlagged: false, + isArchived: true, + labels: [], + hasUnread: false, + status: 'active', + backend: 'fake', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test', + permissionMode: 'ask', + ...overrides, + }; +} + +const PROJECTS: Record = { + alpha: { key: 'project:alpha', label: 'Alpha' }, + beta: { key: 'project:beta', label: 'Beta' }, +}; + +/** `gone` stands for a project id this page cannot name. */ +function projectOf(session: SessionSummary) { + if (!session.projectId) return null; + return PROJECTS[session.projectId]; +} + +function visibleIds(rows: readonly SessionSummary[], scope: ArchivedTaskScope) { + return scopeArchivedTasks(rows, scope, { now: NOW, projectOf, noProjectLabel: 'No project' }) + .visible.map((session) => session.id); +} + +describe('scopeArchivedTasks', () => { + it('keeps only tasks archived strictly more than N days ago', () => { + const rows = [ + task('exactly-7', { archivedAt: NOW - 7 * DAY }), + task('just-over-7', { archivedAt: NOW - 7 * DAY - 1 }), + task('exactly-30', { archivedAt: NOW - 30 * DAY }), + task('just-over-30', { archivedAt: NOW - 30 * DAY - 1 }), + task('just-over-90', { archivedAt: NOW - 90 * DAY - 1 }), + task('archived-in-the-future', { archivedAt: NOW + DAY }), + ]; + assert.deepEqual(visibleIds(rows, { ...UNSCOPED_ARCHIVED_TASKS, minAgeDays: 7 }), [ + 'just-over-7', + 'exactly-30', + 'just-over-30', + 'just-over-90', + ]); + assert.deepEqual(visibleIds(rows, { ...UNSCOPED_ARCHIVED_TASKS, minAgeDays: 30 }), [ + 'just-over-30', + 'just-over-90', + ]); + assert.deepEqual(visibleIds(rows, { ...UNSCOPED_ARCHIVED_TASKS, minAgeDays: 90 }), [ + 'just-over-90', + ]); + }); + + it('leaves out, and counts, unknown archive times only under an age filter', () => { + const rows = [ + task('known-old', { archivedAt: NOW - 100 * DAY }), + // Old by every other clock; still no archive time to judge by. + task('legacy', { lastMessageAt: NOW - 400 * DAY }), + task('legacy-elsewhere', { projectId: 'beta' }), + ]; + const context = { now: NOW, projectOf, noProjectLabel: 'No project' }; + const aged = scopeArchivedTasks(rows, { ...UNSCOPED_ARCHIVED_TASKS, minAgeDays: 7 }, context); + assert.deepEqual( + aged.visible.map((session) => session.id), + ['known-old'], + ); + assert.equal(aged.unknownArchiveTime, 2); + // Only the rows the other filters kept are counted as left out for age. + const agedLoose = scopeArchivedTasks( + rows, + { ...UNSCOPED_ARCHIVED_TASKS, minAgeDays: 7, project: { kind: 'none' } }, + context, + ); + assert.equal(agedLoose.unknownArchiveTime, 1); + const anyTime = scopeArchivedTasks(rows, UNSCOPED_ARCHIVED_TASKS, context); + assert.deepEqual( + anyTime.visible.map((session) => session.id), + ['known-old', 'legacy', 'legacy-elsewhere'], + ); + assert.equal(anyTime.unknownArchiveTime, 0); + }); + + it('filters by project, by "No project", and never claims an unnamed project', () => { + const rows = [ + task('a1', { projectId: 'alpha' }), + task('loose'), + task('b1', { projectId: 'beta' }), + task('unnamed', { projectId: 'gone' }), + task('a2', { projectId: 'alpha' }), + ]; + assert.deepEqual( + visibleIds(rows, { + ...UNSCOPED_ARCHIVED_TASKS, + project: { kind: 'project', key: 'project:alpha' }, + }), + ['a1', 'a2'], + ); + assert.deepEqual(visibleIds(rows, { ...UNSCOPED_ARCHIVED_TASKS, project: { kind: 'none' } }), [ + 'loose', + ]); + assert.deepEqual(visibleIds(rows, UNSCOPED_ARCHIVED_TASKS), [ + 'a1', + 'loose', + 'b1', + 'unnamed', + 'a2', + ]); + }); + + it('combines search with the filters, searching the label the row shows', () => { + const rows = [ + task('Fix rail', { projectId: 'alpha', archivedAt: NOW - 40 * DAY }), + task('Fix rail again', { projectId: 'alpha', archivedAt: NOW - DAY }), + task('Fix build', { projectId: 'beta', archivedAt: NOW - 40 * DAY }), + task('Loose notes', { archivedAt: NOW - 40 * DAY }), + ]; + assert.deepEqual( + visibleIds(rows, { + query: ' FIX ', + minAgeDays: 30, + project: { kind: 'project', key: 'project:alpha' }, + }), + ['Fix rail'], + ); + // The project label is on screen, so it answers to the box too. + assert.deepEqual(visibleIds(rows, { ...UNSCOPED_ARCHIVED_TASKS, query: 'beta' }), [ + 'Fix build', + ]); + assert.deepEqual(visibleIds(rows, { ...UNSCOPED_ARCHIVED_TASKS, query: 'no project' }), [ + 'Loose notes', + ]); + }); + + it('says a scope is narrowed by any of search, age or project', () => { + assert.equal(isArchivedTaskScopeNarrowed(UNSCOPED_ARCHIVED_TASKS), false); + assert.equal(isArchivedTaskScopeNarrowed({ ...UNSCOPED_ARCHIVED_TASKS, query: ' ' }), false); + assert.equal(isArchivedTaskScopeNarrowed({ ...UNSCOPED_ARCHIVED_TASKS, query: 'x' }), true); + assert.equal(isArchivedTaskScopeNarrowed({ ...UNSCOPED_ARCHIVED_TASKS, minAgeDays: 90 }), true); + assert.equal( + isArchivedTaskScopeNarrowed({ ...UNSCOPED_ARCHIVED_TASKS, project: { kind: 'none' } }), + true, + ); + }); +}); + +describe('archivedProjectOptions', () => { + it('offers each named project once, by label, and "No project" only when used', () => { + const rows = [ + task('b', { projectId: 'beta' }), + task('a', { projectId: 'alpha' }), + task('b2', { projectId: 'beta' }), + task('unnamed', { projectId: 'gone' }), + ]; + assert.deepEqual(archivedProjectOptions(rows, projectOf), { + projects: [PROJECTS.alpha, PROJECTS.beta], + hasNoProject: false, + }); + assert.equal(archivedProjectOptions([...rows, task('loose')], projectOf).hasNoProject, true); + }); + + it('falls back to all projects once a chosen project has no rows left', () => { + const options = archivedProjectOptions([task('a', { projectId: 'alpha' })], projectOf); + assert.deepEqual( + availableProjectFilter({ kind: 'project', key: 'project:alpha' }, options), + { kind: 'project', key: 'project:alpha' }, + ); + assert.deepEqual(availableProjectFilter({ kind: 'project', key: 'project:beta' }, options), { + kind: 'all', + }); + assert.deepEqual(availableProjectFilter({ kind: 'none' }, options), { kind: 'all' }); + }); +}); + +describe('purge confirmation', () => { + const preview: SessionRemovePreviewResult = { + archivableSubtaskCount: 1, + removedSubtaskCount: 2, + worktreeCount: 3, + bytes: 4096, + }; + + function harness() { + const requests: Array<{ + ids: readonly string[]; + answer: Deferred; + }> = []; + let state: PurgeConfirmation | undefined; + const controller = createPurgeConfirmationController({ + previewRemovals: (ids) => { + const answer = deferred(); + requests.push({ ids, answer }); + return answer.promise; + }, + onChange: (next) => { + state = next; + }, + }); + return { controller, requests, state: () => state }; + } + + it('deletes exactly the ids it previewed, and only once the preview settles', async () => { + const { controller, requests, state } = harness(); + const shown = ['a', 'b']; + controller.open(shown, true); + // The list changing underneath the dialog does not change what it deletes. + shown.push('c'); + assert.deepEqual(requests[0]?.ids, ['a', 'b']); + assert.equal(state()?.preview.kind, 'loading'); + assert.equal(controller.confirm(), undefined, 'no delete while the preview computes'); + assert.notEqual(state(), undefined); + + requests[0]?.answer.resolve(preview); + await Promise.resolve(); + assert.deepEqual(state()?.preview, { kind: 'ready', preview }); + assert.deepEqual(controller.confirm(), ['a', 'b']); + assert.equal(state(), undefined); + assert.equal(controller.confirm(), undefined, 'a closed dialog deletes nothing'); + }); + + it('keeps a failed preview usable: cancel, or delete the counted tasks', async () => { + const { controller, requests, state } = harness(); + controller.open(['a'], false); + requests[0]?.answer.reject(new Error('Host unavailable')); + await Promise.resolve(); + assert.deepEqual(state(), { sessionIds: ['a'], narrowed: false, preview: { kind: 'failed' } }); + controller.cancel(); + assert.equal(state(), undefined); + + controller.open(['a'], false); + requests[1]?.answer.reject(new Error('Host unavailable')); + await Promise.resolve(); + assert.deepEqual(controller.confirm(), ['a']); + }); + + it('ignores a preview that lands after its dialog closed or was replaced', async () => { + const { controller, requests, state } = harness(); + controller.open(['old'], false); + controller.cancel(); + requests[0]?.answer.resolve(preview); + await Promise.resolve(); + assert.equal(state(), undefined, 'a cancelled dialog does not reopen'); + + controller.open(['first'], false); + controller.open(['second'], true); + requests[1]?.answer.resolve(preview); + await Promise.resolve(); + assert.deepEqual(state(), { + sessionIds: ['second'], + narrowed: true, + preview: { kind: 'loading' }, + }); + }); + + it('reports failure at once without a Desktop preview service', () => { + let state: PurgeConfirmation | undefined; + const controller = createPurgeConfirmationController({ + onChange: (next) => { + state = next; + }, + }); + controller.open(['a'], false); + assert.equal(state?.preview.kind, 'failed'); + }); + + it('states the Host figures when known and only the certainties when not', () => { + const copy = getSettingsTasksCopy('en'); + const size = (bytes: number) => `${bytes} B`; + const ready = describePurgeConfirmation( + { sessionIds: ['a', 'b'], narrowed: true, preview: { kind: 'ready', preview } }, + copy, + size, + ); + assert.equal(ready.title, 'Delete the 2 tasks shown?'); + assert.match(ready.description, /2 child tasks and 3 subagent worktrees/); + assert.match(ready.description, /About 4096 B of task data \(an estimate\)/); + assert.match(ready.description, /1 ordinary subtask is kept/); + + const nothingKept = describePurgeConfirmation( + { + sessionIds: ['a'], + narrowed: false, + preview: { kind: 'ready', preview: { ...preview, archivableSubtaskCount: 0 } }, + }, + copy, + size, + ); + assert.equal(nothingKept.title, 'Clear the 1 archived task?'); + assert.doesNotMatch(nothingKept.description, /kept and moved/); + + const failed = describePurgeConfirmation( + { sessionIds: ['a', 'b', 'c'], narrowed: false, preview: { kind: 'failed' } }, + copy, + size, + ); + assert.equal(failed.title, 'Clear all 3 archived tasks?'); + assert.match(failed.description, /Could not work out what else will be removed/); + assert.match(failed.description, /Any ordinary subtasks are kept/); + assert.doesNotMatch(failed.description, /child task|B of task data/); + }); +}); 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 1a997be545..72c366f8c6 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 @@ -216,6 +216,18 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn }); return { ok: true, result: projected }; }, + 'session.remove.preview': async (input) => { + assert.deepEqual(input, { sessionIds: ['session-ipc'] }); + return { + ok: true, + result: { + archivableSubtaskCount: 1, + removedSubtaskCount: 2, + worktreeCount: 3, + bytes: 4096, + }, + }; + }, 'session.remove': async (input) => { assert.ok(projected); if (restoreUnderNextRemove) { @@ -325,6 +337,14 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn assert.equal(updatedSession.revision, 2); await ipc.invoke('sessions:archive', 'session-ipc'); assert.equal((await ipc.invoke('sessions:list') as Array<{ isArchived: boolean }>)[0]?.isArchived, true); + // The confirm's preview crosses the wire as one page of this Host's ids. + assert.deepEqual(await ipc.invoke('sessions:removePreview', ['session-ipc']), { + archivableSubtaskCount: 1, + removedSubtaskCount: 2, + worktreeCount: 3, + bytes: 4096, + }); + await assert.rejects(ipc.invoke('sessions:removePreview', 'session-ipc'), /Invalid session ids/); // 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; diff --git a/apps/desktop/src/main/__tests__/session-removal-preview.test.ts b/apps/desktop/src/main/__tests__/session-removal-preview.test.ts new file mode 100644 index 0000000000..95be8bafbd --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-removal-preview.test.ts @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { + SESSION_REMOVE_PREVIEW_MAX_ITEMS, + type SessionRemovePreviewResult, +} from '@maka/runtime-host/protocol'; +import { createSessionRemovalPreviewReader } from '../../preload/session-removal-preview.js'; + +/** Desktop ids read `:`; each page answers one of everything per task. */ +function reader(options: { failHost?: string; unreachable?: string } = {}) { + const queries: Array<{ host: string; ids: readonly string[] }> = []; + let inFlight = 0; + let maxInFlight = 0; + const preview = createSessionRemovalPreviewReader({ + resolve: async (sessionId) => { + const [host = '', id = ''] = sessionId.split(':'); + if (host === options.unreachable) { + throw new Error('The Runtime Host for this task is unavailable'); + } + return { scope: host, scopeKey: host, sessionId: id }; + }, + query: async (host, ids): Promise => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + queries.push({ host, ids: [...ids] }); + await new Promise((resolve) => setImmediate(resolve)); + inFlight -= 1; + if (host === options.failHost) throw new Error('persistence_failed'); + return { + archivableSubtaskCount: ids.length, + removedSubtaskCount: 2 * ids.length, + worktreeCount: 3 * ids.length, + bytes: 100 * ids.length, + }; + }, + }); + return { preview, queries, maxInFlight: () => maxInFlight }; +} + +test('pages a large selection per Host into bounded requests and sums them', async () => { + const { preview, queries, maxInFlight } = reader(); + const onA = Array.from( + { length: SESSION_REMOVE_PREVIEW_MAX_ITEMS * 2 + 3 }, + (_, index) => `a:session-${index}`, + ); + const onB = ['b:one', 'b:two']; + const total = onA.length + onB.length; + + assert.deepEqual(await preview([...onA, ...onB, onA[0]!]), { + archivableSubtaskCount: total, + removedSubtaskCount: 2 * total, + worktreeCount: 3 * total, + bytes: 100 * total, + }); + assert.deepEqual( + queries.map((query) => [query.host, query.ids.length]), + [ + ['a', SESSION_REMOVE_PREVIEW_MAX_ITEMS], + ['a', SESSION_REMOVE_PREVIEW_MAX_ITEMS], + ['a', 3], + ['b', 2], + ], + ); + // Host-local ids, each asked about once, one request at a time. + assert.deepEqual(queries[3]?.ids, ['one', 'two']); + assert.equal(new Set(queries.flatMap((query) => query.ids)).size, total); + assert.equal(maxInFlight(), 1); +}); + +test('rejects rather than report a partial preview', async () => { + await assert.rejects(reader({ failHost: 'b' }).preview(['a:one', 'b:two']), /persistence_failed/); + await assert.rejects( + reader({ unreachable: 'b' }).preview(['a:one', 'b:two']), + /Runtime Host for this task is unavailable/, + ); +}); diff --git a/apps/desktop/src/main/__tests__/task-catalog-rows.test.ts b/apps/desktop/src/main/__tests__/task-catalog-rows.test.ts index fc0033f4d6..40efbb6b9a 100644 --- a/apps/desktop/src/main/__tests__/task-catalog-rows.test.ts +++ b/apps/desktop/src/main/__tests__/task-catalog-rows.test.ts @@ -20,11 +20,8 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { SessionSummary } from '@maka/core/session'; -import { - archivedTaskRows, - isOrphanedSubagentTask, - matchesArchivedTaskQuery, -} from '../../renderer/settings/task-catalog-rows.js'; +import { matchesArchivedTaskQuery } from '../../renderer/features/archived-task-cleanup/testing.js'; +import { archivedTaskRows, isOrphanedSubagentTask } from '../../renderer/settings/task-catalog-rows.js'; function summary(id: string, overrides: Partial = {}): SessionSummary { return { @@ -156,19 +153,19 @@ describe('matchesArchivedTaskQuery', () => { it('keeps every task while the box is empty or only whitespace', () => { const task = summary('a', { name: 'rail sorting' }); - assert.equal(matchesArchivedTaskQuery(task, '', projectLabelOf), true); - assert.equal(matchesArchivedTaskQuery(task, ' ', projectLabelOf), true); + assert.equal(matchesArchivedTaskQuery(task, '', projectLabelOf(task)), true); + assert.equal(matchesArchivedTaskQuery(task, ' ', projectLabelOf(task)), true); }); it('matches the task name regardless of case or surrounding spaces', () => { const task = summary('a', { name: 'Fix rail sorting' }); - assert.equal(matchesArchivedTaskQuery(task, ' RAIL ', projectLabelOf), true); - assert.equal(matchesArchivedTaskQuery(task, 'compaction', projectLabelOf), false); + assert.equal(matchesArchivedTaskQuery(task, ' RAIL ', projectLabelOf(task)), true); + assert.equal(matchesArchivedTaskQuery(task, 'compaction', projectLabelOf(task)), false); }); it('matches the project name, because the row shows it too', () => { const task = summary('a', { name: 'Fix rail sorting', projectId: 'p1' }); - assert.equal(matchesArchivedTaskQuery(task, 'astryx', projectLabelOf), true); + assert.equal(matchesArchivedTaskQuery(task, 'astryx', projectLabelOf(task)), true); }); it('never matches across the seam between the name and the project', () => { @@ -176,13 +173,13 @@ describe('matchesArchivedTaskQuery', () => { // nothing at all on the row, which is the one answer a reader cannot // account for. const task = summary('a', { name: 'Fix rail sorting', projectId: 'p1' }); - assert.equal(matchesArchivedTaskQuery(task, 'sorting astryx', projectLabelOf), false); + assert.equal(matchesArchivedTaskQuery(task, 'sorting astryx', projectLabelOf(task)), false); }); it('falls back to the name when the project could not be resolved', () => { const task = summary('a', { name: 'Analyze everything', projectId: 'gone' }); - assert.equal(matchesArchivedTaskQuery(task, 'analyze', projectLabelOf), true); - assert.equal(matchesArchivedTaskQuery(task, 'undefined', projectLabelOf), false); + assert.equal(matchesArchivedTaskQuery(task, 'analyze', projectLabelOf(task)), true); + assert.equal(matchesArchivedTaskQuery(task, 'undefined', projectLabelOf(task)), false); }); }); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index c3e7507961..0641a4e207 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -133,6 +133,7 @@ import { type ExecutionBoundarySummary, type SessionLifecycleState, type SessionMetadataPatch, + type SessionRemovePreviewResult, type SessionUpdateResult, type SkillCatalogWorkspaceContext, type SkillCatalogInvocableItem, @@ -1194,14 +1195,13 @@ export class DesktopRuntimeHostClient { } /** - * 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. + * What deleting these Sessions, one `removeSession` each, would remove and + * archive, per the Host's own removal plans. A delete confirm states this so + * the renderer never re-derives a plan from a catalog projection that omits + * the operator marker and copy state. One bounded page; callers page. */ - async previewSessionRemoval(sessionId: string): Promise { - const result = await this.request("session.remove.preview", { sessionId }); - return result.archivableSubtaskCount; + previewSessionRemoval(sessionIds: readonly string[]): Promise { + return this.request("session.remove.preview", { sessionIds: [...sessionIds] }); } async removeSessionCopy(sessionId: string): Promise<'removed' | 'retained'> { 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 efbaa94948..2178f65264 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 @@ -262,9 +262,13 @@ export function registerRuntimeHostSessionCatalogIpc( 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); + ipcMain.handle('sessions:removePreview', async (_event, sessionIds: unknown) => { + // Read-only: what deleting these would remove and archive, for the confirm. + // One bounded page of this Host's ids; the protocol codec enforces the cap. + if (!Array.isArray(sessionIds) || !sessionIds.every((id) => typeof id === 'string')) { + throw new Error('Invalid session ids'); + } + return deps.client.previewSessionRemoval(sessionIds); }); ipcMain.handle( 'sessions:moveToProject', diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 79d014f812..111985aa34 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -156,6 +156,7 @@ import type { UsageSummaryV2 } from '@maka/core/usage-stats/types'; import type { UsageProvenance } from '@maka/core/usage-ledger-merge'; import type { ContextDiagnosticsResult, + SessionRemovePreviewResult, SessionStorageUsage, StorageUsageQueryResult, } from '@maka/runtime-host/protocol'; @@ -1426,6 +1427,13 @@ export interface MakaBridge { * of estimating from the catalog projection. */ previewRemoval(sessionId: string): Promise; + /** + * What deleting these tasks, one `remove` each, would take with them, per + * each Host's removal plans: linked subtasks archived, child tasks and + * worktrees deleted, and an estimate of the bytes stored. Paged per Host; + * rejects when any task's Host cannot answer rather than under-reporting. + */ + previewRemovals(sessionIds: readonly 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 441afb06c3..2599198033 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -257,11 +257,13 @@ import { type CollaborationTurnRequestQueryResult, type CollaborationTurnRequestWithdrawResult, type SessionTurnAccessRequest, + type SessionRemovePreviewResult, type SessionStorageUsage, type StorageUsageQueryResult, } from '@maka/runtime-host/protocol'; import type { PlanControlIpcResult } from '../shared/plan-mode-ipc.js'; import { createSessionStorageUsageReader } from './session-storage-usage.js'; +import { createSessionRemovalPreviewReader } from './session-removal-preview.js'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import { desktopSessionKey, @@ -1262,6 +1264,17 @@ const loadDesktopSessionStorageUsage = createSessionStorageUsageReader({ ).sessions, }); +const previewDesktopSessionRemoval = createSessionRemovalPreviewReader({ + resolve: async (sessionId) => { + const ref = await runtimeHostSessionRef(sessionId); + return { ...ref, scopeKey: runtimeHostScopeKey(ref.scope) }; + }, + query: (scope: DesktopTargetScope, hostIds) => + invokeWhenReady('sessions:removePreview', scope, hostIds) as Promise< + SessionRemovePreviewResult + >, +}); + async function listScheduledTasks(target?: DesktopRuntimeHostRef): Promise { const host = scopedRuntimeHost(await selectedRuntimeHostScope(target)); for (let attempt = 0; attempt < 3; attempt += 1) { @@ -2704,8 +2717,11 @@ const makaBridge = { } return invokeSessionRuntimeHost('sessions:remove', sessionId, options); }, - previewRemoval(sessionId: string): Promise { - return invokeSessionRuntimeHost('sessions:removePreview', sessionId); + async previewRemoval(sessionId: string): Promise { + return (await previewDesktopSessionRemoval([sessionId])).archivableSubtaskCount; + }, + previewRemovals(sessionIds: readonly string[]): Promise { + return previewDesktopSessionRemoval(sessionIds); }, cleanupSessionCopy(sessionId: string): Promise { return invokeSessionRuntimeHost('sessions:cleanupSessionCopy', sessionId); diff --git a/apps/desktop/src/preload/session-removal-preview.ts b/apps/desktop/src/preload/session-removal-preview.ts new file mode 100644 index 0000000000..6b118c8d45 --- /dev/null +++ b/apps/desktop/src/preload/session-removal-preview.ts @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + SESSION_REMOVE_PREVIEW_MAX_ITEMS, + type SessionRemovePreviewResult, +} from '@maka/runtime-host/protocol'; + +export interface SessionRemovalPreviewRouting { + /** Resolves a Desktop session id to its Host scope; rejects when the Host is gone. */ + resolve(sessionId: string): Promise<{ + readonly scope: Scope; + readonly scopeKey: string; + readonly sessionId: string; + }>; + /** One `session.remove.preview` page against one Host. */ + query(scope: Scope, sessionIds: readonly string[]): Promise; +} + +export type SessionRemovalPreviewReader = ( + sessionIds: readonly string[], +) => Promise; + +/** + * What deleting a set of tasks would remove, keyed by Desktop session id. + * Tasks are grouped by the Host that holds them and each Host is paged one + * bounded request at a time, so a large selection never becomes one unbounded + * Host query. Pages are summed; each Host deduplicates within its own pages, + * and the Desktop only ever asks about archived task rows, which do not share + * revision families. + * + * All or nothing: a task whose Host cannot be resolved, or a page that fails, + * rejects the whole preview. A sum over the Hosts that answered would read as + * the full cost of a delete that also reaches the ones that did not. + */ +export function createSessionRemovalPreviewReader( + routing: SessionRemovalPreviewRouting, +): SessionRemovalPreviewReader { + return async (sessionIds) => { + const byScope = new Map }>(); + for (const sessionId of new Set(sessionIds)) { + const ref = await routing.resolve(sessionId); + const group = byScope.get(ref.scopeKey) ?? { scope: ref.scope, hostIds: new Set() }; + group.hostIds.add(ref.sessionId); + byScope.set(ref.scopeKey, group); + } + const total = { + archivableSubtaskCount: 0, + removedSubtaskCount: 0, + worktreeCount: 0, + bytes: 0, + }; + for (const { scope, hostIds } of byScope.values()) { + const ids = [...hostIds]; + for (let offset = 0; offset < ids.length; offset += SESSION_REMOVE_PREVIEW_MAX_ITEMS) { + const page = await routing.query( + scope, + ids.slice(offset, offset + SESSION_REMOVE_PREVIEW_MAX_ITEMS), + ); + total.archivableSubtaskCount += page.archivableSubtaskCount; + total.removedSubtaskCount += page.removedSubtaskCount; + total.worktreeCount += page.worktreeCount; + total.bytes += page.bytes; + } + } + return total; + }; +} diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 551b2a5acf..82235e5e88 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -51,6 +51,8 @@ import { createDesktopSessionCollaborationServices } from '../platform/desktop/c import { createDesktopSessionNavigationServices } from '../platform/desktop/create-session-navigation-services'; import { SessionBundleServicesProvider } from '../features/session-bundle'; import { StorageUsageServicesProvider } from '../features/storage-usage'; +import { ArchivedTaskCleanupServicesProvider } from '../features/archived-task-cleanup'; +import { createDesktopArchivedTaskCleanupServices } from '../platform/desktop/create-archived-task-cleanup-services.js'; import { createDesktopStorageUsageServices } from '../platform/desktop/create-storage-usage-services.js'; import { createDesktopSessionBundleServices } from '../platform/desktop/create-session-bundle-services.js'; import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services'; @@ -76,6 +78,7 @@ export function createDesktopFeatureServices() { // drilled through the shell. sessionCatalog: createSessionCatalogController(), appUpdate: createDesktopAppUpdateServices(), + archivedTaskCleanup: createDesktopArchivedTaskCleanupServices(), clientPlugins: createDesktopClientPluginServices(), workHub: createDesktopWorkHubServices(), conversation: createDesktopConversationServices(), @@ -123,7 +126,11 @@ export function DesktopFeatureServicesProvider(props: { - {props.children} + + {props.children} + diff --git a/apps/desktop/src/renderer/features/archived-task-cleanup/index.ts b/apps/desktop/src/renderer/features/archived-task-cleanup/index.ts new file mode 100644 index 0000000000..e50b893b8d --- /dev/null +++ b/apps/desktop/src/renderer/features/archived-task-cleanup/index.ts @@ -0,0 +1,26 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Public API of archived-task cleanup. The legacy Settings page renders the +// scope surface and the rows it hands back; it holds no filter or preview state. + +export { ArchivedTaskScopeSurface } from './ui/archived-task-scope-surface.js'; +export { ArchivedTaskCleanupServicesProvider } from './services-context.js'; +export type { ArchivedTaskCleanupServices } from './ports.js'; +export type { ArchivedTaskProject } from './model/archived-task-scope.js'; diff --git a/apps/desktop/src/renderer/features/archived-task-cleanup/model/archived-task-scope.ts b/apps/desktop/src/renderer/features/archived-task-cleanup/model/archived-task-scope.ts new file mode 100644 index 0000000000..101136a3ca --- /dev/null +++ b/apps/desktop/src/renderer/features/archived-task-cleanup/model/archived-task-scope.ts @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionSummary } from '@maka/core/session'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** "Archived more than N days ago" thresholds the page offers. */ +export const ARCHIVED_AGE_DAYS = [7, 30, 90] as const; +export type ArchivedAgeDays = (typeof ARCHIVED_AGE_DAYS)[number]; + +/** + * The project a row is filed under, as the row itself states it. `null` is a + * task with no project. `undefined` is a project this page could not name: the + * row says nothing about it, so no project filter claims it either, and it is + * reachable under "All projects" only. + */ +export type ArchivedTaskProject = + | { readonly key: string; readonly label: string } + | null + | undefined; + +export type ArchivedProjectFilter = + | { readonly kind: 'all' } + | { readonly kind: 'none' } + | { readonly kind: 'project'; readonly key: string }; + +export interface ArchivedTaskScope { + readonly query: string; + /** Only tasks archived more than this many days ago; absent means any time. */ + readonly minAgeDays?: ArchivedAgeDays; + readonly project: ArchivedProjectFilter; +} + +export const UNSCOPED_ARCHIVED_TASKS: ArchivedTaskScope = { query: '', project: { kind: 'all' } }; + +export interface ArchivedTaskScopeContext { + readonly now: number; + readonly projectOf: (session: T) => ArchivedTaskProject; + /** What the row shows for a task with no project, so search answers to it. */ + readonly noProjectLabel: string; +} + +export interface ScopedArchivedTasks { + /** The rows on screen — and therefore exactly the set a bulk delete removes. */ + readonly visible: T[]; + /** + * Rows that match everything else but were left out only because their + * archive time is unknown. Zero unless an age filter is on. + */ + readonly unknownArchiveTime: number; +} + +/** Whether anything narrows the list, so a bulk delete no longer means all of it. */ +export function isArchivedTaskScopeNarrowed(scope: ArchivedTaskScope): boolean { + return ( + scope.query.trim().length > 0 || scope.minAgeDays !== undefined || scope.project.kind !== 'all' + ); +} + +/** + * The archived rows a scope keeps, in their given order. + * + * Age is measured from `archivedAt` only. A task archived before the Host + * recorded that time has no age to compare, so an age filter leaves it out and + * says how many it left out; borrowing another timestamp would delete tasks on + * a guess. "More than N days" is strict: a task archived exactly N days ago is + * not yet older than N days. + */ +export function scopeArchivedTasks( + rows: readonly T[], + scope: ArchivedTaskScope, + context: ArchivedTaskScopeContext, +): ScopedArchivedTasks { + const visible: T[] = []; + let unknownArchiveTime = 0; + for (const session of rows) { + const project = context.projectOf(session); + if (!matchesProject(project, scope.project)) continue; + if (!matchesArchivedTaskQuery(session, scope.query, projectLabel(project, context))) continue; + if (scope.minAgeDays !== undefined) { + if (session.archivedAt === undefined) { + unknownArchiveTime += 1; + continue; + } + if (context.now - session.archivedAt <= scope.minAgeDays * DAY_MS) continue; + } + visible.push(session); + } + return { visible, unknownArchiveTime }; +} + +export interface ArchivedProjectOptions { + /** Each project at least one row is filed under, by label. */ + readonly projects: ReadonlyArray<{ readonly key: string; readonly label: string }>; + /** Whether any row has no project, so "No project" is worth offering. */ + readonly hasNoProject: boolean; +} + +/** The project filter entries the archived rows can answer to. */ +export function archivedProjectOptions( + rows: readonly T[], + projectOf: (session: T) => ArchivedTaskProject, +): ArchivedProjectOptions { + const projects = new Map(); + let hasNoProject = false; + for (const session of rows) { + const project = projectOf(session); + if (project === null) hasNoProject = true; + else if (project) projects.set(project.key, project.label); + } + return { + projects: [...projects] + .map(([key, label]) => ({ key, label })) + .sort( + (left, right) => + left.label.localeCompare(right.label) || left.key.localeCompare(right.key), + ), + hasNoProject, + }; +} + +/** + * A chosen project filter that the rows no longer offer — its last task was + * deleted or restored — falls back to all projects rather than to an empty + * list whose filter the reader can no longer see. + */ +export function availableProjectFilter( + filter: ArchivedProjectFilter, + options: ArchivedProjectOptions, +): ArchivedProjectFilter { + if (filter.kind === 'none') return options.hasNoProject ? filter : { kind: 'all' }; + if (filter.kind === 'project') { + return options.projects.some((project) => project.key === filter.key) + ? filter + : { kind: 'all' }; + } + return filter; +} + +/** + * Whether a task answers to what was typed in the search box. + * + * The project name is searchable because it is on screen: a row reads "name" + * over "project · date", so both halves answer to the same box. They are + * joined by a newline rather than a space so a query can never match across + * the seam and produce a row whose highlight the reader cannot find. A task + * whose project could not be resolved answers to its name alone — `join` + * renders the missing half as nothing, never as the word "undefined". + */ +export function matchesArchivedTaskQuery( + session: SessionSummary, + query: string, + projectLabel: string | undefined, +): boolean { + const haystack = [session.name, projectLabel].join('\n'); + return haystack.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()); +} + +function matchesProject(project: ArchivedTaskProject, filter: ArchivedProjectFilter): boolean { + if (filter.kind === 'all') return true; + if (filter.kind === 'none') return project === null; + return project?.key === filter.key; +} + +function projectLabel( + project: ArchivedTaskProject, + context: ArchivedTaskScopeContext, +): string | undefined { + return project === null ? context.noProjectLabel : project?.label; +} diff --git a/apps/desktop/src/renderer/features/archived-task-cleanup/model/purge-confirmation.ts b/apps/desktop/src/renderer/features/archived-task-cleanup/model/purge-confirmation.ts new file mode 100644 index 0000000000..10a47cd10c --- /dev/null +++ b/apps/desktop/src/renderer/features/archived-task-cleanup/model/purge-confirmation.ts @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionRemovePreviewResult } from '@maka/runtime-host/protocol'; +import type { SettingsTasksCopy } from '../../../locales/settings-tasks-copy.js'; + +export type RemovalPreviewState = + | { readonly kind: 'loading' } + | { readonly kind: 'ready'; readonly preview: SessionRemovePreviewResult } + /** The Host could not say; the confirm still names the tasks and the count. */ + | { readonly kind: 'failed' }; + +export interface PurgeConfirmation { + /** Frozen when the dialog opened: what the preview describes and the delete removes. */ + readonly sessionIds: readonly string[]; + /** Whether a filter or search narrowed the set, which changes the wording. */ + readonly narrowed: boolean; + readonly preview: RemovalPreviewState; +} + +export interface PurgeConfirmationController { + open(sessionIds: readonly string[], narrowed: boolean): void; + cancel(): void; + /** + * Closes the dialog and hands back the ids to delete — exactly the ones the + * preview was asked about, whatever the list shows by now. Undefined while + * the preview is still computing or when nothing is open. + */ + confirm(): readonly string[] | undefined; +} + +/** + * The bulk-delete confirm as a small state machine, so the rules that matter — + * which ids are deleted, and what a late or failed preview may change — hold + * without a DOM. + * + * The set is frozen at `open`. A confirm names a number to a person, and a set + * re-read afterwards could be larger than the one they agreed to. A preview + * that settles after its dialog was cancelled, or after a newer one opened, + * is dropped. A preview that fails leaves the dialog usable: the reader can + * still cancel, or delete the tasks the title counts. + */ +export function createPurgeConfirmationController(deps: { + /** Absent outside a Desktop composition; the preview then reports failure. */ + readonly previewRemovals?: ( + sessionIds: readonly string[], + ) => Promise; + readonly onChange: (confirmation: PurgeConfirmation | undefined) => void; +}): PurgeConfirmationController { + let current: PurgeConfirmation | undefined; + let generation = 0; + const publish = (next: PurgeConfirmation | undefined) => { + current = next; + deps.onChange(next); + }; + const settle = (opened: number, preview: RemovalPreviewState) => { + if (opened !== generation || !current) return; + publish({ ...current, preview }); + }; + + return { + open(sessionIds, narrowed) { + generation += 1; + const opened = generation; + const frozen = [...sessionIds]; + publish({ sessionIds: frozen, narrowed, preview: { kind: 'loading' } }); + if (!deps.previewRemovals) { + settle(opened, { kind: 'failed' }); + return; + } + void deps.previewRemovals(frozen).then( + (preview) => settle(opened, { kind: 'ready', preview }), + () => settle(opened, { kind: 'failed' }), + ); + }, + cancel() { + generation += 1; + publish(undefined); + }, + confirm() { + if (!current || current.preview.kind === 'loading') return undefined; + const { sessionIds } = current; + generation += 1; + publish(undefined); + return sessionIds; + }, + }; +} + +/** The confirm's wording; only the Host preview's own figures are stated. */ +export function describePurgeConfirmation( + confirmation: PurgeConfirmation, + copy: SettingsTasksCopy, + formatSize: (bytes: number) => string, +): { readonly title: string; readonly description: string } { + const count = confirmation.sessionIds.length; + const title = confirmation.narrowed + ? copy.purgeShownConfirmTitle(count) + : copy.purgeAllConfirmTitle(count); + const { preview } = confirmation; + const details = + preview.kind === 'loading' + ? [copy.purgePreviewLoading] + : preview.kind === 'failed' + ? // Nothing measured, so nothing promised — except what every delete + // does regardless, which the reader still needs to know. + [copy.purgePreviewFailed, copy.purgeSubtaskNote] + : [ + copy.purgePreview( + preview.preview.removedSubtaskCount, + preview.preview.worktreeCount, + formatSize(preview.preview.bytes), + ), + preview.preview.archivableSubtaskCount > 0 + ? copy.purgeArchivableNote(preview.preview.archivableSubtaskCount) + : undefined, + ]; + return { + title, + description: [copy.purgeConfirmBody, ...details].filter(Boolean).join(' '), + }; +} diff --git a/apps/desktop/src/renderer/features/archived-task-cleanup/ports.ts b/apps/desktop/src/renderer/features/archived-task-cleanup/ports.ts new file mode 100644 index 0000000000..9d95512f0c --- /dev/null +++ b/apps/desktop/src/renderer/features/archived-task-cleanup/ports.ts @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SessionRemovePreviewResult } from '@maka/runtime-host/protocol'; + +/** + * What archived-task cleanup needs from the Desktop: one read-only preview. + * Deleting stays with the caller, one `session.remove` per task. + */ +export interface ArchivedTaskCleanupServices { + /** + * What deleting these tasks would take with them, per each Host's removal + * plans. Pages a large selection itself; rejects rather than under-report. + */ + previewRemovals(sessionIds: readonly string[]): Promise; +} diff --git a/apps/desktop/src/renderer/features/archived-task-cleanup/services-context.tsx b/apps/desktop/src/renderer/features/archived-task-cleanup/services-context.tsx new file mode 100644 index 0000000000..dc9ffcc30a --- /dev/null +++ b/apps/desktop/src/renderer/features/archived-task-cleanup/services-context.tsx @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext, type ReactNode, useContext } from 'react'; +import type { ArchivedTaskCleanupServices } from './ports.js'; + +const ArchivedTaskCleanupServicesContext = createContext( + undefined, +); + +export function ArchivedTaskCleanupServicesProvider(props: { + readonly services: ArchivedTaskCleanupServices; + readonly children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +/** Undefined outside a Desktop composition; a confirm then says it cannot preview. */ +export function useOptionalArchivedTaskCleanupServices(): ArchivedTaskCleanupServices | undefined { + return useContext(ArchivedTaskCleanupServicesContext); +} diff --git a/apps/desktop/src/renderer/features/archived-task-cleanup/testing.ts b/apps/desktop/src/renderer/features/archived-task-cleanup/testing.ts new file mode 100644 index 0000000000..644d9d2992 --- /dev/null +++ b/apps/desktop/src/renderer/features/archived-task-cleanup/testing.ts @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Test-only surface: the scope rules and the confirm's state machine are where + * "which tasks does this delete" is decided, so they are checked without a DOM. + */ +export { + archivedProjectOptions, + availableProjectFilter, + isArchivedTaskScopeNarrowed, + matchesArchivedTaskQuery, + scopeArchivedTasks, + UNSCOPED_ARCHIVED_TASKS, + type ArchivedTaskScope, +} from './model/archived-task-scope.js'; +export { + createPurgeConfirmationController, + describePurgeConfirmation, + type PurgeConfirmation, +} from './model/purge-confirmation.js'; diff --git a/apps/desktop/src/renderer/features/archived-task-cleanup/ui/archived-task-scope-surface.tsx b/apps/desktop/src/renderer/features/archived-task-cleanup/ui/archived-task-scope-surface.tsx new file mode 100644 index 0000000000..7befd30515 --- /dev/null +++ b/apps/desktop/src/renderer/features/archived-task-cleanup/ui/archived-task-scope-surface.tsx @@ -0,0 +1,229 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { type ReactNode, useMemo, useState } from 'react'; +import type { SessionSummary } from '@maka/core/session'; +import { Button, formatBytes, useMountedRef, useUiLocale } from '@maka/ui'; +import { Search } from '@maka/ui/icons'; +import { HStack, StackItem, VStack } from '@astryxdesign/core'; +import { AlertDialog } from '@astryxdesign/core/AlertDialog'; +import { Selector } from '@astryxdesign/core/Selector'; +import { Text } from '@astryxdesign/core/Text'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { getSettingsSharedCopy } from '../../../locales/settings-shared-copy.js'; +import { getSettingsTasksCopy } from '../../../locales/settings-tasks-copy.js'; +import { + ARCHIVED_AGE_DAYS, + type ArchivedAgeDays, + type ArchivedProjectFilter, + type ArchivedTaskProject, + archivedProjectOptions, + availableProjectFilter, + isArchivedTaskScopeNarrowed, + scopeArchivedTasks, +} from '../model/archived-task-scope.js'; +import { + createPurgeConfirmationController, + describePurgeConfirmation, + type PurgeConfirmation, +} from '../model/purge-confirmation.js'; +import { useOptionalArchivedTaskCleanupServices } from '../services-context.js'; + +const ANY_AGE = 'any'; +const ALL_PROJECTS = '*all'; +const NO_PROJECT = '*none'; +const PROJECT_PREFIX = '='; + +/** + * Settings › Archived tasks: search, the age and project filters, and the one + * bulk delete, which always acts on exactly the rows on screen. + * + * The page keeps the rows' presentation; this surface decides which rows those + * are and hands them back through `children`. A delete opens a confirm that + * names the count at once and fills in the Host's preview of what else goes + * when it arrives. The set it deletes is the one it previewed, frozen at the + * click. + */ +export function ArchivedTaskScopeSurface(props: { + readonly rows: readonly T[]; + readonly projectOf: (session: T) => ArchivedTaskProject; + /** Deletes exactly these tasks and reports the outcome; the list is busy until it settles. */ + readonly onPurge: (sessionIds: readonly string[]) => Promise; + readonly children: (scope: { + readonly visible: readonly T[]; + readonly purging: boolean; + }) => ReactNode; +}) { + const locale = useUiLocale(); + const copy = getSettingsTasksCopy(locale); + const services = useOptionalArchivedTaskCleanupServices(); + const mountedRef = useMountedRef(); + const [query, setQuery] = useState(''); + const [minAgeDays, setMinAgeDays] = useState(undefined); + const [chosenProject, setChosenProject] = useState({ kind: 'all' }); + const [confirmation, setConfirmation] = useState(undefined); + const [purging, setPurging] = useState(false); + + const controller = useMemo( + () => + createPurgeConfirmationController({ + ...(services ? { previewRemovals: (ids) => services.previewRemovals(ids) } : {}), + onChange: (next) => { + if (mountedRef.current) setConfirmation(next); + }, + }), + [mountedRef, services], + ); + + const projectOptions = useMemo( + () => archivedProjectOptions(props.rows, props.projectOf), + [props.projectOf, props.rows], + ); + const scope = { + query, + ...(minAgeDays === undefined ? {} : { minAgeDays }), + project: availableProjectFilter(chosenProject, projectOptions), + }; + const narrowed = isArchivedTaskScopeNarrowed(scope); + const { visible, unknownArchiveTime } = scopeArchivedTasks(props.rows, scope, { + now: Date.now(), + projectOf: props.projectOf, + noProjectLabel: copy.noProject, + }); + + async function confirmPurge() { + const sessionIds = controller.confirm(); + if (!sessionIds) return; + setPurging(true); + try { + await props.onPurge(sessionIds); + } finally { + if (mountedRef.current) setPurging(false); + } + } + + const dialog = confirmation + ? describePurgeConfirmation(confirmation, copy, (bytes) => formatBytes(bytes, locale)) + : undefined; + + return ( + + {/* Search and the delete button share one row: as a section action the + button landed a full 32px page rhythm below the box. */} + + + + + {/* While anything narrows the list the button deletes what is on + screen. One that said 全部 and deleted a set the reader could not + see would be answering a question nobody asked. */} +