diff --git a/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts index c4c03d52f3..352e01098a 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-purge.test.ts @@ -11,7 +11,7 @@ function summary(id: string, overrides: Partial = {}): SessionSu isArchived: true, labels: [], hasUnread: false, - status: 'archived', + status: 'active', backend: 'fake', llmConnectionSlug: 'test', connectionLocked: true, @@ -21,14 +21,9 @@ function summary(id: string, overrides: Partial = {}): SessionSu }; } -/** - * A task that left the archive. Both fields move together because that is what - * `SessionStore.unarchive` writes; flipping only `isArchived` would build a row - * the store cannot produce, and the sweep would then be tested against a state - * it will never meet. - */ +/** A task that left the archive while retaining its independent execution status. */ function restored(id: string): SessionSummary { - return summary(id, { isArchived: false, status: 'active' }); + return summary(id, { isArchived: false }); } type SweepHarness = { diff --git a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts index 3161b7d5af..e8d34b68b2 100644 --- a/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-transcript-range-store.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { StoredMessage } from '@maka/core/session'; +import { SESSION_CONTINUITY_SCHEMA_VERSION } from '@maka/runtime-host/protocol'; import { encodeDesktopTranscriptChange, encodeDesktopTranscriptSnapshot, @@ -546,7 +547,7 @@ function transcriptPage( function continuitySnapshot() { return { - schemaVersion: 3 as const, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: 'session-1', metadataRevision: 1, diff --git a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts index efde6b209c..f48fe34509 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-bot-session-adapter.test.ts @@ -1,11 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { RuntimeHostOperationError } from '@maka/runtime-host/client'; -import type { - SessionCatalogProjection, - SessionContinuitySnapshot, - SubscriptionFrame, - TurnSnapshot, +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionCatalogProjection, + type SessionContinuitySnapshot, + type SubscriptionFrame, + type TurnSnapshot, } from '@maka/runtime-host/protocol'; import { BotSessionUnavailableError } from '../bot-session-adapter.js'; import { @@ -87,6 +88,39 @@ test('prepares a bound Session without exposing Host configuration revisions to ); }); +test('rejects archived Sessions before and after a permission transition', async () => { + const initiallyArchived = createRuntimeHostBotSessionAdapter({ + client: botClient({ + getSession: async () => + session('bot-session-1', { isArchived: true, status: 'active' }), + }), + resolveCreateTarget: hostPathCreateTarget, + emitSessionsChanged() {}, + }); + await assert.rejects( + initiallyArchived.prepareSession('bot-session-1'), + BotSessionUnavailableError, + ); + + const archivedDuringUpdate = createRuntimeHostBotSessionAdapter({ + client: botClient({ + getSession: async () => session('bot-session-1', { permissionMode: 'ask' }), + updateSessionConfiguration: async (sessionId) => + session(sessionId, { + permissionMode: 'explore', + isArchived: true, + status: 'active', + }), + }), + resolveCreateTarget: hostPathCreateTarget, + emitSessionsChanged() {}, + }); + await assert.rejects( + archivedDuringUpdate.prepareSession('bot-session-1'), + BotSessionUnavailableError, + ); +}); + test('reconciles an uncertain Host Session create with its stable Session identity', async () => { const adapter = createRuntimeHostBotSessionAdapter({ client: botClient({ @@ -401,7 +435,7 @@ function startedTurn(turn: TurnSnapshot) { function continuitySnapshot(rootTurn: TurnSnapshot | null): SessionContinuitySnapshot { return { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: 'bot-session-1', metadataRevision: 1, 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 d6a915f80e..4869c93671 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 @@ -176,7 +176,6 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn ...projected, revision: projected.revision + 1, isArchived: archived, - status: archived ? 'archived' : 'active', }); return { ok: true, result: projected }; }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts index 4bc1162eee..9f8ce91cf7 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts @@ -6,14 +6,15 @@ import type { BotIncomingMessage, BotRegistry } from '@maka/runtime/bots'; import type { ComputerUseToolSet } from '@maka/runtime/computer-use-tools'; import type { MakaTool } from '@maka/runtime/tool-runtime'; import type { ClientCapabilityProvider, RuntimeHostConnection } from '@maka/runtime-host/client'; -import type { - ClientCapabilityCallFrame, - OperationInput, - OperationKey, - SessionAssistantStreamIdentity, - SessionCatalogProjection, - SessionContinuitySnapshot, - SubscriptionFrame, +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type ClientCapabilityCallFrame, + type OperationInput, + type OperationKey, + type SessionAssistantStreamIdentity, + type SessionCatalogProjection, + type SessionContinuitySnapshot, + type SubscriptionFrame, } from '@maka/runtime-host/protocol'; import { z } from 'zod'; import { createAttachmentApprovalRegistry } from '../attachment-approval.js'; @@ -988,7 +989,7 @@ function continuitySnapshot( overrides: Partial = {}, ): SessionContinuitySnapshot { return { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: 'session-1', metadataRevision: 1, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts index a2a9a9fdb7..ec3ab770eb 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-execution-ipc-main.test.ts @@ -7,8 +7,11 @@ import test from "node:test"; import type { IpcMain } from "electron"; import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import { type AttachmentRef } from '@maka/core/events'; +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionCatalogProjection, +} from "@maka/runtime-host/protocol"; import { RuntimeHostOperationError } from '@maka/runtime-host/client'; -import type { SessionCatalogProjection } from "@maka/runtime-host/protocol"; import { createAttachmentApprovalRegistry } from "../attachment-approval.js"; import type { DesktopRuntimeHostSession } from "../runtime-host-client.js"; import { @@ -784,7 +787,7 @@ function observerWithTranscript( client: { openSession: async () => runtimeHostSessionFixture({ snapshot: { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: "session-1", metadataRevision: 1, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index 98d9be7e8b..c8b9c283cf 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -3,9 +3,10 @@ import { EventEmitter } from "node:events"; import test from "node:test"; import type { SessionEvent } from '@maka/core/events'; import type { StoredMessage } from '@maka/core/session'; -import type { - SessionContinuitySnapshot, - SubscriptionFrame, +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionContinuitySnapshot, + type SubscriptionFrame, } from "@maka/runtime-host/protocol"; import { RuntimeHostSubscriptionError } from "@maka/runtime-host/client"; import type { DesktopRuntimeHostSession } from "../runtime-host-client.js"; @@ -2246,7 +2247,7 @@ function continuitySnapshot( overrides: Partial = {}, ): SessionContinuitySnapshot { return { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: "session-1", metadataRevision: 1, diff --git a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts index 846905d96a..1d485d31fc 100644 --- a/apps/desktop/src/main/runtime-host-bot-session-adapter.ts +++ b/apps/desktop/src/main/runtime-host-bot-session-adapter.ts @@ -82,7 +82,7 @@ export function createRuntimeHostBotSessionAdapter( throwUnavailable(error, sessionId); throw error; } - if (!session || session.isArchived || session.status === 'archived') { + if (!session || session.isArchived) { throw unavailableSession(sessionId); } if (session.permissionMode === 'explore') return 'ready'; @@ -96,7 +96,7 @@ export function createRuntimeHostBotSessionAdapter( if (isPermissionUpdateRefusal(error)) return 'permission_refused'; throw error; } - if (session.isArchived || session.status === 'archived') { + if (session.isArchived) { throw unavailableSession(sessionId); } if (session.permissionMode !== 'explore') return 'permission_refused'; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 79c6769659..011472cad5 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -757,7 +757,7 @@ function archivedTask( isArchived: true, labels: [], hasUnread: false, - status: 'archived', + status: 'active', backend: 'ai-sdk', llmConnectionSlug: 'zai-live', connectionLocked: true, diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 39dc8640e2..c35f0267b0 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -10,13 +10,14 @@ import type { RuntimeHostSessionSubscription, } from '@maka/runtime-host/client'; import { RuntimeHostSubscriptionError } from '@maka/runtime-host/client'; -import type { - InteractionPendingSnapshot, - OperationInput, - OperationOutput, - SessionCatalogProjection, - SessionContinuitySnapshot, - SubscriptionFrame, +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type InteractionPendingSnapshot, + type OperationInput, + type OperationOutput, + type SessionCatalogProjection, + type SessionContinuitySnapshot, + type SubscriptionFrame, } from '@maka/runtime-host/protocol'; import { createRuntimeHostMakaSessionDriver, @@ -1425,7 +1426,7 @@ function continuitySnapshot( overrides: Partial = {}, ): SessionContinuitySnapshot { return { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: 'session-1', metadataRevision: 1, diff --git a/packages/core/src/__tests__/session-status.test.ts b/packages/core/src/__tests__/session-status.test.ts index 1fb9359417..73c7435c09 100644 --- a/packages/core/src/__tests__/session-status.test.ts +++ b/packages/core/src/__tests__/session-status.test.ts @@ -9,7 +9,6 @@ describe('session status contract', () => { 'running', 'waiting_for_user', 'blocked', - 'archived', 'aborted', ]); for (const status of SESSION_STATUSES) { @@ -17,5 +16,6 @@ describe('session status contract', () => { } assert.equal(isSessionStatus('review'), false); assert.equal(isSessionStatus('done'), false); + assert.equal(isSessionStatus('archived'), false); }); }); diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index febe64e75a..f90573f3d3 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -29,18 +29,12 @@ import { decodeTurnOrigin, type TurnOrigin } from './turn-origin.js'; export { DEEP_RESEARCH_SESSION_LABEL, isDeepResearchSession } from './explore-agent.js'; -/** - * `archived` is still here and still written by `SessionStore.archive()` - * alongside `isArchived`; consolidating those two onto one authority is its own - * change (#2984, PR 3) because it rewrites stored rows. - * - */ +/** Runtime execution states. Archive visibility is represented by `isArchived`. */ export const SESSION_STATUSES = [ 'active', 'running', 'waiting_for_user', 'blocked', - 'archived', 'aborted', ] as const; @@ -206,7 +200,6 @@ export interface SessionHeader { labels: string[]; isArchived: boolean; - archivedAt?: number; status: SessionStatus; blockedReason?: SessionBlockedReason; statusUpdatedAt?: number; @@ -262,6 +255,10 @@ export interface SessionHeader { schemaVersion: 1; } +export type SessionHeaderPatch = Partial> & { + readonly isArchived?: never; +}; + export type BackendKind = 'ai-sdk' | 'fake'; export interface SessionSummary { diff --git a/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs b/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs index 991e068fed..064cd2a78a 100644 --- a/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs +++ b/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createSessionStore } from '@maka/storage'; import { ClientSessionSubscription } from '../dist/client/session-subscription.js'; +import { SESSION_CONTINUITY_SCHEMA_VERSION } from '../dist/protocol/index.js'; import { createSessionTranscriptBootstrap, readSessionTranscriptPage, @@ -62,7 +63,7 @@ async function runFixture(fixture) { activeAssistantStreams: [], transcript: bootstrap, snapshot: { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: state.sessionId, metadataRevision: 1, diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts index 3ecfd9e884..c875ec6e08 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host-suite.ts @@ -665,7 +665,11 @@ export class ExecutionFixture { let stores: Awaited> | undefined; try { stores = await openInteractiveExecutionStoresForWrite(owner.lease); - await stores.sessionStore.archive(this.sessionId); + const current = await stores.sessionStore.readHeaderRecordSnapshot(this.sessionId); + await stores.sessionStore.setSessionsArchivedVersioned( + [{ sessionId: this.sessionId, expectedVersion: current.revision }], + true, + ); } finally { await stores?.sessionStore.close?.(); await owner.close(); diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index a9478a5d60..648d4d6337 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -275,9 +275,9 @@ test('session retirement forgets a terminal Goal without recreating deleted auth const retirement = await coordinator.beginSessionRetirement([session.id], 'archive'); const header = await stores.sessionStore.readHeaderRecordSnapshot(session.id); - await stores.sessionStore.setSessionsLifecycleVersioned( + await stores.sessionStore.setSessionsArchivedVersioned( [{ sessionId: session.id, expectedVersion: header.revision }], - 'archived', + true, ); retirement.commit(); diff --git a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts index d110320952..665308a5d9 100644 --- a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts +++ b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts @@ -1,15 +1,16 @@ import assert from 'node:assert/strict'; -import { randomUUID } from 'node:crypto'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { - prepareStorageRootControlDirectory, - resolveStorageRoot, + discoverMarkedStorageRoot, + STORAGE_ROOT_MARKER_FILE, + STORAGE_ROOT_MARKER_SCHEMA_VERSION, } from '@maka/storage/root-authority'; -import { connectRuntimeHost } from '../client/index.js'; +import { connectResolvedRuntimeHost, type ConnectRuntimeHostResult } from '../client/connection.js'; import { prepareRuntimeHostEndpoint } from '../control/endpoint.js'; import { removeHostRegistration, writeHostRegistration } from '../control/registration.js'; import { @@ -28,7 +29,7 @@ const PROTOCOL = { max: RUNTIME_HOST_PROTOCOL_VERSION, } as const; -test('rejects a Host that accepts with a different compatibility epoch before any domain command', async () => { +test('rejects an epoch-23 Host before any domain command', async () => { let admittedRequest: RequestFrame | undefined; await withForgedHandshakePeer( async (transport, hostEpoch, rootId) => { @@ -40,7 +41,7 @@ test('rejects a Host that accepts with a different compatibility epoch before an hostEpoch, connectionId: 'forged-epoch-connection', selectedProtocol: RUNTIME_HOST_PROTOCOL_VERSION, - compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH + 1, + compatibilityEpoch: 23, compositionId: 'maka.interactive', compositionRevision: '1', state: 'ready', @@ -67,14 +68,30 @@ test('rejects a Host that accepts with a different compatibility epoch before an async function withForgedHandshakePeer( serve: (transport: FramedTransport, hostEpoch: string, rootId: string) => Promise, - run: (result: Awaited>) => Promise, + run: (result: ConnectRuntimeHostResult) => Promise, ): Promise { const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-handshake-')); - const capability = await resolveStorageRoot({ - path: join(base, 'root'), - kind: 'interactive', + const rootPath = join(base, 'root'); + const controlDirectory = join(base, 'control'); + await mkdir(rootPath, { mode: 0o700 }); + await mkdir(controlDirectory, { mode: 0o700 }); + const rootStat = await stat(rootPath, { bigint: true }); + await writeFile( + join(rootPath, STORAGE_ROOT_MARKER_FILE), + `${JSON.stringify({ + schemaVersion: STORAGE_ROOT_MARKER_SCHEMA_VERSION, + kind: 'interactive', + rootId: randomBytes(32).toString('hex'), + rootIdentity: { + dev: rootStat.dev.toString(), + ino: rootStat.ino.toString(), + }, + })}\n`, + { mode: 0o600 }, + ); + const capability = await discoverMarkedStorageRoot({ + path: rootPath, }); - const { controlDirectory } = await prepareStorageRootControlDirectory(capability); const hostEpoch = randomUUID(); const endpoint = await prepareRuntimeHostEndpoint({ rootId: capability.rootId, @@ -105,11 +122,17 @@ async function withForgedHandshakePeer( pid: process.pid, createdAt: new Date().toISOString(), }); - const result = await connectRuntimeHost({ - rootPath: join(base, 'root'), + const resolved = await connectResolvedRuntimeHost({ + capability, + controlDirectory, + clientInstanceId: randomUUID(), surface: 'tui', protocol: PROTOCOL, }); + if (resolved.kind === 'election_deadline_elapsed') { + throw new Error('Unexpected Runtime Host election deadline'); + } + const result = resolved; try { await run(result); } finally { diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 7cc7975fad..3580f15953 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -953,6 +953,8 @@ describe('non-serving Runtime Host kernel', () => { surface: 'tui', protocolMin: CURRENT_PROTOCOL.min, protocolMax: CURRENT_PROTOCOL.max, + compatibilityEpoch: 20, + compositionId: KERNEL_COMPOSITION.descriptor.id, }), ); assert.deepEqual(decodeHostFrame(await staleWhileResident.read(1_000)), { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 113aecbf56..227dfef982 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -69,7 +69,7 @@ describe('Runtime Host bootstrap protocol', () => { }); test('keeps the subscription queue Epoch correlated', () => { - assert.equal(SESSION_CONTINUITY_SCHEMA_VERSION, 3); + assert.equal(SESSION_CONTINUITY_SCHEMA_VERSION, 4); const opened = { requestId: 'open-1', operation: 'subscription.open', diff --git a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts index 8afedecd24..e9bf006b10 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts @@ -522,7 +522,6 @@ function createHarness() { if (state.sessionState === 'missing') throw new SessionNotFoundError(sessionId); return { cwd: '/workspace', - status: state.sessionState === 'archived' ? 'archived' : 'idle', isArchived: state.sessionState === 'archived', }; }, diff --git a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts index db4474949e..53d552d0ba 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts @@ -462,6 +462,7 @@ test('two Clients share stable Session creation, CAS configuration, and catalog }); const retirementIterator = retirementSubscription[Symbol.asyncIterator](); const beforeArchive = await querySession(desktop, created.id); + assert.equal(beforeArchive.status, 'active'); const heartbeat = await desktop.request('scheduled-task.mutate', { kind: 'create', input: { @@ -494,9 +495,11 @@ test('two Clients share stable Session creation, CAS configuration, and catalog }), ); assert.equal(archived.isArchived, true); + assert.equal(archived.status, beforeArchive.status); assert.equal((await querySession(tui, created.id)).isArchived, true); const archivedContinuity = await nextProjection(retirementIterator); assert.equal(archivedContinuity.snapshot.session.isArchived, true); + assert.equal(archivedContinuity.snapshot.session.status, beforeArchive.status); assert.ok(archived.revision > beforeArchive.revision); const restored = requireSessionProjection( @@ -506,8 +509,10 @@ test('two Clients share stable Session creation, CAS configuration, and catalog }), ); assert.equal(restored.isArchived, false); + assert.equal(restored.status, beforeArchive.status); const restoredContinuity = await nextProjection(retirementIterator); assert.equal(restoredContinuity.snapshot.session.isArchived, false); + assert.equal(restoredContinuity.snapshot.session.status, beforeArchive.status); assert.deepEqual( await desktop.request('session.remove', { diff --git a/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts index 437a0ffedd..78eb1764ec 100644 --- a/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts @@ -55,7 +55,7 @@ test('Session recap publishes one protected result and exact retries never repea }, { readSessionHeader: async () => - ({ isArchived: true, status: 'archived' }) as unknown as SessionHeader, + ({ isArchived: true, status: 'active' }) as unknown as SessionHeader, }, ); assert.deepEqual( @@ -122,7 +122,7 @@ test('Session effect leaves Turn admission free and drain aborts accepted recap }, { readSessionHeader: async () => - ({ isArchived: true, status: 'archived' }) as unknown as SessionHeader, + ({ isArchived: true, status: 'active' }) as unknown as SessionHeader, }, ); assert.deepEqual( diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index 4bf6d1e874..a3b0e462e6 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -5,7 +5,11 @@ import { createRuntimeHostSessionProjectionSeed, RuntimeHostSessionProjector, } from '../adapter/session-projector.js'; -import type { SessionContinuitySnapshot, SubscriptionFrame } from '../protocol/index.js'; +import { + SESSION_CONTINUITY_SCHEMA_VERSION, + type SessionContinuitySnapshot, + type SubscriptionFrame, +} from '../protocol/index.js'; test('applies authoritative replacement once and does not complete it again at Turn terminal', () => { const projector = new RuntimeHostSessionProjector( @@ -265,7 +269,7 @@ function deltaFrame( function snapshot(overrides: Partial = {}): SessionContinuitySnapshot { return { - schemaVersion: 3, + schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION, session: { sessionId: 'session-1', metadataRevision: 1, 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 8a351fac08..e70a8af7b9 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -132,7 +132,9 @@ describe('Host Session retirement coordinator', () => { ); assert.equal(archived.ok, true); for (const childSessionId of childSessionIds) { - assert.equal((await harness.store.readHeaderSnapshot(childSessionId)).status, 'archived'); + const header = await harness.store.readHeaderSnapshot(childSessionId); + assert.equal(header.isArchived, true); + assert.equal(header.status, 'active'); } const restored = await harness.coordinator.handlers['session.lifecycle.set']( @@ -141,7 +143,9 @@ describe('Host Session retirement coordinator', () => { ); assert.equal(restored.ok, true); for (const childSessionId of childSessionIds) { - assert.equal((await harness.store.readHeaderSnapshot(childSessionId)).status, 'active'); + const header = await harness.store.readHeaderSnapshot(childSessionId); + assert.equal(header.isArchived, false); + assert.equal(header.status, 'active'); } const target = await harness.store.readHeaderRecordSnapshot(harness.revisionId); @@ -839,8 +843,8 @@ async function withHarness( store.listPendingSessionRetirementCleanupIds(sessionId), completeSessionRetirementCleanup: (sessionId) => store.completeSessionRetirementCleanup(sessionId), - setSessionsLifecycleVersioned: (sessions, state) => - store.setSessionsLifecycleVersioned(sessions, state), + setSessionsArchivedVersioned: (sessions, isArchived) => + store.setSessionsArchivedVersioned(sessions, isArchived), removeSessionsVersioned: async (sessions) => { if (harness.failRemoveCommit) throw new Error('injected remove failure'); if (harness.updateSiblingBeforeRemoveCommit) { @@ -1037,7 +1041,6 @@ async function assertFamilyLifecycle(harness: RetirementHarness, archived: boole for (const sessionId of harness.familyIds) { const header = await harness.store.readHeaderSnapshot(sessionId); assert.equal(header.isArchived, archived); - assert.equal(header.status === 'archived', archived); } } diff --git a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts index 5c6f8a4180..cce4d583a6 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-protocol.test.ts @@ -32,7 +32,7 @@ describe('Session retirement protocol', () => { () => HOST_OPERATION_SPECS['session.lifecycle.set'].assertOutputForInput?.( { sessionId: 'session-1', state: 'archived' }, - projection({ id: 'session-2', isArchived: true, status: 'archived' }), + projection({ id: 'session-2', isArchived: true }), ), isInvalidFrame, ); @@ -47,7 +47,11 @@ describe('Session retirement protocol', () => { }); test('preserves removal conflicts and archived lifecycle state on the wire', () => { - const archived = projection({ isArchived: true, status: 'archived' }); + const archived = projection({ + isArchived: true, + status: 'blocked', + blockedReason: 'tool_failed', + }); assert.deepEqual( decodeHostFrame({ requestId: 'request-archive', diff --git a/packages/runtime-host/src/protocol/session-continuity.ts b/packages/runtime-host/src/protocol/session-continuity.ts index 953b4225ce..cb713f3c88 100644 --- a/packages/runtime-host/src/protocol/session-continuity.ts +++ b/packages/runtime-host/src/protocol/session-continuity.ts @@ -31,7 +31,7 @@ import { type SessionTranscriptBootstrap, } from './session-transcript.js'; -export const SESSION_CONTINUITY_SCHEMA_VERSION = 3 as const; +export const SESSION_CONTINUITY_SCHEMA_VERSION = 4 as const; export const SESSION_CONTINUITY_SNAPSHOT_MAX_BYTES = 56 * 1024; // Leave transport headroom for the response envelope and request correlation. export const SUBSCRIPTION_OPEN_RESULT_MAX_BYTES = 92 * 1024; @@ -59,7 +59,6 @@ export interface SessionContinuityIdentity { createdAt: number; lastUsedAt: number; isArchived: boolean; - archivedAt?: number; } export interface SessionContinuitySnapshot { @@ -868,7 +867,6 @@ function decodeSessionContinuityIdentity(value: unknown): SessionContinuityIdent 'createdAt', 'lastUsedAt', 'isArchived', - 'archivedAt', ]); assertRequiredKeys(record, 'Session continuity identity', [ 'sessionId', @@ -888,9 +886,6 @@ function decodeSessionContinuityIdentity(value: unknown): SessionContinuityIdent createdAt: requireCount(record.createdAt, 'createdAt'), lastUsedAt: requireCount(record.lastUsedAt, 'lastUsedAt'), isArchived: record.isArchived, - ...(record.archivedAt === undefined - ? {} - : { archivedAt: requireCount(record.archivedAt, 'archivedAt') }), }; } diff --git a/packages/runtime-host/src/protocol/session-retirement.ts b/packages/runtime-host/src/protocol/session-retirement.ts index e483dfa81c..b869ecc6ed 100644 --- a/packages/runtime-host/src/protocol/session-retirement.ts +++ b/packages/runtime-host/src/protocol/session-retirement.ts @@ -52,7 +52,7 @@ export const SESSION_RETIREMENT_OPERATION_SPECS = { } if ('kind' in output) return; const archived = input.state === 'archived'; - if (output.isArchived !== archived || (output.status === 'archived') !== archived) { + if (output.isArchived !== archived) { throw invalidProtocolFrame('Session lifecycle result does not match the requested state'); } }, diff --git a/packages/runtime-host/src/server/canonical-session-projection.ts b/packages/runtime-host/src/server/canonical-session-projection.ts index 86a1ea12fe..d3e7683776 100644 --- a/packages/runtime-host/src/server/canonical-session-projection.ts +++ b/packages/runtime-host/src/server/canonical-session-projection.ts @@ -105,7 +105,6 @@ export class CanonicalSessionProjectionReader { createdAt: header.createdAt, lastUsedAt: header.lastUsedAt, isArchived: header.isArchived, - ...(header.archivedAt !== undefined ? { archivedAt: header.archivedAt } : {}), }; return { session, rootTurn, goal, queue, interactions }; } diff --git a/packages/runtime-host/src/server/context-coordinator.ts b/packages/runtime-host/src/server/context-coordinator.ts index fccd79ba29..7beeb720b0 100644 --- a/packages/runtime-host/src/server/context-coordinator.ts +++ b/packages/runtime-host/src/server/context-coordinator.ts @@ -148,7 +148,7 @@ export class HostContextCoordinator { if (isSessionNotFoundError(error)) return notFound('Session does not exist'); throw error; } - if (header.status === 'archived' || header.isArchived) { + if (header.isArchived) { return sessionArchived('Cannot compact an archived Session'); } const unavailableReason = runtimeHostExternalTurnUnavailableReason(header); diff --git a/packages/runtime-host/src/server/deep-research-coordinator.ts b/packages/runtime-host/src/server/deep-research-coordinator.ts index d0f851b22f..68a46eee43 100644 --- a/packages/runtime-host/src/server/deep-research-coordinator.ts +++ b/packages/runtime-host/src/server/deep-research-coordinator.ts @@ -107,7 +107,7 @@ export class HostDeepResearchCoordinator { > { try { const header = await this.#sessions.readHeaderSnapshot(sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { return { code: 'session_archived', message: 'Session is archived' }; } if (!isDeepResearchSession(header.labels)) { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index c009867f2c..c2ca7fea76 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1158,7 +1158,7 @@ export async function createExecutionRuntimeHostComposition( isSessionDeliverable: async (sessionId) => { try { const header = await stores.sessionStore.readHeaderSnapshot(sessionId); - return !header.isArchived && header.status !== 'archived'; + return !header.isArchived; } catch (error) { if (isSessionNotFoundError(error)) return false; throw error; diff --git a/packages/runtime-host/src/server/goal-coordinator.ts b/packages/runtime-host/src/server/goal-coordinator.ts index 581595ea55..2b6c0308bd 100644 --- a/packages/runtime-host/src/server/goal-coordinator.ts +++ b/packages/runtime-host/src/server/goal-coordinator.ts @@ -160,7 +160,7 @@ export class HostGoalCoordinator { const { goal, controlLease } = snapshot.record; try { const header = await this.#stores.sessionStore.readHeaderSnapshot(goal.sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { await this.#deleteOrphanedAuthority(snapshot); continue; } @@ -315,7 +315,7 @@ export class HostGoalCoordinator { if (isSessionNotFoundError(error)) return notFound('Session does not exist'); throw error; } - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { return sessionArchived('Archived Session Goal state cannot be controlled'); } const current = this.manager.get(input.sessionId); diff --git a/packages/runtime-host/src/server/hosted-execution-recovery.ts b/packages/runtime-host/src/server/hosted-execution-recovery.ts index 157fc85f8d..8495f6c0a4 100644 --- a/packages/runtime-host/src/server/hosted-execution-recovery.ts +++ b/packages/runtime-host/src/server/hosted-execution-recovery.ts @@ -149,7 +149,7 @@ export async function prepareHostedExecutionRecovery( if (replayAdmissions.length > 1) { throw new Error(`Session ${session.id} has multiple admitted Turns without Runs`); } - if (replayAdmissions[0] && (session.status === 'archived' || session.isArchived)) { + if (replayAdmissions[0] && session.isArchived) { throw new Error(`Archived Session ${session.id} has an admitted Turn without a Run`); } prepared.push({ diff --git a/packages/runtime-host/src/server/plan-coordinator.ts b/packages/runtime-host/src/server/plan-coordinator.ts index b882d7b72f..8bfcd10ce0 100644 --- a/packages/runtime-host/src/server/plan-coordinator.ts +++ b/packages/runtime-host/src/server/plan-coordinator.ts @@ -265,7 +265,7 @@ export class HostPlanCoordinator { > { try { const header = await this.#sessions.readHeaderSnapshot(sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { return { code: 'session_archived', message: 'Session is archived' }; } return undefined; diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index a2309277cb..e53906839d 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -431,7 +431,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { const header = await this.stores.sessionStore.readHeaderSnapshot(sessionId); if (header.conversationCopy?.state === 'preparing') return null; return { - isArchived: header.isArchived || header.status === 'archived', + isArchived: header.isArchived, unavailableReason: runtimeHostExternalTurnUnavailableReason(header), }; } catch (error) { @@ -704,7 +704,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } throw error; } - if (header.status === 'archived' || header.isArchived) { + if (header.isArchived) { throw new RuntimeHostedRootUnavailableError( input.sessionId, 'Cannot start a hosted root execution in an archived Session', @@ -1282,7 +1282,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } throw error; } - if (header.status === 'archived' || header.isArchived) { + if (header.isArchived) { return completedStart(sessionArchived(request.archivedMessage)); } const unavailableReason = runtimeHostExternalTurnUnavailableReason(header); @@ -1414,7 +1414,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { if (isSessionNotFoundError(error)) return notFound('Session does not exist'); throw error; } - if (header.status === 'archived' || header.isArchived) { + if (header.isArchived) { return sessionArchived('Cannot continue an archived Session'); } const unavailableReason = runtimeHostSafeBoundaryContinuationUnavailableReason(header); @@ -1593,7 +1593,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { } throw error; } - if (header.status === 'archived' || header.isArchived) { + if (header.isArchived) { return { kind: 'complete', outcome: sessionArchived('Cannot continue an archived Session'), diff --git a/packages/runtime-host/src/server/runtime-resource-coordinator.ts b/packages/runtime-host/src/server/runtime-resource-coordinator.ts index 4f4bfb6546..8415385753 100644 --- a/packages/runtime-host/src/server/runtime-resource-coordinator.ts +++ b/packages/runtime-host/src/server/runtime-resource-coordinator.ts @@ -59,7 +59,6 @@ interface RuntimeResourceSessionReader { interface RuntimeResourceHeaderReader { readHeader(sessionId: string): Promise<{ readonly cwd: string; - readonly status: string; readonly isArchived: boolean; }>; } @@ -596,7 +595,7 @@ export class HostRuntimeResourceCoordinator async #assertActiveSession(sessionId: string): Promise { const header = await this.#sessionHeaders.readHeader(sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { throw new Error('Session is archived'); } } @@ -608,7 +607,7 @@ export class HostRuntimeResourceCoordinator > { try { const header = await this.#sessionHeaders.readHeader(sessionId); - return header.isArchived || header.status === 'archived' + return header.isArchived ? { code: 'session_archived', message: 'Session is archived' } : undefined; } catch (error) { diff --git a/packages/runtime-host/src/server/scheduled-task-coordinator.ts b/packages/runtime-host/src/server/scheduled-task-coordinator.ts index 7b2167b771..fe2f05f44b 100644 --- a/packages/runtime-host/src/server/scheduled-task-coordinator.ts +++ b/packages/runtime-host/src/server/scheduled-task-coordinator.ts @@ -666,7 +666,7 @@ export class HostScheduledTaskCoordinator implements ScheduledTaskToolAuthority throw new Error('Session lifecycle is changing'); } const header = await this.#sessions.readHeaderSnapshot(sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { throw new Error('Archived Sessions cannot own session-bound ScheduledTasks'); } return header; diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index b572ef3d2e..7f1d315315 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -13,7 +13,7 @@ import { isSessionStartModeLabel as isExecutionSemanticLabel, sessionStartModeSpec, } from '@maka/core/explore-agent'; -import type { SessionHeader } from '@maka/core/session'; +import type { SessionHeader, SessionHeaderPatch } from '@maka/core/session'; import { isSessionNotFoundError, SessionMetadataConflictError, @@ -388,7 +388,7 @@ export class HostSessionCatalogCoordinator { input.expectedRevision, input.patch.labels, ); - const patch: Partial = { + const patch: SessionHeaderPatch = { ...(input.patch.name === undefined ? {} : normalizeSessionNamePatch(input.patch.name)), ...(labels === undefined ? {} : { labels }), ...(input.patch.isFlagged === undefined ? {} : { isFlagged: input.patch.isFlagged }), @@ -427,7 +427,7 @@ export class HostSessionCatalogCoordinator { if (current.revision !== input.expectedRevision) { return configurationSuccess(revisionConflict(input.expectedRevision, current.revision)); } - if (current.header.isArchived || current.header.status === 'archived') { + if (current.header.isArchived) { return configurationFailure( 'operation_conflict', 'Archived Session configuration cannot be changed', diff --git a/packages/runtime-host/src/server/session-effect-coordinator.ts b/packages/runtime-host/src/server/session-effect-coordinator.ts index c4601245d4..e25feb3e70 100644 --- a/packages/runtime-host/src/server/session-effect-coordinator.ts +++ b/packages/runtime-host/src/server/session-effect-coordinator.ts @@ -192,7 +192,7 @@ export class HostSessionEffectCoordinator { } const header = await this.#readSessionHeader(input.sessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { return settledRecap( recapFailure('session_archived', 'Archived Session cannot generate a recap'), ); diff --git a/packages/runtime-host/src/server/session-retirement-coordinator.ts b/packages/runtime-host/src/server/session-retirement-coordinator.ts index 3c999109a7..a5ed0d9cac 100644 --- a/packages/runtime-host/src/server/session-retirement-coordinator.ts +++ b/packages/runtime-host/src/server/session-retirement-coordinator.ts @@ -49,7 +49,7 @@ type RetirementStores = Pick< | 'listPendingSessionRetirementCleanupIds' | 'completeSessionRetirementCleanup' | 'removeSessionsVersioned' - | 'setSessionsLifecycleVersioned' + | 'setSessionsArchivedVersioned' >; type RetirementRoot = Pick; @@ -206,12 +206,7 @@ export class HostSessionRetirementCoordinator { return await this.#withStableFamily(input.sessionId, async (family) => { const target = requireFamilyRecord(family, input.sessionId); const archived = input.state === 'archived'; - if ( - [...family.records.values()].every( - ({ header }) => - header.isArchived === archived && (header.status === 'archived') === archived, - ) - ) { + if ([...family.records.values()].every(({ header }) => header.isArchived === archived)) { return lifecycleSuccess( projectSessionCatalogRecord(await this.#stores.readCatalogRecord(input.sessionId)), ); @@ -220,7 +215,7 @@ export class HostSessionRetirementCoordinator { if (!archived) { let committed = false; try { - await this.#stores.setSessionsLifecycleVersioned(versionedFamily(family), 'active'); + await this.#stores.setSessionsArchivedVersioned(versionedFamily(family), false); committed = true; this.#goals.unarchiveSessions(family.sessionIds); await this.#refreshFamily(family); @@ -240,10 +235,7 @@ export class HostSessionRetirementCoordinator { await this.#finalizeWorkspacePatches(family.sessionIds); await this.#disposeBackends(family.sessionIds); const committable = await this.#refreshFamilyRecords(family); - await this.#stores.setSessionsLifecycleVersioned( - versionedFamily(committable), - 'archived', - ); + await this.#stores.setSessionsArchivedVersioned(versionedFamily(committable), true); committed = true; handles.goal.commit(); handles.scheduledTasks.commit(); diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index 4cb324e91e..e9282ea7fd 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -256,7 +256,7 @@ export class HostSessionRevisionCoordinator { if (sourceHeader.conversationCopy?.state === 'preparing') { return copyFailure('not_found', 'Source Session does not exist'); } - if (kind === 'revision' && (sourceHeader.isArchived || sourceHeader.status === 'archived')) { + if (kind === 'revision' && sourceHeader.isArchived) { return copyFailure( 'operation_conflict', 'Archived Session revision families cannot create active revisions', @@ -319,7 +319,7 @@ export class HostSessionRevisionCoordinator { sessionHeaders.some( (candidate) => sessionRevisionFamilyId(candidate) === sessionRevisionFamilyId(sourceHeader) && - (candidate.isArchived || candidate.status === 'archived'), + candidate.isArchived, ) ) { return copyFailure( diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 1a5dc56ba8..59db3c82f0 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { LlmConnection } from '@maka/core/llm-connections'; import { thinkingVariantsForModel, type ThinkingLevel } from '@maka/core/model-thinking'; -import { changesBackendConfig } from '@maka/runtime/session-manager'; import { buildProviderOptions, getAIModel } from '@maka/runtime/model-factory'; @@ -596,19 +595,3 @@ describe('buildProviderOptions: openai-compatible namespace', () => { ); }); }); - -describe('changesBackendConfig', () => { - test('thinkingLevel change triggers backend reconfiguration', () => { - assert.equal(changesBackendConfig({ thinkingLevel: 'high' }), true); - assert.equal(changesBackendConfig({ thinkingLevel: undefined }), true); - }); - - test('permissionMode triggers, so a mode change is enforced and not merely stored', () => { - // The backend snapshots the header at construction and decides every - // tool call against that snapshot. Persisting a lower mode without - // rebuilding leaves the live session enforcing the OLD one — which is - // how the bot guard's re-pin to `explore` became advisory. - assert.equal(changesBackendConfig({ permissionMode: 'explore' }), true); - assert.equal(changesBackendConfig({ permissionMode: 'bypass' }), true); - }); -}); diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 6ee8324c4d..0dc9608aee 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1894,8 +1894,6 @@ class ReadOnlyStore implements SessionStore { return { ...makeHeader(id), ...patch }; } - async archive(_sessionId: string): Promise {} - async unarchive(_sessionId: string): Promise {} async setFlagged(_sessionId: string, _isFlagged: boolean): Promise {} async rename(_sessionId: string, _name: string): Promise {} async remove(_sessionId: string): Promise {} diff --git a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts index de1c08abf4..c8dbbabcdf 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts @@ -641,8 +641,6 @@ function memoryStore(): SessionStore { header = { ...header, ...patch }; return header; }, - archive: async () => {}, - unarchive: async () => {}, setFlagged: async () => {}, rename: async () => {}, remove: async () => {}, diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 37b5bc2f79..ea88125ed3 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -2361,14 +2361,6 @@ class TinySessionStore implements SessionStore { return clone(next); } - async archive(sessionId: string): Promise { - await this.updateHeader(sessionId, { isArchived: true, status: 'archived' }); - } - - async unarchive(sessionId: string): Promise { - await this.updateHeader(sessionId, { isArchived: false, status: 'active' }); - } - async setFlagged(sessionId: string, isFlagged: boolean): Promise { await this.updateHeader(sessionId, { isFlagged }); } diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 01aee4ff0f..6ffe66efca 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -1574,7 +1574,7 @@ describe('SessionManager claimed graph intent execution', () => { ), ).toMatchObject({ text: 'summarize the routed records' }); - await store.archive(child.id); + await store.updateHeader(child.id, { isArchived: true }); const retry = await manager.runClaimedAgentGraphIntent({ ...graphExecutionInput(claim, 'summarize the routed records'), }); @@ -2028,7 +2028,7 @@ describe('SessionManager claimed graph intent execution', () => { ); const archivedChild = await createGraphOperatorSession(store, parent.id); - await store.archive(archivedChild.id); + await store.updateHeader(archivedChild.id, { isArchived: true }); const archivedClaim = graphIntentClaim( { claimId: `graph_claim_${'3'.repeat(32)}`, @@ -5089,46 +5089,6 @@ describe('SessionManager permission mode updates', () => { expect(modeNote.data).toEqual({ from: 'explore', to: 'ask' }); }); - test('backend configuration updates rebuild an already-active backend', async () => { - const store = new MemorySessionStore(); - const backends = new BackendRegistry(); - const built: string[] = []; - backends.register('fake', (ctx) => { - built.push( - `${ctx.header.backend}:${ctx.header.llmConnectionSlug}:${ctx.header.model}:${ctx.header.cwd}`, - ); - return new TestBackend(ctx); - }); - backends.register('ai-sdk', (ctx) => { - built.push( - `${ctx.header.backend}:${ctx.header.llmConnectionSlug}:${ctx.header.model}:${ctx.header.cwd}`, - ); - return new TestBackend(ctx); - }); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(5_000) }); - const session = await manager.createSession(makeInput()); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-1', text: 'hello' })); - expect(built).toEqual(['fake:fake:fake-model:/tmp/cwd']); - - const summary = await manager.updateSession(session.id, { - backend: 'ai-sdk', - llmConnectionSlug: 'zai-coding-plan', - model: 'glm-4.7', - cwd: '/tmp/worktree-cwd', - }); - expect(summary.backend).toBe('ai-sdk'); - expect(summary.llmConnectionSlug).toBe('zai-coding-plan'); - expect(summary.cwd).toBe('/tmp/worktree-cwd'); - expect(store.disposeCount).toBe(1); - - await drain(manager.sendMessage(session.id, { turnId: 'turn-2', text: 'again' })); - expect(built).toEqual([ - 'fake:fake:fake-model:/tmp/cwd', - 'ai-sdk:zai-coding-plan:glm-4.7:/tmp/worktree-cwd', - ]); - }); - test('starts a new turn without workspace identity when safety inspection fails', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -11912,37 +11872,6 @@ describe('SessionManager permission mode updates', () => { expect(output.budget.projectedBytes <= output.budget.maxBytes).toBe(true); }); - test('rejects backend configuration updates while a turn is actively streaming', async () => { - const store = new MemorySessionStore(); - const backends = new BackendRegistry(); - const gate = makeGate(); - backends.register('fake', (ctx) => new TestBackend(ctx, gate)); - const manager = new SessionManager({ store, backends, newId: nextId(), now: nextNow(7_000) }); - const session = await manager.createSession(makeInput()); - - const iterator = manager - .sendMessage(session.id, { turnId: 'turn-1', text: 'hello' }) - [Symbol.asyncIterator](); - await iterator.next(); - - await expectRejects( - manager.updateSession(session.id, { - backend: 'ai-sdk', - llmConnectionSlug: 'zai-coding-plan', - model: 'glm-4.7', - cwd: '/tmp/worktree-cwd', - }), - /Cannot change backend configuration while a turn is running/, - ); - const header = await store.readHeader(session.id); - expect(header.backend).toBe('fake'); - expect(header.llmConnectionSlug).toBe('fake'); - - gate.release(); - await iterator.next(); - await iterator.next(); - }); - test('backend build failure after user append writes a failed terminal run fact', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -17452,23 +17381,6 @@ class MemorySessionStore implements SessionStore { return next; } - async archive(sessionId: string): Promise { - await this.updateHeader(sessionId, { - isArchived: true, - status: 'archived', - statusUpdatedAt: 1, - }); - } - - async unarchive(sessionId: string): Promise { - await this.updateHeader(sessionId, { - isArchived: false, - status: 'active', - blockedReason: undefined, - statusUpdatedAt: 1, - }); - } - async setFlagged(sessionId: string, isFlagged: boolean): Promise { await this.updateHeader(sessionId, { isFlagged }); } diff --git a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts index 4c47e05ce3..126c071b33 100644 --- a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts @@ -814,7 +814,7 @@ describe('host-managed agent graph coordinator', () => { readHeader: async (sessionId: string) => ({ id: sessionId, - status: 'archived', + status: 'active', isArchived: true, }) as never, }, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index db08c79b64..cff60aa298 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -27,6 +27,7 @@ import { import type { SessionBlockedReason, SessionHeader, + SessionHeaderPatch, SessionStatus, StoredMessage, SystemNoteMessage, @@ -88,7 +89,7 @@ export interface AgentRunHooks { run: AgentRun, ): Promise; unregisterRun(active: AgentRunActiveSession, run: AgentRun): void | Promise; - updateHeader(sessionId: string, patch: Partial): Promise; + updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise; updateStatus( sessionId: string, status: SessionStatus, diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index a6d68274fd..b6e0c3107e 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -17,6 +17,7 @@ import type { import type { SessionBlockedReason, SessionHeader, + SessionHeaderPatch, SessionStatus, StoredMessage, SystemNoteMessage, @@ -3237,10 +3238,7 @@ export class RuntimeKernel implements RuntimeKernelLike { await this.updateHeader(sessionId, buildStatusPatch(status, ts, blockedReason)); } - private async updateHeader( - sessionId: string, - patch: Partial, - ): Promise { + private async updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise { const next = await this.deps.store.updateHeader(sessionId, patch); this.updateCachedHeader(sessionId, next); return next; diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 632db73bd9..6cd1babeb4 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -29,6 +29,7 @@ import type { import { messageContentsEqual, normalizeMessageContent } from '@maka/core/events'; import type { SessionHeader, + SessionHeaderPatch, SessionBlockedReason, SessionStatus, SessionSummary, @@ -665,10 +666,10 @@ export interface SessionStore { listTurns(sessionId: string): Promise; appendMessage(sessionId: string, m: StoredMessage): Promise; appendMessages(sessionId: string, ms: StoredMessage[]): Promise; - updateHeader(sessionId: string, patch: Partial): Promise; + updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise; updateHeaderVersioned?( sessionId: string, - patch: Partial, + patch: SessionHeaderPatch, expectedRevision: number, ): Promise; readHeaderRecordSnapshot?(sessionId: string): Promise; @@ -676,8 +677,6 @@ export interface SessionStore { sessionId: string, input: SessionConfigurationStoreUpdate, ): Promise; - archive(sessionId: string): Promise; - unarchive(sessionId: string): Promise; setFlagged(sessionId: string, isFlagged: boolean): Promise; rename(sessionId: string, name: string): Promise; setGeneratedTitleIfAbsent?(sessionId: string, title: string): Promise; @@ -1139,7 +1138,7 @@ export class SessionManager { current.revision, ); } - if (current.header.isArchived || current.header.status === 'archived') { + if (current.header.isArchived) { throw new SessionConfigurationTransitionError( 'operation_conflict', 'Archived Session configuration cannot be changed', @@ -1214,7 +1213,7 @@ export class SessionManager { current.revision, ); } - if (current.header.isArchived || current.header.status === 'archived') { + if (current.header.isArchived) { throw new SessionConfigurationTransitionError( 'operation_conflict', 'Archived Session workspace cannot be relocated', @@ -1406,7 +1405,7 @@ export class SessionManager { private async recoverInterruptedSessionsWithPolicy(policy: RecoveryPolicy): Promise { const interrupted = (await listSessionsForRecovery(this.deps.store, policy)).filter( - (session) => session.status !== 'archived', + (session) => !session.isArchived, ); const recovered = new Set(); for (const session of interrupted) { @@ -1563,54 +1562,6 @@ export class SessionManager { return [...recovered]; } - async updateSession(sessionId: string, patch: Partial): Promise { - const backendConfigChanged = changesBackendConfig(patch); - if (backendConfigChanged && this.runtimeKernel.hasActiveRuns(sessionId)) { - throw new Error('Cannot change backend configuration while a turn is running'); - } - - const { permissionMode, name, titleIsManual: _titleIsManual, ...rest } = patch; - const permissionSummary = - permissionMode === undefined - ? undefined - : await this.setPermissionMode(sessionId, permissionMode); - if (name === undefined && Object.keys(rest).length === 0) { - return permissionSummary ?? headerToSummary(await this.deps.store.readHeader(sessionId)); - } - - if (name !== undefined) await this.deps.store.rename(sessionId, name); - const next = - Object.keys(rest).length > 0 - ? await this.deps.store.updateHeader(sessionId, rest) - : await this.deps.store.readHeader(sessionId); - this.runtimeKernel.updateCachedHeader(sessionId, next); - if (changesBackendConfig(rest)) { - // AgentBackend instances snapshot backend/model config at construction - // time. If a stale session is rebound to a real default connection, the - // next turn must build a fresh backend instead of reusing FakeBackend or - // an AiSdkBackend pointed at a deleted connection. - await this.runtimeKernel.disposeBackend(sessionId); - } - return headerToSummary(next); - } - - async archive(sessionId: string): Promise { - const shellRunClose = await this.deps.shellRuns?.terminateSession(sessionId); - try { - await this.deps.store.archive(sessionId); - } catch (error) { - if (shellRunClose) this.deps.shellRuns?.rollbackSessionClose(shellRunClose); - throw error; - } - if (shellRunClose) await this.deps.shellRuns?.commitSessionClose(shellRunClose); - await this.runtimeKernel.disposeBackend(sessionId); - } - - async unarchive(sessionId: string): Promise { - await this.deps.store.unarchive(sessionId); - this.deps.shellRuns?.resumeSession(sessionId); - } - async setSessionStatus( sessionId: string, status: SessionStatus, @@ -2887,7 +2838,7 @@ export class SessionManager { if (messages.some((message) => 'turnId' in message && message.turnId === claim.targetTurnId)) { throw new Error(`Claimed graph turn ${claim.targetTurnId} already has durable messages`); } - if (child.isArchived || child.status === 'archived' || child.status === 'aborted') { + if (child.isArchived || child.status === 'aborted') { throw new Error('Claimed graph execution target child session is terminated'); } if (input.abortSignal?.aborted) { @@ -5282,10 +5233,7 @@ export class SessionManager { await this.updateHeader(sessionId, buildStatusPatch(status, ts, blockedReason)); } - private async updateHeader( - sessionId: string, - patch: Partial, - ): Promise { + private async updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise { const next = await this.deps.store.updateHeader(sessionId, patch); this.runtimeKernel.updateCachedHeader(sessionId, next); return next; @@ -6355,28 +6303,6 @@ function claimedAgentGraphIntentResult( }; } -export function changesBackendConfig(patch: Partial): boolean { - return ( - 'backend' in patch || - 'llmConnectionSlug' in patch || - 'model' in patch || - 'thinkingLevel' in patch || - 'cwd' in patch || - 'collaborationMode' in patch || - // AiSdkBackend snapshots the header at construction and ToolRuntime - // reads `header.permissionMode` at every decision, so a mode change - // that does not rebuild the backend is persisted but NOT enforced — - // the live session keeps deciding with the old mode. - // - // `setPermissionMode` disposes the backend for exactly this reason. - // `updateSession` did not, so every other path that lowers a mode was - // advisory: notably the bot-incoming guard re-pinning a conversation - // to `explore`, which left an already-built `execute`/`bypass` backend - // serving the remote sender. - 'permissionMode' in patch - ); -} - function executionBoundaryMatchesPermissionMode( boundary: ExecutionBoundary, mode: PermissionMode, diff --git a/packages/runtime/src/stream-graph-coordinator.ts b/packages/runtime/src/stream-graph-coordinator.ts index 19804e3f5a..10614d13dd 100644 --- a/packages/runtime/src/stream-graph-coordinator.ts +++ b/packages/runtime/src/stream-graph-coordinator.ts @@ -466,7 +466,7 @@ export class AgentGraphCoordinator { const recovered: string[] = []; for (const header of await this.#input.sessionStore.listForRecovery()) { if (this.#input.rootSessionId && header.id !== this.#input.rootSessionId) continue; - if (header.subagentParent || header.isArchived || header.status === 'archived') continue; + if (header.subagentParent || header.isArchived) continue; const graphId = await this.currentGraphId(header.id); const updates = await this.#input.controlStore.listAgentGraphScheduleUpdates(graphId); if (updates.length === 0) continue; @@ -1136,7 +1136,7 @@ export class AgentGraphCoordinator { async #assertRootSupervisor(rootSessionId: string): Promise { const header = await this.#assertRootGraphReader(rootSessionId); - if (header.isArchived || header.status === 'archived') { + if (header.isArchived) { throw new AgentGraphClientOperationError( 'session_archived', 'Archived Sessions cannot supervise an agent graph', diff --git a/packages/storage/src/__tests__/goal-authority.test.ts b/packages/storage/src/__tests__/goal-authority.test.ts index 2784383f41..662e31a62c 100644 --- a/packages/storage/src/__tests__/goal-authority.test.ts +++ b/packages/storage/src/__tests__/goal-authority.test.ts @@ -142,9 +142,9 @@ test('Session retirement atomically removes its Goal authority', async () => { const [archived, removed] = await Promise.all( sessions.map((session) => stores.sessionStore.readHeaderRecordSnapshot(session.id)), ); - await stores.sessionStore.setSessionsLifecycleVersioned( + await stores.sessionStore.setSessionsArchivedVersioned( [{ sessionId: archived.header.id, expectedVersion: archived.revision }], - 'archived', + true, ); await stores.sessionStore.removeSessionsVersioned([ { sessionId: removed.header.id, expectedVersion: removed.revision }, diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index b2d15d58c9..9d6fe73064 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -336,6 +336,10 @@ describe('SQLite SessionStore', () => { DROP TABLE agent_graph_epochs; DROP TABLE session_message_chunks; DROP TABLE session_message_payloads; + ALTER TABLE session_metadata ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; + ALTER TABLE session_metadata ADD COLUMN status_updated_at INTEGER; + CREATE INDEX session_metadata_by_status + ON session_metadata(status, status_updated_at DESC, session_id); 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 01e9cc8edf..8139b9d6a6 100644 --- a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -15,7 +15,7 @@ import { MAX_EXECUTION_BOUNDARY_SERIALIZED_BYTES, type SandboxBoundarySettlement, } from '@maka/core/sandbox-boundary'; -import { type SessionHeader } from '@maka/core/session'; +import type { SessionHeader, SessionHeaderPatch } from '@maka/core/session'; import type { AgentGraphOperatorProvisionRequest } from '@maka/core/agent-graph-topology'; import { createSqliteSessionMetadataStore, @@ -23,6 +23,7 @@ import { SessionMetadataVersionConflictError, SQLITE_SESSION_METADATA_SCHEMA_VERSION, StoredSessionMessageIncompatibleError, + type SessionConfigurationMetadataUpdate, type SqliteSessionMetadataStoreFailpoint, } from '../sqlite-session-metadata-store.js'; import { @@ -159,7 +160,6 @@ describe('SqliteSessionMetadataStore', () => { >(); const persistedRowsAfterMigration: Array<{ readonly sessionId: string; - readonly status: string; readonly payloadStatus: string; readonly metadataVersion: number; }> = []; @@ -179,6 +179,16 @@ describe('SqliteSessionMetadataStore', () => { const legacy = new DatabaseSync(path); try { + legacy.exec(` + ALTER TABLE session_metadata ADD COLUMN status TEXT; + ALTER TABLE session_metadata ADD COLUMN status_updated_at INTEGER; + UPDATE session_metadata + SET + status = json_extract(payload_json, '$.status'), + status_updated_at = json_extract(payload_json, '$.statusUpdatedAt'); + CREATE INDEX session_metadata_by_status + ON session_metadata(status, status_updated_at DESC, session_id); + `); legacy .prepare( ` @@ -277,7 +287,6 @@ describe('SqliteSessionMetadataStore', () => { ` SELECT session_id AS sessionId, - status, json_extract(payload_json, '$.status') AS payloadStatus, metadata_version AS metadataVersion FROM session_metadata @@ -286,7 +295,6 @@ describe('SqliteSessionMetadataStore', () => { ) .all() as Array<{ readonly sessionId: string; - readonly status: string; readonly payloadStatus: string; readonly metadataVersion: number; }> @@ -295,25 +303,21 @@ describe('SqliteSessionMetadataStore', () => { assert.deepEqual(rows, [ { sessionId: 'legacy-both', - status: 'active', payloadStatus: 'active', metadataVersion: 18, }, { sessionId: 'legacy-done', - status: 'active', payloadStatus: 'active', metadataVersion: 12, }, { sessionId: 'legacy-review', - status: 'active', payloadStatus: 'active', metadataVersion: 8, }, { sessionId: 'legacy-unchanged', - status: 'active', payloadStatus: 'active', metadataVersion: 13, }, @@ -347,7 +351,6 @@ describe('SqliteSessionMetadataStore', () => { ` SELECT session_id AS sessionId, - status, json_extract(payload_json, '$.status') AS payloadStatus FROM session_metadata ORDER BY session_id @@ -355,15 +358,13 @@ describe('SqliteSessionMetadataStore', () => { ) .all() as Array<{ readonly sessionId: string; - readonly status: string; readonly payloadStatus: string; }> ).map((row) => ({ ...row })); assert.deepEqual( rows, - persistedRowsAfterMigration.map(({ sessionId, status, payloadStatus }) => ({ + persistedRowsAfterMigration.map(({ sessionId, payloadStatus }) => ({ sessionId, - status, payloadStatus, })), ); @@ -375,6 +376,349 @@ describe('SqliteSessionMetadataStore', () => { } }); + test('migrates v26 archive signals onto one canonical archive field exactly once', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-archive-v26-')); + const path = join(root, 'state.sqlite'); + const changedSessionIds = [ + 'json-only', + 'sql-only', + 'sql-status-only', + 'json-status', + 'archived-at-only', + 'missing-json-false', + ] as const; + try { + const setup = createSqliteSessionMetadataStore(path, { now: () => 10 }); + await setup.create(fullHeader({ id: 'active-unchanged' })); + await setup.create(fullHeader({ id: 'missing-json-false' })); + await setup.create(fullHeader({ id: 'canonical-archived', isArchived: true })); + await setup.create( + fullHeader({ + id: 'json-only', + status: 'blocked', + blockedReason: 'tool_failed', + statusUpdatedAt: 101, + }), + ); + await setup.create( + fullHeader({ + id: 'sql-only', + status: 'blocked', + blockedReason: 'tool_failed', + statusUpdatedAt: 151, + }), + ); + await setup.create( + fullHeader({ + id: 'sql-status-only', + status: 'blocked', + blockedReason: 'permission_required', + statusUpdatedAt: 202, + }), + ); + await setup.create( + fullHeader({ + id: 'json-status', + status: 'blocked', + blockedReason: 'auth', + statusUpdatedAt: 303, + }), + ); + await setup.create( + fullHeader({ + id: 'archived-at-only', + status: 'blocked', + blockedReason: 'unknown', + statusUpdatedAt: 404, + }), + ); + setup.close(); + + const legacy = new DatabaseSync(path); + try { + legacy.exec(` + ALTER TABLE session_metadata ADD COLUMN status TEXT; + ALTER TABLE session_metadata ADD COLUMN status_updated_at INTEGER; + UPDATE session_metadata + SET + status = json_extract(payload_json, '$.status'), + status_updated_at = json_extract(payload_json, '$.statusUpdatedAt'); + CREATE INDEX session_metadata_by_status + ON session_metadata(status, status_updated_at DESC, session_id); + UPDATE session_metadata_schema SET version = 26 WHERE scope = 'session_metadata'; + `); + legacy + .prepare( + `UPDATE session_metadata + SET payload_json = json_set(payload_json, '$.isArchived', json('true')) + WHERE session_id = 'json-only'`, + ) + .run(); + legacy + .prepare(`UPDATE session_metadata SET is_archived = 1 WHERE session_id = 'sql-only'`) + .run(); + legacy + .prepare( + `UPDATE session_metadata SET status = 'archived' WHERE session_id = 'sql-status-only'`, + ) + .run(); + legacy + .prepare( + `UPDATE session_metadata + SET payload_json = json_set(payload_json, '$.status', 'archived') + WHERE session_id = 'json-status'`, + ) + .run(); + legacy + .prepare( + `UPDATE session_metadata + SET payload_json = json_set(payload_json, '$.archivedAt', 505) + WHERE session_id = 'archived-at-only'`, + ) + .run(); + legacy + .prepare( + `UPDATE session_metadata + SET payload_json = json_remove(payload_json, '$.isArchived') + WHERE session_id = 'missing-json-false'`, + ) + .run(); + } finally { + legacy.close(); + } + + const migrationStartedAt = Date.now(); + const migrated = createSqliteSessionMetadataStore(path, { now: () => 20 }); + const snapshots = new Map(); + try { + assert.equal(migrated.schemaVersion(), SQLITE_SESSION_METADATA_SCHEMA_VERSION); + for (const sessionId of ['active-unchanged', 'canonical-archived']) { + const record = await migrated.read(sessionId); + assert.equal(record.metadataVersion, 1); + assert.equal(record.committedAt, 10); + snapshots.set(sessionId, { + metadataVersion: record.metadataVersion, + committedAt: record.committedAt, + }); + } + assert.equal((await migrated.read('active-unchanged')).header.isArchived, false); + assert.equal((await migrated.read('canonical-archived')).header.isArchived, true); + + for (const sessionId of changedSessionIds) { + const record = await migrated.read(sessionId); + assert.equal(record.header.isArchived, sessionId !== 'missing-json-false'); + assert.equal(record.metadataVersion, 2); + assert.ok(record.committedAt >= migrationStartedAt); + assert.equal('archivedAt' in record.header, false); + snapshots.set(sessionId, { + metadataVersion: record.metadataVersion, + committedAt: record.committedAt, + }); + } + + const jsonStatus = await migrated.read('json-status'); + assert.equal(jsonStatus.header.status, 'active'); + assert.equal(jsonStatus.header.blockedReason, undefined); + assert.equal(jsonStatus.header.statusUpdatedAt, undefined); + + for (const [sessionId, blockedReason, statusUpdatedAt] of [ + ['json-only', 'tool_failed', 101], + ['sql-only', 'tool_failed', 151], + ['sql-status-only', 'permission_required', 202], + ['archived-at-only', 'unknown', 404], + ] as const) { + const record = await migrated.read(sessionId); + assert.equal(record.header.status, 'blocked'); + assert.equal(record.header.blockedReason, blockedReason); + assert.equal(record.header.statusUpdatedAt, statusUpdatedAt); + } + } finally { + migrated.close(); + } + + const persisted = new DatabaseSync(path); + try { + const columns = persisted + .prepare('PRAGMA table_info(session_metadata)') + .all() as unknown as Array<{ readonly name: string }>; + assert.equal( + columns.some(({ name }) => name === 'status'), + false, + ); + assert.equal( + columns.some(({ name }) => name === 'status_updated_at'), + false, + ); + assert.equal( + persisted + .prepare( + "SELECT 1 AS found FROM sqlite_schema WHERE type = 'index' AND name = 'session_metadata_by_status'", + ) + .get(), + undefined, + ); + const archiveRows = persisted + .prepare( + `SELECT + session_id AS sessionId, + is_archived AS sqlArchived, + json_type(payload_json, '$.isArchived') AS jsonArchivedType, + json_type(payload_json, '$.archivedAt') AS archivedAtType + FROM session_metadata + ORDER BY session_id`, + ) + .all() as unknown as Array<{ + readonly sessionId: string; + readonly sqlArchived: number; + readonly jsonArchivedType: string; + readonly archivedAtType: string | null; + }>; + for (const row of archiveRows) { + const expectedArchived = + row.sessionId !== 'active-unchanged' && row.sessionId !== 'missing-json-false'; + assert.equal(row.sqlArchived, expectedArchived ? 1 : 0); + assert.equal(row.jsonArchivedType, expectedArchived ? 'true' : 'false'); + assert.equal(row.archivedAtType, null); + } + } finally { + persisted.close(); + } + + const reopened = createSqliteSessionMetadataStore(path, { now: () => 30 }); + try { + for (const [sessionId, snapshot] of snapshots) { + const record = await reopened.read(sessionId); + assert.deepEqual( + { metadataVersion: record.metadataVersion, committedAt: record.committedAt }, + snapshot, + ); + } + } finally { + reopened.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects a session metadata schema newer than the supported version', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-schema-fence-')); + const path = join(root, 'state.sqlite'); + const newerSchemaVersion = SQLITE_SESSION_METADATA_SCHEMA_VERSION + 1; + try { + const setup = createSqliteSessionMetadataStore(path); + setup.close(); + const newer = new DatabaseSync(path); + try { + newer + .prepare( + `UPDATE session_metadata_schema SET version = ? WHERE scope = 'session_metadata'`, + ) + .run(newerSchemaVersion); + } finally { + newer.close(); + } + assert.throws( + () => createSqliteSessionMetadataStore(path), + new RegExp( + `schema ${newerSchemaVersion} is newer than supported version ${SQLITE_SESSION_METADATA_SCHEMA_VERSION}`, + 'u', + ), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('changes archive state without overwriting execution status', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: () => 100 }); + try { + const header = fullHeader({ + status: 'blocked', + blockedReason: 'tool_failed', + statusUpdatedAt: 20, + }); + await store.create(header); + + const [archived] = await store.setArchivedVersioned( + [{ sessionId: header.id, expectedVersion: 1 }], + true, + ); + + assert.equal(archived?.header.isArchived, true); + assert.equal(archived?.header.status, 'blocked'); + assert.equal(archived?.header.blockedReason, 'tool_failed'); + assert.equal(archived?.header.statusUpdatedAt, 20); + assert.equal('archivedAt' in (archived?.header ?? {}), false); + + const [restored] = await store.setArchivedVersioned( + [{ sessionId: header.id, expectedVersion: 2 }], + false, + ); + + assert.equal(restored?.header.isArchived, false); + assert.equal(restored?.header.status, 'blocked'); + assert.equal(restored?.header.blockedReason, 'tool_failed'); + assert.equal(restored?.header.statusUpdatedAt, 20); + + const unchanged = await store.setArchivedVersioned( + [{ sessionId: header.id, expectedVersion: 3 }], + false, + ); + assert.equal(unchanged[0]?.metadataVersion, 3); + assert.equal(unchanged[0]?.committedAt, restored?.committedAt); + } finally { + store.close(); + } + }); + + test('rejects Session lifecycle fields through generic metadata writes', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + const header = fullHeader(); + await store.create(header); + + await assert.rejects( + store.update(header.id, { isArchived: true } as unknown as SessionHeaderPatch), + /Session archive state requires the dedicated lifecycle writer/u, + ); + await assert.rejects( + store.update(header.id, { archivedAt: 123 } as unknown as SessionHeaderPatch), + /Invalid session header/u, + ); + await assert.rejects( + store.updateSessionConfiguration(header.id, { + expectedVersion: 1, + configuration: { + backend: header.backend, + llmConnectionSlug: header.llmConnectionSlug, + connectionLocked: header.connectionLocked, + model: header.model, + thinkingLevel: header.thinkingLevel, + permissionMode: header.permissionMode, + collaborationMode: header.collaborationMode ?? 'agent', + orchestrationMode: header.orchestrationMode ?? 'default', + labels: header.labels, + isArchived: true, + } as unknown as SessionConfigurationMetadataUpdate['configuration'], + lifecycle: { kind: 'preserve' }, + }), + /Session archive state requires the dedicated lifecycle writer/u, + ); + await assert.rejects( + store.create({ ...fullHeader({ id: 'polluted' }), archivedAt: 123 } as SessionHeader), + /Invalid session header/u, + ); + + const current = await store.read(header.id); + assert.equal(current.metadataVersion, 1); + assert.equal(current.header.isArchived, false); + assert.equal('archivedAt' in current.header, false); + } finally { + store.close(); + } + }); + test('atomically retires a revision family with CAS and tombstone retries', async () => { const store = createSqliteSessionMetadataStore(':memory:', { now: () => 100 }); const root = fullHeader({ @@ -387,7 +731,6 @@ describe('SqliteSessionMetadataStore', () => { revisionIndex: undefined, revisionState: undefined, isArchived: false, - archivedAt: undefined, status: 'active', blockedReason: undefined, }); @@ -401,7 +744,6 @@ describe('SqliteSessionMetadataStore', () => { revisionIndex: 2, revisionState: 'committed', isArchived: false, - archivedAt: undefined, status: 'active', blockedReason: undefined, }); @@ -410,12 +752,12 @@ describe('SqliteSessionMetadataStore', () => { await store.create(revision); await assert.rejects( - store.setLifecycleVersioned( + store.setArchivedVersioned( [ { sessionId: root.id, expectedVersion: 1 }, { sessionId: revision.id, expectedVersion: 2 }, ], - 'archived', + true, ), SessionMetadataVersionConflictError, ); @@ -425,22 +767,23 @@ describe('SqliteSessionMetadataStore', () => { assert.equal(current.header.isArchived, false); } - const archived = await store.setLifecycleVersioned( + const archived = await store.setArchivedVersioned( [ { sessionId: root.id, expectedVersion: 1 }, { sessionId: revision.id, expectedVersion: 1 }, ], - 'archived', + true, ); assert.deepEqual( archived.map((record) => ({ id: record.header.id, revision: record.metadataVersion, + isArchived: record.header.isArchived, status: record.header.status, })), [ - { id: revision.id, revision: 2, status: 'archived' }, - { id: root.id, revision: 2, status: 'archived' }, + { id: revision.id, revision: 2, isArchived: true, status: 'active' }, + { id: root.id, revision: 2, isArchived: true, status: 'active' }, ], ); @@ -1237,8 +1580,7 @@ describe('SqliteSessionMetadataStore', () => { id: 'archived', name: 'Archived', isArchived: true, - archivedAt: 50, - status: 'archived', + status: 'active', blockedReason: undefined, lastMessageAt: 50, labels: ['shared'], diff --git a/packages/storage/src/execution-stores.ts b/packages/storage/src/execution-stores.ts index ff2dec15de..1c760c69df 100644 --- a/packages/storage/src/execution-stores.ts +++ b/packages/storage/src/execution-stores.ts @@ -395,16 +395,14 @@ async function createExecutionStoresForWrite sessionStore.updateSessionConfiguration(sessionId, input)), markSessionReadThroughMessage: (sessionId, messageId) => run(() => sessionStore.markSessionReadThroughMessage(sessionId, messageId)), - archive: (sessionId) => run(() => sessionStore.archive(sessionId)), - unarchive: (sessionId) => run(() => sessionStore.unarchive(sessionId)), setFlagged: (sessionId, isFlagged) => run(() => sessionStore.setFlagged(sessionId, isFlagged)), rename: (sessionId, name) => run(() => sessionStore.rename(sessionId, name)), setGeneratedTitleIfAbsent: (sessionId, title) => run(() => sessionStore.setGeneratedTitleIfAbsent(sessionId, title)), remove: (sessionId) => run(() => sessionStore.remove(sessionId)), - setSessionsLifecycleVersioned: (sessions, state) => - run(() => sessionStore.setSessionsLifecycleVersioned(sessions, state)), + setSessionsArchivedVersioned: (sessions, isArchived) => + run(() => sessionStore.setSessionsArchivedVersioned(sessions, isArchived)), removeSessionsVersioned: (sessions) => run(() => sessionStore.removeSessionsVersioned(sessions)), reconcileOrphanedAgentGraphRetirements: () => diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 02eb0ec807..335d9938dc 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -51,6 +51,7 @@ import type { CreateSessionInput, SessionListFilter } from '@maka/core/runtime-i import { isSessionToolProfile, type SessionHeader, + type SessionHeaderPatch, type SessionConversationCopy, type SessionSummary, type StoredMessage, @@ -259,9 +260,7 @@ export interface SessionStore { listTurns(sessionId: string): Promise; appendMessage(sessionId: string, message: StoredMessage): Promise; appendMessages(sessionId: string, messages: StoredMessage[]): Promise; - updateHeader(sessionId: string, patch: Partial): Promise; - archive(sessionId: string): Promise; - unarchive(sessionId: string): Promise; + updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise; setFlagged(sessionId: string, isFlagged: boolean): Promise; rename(sessionId: string, name: string): Promise; setGeneratedTitleIfAbsent(sessionId: string, title: string): Promise; @@ -335,7 +334,7 @@ export interface SessionAuthorityStore extends SessionStore { readCatalogRecord(sessionId: string): Promise; updateHeaderVersioned( sessionId: string, - patch: Partial, + patch: SessionHeaderPatch, expectedRevision: number, ): Promise; updateSessionConfiguration( @@ -347,9 +346,9 @@ export interface SessionAuthorityStore extends SessionStore { messageId: string, ): Promise; probeSessionRemoval(sessionId: string): Promise; - setSessionsLifecycleVersioned( + setSessionsArchivedVersioned( sessions: readonly VersionedSessionIdentity[], - state: 'active' | 'archived', + isArchived: boolean, ): Promise; removeSessionsVersioned(sessions: readonly VersionedSessionIdentity[]): Promise; reconcileOrphanedAgentGraphRetirements(): Promise; @@ -773,14 +772,14 @@ class SqliteSessionStore implements SessionAuthorityStore { return () => this.transcriptChangeListeners.delete(listener); } - async updateHeader(sessionId: string, patch: Partial): Promise { + async updateHeader(sessionId: string, patch: SessionHeaderPatch): Promise { await this.ensureReady(); return (await this.metadata.update(sessionId, patch)).header; } async updateHeaderVersioned( sessionId: string, - patch: Partial, + patch: SessionHeaderPatch, expectedRevision: number, ): Promise { await this.ensureReady(); @@ -841,12 +840,14 @@ class SqliteSessionStore implements SessionAuthorityStore { return projectRemovalProbe(await this.metadata.probeRemoval(sessionId)); } - async setSessionsLifecycleVersioned( + async setSessionsArchivedVersioned( sessions: readonly VersionedSessionIdentity[], - state: 'active' | 'archived', + isArchived: boolean, ): Promise { await this.ensureReady(); - return (await this.metadata.setLifecycleVersioned(sessions, state)).map(projectHeaderSnapshot); + return (await this.metadata.setArchivedVersioned(sessions, isArchived)).map( + projectHeaderSnapshot, + ); } async removeSessionsVersioned(sessions: readonly VersionedSessionIdentity[]): Promise { @@ -869,26 +870,6 @@ class SqliteSessionStore implements SessionAuthorityStore { await this.metadata.completeSessionRetirementCleanup(sessionId); } - async archive(sessionId: string): Promise { - const now = Date.now(); - await this.updateHeader(sessionId, { - isArchived: true, - archivedAt: now, - status: 'archived', - statusUpdatedAt: now, - }); - } - - async unarchive(sessionId: string): Promise { - await this.updateHeader(sessionId, { - isArchived: false, - archivedAt: undefined, - status: 'active', - blockedReason: undefined, - statusUpdatedAt: Date.now(), - }); - } - async setFlagged(sessionId: string, isFlagged: boolean): Promise { await this.updateHeader(sessionId, { isFlagged }); } @@ -1027,7 +1008,7 @@ export function normalizeSessionHeader( Array.isArray(header.labels) && header.labels.every((label) => typeof label === 'string') && typeof header.isArchived === 'boolean' && - (header.archivedAt === undefined || isFiniteNumber(header.archivedAt)) && + !Object.prototype.hasOwnProperty.call(header, 'archivedAt') && isSessionStatus(header.status) && (header.blockedReason === undefined || isSessionBlockedReason(header.blockedReason)) && (header.statusUpdatedAt === undefined || isFiniteNumber(header.statusUpdatedAt)) && diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts index fce3f2a010..f693d89e23 100644 --- a/packages/storage/src/sqlite-session-metadata-schema.ts +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -1,6 +1,6 @@ import type { DatabaseSync } from 'node:sqlite'; -export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 26; +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 27; export const SQLITE_SESSION_MESSAGE_CHUNK_BYTES = 64 * 1024; export const SQLITE_SESSION_MESSAGE_CHUNK_MARKER = '{"$maka":"session-message-chunks-v1"}'; @@ -948,6 +948,77 @@ const MIGRATIONS: ReadonlyMap = new Map([ DROP TABLE IF EXISTS session_metadata_labels; `, ], + [ + 27, + ` + UPDATE session_metadata + SET + payload_json = json_set( + CASE + WHEN json_extract(payload_json, '$.status') = 'archived' + THEN json_remove( + json_set(payload_json, '$.status', 'active'), + '$.archivedAt', + '$.blockedReason', + '$.statusUpdatedAt' + ) + ELSE json_remove(payload_json, '$.archivedAt') + END, + '$.isArchived', + CASE + WHEN + json_type(payload_json, '$.isArchived') = 'true' + OR json_extract(payload_json, '$.status') = 'archived' + OR is_archived = 1 + OR status = 'archived' + OR json_type(payload_json, '$.archivedAt') IS NOT NULL + THEN json('true') + ELSE json('false') + END + ), + is_archived = CASE + WHEN + json_type(payload_json, '$.isArchived') = 'true' + OR json_extract(payload_json, '$.status') = 'archived' + OR is_archived = 1 + OR status = 'archived' + OR json_type(payload_json, '$.archivedAt') IS NOT NULL + THEN 1 + ELSE 0 + END, + metadata_version = metadata_version + 1, + committed_at = MAX( + committed_at, + CAST(unixepoch('now', 'subsec') * 1000 AS INTEGER) + ) + WHERE + json_extract(payload_json, '$.status') = 'archived' + OR status = 'archived' + OR json_type(payload_json, '$.archivedAt') IS NOT NULL + OR ( + ( + json_type(payload_json, '$.isArchived') = 'true' + OR is_archived = 1 + ) + AND ( + json_type(payload_json, '$.isArchived') IS NOT 'true' + OR is_archived != 1 + ) + ) + OR ( + json_type(payload_json, '$.isArchived') IS NOT 'true' + AND is_archived != 1 + AND ( + json_type(payload_json, '$.isArchived') IS NOT 'false' + OR is_archived != 0 + ) + ); + + DROP INDEX session_metadata_by_status; + ALTER TABLE session_metadata DROP COLUMN status; + ALTER TABLE session_metadata DROP COLUMN status_updated_at; + `, + ], ]); export function configureSqliteSessionMetadataDatabase(db: DatabaseSync): void { diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts index 2dc467bca0..5718e239df 100644 --- a/packages/storage/src/sqlite-session-metadata-store.ts +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -70,6 +70,7 @@ import { isSubagentSessionRuntime, isSubagentSessionSpawn, type SessionHeader, + type SessionHeaderPatch, type StoredMessage, type SubagentSessionParent, decodeStoredMessage, @@ -3310,7 +3311,7 @@ export class SqliteSessionMetadataStore { async update( sessionId: string, - patch: Partial, + patch: SessionHeaderPatch, options: { expectedVersion?: number; skipNoop?: boolean } = {}, ): Promise { this.assertOpen(); @@ -3327,39 +3328,27 @@ export class SqliteSessionMetadataStore { if (Object.prototype.hasOwnProperty.call(patch, 'subagentWorkspace')) { throw new Error('Subagent session workspace binding is immutable'); } - return this.transaction(() => this.updateHeaderSync(sessionId, patch, options)); + return this.transaction(() => + this.updateHeaderSync(sessionId, patch, { + ...(options.expectedVersion === undefined + ? {} + : { expectedVersion: options.expectedVersion }), + ...(options.skipNoop === undefined ? {} : { skipNoop: options.skipNoop }), + }), + ); } - async setLifecycleVersioned( + async setArchivedVersioned( sessions: readonly VersionedSessionIdentity[], - state: 'active' | 'archived', + isArchived: boolean, ): Promise { this.assertOpen(); const identities = uniqueVersionedSessionIdentities(sessions); - const now = this.now(); - const patch: Partial = - state === 'archived' - ? { - isArchived: true, - archivedAt: now, - status: 'archived', - statusUpdatedAt: now, - } - : { - isArchived: false, - archivedAt: undefined, - status: 'active', - blockedReason: undefined, - statusUpdatedAt: now, - }; return this.transaction(() => { const records = identities.map(({ sessionId, expectedVersion }) => - this.updateHeaderSync(sessionId, patch, { - expectedVersion, - skipNoop: true, - }), + this.setArchivedSync(sessionId, expectedVersion, isArchived), ); - if (state === 'archived') this.deleteGoalAuthorities(identities); + if (isArchived) this.deleteGoalAuthorities(identities); return records; }); } @@ -3489,8 +3478,6 @@ export class SqliteSessionMetadataStore { name, is_flagged, is_archived, - status, - status_updated_at, parent_session_id, subagent_parent_session_id, subagent_parent_run_id, @@ -3508,7 +3495,7 @@ export class SqliteSessionMetadataStore { model, metadata_version, committed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .run( @@ -3520,8 +3507,6 @@ export class SqliteSessionMetadataStore { header.name, booleanInteger(header.isFlagged), booleanInteger(header.isArchived), - header.status, - header.statusUpdatedAt ?? null, header.parentSessionId ?? null, header.subagentParent?.parentSessionId ?? null, header.subagentParent?.spawnedBy.parentRunId ?? null, @@ -3700,13 +3685,16 @@ export class SqliteSessionMetadataStore { private updateHeaderSync( sessionId: string, - patch: Partial, + patch: SessionHeaderPatch, options: { expectedVersion?: number; skipNoop?: boolean; catalogPreview?: { readonly kind: 'replace'; readonly value?: string }; } = {}, ): SessionMetadataRecord { + if (Object.prototype.hasOwnProperty.call(patch, 'isArchived')) { + throw new Error('Session archive state requires the dedicated lifecycle writer'); + } const current = this.readRecordSync(sessionId); if (!current) throw new SessionNotFoundError(sessionId); if ( @@ -3720,7 +3708,43 @@ export class SqliteSessionMetadataStore { ); } assertConversationCopyTransition(current.header, patch); - const next = normalizeSessionHeader({ ...current.header, ...patch }, sessionId); + const next = normalizeSessionHeader( + { + ...current.header, + ...patch, + }, + sessionId, + ); + return this.persistHeaderSync(sessionId, current, next, options); + } + + private setArchivedSync( + sessionId: string, + expectedVersion: number, + isArchived: boolean, + ): SessionMetadataRecord { + const current = this.readRecordSync(sessionId); + if (!current) throw new SessionNotFoundError(sessionId); + if (expectedVersion !== current.metadataVersion) { + throw new SessionMetadataVersionConflictError( + sessionId, + expectedVersion, + current.metadataVersion, + ); + } + const next = normalizeSessionHeader({ ...current.header, isArchived }, sessionId); + return this.persistHeaderSync(sessionId, current, next, { skipNoop: true }); + } + + private persistHeaderSync( + sessionId: string, + current: SessionMetadataRecord, + next: SessionHeader, + options: { + skipNoop?: boolean; + catalogPreview?: { readonly kind: 'replace'; readonly value?: string }; + } = {}, + ): SessionMetadataRecord { if (next.id !== sessionId) { throw new SessionMetadataConflictError('Session metadata identity cannot be changed'); } @@ -3745,8 +3769,6 @@ export class SqliteSessionMetadataStore { name = ?, is_flagged = ?, is_archived = ?, - status = ?, - status_updated_at = ?, parent_session_id = ?, subagent_parent_session_id = ?, revision_root_session_id = ?, @@ -3768,8 +3790,6 @@ export class SqliteSessionMetadataStore { next.name, booleanInteger(next.isFlagged), booleanInteger(next.isArchived), - next.status, - next.statusUpdatedAt ?? null, next.parentSessionId ?? null, next.subagentParent?.parentSessionId ?? null, next.revisionRootSessionId ?? null, @@ -3817,7 +3837,7 @@ export class SqliteSessionMetadataStore { }, options: { expectedVersion?: number; - headerPatch?: Partial; + headerPatch?: SessionHeaderPatch; } = {}, ): { boundary: ExecutionBoundary; record: SessionMetadataRecord } { const record = this.readRecordSync(sessionId); @@ -5178,10 +5198,7 @@ function assertSessionCreateFingerprint(value: string): void { } } -function assertConversationCopyTransition( - current: SessionHeader, - patch: Partial, -): void { +function assertConversationCopyTransition(current: SessionHeader, patch: SessionHeaderPatch): void { if (!Object.prototype.hasOwnProperty.call(patch, 'conversationCopy')) return; if (!isValidConversationCopyTransition(current, patch.conversationCopy)) { throw new SessionMetadataConflictError('Session conversation-copy identity is immutable'); diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 5ac70efdab..8e0408691c 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -432,7 +432,7 @@ const CONVERSATION_COPY = { revisionVersionsAriaLabel: '任务版本', revisionVersion: (current, total) => `版本 ${current} / ${total}`, previousRevision: '查看上一版本', nextRevision: '查看下一版本', }, sessions: { - status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', archived: '已归档', aborted: '已中止' }, + status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', aborted: '已中止' }, blockedReason: { NO_REAL_CONNECTION: '等待配置可用模型连接', auth: '需要重新登录', permission_required: '等待权限确认', tool_failed: '工具调用失败', unknown: '运行中断,可重试' }, listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: '任务操作', pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, }, @@ -570,7 +570,7 @@ const CONVERSATION_COPY = { revisionVersionsAriaLabel: 'Task versions', revisionVersion: (current, total) => `Version ${current} of ${total}`, previousRevision: 'View previous version', nextRevision: 'View next version', }, sessions: { - status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', archived: 'Archived', aborted: 'Stopped' }, + status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', aborted: 'Stopped' }, blockedReason: { NO_REAL_CONNECTION: 'Waiting for an available model connection', auth: 'Sign in again', permission_required: 'Waiting for permission', tool_failed: 'Tool call failed', unknown: 'Run interrupted; retry available' }, listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: 'Task actions', pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, }, diff --git a/packages/ui/src/session-status-presentation.ts b/packages/ui/src/session-status-presentation.ts index 1b0d0b2270..2a87f1b3a5 100644 --- a/packages/ui/src/session-status-presentation.ts +++ b/packages/ui/src/session-status-presentation.ts @@ -31,18 +31,17 @@ export interface SessionStatusPresentation { * * `undefined` means no dot: `active` is the resting state and the rail does not * mark a task for being ordinary. Everything else gets one, including - * `archived` and `aborted` — they were `muted` before this change and `muted` - * resolved to a real `neutral` dot, so dropping them to `undefined` was a - * behaviour change, not a consequence of collapsing the layer. Without a dot - * they fell through to the unread branch and an aborted task with unread text - * drew the same accent dot as one that is running. + * `aborted` — it was `muted` before this change and `muted` resolved to a real + * `neutral` dot, so dropping it to `undefined` was a behaviour change, not a + * consequence of collapsing the layer. Without a dot it fell through to the + * unread branch and an aborted task with unread text drew the same accent dot + * as one that is running. */ const STATUS_SEMANTIC: Record = { active: undefined, running: 'active', waiting_for_user: 'attention', blocked: 'attention', - archived: 'neutral', aborted: 'neutral', }; diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 486bd0a445..ea0b077dd2 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -36,12 +36,11 @@ function makeSession(input: { llmConnectionSlug?: string; }): SessionSummary { const status = input.status ?? 'active'; - const isArchived = input.isArchived ?? status === 'archived'; return { id: input.id, name: input.name, isFlagged: input.isFlagged ?? false, - isArchived, + isArchived: input.isArchived ?? false, labels: [], hasUnread: input.hasUnread ?? false, status, @@ -184,12 +183,6 @@ const statusSessions = [ blockedReason: 'auth', lastMessageAt: NOW - 20 * 60 * 1000, }), - makeSession({ - id: 'status-archived', - name: '归档的旧实验', - status: 'archived', - lastMessageAt: NOW - 8 * 24 * 60 * 60 * 1000, - }), makeSession({ id: 'status-aborted', name: '中止的临时尝试',