diff --git a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts index cb7b5d0a57..1b23ca1166 100644 --- a/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts +++ b/apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts @@ -88,6 +88,31 @@ test('resolves persisted locale before first post-settings recovery prompt', () assert.doesNotMatch(defaultHostRecovery, /resolveSystemUiLocale/u); }); +test('lets the Runtime Host migrate its State Root before Desktop opens shared tables', () => { + const hostStart = bootSource.indexOf( + 'runtimeHostManager = await startDesktopRuntimeHostWithRecovery', + ); + const workBoardOpen = bootSource.indexOf( + 'store: createWorkBoardStore(workspaceRoot', + ); + const sessionCopyOpen = bootSource.indexOf( + 'createSessionCopyCleanupAuthority({', + ); + + assert.notEqual(hostStart, -1); + assert.notEqual(workBoardOpen, -1); + assert.notEqual(sessionCopyOpen, -1); + assert.ok(hostStart < workBoardOpen); + assert.match( + bootSource.slice(workBoardOpen, bootSource.indexOf('});', workBoardOpen)), + /schemaMigration: 'require_current'/u, + ); + assert.match( + bootSource.slice(sessionCopyOpen, bootSource.indexOf('}),', sessionCopyOpen)), + /schemaMigration: 'require_current'/u, + ); +}); + test('routes the first-paint IPC only to the active Renderer recovery listener', () => { const ipcHandlerStart = appIpcSource.indexOf( "targetIpc.handle('window:notifyRendererReady'", diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 608ebfd96c..20cbacff8d 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -442,7 +442,6 @@ resolveBrowserDialogAppearance = async () => { } }; const mcpConfigStore = createMcpConfigStore(workspaceRoot); -const workBoardStore = createWorkBoardStore(workspaceRoot); const mcpManager = new McpClientManager({ clientName: "maka-desktop", clientVersion: app.getVersion(), @@ -953,12 +952,6 @@ mcpManager.onChange(() => { registerPersistentClientIpc(); registerPetPackIpc({ ipcMain, workspaceRoot, mainWindowController, settingsStore }); -const workBoardIpc = registerWorkBoardIpc({ - ipcMain, - workspaceRoot, - mainWindowController, - store: workBoardStore, -}); const browserIpc = registerBrowserIpc({ mainWindowController, isHostActive: (scope) => runtimeHostManager?.ownsScope(scope) === true, @@ -1112,6 +1105,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( removeSession, resumeSessionCopy, processId: sessionCopyOwnerProcessId, + databaseOptions: { schemaMigration: 'require_current' }, }), renderer: mainWindowController, onError: (error) => @@ -1288,6 +1282,15 @@ runtimeHostManager = await startDesktopRuntimeHostWithRecovery({ } throw error; }); +// Runtime Host is the only schema-migration authority for its State Root. +// Work Board remains a Desktop-owned table, but it opens only after the Host is +// ready and verifies the schema instead of changing it behind a resident Host. +const workBoardIpc = registerWorkBoardIpc({ + ipcMain, + workspaceRoot, + mainWindowController, + store: createWorkBoardStore(workspaceRoot, { schemaMigration: 'require_current' }), +}); wireLifecycle(); runtimeHostManager.setDefaultProfile(runtimeHostStartup.preferences.defaultProfileId); await guestSessionMountService.start().catch((error: unknown) => { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index d5a69a4d1e..b929c378be 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,12 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 109 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 110 as const; +// 110: Runtime Host is the sole schema-migration authority for its State Root. +// Epoch 109 Desktop builds could migrate the event-only AgentRun schema while +// an older service Host still held the root, leaving that Host querying a +// removed column. Reject the affected mixed generation before either process +// admits domain work; the installation owner can then replace the Host. // 109: accepted Client Capability invocations may carry one bounded nested form // Interaction request/result round trip. // 108: Session Interaction snapshots, forwarded Runtime events, and Agent Graph diff --git a/packages/storage/src/__tests__/operational-state-store.test.ts b/packages/storage/src/__tests__/operational-state-store.test.ts index bea433ada7..11b8bb9ea6 100644 --- a/packages/storage/src/__tests__/operational-state-store.test.ts +++ b/packages/storage/src/__tests__/operational-state-store.test.ts @@ -24,7 +24,10 @@ import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { test } from 'node:test'; import type { SessionHeader } from '@maka/core/session'; -import { acquireOperationalStateDatabase } from '../operational-state-store.js'; +import { + acquireOperationalStateDatabase, + OperationalStateMigrationBlockedError, +} from '../operational-state-store.js'; import { SQLITE_RUNTIME_SCHEMA_VERSION } from '../sqlite-runtime-schema.js'; import { SQLITE_SESSION_METADATA_SCHEMA_VERSION } from '../sqlite-session-metadata-schema.js'; import { SQLITE_USAGE_SCHEMA_VERSION } from '../sqlite-usage-schema.js'; @@ -101,6 +104,67 @@ test('atomically reapplies current owner schema without republishing its registr } }); +test('a non-owner rejects an older schema without migrating it behind the Runtime Host', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-operational-non-owner-')); + const databasePath = join(root, 'runtime.sqlite'); + try { + acquireOperationalStateDatabase(root).close(); + const older = new DatabaseSync(databasePath); + older.exec(` + ALTER TABLE core_agent_runs ADD COLUMN record_json TEXT; + UPDATE operational_schema_migrations + SET version = 6 + WHERE scope = 'core_execution'; + `); + older.close(); + + assert.throws( + () => + acquireOperationalStateDatabase(root, { + schemaMigration: 'require_current', + }), + (error: unknown) => + error instanceof OperationalStateMigrationBlockedError && + /requires migration by its Runtime Host/u.test(error.message), + ); + + const preserved = new DatabaseSync(databasePath, { readOnly: true }); + try { + const columns = preserved.prepare('PRAGMA table_info(core_agent_runs)').all() as Array<{ + name: string; + }>; + assert.ok(columns.some(({ name }) => name === 'record_json')); + assert.equal( + ( + preserved + .prepare( + "SELECT version FROM operational_schema_migrations WHERE scope = 'core_execution'", + ) + .get() as { version: number } + ).version, + 6, + ); + } finally { + preserved.close(); + } + + const hostOwned = acquireOperationalStateDatabase(root); + try { + const columns = hostOwned.database + .prepare('PRAGMA table_info(core_agent_runs)') + .all() as Array<{ name: string }>; + assert.equal( + columns.some(({ name }) => name === 'record_json'), + false, + ); + } finally { + hostOwned.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('retires completed released migration metadata during schema convergence', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-operational-cutover-retirement-')); const databasePath = join(root, 'runtime.sqlite'); diff --git a/packages/storage/src/operational-state-store.ts b/packages/storage/src/operational-state-store.ts index 5f2772a680..63faeba31c 100644 --- a/packages/storage/src/operational-state-store.ts +++ b/packages/storage/src/operational-state-store.ts @@ -142,6 +142,12 @@ const owners = new Map(); export interface OperationalStateDatabaseOptions { now?: () => number; + /** + * `migrate` is reserved for the process that owns the State Root. A + * secondary process may use `require_current` to share current tables, but + * it must never rewrite the schema underneath that owner. + */ + schemaMigration?: 'migrate' | 'require_current'; } export class OperationalStateMigrationBlockedError extends Error { @@ -193,13 +199,22 @@ class OperationalStateDatabaseOwner { readonly databasePath: string, options: OperationalStateDatabaseOptions, ) { + if (options.schemaMigration === 'require_current' && !existsSync(databasePath)) { + throw new OperationalStateMigrationBlockedError( + new Error('Operational state has not been initialized by its Runtime Host'), + ); + } mkdirSync(dirname(databasePath), { recursive: true }); const Database = loadDatabaseSync(); this.database = new Database(databasePath); try { configureSqliteRuntimeLockWait(this.database); this.database.exec('PRAGMA foreign_keys = ON'); - inspectAndMigrateOperationalState(this.database, options.now ?? Date.now); + if (options.schemaMigration === 'require_current') { + requireCurrentOperationalState(this.database); + } else { + inspectAndMigrateOperationalState(this.database, options.now ?? Date.now); + } configureSqliteRuntimeDatabase(this.database); } catch (error) { this.database.close(); @@ -272,6 +287,18 @@ class OperationalStateDatabaseOwner { } } +function requireCurrentOperationalState(database: DatabaseSync): void { + try { + const inspection = inspectOperationalStateSchema(database); + if (inspection.status === 'current' && isCurrentOperationalTargetSchema(database)) return; + throw new Error('Operational state requires migration by its Runtime Host'); + } catch (error) { + if (isSqliteEnvironmentError(error)) throw error; + if (error instanceof OperationalStateMigrationBlockedError) throw error; + throw new OperationalStateMigrationBlockedError(error); + } +} + function inspectAndMigrateOperationalState(database: DatabaseSync, now: () => number): void { try { const inspection = inspectOperationalStateSchema(database); diff --git a/packages/storage/src/session-copy-cleanup.ts b/packages/storage/src/session-copy-cleanup.ts index 64b11143a6..a1cf6dd62b 100644 --- a/packages/storage/src/session-copy-cleanup.ts +++ b/packages/storage/src/session-copy-cleanup.ts @@ -18,7 +18,10 @@ */ import { randomUUID } from 'node:crypto'; -import { acquireOperationalStateDatabase } from './operational-state-store.js'; +import { + acquireOperationalStateDatabase, + type OperationalStateDatabaseOptions, +} from './operational-state-store.js'; import { isProcessLifetimeOwnerReference, type ProcessLifetimeOwner, @@ -84,9 +87,10 @@ export function createSessionCopyCleanupAuthority(input: { processId?: string; isOwnerProcessActive?: (ownerProcessId: string) => boolean | Promise; processLifetimeOwner?: ProcessLifetimeOwner; + databaseOptions?: OperationalStateDatabaseOptions; }): SessionCopyCleanupAuthority { return new SessionCopyCleanupAuthorityImpl( - new SqliteSessionCopyCleanupStore(input.workspaceRoot), + new SqliteSessionCopyCleanupStore(input.workspaceRoot, input.databaseOptions), input.removeSession, input.resumeSessionCopy, input.processId ?? randomUUID(), @@ -304,7 +308,10 @@ class SessionCopyCleanupAuthorityImpl implements SessionCopyCleanupAuthority { } class SqliteSessionCopyCleanupStore implements SessionCopyCleanupStore { - constructor(private readonly workspaceRoot: string) {} + constructor( + private readonly workspaceRoot: string, + private readonly databaseOptions: OperationalStateDatabaseOptions = {}, + ) {} async list(): Promise { return this.withDatabase('read', (database) => @@ -432,7 +439,7 @@ class SqliteSessionCopyCleanupStore implements SessionCopyCleanupStore { mode: 'read' | 'write', operation: (database: import('node:sqlite').DatabaseSync) => T, ): T { - const lease = acquireOperationalStateDatabase(this.workspaceRoot); + const lease = acquireOperationalStateDatabase(this.workspaceRoot, this.databaseOptions); try { return lease.transaction(mode, () => operation(lease.database)); } finally { diff --git a/packages/storage/src/work-board-store.ts b/packages/storage/src/work-board-store.ts index 5d2970a16f..75b2fd1443 100644 --- a/packages/storage/src/work-board-store.ts +++ b/packages/storage/src/work-board-store.ts @@ -39,6 +39,7 @@ import { } from './work-board-list-query.js'; import { acquireOperationalStateDatabase, + type OperationalStateDatabaseOptions, type OperationalStateDatabaseLease, } from './operational-state-store.js'; import { chainWrite } from './write-queue.js'; @@ -70,8 +71,11 @@ export interface WorkBoardStore { close(): void; } -export function createWorkBoardStore(workspaceRoot: string): WorkBoardStore { - return new SqliteWorkBoardStore(workspaceRoot); +export function createWorkBoardStore( + workspaceRoot: string, + databaseOptions: OperationalStateDatabaseOptions = {}, +): WorkBoardStore { + return new SqliteWorkBoardStore(workspaceRoot, databaseOptions); } interface WorkBoardRow { @@ -89,8 +93,8 @@ class SqliteWorkBoardStore implements WorkBoardStore { readonly #lease: OperationalStateDatabaseLease; private readonly writeQueues = new Map>(); - constructor(workspaceRoot: string) { - this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot)); + constructor(workspaceRoot: string, databaseOptions: OperationalStateDatabaseOptions) { + this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot), databaseOptions); } close(): void {