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 abf5d9039a2..fc0033f4d6c 100644 --- a/apps/desktop/src/main/__tests__/task-catalog-rows.test.ts +++ b/apps/desktop/src/main/__tests__/task-catalog-rows.test.ts @@ -107,6 +107,49 @@ describe('archivedTaskRows', () => { }); }); +describe('archived task order', () => { + /** + * Store order here is deliberately none of the orders under test: not + * archive order, not activity order, and the two unknown rows sit on either + * side of known ones. Only an archive-time sort with unknowns last and a + * stable tie-break yields the expected list. + */ + it('puts the most recently archived first, unknown times last, ties in store order', () => { + const sessions = [ + summary('unknown-a', { isArchived: true, lastMessageAt: 900 }), + summary('older', { isArchived: true, lastMessageAt: 800, archivedAt: 1_000 }), + summary('tie-first', { isArchived: true, lastMessageAt: 100, archivedAt: 3_000 }), + summary('unknown-b', { isArchived: true, lastMessageAt: 50 }), + summary('newest', { isArchived: true, lastMessageAt: 10, archivedAt: 5_000 }), + summary('tie-second', { isArchived: true, lastMessageAt: 700, archivedAt: 3_000 }), + ]; + + assert.deepEqual( + archivedTaskRows(sessions).map((session) => session.id), + ['newest', 'tie-first', 'tie-second', 'older', 'unknown-a', 'unknown-b'], + ); + }); + + it('orders a revision family by the archive time of the row that represents it', () => { + const sessions = [ + summary('solo', { isArchived: true, lastMessageAt: 900, archivedAt: 2_000 }), + summary('v1', { isArchived: true, lastMessageAt: 100, archivedAt: 4_000 }), + summary('v2', { + isArchived: true, + lastMessageAt: 200, + archivedAt: 4_000, + revisionRootSessionId: 'v1', + revisionParentSessionId: 'v1', + }), + ]; + + assert.deepEqual( + archivedTaskRows(sessions).map((session) => session.id), + ['v2', 'solo'], + ); + }); +}); + describe('matchesArchivedTaskQuery', () => { const projectLabelOf = (session: SessionSummary) => session.projectId === 'p1' ? 'astryx-design' : undefined; diff --git a/apps/desktop/src/renderer/locales/settings-tasks-copy.ts b/apps/desktop/src/renderer/locales/settings-tasks-copy.ts index 721e53cf21a..ee033e0affe 100644 --- a/apps/desktop/src/renderer/locales/settings-tasks-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-tasks-copy.ts @@ -28,6 +28,10 @@ export type SettingsTasksCopy = { listAria: string; noProject: string; deletedParent: string; + /** Row detail: when the task was archived, from a compact timestamp. */ + archivedAt(when: string): string; + /** Row detail for a task archived before the time was recorded. */ + archiveTimeUnknown: string; searchLabel: string; purgeAll: string; purgeMatches(count: number): string; @@ -64,6 +68,8 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = { listAria: '已归档任务', noProject: '无项目', deletedParent: '原父任务已删除', + archivedAt: (when: string) => `${when}归档`, + archiveTimeUnknown: '归档时间未知', searchLabel: '搜索已归档任务', purgeAll: '清空全部', purgeMatches: (count: number) => `删除这 ${count} 条`, @@ -91,6 +97,8 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = { listAria: '已歸檔任務', noProject: '無專案', deletedParent: '原父任務已刪除', + archivedAt: (when: string) => `${when}歸檔`, + archiveTimeUnknown: '歸檔時間未知', searchLabel: '搜尋已歸檔任務', purgeAll: '清空全部', purgeMatches: (count: number) => `刪除這 ${count} 條`, @@ -118,6 +126,8 @@ const SETTINGS_TASKS_COPY_BY_LOCALE = { listAria: 'Archived tasks', noProject: 'No project', deletedParent: 'Parent task deleted', + archivedAt: (when: string) => `Archived ${when}`, + archiveTimeUnknown: 'Archive time unknown', searchLabel: 'Search archived tasks', purgeAll: 'Clear all', purgeMatches: (count: number) => (count === 1 ? 'Delete this 1' : `Delete these ${count}`), diff --git a/apps/desktop/src/renderer/settings/task-catalog-rows.ts b/apps/desktop/src/renderer/settings/task-catalog-rows.ts index b669956500d..473b3dc3758 100644 --- a/apps/desktop/src/renderer/settings/task-catalog-rows.ts +++ b/apps/desktop/src/renderer/settings/task-catalog-rows.ts @@ -38,9 +38,15 @@ import { deriveSessionRail } from '../features/session-navigation/index.js'; * 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[] { - return deriveSessionRail(sessions, undefined, (session) => session.isArchived).sessions; + const rows = deriveSessionRail(sessions, undefined, (session) => session.isArchived).sessions; + return [...rows].sort((a, b) => (b.archivedAt ?? -1) - (a.archivedAt ?? -1)); } /** Whether an archived row remains because its ordinary parent task was deleted. */ diff --git a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx index a5c71f2bcc0..5c5a1fd84c2 100644 --- a/apps/desktop/src/renderer/settings/tasks-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/tasks-settings-page.tsx @@ -105,8 +105,7 @@ export function TasksSettingsPage( [copy.noProject, projectNames], ); - // Store order is already recency-first with a stable id tie-break, and the - // projection preserves it, so there is nothing left to sort here. + // Most recently archived first; `archivedTaskRows` owns the order. const archived = useMemo(() => archivedTaskRows(props.sessions), [props.sessions]); const knownSessionIds = useMemo( () => new Set(props.sessions.map((session) => session.id)), @@ -221,8 +220,9 @@ export function TasksSettingsPage( ) : ( {visible.map((session) => { + const now = Date.now(); const updated = session.lastMessageAt - ? formatCompactTimestamp(session.lastMessageAt, Date.now(), locale) + ? formatCompactTimestamp(session.lastMessageAt, now, locale) : undefined; const description = [ isOrphanedSubagentTask(session, knownSessionIds) @@ -230,6 +230,11 @@ export function TasksSettingsPage( : undefined, projectLabelOf(session), updated, + // An unknown time is said as such, never borrowed from the + // last message, which the row already shows as its own fact. + session.archivedAt === undefined + ? copy.archiveTimeUnknown + : copy.archivedAt(formatCompactTimestamp(session.archivedAt, now, locale)), ] .filter(Boolean) .join(' · '); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 993f02924ee..fd18305681e 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -372,6 +372,17 @@ export interface SessionSummary { name: string; isFlagged: boolean; isArchived: boolean; + /** + * When the Session last entered the archive — display metadata, never the + * archive state. Whether a Session is archived is `isArchived` and nothing + * else: this field is also absent for a Session archived before the Host + * recorded the time, so `archivedAt === undefined` must never be read as + * "not archived". (Projects do use `archivedAt` presence as their archive + * state; Sessions deliberately do not, after #3074 removed a session + * `archivedAt` that competed with `isArchived` as a second authority.) + * An unknown time is unknown, not derivable from any other timestamp. + */ + archivedAt?: number; labels: string[]; hasUnread: boolean; /** Host-owned recency, including creation before the first message; present on catalog rows. */ diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 48516f4ea23..3e4a90dab71 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -61,6 +61,7 @@ import { HostWorkspaceResolver } from '../server/workspace-resolver.js'; import { HostSessionCatalogCoordinator, NoUsableImportModelError, + projectSessionCatalogRecord, SessionOperationFailure, WorkHubDefaultModelRequiredError, type HostSessionCatalogCoordinatorOptions, @@ -2631,3 +2632,25 @@ test('reselecting the persisted executor model still confirms the Agent state an assert.equal(fixture.header().executorConfig?.model, 'same'); } }); + +test('projects the archive time of an archived Session and drops one on an active Session', () => { + const archivedHeader = { ...sessionHeader('archived', []), isArchived: true }; + const archived = catalogRecord(archivedHeader, 2); + const projected = projectSessionCatalogRecord({ + ...archived, + summary: { ...archived.summary, archivedAt: 1_234 }, + }); + if ('kind' in projected) assert.fail('Expected a supported Session projection'); + assert.equal(projected.archivedAt, 1_234); + + // A time left on an active row must not turn the task into an + // unsupported-record placeholder. + const active = catalogRecord(sessionHeader('active', []), 2); + const stray = projectSessionCatalogRecord({ + ...active, + summary: { ...active.summary, archivedAt: 1_234 }, + }); + if ('kind' in stray) assert.fail('Expected a supported Session projection'); + assert.equal(stray.isArchived, false); + assert.equal(Object.hasOwn(stray, 'archivedAt'), false); +}); diff --git a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts index 1df27371174..db411a174ef 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts @@ -181,6 +181,29 @@ describe('Session catalog protocol', () => { } }); + test('carries an optional archive time only on an archived Session', () => { + const archived = { ...projection(), isArchived: true, archivedAt: 1_700_000_000_000 }; + assert.deepEqual(decodeSessionCatalogItem(archived), archived); + + // Absent means the Host does not know when the Session was archived. + const unknown = { ...projection(), isArchived: true }; + const decoded = decodeSessionCatalogItem(unknown); + assert.deepEqual(decoded, unknown); + assert.equal(Object.hasOwn(decoded, 'archivedAt'), false); + + for (const archivedAt of ['1700000000000', -1, 1.5, null, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws( + () => decodeSessionCatalogItem({ ...projection(), isArchived: true, archivedAt }), + isProtocolError, + `archivedAt ${String(archivedAt)} must be rejected`, + ); + } + assert.throws( + () => decodeSessionCatalogItem({ ...projection(), isArchived: false, archivedAt: 1 }), + isProtocolError, + ); + }); + test('bounds the live running-turn collection explicitly', () => { const atLimit = Array.from( { length: SESSION_CATALOG_RUNNING_TURN_MAX_ITEMS }, diff --git a/packages/runtime-host/src/__tests__/session-catalog-summary.test.ts b/packages/runtime-host/src/__tests__/session-catalog-summary.test.ts new file mode 100644 index 00000000000..40a65a00cfd --- /dev/null +++ b/packages/runtime-host/src/__tests__/session-catalog-summary.test.ts @@ -0,0 +1,65 @@ +/* + * 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 assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { projectSessionCatalogSummary } from '../client/session-catalog-summary.js'; +import type { SessionCatalogProjection } from '../protocol/index.js'; + +function projection(overrides: Partial = {}): SessionCatalogProjection { + return { + id: 'session-1', + revision: 1, + workspace: { target: { kind: 'host_path', path: '/workspace' }, hostCwd: '/workspace' }, + createdAt: 1, + activityAt: 2, + name: 'Session', + isFlagged: false, + isArchived: false, + labels: [], + labelsTruncated: false, + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionId: 'connection-1', + llmConnectionSlug: 'openai-main', + connectionLocked: true, + model: 'gpt-5', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'default', + ...overrides, + }; +} + +// Every Desktop and CLI session row comes through this projection, so the +// archive time reaches the archived-tasks page only if it is carried here. +test('carries the archive time of an archived Session into its summary', () => { + const summary = projectSessionCatalogSummary( + projection({ isArchived: true, archivedAt: 1_700_000_000_000 }), + ); + assert.equal(summary.isArchived, true); + assert.equal(summary.archivedAt, 1_700_000_000_000); +}); + +test('adds no archive time key when the Host reports none', () => { + const summary = projectSessionCatalogSummary(projection({ isArchived: true })); + assert.equal(summary.isArchived, true); + assert.equal(Object.hasOwn(summary, 'archivedAt'), false); +}); diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts index b24ca8e455f..6e2f513c447 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -266,6 +266,15 @@ describe('Host Session retirement coordinator', () => { if ('kind' in archived.result) assert.fail('Expected a supported Session projection'); assert.equal(archived.result.id, harness.revisionId); assert.equal(archived.result.isArchived, true); + // The whole family entered the archive in one write, at one time. + const archivedAt = archived.result.archivedAt; + assert.equal(typeof archivedAt, 'number'); + for (const sessionId of harness.familyIds) { + assert.equal( + (await harness.store.readCatalogRecord(sessionId)).summary.archivedAt, + archivedAt, + ); + } await assertFamilyLifecycle(harness, true); assert.deepEqual(new Set(harness.actions.disposed), new Set(harness.familyIds)); assert.deepEqual(new Set(harness.actions.refreshed), new Set(harness.familyIds)); @@ -280,6 +289,13 @@ describe('Host Session retirement coordinator', () => { if (!restored.ok) return; if ('kind' in restored.result) assert.fail('Expected a supported Session projection'); assert.equal(restored.result.isArchived, false); + assert.equal(Object.hasOwn(restored.result, 'archivedAt'), false); + for (const sessionId of harness.familyIds) { + assert.equal( + (await harness.store.readCatalogRecord(sessionId)).summary.archivedAt, + undefined, + ); + } await assertFamilyLifecycle(harness, false); assert.deepEqual(harness.actions.disposed, []); assert.deepEqual(new Set(harness.actions.refreshed), new Set(harness.familyIds)); diff --git a/packages/runtime-host/src/client/session-catalog-summary.ts b/packages/runtime-host/src/client/session-catalog-summary.ts index 13ba8cf4fbd..f0ce4a4924d 100644 --- a/packages/runtime-host/src/client/session-catalog-summary.ts +++ b/packages/runtime-host/src/client/session-catalog-summary.ts @@ -33,6 +33,7 @@ export function projectSessionCatalogSummary( name: session.name, isFlagged: session.isFlagged, isArchived: session.isArchived, + ...(session.archivedAt === undefined ? {} : { archivedAt: session.archivedAt }), labels: [...session.labels], hasUnread: session.hasUnread, ...(session.lastMessageAt === undefined ? {} : { lastMessageAt: session.lastMessageAt }), diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 2698e6a648c..2375e895a7b 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -104,7 +104,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 200 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 201 as const; +// 201: Session catalog projections may carry `archivedAt`, the time the +// Session last entered the archive. Epoch-200 peers reject the unknown key, so +// a newer Desktop against an older Host would lose session catalog reads. // 200: Agent Graph operator snapshots carry bounded output previews and metrics. // Older peers reject the additional `output` field on strict operator shapes. // 199: `storage.usage.query` and `storage.usage.sessions.query` report storage diff --git a/packages/runtime-host/src/protocol/session-catalog.ts b/packages/runtime-host/src/protocol/session-catalog.ts index 4d30ab35c50..6ab2f60dbc6 100644 --- a/packages/runtime-host/src/protocol/session-catalog.ts +++ b/packages/runtime-host/src/protocol/session-catalog.ts @@ -115,6 +115,7 @@ const PROJECTION_REQUIRED_FIELDS = [ ] as const; const PROJECTION_FIELDS = [ ...PROJECTION_REQUIRED_FIELDS, + 'archivedAt', 'lastMessageAt', 'lastMessagePreview', 'blockedReason', @@ -254,6 +255,11 @@ export interface SessionCatalogProjection { readonly name: string; readonly isFlagged: boolean; readonly isArchived: boolean; + /** + * When the Session last entered the archive. Absent when it is not archived, + * and when the Host has no record of the time (archived before it was kept). + */ + readonly archivedAt?: number; readonly labels: readonly string[]; readonly labelsTruncated: boolean; readonly hasUnread: boolean; @@ -843,6 +849,7 @@ export function decodeSessionCatalogProjection(value: unknown): SessionCatalogPr name: sessionName(record.name), isFlagged: boolean(record.isFlagged, 'Session flagged state'), isArchived: boolean(record.isArchived, 'Session archived state'), + ...optionalArchivedAt(record), labels: labels(record.labels), labelsTruncated: boolean(record.labelsTruncated, 'Session labels truncated state'), hasUnread: boolean(record.hasUnread, 'Session unread state'), @@ -882,6 +889,9 @@ export function decodeSessionCatalogProjection(value: unknown): SessionCatalogPr if ((projection.backend === 'plugin-executor') !== (projection.executorId !== undefined)) { throw invalidProtocolFrame('Session executor identity does not match its backend'); } + if (projection.archivedAt !== undefined && !projection.isArchived) { + throw invalidProtocolFrame('Session archive time requires an archived Session'); + } requireEncodedByteLimit( projection, 'Session catalog projection', @@ -974,6 +984,16 @@ function optionalTimestamp( : {}; } +// One object type with an optional key rather than a union, so this spread +// does not multiply the projection literal's inferred union past TS2590. +function optionalArchivedAt( + record: Record, +): Pick { + return Object.hasOwn(record, 'archivedAt') + ? { archivedAt: timestamp(record.archivedAt, 'Session archivedAt') } + : {}; +} + function optionalText( record: Record, field: Field, diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index e66a6bb6f4b..caa28481435 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -1653,6 +1653,12 @@ export function projectSessionCatalogRecord( name: header.name, isFlagged: header.isFlagged, isArchived: header.isArchived, + // The archive state is `isArchived` alone. A stray time on an active row + // is dropped here rather than failing this Host's own decoder and hiding + // the task behind an unsupported-record placeholder. + ...(header.isArchived && summary.archivedAt !== undefined + ? { archivedAt: summary.archivedAt } + : {}), labels: projectedLabels.labels, labelsTruncated: projectedLabels.truncated, hasUnread: header.hasUnread, diff --git a/packages/storage/src/__tests__/execution-provider-conformance.test.ts b/packages/storage/src/__tests__/execution-provider-conformance.test.ts index 7e5643651a8..5624c4efbfd 100644 --- a/packages/storage/src/__tests__/execution-provider-conformance.test.ts +++ b/packages/storage/src/__tests__/execution-provider-conformance.test.ts @@ -1666,6 +1666,38 @@ for (const backend of ['Local', 'Memory'] as const) { }); }, ); + test(backend + ': the archive writer alone stamps and clears the archive time', async () => { + await withProvider(make(), async (stores, root) => { + const s = stores.sessionStore, + session = await s.create(sessionInput(root)); + const archivedAt = async () => (await s.readCatalogRecord(session.id)).summary.archivedAt; + assert.equal(await archivedAt(), undefined); + const before = Date.now(); + const [archived] = await s.setSessionsArchivedVersioned( + [{ sessionId: session.id, expectedVersion: 1 }], + true, + ); + const first = await archivedAt(); + assert.ok(first !== undefined && first >= before && first <= Date.now()); + await s.updateHeader(session.id, { name: 'Renamed' }); + const renamed = await s.readHeaderRecordSnapshot(session.id); + await s.setSessionsArchivedVersioned( + [{ sessionId: session.id, expectedVersion: renamed.revision }], + true, + ); + assert.equal(await archivedAt(), first); + await s.setSessionsArchivedVersioned( + [{ sessionId: session.id, expectedVersion: renamed.revision }], + false, + ); + assert.equal(await archivedAt(), undefined); + assert.equal( + Object.hasOwn((await s.readCatalogRecord(session.id)).summary, 'archivedAt'), + false, + ); + assert.equal(archived!.header.isArchived, true); + }); + }); test( backend + ': active WorkHub linkage requires target evidence and enforces bounds', async () => { diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index 3e05dc3bd18..0644c9eacc1 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -874,6 +874,7 @@ describe('SQLite SessionStore', () => { DROP INDEX session_metadata_by_external_origin; ALTER TABLE session_metadata DROP COLUMN external_adapter_id; ALTER TABLE session_metadata DROP COLUMN external_source_session_id; + ALTER TABLE session_metadata DROP COLUMN archived_at; UPDATE session_metadata_schema SET version = 22 WHERE scope = 'session_metadata'; `); legacy.close(); diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts index 9aa11d22a92..4e5393ba15a 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -1920,6 +1920,155 @@ describe('SqliteSessionMetadataStore', () => { } }); + test('records when a Session entered the archive, and only the lifecycle writer moves it', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-archived-at-')); + const path = join(root, 'state.sqlite'); + let clock = 100; + const store = createSqliteSessionMetadataStore(path, { now: () => clock }); + const readArchivedAtColumn = () => { + const inspect = new DatabaseSync(path, { readOnly: true }); + try { + return ( + inspect + .prepare('SELECT archived_at AS archivedAt FROM session_metadata WHERE session_id = ?') + .get('session-1') as { archivedAt: number | null } + ).archivedAt; + } finally { + inspect.close(); + } + }; + try { + const header = fullHeader(); + await store.create(header); + assert.equal((await store.readCatalogRecord(header.id)).archivedAt, undefined); + + clock = 200; + await store.setArchivedVersioned([{ sessionId: header.id, expectedVersion: 1 }], true); + assert.equal((await store.readCatalogRecord(header.id)).archivedAt, 200); + + // committed_at moves on every metadata write; the archive time must not. + clock = 300; + const renamed = await store.update(header.id, { name: 'Renamed', isFlagged: true }); + assert.equal(renamed.committedAt, 300); + assert.equal((await store.readCatalogRecord(header.id)).archivedAt, 200); + assert.equal((await store.list(undefined, 'ordinary'))[0]?.archivedAt, 200); + const page = await store.listCatalogPage({}, undefined, 8); + assert.equal(page.records[0]?.archivedAt, 200); + + // Archiving an archived Session is a no-op, so it keeps its first time. + clock = 400; + await store.setArchivedVersioned([{ sessionId: header.id, expectedVersion: 3 }], true); + assert.equal((await store.readCatalogRecord(header.id)).archivedAt, 200); + + clock = 500; + await store.setArchivedVersioned([{ sessionId: header.id, expectedVersion: 3 }], false); + assert.equal((await store.readCatalogRecord(header.id)).archivedAt, undefined); + assert.equal(readArchivedAtColumn(), null); + + clock = 600; + await store.setArchivedVersioned([{ sessionId: header.id, expectedVersion: 4 }], true); + assert.equal((await store.readCatalogRecord(header.id)).archivedAt, 600); + assert.equal(readArchivedAtColumn(), 600); + } finally { + store.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('stamps every Session archived in one lifecycle write with one time', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-session-archived-at-batch-')); + const path = join(directory, 'state.sqlite'); + let clock = 1_000; + // Every read of the clock moves it, so Sessions stamped from separate + // reads could not share a time. + const store = createSqliteSessionMetadataStore(path, { now: () => clock++ }); + try { + for (const id of ['root', 'revision', 'deleted', 'orphaned-child']) { + await store.create(fullHeader({ id })); + } + await store.setArchivedVersioned( + [ + { sessionId: 'root', expectedVersion: 1 }, + { sessionId: 'revision', expectedVersion: 1 }, + ], + true, + ); + const root = (await store.readCatalogRecord('root')).archivedAt; + assert.equal(typeof root, 'number'); + assert.equal((await store.readCatalogRecord('revision')).archivedAt, root); + + // A delete that moves a child to the archive stamps it at the retirement. + await store.removeVersioned( + [{ sessionId: 'deleted', expectedVersion: 1 }], + [{ sessionId: 'orphaned-child', expectedVersion: 1 }], + ); + const child = await store.readCatalogRecord('orphaned-child'); + assert.equal(child.header.isArchived, true); + const inspect = new DatabaseSync(path, { readOnly: true }); + try { + const tombstone = inspect + .prepare( + 'SELECT deleted_at AS deletedAt FROM session_metadata_tombstones WHERE session_id = ?', + ) + .get('deleted') as { deletedAt: number }; + assert.equal(child.archivedAt, tombstone.deletedAt); + } finally { + inspect.close(); + } + } finally { + store.close(); + await rm(directory, { recursive: true, force: true }); + } + }); + + test('leaves the archive time of a Session archived before version 41 unknown', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-archived-at-migration-')); + const path = join(root, 'state.sqlite'); + try { + const setup = createSqliteSessionMetadataStore(path, { now: () => 10 }); + await setup.create(fullHeader({ id: 'legacy-archived' })); + await setup.create(fullHeader({ id: 'legacy-active' })); + await setup.setArchivedVersioned( + [{ sessionId: 'legacy-archived', expectedVersion: 1 }], + true, + ); + setup.close(); + + const version40 = new DatabaseSync(path); + try { + version40.exec(` + ALTER TABLE session_metadata DROP COLUMN archived_at; + UPDATE session_metadata_schema SET version = 40 WHERE scope = 'session_metadata'; + `); + } finally { + version40.close(); + } + + const migrated = createSqliteSessionMetadataStore(path, { now: () => 20 }); + try { + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + const archived = await migrated.readCatalogRecord('legacy-archived'); + assert.equal(archived.header.isArchived, true); + assert.equal(archived.archivedAt, undefined); + // The migration is additive: it neither rewrites the row nor invents + // a time from committed_at. + assert.equal(archived.metadataVersion, 2); + assert.equal(archived.committedAt, 10); + assert.equal((await migrated.readCatalogRecord('legacy-active')).archivedAt, undefined); + + await migrated.setArchivedVersioned( + [{ sessionId: 'legacy-active', expectedVersion: 1 }], + true, + ); + assert.equal((await migrated.readCatalogRecord('legacy-active')).archivedAt, 20); + } finally { + migrated.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test('rejects Session lifecycle fields through generic metadata writes', async () => { const store = createSqliteSessionMetadataStore(':memory:'); try { diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 97e22f7ef49..615f1b320b3 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -95,6 +95,7 @@ import { createHash } from 'node:crypto'; import { createSqliteSessionMetadataStore, type SessionCatalogRevisionState, + type SessionMetadataCatalogRecord, type SessionMetadataRecord, type SessionRemovalProbe, type SqliteSessionMetadataStore, @@ -607,7 +608,7 @@ class SqliteSessionStore implements SessionAuthorityStore { return (await this.metadata.list(filter, 'ordinary')) .filter((record) => record.header.transcriptLedgerVersion !== 0) .filter((record) => record.header.conversationCopy?.state !== 'preparing') - .map((record) => toCatalogSummary(record.header, record.lastMessagePreview)); + .map(toCatalogRecordSummary); } async listCatalogPage( @@ -633,7 +634,7 @@ class SqliteSessionStore implements SessionAuthorityStore { records: page.records.map((record) => ({ ...projectHeaderSnapshot(record), activityAt: record.activityAt, - summary: toCatalogSummary(record.header, record.lastMessagePreview), + summary: toCatalogRecordSummary(record), })), hasMore: page.hasMore, }; @@ -671,7 +672,7 @@ class SqliteSessionStore implements SessionAuthorityStore { return { ...projectHeaderSnapshot(record), activityAt: record.activityAt, - summary: toCatalogSummary(record.header, record.lastMessagePreview), + summary: toCatalogRecordSummary(record), }; } @@ -1007,12 +1008,12 @@ function projectStableSessionCreateProbe( : probe; } -function toCatalogSummary( - header: SessionHeader, - lastMessagePreview: string | undefined, -): SessionSummary { +function toCatalogRecordSummary(record: SessionMetadataCatalogRecord): SessionSummary { return { - ...toSummary(header), - ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), + ...toSummary(record.header), + ...(record.lastMessagePreview === undefined + ? {} + : { lastMessagePreview: record.lastMessagePreview }), + ...(record.archivedAt === undefined ? {} : { archivedAt: record.archivedAt }), }; } diff --git a/packages/storage/src/sqlite-session-catalog-query.ts b/packages/storage/src/sqlite-session-catalog-query.ts index cc6f7776b68..25de16eeef2 100644 --- a/packages/storage/src/sqlite-session-catalog-query.ts +++ b/packages/storage/src/sqlite-session-catalog-query.ts @@ -67,6 +67,7 @@ export function buildSqliteSessionCatalogPageQuery( metadata.payload_json, metadata.metadata_version, metadata.committed_at, + metadata.archived_at, projection.activity_at, projection.last_message_preview FROM session_catalog_projection projection diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index 566f4f51823..c82c3319cb2 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -19,7 +19,7 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 40; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 41; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -37,6 +37,16 @@ export const SQLITE_AGENT_GRAPH_CONTROL_TABLES = [ ] as const; const MIGRATIONS: ReadonlyMap = new Map([ + [ + 41, + ` + -- When the Session last entered the archive, owned by the lifecycle + -- writer alone. Rows archived before this column existed keep NULL: the + -- time they were archived was never recorded, and neither committed_at nor + -- activity says when. + ALTER TABLE session_metadata ADD COLUMN archived_at INTEGER; + `, + ], [ 40, ` @@ -1387,14 +1397,16 @@ export function migrateSqliteSessionMetadataDatabase( ) { const sql = MIGRATIONS.get(version); if (!sql) throw new Error(`Missing SQLite session metadata migration ${version}`); - // Versions 32, 35, and 37 each add one column, and the post-merge convergence - // path can replay them onto a database that already carries the current - // table shape. SQLite has no `ADD COLUMN IF NOT EXISTS`, so the guards - // live here. + // Versions 32, 35, 37, and 41 each add one column, and the post-merge + // convergence path can replay them onto a database that already carries + // the current table shape. SQLite has no `ADD COLUMN IF NOT EXISTS`, so + // the guards live here. const columnAlreadyPresent = (version === 32 && hasColumn(db, 'message_admissions', 'submitted_intent_json')) || (version === 35 && hasColumn(db, 'message_admissions', 'skill_invocation_json')) || - (version === 37 && hasColumn(db, 'cancelled_message_admissions', 'cancellation_claim_id')); + (version === 37 && + hasColumn(db, 'cancelled_message_admissions', 'cancellation_claim_id')) || + (version === 41 && hasColumn(db, 'session_metadata', 'archived_at')); if (!columnAlreadyPresent) { db.exec(sql); } diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index ac9bf27f4d8..b4715adf328 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -282,6 +282,11 @@ export interface SessionMetadataRecord { export interface SessionMetadataCatalogRecord extends SessionMetadataRecord { readonly activityAt: number; readonly lastMessagePreview?: string; + /** + * When the Session last entered the archive. Absent while it is not + * archived, and for a Session archived before the time was recorded. + */ + readonly archivedAt?: number; } export interface SessionCatalogRevisionState { @@ -1203,6 +1208,7 @@ export class SqliteSessionMetadataStore { metadata.payload_json, metadata.metadata_version, metadata.committed_at, + metadata.archived_at, projection.activity_at, projection.last_message_preview FROM session_catalog_projection projection @@ -1447,6 +1453,7 @@ export class SqliteSessionMetadataStore { metadata.payload_json, metadata.metadata_version, metadata.committed_at, + metadata.archived_at, COALESCE(projection.activity_at, 0) AS activity_at, projection.last_message_preview FROM session_metadata metadata @@ -4494,8 +4501,11 @@ export class SqliteSessionMetadataStore { this.assertOpen(); const identities = uniqueVersionedSessionIdentities(sessions); return this.transaction(() => { + // One instant for the whole set, so a family archived together reads as + // archived together. + const archivedAt = this.now(); const records = identities.map(({ sessionId, expectedVersion }) => - this.setArchivedSync(sessionId, expectedVersion, isArchived), + this.setArchivedSync(sessionId, expectedVersion, isArchived, archivedAt), ); if (isArchived) this.deleteGoalAuthorities(identities); return records; @@ -4550,7 +4560,7 @@ export class SqliteSessionMetadataStore { } const deletedAt = this.now(); for (const { sessionId, expectedVersion } of archiveIdentities) { - this.setArchivedSync(sessionId, expectedVersion, true); + this.setArchivedSync(sessionId, expectedVersion, true, deletedAt); } for (const { sessionId } of present) { const deleted = this.db @@ -4899,6 +4909,7 @@ export class SqliteSessionMetadataStore { sessionId: string, expectedVersion: number, isArchived: boolean, + archivedAt: number, ): SessionMetadataRecord { const current = this.readRecordSync(sessionId); if (!current) throw new SessionNotFoundError(sessionId); @@ -4910,7 +4921,13 @@ export class SqliteSessionMetadataStore { ); } const next = normalizeSessionHeader({ ...current.header, isArchived }, sessionId); - return this.persistHeaderSync(sessionId, current, next, { skipNoop: true }); + // A Session already in the requested state is a no-op below, so an + // archived Session keeps the time it was first archived; only a real + // transition writes the column. Every other writer leaves it alone. + return this.persistHeaderSync(sessionId, current, next, { + skipNoop: true, + archivedAt: isArchived ? archivedAt : null, + }); } private persistHeaderSync( @@ -4920,6 +4937,8 @@ export class SqliteSessionMetadataStore { options: { skipNoop?: boolean; catalogPreview?: { readonly kind: 'replace'; readonly value?: string }; + /** Set only by the archive lifecycle writer; null clears the column. */ + archivedAt?: number | null; } = {}, ): SessionMetadataRecord { if (next.id !== sessionId) { @@ -4954,7 +4973,8 @@ export class SqliteSessionMetadataStore { llm_connection_slug = ?, model = ?, metadata_version = ?, - committed_at = ? + committed_at = ?, + archived_at = CASE WHEN ? = 1 THEN ? ELSE archived_at END WHERE session_id = ? AND metadata_version = ? `, ) @@ -4975,6 +4995,8 @@ export class SqliteSessionMetadataStore { next.model, metadataVersion, committedAt, + options.archivedAt === undefined ? 0 : 1, + options.archivedAt ?? null, sessionId, current.metadataVersion, ); @@ -6061,6 +6083,7 @@ interface OrphanedAgentGraphOperatorRow extends OwnedAgentGraphOperatorRow { } interface SessionMetadataCatalogRow extends SessionMetadataRow { + archived_at: number | null; activity_at: number; last_message_preview: string | null; } @@ -6371,13 +6394,23 @@ function decodeCatalogRecord(row: SessionMetadataCatalogRow): SessionMetadataCat throw new Error(`Invalid SQLite Session catalog activity for ${row.session_id}`); } const lastMessagePreview = decodeCatalogPreview(row.last_message_preview, row.session_id); + const archivedAt = decodeCatalogArchivedAt(row.archived_at, row.session_id); return { ...decodeRecord(row), activityAt: row.activity_at, ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), + ...(archivedAt === undefined ? {} : { archivedAt }), }; } +function decodeCatalogArchivedAt(value: unknown, sessionId: string): number | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`Invalid SQLite Session archive time for ${sessionId}`); + } + return value; +} + function decodeCatalogPreview(value: unknown, sessionId: string): string | undefined { if (value === null || value === undefined) return undefined; if (typeof value !== 'string' || Array.from(value).length > 96) { diff --git a/packages/storage/src/test-only/memory-execution-session.ts b/packages/storage/src/test-only/memory-execution-session.ts index cbf5781f2b1..3df960d17af 100644 --- a/packages/storage/src/test-only/memory-execution-session.ts +++ b/packages/storage/src/test-only/memory-execution-session.ts @@ -230,15 +230,35 @@ function updatePublic( } return update(s, id, patch, version); } +/** + * The archive lifecycle writer, mirroring SQLite: only a real transition moves + * the archive time, so an archived Session keeps the time it first entered. + */ +function setArchived( + s: MemoryState, + id: string, + isArchived: boolean, + archivedAt: number, + version?: number, +): Header { + const current = requireHeader(s, id); + const next = update(s, id, { isArchived }, version, true); + if (next === current) return next; + if (isArchived) rows(s, 'archivedAt').set(id, archivedAt); + else rows(s, 'archivedAt').delete(id); + return next; +} function catalog(s: MemoryState, id: string): SessionCatalogRecord { const record = requireHeader(s, id); const preview = rows(s, 'previews').get(id); + const archivedAt = rows(s, 'archivedAt').get(id); return { ...record, activityAt: record.header.lastMessageAt ?? record.header.createdAt, summary: { ...toSummary(record.header), ...(preview === undefined ? {} : { lastMessagePreview: preview }), + ...(archivedAt === undefined ? {} : { archivedAt }), }, }; } @@ -370,6 +390,7 @@ function remove(s: MemoryState, id: string, group: Set): void { conflict('Session has live child Sessions outside retirement'); headers(s).delete(id); messages(s).delete(id); + rows(s, 'archivedAt').delete(id); rows(s, 'tombstones').set(id, true); rows(s, 'cleanup').set(id, true); rows(s, 'goals').delete(id); @@ -664,13 +685,14 @@ export function createMemorySessionStore( write('session.remove', (s) => remove(s, id, new Set([id]))); }, setSessionsArchivedVersioned: async (ids, isArchived) => - write('session.archive', (s) => - ids.map(({ sessionId, expectedVersion }) => { - const result = update(s, sessionId, { isArchived }, expectedVersion, true); + write('session.archive', (s) => { + const archivedAt = Date.now(); + return ids.map(({ sessionId, expectedVersion }) => { + const result = setArchived(s, sessionId, isArchived, archivedAt, expectedVersion); if (isArchived) rows(s, 'goals').delete(sessionId); return result; - }), - ), + }); + }), removeSessionsVersioned: async (ids, archive = []) => write('session.retire', (s) => { const group = new Set(ids.map((i) => i.sessionId)); @@ -684,9 +706,10 @@ export function createMemorySessionStore( h.revision, ); } + const archivedAt = Date.now(); for (const i of archive) { if (group.has(i.sessionId)) conflict('Cannot archive and remove the same Session'); - update(s, i.sessionId, { isArchived: true }, undefined, true); + setArchived(s, i.sessionId, true, archivedAt); rows(s, 'goals').delete(i.sessionId); } for (const i of ids) if (headers(s).has(i.sessionId)) remove(s, i.sessionId, group);