diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index f6582ab142..ee12ca20f7 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -751,7 +751,7 @@ "react": 1 }, "importSpecifiers": 80, - "nonTriviaTokens": 9614 + "nonTriviaTokens": 9576 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, @@ -3026,6 +3026,7 @@ "../features/connection-settings": 1, "../features/external-agent-settings/index.js": 1, "../features/session-bundle": 1, + "../features/session-navigation": 1, "../locales/settings-navigation-copy.js": 1, "../locales/settings-shared-copy.js": 1, "./about-settings-page": 1, @@ -3114,17 +3115,12 @@ "lifecycleMethods": {}, "unresolvedDependencies": 0, "actionFactories": [], - "dependencyPaths": { - "../features/session-navigation/index.js": 1 - } + "dependencyPaths": {} }, "src/renderer/settings/tasks-settings-page.tsx": { "bridgePaths": {}, "environmentCapabilities": {}, "hookCalls": { - "useMountedRef": 1, - "useState": 2, - "useToast": 1, "useUiLocale": 1 }, "lifecycleMethods": {}, @@ -3132,17 +3128,12 @@ "actionFactories": [], "dependencyPaths": { "../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, "@maka/ui/icons": 1, "react": 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..02df9fb649 --- /dev/null +++ b/apps/desktop/src/main/__tests__/archived-task-scope-surface.test.ts @@ -0,0 +1,228 @@ +/* + * 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 { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; +import { + ArchivedTaskScopeSurface, + type SessionNavigationRowActions, + type SessionNavigationSession, +} from '../../renderer/features/session-navigation/testing.js'; +import type { ArchivedPurgeRequest } from '../../renderer/features/session-navigation/testing.js'; +import { runtimeHostProjectKey } from '../../renderer/application/contracts/runtime-host-project-key.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + Event: globalThis.Event, + Node: globalThis.Node, + CSS: globalThis.CSS, + matchMedia: globalThis.matchMedia, + 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); +}); + +test('hands the bulk delete exactly the shown ids, and retires it when the scope changes', async () => { + const harness = installScope(); + await harness.render(); + // Most recently archived first, unknown times last. + assert.deepEqual(harness.shownIds(), ['alpha-new', 'alpha-old', 'loose']); + assert.ok(harness.findButton('Clear all'), 'nothing narrowed keeps the whole-list label'); + + await harness.search('alpha'); + assert.deepEqual(harness.shownIds(), ['alpha-new', 'alpha-old']); + await harness.click('Delete 2 shown'); + + const [request] = harness.requests; + assert.ok(request); + assert.deepEqual(request.sessionIds, ['alpha-new', 'alpha-old']); + assert.equal(request.narrowed, true); + // No age filter, so the Host is asked to hold no age. + assert.equal('requireArchivedForMs' in request, false); + assert.equal(request.isCurrent(), true); + assert.equal(harness.findButton('Delete 2 shown')?.disabled, true, 'busy while it runs'); + + // A scope change retires the pending confirm: a late preview asks nothing. + await harness.search('alp'); + assert.equal(request.isCurrent(), false); + await act(async () => harness.settle()); + assert.equal(harness.findButton('Delete 2 shown')?.disabled, false); +}); + +test('a pending bulk delete asks nothing once the page is gone', async () => { + const harness = installScope(); + await harness.render(); + await harness.click('Clear all'); + const [request] = harness.requests; + assert.ok(request); + assert.equal(request.narrowed, false); + assert.equal(request.isCurrent(), true); + await act(async () => mountedRoot?.unmount()); + mountedRoot = undefined; + assert.equal(request.isCurrent(), false); +}); + +function session(id: string, overrides: Partial): SessionNavigationSession { + return { + id, + name: id, + isFlagged: false, + isArchived: true, + labels: [], + hasUnread: false, + status: 'active', + backend: 'fake', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test', + permissionMode: 'ask', + runtimeHostId: 'host-1', + profileId: 'profile-1', + profileName: 'This Mac', + profileKind: 'local', + ...overrides, + }; +} + +function installScope() { + const { document, window } = parseHTML('
'); + const matchMedia = () => ({ + matches: false, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + }); + Object.assign(window, { matchMedia }); + Object.assign(globalThis, { + document, + window, + matchMedia, + HTMLElement: window.HTMLElement, + Event: window.Event, + Node: window.Node, + CSS: { escape: (value: string) => value }, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + const sessions = [ + session('alpha-old', { projectId: 'alpha', archivedAt: Date.now() - 40 * 86_400_000 }), + session('alpha-new', { projectId: 'alpha', archivedAt: Date.now() - 86_400_000 }), + session('loose', {}), + ]; + const projectScopes = [ + { + key: runtimeHostProjectKey('host-1', 'alpha'), + hostId: 'host-1', + profileName: 'This Mac', + project: { id: 'alpha', name: 'Alpha project' }, + }, + ]; + const requests: ArchivedPurgeRequest[] = []; + const pending = deferred(); + const commands = { + current: { + purgeArchived: async (request: ArchivedPurgeRequest) => { + requests.push(request); + await pending.promise; + }, + } as unknown as SessionNavigationRowActions, + }; + const findButton = (label: string) => + [...document.querySelectorAll('button')].find((candidate) => + candidate.textContent?.includes(label), + ) as HTMLButtonElement | undefined; + return { + requests, + findButton, + settle: () => pending.resolve(), + async render() { + await act(async () => { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(ArchivedTaskScopeSurface, { + sessions, + projectScopes, + commands, + children: ({ visible, controls }) => [ + createElement('div', { key: 'controls' }, controls), + createElement( + 'ul', + { key: 'shown', 'data-testid': 'shown' }, + visible.map((row) => createElement('li', { key: row.id, 'data-id': row.id })), + ), + ], + }), + }), + }), + ); + await Promise.resolve(); + }); + }, + shownIds() { + return [...document.querySelectorAll('[data-testid="shown"] li')].map((item) => + item.getAttribute('data-id'), + ); + }, + 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..2196b37914 --- /dev/null +++ b/apps/desktop/src/main/__tests__/archived-task-scope.test.ts @@ -0,0 +1,322 @@ +/* + * 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 { describe, it } from 'node:test'; +import type { SessionSummary } from '@maka/core/session'; +import { + archivedAgeThresholdMs, + archivedProjectOptions, + archivedTaskProjectResolver, + availableProjectFilter, + isArchivedTaskScopeNarrowed, + matchesArchivedTaskQuery, + scopeArchivedTasks, + UNSCOPED_ARCHIVED_TASKS, + type ArchivedTaskScope, +} from '../../renderer/features/session-navigation/testing.js'; +import { runtimeHostProjectKey } from '../../renderer/application/contracts/runtime-host-project-key.js'; + +const DAY = 24 * 60 * 60 * 1000; +const NOW = 1_000 * DAY; + +type Task = SessionSummary & { runtimeHostId: string }; + +function summary(id: string, overrides: Partial = {}): Task { + return { + id, + name: id, + isFlagged: false, + isArchived: true, + labels: [], + hasUnread: false, + status: 'active', + backend: 'fake', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test', + permissionMode: 'ask', + runtimeHostId: 'host-1', + ...overrides, + }; +} + +function scope(hostId: string, id: string, name: string, aliases?: string[]) { + return { + key: runtimeHostProjectKey(hostId, id), + hostId, + profileName: `Profile ${hostId}`, + project: { id, name, ...(aliases ? { aliases } : {}) }, + }; +} + +const projectOf = archivedTaskProjectResolver([ + scope('host-1', 'alpha', 'Alpha'), + scope('host-1', 'beta', 'Beta'), +]); +const labelOf = (session: Task) => { + const project = projectOf(session); + return project === null ? 'No project' : project?.label; +}; + +function visibleIds(rows: readonly Task[], filter: ArchivedTaskScope) { + return scopeArchivedTasks(rows, filter, { now: NOW, projectOf, labelOf }).visible.map( + (session) => session.id, + ); +} + +describe('scopeArchivedTasks', () => { + it('keeps only tasks archived strictly more than N days ago', () => { + const rows = [ + summary('exactly-7', { archivedAt: NOW - 7 * DAY }), + summary('just-over-7', { archivedAt: NOW - 7 * DAY - 1 }), + summary('exactly-30', { archivedAt: NOW - 30 * DAY }), + summary('just-over-30', { archivedAt: NOW - 30 * DAY - 1 }), + summary('just-over-90', { archivedAt: NOW - 90 * DAY - 1 }), + summary('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 = [ + summary('known-old', { archivedAt: NOW - 100 * DAY }), + // Old by every other clock; still no archive time to judge by. + summary('legacy', { lastMessageAt: NOW - 400 * DAY }), + summary('legacy-elsewhere', { projectId: 'beta' }), + ]; + const context = { now: NOW, projectOf, labelOf }; + 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 rows the other filters kept count as left out for their 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 = [ + summary('a1', { projectId: 'alpha' }), + summary('loose'), + summary('b1', { projectId: 'beta' }), + summary('unnamed', { projectId: 'gone' }), + summary('a2', { projectId: 'alpha' }), + ]; + assert.deepEqual( + visibleIds(rows, { + ...UNSCOPED_ARCHIVED_TASKS, + project: { kind: 'project', key: runtimeHostProjectKey('host-1', '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 = [ + summary('Fix rail', { projectId: 'alpha', archivedAt: NOW - 40 * DAY }), + summary('Fix rail again', { projectId: 'alpha', archivedAt: NOW - DAY }), + summary('Fix build', { projectId: 'beta', archivedAt: NOW - 40 * DAY }), + summary('Loose notes', { archivedAt: NOW - 40 * DAY }), + ]; + assert.deepEqual( + visibleIds(rows, { + query: ' FIX ', + minAgeDays: 30, + project: { kind: 'project', key: runtimeHostProjectKey('host-1', 'alpha') }, + }), + ['Fix rail'], + ); + 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, + ); + }); + + it('asks the Host to hold an age only while an age filter is on', () => { + assert.equal(archivedAgeThresholdMs(UNSCOPED_ARCHIVED_TASKS), undefined); + assert.equal( + archivedAgeThresholdMs({ ...UNSCOPED_ARCHIVED_TASKS, query: 'x', project: { kind: 'none' } }), + undefined, + ); + assert.equal(archivedAgeThresholdMs({ ...UNSCOPED_ARCHIVED_TASKS, minAgeDays: 30 }), 30 * DAY); + }); +}); + +describe('archivedTaskProjectResolver', () => { + it('keeps equal project ids on two Hosts apart', () => { + const resolve = archivedTaskProjectResolver([ + scope('host-1', 'shared', 'Maka'), + scope('host-2', 'shared', 'Maka'), + ]); + const onOne = resolve(summary('one', { projectId: 'shared', runtimeHostId: 'host-1' })); + const onTwo = resolve(summary('two', { projectId: 'shared', runtimeHostId: 'host-2' })); + assert.equal(onOne?.key, runtimeHostProjectKey('host-1', 'shared')); + assert.equal(onTwo?.key, runtimeHostProjectKey('host-2', 'shared')); + // A Host with no such project names nothing, rather than borrowing another's. + assert.equal( + resolve(summary('three', { projectId: 'shared', runtimeHostId: 'host-3' })), + undefined, + ); + // Same name on two Hosts: two entries, told apart by Host. + assert.deepEqual( + archivedProjectOptions( + [ + summary('one', { projectId: 'shared', runtimeHostId: 'host-1' }), + summary('two', { projectId: 'shared', runtimeHostId: 'host-2' }), + ], + resolve, + ).projects, + [ + { key: runtimeHostProjectKey('host-1', 'shared'), label: 'Maka · Profile host-1' }, + { key: runtimeHostProjectKey('host-2', 'shared'), label: 'Maka · Profile host-2' }, + ], + ); + }); + + it('files a task recorded under an alias under its project', () => { + const resolve = archivedTaskProjectResolver([ + scope('host-1', 'current', 'Maka', ['retired']), + ]); + const aliased = resolve(summary('old', { projectId: 'retired' })); + const current = resolve(summary('new', { projectId: 'current' })); + assert.deepEqual(aliased, current); + assert.equal(aliased?.key, runtimeHostProjectKey('host-1', 'current')); + assert.equal(resolve(summary('loose')), null); + }); +}); + +describe('archivedProjectOptions', () => { + it('offers each named project once, by label, and "No project" only when used', () => { + const rows = [ + summary('b', { projectId: 'beta' }), + summary('a', { projectId: 'alpha' }), + summary('b2', { projectId: 'beta' }), + summary('unnamed', { projectId: 'gone' }), + ]; + assert.deepEqual(archivedProjectOptions(rows, projectOf), { + projects: [ + { key: runtimeHostProjectKey('host-1', 'alpha'), label: 'Alpha' }, + { key: runtimeHostProjectKey('host-1', 'beta'), label: 'Beta' }, + ], + hasNoProject: false, + }); + assert.equal(archivedProjectOptions([...rows, summary('loose')], projectOf).hasNoProject, true); + }); + + it('falls back to all projects once a chosen project has no rows left', () => { + const alpha = runtimeHostProjectKey('host-1', 'alpha'); + const options = archivedProjectOptions([summary('a', { projectId: 'alpha' })], projectOf); + assert.deepEqual(availableProjectFilter({ kind: 'project', key: alpha }, options), { + kind: 'project', + key: alpha, + }); + assert.deepEqual( + availableProjectFilter( + { kind: 'project', key: runtimeHostProjectKey('host-1', 'beta') }, + options, + ), + { kind: 'all' }, + ); + assert.deepEqual(availableProjectFilter({ kind: 'none' }, options), { kind: 'all' }); + }); +}); + +describe('matchesArchivedTaskQuery', () => { + const projectLabelOf = (session: SessionSummary) => + session.projectId === 'p1' ? 'astryx-design' : undefined; + + it('keeps every task while the box is empty or only whitespace', () => { + const task = summary('a', { name: 'rail sorting' }); + 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(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(task)), true); + }); + + it('never matches across the seam between the name and the project', () => { + // "sorting astryx" reads like a match on the joined string and like + // 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(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(task)), true); + assert.equal(matchesArchivedTaskQuery(task, 'undefined', projectLabelOf(task)), false); + }); +}); 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..65cfa27f73 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,8 +216,28 @@ 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'], + measureBytes: true, + requireArchived: true, + }); + return { + ok: true, + result: { + archivableSubtaskCount: 1, + removedSubtaskCount: 2, + worktreeCount: 3, + bytes: 4096, + }, + }; + }, 'session.remove': async (input) => { assert.ok(projected); + if (input.requireArchivedForMs !== undefined) { + // The Host's clock says the task is not old enough yet. + return { ok: true, result: { kind: 'too_recent', sessionId: input.sessionId } }; + } if (restoreUnderNextRemove) { // Another window restored the task between the Client's read and // this write. The Host rejects the stale revision, which is what @@ -325,6 +345,29 @@ 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. + const previewInput = { sessionIds: ['session-ipc'], measureBytes: true, requireArchived: true }; + assert.deepEqual(await ipc.invoke('sessions:removePreview', previewInput), { + archivableSubtaskCount: 1, + removedSubtaskCount: 2, + worktreeCount: 3, + bytes: 4096, + }); + // The protocol codec, not a second IPC check, refuses a malformed input. + await assert.rejects( + ipc.invoke('sessions:removePreview', { sessionIds: 'session-ipc' }), + /Session remove preview/, + ); + // An age filter's threshold reaches the Host, and its refusal keeps the task. + assert.deepEqual( + await ipc.invoke('sessions:remove', 'session-ipc', { + revisionFamily: true, + requireArchived: true, + requireArchivedForMs: 604_800_000, + }), + { disposition: 'too_recent', archivedSubtaskCount: 0 }, + ); + assert.equal((await ipc.invoke('sessions:list') as unknown[]).length, 1); // 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-navigation-move-to-project.test.ts b/apps/desktop/src/main/__tests__/session-navigation-move-to-project.test.ts index f2e036f4a4..c883b292c3 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-move-to-project.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-move-to-project.test.ts @@ -52,6 +52,11 @@ function installService( rename: async () => undefined, remove: async () => ({ disposition: 'removed', archivedSubtaskCount: 0 }), previewRemoval: async () => 0, + previewRemovals: async () => ({ + archivableSubtaskCount: 0, + removedSubtaskCount: 0, + worktreeCount: 0, + }), moveToProject: async (sessionId: string, projectId: string | null) => { h.moves.push({ sessionId, projectId }); return outcome; 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 57844ac7ac..d9e78f2a81 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 @@ -75,6 +75,9 @@ function createService( if (preview.throws) throw new Error('preview failed'); return preview.count ?? 0; }, + previewRemovals: async () => { + throw new Error('a single delete does not preview a batch'); + }, moveToProject: async (id: string, projectId: string | null) => { calls.push(`move:${id}:${projectId ?? 'none'}`); return { ok: true } as const; 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 5a910bf19c..e276e6c5e4 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 @@ -20,6 +20,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { SessionSummary } from '@maka/core/session'; +import type { SessionRemovePreviewResult } from '@maka/runtime-host/protocol'; import { createSessionNavigationRowActions, type SessionNavigationSessionService, @@ -56,6 +57,14 @@ type SweepHarness = { archived: string[]; /** Each `remove` call as `[sessionId, requireArchived]`. */ removeOptions: Array<[string, boolean]>; + /** The age guard each `remove` carried, when it carried one. */ + removeAgeGuards: Array<[string, number]>; + /** Each `previewRemovals` call: ids and options. */ + batchPreviews: Array<{ ids: string[]; options: unknown }>; + /** Each confirm the flow opened. */ + confirms: Array<{ title: string; description: string }>; + /** Error toasts, as title then description. */ + errors: Array<[string, string | undefined]>; cleared: string[]; /** Titles of the success toasts a row action raised. */ toasts: string[]; @@ -91,6 +100,10 @@ function installService( catalog?: readonly SessionSummary[]; /** Subtasks the Host archives per removed id, summed into the outcome. */ archivedByRemoval?: Record; + /** What the batch preview answers; a thrown error when `null`. */ + batchPreview?: SessionRemovePreviewResult | null; + /** Ids the Host keeps as archived too recently. */ + tooRecentIds?: readonly string[]; } = {}, ): SessionNavigationSessionService { return { @@ -108,6 +121,12 @@ function installService( rename: async () => undefined, remove: async (id, removeOptions) => { harness.removeOptions.push([id, removeOptions.requireArchived]); + if (removeOptions.requireArchivedForMs !== undefined) { + harness.removeAgeGuards.push([id, removeOptions.requireArchivedForMs]); + } + if (options.tooRecentIds?.includes(id)) { + return { disposition: 'too_recent', archivedSubtaskCount: 0 }; + } if (options.rejectWithUndefinedIds?.includes(id)) { return Promise.reject(undefined); } @@ -125,6 +144,13 @@ function installService( if (options.rejectPreviewIds?.includes(id)) throw new Error(`preview-unavailable:${id}`); return options.previewSubtasks?.[id] ?? 0; }, + previewRemovals: async (ids, previewOptions) => { + harness.batchPreviews.push({ ids: [...ids], options: previewOptions }); + if (options.batchPreview === null) throw new Error('preview-unavailable'); + return ( + options.batchPreview ?? { archivableSubtaskCount: 0, removedSubtaskCount: 0, worktreeCount: 0 } + ); + }, moveToProject: async () => ({ ok: true }), }; } @@ -154,8 +180,13 @@ function createActions(input: { input.harness.toasts.push(title); input.harness.toastDescriptions.push(description); }, - error: () => undefined, - confirm: async (options) => input.onConfirm?.(options) ?? true, + error: (title: string, description?: string) => { + input.harness.errors.push([title, description]); + }, + confirm: async (options) => { + input.harness.confirms.push({ title: options.title, description: options.description }); + return input.onConfirm?.(options) ?? true; + }, }, }); } @@ -166,6 +197,10 @@ function harness(): SweepHarness { previews: [], archived: [], removeOptions: [], + removeAgeGuards: [], + batchPreviews: [], + confirms: [], + errors: [], cleared: [], toasts: [], toastDescriptions: [], @@ -193,6 +228,7 @@ describe('purgeSessions', () => { archivedSubtasks: 0, remaining: [], restored: [], + tooRecent: [], verified: true, firstFailure: undefined, }); @@ -437,3 +473,128 @@ describe('deleteSession', () => { assert.deepEqual(h.toasts, ['rescued was restored, so it was kept']); }); }); + +describe('purgeArchived', () => { + const DAY = 24 * 60 * 60 * 1000; + const sessions = [summary('a'), summary('b'), summary('c')]; + + it('previews the frozen ids, measured and archived-only, then deletes exactly them', async () => { + const h = harness(); + const service = installService(h, { + batchPreview: { + archivableSubtaskCount: 1, + removedSubtaskCount: 2, + worktreeCount: 0, + bytes: 2048, + }, + }); + const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, service }); + const ids = ['a', 'b']; + + const run = actions.purgeArchived({ + sessionIds: ids, + narrowed: true, + requireArchivedForMs: 30 * DAY, + isCurrent: () => true, + }); + ids.push('c'); + await run; + + assert.deepEqual(h.batchPreviews, [ + { ids: ['a', 'b'], options: { measureBytes: true, requireArchived: true } }, + ]); + assert.deepEqual(h.confirms, [ + { + title: 'Delete the 2 tasks shown?', + description: + 'The tasks and all of their messages are removed permanently. This cannot be undone. ' + + '2 Agent Graph subtasks are deleted with them. ' + + '1 ordinary subtask is kept and moved to Archived. ' + + 'About 2.0 KB of task data (an estimate).', + }, + ]); + assert.deepEqual(h.removed, ['a', 'b']); + // The age filter's threshold travels with every delete, for the Host's clock. + assert.deepEqual(h.removeAgeGuards, [ + ['a', 30 * DAY], + ['b', 30 * DAY], + ]); + }); + + it('asks no age of the Host when no age filter is on', async () => { + const h = harness(); + const service = installService(h); + const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, service }); + await actions.purgeArchived({ sessionIds: ['a'], narrowed: false, isCurrent: () => true }); + assert.deepEqual(h.removed, ['a']); + assert.deepEqual(h.removeAgeGuards, []); + // All-zero figures say nothing beyond the body. + assert.deepEqual(h.confirms, [ + { + title: 'Clear the 1 archived task?', + description: + 'The tasks and all of their messages are removed permanently. This cannot be undone.', + }, + ]); + }); + + it('falls back to the uncertain note when the Host cannot preview', async () => { + const h = harness(); + const service = installService(h, { batchPreview: null }); + const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, service }); + await actions.purgeArchived({ sessionIds: ['a', 'b', 'c'], narrowed: false, isCurrent: () => true }); + assert.deepEqual(h.confirms, [ + { + title: 'Clear all 3 archived tasks?', + description: + 'The tasks and all of their messages are removed permanently. This cannot be undone. ' + + 'Any ordinary subtasks are kept and moved to Archived.', + }, + ]); + assert.deepEqual(h.removed, ['a', 'b', 'c']); + }); + + it('opens no confirm for a preview that outlived its page or scope', async () => { + const h = harness(); + const service = installService(h); + const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, service }); + await actions.purgeArchived({ sessionIds: ['a'], narrowed: false, isCurrent: () => false }); + assert.equal(h.batchPreviews.length, 1); + assert.deepEqual(h.confirms, []); + assert.deepEqual(h.removeOptions, []); + }); + + it('deletes nothing when the confirm is declined', async () => { + const h = harness(); + const service = installService(h); + const actions = createActions({ + harness: h, + sessions, + activeIdRef: { current: undefined }, + service, + onConfirm: () => false, + }); + await actions.purgeArchived({ sessionIds: ['a'], narrowed: false, isCurrent: () => true }); + assert.deepEqual(h.removeOptions, []); + }); + + it('counts a task the Host kept as too recent as kept, not failed', async () => { + const h = harness(); + const service = installService(h, { tooRecentIds: ['b'] }); + const actions = createActions({ harness: h, sessions, activeIdRef: { current: undefined }, service }); + await actions.purgeArchived({ + sessionIds: ['a', 'b'], + narrowed: true, + requireArchivedForMs: 7 * DAY, + isCurrent: () => true, + }); + assert.deepEqual(h.removed, ['a']); + assert.deepEqual(h.errors, []); + assert.deepEqual(h.toasts, ['Deleted 1 task']); + assert.deepEqual(h.toastDescriptions, [ + '1 more was archived too recently for the chosen age and kept.', + ]); + // Kept, so nothing the renderer holds for it is dropped. + assert.ok(!h.cleared.includes('b')); + }); +}); 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..37f2d33509 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-removal-preview.test.ts @@ -0,0 +1,156 @@ +/* + * 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 SessionRemovePreviewInput, + type SessionRemovePreviewResult, +} from '@maka/runtime-host/protocol'; +import { forEachHostSessionPage } from '../../preload/host-session-pages.js'; +import { createSessionRemovalPreviewReader } from '../../preload/session-removal-preview.js'; + +/** Desktop ids read `:`. */ +const resolve = async (sessionId: string) => { + const [host = '', id = ''] = sessionId.split(':'); + if (host === 'gone') throw new Error('The Runtime Host for this task is unavailable'); + return { scope: host, scopeKey: host, sessionId: id }; +}; + +test('pages each Host one bounded request at a time, Hosts side by side', async () => { + const pages: Array<[string, readonly string[]]> = []; + const inFlight = new Map(); + let maxPerHost = 0; + let maxOverall = 0; + const ids = [ + ...Array.from({ length: 2 * 3 + 1 }, (_, index) => `a:${index}`), + 'b:x', + 'b:y', + 'a:0', + ]; + await forEachHostSessionPage(ids, { resolve, pageSize: 3 }, async (page) => { + pages.push([page.scopeKey, page.hostIds]); + inFlight.set(page.scopeKey, (inFlight.get(page.scopeKey) ?? 0) + 1); + maxPerHost = Math.max(maxPerHost, inFlight.get(page.scopeKey)!); + maxOverall = Math.max(maxOverall, [...inFlight.values()].reduce((sum, count) => sum + count, 0)); + assert.equal(page.desktopIds.get(page.hostIds[0]!), `${page.scopeKey}:${page.hostIds[0]}`); + await new Promise((settle) => setImmediate(settle)); + inFlight.set(page.scopeKey, inFlight.get(page.scopeKey)! - 1); + }); + assert.deepEqual( + pages.filter(([host]) => host === 'a').map(([, hostIds]) => hostIds), + [['0', '1', '2'], ['3', '4', '5'], ['6']], + ); + assert.deepEqual( + pages.filter(([host]) => host === 'b').map(([, hostIds]) => hostIds), + [['x', 'y']], + ); + assert.equal(maxPerHost, 1, 'no Host has two pages in flight'); + assert.equal(maxOverall, 2, 'two Hosts are asked side by side'); +}); + +test('rejects on any failure unless told to tolerate, then skips only that Host', async () => { + const fail = async (page: { scopeKey: string }) => { + if (page.scopeKey === 'b') throw new Error('persistence_failed'); + }; + await assert.rejects( + forEachHostSessionPage(['a:1', 'b:2'], { resolve, pageSize: 3 }, fail), + /persistence_failed/, + ); + await assert.rejects( + forEachHostSessionPage(['a:1', 'gone:2'], { resolve, pageSize: 3 }, async () => undefined), + /unavailable/, + ); + + const failed: string[] = []; + const visited: string[] = []; + await forEachHostSessionPage( + ['a:1', 'b:2', 'gone:3', 'c:4'], + { + resolve, + pageSize: 3, + tolerate: { + skip: (scopeKey) => scopeKey === 'c', + failed: (scopeKey) => failed.push(scopeKey), + }, + }, + async (page) => { + visited.push(page.scopeKey); + await fail(page); + }, + ); + assert.deepEqual(visited.sort(), ['a', 'b']); + assert.deepEqual(failed, ['b']); +}); + +test('sums preview pages, forwarding the options with each page', async () => { + const queries: SessionRemovePreviewInput[] = []; + const preview = createSessionRemovalPreviewReader({ + resolve, + query: async (_host, input): Promise => { + queries.push(input); + const n = input.sessionIds.length; + return { + archivableSubtaskCount: n, + removedSubtaskCount: 2 * n, + worktreeCount: 3 * n, + ...(input.measureBytes ? { bytes: 100 * n } : {}), + }; + }, + }); + const ids = Array.from({ length: SESSION_REMOVE_PREVIEW_MAX_ITEMS + 1 }, (_, index) => `a:${index}`); + + assert.deepEqual(await preview([...ids, 'b:x'], { measureBytes: true, requireArchived: true }), { + archivableSubtaskCount: ids.length + 1, + removedSubtaskCount: 2 * (ids.length + 1), + worktreeCount: 3 * (ids.length + 1), + bytes: 100 * (ids.length + 1), + }); + assert.deepEqual( + queries.map((query) => [query.sessionIds.length, query.measureBytes, query.requireArchived]).sort(), + [ + [SESSION_REMOVE_PREVIEW_MAX_ITEMS, true, true], + [1, true, true], + [1, true, true], + ].sort(), + ); + // A single delete's preview asks for no bytes, and reports none. + queries.length = 0; + assert.deepEqual(await preview(['a:1']), { + archivableSubtaskCount: 1, + removedSubtaskCount: 2, + worktreeCount: 3, + }); + assert.deepEqual(queries, [{ sessionIds: ['1'] }]); +}); + +test('reports no bytes when any page could not measure them', async () => { + const preview = createSessionRemovalPreviewReader({ + resolve, + query: async (host) => ({ + archivableSubtaskCount: 0, + removedSubtaskCount: 0, + worktreeCount: 0, + ...(host === 'a' ? { bytes: 10 } : {}), + }), + }); + assert.equal('bytes' in (await preview(['a:1', 'b:1'], { measureBytes: true })), false); + assert.equal((await preview(['a:1', 'a:2'], { measureBytes: true })).bytes, 10); +}); 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..92c803a1a5 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 { archivedTaskRows } from '../../renderer/features/session-navigation/testing.js'; +import { isOrphanedSubagentTask } from '../../renderer/settings/task-catalog-rows.js'; function summary(id: string, overrides: Partial = {}): SessionSummary { return { @@ -150,42 +147,6 @@ describe('archived task order', () => { }); }); -describe('matchesArchivedTaskQuery', () => { - const projectLabelOf = (session: SessionSummary) => - session.projectId === 'p1' ? 'astryx-design' : undefined; - - 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); - }); - - 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); - }); - - 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); - }); - - it('never matches across the seam between the name and the project', () => { - // "sorting astryx" reads like a match on the joined string and like - // 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); - }); - - 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); - }); -}); - describe('isOrphanedSubagentTask', () => { it('labels ordinary linked Sessions only when their parent is missing', () => { const projected = summary('projected-child', { diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index c3e7507961..b5cb1aac62 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -133,6 +133,8 @@ import { type ExecutionBoundarySummary, type SessionLifecycleState, type SessionMetadataPatch, + type SessionRemovePreviewInput, + type SessionRemovePreviewResult, type SessionUpdateResult, type SkillCatalogWorkspaceContext, type SkillCatalogInvocableItem, @@ -176,7 +178,7 @@ type QueueMutationInput = Omit< * How a remove settled. `restored` is not a failure: the task left the state * the caller decided against, so nothing was destroyed and nothing is wrong. */ -export type SessionRemoveDisposition = "removed" | "restored"; +export type SessionRemoveDisposition = "removed" | "restored" | "too_recent"; /** * How a remove settled together with what it archived. `archivedSubtaskCount` @@ -1175,7 +1177,7 @@ export class DesktopRuntimeHostClient { */ async removeSession( sessionId: string, - options: { requireArchived?: boolean } = {}, + options: { requireArchived?: boolean; requireArchivedForMs?: number } = {}, ): Promise { for (let attempt = 0; attempt < MAX_SESSION_REVISION_ATTEMPTS; attempt += 1) { const current = await this.#requireSession(sessionId); @@ -1185,23 +1187,29 @@ export class DesktopRuntimeHostClient { const result = await this.request("session.remove", { sessionId, expectedRevision: current.revision, + ...(options.requireArchivedForMs === undefined + ? {} + : { requireArchivedForMs: options.requireArchivedForMs }), }); if (result.kind === "removed") { return { disposition: "removed", archivedSubtaskCount: result.archivedSubtaskCount ?? 0 }; } + // The Host's clock says it was archived too recently: kept, not failed. + if (result.kind === "too_recent") { + return { disposition: "too_recent", 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. + * 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(input: SessionRemovePreviewInput): Promise { + return this.request("session.remove.preview", input); } 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..f2d455805b 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 @@ -29,6 +29,7 @@ import { RuntimeHostOperationError, projectSessionCatalogSummary } from '@maka/r import type { SessionCatalogProjection, SessionCreateInput, + SessionRemovePreviewInput, WorkspaceTarget, SessionModelTarget, } from '@maka/runtime-host/protocol'; @@ -258,14 +259,17 @@ export function registerRuntimeHostSessionCatalogIpc( // downstream of the deletion runs for it. const outcome = await deps.client.removeSession(sessionId, { requireArchived: requiresArchivedSession(options), + // Shape-checked by the protocol codec, which refuses anything but a + // positive integer rather than dropping the guard. + ...archiveAgeGuard(options), }); 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, input: unknown) => + // Read-only, for the confirm. The protocol codec validates the whole input. + deps.client.previewSessionRemoval(input as SessionRemovePreviewInput), + ); ipcMain.handle( 'sessions:moveToProject', async (_event, sessionId: string, projectId: unknown) => { @@ -331,6 +335,11 @@ async function moveSessionToProject( * destructive answer. It repeats the shape check its sibling does instead of * relying on the caller running that one first. */ +function archiveAgeGuard(options: unknown): { requireArchivedForMs?: number } { + const value = (options as { requireArchivedForMs?: unknown } | undefined)?.requireArchivedForMs; + return value === undefined ? {} : { requireArchivedForMs: value as number }; +} + function requiresArchivedSession(options: unknown): boolean { if (options === undefined) return false; if (!options || typeof options !== 'object' || Array.isArray(options)) { diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 79d014f812..f16dde83b2 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'; @@ -1412,20 +1413,33 @@ 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. `archivedSubtaskCount` - * is the Host's executed count of ordinary linked subtasks moved to the - * archive — 0 when restored or when nothing was archived. + * restored meanwhile answers `restored` and is kept. `requireArchivedForMs` + * adds an age the Host checks on its own clock: a task archived more + * recently answers `too_recent` and is kept. `archivedSubtaskCount` is the + * Host's executed count of ordinary linked subtasks moved to the archive — + * 0 when the task was kept or when nothing was archived. */ remove( sessionId: string, - options?: { revisionFamily?: boolean; requireArchived?: boolean }, - ): Promise<{ disposition: 'removed' | 'restored'; archivedSubtaskCount: number }>; + options?: { revisionFamily?: boolean; requireArchived?: boolean; requireArchivedForMs?: number }, + ): Promise<{ disposition: 'removed' | 'restored' | 'too_recent'; 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; + /** + * What deleting these tasks, one `remove` each, would take with them, per + * each Host's removal plans: linked subtasks archived, Agent Graph subtasks + * and worktrees deleted, and with `measureBytes` 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[], + options?: { measureBytes?: boolean; requireArchived?: boolean }, + ): Promise; cleanupSessionCopy(sessionId: string): Promise; abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; }; diff --git a/apps/desktop/src/preload/host-session-pages.ts b/apps/desktop/src/preload/host-session-pages.ts new file mode 100644 index 0000000000..9bffff50dc --- /dev/null +++ b/apps/desktop/src/preload/host-session-pages.ts @@ -0,0 +1,90 @@ +/* + * 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. + */ + +export interface HostSessionRef { + readonly scope: Scope; + readonly scopeKey: string; + /** The Host-local id. */ + readonly sessionId: string; +} + +export interface HostSessionPage { + readonly scope: Scope; + readonly scopeKey: string; + /** Host-local ids of this page, at most the page size. */ + readonly hostIds: readonly string[]; + /** Host-local id to the Desktop id it was asked by. */ + readonly desktopIds: ReadonlyMap; +} + +export interface HostSessionPaging { + /** Resolves a Desktop session id to its Host scope; rejects when the Host is gone. */ + resolve(sessionId: string): Promise>; + readonly pageSize: number; + /** + * Failure policy. Absent: an unresolvable id or a failed page rejects the + * whole call. Present: such an id is left out, a Host \`skip\` names is not + * asked, and a failed page ends only its own Host's pages. + */ + readonly tolerate?: { + skip(scopeKey: string): boolean; + failed(scopeKey: string, error: unknown): void; + }; +} + +/** + * Groups Desktop session ids by the Host that holds them, each id once, and + * visits each Host's ids in bounded pages, one request at a time per Host. + * Hosts are visited side by side: one slow Host does not hold up another's + * pages, and no Host ever has more than one page in flight. + */ +export async function forEachHostSessionPage( + sessionIds: readonly string[], + paging: HostSessionPaging, + visit: (page: HostSessionPage) => Promise, +): Promise { + const groups = new Map }>(); + for (const sessionId of new Set(sessionIds)) { + let ref: HostSessionRef; + try { + ref = await paging.resolve(sessionId); + } catch (error) { + if (paging.tolerate) continue; + throw error; + } + if (paging.tolerate?.skip(ref.scopeKey)) continue; + const group = groups.get(ref.scopeKey) ?? { scope: ref.scope, desktopIds: new Map() }; + group.desktopIds.set(ref.sessionId, sessionId); + groups.set(ref.scopeKey, group); + } + await Promise.all( + [...groups].map(async ([scopeKey, { scope, desktopIds }]) => { + const ids = [...desktopIds.keys()]; + try { + for (let offset = 0; offset < ids.length; offset += paging.pageSize) { + const hostIds = ids.slice(offset, offset + paging.pageSize); + await visit({ scope, scopeKey, hostIds, desktopIds }); + } + } catch (error) { + if (!paging.tolerate) throw error; + paging.tolerate.failed(scopeKey, error); + } + }), + ); +} diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 441afb06c3..f6f70531b6 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, input) => + invokeWhenReady('sessions:removePreview', scope, input) as Promise< + SessionRemovePreviewResult + >, +}); + async function listScheduledTasks(target?: DesktopRuntimeHostRef): Promise { const host = scopedRuntimeHost(await selectedRuntimeHostScope(target)); for (let attempt = 0; attempt < 3; attempt += 1) { @@ -2696,16 +2709,26 @@ const makaBridge = { }, async remove( sessionId: string, - options?: { revisionFamily?: boolean; requireArchived?: boolean }, - ): Promise<{ disposition: 'removed' | 'restored'; archivedSubtaskCount: number }> { + options?: { + revisionFamily?: boolean; + requireArchived?: boolean; + requireArchivedForMs?: number; + }, + ): Promise<{ disposition: 'removed' | 'restored' | 'too_recent'; archivedSubtaskCount: number }> { const session = await runtimeHostSessionRef(sessionId); if (await invokeWhenReady('session-local:discard', session.scope, session.sessionId)) { return { disposition: 'removed', archivedSubtaskCount: 0 }; } 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[], + options?: { measureBytes?: boolean; requireArchived?: boolean }, + ): Promise { + return previewDesktopSessionRemoval(sessionIds, options); }, 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..38e27611c2 --- /dev/null +++ b/apps/desktop/src/preload/session-removal-preview.ts @@ -0,0 +1,77 @@ +/* + * 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 SessionRemovePreviewInput, + type SessionRemovePreviewResult, +} from '@maka/runtime-host/protocol'; +import { forEachHostSessionPage, type HostSessionRef } from './host-session-pages.js'; + +export type SessionRemovalPreviewOptions = Omit; + +export interface SessionRemovalPreviewRouting { + /** Resolves a Desktop session id to its Host scope; rejects when the Host is gone. */ + resolve(sessionId: string): Promise>; + /** One `session.remove.preview` page against one Host. */ + query(scope: Scope, input: SessionRemovePreviewInput): Promise; +} + +export type SessionRemovalPreviewReader = ( + sessionIds: readonly string[], + options?: SessionRemovalPreviewOptions, +) => Promise; + +/** + * What deleting a set of tasks would remove, summed over bounded pages per + * Host. Each page deduplicates what its own targets share; the archived task + * rows a Client selects are distinct tasks, so pages do not overlap. + * + * 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 others. Bytes are + * reported only when every page measured them. + */ +export function createSessionRemovalPreviewReader( + routing: SessionRemovalPreviewRouting, +): SessionRemovalPreviewReader { + return async (sessionIds, options = {}) => { + let archivableSubtaskCount = 0; + let removedSubtaskCount = 0; + let worktreeCount = 0; + let bytes: number | undefined = 0; + await forEachHostSessionPage( + sessionIds, + { resolve: routing.resolve, pageSize: SESSION_REMOVE_PREVIEW_MAX_ITEMS }, + async ({ scope, hostIds }) => { + const page = await routing.query(scope, { ...options, sessionIds: [...hostIds] }); + archivableSubtaskCount += page.archivableSubtaskCount; + removedSubtaskCount += page.removedSubtaskCount; + worktreeCount += page.worktreeCount; + bytes = bytes === undefined || page.bytes === undefined ? undefined : bytes + page.bytes; + }, + ); + return { + archivableSubtaskCount, + removedSubtaskCount, + worktreeCount, + ...(bytes === undefined || !options.measureBytes ? {} : { bytes }), + }; + }; +} diff --git a/apps/desktop/src/preload/session-storage-usage.ts b/apps/desktop/src/preload/session-storage-usage.ts index ee7c7de049..ac9d033df7 100644 --- a/apps/desktop/src/preload/session-storage-usage.ts +++ b/apps/desktop/src/preload/session-storage-usage.ts @@ -21,14 +21,11 @@ import { STORAGE_USAGE_SESSION_MAX_ITEMS, type SessionStorageUsage, } from '@maka/runtime-host/protocol'; +import { forEachHostSessionPage, type HostSessionRef } from './host-session-pages.js'; export interface SessionStorageUsageRouting { /** 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; - }>; + resolve(sessionId: string): Promise>; /** One `storage.usage.sessions.query` against one Host. */ query(scope: Scope, sessionIds: readonly string[]): Promise; } @@ -62,54 +59,26 @@ export function createSessionStorageUsageReader( failedScopes.delete(scopeKey); return false; }; - return (sessionIds) => - loadSessionStorageUsage(sessionIds, routing, { - skip: isCoolingDown, - failed: (scopeKey) => failedScopes.set(scopeKey, now()), - }); -} - -async function loadSessionStorageUsage( - sessionIds: readonly string[], - routing: SessionStorageUsageRouting, - hosts: { skip(scopeKey: string): boolean; failed(scopeKey: string): void }, -): Promise> { - const byScope = new Map }>(); - for (const sessionId of new Set(sessionIds)) { - let ref: Awaited['resolve']>>; - try { - ref = await routing.resolve(sessionId); - } catch { - continue; - } - if (hosts.skip(ref.scopeKey)) continue; - const group = byScope.get(ref.scopeKey) ?? { - scope: ref.scope, - desktopIds: new Map(), - }; - group.desktopIds.set(ref.sessionId, sessionId); - byScope.set(ref.scopeKey, group); - } - const usage: Record = {}; - await Promise.all( - [...byScope.entries()].map(async ([scopeKey, { scope, desktopIds }]) => { - const hostIds = [...desktopIds.keys()]; - try { - for (let offset = 0; offset < hostIds.length; offset += STORAGE_USAGE_SESSION_MAX_ITEMS) { - const sessions = await routing.query( - scope, - hostIds.slice(offset, offset + STORAGE_USAGE_SESSION_MAX_ITEMS), - ); - for (const session of sessions) { - const desktopId = desktopIds.get(session.sessionId); - if (desktopId) usage[desktopId] = session; - } - } - } catch { + return async (sessionIds) => { + const usage: Record = {}; + await forEachHostSessionPage( + sessionIds, + { + resolve: routing.resolve, + pageSize: STORAGE_USAGE_SESSION_MAX_ITEMS, // This Host's remaining tasks stay unknown; the other Hosts still answer. - hosts.failed(scopeKey); - } - }), - ); - return usage; + tolerate: { + skip: isCoolingDown, + failed: (scopeKey) => failedScopes.set(scopeKey, now()), + }, + }, + async ({ scope, hostIds, desktopIds }) => { + for (const session of await routing.query(scope, hostIds)) { + const desktopId = desktopIds.get(session.sessionId); + if (desktopId) usage[desktopId] = session; + } + }, + ); + return usage; + }; } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index a9da03d28c..12f363ef90 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -912,7 +912,6 @@ function AppShellContent({ projects, projectCapabilities, activeProjectCapabilities, - localProjects, currentProjectId, currentProject, projectPickerPendingRef, @@ -1098,15 +1097,10 @@ function AppShellContent({ const archivedTasksBridge = useMemo( () => ({ catalog: sessionCatalogController, - projects: localProjects, - onRestore: (sessionId) => - void sessionNavigationCommandsRef.current?.unarchiveSession(sessionId), - onDelete: (sessionId) => - void sessionNavigationCommandsRef.current?.deleteSession(sessionId), - onPurge: (sessionIds) => - sessionNavigationCommandsRef.current!.purgeSessions(sessionIds), + projectScopes: taskEntry.selectors.projectScopes, + commands: sessionNavigationCommandsRef, }), - [sessionCatalogController, localProjects], + [sessionCatalogController, taskEntry.selectors.projectScopes], ); const { applyE2eFixture } = useStableActions(createAppShellE2eFixtureActions, { 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 479f6edfcc..028270b040 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 @@ -20,6 +20,10 @@ import type { SessionSummary } from '@maka/core/session'; import type { UiLocale } from '@maka/core/ui-locale'; import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +import { getSettingsSharedCopy } from '../../../locales/settings-shared-copy.js'; +import { getSettingsTasksCopy } from '../../../locales/settings-tasks-copy.js'; +import { formatBytes } from '@maka/ui'; +import type { SessionRemovePreviewResult } from '@maka/runtime-host/protocol'; import { revisionFamilySessionIds } from '@maka/core/session-revisions'; import type { RefObject } from 'react'; import type { @@ -48,6 +52,8 @@ export interface SessionPurgeOutcome { * failed: the deletion was called off because its premise was gone. */ restored: string[]; + /** Tasks the Host kept because, by its clock, they were archived too recently. */ + tooRecent: string[]; verified: boolean; /** First rejection and the Session whose Host produced it. */ firstFailure?: { @@ -74,6 +80,17 @@ interface SessionArchiveOutcome { }; } +export interface ArchivedPurgeRequest { + /** Frozen at the click: what is previewed is exactly what is deleted. */ + readonly sessionIds: readonly string[]; + /** A search or filter narrowed the list, which changes the confirm's title. */ + readonly narrowed: boolean; + /** An age filter's threshold, which the Host enforces on its own clock. */ + readonly requireArchivedForMs?: number; + /** False once the page closed or its scope changed: a late preview asks nothing. */ + isCurrent(): boolean; +} + export interface SessionNavigationRowActions { flagSession(sessionId: string, flagged: boolean): Promise; archiveSession(sessionId: string): Promise; @@ -85,7 +102,15 @@ export interface SessionNavigationRowActions { */ moveSessionToProject(sessionId: string, projectId: string | null): Promise; deleteSession(sessionId: string): Promise; - purgeSessions(sessionIds: readonly string[]): Promise; + purgeSessions( + sessionIds: readonly string[], + options?: { requireArchivedForMs?: number }, + ): Promise; + /** + * Settings › Archived tasks' bulk delete, as one flow the way a single + * delete is: preview, confirm, sweep, report. + */ + purgeArchived(request: ArchivedPurgeRequest): Promise; /** Sweeps and reports — the rail's own wording. */ archiveSelected(sessionIds: readonly string[]): Promise; /** Pins or unpins a picked set in one sweep. */ @@ -113,6 +138,7 @@ export function createSessionNavigationRowActions(deps: { toastApi, } = deps; const copy = getShellCopy(uiLocale).sessionRowActions; + const tasksCopy = getSettingsTasksCopy(uiLocale); async function withAutomaticQueryBlockOn( sessionIds: readonly string[], @@ -254,16 +280,16 @@ export function createSessionNavigationRowActions(deps: { */ async function removeSessionFamily( sessionId: string, - options: { requireArchived: boolean }, + options: { requireArchived: boolean; requireArchivedForMs?: number }, ): 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 outcome = await service.remove(sessionId, { revisionFamily: true, - requireArchived: options.requireArchived, + ...options, }); - if (outcome.disposition === 'restored') return outcome; + if (outcome.disposition !== 'removed') return outcome; for (const id of familyIds) clearSessionRendererState(id); return outcome; } @@ -295,9 +321,13 @@ export function createSessionNavigationRowActions(deps: { * No confirm and no toast: the caller owns the wording for a sweep, which is * the one thing single-row delete cannot phrase. */ - async function purgeSessions(sessionIds: readonly string[]): Promise { + async function purgeSessions( + sessionIds: readonly string[], + options: { requireArchivedForMs?: number } = {}, + ): Promise { const unsettled: string[] = []; const restored: string[] = []; + const tooRecent: string[] = []; let firstFailure: SessionPurgeOutcome['firstFailure']; let removed = 0; let archivedSubtasks = 0; @@ -315,8 +345,10 @@ export function createSessionNavigationRowActions(deps: { try { const { disposition, archivedSubtaskCount } = await removeSessionFamily(sessionId, { requireArchived: true, + ...options, }); if (disposition === 'restored') restored.push(sessionId); + else if (disposition === 'too_recent') tooRecent.push(sessionId); else { removed += 1; archivedSubtasks += archivedSubtaskCount; @@ -335,6 +367,7 @@ export function createSessionNavigationRowActions(deps: { archivedSubtasks, remaining: [], restored, + tooRecent, verified: true, firstFailure, }; @@ -352,6 +385,7 @@ export function createSessionNavigationRowActions(deps: { archivedSubtasks, remaining: [], restored, + tooRecent, verified: false, firstFailure, }; @@ -363,11 +397,96 @@ export function createSessionNavigationRowActions(deps: { archivedSubtasks, remaining, restored, + tooRecent, verified: true, firstFailure, }; } + async function purgeArchived(request: ArchivedPurgeRequest): Promise { + // Frozen here: what the confirm counts is exactly what the sweep deletes. + const sessionIds = [...request.sessionIds]; + // Measured, and only archived targets, as the sweep will find them. A + // failed preview is not silence: the confirm falls back to the note every + // bulk delete carries, as a single delete does. + let preview: SessionRemovePreviewResult | undefined; + try { + preview = await service.previewRemovals(sessionIds, { + measureBytes: true, + requireArchived: true, + }); + } catch { + preview = undefined; + } + if (!request.isCurrent()) return; + const count = sessionIds.length; + const ok = await toastApi.confirm({ + title: request.narrowed + ? tasksCopy.purgeShownConfirmTitle(count) + : tasksCopy.purgeAllConfirmTitle(count), + description: [tasksCopy.purgeConfirmBody, ...purgePreviewNotes(preview)].join(' '), + confirmLabel: tasksCopy.purgeConfirmAction, + cancelLabel: getSettingsSharedCopy(uiLocale).cancel, + destructive: true, + }); + if (!ok) return; + const outcome = await purgeSessions( + sessionIds, + request.requireArchivedForMs === undefined + ? {} + : { requireArchivedForMs: request.requireArchivedForMs }, + ); + reportPurge(outcome); + } + + /** Only the figures the Host stated, each clause only when it is not zero. */ + function purgePreviewNotes(preview: SessionRemovePreviewResult | undefined): string[] { + if (!preview) return [tasksCopy.purgeSubtaskNote]; + const notes: string[] = []; + if (preview.removedSubtaskCount > 0) { + notes.push(tasksCopy.purgeGraphSubtaskNote(preview.removedSubtaskCount)); + } + if (preview.worktreeCount > 0) notes.push(tasksCopy.purgeWorktreeNote(preview.worktreeCount)); + if (preview.archivableSubtaskCount > 0) { + notes.push(tasksCopy.purgeArchivableNote(preview.archivableSubtaskCount)); + } + if (preview.bytes) notes.push(tasksCopy.purgeSizeNote(formatBytes(preview.bytes, uiLocale))); + return notes; + } + + /** + * The person agreed to a number, so a sweep that lands on a smaller one owes + * them the whole account. Kept tasks and failures are independent — + * reporting one and dropping the other is how a count stops adding up. + */ + function reportPurge(outcome: SessionPurgeOutcome): void { + const detail = [ + outcome.archivedSubtasks > 0 ? tasksCopy.purgedSubtaskNote(outcome.archivedSubtasks) : '', + outcome.restored.length > 0 ? tasksCopy.purgeKeptRestored(outcome.restored.length) : '', + outcome.tooRecent.length > 0 ? tasksCopy.purgeKeptTooRecent(outcome.tooRecent.length) : '', + ].filter(Boolean); + if (outcome.verified && outcome.remaining.length === 0) { + toastApi.success( + tasksCopy.purgedToast(outcome.removed), + detail.length > 0 ? detail.join(' ') : undefined, + ); + return; + } + // 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. + const reason = !outcome.verified + ? tasksCopy.purgeUnverified + : outcome.firstFailure + ? localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, uiLocale) + : tasksCopy.purgeFailedBody(outcome.remaining.length); + toastApi.error( + tasksCopy.purgeFailedTitle, + [reason, ...detail].join(' '), + undefined, + outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, + ); + } + /** * The rail's multi-select archive. * @@ -527,6 +646,7 @@ export function createSessionNavigationRowActions(deps: { moveSessionToProject, deleteSession, purgeSessions, + purgeArchived, archiveSelected, flagSelected, }; diff --git a/apps/desktop/src/renderer/features/session-navigation/index.ts b/apps/desktop/src/renderer/features/session-navigation/index.ts index 0a526a0f92..830e105ef1 100644 --- a/apps/desktop/src/renderer/features/session-navigation/index.ts +++ b/apps/desktop/src/renderer/features/session-navigation/index.ts @@ -20,14 +20,15 @@ export { SessionNavigationServicesProvider } from './services-context.js'; export { SessionNavigationProvider } from './ui/session-navigation-provider.js'; export { SessionHistoryNavigation } from './ui/session-history-navigation.js'; +export { + ArchivedTaskScope, + type ArchivedTaskScopeView, +} from './ui/archived-task-scope.js'; +export type { ArchivedTaskProjectScope } from './model/archived-task-scope.js'; export { createSessionOpenCommand } from './controller/session-open-command.js'; export { useSessionNavigationReads } from './controller/use-session-navigation-reads.js'; -export { deriveSessionRail } from './model/session-rail.js'; export { sessionRailLayoutStore } from './model/session-rail-layout-store.js'; -export type { - SessionNavigationRowActions, - SessionPurgeOutcome, -} from './controller/session-row-actions.js'; +export type { SessionNavigationRowActions } from './controller/session-row-actions.js'; export type { SessionNavigationPorts, SessionNavigationServices, diff --git a/apps/desktop/src/renderer/features/session-navigation/model/archived-task-scope.ts b/apps/desktop/src/renderer/features/session-navigation/model/archived-task-scope.ts new file mode 100644 index 0000000000..2ed7eba7f0 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/model/archived-task-scope.ts @@ -0,0 +1,261 @@ +/* + * 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'; +import { runtimeHostProjectKey } from '../../../application/contracts/runtime-host-project-key.js'; +import { deriveSessionRail } from './session-rail.js'; + +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. `null` is a task with no project. + * `undefined` is a project no known Host scope names: the row says nothing + * about it, so no project filter claims it and only "All projects" shows it. + */ +export type ArchivedTaskProject = + | { readonly key: string; readonly label: string; readonly hostLabel: 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' } }; + +/** A Project together with the Runtime Host that owns its identity, as the rail sees it. */ +export interface ArchivedTaskProjectScope { + /** `runtimeHostProjectKey(hostId, project.id)`. */ + readonly key: string; + readonly hostId: string; + readonly profileName: string; + readonly project: { + readonly id: string; + readonly name: string; + readonly aliases?: readonly string[]; + }; +} + +/** + * The archived tasks, counted the way the rail counts tasks. + * + * `sessions.list()` returns the physical session catalog, which is not the set + * of things a person calls a task: edit-and-resend produces one session per + * revision, and a linked subagent session belongs to the task that spawned it. + * `deriveSessionRail` already owns both rules — including the one that is easy + * to get wrong on a second pass, that a linked child whose parent is gone stays + * a row of its own instead of vanishing from every surface at once. Reusing it + * is what makes a row here mean what a row there means; a second projection + * would only mean it approximately. + * + * No active session is passed. The rail passes one so it can highlight the row + * you are on, but that also pins a family's representative to whichever + * revision happens to be open — which would move a row's name, date and + * position here for a reason this page never shows. The rail additionally hides + * in-flight companion forks, another property of its own view rather than of + * the archived catalog. + * + * Rows are then ordered by when they were archived, most recent first: this + * page is where you look for what you just put away. A task archived before + * the Host recorded the time has no place in that order, so those go last, + * and any tie keeps the rail's store order (`sort` is stable). + */ +export function archivedTaskRows(sessions: readonly T[]): T[] { + const rows = deriveSessionRail(sessions, undefined, (session) => session.isArchived).sessions; + return [...rows].sort((a, b) => (b.archivedAt ?? -1) - (a.archivedAt ?? -1)); +} + +/** + * Resolves a task's project the way the rail groups it: by the Host that owns + * the id, through that Project's aliases. Equal ids on two Hosts are two + * projects, and a task filed under a retired alias lands on its project. + */ +export function archivedTaskProjectResolver( + scopes: readonly ArchivedTaskProjectScope[], +): (session: SessionSummary & { readonly runtimeHostId: string }) => ArchivedTaskProject { + const byIdentity = new Map(); + for (const scope of scopes) { + for (const id of [scope.project.id, ...(scope.project.aliases ?? [])]) { + byIdentity.set(runtimeHostProjectKey(scope.hostId, id), scope); + } + } + return (session) => { + if (!session.projectId) return null; + const scope = byIdentity.get(runtimeHostProjectKey(session.runtimeHostId, session.projectId)); + return scope && { key: scope.key, label: scope.project.name, hostLabel: scope.profileName }; + }; +} + +export interface ArchivedTaskScopeContext { + readonly now: number; + readonly projectOf: (session: T) => ArchivedTaskProject; + /** What the row shows beside the name, so search answers to it. */ + readonly labelOf: (session: T) => string | undefined; +} + +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 age a bulk delete asks the Host to hold, on its own clock, or nothing + * when no age filter is on. The filter above runs on this machine's clock and + * only decides what is shown; the Host has the final say. + */ +export function archivedAgeThresholdMs(scope: ArchivedTaskScope): number | undefined { + return scope.minAgeDays === undefined ? undefined : scope.minAgeDays * DAY_MS; +} + +/** + * 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 threshold = archivedAgeThresholdMs(scope); + const visible: T[] = []; + let unknownArchiveTime = 0; + for (const session of rows) { + if (!matchesProject(context.projectOf(session), scope.project)) continue; + if (!matchesArchivedTaskQuery(session, scope.query, context.labelOf(session))) continue; + if (threshold !== undefined) { + if (session.archivedAt === undefined) { + unknownArchiveTime += 1; + continue; + } + if (context.now - session.archivedAt <= threshold) 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. Two projects of + * the same name on different Hosts stay two entries, told apart by Host. + */ +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); + } + const labels = [...projects.values()].map((project) => project.label); + return { + projects: [...projects] + .map(([key, { label, hostLabel }]) => ({ + key, + label: + labels.indexOf(label) === labels.lastIndexOf(label) ? label : `${label} · ${hostLabel}`, + })) + .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; +} diff --git a/apps/desktop/src/renderer/features/session-navigation/ports.ts b/apps/desktop/src/renderer/features/session-navigation/ports.ts index e795003213..faf9225b5e 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ports.ts +++ b/apps/desktop/src/renderer/features/session-navigation/ports.ts @@ -21,14 +21,16 @@ import type { RefObject } from 'react'; import type { SessionSummary } from '@maka/core/session'; import type { ProjectRecord } from '@maka/core/project'; import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind'; +import type { SessionRemovePreviewResult } from '@maka/runtime-host/protocol'; import type { DesktopSessionUpdateFailureCode } from '../../../shared/desktop-session-projection.js'; -export type SessionNavigationRemoveDisposition = 'removed' | 'restored'; +/** `restored` and `too_recent` both mean the task was kept, not that it failed. */ +export type SessionNavigationRemoveDisposition = 'removed' | 'restored' | 'too_recent'; /** * 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. + * called off — so the toast reports a fact, not a renderer guess. */ export interface SessionNavigationRemoveOutcome { readonly disposition: SessionNavigationRemoveDisposition; @@ -98,7 +100,7 @@ export interface SessionNavigationSessionService { ): Promise; remove( sessionId: string, - options: { revisionFamily: true; requireArchived: boolean }, + options: { revisionFamily: true; requireArchived: boolean; requireArchivedForMs?: number }, ): Promise; /** * How many linked subtasks a delete of this parent would move to the archive, @@ -106,6 +108,14 @@ export interface SessionNavigationSessionService { * estimating from the catalog projection. */ previewRemoval(sessionId: string): Promise; + /** + * The same plan for a set of tasks, paged per Host, with the Agent Graph + * subtasks and worktrees it deletes and, when asked, the bytes it holds. + */ + previewRemovals( + sessionIds: readonly string[], + options: { measureBytes: boolean; requireArchived: boolean }, + ): Promise; /** * Re-file one task under another project, or out of every project (`null`). * Settles as an outcome rather than throwing for the expected refusals, so diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index d74709c006..22db18fbd1 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -41,10 +41,26 @@ export { type UseSessionNavigationControllerInput, } from './controller/use-session-navigation-controller.js'; export { useSessionSelection } from './controller/use-session-selection.js'; -export type { SessionNavigationRowActions } from './controller/session-row-actions.js'; +export type { + ArchivedPurgeRequest, + SessionNavigationRowActions, +} from './controller/session-row-actions.js'; export { useSessionNavigationReads } from './controller/use-session-navigation-reads.js'; export { SessionNavigationProvider } from './ui/session-navigation-provider.js'; export { sessionMatchesRail } from './model/session-nav-filter.js'; +export { + archivedAgeThresholdMs, + archivedProjectOptions, + archivedTaskProjectResolver, + archivedTaskRows, + availableProjectFilter, + isArchivedTaskScopeNarrowed, + matchesArchivedTaskQuery, + scopeArchivedTasks, + UNSCOPED_ARCHIVED_TASKS, + type ArchivedTaskScope, +} from './model/archived-task-scope.js'; +export { ArchivedTaskScope as ArchivedTaskScopeSurface } from './ui/archived-task-scope.js'; export { deriveSessionRail } from './model/session-rail.js'; export { deriveSessionNavigationGroups } from './model/session-navigation-groups.js'; export { sessionMoveTargets } from './model/session-navigation-move-targets.js'; @@ -73,6 +89,11 @@ export function createFakeSessionNavigationServices( rename: async () => undefined, remove: async () => ({ disposition: 'removed', archivedSubtaskCount: 0 }), previewRemoval: async () => 0, + previewRemovals: async () => ({ + archivableSubtaskCount: 0, + removedSubtaskCount: 0, + worktreeCount: 0, + }), moveToProject: async () => ({ ok: true }), }, ...overrides, diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/archived-task-scope.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/archived-task-scope.tsx new file mode 100644 index 0000000000..4d6d3bac6a --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/ui/archived-task-scope.tsx @@ -0,0 +1,214 @@ +/* + * 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, useRef, useState } from 'react'; +import { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile-kind'; +import { Button, useMountedRef, useUiLocale } from '@maka/ui'; +import { Search } from '@maka/ui/icons'; +import { HStack, StackItem, VStack } from '@astryxdesign/core'; +import { Selector } from '@astryxdesign/core/Selector'; +import { Text } from '@astryxdesign/core/Text'; +import { TextInput } from '@astryxdesign/core/TextInput'; +import { getSettingsTasksCopy } from '../../../locales/settings-tasks-copy.js'; +import type { SessionNavigationRowActions } from '../controller/session-row-actions.js'; +import { + ARCHIVED_AGE_DAYS, + type ArchivedProjectFilter, + type ArchivedTaskProjectScope, + type ArchivedTaskScope as Scope, + archivedAgeThresholdMs, + archivedProjectOptions, + archivedTaskProjectResolver, + archivedTaskRows, + availableProjectFilter, + isArchivedTaskScopeNarrowed, + scopeArchivedTasks, + UNSCOPED_ARCHIVED_TASKS, +} from '../model/archived-task-scope.js'; +import type { SessionNavigationSession } from '../ports.js'; + +const ANY_AGE = 'any'; +const ALL_PROJECTS = '*all'; +const NO_PROJECT = '*none'; +const PROJECT_PREFIX = '='; + +export interface ArchivedTaskScopeView { + /** Every archived task row, before any filter. */ + readonly rows: readonly T[]; + /** The rows on screen — exactly the set the bulk delete removes. */ + readonly visible: readonly T[]; + readonly purging: boolean; + /** Search, filters and the bulk delete, for the page to place above its list. */ + readonly controls: ReactNode; + /** What a row shows as its project, which is also what search matches. */ + projectLabelOf(session: T): string | undefined; +} + +/** + * Settings › Archived tasks' scope: search, the age and project filters, and + * the one bulk delete, which always acts on exactly the rows on screen. It + * owns that state so the page, a legacy Settings surface, holds none; the page + * renders the rows this hands it. + * + * The delete itself is the rail's `purgeArchived` flow — preview, confirm, + * sweep, report — run on the ids frozen at the click. A preview that settles + * after the page closed or the scope changed opens no dialog. + */ +export function ArchivedTaskScope(props: { + readonly sessions: readonly T[]; + readonly projectScopes: readonly ArchivedTaskProjectScope[]; + readonly commands: { readonly current: SessionNavigationRowActions | null }; + readonly children: (view: ArchivedTaskScopeView) => ReactNode; +}) { + const copy = getSettingsTasksCopy(useUiLocale()); + const mountedRef = useMountedRef(); + const [scope, setScope] = useState(UNSCOPED_ARCHIVED_TASKS); + const [purging, setPurging] = useState(false); + // The click a pending confirm belongs to; any scope change retires it. + const pendingPurgeRef = useRef(null); + + const rows = useMemo(() => archivedTaskRows(props.sessions), [props.sessions]); + const projectOf = useMemo( + () => archivedTaskProjectResolver(props.projectScopes), + [props.projectScopes], + ); + // A Host-workspace task is labelled by its Host, as it always was here. + const projectLabelOf = (session: T): string | undefined => { + if (runtimeHostProfileUsesHostWorkspace(session.profileKind)) return session.profileName; + const project = projectOf(session); + return project === null ? copy.noProject : project?.label; + }; + const projectOptions = useMemo(() => archivedProjectOptions(rows, projectOf), [projectOf, rows]); + const effective = { ...scope, project: availableProjectFilter(scope.project, projectOptions) }; + const narrowed = isArchivedTaskScopeNarrowed(effective); + const { visible, unknownArchiveTime } = scopeArchivedTasks(rows, effective, { + now: Date.now(), + projectOf, + labelOf: projectLabelOf, + }); + + const updateScope = (patch: Partial) => { + pendingPurgeRef.current = null; + setScope((current) => ({ ...current, ...patch })); + }; + + async function purge() { + const commands = props.commands.current; + if (!commands) return; + const token = {}; + pendingPurgeRef.current = token; + const requireArchivedForMs = archivedAgeThresholdMs(effective); + setPurging(true); + try { + await commands.purgeArchived({ + sessionIds: visible.map((session) => session.id), + narrowed, + ...(requireArchivedForMs === undefined ? {} : { requireArchivedForMs }), + isCurrent: () => mountedRef.current && pendingPurgeRef.current === token, + }); + } finally { + if (mountedRef.current) setPurging(false); + } + } + + const controls = ( + + {/* Search and the delete button share one row: as a section action the + button landed a full 32px page rhythm below the box. */} + + + updateScope({ query })} + startIcon={Search} + hasClear + width="100%" + /> + + {/* 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. */} +