Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/desktop/src/main/__tests__/main-startup-lifetime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
17 changes: 10 additions & 7 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,6 @@ resolveBrowserDialogAppearance = async () => {
}
};
const mcpConfigStore = createMcpConfigStore(workspaceRoot);
const workBoardStore = createWorkBoardStore(workspaceRoot);
const mcpManager = new McpClientManager({
clientName: "maka-desktop",
clientVersion: app.getVersion(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1112,6 +1105,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager(
removeSession,
resumeSessionCopy,
processId: sessionCopyOwnerProcessId,
databaseOptions: { schemaMigration: 'require_current' },
}),
renderer: mainWindowController,
onError: (error) =>
Expand Down Expand Up @@ -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) => {
Expand Down
7 changes: 6 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 65 additions & 1 deletion packages/storage/src/__tests__/operational-state-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
29 changes: 28 additions & 1 deletion packages/storage/src/operational-state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ const owners = new Map<string, OperationalStateDatabaseOwner>();

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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 11 additions & 4 deletions packages/storage/src/session-copy-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -84,9 +87,10 @@ export function createSessionCopyCleanupAuthority(input: {
processId?: string;
isOwnerProcessActive?: (ownerProcessId: string) => boolean | Promise<boolean>;
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(),
Expand Down Expand Up @@ -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<PersistedSessionCopyLease[]> {
return this.withDatabase('read', (database) =>
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 8 additions & 4 deletions packages/storage/src/work-board-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -89,8 +93,8 @@ class SqliteWorkBoardStore implements WorkBoardStore {
readonly #lease: OperationalStateDatabaseLease;
private readonly writeQueues = new Map<string, Promise<void>>();

constructor(workspaceRoot: string) {
this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot));
constructor(workspaceRoot: string, databaseOptions: OperationalStateDatabaseOptions) {
this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot), databaseOptions);
}

close(): void {
Expand Down