From d77e854f723d21f71816dbc74aae52c2581b0926 Mon Sep 17 00:00:00 2001 From: skblue Date: Mon, 24 Aug 2026 18:30:07 +0800 Subject: [PATCH 001/386] fix(runtime-host): keep the preferred location when registering a project path (#3573) Project preference is derived from each location's last-used timestamp, so plain registration was also selecting that checkout. Add an explicit registration preference while keeping omission prefer-on for Desktop folder opens. The runtime-host project add CLI now registers without usage by default and exposes --prefer. Compatibility epoch 40 fences the added field on the closed request shape. Generated-by: pi (gpt-5.6-sol) --- .../runtime-host-operator-command.test.ts | 68 ++++++++++++------ packages/cli/src/cli-core.ts | 9 ++- packages/cli/src/runtime-host-cli.ts | 14 +++- .../cli/src/runtime-host-project-command.ts | 8 ++- .../project-catalog-coordinator.test.ts | 69 ++++++++++++++++++- .../project-catalog-protocol.test.ts | 17 +++++ .../src/__tests__/protocol.test.ts | 6 ++ packages/runtime-host/src/protocol/index.ts | 4 +- .../src/protocol/project-catalog.ts | 18 ++++- .../src/server/project-catalog-coordinator.ts | 4 +- .../src/__tests__/project-catalog.test.ts | 44 +++++++++++- packages/storage/src/project-catalog.ts | 12 +++- 12 files changed, 236 insertions(+), 37 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index 477757f534..abd195c545 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -50,6 +50,23 @@ describe('Runtime Host operator commands', () => { kind: 'runtime-host-project-add', rootPath: '/srv/maka', path: '/work/project', + prefer: false, + }, + ); + assert.deepEqual( + parseRuntimeHostCommand([ + 'project', + 'add', + '/work/project', + '--prefer', + '--root', + '/srv/maka', + ]), + { + kind: 'runtime-host-project-add', + rootPath: '/srv/maka', + path: '/work/project', + prefer: true, }, ); assert.deepEqual( @@ -248,12 +265,12 @@ describe('Runtime Host operator commands', () => { assert.equal(JSON.stringify(event).includes('credential'), false); }); - test('registers a Project through the local owner connection', async () => { - let closed = false; - let request: unknown; + test('registers a Project without preferring it unless explicitly requested', async () => { + let closeCount = 0; + const requests: unknown[] = []; const connection = { request: async (operation: string, input: unknown) => { - request = { operation, input }; + requests.push({ operation, input }); return { kind: 'project', project: { @@ -267,29 +284,38 @@ describe('Runtime Host operator commands', () => { }; }, close: async () => { - closed = true; + closeCount += 1; }, } as unknown as RuntimeHostConnection; const output: string[] = []; + const commands = [ + { kind: 'add' as const, rootPath: '/srv/maka', path: 'project', prefer: false }, + { kind: 'add' as const, rootPath: '/srv/maka', path: 'project', prefer: true }, + ]; - assert.equal( - await runRuntimeHostProjectCli( - { kind: 'add', rootPath: '/srv/maka', path: 'project' }, - { + for (const command of commands) { + assert.equal( + await runRuntimeHostProjectCli(command, { connect: async () => connection, write: (value) => output.push(value), - }, - ), - 0, - ); - assert.deepEqual(request, { - operation: 'project.catalog.mutate', - input: { kind: 'register', path: resolve('project') }, - }); - assert.equal(closed, true); - assert.equal( - (JSON.parse(output.join('')) as { project: { id: string } }).project.id, - 'project-1', + }), + 0, + ); + } + assert.deepEqual(requests, [ + { + operation: 'project.catalog.mutate', + input: { kind: 'register', path: resolve('project'), prefer: false }, + }, + { + operation: 'project.catalog.mutate', + input: { kind: 'register', path: resolve('project'), prefer: true }, + }, + ]); + assert.equal(closeCount, 2); + assert.deepEqual( + output.map((value) => (JSON.parse(value) as { project: { id: string } }).project.id), + ['project-1', 'project-1'], ); }); }); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 0e931db9a3..42b9864e08 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -130,7 +130,7 @@ function helpText(cliCommand: string): string { ` ${cliCommand} runtime-host access issue --kind capability-provider --principal `, ` ${cliCommand} runtime-host access revoke --credential `, ` ${cliCommand} runtime-host project list [--root ]`, - ` ${cliCommand} runtime-host project add [--root ]`, + ` ${cliCommand} runtime-host project add [--prefer] [--root ]`, ` ${cliCommand} runtime-host profile list`, ` ${cliCommand} runtime-host profile set --id --name --tls-url --expected-root [--credential-env ]`, ` ${cliCommand} runtime-host profile set --id --name --ssh-destination --ssh-remote-port --expected-root [--ssh-port ] [--credential-env ]`, @@ -373,7 +373,12 @@ export async function runMakaCli( const rootPath = command.rootPath ?? dataRoots.workspaceRoot; return command.kind === 'runtime-host-project-list' ? runRuntimeHostProjectCli({ kind: 'list', rootPath }) - : runRuntimeHostProjectCli({ kind: 'add', rootPath, path: command.path }); + : runRuntimeHostProjectCli({ + kind: 'add', + rootPath, + path: command.path, + prefer: command.prefer, + }); } case 'runtime-host-capability-provider-serve': { const { runRuntimeHostCapabilityProviderCli } = await import( diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index c49bd248f7..a5205258ce 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -127,7 +127,7 @@ export type RuntimeHostCliCommand = framed: boolean; } | { kind: 'runtime-host-project-list'; rootPath?: string } - | { kind: 'runtime-host-project-add'; rootPath?: string; path: string } + | { kind: 'runtime-host-project-add'; rootPath?: string; path: string; prefer: boolean } | { kind: 'runtime-host-capability-provider-serve'; url: string; @@ -512,6 +512,7 @@ function parseProjectCommand(argv: string[]): RuntimeHostCliCommand { } let rootPath: string | undefined; let path: string | undefined; + let prefer = false; for (let index = 1; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--root') { @@ -521,6 +522,10 @@ function parseProjectCommand(argv: string[]): RuntimeHostCliCommand { index += 1; continue; } + if (action === 'add' && argument === '--prefer') { + prefer = true; + continue; + } if (action === 'add' && path === undefined) { path = argument; continue; @@ -531,7 +536,12 @@ function parseProjectCommand(argv: string[]): RuntimeHostCliCommand { return { kind: 'runtime-host-project-list', ...(rootPath ? { rootPath } : {}) }; } if (!path) return error('runtime-host project add requires a path'); - return { kind: 'runtime-host-project-add', path, ...(rootPath ? { rootPath } : {}) }; + return { + kind: 'runtime-host-project-add', + path, + prefer, + ...(rootPath ? { rootPath } : {}), + }; } function parseProfileCommand(argv: string[]): RuntimeHostCliCommand { diff --git a/packages/cli/src/runtime-host-project-command.ts b/packages/cli/src/runtime-host-project-command.ts index 5bd9bafc97..b9f6fd9f8c 100644 --- a/packages/cli/src/runtime-host-project-command.ts +++ b/packages/cli/src/runtime-host-project-command.ts @@ -32,7 +32,12 @@ const PROTOCOL = { export type RuntimeHostProjectCommand = | { readonly kind: 'list'; readonly rootPath: string } - | { readonly kind: 'add'; readonly rootPath: string; readonly path: string }; + | { + readonly kind: 'add'; + readonly rootPath: string; + readonly path: string; + readonly prefer: boolean; + }; interface RuntimeHostProjectCommandDeps { readonly connect: (rootPath: string) => Promise; @@ -52,6 +57,7 @@ export async function runRuntimeHostProjectCli( : await connection.request('project.catalog.mutate', { kind: 'register', path: resolve(command.path), + prefer: command.prefer, }); deps.write(`${JSON.stringify(result, null, 2)}\n`); return 0; diff --git a/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts index 909b79dda9..f0b4fdb756 100644 --- a/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts @@ -18,16 +18,56 @@ */ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, realpath, rename, rm } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, realpath, rename, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; +import { promisify } from 'node:util'; import { createProjectCatalog, createSessionStore } from '@maka/storage'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import { HostChangeFeed } from '../server/host-change-feed.js'; import { HostProjectCatalogCoordinator } from '../server/project-catalog-coordinator.js'; import { HostProjectMembershipGate } from '../server/project-membership-gate.js'; +const execFileAsync = promisify(execFile); + +test('Host Project Catalog can register a location without changing the preferred path', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-host-project-register-preference-')); + const repository = join(base, 'repository'); + const linkedWorktree = join(base, 'linked'); + await createGitRepositoryWithWorktree(repository, linkedWorktree); + let now = 1_000; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => 'project-1', + }); + const coordinator = new HostProjectCatalogCoordinator( + catalog, + { publish: () => {} }, + { publish: () => {} }, + new HostProjectMembershipGate(), + () => assert.fail('ordinary project mutations must not drain the Host'), + ); + + try { + const original = await catalog.register(repository); + const repositoryPath = await realpath(repository); + now = 2_000; + const input = { kind: 'register' as const, path: linkedWorktree, prefer: false }; + const registered = await coordinator.handlers['project.catalog.mutate'](input, connection()); + + assert.equal(registered.ok, true); + if (!registered.ok || registered.result.kind !== 'project') return; + assert.equal(registered.result.project.id, original.id); + assert.equal(registered.result.project.locationCount, 2); + assert.equal((await catalog.list())[0]?.preferredPath, repositoryPath); + } finally { + catalog.close(); + await rm(base, { recursive: true, force: true }); + } +}); + test('Host Project Catalog relink merges identities and reassigns every affected Session', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-host-project-catalog-')); const storageRoot = join(base, 'storage'); @@ -187,6 +227,33 @@ test('directory resolution failures cannot enter the unknown-commit drain path', } }); +async function createGitRepositoryWithWorktree( + repository: string, + linkedWorktree: string, +): Promise { + await mkdir(repository); + await execFileAsync('git', ['init', '--quiet'], { cwd: repository }); + await writeFile(join(repository, 'tracked.txt'), 'tracked\n', 'utf8'); + await execFileAsync('git', ['add', 'tracked.txt'], { cwd: repository }); + await execFileAsync( + 'git', + [ + '-c', + 'user.name=Maka Test', + '-c', + 'user.email=test@maka.invalid', + 'commit', + '--quiet', + '-m', + 'init', + ], + { cwd: repository }, + ); + await execFileAsync('git', ['worktree', 'add', '--quiet', '-b', 'linked', linkedWorktree], { + cwd: repository, + }); +} + function sessionInput(cwd: string, projectId: string) { return { cwd, diff --git a/packages/runtime-host/src/__tests__/project-catalog-protocol.test.ts b/packages/runtime-host/src/__tests__/project-catalog-protocol.test.ts index 8e2a4d4966..cd93bc9644 100644 --- a/packages/runtime-host/src/__tests__/project-catalog-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/project-catalog-protocol.test.ts @@ -109,6 +109,23 @@ describe('Project catalog protocol', () => { ); }); + test('decodes an optional project registration preference and rejects non-booleans', () => { + const frame = { + requestId: 'request-register-preference', + operation: 'project.catalog.mutate' as const, + input: { kind: 'register' as const, path: projectPath, prefer: false }, + }; + assert.deepEqual(decodeClientFrame(frame), frame); + assert.throws( + () => + decodeClientFrame({ + ...frame, + input: { ...frame.input, prefer: 'false' }, + }), + isProtocolError, + ); + }); + test('rejects relative paths, open records, oversized pages, and stale shapes', () => { assert.throws( () => diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 7bcbdbc7ae..6726905f71 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -176,6 +176,12 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 45); }); + test('publishes a new compatibility epoch for the project registration preference', () => { + // Epoch 46 Hosts reject the optional preference field on the closed register + // input, so mixed-version peers must fail during the handshake instead. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 46); + }); + test('adds credential rotation without changing existing credential inputs', () => { const issueInput = { principalKind: 'remote_owner', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 0edebdf8df..2d3569b957 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,9 @@ 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 = 46 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 47 as const; +// 47: Project registration can carry an explicit location preference. Epoch-46 +// hosts reject that optional field on the closed registration input. // 46: Queued message content can be edited in place (queue.entry.update). // 45: Connection onboarding inputs require `baseUrl` and `connectionId`, and // results can carry the `base_url_not_configured` / `connection_not_found` diff --git a/packages/runtime-host/src/protocol/project-catalog.ts b/packages/runtime-host/src/protocol/project-catalog.ts index a09321eb04..795dc7febb 100644 --- a/packages/runtime-host/src/protocol/project-catalog.ts +++ b/packages/runtime-host/src/protocol/project-catalog.ts @@ -23,6 +23,7 @@ import { requireEntityId, requireExactRecord, requireRecord, + requireShapedRecord, requireUtf8String, } from './codec.js'; import { invalidProtocolFrame } from './errors.js'; @@ -136,7 +137,7 @@ type ProjectCatalogListQueryResult = }; export type ProjectCatalogMutateInput = - | { readonly kind: 'register'; readonly path: string } + | { readonly kind: 'register'; readonly path: string; readonly prefer?: boolean } | ({ readonly kind: 'register_directory' } & ProjectDirectoryRegisterInput) | { readonly kind: 'relink'; readonly projectId: string; readonly path: string } | { readonly kind: 'rename'; readonly projectId: string; readonly name: string } @@ -423,8 +424,19 @@ export function decodeProjectCatalogMutateInput(value: unknown): ProjectCatalogM const record = requireRecord(value, 'project catalog mutation input'); switch (record.kind) { case 'register': { - const input = requireExactRecord(record, 'project register input', ['kind', 'path']); - return { kind: 'register', path: absolutePath(input.path, 'project path') }; + const input = requireShapedRecord( + record, + 'project register input', + ['kind', 'path'], + ['prefer'], + ); + return { + kind: 'register', + path: absolutePath(input.path, 'project path'), + ...(Object.hasOwn(input, 'prefer') + ? { prefer: boolean(input.prefer, 'project preference') } + : {}), + }; } case 'register_directory': { return { diff --git a/packages/runtime-host/src/server/project-catalog-coordinator.ts b/packages/runtime-host/src/server/project-catalog-coordinator.ts index c70dbf75d9..0ec9f66a41 100644 --- a/packages/runtime-host/src/server/project-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/project-catalog-coordinator.ts @@ -157,7 +157,9 @@ export class HostProjectCatalogCoordinator { ): Promise { switch (input.kind) { case 'register': - return projectResult(await this.catalog.register(input.path)); + return projectResult( + await this.catalog.register(input.path, { prefer: input.prefer ?? true }), + ); case 'register_directory': { if (!directoryRegistration) throw new TypeError('Project directory was not resolved'); return projectResult( diff --git a/packages/storage/src/__tests__/project-catalog.test.ts b/packages/storage/src/__tests__/project-catalog.test.ts index e7d81c903f..6348662534 100644 --- a/packages/storage/src/__tests__/project-catalog.test.ts +++ b/packages/storage/src/__tests__/project-catalog.test.ts @@ -279,17 +279,23 @@ test('registering a repository and its linked worktree creates one project with const repository = join(base, 'repository'); const linkedWorktree = join(base, 'linked'); await createGitRepositoryWithWorktree(repository, linkedWorktree, 'catalog-linked'); + let now = 1_000; const catalog = createProjectCatalog(join(base, 'storage'), { - now: () => 1_000, + now: () => now, createId: () => 'project-1', }); const first = await catalog.register(repository); + now = 2_000; const second = await catalog.register(linkedWorktree); - const expectedPaths = [await realpath(linkedWorktree), await realpath(repository)].sort(); + const repositoryPath = await realpath(repository); + const linkedWorktreePath = await realpath(linkedWorktree); + const expectedPaths = [linkedWorktreePath, repositoryPath].sort(); assert.equal(first.id, 'project-1'); + assert.equal(first.preferredPath, repositoryPath); assert.equal(second.id, first.id); + assert.equal(second.preferredPath, linkedWorktreePath); assert.deepEqual( (await catalog.list()).map((project) => ({ id: project.id, @@ -311,6 +317,40 @@ test('registering a repository and its linked worktree creates one project with } }); +test('registering without preference preserves the preferred location until it is touched', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-catalog-not-preferred-')); + try { + const repository = join(base, 'repository'); + const linkedWorktree = join(base, 'linked'); + await createGitRepositoryWithWorktree(repository, linkedWorktree, 'catalog-not-preferred'); + let now = 1_000; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => now, + createId: () => 'project-1', + }); + const doNotPrefer = { prefer: false } as const; + const repositoryPath = await realpath(repository); + const linkedWorktreePath = await realpath(linkedWorktree); + + const first = await catalog.register(repository, doNotPrefer); + now = 2_000; + const added = await catalog.register(linkedWorktree, doNotPrefer); + assert.equal(added.id, first.id); + assert.equal(added.locations.length, 2); + assert.equal(added.preferredPath, repositoryPath); + + now = 3_000; + const registeredAgain = await catalog.register(linkedWorktree, doNotPrefer); + assert.equal(registeredAgain.preferredPath, repositoryPath); + + now = 4_000; + const touched = await catalog.touch(first.id, linkedWorktreePath); + assert.equal(touched.preferredPath, linkedWorktreePath); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + test('archiving a project preserves it with an archive timestamp', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-project-archive-')); try { diff --git a/packages/storage/src/project-catalog.ts b/packages/storage/src/project-catalog.ts index 1e2b2f8bae..7424bf6d05 100644 --- a/packages/storage/src/project-catalog.ts +++ b/packages/storage/src/project-catalog.ts @@ -129,6 +129,11 @@ export interface ProjectRegistrationOptions { * its published boundary. */ readonly withinRoot?: string; + /** + * Whether an additional location should be recorded as recently used. A new + * project still establishes its sole location as the initial preference. + */ + readonly prefer?: boolean; } interface PersistedProject { @@ -207,7 +212,7 @@ class SqliteProjectCatalog implements ProjectCatalog { if (options?.withinRoot && !isPathWithin(options.withinRoot, resolved.canonicalPath)) { throw new ProjectPathBoundaryError(resolved.canonicalPath); } - return this.upsertResolvedProject(resolved, this.now()); + return this.upsertResolvedProject(resolved, this.now(), options?.prefer !== false); } async resolveHistoricalPath(path: string, usedAt: number = this.now()): Promise { @@ -236,6 +241,7 @@ class SqliteProjectCatalog implements ProjectCatalog { private async upsertResolvedProject( resolved: ResolvedProjectLocation, timestamp: number, + prefer = true, ): Promise { const registered = await this.mutate((file) => { const locationPath = @@ -244,13 +250,13 @@ class SqliteProjectCatalog implements ProjectCatalog { if (existing) { const location = existing.locations.find((item) => item.path === locationPath); if (location) { - location.lastUsedAt = Math.max(location.lastUsedAt, timestamp); + if (prefer) location.lastUsedAt = Math.max(location.lastUsedAt, timestamp); location.isWorktree = resolved.git?.isWorktree ?? false; } else { existing.locations.push({ path: locationPath, isWorktree: resolved.git?.isWorktree ?? false, - lastUsedAt: timestamp, + lastUsedAt: prefer ? timestamp : 0, }); } existing.lastUsedAt = Math.max(existing.lastUsedAt, timestamp); From d807c00783c5c7bdb42e7cedd257eff932b94be6 Mon Sep 17 00:00:00 2001 From: Xinhao Xu <84456268+xxhZs@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:45:25 +0800 Subject: [PATCH 002/386] feat(runtime): add plugin composition foundation (#3250) * feat(runtime): add plugin composition foundation Migrate the feat-defined Context/Fiber kernel, plugin runtime contracts, composition loader, and public Runtime entrypoints. Keep Host control, persistence, package loading, and product wiring out of scope. Retain regression-tested foundation fixes for Service activation, Fiber transition and config semantics, Effect ownership and disposal, weak Plugin runtime caching, composition Tree failure invariants and inspection, safe Service record names, and contribution registration. Defer atomic Service-provider replacement and rollback, candidate Context Effect publication, and full asynchronous Effect failure policy to follow-up work that can define the required lifecycle contracts. Generated-by: Codex * fix(runtime): harden composition foundation invariants Keep inherited Context metadata authoritative over same-named Services, surface Service health-check failures through Fiber state, and make live snapshot/subtree replacement advance composition generations. Reject duplicate package installation until atomic Service-provider replacement has a dedicated lifecycle contract. Generated-by: Codex --- packages/runtime/package.json | 3 + .../plugin-composition-loader.test.ts | 660 +++++++++++ .../src/__tests__/plugin-kernel.test.ts | 701 +++++++++++ .../runtime/src/plugin-composition-loader.ts | 886 ++++++++++++++ packages/runtime/src/plugin-kernel.ts | 1056 +++++++++++++++++ packages/runtime/src/plugin-runtime.ts | 378 ++++++ 6 files changed, 3684 insertions(+) create mode 100644 packages/runtime/src/__tests__/plugin-composition-loader.test.ts create mode 100644 packages/runtime/src/__tests__/plugin-kernel.test.ts create mode 100644 packages/runtime/src/plugin-composition-loader.ts create mode 100644 packages/runtime/src/plugin-kernel.ts create mode 100644 packages/runtime/src/plugin-runtime.ts diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 693b3d88f6..eb4db917d4 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -74,6 +74,9 @@ "./path-containment": "./dist/path-containment.js", "./plan-mode": "./dist/plan-mode.js", "./plan-tools": "./dist/plan-tools.js", + "./plugin-composition-loader": "./dist/plugin-composition-loader.js", + "./plugin-kernel": "./dist/plugin-kernel.js", + "./plugin-runtime": "./dist/plugin-runtime.js", "./process-tree-terminator": "./dist/process-tree-terminator.js", "./provider-request-telemetry": "./dist/provider-request-telemetry.js", "./request-customization-fetch": "./dist/request-customization-fetch.js", diff --git a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts new file mode 100644 index 0000000000..6284f43819 --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts @@ -0,0 +1,660 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Context, type Plugin } from '../plugin-kernel.js'; +import { MakaCompositionLoader } from '../plugin-composition-loader.js'; +import { + MakaPluginTransactionBuffer, + type MakaCompositionEntry, + type MakaPluginPackage, +} from '../plugin-runtime.js'; + +test('composition tree supports nested groups and repeated package instances', async () => { + const activations: string[] = []; + const plugin = ((ctx: Context, config: { label: string }) => { + activations.push(`${ctx.maka!.entryId}:${config.label}`); + ctx.effect(() => () => activations.push(`dispose:${ctx.maka!.entryId}`), 'fixture'); + }) as Plugin; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('fixture', plugin)); + await loader.create('profile', { + id: 'group', + children: [ + entry('first', 'fixture', { label: 'one' }), + entry('second', 'fixture', { label: 'two' }), + ], + }); + assert.deepEqual(activations, ['first:one', 'second:two']); + assert.deepEqual( + loader.inspectTree('profile').map(({ id }) => id), + ['group'], + ); + assert.deepEqual( + loader.inspect('group').children.map(({ id }) => id), + ['first', 'second'], + ); + assert.equal(loader.root.kernelFibers().length, 3, 'root plus one real Fiber per package Entry'); + await loader.remove('first'); + assert.equal(loader.inspect('second').status, 'active'); + assert.ok(activations.includes('dispose:first')); + await loader.close(); +}); + +test('missing injected service enters pending and activates when provided', async () => { + let started = 0; + const plugin = Object.assign( + () => { + started += 1; + }, + { inject: ['fixtureService'] }, + ); + const loader = new MakaCompositionLoader(); + await loader.install(pkg('consumer', plugin)); + await loader.create('profile', entry('consumer-one', 'consumer')); + assert.equal(loader.inspect('consumer-one').status, 'pending'); + loader.root.provide('fixtureService', { value: 1 }); + await loader.awaitSettled(); + assert.equal(loader.inspect('consumer-one').status, 'active'); + assert.equal(started, 1); + await loader.close(); +}); + +test('composition metadata wins over same-named root Services', async () => { + const root = new Context(); + root.provide('maka', { hijacked: true }); + let seenEntryId: string | undefined; + const loader = new MakaCompositionLoader({ root }); + await loader.install( + pkg('metadata-owner', (ctx: Context) => { + seenEntryId = ctx.maka?.entryId; + }), + ); + + await loader.create('profile', entry('metadata-entry', 'metadata-owner')); + + assert.equal(seenEntryId, 'metadata-entry'); + assert.deepEqual(root.get('maka'), { hijacked: true }); + await loader.close(); +}); + +test('config update uses the existing Fiber and preserves entry identity', async () => { + const values: number[] = []; + const plugin = (_ctx: Context, config: { value: number }) => { + values.push(config.value); + }; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('configurable', plugin)); + const initial = await loader.create( + 'profile', + entry('configurable-one', 'configurable', { value: 1 }), + ); + const updated = await loader.update('configurable-one', { config: { value: 2 } }); + assert.equal(updated.id, initial.id); + assert.equal(updated.generation, initial.generation); + assert.equal(loader.snapshot().generation, 2); + assert.deepEqual(values, [1, 2]); + await loader.close(); +}); + +test('duplicate package install is rejected without replacing live code', async () => { + const live = new Set(); + const current = (ctx: Context) => { + live.add(ctx.maka!.entryId); + return () => live.delete(ctx.maka!.entryId); + }; + const replacement = () => undefined; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('atomic', current)); + await loader.create('profile', entry('atomic-one', 'atomic')); + await assert.rejects( + () => loader.install(pkg('atomic', replacement)), + /Plugin package is already installed: atomic/u, + ); + assert.equal(loader.inspect('atomic-one').status, 'active'); + assert.equal(loader.package('atomic').host, current); + assert.deepEqual([...live], ['atomic-one']); + await loader.close(); +}); + +test('remove and close exhaust subtree cleanup across retirement failures', async (t) => { + t.mock.method(console, 'warn', () => undefined); + const createLoader = async (lifecycle: string[]) => { + const loader = new MakaCompositionLoader(); + await loader.install( + pkg( + 'cleanup', + (_ctx: Context, config: { readonly label: string; readonly fail?: boolean }) => { + return () => { + lifecycle.push(config.label); + if (config.fail) throw new Error(`${config.label} cleanup failed`); + }; + }, + ), + ); + await loader.create('profile', { + id: 'cleanup-group', + children: [ + entry('cleanup-first', 'cleanup', { label: 'first', fail: true }), + entry('cleanup-second', 'cleanup', { label: 'second' }), + ], + }); + return loader; + }; + + const removed: string[] = []; + const removeLoader = await createLoader(removed); + await removeLoader.remove('cleanup-group'); + assert.deepEqual(removed, ['second', 'first']); + await removeLoader.close(); + + const closed: string[] = []; + const closeLoader = await createLoader(closed); + await assert.rejects(closeLoader.close(), AggregateError); + assert.deepEqual(closed, ['second', 'first']); +}); + +test('disabled ancestors suppress insert, move, update, and subtree replacement activation', async () => { + const activations: string[] = []; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('disabled-child', (ctx: Context, config: { readonly value: string }) => { + activations.push(`${ctx.maka!.entryId}:${config.value}`); + }), + ); + await loader.create('profile', { id: 'disabled-parent', disabled: true }); + + await loader.create( + 'profile', + entry('inserted-child', 'disabled-child', { value: 'inserted' }), + 'disabled-parent', + ); + assert.equal(loader.inspect('inserted-child').disabled, true); + assert.equal(loader.inspect('inserted-child').status, 'disabled'); + + await loader.create('profile', entry('moved-child', 'disabled-child', { value: 'before-move' })); + assert.deepEqual(activations, ['moved-child:before-move']); + await loader.move('moved-child', 'disabled-parent'); + assert.equal(loader.inspect('moved-child').status, 'disabled'); + + await loader.replaceSubtree( + 'inserted-child', + entry('inserted-child', 'disabled-child', { value: 'replaced' }), + ); + await loader.update('inserted-child', { config: { value: 'updated' } }); + + assert.deepEqual(activations, ['moved-child:before-move']); + assert.equal(loader.inspect('inserted-child').status, 'disabled'); + assert.equal(loader.inspect('moved-child').status, 'disabled'); + await loader.close(); +}); + +test('insert commit failure disposes its unindexed Fiber exactly once', async () => { + let disposals = 0; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('commit-failure', (ctx: Context) => { + ctx.makaTransaction!.stage( + 'first', + () => () => { + disposals += 1; + }, + ctx, + ); + ctx.makaTransaction!.stage( + 'failure', + () => { + throw new Error('registration failed'); + }, + ctx, + ); + }), + ); + + await assert.rejects( + loader.create('profile', entry('failed-entry', 'commit-failure')), + /registration failed/u, + ); + + assert.deepEqual(loader.inspectTree('profile'), []); + assert.equal(loader.root.kernelFibers().length, 1); + assert.equal(disposals, 1); + await loader.close(); + assert.equal(disposals, 1); +}); + +test('transaction commit failure rolls registrations back sequentially in LIFO order', async () => { + const lifecycle: string[] = []; + let laterDisposed = false; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('ordered-rollback', (ctx: Context) => { + ctx.makaTransaction!.stage( + 'first', + () => () => { + lifecycle.push(laterDisposed ? 'first-after-later' : 'first-overlapped-later'); + }, + ctx, + ); + ctx.makaTransaction!.stage( + 'later', + () => async () => { + await Promise.resolve(); + laterDisposed = true; + lifecycle.push('later'); + }, + ctx, + ); + ctx.makaTransaction!.stage( + 'failure', + () => { + throw new Error('registration failed'); + }, + ctx, + ); + }), + ); + + await assert.rejects( + loader.create('profile', entry('ordered-rollback-entry', 'ordered-rollback')), + /registration failed/u, + ); + + assert.deepEqual(lifecycle, ['later', 'first-after-later']); + await loader.close(); +}); + +test('retirement cleanup failure does not roll back a published removal generation', async (t) => { + t.mock.method(console, 'warn', () => undefined); + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('retirement-failure', () => () => { + throw new Error('retirement failed'); + }), + ); + await loader.create('profile', entry('retired-entry', 'retirement-failure')); + const generation = loader.snapshot().generation; + + await loader.remove('retired-entry'); + + assert.equal(loader.snapshot().generation, generation + 1); + assert.deepEqual(loader.inspectTree('profile'), []); + await loader.close(); +}); + +test('retirement cleanup failure does not roll back a published structural update', async (t) => { + t.mock.method(console, 'warn', () => undefined); + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('retired-package', () => () => { + throw new Error('retirement failed'); + }), + ); + await loader.install(pkg('replacement-package', () => undefined)); + await loader.create('profile', entry('updated-entry', 'retired-package')); + const generation = loader.snapshot().generation; + + await loader.update('updated-entry', { packageId: 'replacement-package' }); + + assert.equal(loader.snapshot().generation, generation + 1); + assert.equal(loader.inspect('updated-entry').packageId, 'replacement-package'); + assert.equal(loader.inspect('updated-entry').status, 'active'); + await loader.close(); +}); + +test('contribution registrations are staged and owned by the entry Fiber', async () => { + const root = new Context(); + const registrations = new Set(); + const loader = new MakaCompositionLoader({ + root, + transaction: (context) => new MakaPluginTransactionBuffer(context), + }); + const plugin = (ctx: Context, config: { suffix: string }) => { + ctx.makaTransaction!.stage( + `fixture:${config.suffix}`, + () => { + registrations.add(config.suffix); + return () => { + registrations.delete(config.suffix); + }; + }, + ctx, + ); + }; + await loader.install(pkg('owner', plugin)); + await loader.create('profile', entry('entry-a', 'owner', { suffix: 'a' })); + await loader.create('profile', entry('entry-b', 'owner', { suffix: 'b' })); + assert.deepEqual([...registrations], ['a', 'b']); + await loader.remove('entry-a'); + assert.deepEqual([...registrations], ['b']); + await loader.close(); +}); + +test('snapshot replacement restores ordered roots and descendants', async () => { + const loader = new MakaCompositionLoader(); + await loader.install(pkg('snapshot', () => undefined)); + await loader.replaceSnapshot({ + schemaVersion: 1, + generation: 41, + roots: { + profile: [entry('profile-entry', 'snapshot')], + desktopUi: [{ id: 'ui-group', children: [entry('ui-entry', 'snapshot')] }], + sessions: { s1: [entry('session-entry', 'snapshot')] }, + }, + }); + assert.equal(loader.snapshot().generation, 41); + assert.deepEqual( + loader.inspectTree().map(({ id }) => id), + ['profile-entry', 'ui-group', 'session-entry'], + ); + assert.equal(loader.inspect('ui-entry').parentId, 'ui-group'); + await loader.close(); +}); + +test('live snapshot and subtree replacement publish a fresh composition generation', async () => { + const loader = new MakaCompositionLoader(); + await loader.create('profile', { id: 'before' }); + const staleGeneration = loader.snapshot().generation; + + await loader.replaceSnapshot({ + schemaVersion: 1, + generation: staleGeneration, + roots: { profile: [{ id: 'after' }], desktopUi: [], sessions: {} }, + }); + + assert.equal(loader.snapshot().generation, staleGeneration + 1); + await assert.rejects( + () => loader.apply({ baseGeneration: staleGeneration, operations: [] }), + /Composition generation changed/u, + ); + + const beforeSubtreeReplacement = loader.snapshot().generation; + await loader.replaceSubtree('after', { id: 'after', children: [{ id: 'child' }] }); + assert.equal(loader.snapshot().generation, beforeSubtreeReplacement + 1); + await loader.close(); +}); + +test('entry inject and intercept metadata retain the feat shallow-copy contract', async () => { + const dependencyCheck = () => true; + const interceptConfig = { select: () => true }; + const loader = new MakaCompositionLoader(); + + await loader.create('profile', { + id: 'metadata-group', + inject: { fixtureService: dependencyCheck }, + intercept: { fixtureService: interceptConfig }, + }); + + assert.equal(loader.inspect('metadata-group').status, 'active'); + await loader.close(); +}); + +test('replacement subtrees reject duplicate ids across different branches', async () => { + const loader = new MakaCompositionLoader(); + await loader.create('profile', { id: 'replacement-root' }); + + await assert.rejects( + loader.replaceSubtree('replacement-root', { + id: 'replacement-root', + children: [ + { id: 'left-branch', children: [{ id: 'repeated-child' }] }, + { id: 'right-branch', children: [{ id: 'repeated-child' }] }, + ], + }), + /Replacement subtree repeats entry repeated-child/u, + ); + + assert.deepEqual(loader.inspect('replacement-root').children, []); + await loader.close(); +}); + +test('snapshot preserves session ids that overlap object prototype properties', async () => { + const loader = new MakaCompositionLoader(); + await loader.create('session:__proto__', { id: 'special-session-entry' }); + + const snapshot = loader.snapshot(); + assert.equal(Object.hasOwn(snapshot.roots.sessions, '__proto__'), true); + assert.deepEqual( + snapshot.roots.sessions.__proto__?.map(({ id }) => id), + ['special-session-entry'], + ); + + await loader.replaceSnapshot(snapshot); + assert.deepEqual( + loader.inspectTree('session:__proto__').map(({ id }) => id), + ['special-session-entry'], + ); + await loader.close(); +}); + +test('inspecting a missing root does not mutate the composition snapshot', async () => { + const loader = new MakaCompositionLoader(); + const before = loader.snapshot(); + + assert.deepEqual(loader.inspectTree('session:missing'), []); + + assert.deepEqual(loader.snapshot(), before); + await loader.close(); +}); + +test('failed insert does not create an empty composition root', async () => { + const loader = new MakaCompositionLoader(); + const before = loader.snapshot(); + + await assert.rejects( + loader.create('session:ghost', { id: 'orphan' }, 'missing-parent'), + /Composition entry not found: missing-parent/u, + ); + + assert.deepEqual(loader.snapshot(), before); + await loader.close(); +}); + +test('structural updates preserve descendants added after the parent was created', async () => { + const loader = new MakaCompositionLoader(); + await loader.create('profile', { id: 'dynamic-group' }); + await loader.create('profile', { id: 'dynamic-child' }, 'dynamic-group'); + + await loader.disable('dynamic-group'); + + assert.equal(loader.inspect('dynamic-child').parentId, 'dynamic-group'); + assert.equal(loader.inspect('dynamic-child').disabled, true); + assert.deepEqual( + loader.snapshot().roots.profile[0]?.children?.map(({ id }) => id), + ['dynamic-child'], + ); + await loader.close(); +}); + +test('failed rebind leaves parent and position unchanged', async () => { + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('move-guard', (ctx: Context) => { + if (ctx.interceptConfig('moveGuard').length) throw new Error('target rejected move'); + }), + ); + await loader.create('profile', { id: 'target-parent', intercept: { moveGuard: true } }); + await loader.create('profile', entry('movable-entry', 'move-guard')); + const before = loader.snapshot(); + + await assert.rejects(loader.move('movable-entry', 'target-parent'), /target rejected move/u); + + assert.equal(loader.inspect('movable-entry').parentId, undefined); + assert.deepEqual(loader.snapshot(), before); + await loader.close(); +}); + +test('inspection includes package dependencies and live Fiber failures', async () => { + const loader = new MakaCompositionLoader(); + await loader.install( + pkg( + 'diagnostic-consumer', + Object.assign( + () => { + throw new Error('dependency activation failed'); + }, + { inject: ['packageService'] }, + ), + ), + ); + await loader.create('profile', entry('diagnostic-entry', 'diagnostic-consumer')); + + assert.deepEqual(loader.inspect('diagnostic-entry').waitingFor, ['packageService']); + loader.root.provide('packageService', { ready: true }); + await loader.awaitSettled(); + + const inspection = loader.inspect('diagnostic-entry'); + assert.equal(inspection.status, 'failed'); + assert.match(inspection.diagnostic ?? '', /dependency activation failed/u); + await loader.close(); +}); + +test('committed transactions reject late registration before acquiring resources', async (t) => { + t.mock.method(console, 'warn', () => undefined); + let registrations = 0; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('late-transaction', (ctx: Context) => () => { + ctx.makaTransaction!.stage( + 'late-registration', + () => { + registrations += 1; + return () => undefined; + }, + ctx, + ); + }), + ); + await loader.create('profile', entry('late-transaction-entry', 'late-transaction')); + + await loader.remove('late-transaction-entry'); + + assert.equal(registrations, 0); + await loader.close(); +}); + +test('callable config remains inspectable after publication', async () => { + const config = () => 'callable'; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('callable-config', () => undefined)); + + const inspection = await loader.create( + 'profile', + entry('callable-config-entry', 'callable-config', config), + ); + + assert.equal(inspection.config, config); + assert.equal(loader.inspect('callable-config-entry').config, config); + assert.equal(loader.snapshot().roots.profile[0]?.config, config); + await loader.close(); +}); + +test('callable intercept changes trigger structural Context replacement', async () => { + const first = () => 'first'; + const second = () => 'second'; + const seen: unknown[] = []; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('callable-intercept', (ctx: Context) => { + seen.push(ctx.interceptConfig('fixture')[0]); + }), + ); + await loader.create('profile', { + id: 'callable-intercept-entry', + packageId: 'callable-intercept', + intercept: { fixture: first }, + }); + + await loader.update('callable-intercept-entry', { intercept: { fixture: second } }); + + assert.deepEqual(seen, [first, second]); + assert.equal(loader.snapshot().roots.profile[0]?.intercept?.fixture, second); + await loader.close(); +}); + +test('staging and commit failures do not retain newly created session roots', async () => { + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('activation-failure', () => { + throw new Error('activation failed'); + }), + ); + await loader.install( + pkg('commit-root-failure', (ctx: Context) => { + ctx.makaTransaction!.stage('failure', () => { + throw new Error('commit failed'); + }); + }), + ); + const before = loader.snapshot(); + + await assert.rejects( + loader.create('session:missing-package', entry('missing-package-entry', 'missing-package')), + /not installed/u, + ); + await assert.rejects( + loader.create( + 'session:activation-failure', + entry('activation-failure-entry', 'activation-failure'), + ), + /activation failed/u, + ); + await assert.rejects( + loader.create('session:commit-failure', entry('commit-failure-entry', 'commit-root-failure')), + /commit failed/u, + ); + + assert.deepEqual(loader.snapshot(), before); + await loader.close(); +}); + +test('composition apply batches EntryTree operations under one generation check', async () => { + const loader = new MakaCompositionLoader(); + await loader.install(pkg('batch', () => undefined)); + const initial = loader.snapshot().generation; + const changed = await loader.apply({ + baseGeneration: initial, + operations: [ + { type: 'insert', entry: entry('batch-a', 'batch') }, + { type: 'insert', parentId: 'batch-a', entry: { id: 'batch-group' } }, + { type: 'update', entryId: 'batch-group', patch: { disabled: true } }, + ], + }); + assert.deepEqual( + changed.map(({ id }) => id), + ['batch-a', 'batch-group', 'batch-group'], + ); + assert.equal(loader.inspect('batch-group').disabled, true); + await assert.rejects( + () => loader.apply({ baseGeneration: initial, operations: [] }), + /Composition generation changed/u, + ); + await loader.close(); +}); + +test('failed composition batches restore the prior generation exactly', async () => { + const loader = new MakaCompositionLoader(); + await loader.create('profile', { id: 'stable-entry' }); + const before = loader.snapshot(); + + await assert.rejects( + () => + loader.apply({ + baseGeneration: before.generation, + operations: [ + { type: 'insert', entry: { id: 'temporary-entry' } }, + { type: 'update', entryId: 'missing-entry', patch: { disabled: true } }, + ], + }), + /Composition entry not found: missing-entry/u, + ); + + assert.deepEqual(loader.snapshot(), before); + await loader.close(); +}); + +function pkg(packageId: string, host: Plugin): MakaPluginPackage { + return Object.freeze({ packageId, host }); +} + +function entry(id: string, packageId: string, config?: unknown): MakaCompositionEntry { + return Object.freeze({ id, packageId, ...(config === undefined ? {} : { config }) }); +} diff --git a/packages/runtime/src/__tests__/plugin-kernel.test.ts b/packages/runtime/src/__tests__/plugin-kernel.test.ts new file mode 100644 index 0000000000..3af7731b5b --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-kernel.test.ts @@ -0,0 +1,701 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Context, FiberState, Service, type Plugin } from '../plugin-kernel.js'; +import { registerPluginContribution } from '../plugin-runtime.js'; + +declare module '../plugin-kernel.js' { + interface Context { + fixture?: { readonly value: string }; + } +} + +test('injected plugins wait for Services and reload when ownership changes', async () => { + const root = new Context(); + const lifecycle: string[] = []; + const consumer = Object.assign( + (ctx: Context) => { + const value = ctx.fixture?.value; + lifecycle.push(`load:${value}`); + return () => lifecycle.push(`dispose:${value}`); + }, + { inject: ['fixture'] }, + ) satisfies Plugin; + + const fiber = root.plugin(consumer); + await fiber.await(); + assert.equal(fiber.state, FiberState.PENDING); + + const removeFirst = root.provide('fixture', { value: 'first' }); + await fiber.await(); + assert.equal(fiber.state, FiberState.ACTIVE); + assert.deepEqual(lifecycle, ['load:first']); + + await removeFirst(); + await fiber.await(); + assert.equal(fiber.state, FiberState.PENDING); + assert.deepEqual(lifecycle, ['load:first', 'dispose:first']); + + root.provide('fixture', { value: 'second' }); + await fiber.await(); + assert.equal(fiber.state, FiberState.ACTIVE); + assert.deepEqual(lifecycle, ['load:first', 'dispose:first', 'load:second']); + await root.fiber.dispose(); +}); + +test('Services provided by a plugin activate dependent plugins after the provider is active', async () => { + const root = new Context(); + let consumed: string | undefined; + const consumer = Object.assign( + (ctx: Context) => { + consumed = ctx.get<{ readonly value: string }>('pluginService')?.value; + }, + { inject: ['pluginService'] }, + ); + const consumerFiber = root.plugin(consumer); + await consumerFiber.await(); + assert.equal(consumerFiber.state, FiberState.PENDING); + + const providerFiber = root.plugin((ctx) => { + ctx.provide('pluginService', { value: 'ready' }); + }); + await providerFiber.await(); + await consumerFiber.await(); + + assert.equal(providerFiber.state, FiberState.ACTIVE); + assert.equal(consumerFiber.state, FiberState.ACTIVE); + assert.equal(consumed, 'ready'); + await root.fiber.dispose(); +}); + +test('Service health-check failures move consumers to failed and allow recovery', async () => { + const root = new Context(); + let healthy = true; + let activations = 0; + root.provide('checkedService', { value: 1 }, () => { + if (!healthy) throw new Error('Service health check failed'); + return true; + }); + const consumer = root.plugin( + Object.assign( + () => { + activations += 1; + }, + { inject: ['checkedService'] }, + ), + ); + await consumer.await(); + assert.equal(consumer.state, FiberState.ACTIVE); + + healthy = false; + root.set('checkedService', { value: 2 }); + await assert.rejects(consumer.await(), /Service health check failed/u); + assert.equal(consumer.state, FiberState.FAILED); + + healthy = true; + root.set('checkedService', { value: 3 }); + await consumer.await(); + assert.equal(consumer.state, FiberState.ACTIVE); + assert.equal(activations, 2); + await root.fiber.dispose(); +}); + +test('a provider with multiple Services activates each dependent Fiber once', async () => { + const root = new Context(); + let activations = 0; + const consumer = root.plugin( + Object.assign( + () => { + activations += 1; + }, + { inject: ['firstService', 'secondService'] }, + ), + ); + await consumer.await(); + + const provider = root.plugin((ctx) => { + ctx.provide('firstService', { value: 1 }); + ctx.provide('secondService', { value: 2 }); + }); + await provider.await(); + await consumer.await(); + + assert.equal(provider.state, FiberState.ACTIVE); + assert.equal(consumer.state, FiberState.ACTIVE); + assert.equal(activations, 1); + await root.fiber.dispose(); +}); + +test('rapid Service updates coalesce dependent Fiber reloads around the latest value', async () => { + const root = new Context(); + root.provide('fixture', { value: 1 }); + const activations: number[] = []; + const consumer = root.plugin( + Object.assign( + (ctx: Context) => { + activations.push(ctx.get<{ readonly value: number }>('fixture')!.value); + }, + { inject: ['fixture'] }, + ), + ); + await consumer.await(); + + root.set('fixture', { value: 2 }); + root.set('fixture', { value: 3 }); + await consumer.await(); + + assert.deepEqual(activations, [1, 3]); + await root.fiber.dispose(); +}); + +test('Fiber await includes a Service refresh queued during activation', async () => { + const root = new Context(); + root.provide('fixture', { value: 1 }); + const activations: number[] = []; + let releaseFirst!: () => void; + let releaseSecond!: () => void; + let signalFirst!: () => void; + let signalSecond!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const secondGate = new Promise((resolve) => { + releaseSecond = resolve; + }); + const firstStarted = new Promise((resolve) => { + signalFirst = resolve; + }); + const secondStarted = new Promise((resolve) => { + signalSecond = resolve; + }); + const consumer = root.plugin( + Object.assign( + async (ctx: Context) => { + const value = ctx.get<{ readonly value: number }>('fixture')!.value; + activations.push(value); + if (value === 1) { + signalFirst(); + await firstGate; + } else { + signalSecond(); + await secondGate; + } + }, + { inject: ['fixture'] }, + ), + ); + + await firstStarted; + const settled = consumer.await(); + root.set('fixture', { value: 2 }); + releaseFirst(); + await secondStarted; + + let completed = false; + void settled.then(() => { + completed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(completed, false); + + releaseSecond(); + await settled; + assert.equal(consumer.state, FiberState.ACTIVE); + assert.deepEqual(activations, [1, 2]); + await root.fiber.dispose(); +}); + +test('Fiber await observes the final state after an intermediate transition rejects', async () => { + const root = new Context(); + const fiber = root.plugin((_ctx, config: string) => { + if (config === 'broken') throw new Error('broken transition'); + }, 'initial'); + await fiber.await(); + + const broken = fiber.update('broken'); + const recovered = fiber.update('recovered'); + await fiber.await(); + + await assert.rejects(broken, /broken transition/u); + await recovered; + assert.equal(fiber.state, FiberState.ACTIVE); + assert.equal(fiber.config, 'recovered'); + await root.fiber.dispose(); +}); + +test('fire-and-forget Fiber activation failures do not become unhandled rejections', async () => { + const root = new Context(); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + const fiber = root.plugin(() => { + throw new Error('activation failed'); + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(unhandled, []); + await assert.rejects(fiber.await(), /activation failed/u); + } finally { + process.off('unhandledRejection', onUnhandled); + await root.fiber.dispose().catch(() => undefined); + } +}); + +test('asynchronous Effect setup and cleanup failures are observed internally', async () => { + const root = new Context(); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + const fiber = root.plugin((ctx) => { + ctx.effect(async function* () { + yield () => { + throw new Error('effect cleanup failed'); + }; + throw new Error('effect setup failed'); + }, 'failed-async-effect'); + }); + await fiber.await(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(unhandled, []); + assert.ok(fiber.error instanceof AggregateError); + } finally { + process.off('unhandledRejection', onUnhandled); + await root.fiber.dispose().catch(() => undefined); + } +}); + +test('synchronous Effect setup failures preserve the active Fiber contract', async () => { + const root = new Context(); + const fiber = root.plugin((ctx) => { + ctx.effect(() => { + throw new Error('synchronous effect setup failed'); + }, 'failed-sync-effect'); + }); + + await fiber.await(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(fiber.state, FiberState.ACTIVE); + assert.match(String(fiber.error), /synchronous effect setup failed/u); + await root.fiber.dispose(); +}); + +test('Service isolation keeps sibling implementations independent', async () => { + const root = new Context(); + root.provide('fixture', { value: 'root' }); + const isolated = root.isolate('fixture'); + isolated.provide('fixture', { value: 'isolated' }); + + assert.equal(root.get<{ value: string }>('fixture')?.value, 'root'); + assert.equal(isolated.get<{ value: string }>('fixture')?.value, 'isolated'); + assert.equal(root.extend().get<{ value: string }>('fixture')?.value, 'root'); + await root.fiber.dispose(); +}); + +test('Fiber update preserves identity and disposes Effects in reverse order', async () => { + const root = new Context(); + const lifecycle: string[] = []; + const plugin = (ctx: Context, config: { value: number }) => { + lifecycle.push(`load:${config.value}`); + ctx.effect(() => () => lifecycle.push(`first:${config.value}`), 'first'); + ctx.effect(() => () => lifecycle.push(`second:${config.value}`), 'second'); + }; + const fiber = root.plugin(plugin, { value: 1 }); + await fiber.await(); + const id = fiber.id; + + await fiber.update({ value: 2 }); + assert.equal(fiber.id, id); + assert.deepEqual(lifecycle, ['load:1', 'second:1', 'first:1', 'load:2']); + assert.deepEqual( + fiber.getEffects().map(({ label }) => label), + ['first', 'second'], + ); + await root.fiber.dispose(); +}); + +test('Fiber Proxy exposes getters backed by private state', async () => { + const root = new Context(); + const fiber = root.plugin({ name: 'named-plugin', apply: () => undefined }); + + assert.equal(fiber.name, 'named-plugin'); + await fiber.await(); + await root.fiber.dispose(); +}); + +test('Plugin objects sharing apply retain independent Runtime metadata', async () => { + const root = new Context(); + const activations: string[] = []; + const apply = (_ctx: Context, config: string) => { + activations.push(config); + }; + const plugin = (name: string, prefix: string): Plugin.Object => ({ + name, + apply, + Config: { + '~standard': { + validate: (value) => ({ value: `${prefix}:${String(value)}` }), + }, + }, + }); + + const first = root.plugin(plugin('first-plugin', 'first'), 'a'); + const second = root.plugin(plugin('second-plugin', 'second'), 'b'); + await Promise.all([first.await(), second.await()]); + + assert.equal(first.name, 'first-plugin'); + assert.equal(second.name, 'second-plugin'); + assert.deepEqual(activations, ['first:a', 'second:b']); + await root.fiber.dispose(); +}); + +test('Standard Schema validation preserves raw Fiber config across restarts', async () => { + const root = new Context(); + const values: string[] = []; + const plugin: Plugin.Object = { + Config: { + '~standard': { + validate: (value) => ({ value: `parsed:${String(value)}` }), + }, + }, + apply(_ctx, config) { + values.push(String(config)); + }, + }; + const fiber = root.plugin(plugin, 'raw'); + await fiber.await(); + await fiber.restart(); + + assert.equal(fiber.config, 'raw'); + assert.deepEqual(values, ['parsed:raw', 'parsed:raw']); + await root.fiber.dispose(); +}); + +test('Fiber update restores the previous active config after activation fails', async () => { + const root = new Context(); + const lifecycle: string[] = []; + const fiber = root.plugin( + (_ctx, config: { value: number }) => { + lifecycle.push(`load:${config.value}`); + if (config.value === 2) throw new Error('invalid update'); + return () => lifecycle.push(`dispose:${config.value}`); + }, + { value: 1 }, + ); + await fiber.await(); + + await assert.rejects(fiber.update({ value: 2 }), /invalid update/u); + + assert.equal(fiber.state, FiberState.ACTIVE); + assert.deepEqual(fiber.config, { value: 1 }); + assert.deepEqual(lifecycle, ['load:1', 'dispose:1', 'load:2', 'load:1']); + await root.fiber.dispose(); +}); + +test('concurrent Fiber updates serialize config activation and isolate rollback', async () => { + const values: string[] = []; + const root = new Context(); + const fiber = root.plugin((_ctx, config: string) => { + values.push(config); + if (config === 'broken') throw new Error('broken config'); + }, 'initial'); + await fiber.await(); + + await Promise.all([fiber.update('first'), fiber.update('second')]); + assert.deepEqual(values, ['initial', 'first', 'second']); + assert.equal(fiber.config, 'second'); + + const results = await Promise.allSettled([fiber.update('broken'), fiber.update('final')]); + assert.equal(results[0]?.status, 'rejected'); + assert.equal(results[1]?.status, 'fulfilled'); + assert.deepEqual(values, ['initial', 'first', 'second', 'broken', 'second', 'final']); + assert.equal(fiber.config, 'final'); + assert.equal(fiber.state, FiberState.ACTIVE); + await fiber.dispose(); + await root.fiber.dispose(); +}); + +test('Fiber cleanup exhausts Effects before reporting disposer failures', async () => { + const root = new Context(); + const lifecycle: string[] = []; + const fiber = root.plugin((ctx) => { + ctx.effect(() => () => lifecycle.push('first')); + ctx.effect(() => () => { + lifecycle.push('failing'); + throw new Error('cleanup failed'); + }); + ctx.effect(() => () => lifecycle.push('last')); + }); + await fiber.await(); + + const firstDispose = fiber.dispose(); + const concurrentDispose = fiber.dispose(); + assert.equal(concurrentDispose, firstDispose); + const firstError = await firstDispose.then( + () => undefined, + (error: unknown) => error, + ); + assert.ok(firstError instanceof AggregateError); + + const retryDispose = fiber.dispose(); + assert.equal(retryDispose, firstDispose); + const retryError = await retryDispose.then( + () => undefined, + (error: unknown) => error, + ); + assert.equal(retryError, firstError); + assert.deepEqual(lifecycle, ['last', 'failing', 'first']); + assert.equal(fiber.state, FiberState.DISPOSED); + assert.equal(root.kernelFibers().includes(fiber), false); + await root.fiber.dispose(); +}); + +test('concurrent Effect disposal shares the same completion task', async () => { + const root = new Context(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const dispose = root.effect(() => async () => gate, 'slow-cleanup'); + + const first = dispose(); + const second = dispose(); + assert.equal(second, first); + let completed = false; + void second.then(() => { + completed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(completed, false); + + release(); + await first; + assert.equal(completed, true); + await root.fiber.dispose(); +}); + +test('Plugin Runtime metadata cache uses weak Plugin identities', async () => { + const root = new Context(); + assert.ok(root._kernel().runtimes instanceof WeakMap); + await root.fiber.dispose(); +}); + +test('accessors cannot shadow existing Context properties', async () => { + const root = new Context(); + assert.throws( + () => root.accessor('plugin', { get: () => 'hidden' }), + /Context property already exists: plugin/u, + ); + await root.fiber.dispose(); +}); + +test('child accessors cannot shadow metadata inherited from parent Contexts', async () => { + const root = new Context(); + const parent = root.extend({ inheritedMeta: 'visible' }); + const child = parent.extend(); + + assert.throws( + () => child.accessor('inheritedMeta', { get: () => 'hidden' }), + /Context property already exists: inheritedMeta/u, + ); + assert.equal(Reflect.get(child, 'inheritedMeta'), 'visible'); + await root.fiber.dispose(); +}); + +test('Services cannot shadow metadata inherited from parent Contexts', async () => { + const root = new Context(); + root.provide('entryMetadata', { value: 'service' }); + const metadata = { value: 'metadata' }; + const child = root.extend({ entryMetadata: metadata }).extend(); + + assert.equal(Reflect.get(child, 'entryMetadata'), metadata); + assert.deepEqual(child.get('entryMetadata'), { value: 'service' }); + await root.fiber.dispose(); +}); + +test('mixin validates every target before publishing accessors', async () => { + const root = new Context(); + + assert.throws( + () => root.mixin({ first: 1, second: 2 }, { first: 'mixed', second: 'plugin' }), + /Context property already exists: plugin/u, + ); + assert.equal(root._kernel().accessors.has('mixed'), false); + assert.equal(Reflect.get(root, 'mixed'), undefined); + await root.fiber.dispose(); +}); + +test('unknown Context property reads do not create Service labels', async () => { + const root = new Context(); + const labels = root._kernel().serviceLabels; + + for (let index = 0; index < 100; index += 1) { + assert.equal(Reflect.get(root, `unknownService${index}`), undefined); + } + assert.equal(labels.size, 0); + + const service = { value: 'available' }; + root.provide('futureService', service); + assert.equal(Reflect.get(root, 'futureService'), service); + assert.equal(labels.size, 1); + await root.fiber.dispose(); +}); + +test('event dispatch supports emit, parallel, serial, bail, and waterfall', async () => { + const root = new Context(); + const emitted: string[] = []; + root.on('emit', (value) => emitted.push(`one:${String(value)}`)); + root.on('emit', (value) => emitted.push(`two:${String(value)}`)); + root.emit('emit', 1); + assert.deepEqual(emitted, ['one:1', 'two:1']); + + const parallel: string[] = []; + root.on('parallel', async () => { + await Promise.resolve(); + parallel.push('one'); + }); + root.on('parallel', () => parallel.push('two')); + await root.parallel('parallel'); + assert.deepEqual(parallel.sort(), ['one', 'two']); + + const attempted: string[] = []; + root.on('parallel-error', () => { + attempted.push('throwing'); + throw new Error('synchronous listener failed'); + }); + root.on('parallel-error', () => attempted.push('following')); + await assert.rejects(root.parallel('parallel-error'), AggregateError); + assert.deepEqual(attempted, ['throwing', 'following']); + + root.on('serial', () => undefined); + root.on('serial', () => 'stop'); + root.on('serial', () => 'unreachable'); + assert.equal(await root.serial('serial'), 'stop'); + + root.on('bail', () => false); + root.on('bail', () => 42); + assert.equal(root.bail('bail'), 42); + + root.on('waterfall', (value, next) => `outer(${String((next as () => unknown)())}:${value})`); + root.on('waterfall', (value, next) => `inner(${String((next as () => unknown)())}:${value})`); + assert.equal( + root.waterfall('waterfall', 'x', () => 'base'), + 'outer(inner(base:x):x)', + ); + await root.fiber.dispose(); +}); + +test('event hooks are not published while their Fiber is unloading', async () => { + const root = new Context(); + let calls = 0; + const fiber = root.plugin((ctx) => () => { + ctx.on('late-hook', () => { + calls += 1; + }); + }); + await fiber.await(); + + await assert.rejects(fiber.dispose(), AggregateError); + root.emit('late-hook'); + + assert.equal(calls, 0); + await root.fiber.dispose(); +}); + +test('unloading Contexts cannot create escaping child Fibers', async () => { + const root = new Context(); + let childActivations = 0; + const fiber = root.plugin((ctx) => () => { + ctx.plugin(() => { + childActivations += 1; + }); + }); + await fiber.await(); + + await assert.rejects(fiber.dispose(), AggregateError); + assert.equal(childActivations, 0); + assert.deepEqual( + root.kernelFibers().map(({ id }) => id), + [0], + ); + await root.fiber.dispose(); +}); + +test('dispose requests immediately fence child Fiber creation and Service labels', async () => { + const root = new Context(); + let context!: Context; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const fiber = root.plugin(async (ctx, config: string) => { + context = ctx; + if (config === 'slow') await gate; + }, 'ready'); + await fiber.await(); + + const update = fiber.update('slow'); + await new Promise((resolve) => setImmediate(resolve)); + const dispose = fiber.dispose(); + + assert.throws(() => context.plugin(() => undefined), /Plugin Context is disposed/u); + assert.throws( + () => context.provide('lateService', { value: true }), + /Plugin Context is disposed/u, + ); + assert.equal(root._kernel().serviceLabels.has('lateService'), false); + + release(); + await update; + await dispose; + await root.fiber.dispose(); +}); + +test('late contribution registration is rejected before acquiring resources', async () => { + const root = new Context(); + let registrations = 0; + const fiber = root.plugin((ctx) => () => { + registerPluginContribution(ctx, 'late-contribution', () => { + registrations += 1; + return () => undefined; + }); + }); + await fiber.await(); + + await assert.rejects(fiber.dispose(), AggregateError); + assert.equal(registrations, 0); + await root.fiber.dispose(); +}); + +test('intercept configuration is inherited without mutating parent Contexts', async () => { + const root = new Context(); + const child = root.intercept('fixture', { child: true }); + const grandchild = child.intercept('fixture', { grandchild: true }); + + assert.deepEqual(root.interceptConfig('fixture'), []); + assert.deepEqual(child.interceptConfig('fixture'), [{ child: true }]); + assert.deepEqual(grandchild.interceptConfig('fixture'), [{ child: true }, { grandchild: true }]); + await root.fiber.dispose(); +}); + +test('Service records support names inherited from Object.prototype', async () => { + class FixtureService extends Service> { + merge(): Record { + return this.resolveConfig({ base: true }); + } + } + + const root = new Context(); + const intercepted = root.intercept('constructor', { intercepted: true }); + const isolated = intercepted.isolate('constructor'); + const service = new FixtureService(isolated, 'constructor'); + const bound = isolated.get('constructor'); + + assert.deepEqual(intercepted.interceptConfig('constructor'), [{ intercepted: true }]); + assert.ok(bound); + assert.deepEqual(bound.merge(), { base: true, intercepted: true }); + assert.deepEqual(service.merge(), { base: true, intercepted: true }); + await root.fiber.dispose(); +}); diff --git a/packages/runtime/src/plugin-composition-loader.ts b/packages/runtime/src/plugin-composition-loader.ts new file mode 100644 index 0000000000..21174768c8 --- /dev/null +++ b/packages/runtime/src/plugin-composition-loader.ts @@ -0,0 +1,886 @@ +import { Context, type Fiber, type Inject, type Plugin } from './plugin-kernel.js'; +import { + fiberStateName, + type MakaCompositionEntry, + type MakaCompositionEntryInspection, + type MakaCompositionApplyInput, + type MakaCompositionSnapshot, + type MakaPluginMetadata, + type MakaPluginPackage, + type MakaPluginRootId, + MakaPluginRuntimeError, + MakaPluginTransactionBuffer, + type MakaPluginTransaction, + validateCompositionEntry, + validatePluginPackage, + validatePluginRootId, +} from './plugin-runtime.js'; + +interface LiveEntry { + spec: MakaCompositionEntry; + readonly rootId: MakaPluginRootId; + parent?: LiveEntry; + context: Context; + fiber?: Fiber; + generation?: number; + readonly children: LiveEntry[]; + diagnostic?: string; +} + +const FIBER_PENDING = 0; +const FIBER_FAILED = 3; + +interface LiveRoot { + readonly id: MakaPluginRootId; + readonly context: Context; + readonly entries: LiveEntry[]; +} + +export interface MakaCompositionLoaderOptions { + readonly root?: Context; + readonly transaction?: (context: Context) => MakaPluginTransaction | undefined; +} + +export class MakaCompositionLoader { + readonly root: Context; + readonly #packages = new Map(); + readonly #roots = new Map(); + readonly #entries = new Map(); + readonly #isolationLabels = new Map(); + readonly #transaction?: (context: Context) => MakaPluginTransaction | undefined; + #compositionGeneration = 0; + #fiberGeneration = 0; + #mutation: Promise = Promise.resolve(); + + constructor(options: MakaCompositionLoaderOptions = {}) { + this.root = options.root ?? new Context(); + this.#transaction = options.transaction; + } + + install(pkg: MakaPluginPackage): Promise { + return this.#mutate(async () => { + validatePluginPackage(pkg); + if (this.#packages.has(pkg.packageId)) { + throw new MakaPluginRuntimeError( + 'package_exists', + `Plugin package is already installed: ${pkg.packageId}`, + ); + } + this.#packages.set(pkg.packageId, freezePackage(pkg)); + }); + } + + uninstall(packageId: string): Promise { + return this.#mutate(async () => { + if (!this.#packages.has(packageId)) { + throw new MakaPluginRuntimeError( + 'package_not_found', + `Plugin package is not installed: ${packageId}`, + ); + } + const user = [...this.#entries.values()].find((entry) => entry.spec.packageId === packageId); + if (user) { + throw new MakaPluginRuntimeError( + 'package_in_use', + `Plugin package is used by entry ${user.spec.id}`, + ); + } + this.#packages.delete(packageId); + }); + } + + create( + rootId: MakaPluginRootId, + entry: MakaCompositionEntry, + parentId?: string, + position = Infinity, + ): Promise { + return this.apply({ + operations: [{ type: 'insert', rootId, entry, parentId, position }], + }).then(([inspection]) => inspection!); + } + + update( + entryId: string, + patch: Partial>, + ): Promise { + return this.apply({ operations: [{ type: 'update', entryId, patch }] }).then( + ([inspection]) => inspection!, + ); + } + + move( + entryId: string, + newParentId?: string, + position = Infinity, + ): Promise { + return this.apply({ + operations: [{ type: 'move', entryId, parentId: newParentId, position }], + }).then(([inspection]) => inspection!); + } + + enable(entryId: string): Promise { + return this.update(entryId, { disabled: false }); + } + + disable(entryId: string): Promise { + return this.update(entryId, { disabled: true }); + } + + apply(input: MakaCompositionApplyInput): Promise { + return this.#mutate(async () => { + if ( + input.baseGeneration !== undefined && + input.baseGeneration !== this.#compositionGeneration + ) + throw new MakaPluginRuntimeError( + 'invalid_entry', + `Composition generation changed from ${input.baseGeneration} to ${this.#compositionGeneration}`, + ); + const before = this.snapshot(); + const inspections: MakaCompositionEntryInspection[] = []; + let appliedOperations = 0; + try { + for (const operation of input.operations) { + switch (operation.type) { + case 'insert': { + const entry = await this.#insert( + operation.rootId ?? this.#inferRoot(operation.parentId), + operation.entry, + operation.parentId, + operation.position, + ); + inspections.push(this.#inspect(entry)); + appliedOperations += 1; + break; + } + case 'update': { + const entry = await this.#update(operation.entryId, operation.patch); + inspections.push(this.#inspect(entry)); + appliedOperations += 1; + break; + } + case 'move': { + const entry = await this.#move( + operation.entryId, + operation.parentId, + operation.position, + ); + inspections.push(this.#inspect(entry)); + appliedOperations += 1; + break; + } + case 'remove': + await this.#remove(operation.entryId); + appliedOperations += 1; + break; + } + } + } catch (error) { + // A candidate can fail before changing the live tree. Rebuilding in + // that case would unnecessarily dispose the current Fiber and lose + // its registered contributions. + if (appliedOperations > 0) await this.#replaceSnapshot(before, 'rollback'); + throw error; + } + if (input.operations.length > 0) this.#compositionGeneration += 1; + return Object.freeze(inspections); + }); + } + + replaceSubtree( + entryId: string, + entry: MakaCompositionEntry, + ): Promise { + return this.#mutate(async () => { + const current = this.#requireEntry(entryId); + if (entry.id !== entryId) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + 'Replacement subtree must preserve entry id', + ); + } + validateCompositionEntry(entry); + const descendantIds = new Set(); + for (const item of walk(entry)) { + if (descendantIds.has(item.id)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Replacement subtree repeats entry ${item.id}`, + ); + } + descendantIds.add(item.id); + const existing = this.#entries.get(item.id); + if (existing && !isWithin(existing, current)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${item.id}`, + ); + } + } + const inspection = await this.#replace(current, freezeEntry(entry)); + this.#compositionGeneration += 1; + return inspection; + }); + } + + remove(entryId: string): Promise { + return this.apply({ operations: [{ type: 'remove', entryId }] }).then(() => undefined); + } + + inspectTree(rootId?: MakaPluginRootId): readonly MakaCompositionEntryInspection[] { + if (rootId) validatePluginRootId(rootId); + const selected = rootId ? this.#roots.get(rootId) : undefined; + const roots = rootId ? (selected ? [selected] : []) : [...this.#roots.values()]; + return Object.freeze( + roots.flatMap((root) => root.entries.map((entry) => this.#inspect(entry))), + ); + } + + inspect(entryId: string): MakaCompositionEntryInspection { + return this.#inspect(this.#requireEntry(entryId)); + } + + installedPackages(): readonly { readonly packageId: string }[] { + return Object.freeze( + [...this.#packages.values()] + .map(({ packageId }) => Object.freeze({ packageId })) + .sort((left, right) => left.packageId.localeCompare(right.packageId)), + ); + } + + package(packageId: string): MakaPluginPackage { + const pkg = this.#packages.get(packageId); + if (!pkg) { + throw new MakaPluginRuntimeError( + 'package_not_found', + `Plugin package is not installed: ${packageId}`, + ); + } + return pkg; + } + + async awaitSettled(): Promise { + while (true) { + const tasks = [...this.#entries.values()].flatMap((entry) => + entry.fiber?.inertia ? [entry.fiber.inertia] : [], + ); + if (!tasks.length) return; + await Promise.allSettled(tasks); + } + } + + snapshot(): MakaCompositionSnapshot { + const encode = (rootId: MakaPluginRootId): readonly MakaCompositionEntry[] => + Object.freeze((this.#roots.get(rootId)?.entries ?? []).map((entry) => serialize(entry))); + const sessions = Object.fromEntries( + [...this.#roots.values()].flatMap((root) => + root.id.startsWith('session:') + ? [[root.id.slice('session:'.length), encode(root.id)] as const] + : [], + ), + ); + return Object.freeze({ + schemaVersion: 1, + generation: this.#compositionGeneration, + roots: Object.freeze({ + profile: encode('profile'), + desktopUi: encode('desktop-ui'), + sessions: Object.freeze(sessions), + }), + }); + } + + replaceSnapshot(snapshot: MakaCompositionSnapshot): Promise { + return this.#mutate(() => this.#replaceSnapshot(snapshot, 'publish')); + } + + async #replaceSnapshot( + snapshot: MakaCompositionSnapshot, + generationMode: 'publish' | 'rollback', + ): Promise { + if (snapshot.schemaVersion !== 1) + throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition snapshot'); + const previousGeneration = this.#compositionGeneration; + const pristine = previousGeneration === 0 && this.#entries.size === 0 && this.#roots.size === 0; + const specs = new Map([ + ['profile', snapshot.roots.profile], + ['desktop-ui', snapshot.roots.desktopUi], + ...Object.entries(snapshot.roots.sessions).map( + ([id, entries]) => [`session:${id}` as MakaPluginRootId, entries] as const, + ), + ]); + const stagedRoots = new Map(); + const stagedIds = new Set(); + try { + for (const [rootId, entries] of specs) { + validatePluginRootId(rootId); + const context = this.root.extend({ makaRootId: rootId }); + const root: LiveRoot = { id: rootId, context, entries: [] }; + stagedRoots.set(rootId, root); + for (const spec of entries) { + validateCompositionEntry(spec); + for (const item of walk(spec)) { + if (stagedIds.has(item.id)) + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${item.id}`, + ); + stagedIds.add(item.id); + } + root.entries.push(await this.#stage(spec, rootId, undefined, context, false)); + } + } + for (const root of stagedRoots.values()) + for (const entry of root.entries) await this.#commitSubtree(entry); + } catch (error) { + return rethrowAfterCleanup( + error, + () => + settleAll( + [...stagedRoots.values()].flatMap((root) => + [...root.entries].reverse().map((entry) => this.#dispose(entry)), + ), + 'Staged composition cleanup failed', + ), + 'Composition replacement and cleanup failed', + ); + } + const previous = [...this.#roots.values()]; + this.#roots.clear(); + this.#entries.clear(); + for (const [rootId, root] of stagedRoots) { + this.#roots.set(rootId, root); + for (const entry of root.entries) this.#index(entry); + } + this.#compositionGeneration = + generationMode === 'rollback' + ? snapshot.generation + : pristine + ? snapshot.generation + : Math.max(previousGeneration, snapshot.generation) + 1; + await this.#retire( + settleAll( + previous.flatMap((root) => + [...root.entries].reverse().map((entry) => this.#dispose(entry)), + ), + 'Previous composition cleanup failed', + ), + 'Previous composition cleanup failed after publishing the replacement', + ); + } + + async close(): Promise { + await this.#mutate(async () => { + const roots = [...this.#roots.values()]; + this.#roots.clear(); + this.#entries.clear(); + const errors: unknown[] = []; + try { + await settleAll( + roots.flatMap((root) => [...root.entries].reverse().map((entry) => this.#dispose(entry))), + 'Composition entry cleanup failed', + ); + } catch (error) { + errors.push(error); + } + try { + await this.root.fiber.dispose(); + } catch (error) { + errors.push(error); + } + throwIfErrors(errors, 'Composition loader close failed'); + }); + } + + async #replace( + current: LiveEntry, + spec: MakaCompositionEntry, + ): Promise { + const parentContext = current.parent?.context ?? this.#root(current.rootId).context; + const candidate = await this.#stage( + spec, + current.rootId, + current.parent, + parentContext, + current.parent ? isDisabled(current.parent) : false, + ); + try { + await this.#commitSubtree(candidate); + } catch (error) { + current.diagnostic = diagnostic(error); + return rethrowAfterCleanup( + error, + () => this.#dispose(candidate), + `Entry ${current.spec.id} replacement and cleanup failed`, + ); + } + const siblings = current.parent?.children ?? this.#root(current.rootId).entries; + const index = siblings.indexOf(current); + this.#unindex(current); + siblings[index] = candidate; + this.#index(candidate); + await this.#retire( + this.#dispose(current), + `Entry ${current.spec.id} cleanup failed after publishing its replacement`, + ); + return this.#inspect(candidate); + } + + async #rebind(entry: LiveEntry, parent: LiveEntry | undefined, position: number): Promise { + const replacement = await this.#stage( + serialize(entry), + entry.rootId, + parent, + parent?.context ?? this.#root(entry.rootId).context, + parent ? isDisabled(parent) : false, + ); + try { + await this.#commitSubtree(replacement); + } catch (error) { + return rethrowAfterCleanup( + error, + () => this.#dispose(replacement), + `Entry ${entry.spec.id} move activation and cleanup failed`, + ); + } + const source = entry.parent?.children ?? this.#root(entry.rootId).entries; + const target = parent?.children ?? this.#root(entry.rootId).entries; + this.#unindex(entry); + source.splice(source.indexOf(entry), 1); + target.splice(Math.min(position, target.length), 0, replacement); + this.#index(replacement); + await this.#retire( + this.#dispose(entry), + `Entry ${entry.spec.id} cleanup failed after publishing its rebound Fiber`, + ); + } + + async #stage( + spec: MakaCompositionEntry, + rootId: MakaPluginRootId, + parent: LiveEntry | undefined, + parentContext: Context, + ancestorDisabled: boolean, + ): Promise { + let context = parentContext.extend({ makaEntryId: spec.id }); + for (const [service, label] of Object.entries(spec.isolate ?? {})) { + const symbol = label === true ? Symbol(`${spec.id}:${service}`) : this.#isolationLabel(label); + context = context.isolate(service, symbol); + } + for (const [service, config] of Object.entries(spec.intercept ?? {})) + context = context.intercept(service, config); + const live: LiveEntry = { spec: freezeEntry(spec), rootId, parent, context, children: [] }; + const disabled = ancestorDisabled || spec.disabled === true; + if (!disabled && spec.packageId) { + const pkg = this.#packages.get(spec.packageId); + if (!pkg) + throw new MakaPluginRuntimeError( + 'package_not_found', + `Plugin package is not installed: ${spec.packageId}`, + ); + if (!pkg.host) + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package has no Host plugin: ${spec.packageId}`, + ); + const generation = ++this.#fiberGeneration; + const metadata: MakaPluginMetadata = Object.freeze({ + rootId, + entryId: spec.id, + packageId: spec.packageId, + generation, + }); + context = context.extend({ maka: metadata }); + const transaction = this.#transaction?.(context) ?? new MakaPluginTransactionBuffer(context); + if (transaction) context = context.extend({ makaTransaction: transaction }); + live.context = context; + live.generation = generation; + const plugin = entryPlugin(pkg.host, spec.inject); + live.fiber = context.plugin(plugin, spec.config); + try { + await live.fiber.await(); + if (live.fiber.state === FIBER_FAILED) throw new Error(`Plugin Fiber failed: ${spec.id}`); + } catch (error) { + live.diagnostic = diagnostic(error); + const cleanupErrors: unknown[] = []; + try { + await live.fiber.dispose(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + try { + await transaction?.rollback(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + const cause = cleanupErrors.length + ? new AggregateError( + [error, ...cleanupErrors], + `Entry ${spec.id} activation and cleanup failed`, + ) + : error; + throw new MakaPluginRuntimeError( + 'activation_failed', + `Unable to activate entry ${spec.id}: ${diagnostic(error)}`, + { cause }, + ); + } + } + try { + for (const child of spec.children ?? []) + live.children.push(await this.#stage(child, rootId, live, live.context, disabled)); + } catch (error) { + return rethrowAfterCleanup( + error, + () => this.#dispose(live), + `Entry ${spec.id} staging and cleanup failed`, + ); + } + return live; + } + + async #commitSubtree(entry: LiveEntry): Promise { + await entry.context.makaTransaction?.commit(); + for (const child of entry.children) await this.#commitSubtree(child); + } + + async #dispose(entry: LiveEntry): Promise { + const errors: unknown[] = []; + try { + await settleAll( + [...entry.children].reverse().map((child) => this.#dispose(child)), + `Entry ${entry.spec.id} child cleanup failed`, + ); + } catch (error) { + errors.push(error); + } + try { + await entry.context.makaTransaction?.rollback(); + } catch (error) { + errors.push(error); + } + try { + await entry.fiber?.dispose(); + } catch (error) { + errors.push(error); + } + throwIfErrors(errors, `Entry ${entry.spec.id} cleanup failed`); + } + + async #retire(task: Promise, message: string): Promise { + try { + await task; + } catch (error) { + this.root.logger.warn(message, error); + } + } + + #root(rootId: MakaPluginRootId): LiveRoot { + validatePluginRootId(rootId); + let root = this.#roots.get(rootId); + if (!root) { + root = { id: rootId, context: this.root.extend({ makaRootId: rootId }), entries: [] }; + this.#roots.set(rootId, root); + } + return root; + } + + #inferRoot(parentId: string | undefined): MakaPluginRootId { + if (!parentId) return 'profile'; + return this.#requireEntry(parentId).rootId; + } + + async #insert( + rootId: MakaPluginRootId, + entry: MakaCompositionEntry, + parentId?: string, + position = Infinity, + ): Promise { + validatePluginRootId(rootId); + validateCompositionEntry(entry); + this.#assertUniqueSubtree(entry); + const parent = parentId ? this.#requireEntry(parentId) : undefined; + if (parent && parent.rootId !== rootId) + throw new MakaPluginRuntimeError( + 'invalid_entry', + 'Composition entries cannot move between roots', + ); + const createdRoot = !this.#roots.has(rootId); + const root = this.#root(rootId); + try { + const live = await this.#stage( + entry, + rootId, + parent, + parent?.context ?? root.context, + parent ? isDisabled(parent) : false, + ); + try { + await this.#commitSubtree(live); + } catch (error) { + await rethrowAfterCleanup( + error, + () => this.#dispose(live), + `Entry ${entry.id} commit and cleanup failed`, + ); + } + const siblings = parent?.children ?? root.entries; + siblings.splice(Math.min(position, siblings.length), 0, live); + this.#index(live); + return live; + } catch (error) { + if (createdRoot && root.entries.length === 0) this.#roots.delete(rootId); + throw error; + } + } + + async #update( + entryId: string, + patch: Partial>, + ): Promise { + const current = this.#requireEntry(entryId); + const next = freezeEntry({ + ...current.spec, + ...patch, + id: current.spec.id, + children: current.children.map(serialize), + }); + validateCompositionEntry(next); + const structural = + next.packageId !== current.spec.packageId || + !shallowCompositionEqual(next.inject, current.spec.inject) || + !shallowCompositionEqual(next.isolate, current.spec.isolate) || + !shallowCompositionEqual(next.intercept, current.spec.intercept); + if (!structural && current.fiber && next.disabled !== true && current.spec.disabled !== true) { + await current.fiber.update(next.config); + current.spec = next; + current.diagnostic = undefined; + return current; + } + return this.#replace(current, next).then((inspection) => this.#requireEntry(inspection.id)); + } + + async #move(entryId: string, newParentId?: string, position = Infinity): Promise { + const entry = this.#requireEntry(entryId); + const parent = newParentId ? this.#requireEntry(newParentId) : undefined; + if (parent && parent.rootId !== entry.rootId) + throw new MakaPluginRuntimeError( + 'invalid_entry', + 'Composition entries cannot move between roots', + ); + for (let ancestor = parent; ancestor; ancestor = ancestor.parent) + if (ancestor === entry) + throw new MakaPluginRuntimeError( + 'dependency_cycle', + `Entry ${entryId} cannot contain itself`, + ); + await this.#rebind(entry, parent, position); + return this.#requireEntry(entryId); + } + + async #remove(entryId: string): Promise { + const entry = this.#requireEntry(entryId); + const siblings = entry.parent?.children ?? this.#root(entry.rootId).entries; + siblings.splice(siblings.indexOf(entry), 1); + this.#unindex(entry); + await this.#retire( + this.#dispose(entry), + `Entry ${entry.spec.id} cleanup failed after removing it from the composition`, + ); + } + + #requireEntry(entryId: string): LiveEntry { + const entry = this.#entries.get(entryId); + if (!entry) + throw new MakaPluginRuntimeError( + 'entry_not_found', + `Composition entry not found: ${entryId}`, + ); + return entry; + } + + #assertUniqueSubtree(entry: MakaCompositionEntry): void { + const local = new Set(); + for (const item of walk(entry)) { + if (local.has(item.id) || this.#entries.has(item.id)) + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${item.id}`, + ); + local.add(item.id); + } + } + + #index(entry: LiveEntry): void { + this.#entries.set(entry.spec.id, entry); + for (const child of entry.children) this.#index(child); + } + + #unindex(entry: LiveEntry): void { + this.#entries.delete(entry.spec.id); + for (const child of entry.children) this.#unindex(child); + } + + #inspect(entry: LiveEntry): MakaCompositionEntryInspection { + const sourceInject = entry.fiber?.inject ?? entry.spec.inject; + const inject = Array.isArray(sourceInject) + ? sourceInject + : Object.keys((sourceInject as Readonly> | undefined) ?? {}); + const waitingFor = + entry.fiber?.state === FIBER_PENDING + ? inject.filter((name) => entry.context.get(name) === undefined) + : []; + return Object.freeze({ + id: entry.spec.id, + rootId: entry.rootId, + ...(entry.parent ? { parentId: entry.parent.spec.id } : {}), + ...(entry.spec.packageId ? { packageId: entry.spec.packageId } : {}), + ...(entry.spec.config === undefined ? {} : { config: entry.spec.config }), + disabled: isDisabled(entry), + status: isDisabled(entry) + ? 'disabled' + : entry.fiber + ? fiberStateName(entry.fiber.state) + : 'active', + ...(entry.generation === undefined ? {} : { generation: entry.generation }), + waitingFor: Object.freeze(waitingFor), + effects: Object.freeze(entry.fiber?.getEffects().map(({ label }) => label) ?? []), + children: Object.freeze(entry.children.map((child) => this.#inspect(child))), + ...((entry.diagnostic ?? entry.fiber?.error) + ? { diagnostic: entry.diagnostic ?? diagnostic(entry.fiber?.error) } + : {}), + }); + } + + #isolationLabel(label: string): symbol { + let symbol = this.#isolationLabels.get(label); + if (!symbol) { + symbol = Symbol(label); + this.#isolationLabels.set(label, symbol); + } + return symbol; + } + + #mutate(operation: () => Promise): Promise { + const result = this.#mutation.then(operation, operation); + this.#mutation = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} + +function entryPlugin(plugin: Plugin, inject: MakaCompositionEntry['inject']): Plugin { + const combined = mergeInject((plugin as Plugin.Base).inject, inject); + return { + name: (plugin as Plugin.Base).name ?? 'maka-entry', + ...(combined ? { inject: combined } : {}), + ...((plugin as Plugin.Base).Config ? { Config: (plugin as Plugin.Base).Config } : {}), + apply(ctx: Context, config: unknown) { + if (typeof plugin !== 'function') return plugin.apply(ctx, config as never); + if (isConstructor(plugin)) return Reflect.construct(plugin, [ctx, config]); + return (plugin as Plugin.Function)(ctx, config as never); + }, + }; +} + +function isConstructor(value: Function): boolean { + return /^class\s/u.test(Function.prototype.toString.call(value)); +} + +function mergeInject( + left: Inject | undefined, + right: MakaCompositionEntry['inject'], +): Inject | undefined { + if (!left && !right) return undefined; + const output: Record = {}; + for (const source of [left, right]) { + if (Array.isArray(source)) for (const name of source) output[name] = null; + else Object.assign(output, source ?? {}); + } + return output; +} + +function freezePackage(pkg: MakaPluginPackage): MakaPluginPackage { + return Object.freeze({ ...pkg, contributions: Object.freeze([...(pkg.contributions ?? [])]) }); +} + +function freezeEntry(entry: MakaCompositionEntry): MakaCompositionEntry { + return Object.freeze({ + ...entry, + ...(entry.inject && !Array.isArray(entry.inject) + ? { inject: Object.freeze({ ...entry.inject }) } + : entry.inject + ? { inject: Object.freeze([...entry.inject]) } + : {}), + ...(entry.isolate ? { isolate: Object.freeze({ ...entry.isolate }) } : {}), + ...(entry.intercept ? { intercept: Object.freeze({ ...entry.intercept }) } : {}), + children: Object.freeze((entry.children ?? []).map(freezeEntry)), + }); +} + +function serialize(entry: LiveEntry): MakaCompositionEntry { + return freezeEntry({ ...entry.spec, children: entry.children.map(serialize) }); +} + +function shallowCompositionEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false; + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + return ( + leftEntries.length === rightEntries.length && + leftEntries.every( + ([key, value]) => + Object.hasOwn(right, key) && + Object.is(value, (right as Readonly>)[key]), + ) + ); +} + +function* walk(entry: MakaCompositionEntry): Generator { + yield entry; + for (const child of entry.children ?? []) yield* walk(child); +} + +function isWithin(entry: LiveEntry, root: LiveEntry): boolean { + for (let current: LiveEntry | undefined = entry; current; current = current.parent) + if (current === root) return true; + return false; +} + +function isDisabled(entry: LiveEntry): boolean { + for (let current: LiveEntry | undefined = entry; current; current = current.parent) { + if (current.spec.disabled === true) return true; + } + return false; +} + +function diagnostic(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function settleAll(tasks: Iterable>, message: string): Promise { + const results = await Promise.allSettled(tasks); + const errors = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])); + throwIfErrors(errors, message); +} + +async function rethrowAfterCleanup( + error: unknown, + cleanup: () => Promise, + message: string, +): Promise { + try { + await cleanup(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], message); + } + throw error; +} + +function throwIfErrors(errors: readonly unknown[], message: string): void { + if (errors.length) throw new AggregateError(errors, message); +} diff --git a/packages/runtime/src/plugin-kernel.ts b/packages/runtime/src/plugin-kernel.ts new file mode 100644 index 0000000000..710cc9e67b --- /dev/null +++ b/packages/runtime/src/plugin-kernel.ts @@ -0,0 +1,1056 @@ +export type Awaitable = T | PromiseLike; + +export type Disposable> = () => T; + +export type Inject = readonly string[] | Readonly>; + +export const enum FiberState { + PENDING, + LOADING, + ACTIVE, + FAILED, + DISPOSED, + UNLOADING, +} + +export interface EffectMeta { + readonly label: string; + readonly children: readonly EffectMeta[]; +} + +export interface StandardSchema { + readonly '~standard': { + validate( + value: unknown, + ): + | { readonly value: unknown; readonly issues?: undefined } + | { readonly issues: readonly { readonly message: string }[] } + | Promise< + | { readonly value: unknown; readonly issues?: undefined } + | { readonly issues: readonly { readonly message: string }[] } + >; + }; +} + +export type Plugin = Plugin.Function | Plugin.Constructor | Plugin.Object; + +export namespace Plugin { + export interface Base { + readonly name?: string; + readonly inject?: Inject; + readonly Config?: StandardSchema; + } + + export type Function = Base & ((ctx: Context, config: T) => unknown); + + export type Constructor = Base & (new (ctx: Context, config: T) => unknown); + + export interface Object extends Base { + apply(ctx: Context, config: T): unknown; + } +} + +export interface EventOptions { + readonly prepend?: boolean; + readonly global?: boolean; +} + +interface ServiceImplementation { + readonly name: string; + readonly label: symbol; + readonly fiber: Fiber; + value: unknown; + readonly check?: () => boolean; +} + +interface Hook { + readonly context: Context; + readonly listener: (...args: unknown[]) => unknown; + readonly global: boolean; +} + +interface Accessor { + readonly owner: Fiber; + readonly get: (this: Context, receiver: unknown) => unknown; + readonly set?: (this: Context, value: unknown, receiver: unknown) => boolean; +} + +interface PluginRuntime { + readonly callback: Function; + readonly fibers: Set; + readonly name?: string; + readonly Config?: StandardSchema; +} + +interface KernelState { + readonly root: Context; + readonly services: Map; + readonly serviceLabels: Map; + readonly runtimes: WeakMap; + readonly listeners: Map; + readonly accessors: Map; + readonly fibers: Set; + nextFiberId: number; + closed: boolean; +} + +const contextBrand = Symbol.for('maka.plugin-kernel.context'); +const effectMeta = Symbol('maka.plugin-kernel.effect-meta'); +const disposedFibers = new WeakSet(); + +export interface Logger { + readonly name: string; + error(value: unknown, ...values: unknown[]): void; + warn(value: unknown, ...values: unknown[]): void; + info(value: unknown, ...values: unknown[]): void; + debug(value: unknown, ...values: unknown[]): void; +} + +export interface LoggerService extends Logger { + (name?: string): Logger; +} + +export interface Context { + root: Context; + parent?: Context; + fiber: Fiber; + readonly logger: LoggerService; + [Context.filter]?: (listenerContext: Context) => boolean; +} + +export class Context { + static readonly effect = effectMeta; + static readonly filter = Symbol('maka.plugin-kernel.filter'); + + readonly [contextBrand] = true; + readonly #kernel: KernelState; + readonly #isolation: Readonly>; + readonly #intercepts: Readonly>; + readonly #proxy: Context; + + static is(value: unknown): value is Context { + return Boolean((value as { readonly [contextBrand]?: boolean } | undefined)?.[contextBrand]); + } + + constructor(); + constructor( + kernel?: KernelState, + parent?: Context, + fiber?: Fiber, + isolation?: Readonly>, + intercepts?: Readonly>, + meta?: object, + ); + constructor( + kernel?: KernelState, + parent?: Context, + fiber?: Fiber, + isolation?: Readonly>, + intercepts?: Readonly>, + meta: object = {}, + ) { + this.parent = parent; + this.#isolation = isolation ?? parent?._isolation() ?? freezeRecord(); + this.#intercepts = intercepts ?? parent?._intercepts() ?? freezeRecord(); + if (kernel) { + this.#kernel = kernel; + this.root = kernel.root; + this.fiber = fiber ?? parent?.fiber ?? kernel.root.fiber; + } else { + const placeholder = {} as KernelState; + this.#kernel = placeholder; + this.root = this; + const rootFiber = Fiber.root(this); + this.fiber = rootFiber; + Object.assign(placeholder, { + root: this, + services: new Map(), + serviceLabels: new Map(), + runtimes: new WeakMap(), + listeners: new Map(), + accessors: new Map(), + fibers: new Set([rootFiber]), + nextFiberId: 0, + closed: false, + } satisfies KernelState); + } + Object.assign(this, meta); + Object.defineProperty(this, 'logger', { + enumerable: true, + configurable: false, + value: createLoggerService(() => this.fiber.name), + }); + this.#proxy = new Proxy(this, contextProxy); + return this.#proxy; + } + + extend(meta: object = {}): this { + this.#assertOpen(); + return new Context( + this.#kernel, + this, + this.fiber, + this.#isolation, + this.#intercepts, + meta, + ) as this; + } + + isolate(name: string, label = Symbol(name)): this { + validateServiceName(name); + return new Context( + this.#kernel, + this, + this.fiber, + freezeRecord({ ...this.#isolation, [name]: label }), + this.#intercepts, + ) as this; + } + + intercept(name: string, config: unknown): this { + validateServiceName(name); + const existing = this.#intercepts[name] ?? []; + return new Context( + this.#kernel, + this, + this.fiber, + this.#isolation, + freezeRecord({ ...this.#intercepts, [name]: Object.freeze([...existing, config]) }), + ) as this; + } + + plugin

(plugin: P, config?: unknown): Fiber & PromiseLike { + this.#assertOpen(); + const callback = resolvePlugin(plugin); + let runtime = this.#kernel.runtimes.get(plugin); + if (!runtime) { + runtime = { + callback, + fibers: new Set(), + name: plugin.name, + Config: plugin.Config, + }; + this.#kernel.runtimes.set(plugin, runtime); + } + const fiber = new Fiber(this, plugin, config, normalizeInject(plugin.inject), runtime); + return new Proxy(fiber, { + get(target, property, receiver) { + if (property === 'then') { + return ( + onFulfilled?: ((value: Fiber) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike => + target + .await() + .then( + () => (onFulfilled ? onFulfilled(target) : (target as unknown as TResult1)), + onRejected ?? undefined, + ); + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }) as Fiber & PromiseLike; + } + + inject(inject: Inject, callback: Plugin.Function): Fiber & PromiseLike { + return this.plugin(Object.assign(callback, { inject })); + } + + effect(execute: () => unknown, label = 'anonymous'): Disposable> { + return this.fiber.effect(execute, label); + } + + provide(name: string, value?: unknown, check?: () => boolean): Disposable> { + this.#assertOpen(); + validateServiceName(name); + const label = this.#label(name); + if (this.#kernel.services.has(label)) { + throw new Error(`Service is already provided in this scope: ${name}`); + } + return this.effect( + () => { + const implementation: ServiceImplementation = { + name, + label, + value, + fiber: this.fiber, + check, + }; + this.#kernel.services.set(label, implementation); + this.#notifyService(name, label); + return async () => { + if (this.#kernel.services.get(label) !== implementation) return; + this.#kernel.services.delete(label); + await Promise.allSettled(this.#notifyService(name, label).map((fiber) => fiber.await())); + }; + }, + `ctx.provide(${JSON.stringify(name)})`, + ); + } + + get(name: string, strict = true): T | undefined { + const implementation = this.#implementation(name); + if (!implementation) return undefined; + if (strict && implementation.fiber.state !== FiberState.ACTIVE) return undefined; + if (implementation.check && !implementation.check.call(implementation.value)) return undefined; + return ( + implementation.value instanceof Service + ? implementation.value._bind(this.#proxy) + : implementation.value + ) as T; + } + + set(name: string, value: unknown): boolean { + const implementation = this.#implementation(name); + if (!implementation) throw new Error(`Cannot set missing Service: ${name}`); + if (implementation.fiber !== this.fiber) { + throw new Error(`Cannot mutate Service owned by another Fiber: ${name}`); + } + implementation.value = value; + this.#notifyService(name, implementation.label); + return true; + } + + accessor( + name: string, + options: { + readonly get: (this: Context, receiver: unknown) => unknown; + readonly set?: (this: Context, value: unknown, receiver: unknown) => boolean; + }, + ): Disposable> { + this.#assertAccessorAvailable(name); + return this.effect( + () => { + const accessor = { owner: this.fiber, ...options }; + this.#kernel.accessors.set(name, accessor); + return () => { + if (this.#kernel.accessors.get(name) === accessor) this.#kernel.accessors.delete(name); + }; + }, + `ctx.accessor(${JSON.stringify(name)})`, + ); + } + + mixin( + source: string | object, + names: readonly string[] | Readonly>, + ): void { + const entries = Array.isArray(names) + ? names.map((name) => [name, name] as const) + : Object.entries(names); + const targets = new Set(); + for (const [, targetName] of entries) { + if (targets.has(targetName)) + throw new Error(`Context property already exists: ${targetName}`); + targets.add(targetName); + this.#assertAccessorAvailable(targetName); + } + for (const [sourceName, targetName] of entries) { + this.accessor(targetName, { + get(receiver) { + const target = typeof source === 'string' ? this.get(source) : source; + const value = Reflect.get(target as object, sourceName, receiver ?? target); + return typeof value === 'function' ? value.bind(target) : value; + }, + set(value, receiver) { + const target = typeof source === 'string' ? this.get(source) : source; + return Reflect.set(target as object, sourceName, value, receiver ?? target); + }, + }); + } + } + + on( + name: PropertyKey, + listener: (...args: unknown[]) => unknown, + options: boolean | EventOptions = {}, + ): Disposable { + this.#assertOpen(); + const normalized = typeof options === 'boolean' ? { prepend: options } : options; + const hook: Hook = { context: this, listener, global: normalized.global === true }; + const hooks = this.#kernel.listeners.get(name) ?? []; + let active = true; + const unregister = () => { + if (!active) return false; + active = false; + const index = hooks.indexOf(hook); + if (index >= 0) hooks.splice(index, 1); + if (!hooks.length) this.#kernel.listeners.delete(name); + return index >= 0; + }; + this.effect( + () => { + if (normalized.prepend) hooks.unshift(hook); + else hooks.push(hook); + this.#kernel.listeners.set(name, hooks); + return unregister; + }, + `ctx.on(${String(name)})`, + ); + return unregister; + } + + once( + name: PropertyKey, + listener: (...args: unknown[]) => unknown, + options: boolean | EventOptions = {}, + ): Disposable { + let unregister: Disposable; + unregister = this.on( + name, + (...args) => { + unregister(); + return listener(...args); + }, + options, + ); + return unregister; + } + + emit(...input: unknown[]): void { + const { hooks, args } = this.#dispatch(input); + for (const hook of hooks) hook.listener(...args); + } + + async parallel(...input: unknown[]): Promise { + const { hooks, args } = this.#dispatch(input); + const settled = await Promise.allSettled( + hooks.map((hook) => Promise.resolve().then(() => hook.listener(...args))), + ); + const errors = settled + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(({ reason }) => reason); + if (errors.length) throw new AggregateError(errors); + } + + async serial(...input: unknown[]): Promise { + const { hooks, args } = this.#dispatch(input); + for (const hook of hooks) { + const result = await hook.listener(...args); + if (result !== undefined && result !== null && result !== false) return result; + } + } + + bail(...input: unknown[]): unknown { + const { hooks, args } = this.#dispatch(input); + for (const hook of hooks) { + const result = hook.listener(...args); + if (result !== undefined && result !== null && result !== false) return result; + } + } + + waterfall(...input: unknown[]): unknown { + const { hooks, args } = this.#dispatch(input); + const terminal = args.pop(); + if (typeof terminal !== 'function') + throw new TypeError('Waterfall requires a terminal callback'); + const callbacks = hooks.map(({ listener }) => listener); + const next = (): unknown => { + const callback = callbacks.shift() ?? terminal; + return callback(...args, next); + }; + return next(); + } + + interceptConfig(name: string): readonly unknown[] { + return this.#intercepts[name] ?? []; + } + + kernelFibers(): readonly Fiber[] { + return Object.freeze([...this.#kernel.fibers]); + } + + #dispatch(input: readonly unknown[]): { + readonly hooks: readonly Hook[]; + readonly args: unknown[]; + } { + const args = [...input]; + const thisArg = Context.is(args[0]) ? (args.shift() as Context) : undefined; + const name = args.shift(); + if (typeof name !== 'string' && typeof name !== 'symbol') { + throw new TypeError('Event name must be a string or symbol'); + } + const filter = thisArg?.[Context.filter]; + const hooks = (this.#kernel.listeners.get(name) ?? []).filter( + (hook) => hook.global || !filter || filter(hook.context), + ); + return { hooks, args }; + } + + #implementation(name: string): ServiceImplementation | undefined { + const label = this.#lookupLabel(name); + return label ? this.#kernel.services.get(label) : undefined; + } + + #lookupLabel(name: string): symbol | undefined { + return this.#isolation[name] ?? this.#kernel.serviceLabels.get(name); + } + + #label(name: string): symbol { + const isolated = this.#isolation[name]; + if (isolated) return isolated; + let label = this.#kernel.serviceLabels.get(name); + if (!label) { + label = Symbol(name); + this.#kernel.serviceLabels.set(name, label); + } + return label; + } + + #notifyService(name: string, label: symbol): Fiber[] { + return notifyService(this.#kernel, name, label); + } + + #assertOpen(): void { + if ( + this.#kernel.closed || + disposedFibers.has(this.fiber) || + this.fiber.state === FiberState.DISPOSED || + this.fiber.state === FiberState.UNLOADING + ) { + throw new Error('Plugin Context is disposed'); + } + } + + #assertAccessorAvailable(name: string): void { + this.#assertOpen(); + if ( + Reflect.has(this, name) || + hasAncestorProperty(this.parent, name) || + this.#kernel.accessors.has(name) + ) { + throw new Error(`Context property already exists: ${name}`); + } + } + + _kernel(): KernelState { + return this.#kernel; + } + + _label(name: string): symbol { + return this.#label(name); + } + + _isolation(): Readonly> { + return this.#isolation; + } + + _intercepts(): Readonly> { + return this.#intercepts; + } +} + +const contextProxy: ProxyHandler = { + get(target, property, receiver) { + if (Reflect.has(target, property)) { + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' && Object.hasOwn(Context.prototype, property) + ? value.bind(target) + : value; + } + for (let ancestor = target.parent; ancestor; ancestor = ancestor.parent) { + if (Object.hasOwn(ancestor, property)) return Reflect.get(ancestor, property); + } + const accessor = target._kernel().accessors.get(property); + if (accessor) return accessor.get.call(receiver as Context, receiver); + if (typeof property === 'string') { + const service = target.get(property); + if (service !== undefined) return service; + } + }, + set(target, property, value, receiver) { + if (Reflect.has(target, property)) return Reflect.set(target, property, value, receiver); + const accessor = target._kernel().accessors.get(property); + if (accessor?.set) return accessor.set.call(receiver as Context, value, receiver); + if (typeof property === 'string' && target.get(property, false) !== undefined) { + return target.set(property, value); + } + return Reflect.set(target, property, value, receiver); + }, + has(target, property) { + return ( + Reflect.has(target, property) || + target._kernel().accessors.has(property) || + (typeof property === 'string' && target.get(property, false) !== undefined) || + hasAncestorProperty(target.parent, property) + ); + }, +}; + +function hasAncestorProperty(context: Context | undefined, property: PropertyKey): boolean { + for (let ancestor = context; ancestor; ancestor = ancestor.parent) { + if (Object.hasOwn(ancestor, property)) return true; + } + return false; +} + +export class Fiber { + readonly id: number; + readonly ctx: Context; + readonly parent: Context; + readonly plugin?: Plugin; + readonly inject: Readonly>; + state: FiberState; + config: unknown; + inertia?: Promise; + error?: unknown; + + readonly #runtime?: PluginRuntime; + readonly #children = new Set(); + readonly #effects: Array> & { [effectMeta]?: EffectMeta }> = []; + readonly #services = new Map(); + #disposed = false; + #dependencyRefreshQueued = false; + #disposeTask?: Promise; + #transition: Promise = Promise.resolve(); + + static root(context: Context): Fiber { + return new Fiber(context, undefined, undefined, {}, undefined, true); + } + + constructor( + parent: Context, + plugin: Plugin | undefined, + config: unknown, + inject: Readonly>, + runtime: PluginRuntime | undefined, + root = false, + ) { + this.parent = parent; + this.plugin = plugin; + this.config = config; + this.inject = inject; + this.#runtime = runtime; + const kernel = parent._kernel(); + this.id = root ? 0 : ++kernel.nextFiberId; + this.state = root ? FiberState.ACTIVE : FiberState.PENDING; + this.ctx = root ? parent : new Context(kernel, parent, this, undefined, undefined); + if (!root) { + kernel.fibers.add(this); + runtime?.fibers.add(this); + parent.fiber.#children.add(this); + this.refreshDependencies(); + } + } + + get name(): string { + return ( + this.#runtime?.name || this.plugin?.name || (this.id === 0 ? 'root' : `plugin-${this.id}`) + ); + } + + requires(name: string): boolean { + return Object.hasOwn(this.inject, name); + } + + serviceLabel(name: string): symbol { + return this.ctx._label(name); + } + + refreshDependencies(): void { + if (this.#disposed || !this.plugin) return; + if (this.#dependencyRefreshQueued) return; + this.#dependencyRefreshQueued = true; + this.#enqueue(async () => { + this.#dependencyRefreshQueued = false; + await this.#refreshDependencies(); + }); + } + + async #refreshDependencies(): Promise { + if (this.#disposed || !this.plugin) return; + const next = new Map(); + for (const name of Object.keys(this.inject)) { + let implementation: unknown; + try { + implementation = this.ctx.get(name); + } catch (error) { + const errors = [error]; + this.#services.clear(); + if (this.state === FiberState.ACTIVE || this.state === FiberState.FAILED) { + try { + await this.#unload(FiberState.PENDING); + } catch (cleanupError) { + errors.push(cleanupError); + } + } + this.error = + errors.length === 1 + ? error + : new AggregateError(errors, `Fiber ${this.name} dependency check and cleanup failed`); + this.#setState(FiberState.FAILED); + return; + } + if (implementation === undefined) { + this.#services.clear(); + if (this.state === FiberState.ACTIVE || this.state === FiberState.FAILED) { + await this.#unload(FiberState.PENDING); + } else { + this.#setState(FiberState.PENDING); + } + return; + } + next.set(name, implementation); + } + const changed = + next.size !== this.#services.size || + [...next].some(([name, value]) => this.#services.get(name) !== value); + this.#services.clear(); + for (const [name, value] of next) this.#services.set(name, value); + if (this.state === FiberState.PENDING || this.state === FiberState.FAILED) { + await this.#load(); + } else if (this.state === FiberState.ACTIVE && changed) { + await this.#unload(FiberState.PENDING); + await this.#load(); + } + } + + effect(execute: () => unknown, label = 'anonymous'): Disposable> { + if (this.#disposed || this.state === FiberState.UNLOADING) { + throw new Error('Cannot create an Effect on an inactive Fiber'); + } + const disposers: Disposable>[] = []; + let disposeTask: Promise | undefined; + const collect = (value: unknown): void => { + if (typeof value === 'function') disposers.push(value as Disposable>); + else if (value !== undefined && value !== null) { + throw new TypeError('Plugin Effect must return a disposer'); + } + }; + const run = async (): Promise => { + const result = execute(); + if (isAsyncIterable(result)) { + for await (const value of result) collect(value); + } else if (isIterable(result)) { + for (const value of result) collect(value); + } else { + collect(await result); + } + }; + const setupTask = run(); + const dispose = Object.assign( + () => { + disposeTask ??= (async () => { + await setupTask.catch(() => undefined); + const errors: unknown[] = []; + try { + for (const cleanup of disposers.reverse()) { + try { + await cleanup(); + } catch (error) { + errors.push(error); + } + } + } finally { + const index = this.#effects.indexOf(dispose); + if (index >= 0) this.#effects.splice(index, 1); + } + if (errors.length) throw new AggregateError(errors, `Effect ${label} cleanup failed`); + })(); + return disposeTask; + }, + { [effectMeta]: Object.freeze({ label, children: Object.freeze([]) }) }, + ); + this.#effects.push(dispose); + void setupTask.catch(async (error) => { + const errors = [error]; + try { + await dispose(); + } catch (cleanupError) { + errors.push(cleanupError); + } + this.error = + errors.length === 1 + ? error + : new AggregateError(errors, `Effect ${label} setup and cleanup failed`); + }); + return dispose; + } + + getEffects(): readonly EffectMeta[] { + return Object.freeze( + this.#effects.flatMap((dispose) => (dispose[effectMeta] ? [dispose[effectMeta]] : [])), + ); + } + + async await(): Promise { + while (this.inertia) await this.inertia.catch(() => undefined); + if (this.state === FiberState.FAILED) throw this.error; + } + + async restart(): Promise { + if (this.#disposed) throw new Error('Cannot restart a disposed Fiber'); + await this.#enqueue(() => this.#restart()); + } + + async update(config: unknown): Promise { + if (this.#disposed) throw new Error('Cannot update a disposed Fiber'); + await this.#enqueue(async () => { + const previous = this.config; + this.config = config; + try { + await this.#restart(); + } catch (error) { + this.config = previous; + try { + await this.#restart(); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `Fiber ${this.name} update and rollback failed`, + ); + } + throw error; + } + }); + } + + dispose(): Promise { + if (this.#disposeTask) return this.#disposeTask; + this.#disposed = true; + disposedFibers.add(this); + this.#disposeTask = this.#enqueue(async () => { + try { + await this.#unload(FiberState.DISPOSED); + } finally { + const kernel = this.parent._kernel(); + kernel.fibers.delete(this); + this.#runtime?.fibers.delete(this); + this.parent.fiber.#children.delete(this); + if (this.id === 0) kernel.closed = true; + } + }); + return this.#disposeTask; + } + + #enqueue(operation: () => Promise): Promise { + const task = this.#transition.then(operation, operation); + this.#transition = task.catch(() => undefined); + const settled = task.finally(() => { + if (this.inertia === settled) this.inertia = undefined; + }); + void settled.catch(() => undefined); + this.inertia = settled; + return settled; + } + + async #restart(): Promise { + await this.#unload(FiberState.PENDING); + if (this.#dependenciesAvailable()) await this.#load(); + } + + async #load(): Promise { + if (this.#disposed || !this.plugin || !this.#dependenciesAvailable()) return; + this.error = undefined; + this.#setState(FiberState.LOADING); + try { + const config = await validateConfig(this.#runtime?.Config, this.config); + const output = await invokePlugin(this.plugin, this.ctx, config); + if (typeof output === 'function') this.effect(() => output, `plugin:${this.name}`); + else if ( + output !== undefined && + output !== null && + !(typeof this.plugin === 'function' && isConstructor(this.plugin)) + ) { + throw new TypeError('Plugin must return a disposer or nothing'); + } + this.#setState(FiberState.ACTIVE); + } catch (error) { + const errors = [error]; + try { + await this.#disposeEffects(); + } catch (cleanupError) { + errors.push(cleanupError); + } + this.error = + errors.length === 1 + ? error + : new AggregateError(errors, `Fiber ${this.name} activation and cleanup failed`); + this.#setState(FiberState.FAILED); + throw this.error; + } + } + + async #unload(nextState: FiberState): Promise { + if (this.state === FiberState.DISPOSED) return; + this.#setState(FiberState.UNLOADING); + const childResults = await Promise.allSettled( + [...this.#children].reverse().map((child) => child.dispose()), + ); + const errors = childResults.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + try { + await this.#disposeEffects(); + } catch (error) { + errors.push(error); + } + this.#setState(nextState); + if (errors.length) throw new AggregateError(errors, `Fiber ${this.name} cleanup failed`); + } + + async #disposeEffects(): Promise { + const errors: unknown[] = []; + for (const dispose of [...this.#effects].reverse()) { + try { + await dispose(); + } catch (error) { + errors.push(error); + } + } + if (errors.length) throw new AggregateError(errors, `Fiber ${this.name} Effect cleanup failed`); + } + + #dependenciesAvailable(): boolean { + return Object.keys(this.inject).every((name) => this.ctx.get(name) !== undefined); + } + + #setState(state: FiberState): void { + const previous = this.state; + this.state = state; + if (state === FiberState.ACTIVE) { + notifyProvidedServices(this.parent._kernel(), this); + } + if (previous !== state) this.ctx.emit('internal/status', this, previous); + } +} + +function notifyService(kernel: KernelState, name: string, label: symbol): Fiber[] { + const fibers = serviceConsumers(kernel, name, label); + for (const fiber of fibers) fiber.refreshDependencies(); + return fibers; +} + +function notifyProvidedServices(kernel: KernelState, provider: Fiber): void { + const fibers = new Set(); + for (const implementation of kernel.services.values()) { + if (implementation.fiber !== provider) continue; + for (const fiber of serviceConsumers(kernel, implementation.name, implementation.label)) { + fibers.add(fiber); + } + } + for (const fiber of fibers) fiber.refreshDependencies(); +} + +function serviceConsumers(kernel: KernelState, name: string, label: symbol): Fiber[] { + const fibers: Fiber[] = []; + for (const fiber of kernel.fibers) { + if (!fiber.requires(name) || fiber.serviceLabel(name) !== label) continue; + fibers.push(fiber); + } + return fibers; +} + +export abstract class Service { + readonly name: string; + readonly #contexts = new WeakMap(); + + constructor( + protected readonly ctx: Context, + name: string, + ) { + this.name = name; + ctx.provide(name, this); + } + + _bind(context: Context): this { + const cached = this.#contexts.get(context); + if (cached) return cached; + const bound = new Proxy(this, { + get: (target, property, receiver) => { + if (property === 'ctx') return context; + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(receiver) : value; + }, + }); + this.#contexts.set(context, bound); + return bound; + } + + protected resolveConfig(base?: T, head?: T): T { + const values = [base, ...this.ctx.interceptConfig(this.name), head].filter( + (value): value is T => value !== undefined, + ); + return Object.assign({}, ...values); + } +} + +function normalizeInject(inject: Inject | undefined): Readonly> { + if (!inject) return freezeRecord(); + if (Array.isArray(inject)) { + return freezeRecord(Object.fromEntries(inject.map((name) => [name, null]))); + } + return freezeRecord(inject as Readonly>); +} + +function freezeRecord(source?: Readonly>): Readonly> { + return Object.freeze(Object.assign(Object.create(null) as Record, source)); +} + +function resolvePlugin(plugin: Plugin): Function { + if (typeof plugin === 'function') return plugin; + if (plugin && typeof plugin.apply === 'function') return plugin.apply; + throw new TypeError('Plugin must be a function, class, or object with apply()'); +} + +async function invokePlugin(plugin: Plugin, context: Context, config: unknown): Promise { + if (typeof plugin === 'function') { + if (isConstructor(plugin)) return Reflect.construct(plugin, [context, config]); + return (plugin as Plugin.Function)(context, config); + } + return plugin.apply(context, config); +} + +function isConstructor(value: Function): boolean { + return /^class\s/u.test(Function.prototype.toString.call(value)); +} + +async function validateConfig( + schema: StandardSchema | undefined, + value: unknown, +): Promise { + if (!schema) return value; + const result = await schema['~standard'].validate(value); + if ('issues' in result && result.issues) { + throw new TypeError(result.issues.map(({ message }) => message).join('; ')); + } + return result.value; +} + +function validateServiceName(name: string): void { + if (!/^[A-Za-z][A-Za-z0-9._:-]*$/u.test(name)) + throw new TypeError(`Invalid Service name: ${name}`); +} + +function isIterable(value: unknown): value is Iterable { + return Boolean(value && typeof (value as Iterable)[Symbol.iterator] === 'function'); +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return Boolean( + value && typeof (value as AsyncIterable)[Symbol.asyncIterator] === 'function', + ); +} + +function createLoggerService(name: () => string): LoggerService { + const create = (explicit?: string): Logger => { + const loggerName = explicit || name(); + return { + name: loggerName, + error: (value, ...values) => console.error(`[${loggerName}]`, value, ...values), + warn: (value, ...values) => console.warn(`[${loggerName}]`, value, ...values), + info: (value, ...values) => console.info(`[${loggerName}]`, value, ...values), + debug: (value, ...values) => console.debug(`[${loggerName}]`, value, ...values), + }; + }; + const callable = ((explicit?: string) => create(explicit)) as LoggerService; + Object.defineProperties(callable, { + name: { value: 'logger' }, + error: { value: (value: unknown, ...values: unknown[]) => create().error(value, ...values) }, + warn: { value: (value: unknown, ...values: unknown[]) => create().warn(value, ...values) }, + info: { value: (value: unknown, ...values: unknown[]) => create().info(value, ...values) }, + debug: { value: (value: unknown, ...values: unknown[]) => create().debug(value, ...values) }, + }); + return callable; +} diff --git a/packages/runtime/src/plugin-runtime.ts b/packages/runtime/src/plugin-runtime.ts new file mode 100644 index 0000000000..10cb9b40e1 --- /dev/null +++ b/packages/runtime/src/plugin-runtime.ts @@ -0,0 +1,378 @@ +import type { Context, FiberState, Plugin } from './plugin-kernel.js'; + +const ID_PATTERN = /^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$/u; + +export type MakaPluginRootId = 'profile' | 'desktop-ui' | `session:${string}`; + +export interface MakaPluginPackage { + readonly packageId: string; + readonly host?: Plugin; + readonly client?: Plugin; + readonly contributions?: readonly MakaPluginContribution[]; +} + +export interface MakaPluginContribution { + readonly id: string; + readonly kind: 'tool' | 'ui' | 'hook' | 'service' | 'timer' | string; +} + +export interface MakaCompositionEntry { + readonly id: string; + readonly packageId?: string; + readonly config?: unknown; + readonly disabled?: boolean; + readonly inject?: readonly string[] | Readonly>; + readonly isolate?: Readonly>; + readonly intercept?: Readonly>; + readonly children?: readonly MakaCompositionEntry[]; +} + +export interface MakaCompositionSnapshot { + readonly schemaVersion: 1; + readonly generation: number; + readonly roots: { + readonly profile: readonly MakaCompositionEntry[]; + readonly desktopUi: readonly MakaCompositionEntry[]; + readonly sessions: Readonly>; + }; +} + +export type MakaCompositionOperation = + | { + readonly type: 'insert'; + readonly rootId?: MakaPluginRootId; + readonly parentId?: string; + readonly entry: MakaCompositionEntry; + readonly position?: number; + } + | { + readonly type: 'update'; + readonly entryId: string; + readonly patch: Partial>; + } + | { + readonly type: 'move'; + readonly entryId: string; + readonly parentId?: string; + readonly position?: number; + } + | { readonly type: 'remove'; readonly entryId: string }; + +export interface MakaCompositionApplyInput { + readonly baseGeneration?: number; + readonly operations: readonly MakaCompositionOperation[]; +} + +export type MakaCompositionEntryStatus = + | 'disabled' + | 'pending' + | 'loading' + | 'active' + | 'failed' + | 'unloading' + | 'disposed'; + +export interface MakaCompositionEntryInspection { + readonly id: string; + readonly rootId: MakaPluginRootId; + readonly parentId?: string; + readonly packageId?: string; + readonly config?: unknown; + readonly disabled: boolean; + readonly status: MakaCompositionEntryStatus; + readonly generation?: number; + readonly waitingFor: readonly string[]; + readonly effects: readonly string[]; + readonly children: readonly MakaCompositionEntryInspection[]; + readonly diagnostic?: string; +} + +export interface MakaPluginMountInput { + readonly entryId: string; + readonly rootId: string; + readonly packageId: string; + readonly config?: unknown; +} + +export interface MakaPluginMountInspection { + readonly entryId: string; + readonly rootId: string; + readonly packageId: string; + readonly enabled: boolean; + readonly status: MakaCompositionEntryStatus; + readonly current?: { readonly generation: number }; + readonly waitingFor: readonly string[]; + readonly pendingCleanupEffects: number; + readonly diagnostic?: { readonly message: string }; +} + +export interface MakaRuntimeCompositionEntry { + readonly entryId: string; + readonly packageId: string; + readonly generation: number; + readonly contributions: readonly MakaPluginContribution[]; +} + +export interface MakaRuntimeCompositionSnapshot { + readonly schemaVersion: 1; + readonly rootId: string; + readonly digest: `sha256:${string}`; + readonly entries: readonly MakaRuntimeCompositionEntry[]; +} + +export interface MakaPluginMetadata { + readonly rootId: MakaPluginRootId; + readonly entryId: string; + readonly packageId: string; + readonly generation: number; +} + +export interface MakaContributionIdentity { + readonly entryId: string; + readonly scopeId: string; + readonly extensionId: string; + readonly generation: number; +} + +export interface MakaContributionContext extends MakaContributionIdentity { + readonly signal: AbortSignal; + readonly runtimeContext: Context; + ownEffect(label: string, dispose: () => void | Promise): void; + dependency(packageId: string): T; +} + +export interface MakaPluginTransaction { + stage(label: string, register: () => () => void | Promise, owner?: Context): void; + commit(): void | Promise; + rollback(): void | Promise; +} + +declare module './plugin-kernel.js' { + interface Context { + maka?: MakaPluginMetadata; + makaTransaction?: MakaPluginTransaction; + } +} + +export class MakaPluginRuntimeError extends Error { + readonly name = 'MakaPluginRuntimeError'; + + constructor( + readonly code: + | 'invalid_package' + | 'package_exists' + | 'package_not_found' + | 'package_in_use' + | 'invalid_entry' + | 'entry_exists' + | 'entry_not_found' + | 'dependency_cycle' + | 'activation_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +export function validatePluginPackage(pkg: MakaPluginPackage): void { + validatePluginId(pkg.packageId, 'packageId'); + if (!pkg.host && !pkg.client) { + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package ${pkg.packageId} has no host or client plugin`, + ); + } +} + +export function validateCompositionEntry(entry: MakaCompositionEntry): void { + validatePluginId(entry.id, 'entry id'); + if (entry.packageId !== undefined) { + validatePluginId(entry.packageId!, 'packageId'); + } + for (const key of Object.keys(entry.isolate ?? {})) validateServiceName(key); + for (const key of Object.keys(entry.intercept ?? {})) validateServiceName(key); + for (const dependency of Array.isArray(entry.inject) + ? entry.inject + : Object.keys(entry.inject ?? {})) { + validateServiceName(dependency); + } + const childIds = new Set(); + for (const child of entry.children ?? []) { + validateCompositionEntry(child); + if (childIds.has(child.id)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Entry ${entry.id} repeats child ${child.id}`, + ); + } + childIds.add(child.id); + } +} + +export function validatePluginRootId(rootId: string): asserts rootId is MakaPluginRootId { + if ( + rootId !== 'profile' && + rootId !== 'desktop-ui' && + !(rootId.startsWith('session:') && rootId.length > 'session:'.length) + ) { + throw new MakaPluginRuntimeError('invalid_entry', `Invalid composition root: ${rootId}`); + } +} + +export function pluginIdentity(ctx: Context): MakaContributionIdentity { + const metadata = ctx.maka; + if (!metadata) { + throw new MakaPluginRuntimeError( + 'activation_failed', + 'Contribution registration requires a composition entry Context', + ); + } + return Object.freeze({ + entryId: metadata.entryId, + scopeId: metadata.rootId, + extensionId: metadata.packageId, + generation: metadata.generation, + }); +} + +export function ownPluginEffect( + ctx: Context, + label: string, + dispose: () => void | Promise, +): void { + attachPluginEffect(ctx, label, dispose); +} + +function attachPluginEffect( + ctx: Context, + label: string, + dispose: () => void | Promise, +): () => Promise { + return ctx.effect(() => dispose, label); +} + +function registerPluginEffect( + ctx: Context, + label: string, + register: () => () => void | Promise, +): () => Promise { + let contributionDispose: (() => void | Promise) | undefined; + const release = ctx.effect(() => () => contributionDispose?.(), label); + try { + contributionDispose = register(); + return release; + } catch (error) { + void release().catch(() => undefined); + throw error; + } +} + +export function registerPluginContribution( + ctx: Context, + label: string, + register: () => () => void | Promise, +): void { + if (ctx.makaTransaction) { + ctx.makaTransaction.stage(label, register, ctx); + return; + } + registerPluginEffect(ctx, label, register); +} + +export class MakaPluginTransactionBuffer implements MakaPluginTransaction { + readonly #registrations: Array<{ + readonly label: string; + readonly register: () => () => void | Promise; + readonly owner: Context; + }> = []; + #state: 'staging' | 'committed' | 'rolled_back' = 'staging'; + + constructor(private readonly context: Context) {} + + stage(label: string, register: () => () => void | Promise, owner = this.context): void { + if (this.#state === 'committed') { + registerPluginEffect(owner, label, register); + return; + } + if (this.#state === 'rolled_back') { + throw new MakaPluginRuntimeError( + 'activation_failed', + `Cannot stage contribution after transaction is ${this.#state}`, + ); + } + this.#registrations.push({ label, register, owner }); + } + + async commit(): Promise { + if (this.#state === 'committed') return; + if (this.#state === 'rolled_back') { + throw new MakaPluginRuntimeError( + 'activation_failed', + 'Cannot commit a rolled back transaction', + ); + } + const registered: Array<() => Promise> = []; + try { + for (const item of this.#registrations) { + registered.push(registerPluginEffect(item.owner, item.label, item.register)); + } + this.#state = 'committed'; + this.#registrations.length = 0; + } catch (error) { + this.#state = 'rolled_back'; + this.#registrations.length = 0; + const cleanupErrors: unknown[] = []; + for (const dispose of registered.reverse()) { + try { + await dispose(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + } + if (cleanupErrors.length) { + throw new AggregateError( + [error, ...cleanupErrors], + 'Plugin transaction commit and rollback failed', + ); + } + throw error; + } + } + + rollback(): void { + if (this.#state !== 'staging') return; + this.#state = 'rolled_back'; + this.#registrations.length = 0; + } +} + +export function fiberStateName(state: FiberState): MakaCompositionEntryStatus { + return ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'][ + state + ] as MakaCompositionEntryStatus; +} + +export function isCanonicalPluginId(value: unknown): value is string { + return typeof value === 'string' && value.length <= 128 && ID_PATTERN.test(value); +} + +export const isCanonicalExtensionId = isCanonicalPluginId; + +export function isCanonicalExtensionScopeId(value: unknown): value is string { + return ( + typeof value === 'string' && value.length <= 128 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value) + ); +} + +function validatePluginId(value: unknown, label: string): asserts value is string { + if (!isCanonicalPluginId(value)) { + throw new MakaPluginRuntimeError('invalid_entry', `Invalid ${label}`); + } +} + +function validateServiceName(value: string): void { + if (!/^[A-Za-z][A-Za-z0-9._:-]{0,255}$/u.test(value)) { + throw new MakaPluginRuntimeError('invalid_entry', `Invalid service name: ${value}`); + } +} From 8801edc32bd83740a2062b63e644ca5ca255e282 Mon Sep 17 00:00:00 2001 From: Bo Liu Date: Mon, 24 Aug 2026 19:47:07 +0900 Subject: [PATCH 003/386] fix(desktop): keep the slash picker stable across same-context refreshes (#2768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An open `/` menu alternated between its commands-only and commands-plus-skills geometries on every session or MCP event (#2667): each refresh cleared the invocable-Skill catalog fail-closed, so the popup lost and regained its Skills group for the length of one IPC round trip. Fail closed only when the context key actually changes. A same-context refresh keeps the Skills already on screen, and a settled refresh that returned an identical list keeps the previous array identity, so the composer's trigger memo and menu-replay effect stay quiet. A Skill withdrawn inside that stale window still fails safely, because selection resolves through the Runtime resolver that no longer knows it. The + menu's separate fail-closed path is unchanged: a Plan toggle moves the context key, so it still clears and still reads `settled`. --- apps/desktop/e2e/slash-command-menu.spec.ts | 75 +++++++++++++++++++ .../src/renderer/use-composer-mentions.ts | 57 +++++++++++--- 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/apps/desktop/e2e/slash-command-menu.spec.ts b/apps/desktop/e2e/slash-command-menu.spec.ts index 49c705992c..e66adc28b5 100644 --- a/apps/desktop/e2e/slash-command-menu.spec.ts +++ b/apps/desktop/e2e/slash-command-menu.spec.ts @@ -162,3 +162,78 @@ test('dispatches /side instead of steering it into a running turn', async ({ await expect(page.locator('.maka-quote-workbar-panel')).toHaveCount(1); await page.getByRole('button', { name: '停止' }).click(); }); + +test('an open menu keeps its container and skills group across projection refreshes', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('seed session'); + await composer.press('Enter'); + await expect(page.getByText('Fake backend received: seed session')).toBeVisible(); + + await composer.click(); + await composer.pressSequentially('/'); + const menu = page.getByRole('listbox', { name: '命令和技能' }); + await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible(); + + // Armed before the refresh: the flicker was the skills group (and with it + // the listbox geometry) being torn down and re-created when the projection + // cleared and repopulated, so any removal during the refresh is the + // regression (#2667). + await page.evaluate(() => { + const state = { removals: 0 }; + (globalThis as unknown as { __slashMenuWatch?: unknown }).__slashMenuWatch = state; + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + if (!(node instanceof HTMLElement)) continue; + if ( + node.matches('[role="listbox"], [role="group"]') || + node.querySelector('[role="listbox"], [role="group"]') !== null + ) { + state.removals += 1; + } + } + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); + + // A thinking-level change publishes the session's 'updated' event and + // reloads the Skill projection without changing what the menu shows: the + // exact same-content refresh that used to alternate the popup (#2667). + const sessionId = await page.evaluate(async () => { + const sessions = await ( + window as unknown as { + maka: { sessions: { list(): Promise> } }; + } + ).maka.sessions.list(); + return sessions[0]?.id; + }); + for (let round = 0; round < 3; round += 1) { + await page.evaluate( + (id) => + ( + window as unknown as { + maka: { sessions: { setThinkingLevel(id: string, level?: null): Promise } }; + } + ).maka.sessions.setThinkingLevel(id!, null), + sessionId, + ); + } + // The refresh round trip is IPC-fast; the poll below gives it room while + // asserting the menu never lost its skills group. + await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible(); + await expect + .poll( + () => + page.evaluate( + () => + (globalThis as unknown as { __slashMenuWatch: { removals: number } }).__slashMenuWatch + .removals, + ), + { timeout: 3_000 }, + ) + .toBe(0); + await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible(); +}); diff --git a/apps/desktop/src/renderer/use-composer-mentions.ts b/apps/desktop/src/renderer/use-composer-mentions.ts index a4061b491d..2d31f9505c 100644 --- a/apps/desktop/src/renderer/use-composer-mentions.ts +++ b/apps/desktop/src/renderer/use-composer-mentions.ts @@ -26,6 +26,27 @@ import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; /** One frozen identity, so a context-mismatch render does not churn props. */ const EMPTY_SKILLS: InvocableSkillEntry[] = []; +/** + * Whether a reloaded projection describes the same Skills as the one on + * screen, so an unchanged refresh can keep the array it already published. + */ +function invocableSkillListsEqual( + current: readonly InvocableSkillEntry[], + next: readonly InvocableSkillEntry[], +): boolean { + if (current.length !== next.length) return false; + return current.every((skill, index) => { + const other = next[index]; + return ( + other !== undefined && + skill.ref === other.ref && + skill.id === other.id && + skill.name === other.name && + skill.description === other.description + ); + }); +} + /** * Owns the composer mention popup wiring so app-shell.tsx keeps no inline * `window.maka` state (app-shell-composer-attachment-owner-contract). Derives @@ -105,14 +126,21 @@ export function useComposerMentions(options: { let requestVersion = 0; const refresh = () => { const version = ++requestVersion; - setCatalog((previous) => ({ - contextKey, - loading: true, - // A same-context refresh keeps its settled verdict; a context switch - // has nothing settled to hold. - settled: previous.contextKey === contextKey ? previous.settled : undefined, - skills: [], - })); + setCatalog((previous) => + previous.contextKey === contextKey + ? // A same-context refresh keeps both its settled verdict and the + // Skills already on screen. Clearing here is what made an open `/` + // menu alternate between its commands-only and commands-plus-skills + // geometries on every session or MCP event (#2667). The backend + // surface has not changed, so there is nothing to fail closed + // against; and a Skill withdrawn inside the one-IPC-round-trip + // stale window still fails safely, because selection resolves + // through the Runtime resolver that no longer knows it. + { ...previous, loading: true } + : // A context switch has nothing settled to hold, and its Skills + // belong to the surface being left behind. + { contextKey, loading: true, settled: undefined, skills: [] }, + ); const context = { ...(newSessionModel ?? {}), collaborationMode: newSessionCollaborationMode ?? 'agent', @@ -128,12 +156,19 @@ export function useComposerMentions(options: { void request.then( (next) => { if (cancelled || version !== requestVersion) return; - setCatalog({ + setCatalog((previous) => ({ contextKey, loading: false, settled: next.length === 0 ? 'empty' : 'populated', - skills: next, - }); + // A refresh that changed nothing keeps the previous array + // identity, so the composer's trigger memo and the menu-replay + // effect stay quiet instead of remounting the popup. + skills: + previous.contextKey === contextKey && + invocableSkillListsEqual(previous.skills, next) + ? previous.skills + : [...next], + })); }, () => { // Fail soft: an unavailable projection leaves `/` with no suggestions. From 389802684d24932ac8c9f37c9251a2ee73c2f4bb Mon Sep 17 00:00:00 2001 From: AstroHan Date: Mon, 24 Aug 2026 19:47:52 +0800 Subject: [PATCH 004/386] fix(runtime): add the missing ASF headers to the plugin modules (#3708) `Check ASF source headers` fails on main. Five files added by #3250 carry no license header, so `main` and every branch built on it report a red CI run for a reason unrelated to their own changes. Generated by `node scripts/asf-license-headers.mjs write`; the header text is the standard one the script emits, and nothing else in these files changed. --- .../plugin-composition-loader.test.ts | 19 +++++++++++++++++++ .../src/__tests__/plugin-kernel.test.ts | 19 +++++++++++++++++++ .../runtime/src/plugin-composition-loader.ts | 19 +++++++++++++++++++ packages/runtime/src/plugin-kernel.ts | 19 +++++++++++++++++++ packages/runtime/src/plugin-runtime.ts | 19 +++++++++++++++++++ 5 files changed, 95 insertions(+) diff --git a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts index 6284f43819..53e44f61ab 100644 --- a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts +++ b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import assert from 'node:assert/strict'; import { test } from 'node:test'; import { Context, type Plugin } from '../plugin-kernel.js'; diff --git a/packages/runtime/src/__tests__/plugin-kernel.test.ts b/packages/runtime/src/__tests__/plugin-kernel.test.ts index 3af7731b5b..cc631e8714 100644 --- a/packages/runtime/src/__tests__/plugin-kernel.test.ts +++ b/packages/runtime/src/__tests__/plugin-kernel.test.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import assert from 'node:assert/strict'; import test from 'node:test'; import { Context, FiberState, Service, type Plugin } from '../plugin-kernel.js'; diff --git a/packages/runtime/src/plugin-composition-loader.ts b/packages/runtime/src/plugin-composition-loader.ts index 21174768c8..abb887437c 100644 --- a/packages/runtime/src/plugin-composition-loader.ts +++ b/packages/runtime/src/plugin-composition-loader.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import { Context, type Fiber, type Inject, type Plugin } from './plugin-kernel.js'; import { fiberStateName, diff --git a/packages/runtime/src/plugin-kernel.ts b/packages/runtime/src/plugin-kernel.ts index 710cc9e67b..423968b729 100644 --- a/packages/runtime/src/plugin-kernel.ts +++ b/packages/runtime/src/plugin-kernel.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + export type Awaitable = T | PromiseLike; export type Disposable> = () => T; diff --git a/packages/runtime/src/plugin-runtime.ts b/packages/runtime/src/plugin-runtime.ts index 10cb9b40e1..06fbfe0408 100644 --- a/packages/runtime/src/plugin-runtime.ts +++ b/packages/runtime/src/plugin-runtime.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import type { Context, FiberState, Plugin } from './plugin-kernel.js'; const ID_PATTERN = /^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$/u; From 1053c1926a4f7374667dec6d0e86a33b33567947 Mon Sep 17 00:00:00 2001 From: Berlin <1580940252@qq.com> Date: Mon, 24 Aug 2026 19:56:41 +0800 Subject: [PATCH 005/386] refactor(storage): drop the barrel and publish narrow entrypoints (#3301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(storage): drop the barrel and publish narrow entrypoints `maka --help` printed Node's SQLite ExperimentalWarning to stderr, on a command that never opens a database. The cause was structural, not local: `@maka/storage` published one `export *` barrel, and `cli-core.ts` imported it to reach `resolveMakaDataRoots`. Three modules in that barrel's graph take a static value import of `node:sqlite`, and Node evaluates a builtin the moment it enters a module graph, so every consumer of the barrel loaded SQLite whether or not it wanted a database. `operational-target-schema.ts` was the amplifier: `operational-state-store.ts` imports it, and roughly forty modules import that, which is how three import statements reached 45 of the package's 110 modules and 24 of the barrel's 43 export entries. Remove the barrel instead of working around it. `.` is gone from the exports map, `src/index.ts` is deleted, and the 20 modules that consumers actually reached through it are published as narrow subpaths. This is already the prevailing convention here — `root-authority` has 141 call sites and `execution-stores` 108, against 31 non-test sites on the bare specifier. The three static `node:sqlite` imports stay exactly as they were. They are honest: those modules do need SQLite. What changes is that needing SQLite is now visible in the import path, so `@maka/storage/workspace-root` costs nothing and `@maka/storage/session-store` costs what it should. No lazy-load indirection and no warning suppression are involved. `public-entrypoints.test.ts` pins the boundary: no `.` export, and exactly 28 of the 54 published entrypoints load `node:sqlite`. Widening that set now requires editing the list and saying why. Two tests moved off the barrel's shape rather than its contents: `managed-workspace-baseline` asserted internals were absent from the barrel object and now asserts their modules are absent from the exports map; `provider-request-capture-artifact` reaches its subject directly. Generated-by: Claude Code * fix(storage): address review — repair path imports, harden entrypoint guards - Point the release smoke script's deep import at dist/workspace-root.js, which owns resolveMakaDataRoots now that dist/index.js is not emitted, and guard every such by-path import with a release file-policy test. - Import openStorageWriterComposition through its published subpath; the bare specifier resolved to the removed barrel entrypoint after #3295. public-entrypoints.test.ts now rejects bare @maka/storage imports anywhere in the tree, so the next stale-merge of this kind fails a test instead of a build. - Add ./storage-writer-composition to SQLITE_BACKED_ENTRYPOINTS: it statically imports execution-stores and thirteen other SQLite-backed modules. - Detect the SQLite boundary with a module.registerHooks resolve hook instead of matching Node's ExperimentalWarning text, which Node 26 has already reworded. - Drop the dangling main/types manifest fields and assert that every published entrypoint target is emitted by the build. - Assert internals stay private by loading every published entrypoint and checking the union of reachable symbols, not the export map's targets, so a future re-export cannot leak them silently. - Run Biome over the two files format:check rejected. Generated-by: Claude Code Co-Authored-By: Claude Fable 5 * refactor(storage): narrow published surfaces to the barrel's picks - Repoint the real-model computer-use script at dist/agent-run-store.js; the release file-policy test now walks every script for both the node_modules and the relative packages/*/dist import forms and asserts each target is still emitted, which catches this whole class. - Publish operational-state-store and credential-store through facades that re-export exactly the names the deleted barrel picked. The schema-migration internals and the credential file lock return to package-private. artifact-store needs no facade: nothing outside the package imports it on current main, so it is not published at all and the lease-gated write authority stays private with the rest of the module. The reachable-symbol union test names all five withheld symbols. - Drop the five session-bundle entrypoints with no consumer outside the package; public-entrypoints.test.ts now asserts the exact set of consumer-less entrypoints, allowlisting only those that predate this change, and that every imported subpath is published. - Extend the bare-specifier guard to the side-effect import form and cap the SQLite probe children at four concurrent. - Delete the storybook path mapping to the removed src/index.ts. Generated-by: Claude Code Co-Authored-By: Claude Fable 5 * fix(storage): keep the credential-store entrypoint's existing surface `./credential-store` was a published subpath before this branch and mapped straight to `credential-store.js`, so `withCredentialFileLock` was reachable through it. Routing it through a facade turned that symbol into `undefined` — a contract change to a pre-existing entrypoint, and one that has nothing to do with removing the barrel. The facade was applying the barrel's picks to an entrypoint the barrel never owned. That rule is right for the subpaths this branch publishes for the first time, where the surface is still being chosen; it is not a reason to narrow one that already shipped. Whether the file lock should be package-private is a separate compatibility decision, and stays open. The export map now only drops `.` and adds subpaths — no pre-existing entrypoint changes target, which one command shows: git diff upstream/main...HEAD -- packages/storage/package.json `withCredentialFileLock` also leaves the reachable-symbol denylist in `managed-workspace-baseline`, because it is publicly reachable again, exactly as it was before this branch. Generated-by: Claude Code Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Fable 5 --- apps/desktop/e2e/fixtures.ts | 8 +- .../__tests__/bot-onboarding-main.test.ts | 2 +- .../__tests__/config-transfer-service.test.ts | 2 +- .../src/main/__tests__/mcp-ipc-main.test.ts | 3 +- .../main/__tests__/pet-pack-import.test.ts | 2 +- .../project-management-service.test.ts | 2 +- apps/desktop/src/main/app-icon-ipc.ts | 2 +- apps/desktop/src/main/bot-onboarding-main.ts | 2 +- .../src/main/client-settings-effects.ts | 2 +- .../src/main/client-settings-ipc-main.ts | 2 +- .../src/main/config-transfer-service.ts | 4 +- apps/desktop/src/main/e2e-fixture.ts | 2 +- .../src/main/e2e-fixture/seed-helpers.ts | 6 +- apps/desktop/src/main/mcp-ipc-main.ts | 6 +- apps/desktop/src/main/new-session-project.ts | 2 +- .../github-copilot-subscription-service.ts | 2 +- apps/desktop/src/main/pet-pack-import.ts | 2 +- .../src/main/quote-companion-cleanup.ts | 2 +- apps/desktop/src/main/runtime-host-boot.ts | 8 +- .../src/main/runtime-host-config-ipc-main.ts | 2 +- .../main/runtime-host-settings-ipc-main.ts | 2 +- .../src/main/settings-bots-ipc-main.ts | 2 +- apps/desktop/src/preload/bridge-contract.d.ts | 2 +- apps/desktop/src/preload/preload.ts | 2 +- .../renderer/locales/settings-data-copy.ts | 2 +- .../renderer/settings/data-settings-page.tsx | 2 +- apps/desktop/tsconfig.storybook.json | 1 - packages/cli/src/activation-command.ts | 2 +- ...untime-host-capability-provider-command.ts | 3 +- packages/cli/src/runtime-host-cli-context.ts | 2 +- .../cli/src/runtime-host-profile-command.ts | 2 +- .../cli/src/runtime-host-systemd-service.ts | 2 +- packages/cli/src/runtime-host-tui-command.ts | 2 +- packages/cli/src/workspace-root.ts | 2 +- .../transcript-data-plane-benchmark.mjs | 2 +- .../canonical-session-projection.test.ts | 2 +- .../client-capability-recovery.test.ts | 2 +- .../daily-review-coordinator.test.ts | 2 +- .../execution-model-composition.test.ts | 2 +- .../src/__tests__/fixtures/execution-host.ts | 2 +- .../src/__tests__/host-profile.test.ts | 2 +- .../project-catalog-coordinator.test.ts | 3 +- .../project-catalog-two-client-uds.test.ts | 2 +- .../__tests__/root-admission-owner.test.ts | 4 +- .../runtime-resource-coordinator.test.ts | 2 +- .../session-catalog-coordinator.test.ts | 2 +- .../session-retirement-coordinator.test.ts | 2 +- .../__tests__/usage-pricing-protocol.test.ts | 2 +- .../runtime-host/src/client/host-profile.ts | 2 +- .../src/server/execution-composition.ts | 2 +- .../server/external-session-coordinator.ts | 2 +- .../src/server/project-catalog-coordinator.ts | 2 +- .../src/server/workspace-resolver.ts | 2 +- .../agent-graph-supervisor-wake.test.ts | 2 +- .../__tests__/agent-graph-timeline.test.ts | 2 +- .../agent-run-steering-recovery.test.ts | 8 +- .../src/__tests__/context-diagnostics.test.ts | 2 +- .../src/__tests__/conversation-copy.test.ts | 8 +- .../src/__tests__/deep-research-tools.test.ts | 2 +- .../src/__tests__/execution-inspect.test.ts | 5 +- .../__tests__/latest-context-commit.test.ts | 8 +- .../recovery-authority-equivalence.test.ts | 2 +- .../runtime-continuation-crash.test.ts | 5 +- .../__tests__/runtime-ledger-repair.test.ts | 8 +- .../__tests__/runtime-resume-crash.test.ts | 3 +- .../sandbox-boundary-restart-recovery.test.ts | 8 +- .../src/__tests__/shell-run-manager.test.ts | 2 +- .../__tests__/shell-run-tool-result.test.ts | 2 +- .../stream-graph-coordinator.test.ts | 12 +- .../stream-graph-schedule-reconcile.test.ts | 2 +- .../stream-graph-supervisor-tools.test.ts | 2 +- .../subscription-credentials.test.ts | 2 +- .../tool-runtime-sqlite-boundary.test.ts | 2 +- packages/storage/package.json | 52 ++-- .../session-bundle-hydration-binding-crash.ts | 2 +- ...on-bundle-hydration-owner-write-failure.ts | 3 +- .../fixtures/session-bundle-inspect-child.ts | 3 +- .../session-bundle-inspect-source-mutator.ts | 3 +- ...ession-bundle-pack-destination-replacer.ts | 3 +- .../session-bundle-pack-link-replacer.ts | 3 +- ...session-bundle-pack-linked-temp-remover.ts | 3 +- .../managed-workspace-baseline.test.ts | 39 ++- .../provider-request-capture-artifact.test.ts | 7 +- .../src/__tests__/public-entrypoints.test.ts | 269 ++++++++++++++++++ .../session-bundle-canonical-tree.test.ts | 5 +- .../__tests__/session-bundle-contract.test.ts | 2 +- .../session-bundle-file-service.test.ts | 8 +- .../__tests__/session-bundle-manifest.test.ts | 8 +- .../__tests__/session-bundle-ustar.test.ts | 4 +- packages/storage/src/index.ts | 180 ------------ .../src/operational-state-store-public.ts | 36 +++ scripts/computer-use/real-model.mjs | 2 +- scripts/release-cli-file-policy.test.mjs | 62 +++- scripts/smoke-release-cli-package.mjs | 5 +- 94 files changed, 573 insertions(+), 345 deletions(-) create mode 100644 packages/storage/src/__tests__/public-entrypoints.test.ts delete mode 100644 packages/storage/src/index.ts create mode 100644 packages/storage/src/operational-state-store-public.ts diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index f4c9f8ef5a..1684647b1b 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -24,11 +24,9 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; -import { - createProjectCatalog, - createSessionStore, - createSettingsStore, -} from '@maka/storage'; +import { createProjectCatalog } from '@maka/storage/project-catalog'; +import { createSessionStore } from '@maka/storage/session-store'; +import { createSettingsStore } from '@maka/storage/settings-store'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner, diff --git a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts index ef77a151e3..fbd24ef895 100644 --- a/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts +++ b/apps/desktop/src/main/__tests__/bot-onboarding-main.test.ts @@ -26,7 +26,7 @@ import { type UpdateAppSettingsInput, } from '@maka/core/settings'; import type { BotRegistry } from '@maka/runtime/bots'; -import type { SettingsStore } from '@maka/storage'; +import type { SettingsStore } from '@maka/storage/settings-store'; import { BotOnboardingService, wecomTerminalPollStatus, diff --git a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts index 3952a3ddf8..a904296104 100644 --- a/apps/desktop/src/main/__tests__/config-transfer-service.test.ts +++ b/apps/desktop/src/main/__tests__/config-transfer-service.test.ts @@ -21,7 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import type { AppSettings } from '@maka/core/settings'; import type { LlmConnection } from '@maka/core/llm-connections'; -import type { CredentialKind } from '@maka/storage'; +import type { CredentialKind } from '@maka/storage/credential-store'; import { applyConfigImport, type ConfigTransferDeps } from '../config-transfer-service.js'; function conn(slug: string): LlmConnection { diff --git a/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts b/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts index 1d0ffce245..af1170c964 100644 --- a/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/mcp-ipc-main.test.ts @@ -20,11 +20,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { MCP_CONFIG_VERSION, type McpConfigFile, type McpServerStatus } from '@maka/core/mcp'; -import { McpServerExistsError } from '@maka/storage'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createMcpConfigStore } from '@maka/storage'; +import { createMcpConfigStore, McpServerExistsError } from '@maka/storage/mcp-config-store'; import { createMcpExclusiveLane, registerMcpIpcMain } from '../mcp-ipc-main.js'; test('MCP IPC commits config before publishing capabilities and emitting status', async () => { diff --git a/apps/desktop/src/main/__tests__/pet-pack-import.test.ts b/apps/desktop/src/main/__tests__/pet-pack-import.test.ts index 328355bf71..0306cf8f5b 100644 --- a/apps/desktop/src/main/__tests__/pet-pack-import.test.ts +++ b/apps/desktop/src/main/__tests__/pet-pack-import.test.ts @@ -23,7 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { PET_PACK_SCHEMA_V1 } from '@maka/core/pet'; -import { createSettingsStore } from '@maka/storage'; +import { createSettingsStore } from '@maka/storage/settings-store'; import { createPetPackStore } from '@maka/storage/pet-pack-store'; import { importPetPackFromDirectory, diff --git a/apps/desktop/src/main/__tests__/project-management-service.test.ts b/apps/desktop/src/main/__tests__/project-management-service.test.ts index fc98e39750..fef7a9cb70 100644 --- a/apps/desktop/src/main/__tests__/project-management-service.test.ts +++ b/apps/desktop/src/main/__tests__/project-management-service.test.ts @@ -26,7 +26,7 @@ import { test } from 'node:test'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); -import { createProjectCatalog, type ProjectCatalog } from '@maka/storage'; +import { createProjectCatalog, type ProjectCatalog } from '@maka/storage/project-catalog'; import { createProjectManagementService, type ProjectManagementCatalog, diff --git a/apps/desktop/src/main/app-icon-ipc.ts b/apps/desktop/src/main/app-icon-ipc.ts index a06d756cca..08a105f3f5 100644 --- a/apps/desktop/src/main/app-icon-ipc.ts +++ b/apps/desktop/src/main/app-icon-ipc.ts @@ -30,7 +30,7 @@ import { type AppIconChoice, type AppSettings, } from "@maka/core/settings"; -import type { SettingsStore } from "@maka/storage"; +import type { SettingsStore } from "@maka/storage/settings-store"; import type { AppIconPreview } from "./app-icon-surface.js"; import { CustomAppIconError, diff --git a/apps/desktop/src/main/bot-onboarding-main.ts b/apps/desktop/src/main/bot-onboarding-main.ts index 663f72f91a..6ed3ff7535 100644 --- a/apps/desktop/src/main/bot-onboarding-main.ts +++ b/apps/desktop/src/main/bot-onboarding-main.ts @@ -32,7 +32,7 @@ import { generalizedErrorMessageChinese, redactSecrets } from '@maka/core/redact import { isBotOnboardingBrand, isBotOnboardingProvider } from '@maka/core/bot-onboarding'; import type { BotRegistry } from '@maka/runtime/bots'; import { proxiedFetch } from '@maka/runtime/bots'; -import type { SettingsStore } from '@maka/storage'; +import type { SettingsStore } from '@maka/storage/settings-store'; import { createQQBindTask, pollQQBindTask } from './qq-bot-scan-login.js'; import { fetchWeChatQrcode, pollWeChatQrcodeStatus } from './wechat-scan-login.js'; diff --git a/apps/desktop/src/main/client-settings-effects.ts b/apps/desktop/src/main/client-settings-effects.ts index 138f3a2c4e..6a56ed0d1e 100644 --- a/apps/desktop/src/main/client-settings-effects.ts +++ b/apps/desktop/src/main/client-settings-effects.ts @@ -17,7 +17,7 @@ * under the License. */ import { toAppIconChoice, type AppIconChoice, type AppSettings } from '@maka/core/settings'; -import type { SettingsStore } from '@maka/storage'; +import type { SettingsStore } from '@maka/storage/settings-store'; export interface ClientSettingsEffects { apply(settings: AppSettings, notifyRenderer: boolean): Promise; diff --git a/apps/desktop/src/main/client-settings-ipc-main.ts b/apps/desktop/src/main/client-settings-ipc-main.ts index 84150086cd..a5e0e21e29 100644 --- a/apps/desktop/src/main/client-settings-ipc-main.ts +++ b/apps/desktop/src/main/client-settings-ipc-main.ts @@ -22,7 +22,7 @@ import type { UpdateAppSettingsInput, UpdateAppSettingsResult, } from "@maka/core/settings"; -import type { SettingsStore } from "@maka/storage"; +import type { SettingsStore } from "@maka/storage/settings-store"; import type { IpcMain } from "electron"; import { clientOwnedSettingsPatch, diff --git a/apps/desktop/src/main/config-transfer-service.ts b/apps/desktop/src/main/config-transfer-service.ts index c916b77847..a66cc53701 100644 --- a/apps/desktop/src/main/config-transfer-service.ts +++ b/apps/desktop/src/main/config-transfer-service.ts @@ -25,9 +25,9 @@ import { import { type ConfigBundle, type ConnectionConflictStrategy, - type CredentialKind, planConnectionMerge, -} from '@maka/storage'; +} from '@maka/storage/config-transfer'; +import { type CredentialKind } from '@maka/storage/credential-store'; /** * Electron-free config import orchestration. Runtime Host owns export because diff --git a/apps/desktop/src/main/e2e-fixture.ts b/apps/desktop/src/main/e2e-fixture.ts index 9e5dcb56c9..25360bffd0 100644 --- a/apps/desktop/src/main/e2e-fixture.ts +++ b/apps/desktop/src/main/e2e-fixture.ts @@ -21,7 +21,7 @@ import { mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; import type { E2eFixtureScenario, E2eFixtureState } from '@maka/core/e2e-fixture'; import type { UiLocale } from '@maka/core/ui-locale'; -import { createProjectCatalog } from '@maka/storage'; +import { createProjectCatalog } from '@maka/storage/project-catalog'; import { resolveStorageRoot } from '@maka/storage/root-authority'; import { E2E_FIXTURE_NOW, diff --git a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts index c2d27c4a12..801267932c 100644 --- a/apps/desktop/src/main/e2e-fixture/seed-helpers.ts +++ b/apps/desktop/src/main/e2e-fixture/seed-helpers.ts @@ -22,10 +22,10 @@ import { dirname, join } from 'node:path'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { acquireOperationalStateDatabase, - createSqliteSessionMetadataStore, OPERATIONAL_STATE_DATABASE_NAME, - projectSessionCatalogMessages, -} from '@maka/storage'; +} from '@maka/storage/operational-state-store'; +import { projectSessionCatalogMessages } from '@maka/storage/session-store'; +import { createSqliteSessionMetadataStore } from '@maka/storage/sqlite-session-metadata-store'; // Fixed clock for the e2e-fixture. All seeded timestamps and // transient fixture state derive from this value unless tests explicitly diff --git a/apps/desktop/src/main/mcp-ipc-main.ts b/apps/desktop/src/main/mcp-ipc-main.ts index b958ad1961..050cd35290 100644 --- a/apps/desktop/src/main/mcp-ipc-main.ts +++ b/apps/desktop/src/main/mcp-ipc-main.ts @@ -27,7 +27,11 @@ import { type McpServerStatus, } from '@maka/core/mcp'; import type { McpClientManager } from '@maka/mcp'; -import { McpServerExistsError, normalizeMcpConfig, type McpConfigStore } from '@maka/storage'; +import { + McpServerExistsError, + normalizeMcpConfig, + type McpConfigStore, +} from '@maka/storage/mcp-config-store'; import type { McpOAuthController } from './mcp-oauth-controller.js'; import { redactMcpConfigSecrets, diff --git a/apps/desktop/src/main/new-session-project.ts b/apps/desktop/src/main/new-session-project.ts index 097dcd280d..78b6275638 100644 --- a/apps/desktop/src/main/new-session-project.ts +++ b/apps/desktop/src/main/new-session-project.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { ProjectCatalog } from '@maka/storage'; +import type { ProjectCatalog } from '@maka/storage/project-catalog'; import type { WorkspaceTarget } from '@maka/runtime-host/protocol'; export interface DesktopSessionWorkspaceInput { diff --git a/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts b/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts index 5a62fa6b5d..1d6930eed2 100644 --- a/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts +++ b/apps/desktop/src/main/oauth/github-copilot-subscription-service.ts @@ -33,7 +33,7 @@ import { type OAuthSubscriptionTokens, } from '@maka/runtime/subscription-credentials'; import { fetchGitHubCopilotModels } from '@maka/runtime/model-fetcher'; -import type { CredentialStore } from '@maka/storage'; +import type { CredentialStore } from '@maka/storage/credential-store'; const GITHUB_COPILOT_CONNECTION_SLUG = 'github-copilot'; const execFileAsync = promisify(execFile); diff --git a/apps/desktop/src/main/pet-pack-import.ts b/apps/desktop/src/main/pet-pack-import.ts index b6a4b2dfbb..73de5c4ee8 100644 --- a/apps/desktop/src/main/pet-pack-import.ts +++ b/apps/desktop/src/main/pet-pack-import.ts @@ -30,7 +30,7 @@ import { PetPackStoreError, type PetPackStore, } from '@maka/storage/pet-pack-store'; -import type { SettingsStore } from '@maka/storage'; +import type { SettingsStore } from '@maka/storage/settings-store'; import type { createMainWindowController } from './main-window.js'; type MainWindowController = Pick< diff --git a/apps/desktop/src/main/quote-companion-cleanup.ts b/apps/desktop/src/main/quote-companion-cleanup.ts index 09d4576255..21f5017c15 100644 --- a/apps/desktop/src/main/quote-companion-cleanup.ts +++ b/apps/desktop/src/main/quote-companion-cleanup.ts @@ -18,7 +18,7 @@ */ import { randomUUID } from 'node:crypto'; -import { acquireOperationalStateDatabase } from '@maka/storage'; +import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store'; export interface SessionCopyCreationLease { sessionId: string; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 9d67cf7ac5..5dafdc7aff 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -54,11 +54,9 @@ import { } from "@maka/runtime-host/client"; import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; import { createCredentialMcpOAuthStorage, McpClientManager } from "@maka/mcp"; -import { - createSettingsStore, - createMcpConfigStore, - createFileCredentialStore, -} from "@maka/storage"; +import { createFileCredentialStore } from "@maka/storage/credential-store"; +import { createMcpConfigStore } from "@maka/storage/mcp-config-store"; +import { createSettingsStore } from "@maka/storage/settings-store"; import { resolveStorageRoot } from "@maka/storage/root-authority"; import { createMcpOAuthController } from "./mcp-oauth-controller.js"; diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index 7edc25d629..1a8c32149d 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -51,7 +51,7 @@ import { type ConfigCategory, type ConfigData, type ConnectionConflictStrategy, -} from '@maka/storage'; +} from '@maka/storage/config-transfer'; interface RuntimeHostConfigIpcDeps { readonly ipcMain: Pick; diff --git a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts index 9326dbdf76..d3edb27cce 100644 --- a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts @@ -33,7 +33,7 @@ import type { ProxySettings, TestProxyInput, } from "@maka/core/settings/network-settings"; -import type { SettingsStore } from "@maka/storage"; +import type { SettingsStore } from "@maka/storage/settings-store"; import { buildSettingsUpdateResult, maskAppSettings, diff --git a/apps/desktop/src/main/settings-bots-ipc-main.ts b/apps/desktop/src/main/settings-bots-ipc-main.ts index c98eafa257..5910c3b117 100644 --- a/apps/desktop/src/main/settings-bots-ipc-main.ts +++ b/apps/desktop/src/main/settings-bots-ipc-main.ts @@ -27,7 +27,7 @@ import { testBotChannel as testRuntimeBotChannel, type BotRegistry, } from '@maka/runtime/bots'; -import type { SettingsStore } from '@maka/storage'; +import type { SettingsStore } from '@maka/storage/settings-store'; import { BotOnboardingService, type BotOnboardingProviderAdapter, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f05bdac90d..1c5be02f41 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -169,7 +169,7 @@ import type { import type { BotStatus, WechatBridgeQrCodeResult } from '@maka/runtime/bots'; import type { ShellRunPtyDataEvent, ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry } from '@maka/ui'; -import type { ConfigCategory } from '@maka/storage'; +import type { ConfigCategory } from '@maka/storage/config-transfer'; import type { OnboardingMilestone, OnboardingMilestoneId, OnboardingState } from '@maka/core/onboarding'; import type { RemoteRuntimeHostProfile, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 67b2c15bb7..6711fa73c0 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -183,7 +183,7 @@ import type { BotStatus, WechatBridgeQrCodeResult } from '@maka/runtime/bots'; import type { ShellRunPtyDataEvent, ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; import type { GoalState } from '@maka/runtime/goal-state'; import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry } from '@maka/ui'; -import type { ConfigCategory } from '@maka/storage'; +import type { ConfigCategory } from '@maka/storage/config-transfer'; import { SENSITIVE_PLACEHOLDER, type TestProxyInput, diff --git a/apps/desktop/src/renderer/locales/settings-data-copy.ts b/apps/desktop/src/renderer/locales/settings-data-copy.ts index a171fd2ae5..7f38a86812 100644 --- a/apps/desktop/src/renderer/locales/settings-data-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-data-copy.ts @@ -18,7 +18,7 @@ */ import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; -import type { ConfigCategory } from '@maka/storage'; +import type { ConfigCategory } from '@maka/storage/config-transfer'; export type DataSettingsCopy = { categories: Record; diff --git a/apps/desktop/src/renderer/settings/data-settings-page.tsx b/apps/desktop/src/renderer/settings/data-settings-page.tsx index 1657b44b68..517563eb0f 100644 --- a/apps/desktop/src/renderer/settings/data-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/data-settings-page.tsx @@ -18,7 +18,7 @@ */ import { useEffect, useState } from 'react'; -import type { ConfigCategory } from '@maka/storage'; +import type { ConfigCategory } from '@maka/storage/config-transfer'; import { Button, Selector, diff --git a/apps/desktop/tsconfig.storybook.json b/apps/desktop/tsconfig.storybook.json index e65f38697a..452cfde8bb 100644 --- a/apps/desktop/tsconfig.storybook.json +++ b/apps/desktop/tsconfig.storybook.json @@ -10,7 +10,6 @@ "@maka/ui/artifact-preview-registry": ["../../packages/ui/src/artifact-preview-registry.ts"], "@maka/ui/assistant-stream": ["../../packages/ui/src/assistant-stream.ts"], "@maka/ui/maka-uri": ["../../packages/ui/src/maka-uri.ts"], - "@maka/storage": ["../../packages/storage/src/index.ts"], } }, "include": [ diff --git a/packages/cli/src/activation-command.ts b/packages/cli/src/activation-command.ts index 1c026840ab..aa0fce3f51 100644 --- a/packages/cli/src/activation-command.ts +++ b/packages/cli/src/activation-command.ts @@ -26,7 +26,7 @@ import type { CreateSessionInput, UserMessageInput } from '@maka/core/runtime-in import type { SessionSummary } from '@maka/core/session'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { redactSecrets } from '@maka/core/redaction'; -import { assertSessionBundleRootLayout } from '@maka/storage'; +import { assertSessionBundleRootLayout } from '@maka/storage/session-bundle-policy'; import { projectSessionCatalogSummary, readRuntimeHostSessions } from '@maka/runtime-host/client'; import { connectRuntimeHostCli, resolveRuntimeHostCliTarget } from './runtime-host-cli-context.js'; import { createRuntimeHostRunContext } from './runtime-host-run-command.js'; diff --git a/packages/cli/src/runtime-host-capability-provider-command.ts b/packages/cli/src/runtime-host-capability-provider-command.ts index f4f18a29a3..ce68ed2d0c 100644 --- a/packages/cli/src/runtime-host-capability-provider-command.ts +++ b/packages/cli/src/runtime-host-capability-provider-command.ts @@ -28,7 +28,8 @@ import type { McpToolDescriptor, } from '@maka/core/mcp'; import { createCredentialMcpOAuthStorage, McpClientManager } from '@maka/mcp'; -import { createFileCredentialStore, normalizeMcpConfig } from '@maka/storage'; +import { createFileCredentialStore } from '@maka/storage/credential-store'; +import { normalizeMcpConfig } from '@maka/storage/mcp-config-store'; import { connectRemoteRuntimeHost, loadOrCreateRuntimeHostClientInstanceId, diff --git a/packages/cli/src/runtime-host-cli-context.ts b/packages/cli/src/runtime-host-cli-context.ts index 46463187b4..dd4cc4bb38 100644 --- a/packages/cli/src/runtime-host-cli-context.ts +++ b/packages/cli/src/runtime-host-cli-context.ts @@ -44,7 +44,7 @@ import { type HostRegistration, type HostIncompatible, } from '@maka/runtime-host/protocol'; -import { resolveMakaClientDataRoot } from '@maka/storage'; +import { resolveMakaClientDataRoot } from '@maka/storage/workspace-root'; /** * The mode a new Session starts in belongs to the Host: `session.create` diff --git a/packages/cli/src/runtime-host-profile-command.ts b/packages/cli/src/runtime-host-profile-command.ts index 085a53401c..04f0f010f4 100644 --- a/packages/cli/src/runtime-host-profile-command.ts +++ b/packages/cli/src/runtime-host-profile-command.ts @@ -23,7 +23,7 @@ import { type RuntimeHostRemoteTransport, type RuntimeHostProfileCatalog, } from '@maka/runtime-host/client'; -import { resolveMakaClientDataRoot } from '@maka/storage'; +import { resolveMakaClientDataRoot } from '@maka/storage/workspace-root'; const DEFAULT_CREDENTIAL_ENV = 'MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'; diff --git a/packages/cli/src/runtime-host-systemd-service.ts b/packages/cli/src/runtime-host-systemd-service.ts index 81b92d4f17..9241b25f61 100644 --- a/packages/cli/src/runtime-host-systemd-service.ts +++ b/packages/cli/src/runtime-host-systemd-service.ts @@ -20,7 +20,7 @@ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { resolveXdgConfigHome } from '@maka/storage'; +import { resolveXdgConfigHome } from '@maka/storage/workspace-root'; import { removeRuntimeHostServiceFile, RuntimeHostServiceManagerError, diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index b74150e17d..52293a35db 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -22,7 +22,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { createInterface } from 'node:readline/promises'; import { SessionActivityRegistry } from '@maka/runtime/goal-turn-lifecycle'; import { readRuntimeHostConnectionCatalog } from '@maka/runtime-host/client'; -import { createForeignSessionStore } from '@maka/storage'; +import { createForeignSessionStore } from '@maka/storage/foreign-session-store'; import { formatMakaResumeHint } from './cli-invocation.js'; import { connectRuntimeHostCli, diff --git a/packages/cli/src/workspace-root.ts b/packages/cli/src/workspace-root.ts index 8106f13c91..06a6a668e3 100644 --- a/packages/cli/src/workspace-root.ts +++ b/packages/cli/src/workspace-root.ts @@ -28,4 +28,4 @@ export { type MakaDataRoots, type ResolveMakaClientDataRootInput, type ResolveMakaWorkspaceRootInput, -} from '@maka/storage'; +} from '@maka/storage/workspace-root'; diff --git a/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs b/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs index 9a4fa8c40f..5832696c60 100644 --- a/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs +++ b/packages/runtime-host/scripts/transcript-data-plane-benchmark.mjs @@ -21,7 +21,7 @@ import { performance } from 'node:perf_hooks'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createSessionStore } from '@maka/storage'; +import { createSessionStore } from '@maka/storage/session-store'; import { ClientSessionSubscription } from '../dist/client/session-subscription.js'; import { SESSION_CONTINUITY_SCHEMA_VERSION } from '../dist/protocol/index.js'; import { diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index a0fcd301a4..ab624029d6 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -29,7 +29,7 @@ import { type ExecutionStoresWriter, } from '@maka/storage/execution-stores'; import type { StoredInteractionRequest } from '@maka/storage/interaction-store'; -import { acquireOperationalStateDatabase } from '@maka/storage'; +import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store'; import { createSessionEventMapMemory, mapSessionEventToRuntimeEvent, diff --git a/packages/runtime-host/src/__tests__/client-capability-recovery.test.ts b/packages/runtime-host/src/__tests__/client-capability-recovery.test.ts index 01df649f31..badfba53e2 100644 --- a/packages/runtime-host/src/__tests__/client-capability-recovery.test.ts +++ b/packages/runtime-host/src/__tests__/client-capability-recovery.test.ts @@ -25,7 +25,7 @@ import { type ToolRecoveryMode, } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; -import { createSqliteRuntimeStore } from '@maka/storage'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { recoverClientCapabilityOutcomes } from '../server/client-capability-recovery.js'; test('successor recovery durably settles dispatched Client Capabilities as outcome_unknown', async () => { diff --git a/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts b/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts index 18cdad4770..c781604f41 100644 --- a/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/daily-review-coordinator.test.ts @@ -24,7 +24,7 @@ import { join } from 'node:path'; import { test } from 'node:test'; import { localDayBoundsAt, type DailyReviewArchive } from '@maka/core/daily-review'; import { openInteractiveDailyReviewAuthorityForWrite } from '@maka/storage/daily-review-authority'; -import { acquireOperationalStateDatabase } from '@maka/storage'; +import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority'; import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 424eaa35a6..9ebdd64937 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -62,7 +62,7 @@ import { createToolResultArchiveCapability } from '@maka/runtime/tool-result-arc import { loadHistoryCompactCheckpointsFromRunLedger } from '@maka/runtime/history-compact-ledger'; import { stableHash, toolCatalogHash } from '@maka/runtime/request-shape'; import { toolAvailabilityHash } from '@maka/runtime/tool-availability'; -import { createSqliteRuntimeStore } from '@maka/storage'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; import { openInteractiveArtifactStoreForWrite } from '@maka/storage/artifact-stores'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; diff --git a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts index dea303bf6f..5d7c2d3033 100644 --- a/packages/runtime-host/src/__tests__/fixtures/execution-host.ts +++ b/packages/runtime-host/src/__tests__/fixtures/execution-host.ts @@ -20,7 +20,7 @@ import { join } from 'node:path'; import { inspect } from 'node:util'; import { FakeBackend } from '@maka/runtime/test-only/fake-backend'; -import { createSqliteRuntimeStore } from '@maka/storage'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { startExecutionRuntimeHostCandidate } from '../../server/execution-candidate.js'; import { createExecutionRuntimeHostComposition } from '../../server/execution-composition.js'; import { runRuntimeHostProcessLifecycle } from '../../server/process-lifecycle.js'; diff --git a/packages/runtime-host/src/__tests__/host-profile.test.ts b/packages/runtime-host/src/__tests__/host-profile.test.ts index 098da5a7a4..ffcf7402fd 100644 --- a/packages/runtime-host/src/__tests__/host-profile.test.ts +++ b/packages/runtime-host/src/__tests__/host-profile.test.ts @@ -22,7 +22,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, describe, test } from 'node:test'; -import { createFileCredentialStore } from '@maka/storage'; +import { createFileCredentialStore } from '@maka/storage/credential-store'; import { RUNTIME_HOST_REMOTE_INCOMPATIBLE_CODE, RuntimeHostRemoteCompatibilityError, diff --git a/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts index f0b4fdb756..29e6868021 100644 --- a/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/project-catalog-coordinator.test.ts @@ -24,7 +24,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { promisify } from 'node:util'; -import { createProjectCatalog, createSessionStore } from '@maka/storage'; +import { createProjectCatalog } from '@maka/storage/project-catalog'; +import { createSessionStore } from '@maka/storage/session-store'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import { HostChangeFeed } from '../server/host-change-feed.js'; import { HostProjectCatalogCoordinator } from '../server/project-catalog-coordinator.js'; diff --git a/packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts index c9e2a1aaa0..012a2e7401 100644 --- a/packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/project-catalog-two-client-uds.test.ts @@ -22,7 +22,7 @@ import { mkdir, mkdtemp, realpath, rename, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { openInteractiveProjectCatalogForWrite } from '@maka/storage'; +import { openInteractiveProjectCatalogForWrite } from '@maka/storage/project-catalog-authority'; import { openInteractiveExecutionStoresForWrite } from '@maka/storage/execution-stores'; import { resolveRootControlNamespace, diff --git a/packages/runtime-host/src/__tests__/root-admission-owner.test.ts b/packages/runtime-host/src/__tests__/root-admission-owner.test.ts index 43a6223a1c..fdc0552dba 100644 --- a/packages/runtime-host/src/__tests__/root-admission-owner.test.ts +++ b/packages/runtime-host/src/__tests__/root-admission-owner.test.ts @@ -26,8 +26,8 @@ import { type RootTurnAdmission, type RootTurnAdmissionStore, type RootTurnSourceMessage, -} from '@maka/storage'; -import { createSqliteAgentRunStore } from '@maka/storage'; +} from '@maka/storage/agent-run-store'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { RootAdmissionOwner } from '../server/root-admission-owner.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; 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 fd1508cade..a450c4a598 100644 --- a/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/runtime-resource-coordinator.test.ts @@ -27,7 +27,7 @@ import { } from '@maka/core/events'; import type { ShellRunBashInput, ShellRunWriteInput } from '@maka/runtime/shell-run-contract'; import { ShellPreferenceError } from '@maka/runtime/shell-detect'; -import { SessionNotFoundError } from '@maka/storage'; +import { SessionNotFoundError } from '@maka/storage/session-store'; import { RUNTIME_RESOURCE_RESULT_MAX_BYTES } from '../protocol/runtime-resource.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import { diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 058cf6e1ad..1da01903a6 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -31,7 +31,7 @@ import { SessionConfigurationTransitionError, headerToSummary, } from '@maka/runtime/session-manager'; -import { type ProjectCatalog, ProjectUnavailableError } from '@maka/storage'; +import { type ProjectCatalog, ProjectUnavailableError } from '@maka/storage/project-catalog'; import type { ResolveExecutionConnectionResult } from '@maka/storage/runtime-policy-stores'; import { SessionMetadataVersionConflictError, 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 daa8017a16..4ca68158e0 100644 --- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts @@ -26,7 +26,7 @@ import { describe, test } from 'node:test'; import type { AgentGraphOperatorProvisionRequest } from '@maka/core/agent-graph-topology'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { agentGraphIdForRootSession } from '@maka/runtime/stream-graph-coordinator'; -import { createSessionStore } from '@maka/storage'; +import { createSessionStore } from '@maka/storage/session-store'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; import { HostScheduledTaskSessionBusyError, diff --git a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts index eb1ca1f9c1..9a5ed0dba2 100644 --- a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts @@ -33,7 +33,7 @@ import { resolveStorageRoot, tryAcquireInteractiveRootOwner, } from '@maka/storage/root-authority'; -import { acquireOperationalStateDatabase } from '@maka/storage'; +import { acquireOperationalStateDatabase } from '@maka/storage/operational-state-store'; import { openInteractiveUsageStoresForWrite } from '@maka/storage/usage-stores'; import { decodeClientFrame, diff --git a/packages/runtime-host/src/client/host-profile.ts b/packages/runtime-host/src/client/host-profile.ts index d08a65ae71..d4bb6c3ee0 100644 --- a/packages/runtime-host/src/client/host-profile.ts +++ b/packages/runtime-host/src/client/host-profile.ts @@ -20,7 +20,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { createFileCredentialStore, type CredentialStore } from '@maka/storage'; +import { createFileCredentialStore, type CredentialStore } from '@maka/storage/credential-store'; import { withFileUpdateLock } from '@maka/storage/file-update-lock'; import { INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 153b25ccb3..153468a1e2 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -76,7 +76,7 @@ import { isSessionNotFoundError } from '@maka/storage/execution-stores'; import { createExternalSessionAdapterRegistry } from '@maka/storage/external-sessions'; import { createGitWorktreeChildExecutor } from '@maka/storage/git-worktree-child-executor'; import { runWithStorageRootLease } from '@maka/storage/root-authority'; -import { openStorageWriterComposition } from '@maka/storage'; +import { openStorageWriterComposition } from '@maka/storage/storage-writer-composition'; import { resolveWorkspaceIdentity } from '@maka/storage/workspace-identity'; import { type ManagedWorkspaceFilesystemWorker } from '@maka/storage/managed-workspace-owner'; import { CanonicalSessionProjectionReader } from './canonical-session-projection.js'; diff --git a/packages/runtime-host/src/server/external-session-coordinator.ts b/packages/runtime-host/src/server/external-session-coordinator.ts index 8281fa0ac1..18cad24d7f 100644 --- a/packages/runtime-host/src/server/external-session-coordinator.ts +++ b/packages/runtime-host/src/server/external-session-coordinator.ts @@ -24,7 +24,7 @@ import type { } from '@maka/core/external-session'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import type { SessionExternalOrigin, SessionHeader, StoredMessage } from '@maka/core/session'; -import type { ExternalSessionImportLookupResult } from '@maka/storage'; +import type { ExternalSessionImportLookupResult } from '@maka/storage/session-store'; import type { SessionCatalogRecord } from '@maka/storage/execution-stores'; import { ExternalSessionImporter } from '@maka/storage/external-sessions'; import { diff --git a/packages/runtime-host/src/server/project-catalog-coordinator.ts b/packages/runtime-host/src/server/project-catalog-coordinator.ts index 0ec9f66a41..7c3040560f 100644 --- a/packages/runtime-host/src/server/project-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/project-catalog-coordinator.ts @@ -26,7 +26,7 @@ import { ProjectPathConflictError, ProjectPathMismatchError, ProjectUnavailableError, -} from '@maka/storage'; +} from '@maka/storage/project-catalog'; import { decodeProjectCatalogProject, PROJECT_CATALOG_PAGE_MAX_BYTES, diff --git a/packages/runtime-host/src/server/workspace-resolver.ts b/packages/runtime-host/src/server/workspace-resolver.ts index fdea265032..7edce18b89 100644 --- a/packages/runtime-host/src/server/workspace-resolver.ts +++ b/packages/runtime-host/src/server/workspace-resolver.ts @@ -26,7 +26,7 @@ import { ProjectNotFoundError, ProjectPathMismatchError, ProjectUnavailableError, -} from '@maka/storage'; +} from '@maka/storage/project-catalog'; import type { WorkspaceTarget } from '../protocol/index.js'; import type { HostProjectMembershipGate } from './project-membership-gate.js'; diff --git a/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts b/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts index 1fd22eefc5..260222e56d 100644 --- a/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts +++ b/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { createSqliteSessionMetadataStore } from '@maka/storage'; +import { createSqliteSessionMetadataStore } from '@maka/storage/sqlite-session-metadata-store'; import { AgentGraphSupervisorContextOverflowError, AgentGraphSupervisorWakeCoordinator, diff --git a/packages/runtime/src/__tests__/agent-graph-timeline.test.ts b/packages/runtime/src/__tests__/agent-graph-timeline.test.ts index e7506cb80e..42e3cb8e7b 100644 --- a/packages/runtime/src/__tests__/agent-graph-timeline.test.ts +++ b/packages/runtime/src/__tests__/agent-graph-timeline.test.ts @@ -23,7 +23,7 @@ import { AGENT_GRAPH_SUPERVISOR_WAKE_SCHEMA_VERSION } from '@maka/core/agent-gra import { type AgentGraphTimelineMetadataSnapshot } from '@maka/core/agent-graph-timeline'; import { type AgentRunHeader } from '@maka/core/agent-run'; import { type RuntimeEvent } from '@maka/core/runtime-event'; -import { createSqliteSessionMetadataStore } from '@maka/storage'; +import { createSqliteSessionMetadataStore } from '@maka/storage/sqlite-session-metadata-store'; import { buildAgentGraphTimeline, buildAgentGraphTimelineCurrentState, diff --git a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts index a82b0d6cbc..f3d95e1ac2 100644 --- a/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts +++ b/packages/runtime/src/__tests__/agent-run-steering-recovery.test.ts @@ -25,11 +25,9 @@ import { test } from 'node:test'; import type { AgentRunHeader } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { SessionEvent } from '@maka/core/events'; -import { - createSessionStore, - createSqliteAgentRunStore, - createWorkspaceRuntimeStore, -} from '@maka/storage'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; +import { createSessionStore } from '@maka/storage/session-store'; import { AgentRun } from '../agent-run.js'; import { RuntimeLedgerRepair } from '../runtime-ledger-repair.js'; import { buildStatusPatch } from '../session-projection-helpers.js'; diff --git a/packages/runtime/src/__tests__/context-diagnostics.test.ts b/packages/runtime/src/__tests__/context-diagnostics.test.ts index 9f7c33ed92..d5e84bcacc 100644 --- a/packages/runtime/src/__tests__/context-diagnostics.test.ts +++ b/packages/runtime/src/__tests__/context-diagnostics.test.ts @@ -28,7 +28,7 @@ import type { AgentRunStore, EmittedAgentRunEvent, } from '@maka/core/agent-run'; -import { createSqliteAgentRunStore } from '@maka/storage'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { readLatestContextDiagnostics } from '../context-diagnostics.js'; test('serves the sealed snapshot without reading a single run', async () => { diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 8b9b4116ed..db44f4c5d7 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -29,11 +29,9 @@ import type { StoredMessage } from '@maka/core/session'; import { decodeCanonicalToolResultContent } from '@maka/core/tool-result-record-schema'; import { isSessionInlineRun } from '@maka/core/agent-run'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; -import { - createSqliteAgentRunStore, - createSqliteRuntimeStore, - createWorkspaceRuntimeStore, -} from '@maka/storage'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { archivedToolResultContainsConversationOwnedReferences, cloneConversationRuntimeLedger, diff --git a/packages/runtime/src/__tests__/deep-research-tools.test.ts b/packages/runtime/src/__tests__/deep-research-tools.test.ts index 0dfef1c306..6b08d15c92 100644 --- a/packages/runtime/src/__tests__/deep-research-tools.test.ts +++ b/packages/runtime/src/__tests__/deep-research-tools.test.ts @@ -25,7 +25,7 @@ import { describe, it } from 'node:test'; import { z } from 'zod'; import type { ArtifactRecord } from '@maka/core/artifacts'; import type { DeepResearchRun } from '@maka/core/deep-research-run'; -import { createSqliteDeepResearchStore } from '@maka/storage'; +import { createSqliteDeepResearchStore } from '@maka/storage/deep-research-store'; import { DEEP_RESEARCH_CHECKPOINT_TOOL_NAME, DEEP_RESEARCH_COMPLETE_TOOL_NAME, diff --git a/packages/runtime/src/__tests__/execution-inspect.test.ts b/packages/runtime/src/__tests__/execution-inspect.test.ts index b4f4bd5f73..9c1d4d46eb 100644 --- a/packages/runtime/src/__tests__/execution-inspect.test.ts +++ b/packages/runtime/src/__tests__/execution-inspect.test.ts @@ -29,8 +29,9 @@ import type { EmittedAgentRunEvent, } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { createSessionStore } from '@maka/storage'; -import { createSqliteAgentRunStore, createWorkspaceRuntimeStore } from '@maka/storage'; +import { createSessionStore } from '@maka/storage/session-store'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { inspectAgentRunDocument, renderAgentRunInspectTree } from '../execution-inspect.js'; describe('versioned execution inspect documents', () => { diff --git a/packages/runtime/src/__tests__/latest-context-commit.test.ts b/packages/runtime/src/__tests__/latest-context-commit.test.ts index e183039359..e1903d94a3 100644 --- a/packages/runtime/src/__tests__/latest-context-commit.test.ts +++ b/packages/runtime/src/__tests__/latest-context-commit.test.ts @@ -38,11 +38,9 @@ import { MockLanguageModelV4, simulateReadableStream } from 'ai/test'; import type { LanguageModelV4StreamPart } from '@ai-sdk/provider'; import type { ModelCallAttempt } from '@maka/core/model-call-attempt'; import type { ModelCallCommit } from '@maka/core/agent-run'; -import { - createSessionStore, - createSqliteAgentRunStore, - createWorkspaceRuntimeStore, -} from '@maka/storage'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; +import { createSessionStore } from '@maka/storage/session-store'; import { BackendRegistry, SessionManager } from '../session-manager.js'; import { readLatestContextDiagnostics } from '../context-diagnostics.js'; import { createTestAiSdkBackend } from './execution-boundary-test-helpers.js'; diff --git a/packages/runtime/src/__tests__/recovery-authority-equivalence.test.ts b/packages/runtime/src/__tests__/recovery-authority-equivalence.test.ts index 64c6cb1413..fb7401779c 100644 --- a/packages/runtime/src/__tests__/recovery-authority-equivalence.test.ts +++ b/packages/runtime/src/__tests__/recovery-authority-equivalence.test.ts @@ -24,7 +24,7 @@ import { join } from 'node:path'; import { describe, it } from 'node:test'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { canonicalToolArgsHash } from '@maka/core/tool-args-identity'; -import { createSqliteRuntimeStore } from '@maka/storage'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { resolveRuntimeRecovery } from '../recovery-resolver.js'; import { buildResumePlanFromRuntimeEvents } from '../runtime-resume.js'; diff --git a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts index 17c8e90502..b4cc1af6b1 100644 --- a/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-continuation-crash.test.ts @@ -29,8 +29,9 @@ import { describe, test } from 'node:test'; import type { AgentRunHeader } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { createSessionStore, createSqliteRuntimeStore } from '@maka/storage'; -import { createSqliteAgentRunStore } from '@maka/storage'; +import { createSessionStore } from '@maka/storage/session-store'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { type RuntimeContinuationFailpoint } from '../agent-run.js'; import { BackendRegistry, SessionManager } from '../session-manager.js'; diff --git a/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts b/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts index 173fd568b6..f3da347e0b 100644 --- a/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts +++ b/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts @@ -23,12 +23,10 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import type { StoredMessage } from '@maka/core/session'; -import { - createSqliteAgentRunStore, - createSqliteRuntimeStore, - createSessionStore, -} from '@maka/storage'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createExternalSessionAdapterRegistry } from '@maka/storage/external-sessions'; +import { createSessionStore } from '@maka/storage/session-store'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; import { buildPriorRuntimeContext } from '../prior-run-context.js'; import { backfillRuntimeEventsFromStoredMessages } from '../runtime-event-backfill.js'; diff --git a/packages/runtime/src/__tests__/runtime-resume-crash.test.ts b/packages/runtime/src/__tests__/runtime-resume-crash.test.ts index c4372960af..13d188f9b1 100644 --- a/packages/runtime/src/__tests__/runtime-resume-crash.test.ts +++ b/packages/runtime/src/__tests__/runtime-resume-crash.test.ts @@ -27,7 +27,8 @@ import { spawn } from 'node:child_process'; import { describe, test } from 'node:test'; import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { createSqliteAgentRunStore, createWorkspaceRuntimeStore } from '@maka/storage'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { RUNTIME_RESUME_FAILPOINTS, diff --git a/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts b/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts index 95c8d08378..ce07d55262 100644 --- a/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts +++ b/packages/runtime/src/__tests__/sandbox-boundary-restart-recovery.test.ts @@ -26,12 +26,12 @@ import type { AgentRunEvent, EmittedAgentRunEvent, AgentRunHeader } from '@maka/ import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import type { SessionHeader, StoredMessage } from '@maka/core/session'; import { - createSessionStore, type DurableAgentRunStore, type DurableRuntimeEventStore, - type SessionAuthorityStore, -} from '@maka/storage'; -import { createSqliteAgentRunStore, createWorkspaceRuntimeStore } from '@maka/storage'; +} from '@maka/storage/agent-run-store'; +import { createSessionStore, type SessionAuthorityStore } from '@maka/storage/session-store'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { BackendRegistry, SessionManager } from '../session-manager.js'; /** diff --git a/packages/runtime/src/__tests__/shell-run-manager.test.ts b/packages/runtime/src/__tests__/shell-run-manager.test.ts index 1b1764c33f..36dce2207a 100644 --- a/packages/runtime/src/__tests__/shell-run-manager.test.ts +++ b/packages/runtime/src/__tests__/shell-run-manager.test.ts @@ -34,7 +34,7 @@ import { type ShellRunStore, } from '@maka/core/shell-run'; import { type ShellRunUpdate, type ToolResultContent } from '@maka/core/events'; -import { createSqliteShellRunStore } from '@maka/storage'; +import { createSqliteShellRunStore } from '@maka/storage/shell-run-store'; import { ShellRunProcessManager } from '../shell-run-manager.js'; import { diff --git a/packages/runtime/src/__tests__/shell-run-tool-result.test.ts b/packages/runtime/src/__tests__/shell-run-tool-result.test.ts index 4602125a40..fb43b07e9a 100644 --- a/packages/runtime/src/__tests__/shell-run-tool-result.test.ts +++ b/packages/runtime/src/__tests__/shell-run-tool-result.test.ts @@ -25,7 +25,7 @@ import { describe, test } from 'node:test'; import { type PtyShellOutput, type ShellRunRecord } from '@maka/core/shell-run'; import { type RuntimeEvent } from '@maka/core/runtime-event'; import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; -import { createSessionStore } from '@maka/storage'; +import { createSessionStore } from '@maka/storage/session-store'; import { projectPtyOutputForModel, diff --git a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts index 9556db0538..e596bd0377 100644 --- a/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-coordinator.test.ts @@ -35,13 +35,11 @@ import { import { type AgentGraphScheduleUpdate } from '@maka/core/agent-graph-schedule'; import { type AgentRunHeader } from '@maka/core/agent-run'; import { type RuntimeEvent } from '@maka/core/runtime-event'; -import { - createSessionStore, - createSqliteSessionMetadataStore, - isSessionNotFoundError, - OPERATIONAL_STATE_DATABASE_NAME, -} from '@maka/storage'; -import { createSqliteAgentRunStore, createWorkspaceRuntimeStore } from '@maka/storage'; +import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store'; +import { createSessionStore, isSessionNotFoundError } from '@maka/storage/session-store'; +import { createSqliteSessionMetadataStore } from '@maka/storage/sqlite-session-metadata-store'; +import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; +import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { FakeBackend } from '../test-only/fake-backend.js'; import { BackendRegistry, SessionManager } from '../session-manager.js'; import { SessionActivityRegistry } from '../goal-turn-lifecycle.js'; diff --git a/packages/runtime/src/__tests__/stream-graph-schedule-reconcile.test.ts b/packages/runtime/src/__tests__/stream-graph-schedule-reconcile.test.ts index e86f12ef8e..9f0d743e0b 100644 --- a/packages/runtime/src/__tests__/stream-graph-schedule-reconcile.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-schedule-reconcile.test.ts @@ -26,7 +26,7 @@ import type { import type { AgentGraphIntentClaimStore } from '@maka/core/agent-graph-control'; import type { AgentGraphOperatorProvision } from '@maka/core/agent-graph-topology'; import type { SessionHeader } from '@maka/core/session'; -import { createSqliteSessionMetadataStore } from '@maka/storage'; +import { createSqliteSessionMetadataStore } from '@maka/storage/sqlite-session-metadata-store'; import type { AgentGraphIntentExecutor, AgentGraphSupervisorObservation, diff --git a/packages/runtime/src/__tests__/stream-graph-supervisor-tools.test.ts b/packages/runtime/src/__tests__/stream-graph-supervisor-tools.test.ts index ac892ce157..2e91fe60f0 100644 --- a/packages/runtime/src/__tests__/stream-graph-supervisor-tools.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-supervisor-tools.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { createSqliteSessionMetadataStore } from '@maka/storage'; +import { createSqliteSessionMetadataStore } from '@maka/storage/sqlite-session-metadata-store'; import type { AgentGraphSupervisorObservation } from '../stream-graph-dispatch.js'; import type { MakaToolContext } from '../tool-runtime.js'; import { diff --git a/packages/runtime/src/__tests__/subscription-credentials.test.ts b/packages/runtime/src/__tests__/subscription-credentials.test.ts index 4268e03757..404f84f90f 100644 --- a/packages/runtime/src/__tests__/subscription-credentials.test.ts +++ b/packages/runtime/src/__tests__/subscription-credentials.test.ts @@ -23,7 +23,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; -import { createFileCredentialStore } from '@maka/storage'; +import { createFileCredentialStore } from '@maka/storage/credential-store'; import { createGitHubCopilotAccountTokens, diff --git a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts index ecfd601d01..676a4490f6 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sqlite-boundary.test.ts @@ -28,7 +28,7 @@ import { type LlmConnection } from '@maka/core/llm-connections'; import { type SessionEvent } from '@maka/core/events'; import { type SessionHeader } from '@maka/core/session'; import type { McpToolBinding } from '@maka/core/mcp'; -import { createSqliteRuntimeStore } from '@maka/storage'; +import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { createSessionEventMapMemory, mapSessionEventToRuntimeEvent } from '../ai-sdk-flow.js'; import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; import { buildMcpTools } from '../mcp-tools.js'; diff --git a/packages/storage/package.json b/packages/storage/package.json index b45746f046..0065779df1 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -4,43 +4,55 @@ "license": "Apache-2.0", "private": true, "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "exports": { - ".": "./dist/index.js", - "./credential-store": "./dist/credential-store.js", - "./file-update-lock": "./dist/file-update-lock.js", - "./process-lifetime-file-update-lock": "./dist/process-lifetime-file-update-lock.js", - "./stable-storage": "./dist/stable-storage.js", + "./activation-secret-injector": "./dist/activation-secret-injector.js", + "./agent-graph-control-store": "./dist/agent-graph-control-store.js", + "./agent-run-store": "./dist/agent-run-store.js", "./artifact-stores": "./dist/artifact-stores.js", + "./config-transfer": "./dist/config-transfer.js", + "./credential-store": "./dist/credential-store.js", "./daily-review-authority": "./dist/daily-review-authority.js", "./deep-research-authority": "./dist/deep-research-authority.js", - "./goal-authority": "./dist/goal-authority.js", - "./plan-authority": "./dist/plan-authority.js", + "./deep-research-store": "./dist/deep-research-store.js", + "./encrypted-file-managed-secret-store": "./dist/encrypted-file-managed-secret-store.js", "./execution-stores": "./dist/execution-stores.js", "./external-sessions": "./dist/external-sessions.js", - "./agent-graph-control-store": "./dist/agent-graph-control-store.js", - "./interaction-store": "./dist/interaction-store-public.js", + "./file-update-lock": "./dist/file-update-lock.js", + "./foreign-session-store": "./dist/foreign-session-store.js", "./git-worktree-child-executor": "./dist/git-worktree-child-executor.js", - "./memory-bundle-store": "./dist/memory-bundle-store.js", - "./managed-workspace-owner": "./dist/managed-workspace-owner.js", - "./managed-secret-store": "./dist/managed-secret-store.js", - "./activation-secret-injector": "./dist/activation-secret-injector.js", - "./encrypted-file-managed-secret-store": "./dist/encrypted-file-managed-secret-store.js", + "./goal-authority": "./dist/goal-authority.js", + "./interaction-store": "./dist/interaction-store-public.js", "./long-term-memory-store": "./dist/long-term-memory-store.js", + "./managed-secret-store": "./dist/managed-secret-store.js", + "./managed-workspace-owner": "./dist/managed-workspace-owner.js", + "./mcp-config-store": "./dist/mcp-config-store.js", + "./memory-bundle-store": "./dist/memory-bundle-store.js", + "./model-call-ledger": "./dist/model-call-ledger.js", + "./operational-state-store": "./dist/operational-state-store-public.js", "./pet-pack-store": "./dist/pet-pack-store.js", + "./plan-authority": "./dist/plan-authority.js", + "./process-lifetime-file-update-lock": "./dist/process-lifetime-file-update-lock.js", + "./project-catalog": "./dist/project-catalog.js", + "./project-catalog-authority": "./dist/project-catalog-authority.js", "./root-authority": "./dist/root-authority.js", - "./state-root-composition": "./dist/state-root-composition.js", - "./storage-writer-composition": "./dist/storage-writer-composition.js", + "./runtime-event-persistence": "./dist/runtime-event-persistence.js", "./runtime-policy-stores": "./dist/runtime-policy-stores.js", "./scheduled-task-store": "./dist/scheduled-task-store.js", + "./session-bundle-policy": "./dist/session-bundle-policy.js", + "./session-store": "./dist/session-store.js", "./settings-store": "./dist/settings-store.js", "./shell-run-authority": "./dist/shell-run-authority.js", + "./shell-run-store": "./dist/shell-run-store.js", + "./sqlite-runtime-store": "./dist/sqlite-runtime-store.js", + "./sqlite-session-metadata-store": "./dist/sqlite-session-metadata-store.js", + "./stable-storage": "./dist/stable-storage.js", + "./state-root-composition": "./dist/state-root-composition.js", + "./storage-writer-composition": "./dist/storage-writer-composition.js", "./task-ledger-authority": "./dist/task-ledger-authority.js", - "./work-board-store": "./dist/work-board-store.js", - "./model-call-ledger": "./dist/model-call-ledger.js", "./usage-stores": "./dist/usage-stores.js", + "./work-board-store": "./dist/work-board-store.js", "./workspace-identity": "./dist/workspace-identity.js", + "./workspace-root": "./dist/workspace-root.js", "./write-queue": "./dist/write-queue.js" }, "scripts": { diff --git a/packages/storage/src/__tests__/fixtures/session-bundle-hydration-binding-crash.ts b/packages/storage/src/__tests__/fixtures/session-bundle-hydration-binding-crash.ts index 7befd0ffa1..f7873130a6 100644 --- a/packages/storage/src/__tests__/fixtures/session-bundle-hydration-binding-crash.ts +++ b/packages/storage/src/__tests__/fixtures/session-bundle-hydration-binding-crash.ts @@ -52,7 +52,7 @@ fs.promises.open = async (...args) => { }; syncBuiltinESMExports(); -const { createSessionBundleFileService } = await import('../../index.js'); +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); // Keep the deliberately wedged top-level await alive until the parent delivers // SIGKILL. Without a referenced handle, Node exits with code 13 as soon as the // event loop is empty and races the crash-state assertions in the parent test. diff --git a/packages/storage/src/__tests__/fixtures/session-bundle-hydration-owner-write-failure.ts b/packages/storage/src/__tests__/fixtures/session-bundle-hydration-owner-write-failure.ts index 32b89d46e0..7629661275 100644 --- a/packages/storage/src/__tests__/fixtures/session-bundle-hydration-owner-write-failure.ts +++ b/packages/storage/src/__tests__/fixtures/session-bundle-hydration-owner-write-failure.ts @@ -56,7 +56,8 @@ fs.promises.open = async (...args) => { }; syncBuiltinESMExports(); -const { createSessionBundleFileService, SessionBundleFileError } = await import('../../index.js'); +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +const { SessionBundleFileError } = await import('../../session-bundle-contract.js'); try { await createSessionBundleFileService().hydrate({ source: { diff --git a/packages/storage/src/__tests__/fixtures/session-bundle-inspect-child.ts b/packages/storage/src/__tests__/fixtures/session-bundle-inspect-child.ts index 6c15ba4994..96abb5d5c3 100644 --- a/packages/storage/src/__tests__/fixtures/session-bundle-inspect-child.ts +++ b/packages/storage/src/__tests__/fixtures/session-bundle-inspect-child.ts @@ -17,7 +17,8 @@ * under the License. */ -import { createSessionBundleFileService, type SessionBundleLimits } from '../../index.js'; +import { createSessionBundleFileService } from '../../session-bundle-file-service.js'; +import type { SessionBundleLimits } from '../../session-bundle-contract.js'; const [archivePath, archiveDigest, limitsJson, destinationRoot] = process.argv.slice(2); if (!archivePath || !archiveDigest || !limitsJson) process.exit(2); diff --git a/packages/storage/src/__tests__/fixtures/session-bundle-inspect-source-mutator.ts b/packages/storage/src/__tests__/fixtures/session-bundle-inspect-source-mutator.ts index c774e8c915..03307ed9f0 100644 --- a/packages/storage/src/__tests__/fixtures/session-bundle-inspect-source-mutator.ts +++ b/packages/storage/src/__tests__/fixtures/session-bundle-inspect-source-mutator.ts @@ -43,7 +43,8 @@ fs.promises.open = async (...args) => { }; syncBuiltinESMExports(); -const { createSessionBundleFileService, SessionBundleFileError } = await import('../../index.js'); +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +const { SessionBundleFileError } = await import('../../session-bundle-contract.js'); try { await createSessionBundleFileService().inspect({ source: { path: target }, diff --git a/packages/storage/src/__tests__/fixtures/session-bundle-pack-destination-replacer.ts b/packages/storage/src/__tests__/fixtures/session-bundle-pack-destination-replacer.ts index e02d3b3191..0888f0ca61 100644 --- a/packages/storage/src/__tests__/fixtures/session-bundle-pack-destination-replacer.ts +++ b/packages/storage/src/__tests__/fixtures/session-bundle-pack-destination-replacer.ts @@ -41,7 +41,8 @@ fs.promises.link = async (existingPath, newPath) => { }; syncBuiltinESMExports(); -const { createSessionBundleFileService, SessionBundleFileError } = await import('../../index.js'); +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +const { SessionBundleFileError } = await import('../../session-bundle-contract.js'); try { await createSessionBundleFileService().pack({ snapshot: { diff --git a/packages/storage/src/__tests__/fixtures/session-bundle-pack-link-replacer.ts b/packages/storage/src/__tests__/fixtures/session-bundle-pack-link-replacer.ts index 31137c26a9..e5ecb7352c 100644 --- a/packages/storage/src/__tests__/fixtures/session-bundle-pack-link-replacer.ts +++ b/packages/storage/src/__tests__/fixtures/session-bundle-pack-link-replacer.ts @@ -45,7 +45,8 @@ fs.promises.link = async (existingPath, newPath) => { }; syncBuiltinESMExports(); -const { createSessionBundleFileService, SessionBundleFileError } = await import('../../index.js'); +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +const { SessionBundleFileError } = await import('../../session-bundle-contract.js'); try { await createSessionBundleFileService().pack({ snapshot: { diff --git a/packages/storage/src/__tests__/fixtures/session-bundle-pack-linked-temp-remover.ts b/packages/storage/src/__tests__/fixtures/session-bundle-pack-linked-temp-remover.ts index 7b3bcc155d..ead185c92b 100644 --- a/packages/storage/src/__tests__/fixtures/session-bundle-pack-linked-temp-remover.ts +++ b/packages/storage/src/__tests__/fixtures/session-bundle-pack-linked-temp-remover.ts @@ -43,7 +43,8 @@ fs.promises.link = async (existingPath, newPath) => { }; syncBuiltinESMExports(); -const { createSessionBundleFileService, SessionBundleFileError } = await import('../../index.js'); +const { createSessionBundleFileService } = await import('../../session-bundle-file-service.js'); +const { SessionBundleFileError } = await import('../../session-bundle-contract.js'); try { await createSessionBundleFileService().pack({ snapshot: { diff --git a/packages/storage/src/__tests__/managed-workspace-baseline.test.ts b/packages/storage/src/__tests__/managed-workspace-baseline.test.ts index 60716bf264..eb8b008b0b 100644 --- a/packages/storage/src/__tests__/managed-workspace-baseline.test.ts +++ b/packages/storage/src/__tests__/managed-workspace-baseline.test.ts @@ -42,7 +42,6 @@ import { afterEach, before, test } from 'node:test'; import { openManagedWorkspaceOwner } from '../managed-workspace-owner.js'; import { managedWorkspaceExecutionAuthorityTestSupport } from '../managed-workspace-execution-authority-internal.js'; import { createGitWorkspaceService } from '../git-workspace-service.js'; -import * as publicStorage from '../index.js'; import { acquireOperationalStateDatabase } from '../operational-state-store.js'; import { adoptStorageRootOnImport, @@ -84,11 +83,39 @@ test('does not expose baseline receipt issuance on the public Git workspace serv }); test('does not expose artifact-only workspace creation through the public storage API', async () => { - assert.equal('createGitWorkspaceService' in publicStorage, false); - assert.equal('issueManagedWorkspaceExecutionHandleInternal' in publicStorage, false); - assert.equal('inspectManagedWorkspaceExecutionHandleInternal' in publicStorage, false); - assert.equal('issueManagedWorkspaceExecutionScopeInternal' in publicStorage, false); - assert.equal('inspectManagedWorkspaceExecutionScopeInternal' in publicStorage, false); + // The package has no barrel: reachability is decided by the `exports` map. + // Asserting on the map's targets alone would miss a published entrypoint + // re-exporting an internal module, so this loads every published entrypoint + // and checks the union of the symbols a consumer can actually reach. + const manifest = JSON.parse( + await readFile(new URL('../../package.json', import.meta.url), 'utf8'), + ) as { exports: Record }; + const reachable = new Set(); + await Promise.all( + Object.values(manifest.exports).map(async (target) => { + const entrypoint = (await import(new URL(`../../${target}`, import.meta.url).href)) as object; + for (const name of Object.keys(entrypoint)) reachable.add(name); + }), + ); + for (const internalSymbol of [ + 'createGitWorkspaceService', + 'GitWorkspaceServiceError', + 'createManagedWorkspaceWorkerBridgeInternal', + 'ManagedWorkspaceWorkerBridgeError', + 'ManagedWorkspaceExecutionAuthorityError', + 'issueManagedWorkspaceExecutionHandleInternal', + 'requireManagedWorkspaceExecutionHandleInternal', + 'issueManagedWorkspaceExecutionScopeInternal', + 'revokeManagedWorkspaceExecutionScopeInternal', + 'requireManagedWorkspaceExecutionScopeInternal', + 'managedWorkspaceExecutionAuthorityTestSupport', + 'createSqliteArtifactStoreWriteAuthority', + 'migrateOperationalStateDatabaseInternal', + 'inspectOperationalStateSchema', + 'OperationalStateMigrationBlockedError', + ]) { + assert.equal(reachable.has(internalSymbol), false, internalSymbol); + } const root = await temporaryRoot(); const storageRoot = join(root, 'storage'); const capability = trackControlDirectory( diff --git a/packages/storage/src/__tests__/provider-request-capture-artifact.test.ts b/packages/storage/src/__tests__/provider-request-capture-artifact.test.ts index ceeeb5e6cd..fcbe5ec943 100644 --- a/packages/storage/src/__tests__/provider-request-capture-artifact.test.ts +++ b/packages/storage/src/__tests__/provider-request-capture-artifact.test.ts @@ -24,12 +24,15 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createSqliteArtifactStore as createArtifactStore } from '../artifact-store.js'; -import * as storage from '../index.js'; +import * as providerRequestCapture from '../provider-request-capture-artifact.js'; test('persists the exact prepared request as a private artifact', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-provider-capture-')); const store = createArtifactStore(root); - const persist = Reflect.get(storage, 'persistProviderRequestCaptureArtifact') as unknown as + const persist = Reflect.get( + providerRequestCapture, + 'persistProviderRequestCaptureArtifact', + ) as unknown as | (( store: ReturnType, input: Record, diff --git a/packages/storage/src/__tests__/public-entrypoints.test.ts b/packages/storage/src/__tests__/public-entrypoints.test.ts new file mode 100644 index 0000000000..52409b4a49 --- /dev/null +++ b/packages/storage/src/__tests__/public-entrypoints.test.ts @@ -0,0 +1,269 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import { test } from 'node:test'; + +const run = promisify(execFile); +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +/** + * Entrypoints whose module graph reaches `node:sqlite` at load time, so + * importing one emits Node's SQLite ExperimentalWarning. + * + * This list is the package's SQLite boundary, stated out loud. `@maka/storage` + * publishes no barrel: a consumer that needs a durable store imports the entry + * that owns it and accepts the warning, and a consumer that needs + * `workspace-root` or `credential-store` pays nothing. Issue #1257 came from + * the opposite arrangement, where one `export *` barrel made every consumer — + * `maka --help` included — load SQLite. + * + * Adding an entry here is a deliberate widening of that boundary. Removing one + * means an entrypoint became SQLite-free. Either way, update this list in the + * same change and say why. + */ +const SQLITE_BACKED_ENTRYPOINTS = [ + './agent-graph-control-store', + './agent-run-store', + './artifact-stores', + './daily-review-authority', + './deep-research-authority', + './deep-research-store', + './execution-stores', + './git-worktree-child-executor', + './goal-authority', + './interaction-store', + './managed-workspace-owner', + './model-call-ledger', + './operational-state-store', + './plan-authority', + './project-catalog', + './project-catalog-authority', + './runtime-event-persistence', + './scheduled-task-store', + './session-bundle-policy', + './session-store', + './settings-store', + './shell-run-authority', + './shell-run-store', + './sqlite-runtime-store', + './sqlite-session-metadata-store', + './storage-writer-composition', + './task-ledger-authority', + './usage-stores', + './work-board-store', +]; + +/** + * Loads an entrypoint in a child process and reports whether `node:sqlite` + * entered its module graph, observed through a `module.registerHooks` resolve + * hook. Matching Node's ExperimentalWarning text instead would tie this guard + * to a string Node owns and has already reworded once. + */ +async function loadsSqlite(target: string): Promise { + const specifier = pathToFileURL(resolve(packageRoot, target)).href; + const probe = [ + "import { registerHooks } from 'node:module';", + 'let sawSqlite = false;', + 'registerHooks({', + ' resolve(request, context, nextResolve) {', + ' const resolved = nextResolve(request, context);', + " if (resolved.url === 'node:sqlite') sawSqlite = true;", + ' return resolved;', + ' },', + '});', + `await import(${JSON.stringify(specifier)});`, + "process.stdout.write(sawSqlite ? '\\nSQLITE_IN_GRAPH=yes' : '\\nSQLITE_IN_GRAPH=no');", + ].join('\n'); + const { stdout } = await run(process.execPath, ['--input-type=module', '--eval', probe], { + encoding: 'utf8', + }); + const verdict = /SQLITE_IN_GRAPH=(yes|no)$/u.exec(stdout); + assert.ok(verdict, `probe for ${target} produced no verdict; stdout was: ${stdout}`); + return verdict[1] === 'yes'; +} + +async function publishedEntrypoints(): Promise> { + const manifest = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8')) as { + exports: Record; + }; + return manifest.exports; +} + +test('the package publishes no barrel entrypoint', async () => { + const exports = await publishedEntrypoints(); + assert.equal('.' in exports, false); + const manifest = JSON.parse(await readFile(resolve(packageRoot, 'package.json'), 'utf8')) as { + main?: string; + types?: string; + }; + assert.equal(manifest.main, undefined, 'a `main` field would re-advertise the barrel'); + assert.equal(manifest.types, undefined, 'a `types` field would re-advertise the barrel'); +}); + +test('every published entrypoint target is emitted by the build', async () => { + const exports = await publishedEntrypoints(); + for (const [subpath, target] of Object.entries(exports)) { + assert.ok( + existsSync(resolve(packageRoot, target)), + `"${subpath}" points at ${target}, which the build did not emit`, + ); + } +}); + +/** + * Published entrypoints no file outside this package imports today. Every one + * of them predates this change, so retiring them is a separate compatibility + * decision. The assertion is exact in both directions — gaining a consumer + * means removing the entry, and publishing a *new* consumer-less entrypoint + * fails outright, which is the direction this guard exists to hold. + * + * `./model-call-ledger` is on the list without this change touching it: it was + * already published, and `repairPendingModelCallProjections` lost its last + * caller when `canonical-usage-reader` was rewritten on `main`. It is listed + * here rather than unpublished for the same reason as the rest — retiring a + * subpath that already shipped is a compatibility call of its own. + */ +const PREEXISTING_UNCONSUMED_ENTRYPOINTS = [ + './activation-secret-injector', + './encrypted-file-managed-secret-store', + './managed-secret-store', + './model-call-ledger', + './work-board-store', + './write-queue', +]; + +interface StorageImportScan { + bareImporters: string[]; + subpathImporters: Map; + externalSubpaths: Set; +} + +let storageImportScan: Promise | undefined; + +/** Collects every `@maka/storage` import specifier in the repository's sources, once. */ +function scanStorageImports(): Promise { + storageImportScan ??= runStorageImportScan(); + return storageImportScan; +} + +async function runStorageImportScan(): Promise { + const repoRoot = resolve(packageRoot, '../..'); + const specifierPattern = + /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)['"]@maka\/storage(\/[^'"]*)?['"]/gu; + const sourceExtensions = /\.(?:ts|tsx|mts|cts|js|mjs|cjs)$/u; + const skipped = new Set(['node_modules', 'dist', '.git']); + const scan: StorageImportScan = { + bareImporters: [], + subpathImporters: new Map(), + externalSubpaths: new Set(), + }; + async function walk(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + if (skipped.has(entry.name)) return; + const path = join(directory, entry.name); + if (entry.isDirectory()) return walk(path); + if (!sourceExtensions.test(entry.name)) return; + const source = await readFile(path, 'utf8'); + for (const match of source.matchAll(specifierPattern)) { + if (!match[1]) { + scan.bareImporters.push(path); + continue; + } + const subpath = `.${match[1]}`; + const importers = scan.subpathImporters.get(subpath) ?? []; + importers.push(path); + scan.subpathImporters.set(subpath, importers); + if (relative(packageRoot, path).startsWith('..')) scan.externalSubpaths.add(subpath); + } + }), + ); + } + await Promise.all( + ['packages', 'apps', 'scripts'].map((directory) => walk(join(repoRoot, directory))), + ); + return scan; +} + +test('no source file imports the retired bare specifier', { timeout: 60_000 }, async () => { + const { bareImporters } = await scanStorageImports(); + assert.deepEqual( + bareImporters, + [], + 'bare `@maka/storage` imports resolve to the removed `.` entrypoint and fail at runtime', + ); +}); + +test('published entrypoints and their consumers match exactly', { timeout: 60_000 }, async () => { + const exports = await publishedEntrypoints(); + const { subpathImporters, externalSubpaths } = await scanStorageImports(); + const unpublished = [...subpathImporters.keys()].filter((subpath) => !(subpath in exports)); + assert.deepEqual(unpublished.sort(), [], 'imported subpaths missing from the exports map'); + const unconsumed = Object.keys(exports).filter((subpath) => !externalSubpaths.has(subpath)); + assert.deepEqual( + unconsumed.sort(), + PREEXISTING_UNCONSUMED_ENTRYPOINTS, + 'each published subpath is a compatibility promise — publish it when a consumer exists', + ); +}); + +/** Caps concurrent probe children at `limit`; the outer test runner is already concurrent. */ +async function mapWithConcurrency( + items: Item[], + limit: number, + task: (item: Item) => Promise, +): Promise { + const results: Result[] = new Array(items.length); + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const index = next; + next += 1; + results[index] = await task(items[index]); + } + }), + ); + return results; +} + +test('only the declared entrypoints load node:sqlite', { timeout: 120_000 }, async () => { + const exports = await publishedEntrypoints(); + const results = await mapWithConcurrency( + Object.entries(exports), + 4, + async ([subpath, target]) => ({ + subpath, + sqlite: await loadsSqlite(target), + }), + ); + const actual = results + .filter((entry) => entry.sqlite) + .map((entry) => entry.subpath) + .sort(); + assert.deepEqual(actual, [...SQLITE_BACKED_ENTRYPOINTS].sort()); +}); diff --git a/packages/storage/src/__tests__/session-bundle-canonical-tree.test.ts b/packages/storage/src/__tests__/session-bundle-canonical-tree.test.ts index 195ef8b2dd..b2cec3194b 100644 --- a/packages/storage/src/__tests__/session-bundle-canonical-tree.test.ts +++ b/packages/storage/src/__tests__/session-bundle-canonical-tree.test.ts @@ -25,10 +25,9 @@ import { computeSessionBundleCanonicalTreeDigest, encodeSessionBundleCanonicalTree, SessionBundleCanonicalTreeDigestBuilder, - SessionBundleFileError, type SessionBundleCanonicalTreeEntry, - type Sha256Digest, -} from '../index.js'; +} from '../session-bundle-canonical-tree.js'; +import { SessionBundleFileError, type Sha256Digest } from '../session-bundle-contract.js'; const identityDigest: Sha256Digest = 'sha256:0e9561cfb83d50990a103b3896fe249a11fe27fa28985448187f93ec12116d72'; diff --git a/packages/storage/src/__tests__/session-bundle-contract.test.ts b/packages/storage/src/__tests__/session-bundle-contract.test.ts index 4096df921c..1d95375bbc 100644 --- a/packages/storage/src/__tests__/session-bundle-contract.test.ts +++ b/packages/storage/src/__tests__/session-bundle-contract.test.ts @@ -27,7 +27,7 @@ import { SESSION_BUNDLE_LIMIT_NAMES, SessionBundleFileError, type SessionBundleLimits, -} from '../index.js'; +} from '../session-bundle-contract.js'; const limits: SessionBundleLimits = { maxCompressedBytes: 0, diff --git a/packages/storage/src/__tests__/session-bundle-file-service.test.ts b/packages/storage/src/__tests__/session-bundle-file-service.test.ts index fc007730ec..849590af67 100644 --- a/packages/storage/src/__tests__/session-bundle-file-service.test.ts +++ b/packages/storage/src/__tests__/session-bundle-file-service.test.ts @@ -41,13 +41,13 @@ import { spawn } from 'node:child_process'; import { afterEach, test } from 'node:test'; import { constants as zlibConstants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'; import { - createSessionBundleFileService, - decodeSessionBundleUstarHeaderV1, - encodeSessionBundleManifestV1, SessionBundleFileError, type SessionBundleArtifact, type SessionBundleLimits, -} from '../index.js'; +} from '../session-bundle-contract.js'; +import { createSessionBundleFileService } from '../session-bundle-file-service.js'; +import { encodeSessionBundleManifestV1 } from '../session-bundle-manifest.js'; +import { decodeSessionBundleUstarHeaderV1 } from '../session-bundle-ustar.js'; const roots: string[] = []; const identityBytes = Buffer.from('{"schemaVersion":1,"makaSessionId":"maka-α"}', 'utf8'); diff --git a/packages/storage/src/__tests__/session-bundle-manifest.test.ts b/packages/storage/src/__tests__/session-bundle-manifest.test.ts index 1b5d0273b1..7f3620069a 100644 --- a/packages/storage/src/__tests__/session-bundle-manifest.test.ts +++ b/packages/storage/src/__tests__/session-bundle-manifest.test.ts @@ -20,11 +20,13 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { - decodeSessionBundleManifestV1, - encodeSessionBundleManifestV1, SessionBundleFileError, type SessionBundleManifestV1, -} from '../index.js'; +} from '../session-bundle-contract.js'; +import { + decodeSessionBundleManifestV1, + encodeSessionBundleManifestV1, +} from '../session-bundle-manifest.js'; const manifest: SessionBundleManifestV1 = { schemaVersion: 1, diff --git a/packages/storage/src/__tests__/session-bundle-ustar.test.ts b/packages/storage/src/__tests__/session-bundle-ustar.test.ts index ff23743fe8..8cbc6a5a77 100644 --- a/packages/storage/src/__tests__/session-bundle-ustar.test.ts +++ b/packages/storage/src/__tests__/session-bundle-ustar.test.ts @@ -20,11 +20,11 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { test } from 'node:test'; +import { SessionBundleFileError } from '../session-bundle-contract.js'; import { decodeSessionBundleUstarHeaderV1, encodeSessionBundleUstarHeaderV1, - SessionBundleFileError, -} from '../index.js'; +} from '../session-bundle-ustar.js'; test('pins the exact canonical USTAR V1 header', () => { const header = Buffer.from( diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts deleted file mode 100644 index 5f3a099322..0000000000 --- a/packages/storage/src/index.ts +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { - SessionNotFoundError, - SessionReadMarkerMessageNotFoundError, - EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_RECENT_SESSION_IDS, - EXTERNAL_SESSION_IMPORT_LOOKUP_MAX_SOURCE_IDS, - assertSafeSessionId, - createSessionStore, - createUserMessage, - decodePersistedSessionHeader, - isSafeSessionId, - isSessionNotFoundError, - normalizeSessionHeader, - projectSessionCatalogMessages, -} from './session-store.js'; -export type { - CreateStableSessionRequest, - CreateStableSessionResult, - ExternalSessionImportLookupResult, - ProbeStableSessionCreateResult, - SessionAuthorityStore, - SessionCatalogPageCursor, - SessionCatalogPageResult, - SessionCatalogRecord, - SessionHeaderSnapshot, - SessionStore, - SessionTranscriptPageRequest, - SessionTranscriptMessageLookupRequest, - SessionTranscriptStoragePage, - SessionTranscriptStorageFragment, - StableSessionCreateInput, - UpdateSessionConfigurationRequest, -} from './session-store.js'; -export * from './sqlite-session-metadata-store.js'; -export { - ROOT_TURN_ADMISSION_MAX_CONTENT_BYTES, - ROOT_TURN_ADMISSION_MAX_RECORD_BYTES, - ROOT_TURN_ADMISSION_MAX_SOURCE_MESSAGES, - ROOT_TURN_ADMISSION_SCHEMA_VERSION, - createSqliteAgentRunStore, - normalizeRootTurnAdmissionPayload, -} from './agent-run-store.js'; -export type { - AdmitRootTurnInput, - AdmitRootTurnResult, - ConversationCopyRuntimeEventBatch, - DurableAgentRunStore, - DurableRuntimeEventStore, - ImmutableSteeringMessageProof, - RootTurnAdmission, - RootTurnAdmissionStore, - RootTurnSourceMessage, - RootTurnSourceMessageReceipt, -} from './agent-run-store.js'; -export { createSqliteShellRunStore } from './shell-run-store.js'; -export type { ClosableShellRunStore } from './shell-run-store.js'; -export * from './workspace-root.js'; -export { CREDENTIAL_SCHEMA_VERSION, createFileCredentialStore } from './credential-store.js'; -export type { CredentialCasResult, CredentialKind, CredentialStore } from './credential-store.js'; -export * from './settings-store.js'; -export { createSqliteArtifactMetadataRepository } from './sqlite-artifact-metadata.js'; -export { - TelemetryQueryValidationError, - TelemetryRepoClosedError, - TelemetryRepoNotLoadedError, - TelemetryRepoPublicationError, - resolveRange, -} from './telemetry-repo.js'; -export type { - CreateTelemetryRepoOptions, - PersistedLlmCallRecord, - PersistedToolInvocationRecord, - TelemetryRepo, - ToolUsageQuery, -} from './telemetry-repo.js'; -export * from './sqlite-usage-store.js'; -export * from './model-call-ledger.js'; -export * from './usage-stores.js'; -export { - ARTIFACT_BINARY_PREVIEW_LIMIT_BYTES, - ARTIFACT_TEXT_PREVIEW_LIMIT_BYTES, - createSqliteArtifactStore, - isSafeRelativeArtifactPath, - resolveArtifactPath, - sanitizeArtifactName, -} from './artifact-store.js'; -export type { - ArtifactStore, - ArtifactStoreReader, - CreateArtifactInput, - DurableArtifactAttachmentReader, - DurableArtifactBinaryReadResult, -} from './artifact-store.js'; -export * from './artifact-attachments.js'; -export * from './provider-request-capture-artifact.js'; -export { applyPlanEvent, createSqlitePlanStore } from './plan-store.js'; -export type { - CreatePlanStoreOptions, - CreateSqlitePlanStoreOptions, - SqlitePlanStore, -} from './plan-store.js'; -export * from './plan-authority.js'; -export { createSqliteTaskLedgerStore } from './task-ledger-store.js'; -export type { - ConversationTaskLedgerCopyInput, - SqliteTaskLedgerStore, - TaskLedgerAuthorityStore, - TaskLedgerStore, -} from './task-ledger-store.js'; -export * from './foreign-session-store.js'; -export { createWorkBoardStore, WorkBoardStoreError } from './work-board-store.js'; -export type { - WorkBoardMutationOptions, - WorkBoardStore, - WorkBoardStoreErrorCode, -} from './work-board-store.js'; -export { createSqliteDeepResearchStore } from './deep-research-store.js'; -export type { - CreateDeepResearchStoreOptions, - CreateSqliteDeepResearchStoreOptions, - DeepResearchStore, - SqliteDeepResearchStore, -} from './deep-research-store.js'; -export { - authenticateInteractiveDeepResearchStoreWriter, - openInteractiveDeepResearchStoreForWrite, -} from './deep-research-authority.js'; -export type { InteractiveDeepResearchStoreWriter } from './deep-research-authority.js'; -export * from './config-transfer.js'; -export * from './daily-review-authority.js'; -export * from './sqlite-runtime-store.js'; -export * from './runtime-event-persistence.js'; -export { - acquireOperationalStateDatabase, - OPERATIONAL_STATE_DATABASE_NAME, - OPERATIONAL_STATE_SCHEMA_VERSION, - resolveOperationalStateDatabasePath, -} from './operational-state-store.js'; -export type { - OperationalStateDatabaseLease, - OperationalStateDatabaseOptions, -} from './operational-state-store.js'; -export * from './operational-state-backup.js'; -export * from './mcp-config-store.js'; -export * from './workspace-identity.js'; -export * from './memory-bundle-store.js'; -export * from './storage-writer-composition.js'; -export * from './long-term-memory-store.js'; -export * from './project-catalog.js'; -export * from './project-catalog-authority.js'; -export * from './git-worktree-child-executor.js'; -export * from './managed-workspace-owner.js'; -export * from './pet-pack-store.js'; -export * from './session-bundle-policy.js'; -export * from './session-bundle-contract.js'; -export * from './session-bundle-manifest.js'; -export * from './session-bundle-canonical-tree.js'; -export * from './session-bundle-ustar.js'; -export * from './session-bundle-file-service.js'; -export * from './managed-secret-store.js'; -export * from './activation-secret-injector.js'; -export * from './encrypted-file-managed-secret-store.js'; diff --git a/packages/storage/src/operational-state-store-public.ts b/packages/storage/src/operational-state-store-public.ts new file mode 100644 index 0000000000..1fd6c8b6d9 --- /dev/null +++ b/packages/storage/src/operational-state-store-public.ts @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Published operational-state-store surface. + * + * The owning module also exports the schema-migration internals; those run + * against a caller-supplied database and stay package-private, exactly as the + * deleted barrel kept them. + */ +export { + acquireOperationalStateDatabase, + OPERATIONAL_STATE_DATABASE_NAME, + OPERATIONAL_STATE_SCHEMA_VERSION, + resolveOperationalStateDatabasePath, +} from './operational-state-store.js'; +export type { + OperationalStateDatabaseLease, + OperationalStateDatabaseOptions, +} from './operational-state-store.js'; diff --git a/scripts/computer-use/real-model.mjs b/scripts/computer-use/real-model.mjs index d8d1ec4068..67c2d20d43 100644 --- a/scripts/computer-use/real-model.mjs +++ b/scripts/computer-use/real-model.mjs @@ -29,7 +29,7 @@ import { fileURLToPath } from 'node:url'; import { evaluateCuE2eScenarioState, getCuE2eScenario } from './e2e-scenarios.mjs'; import { validateRealReport } from './provider-matrix.mjs'; import { sanitizeCuActionRecord, sanitizeCuReport, sanitizeCuTrace } from './report-sanitize.mjs'; -import { createSqliteAgentRunStore } from '../../packages/storage/dist/index.js'; +import { createSqliteAgentRunStore } from '../../packages/storage/dist/agent-run-store.js'; import { resolveStorageRoot, tryAcquireInteractiveRootOwner, diff --git a/scripts/release-cli-file-policy.test.mjs b/scripts/release-cli-file-policy.test.mjs index 52d3503c08..08db8bf2b2 100644 --- a/scripts/release-cli-file-policy.test.mjs +++ b/scripts/release-cli-file-policy.test.mjs @@ -18,9 +18,9 @@ */ import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { describe, test } from 'node:test'; import { collectWorkspaceDependencyClosure, @@ -148,3 +148,61 @@ describe('CLI release file policy', () => { assert.equal(isThirdPartyDevelopmentArtifact('dist/test-only/index.js'), false); }); }); + +describe('script imports of workspace build output', () => { + // Scripts load built files by path — installed under node_modules or straight + // out of packages/*/dist — so neither the export maps nor the typechecker + // notice when a target module stops being emitted. This walks every script + // and asserts each such literal names a file the build still produces. The + // dist trees it asserts against are kept fresh by check:stale, which runs + // ahead of this suite in check:release. + test('every workspace dist module a script references by path still exists', () => { + const repoRoot = resolve(import.meta.dirname, '..'); + const workspaceDirByPackageName = new Map( + readdirSync(join(repoRoot, 'packages')).flatMap((directory) => { + const manifestPath = join(repoRoot, 'packages', directory, 'package.json'); + if (!existsSync(manifestPath)) return []; + return [[JSON.parse(readFileSync(manifestPath, 'utf8')).name, directory]]; + }), + ); + const scriptPaths = []; + const collectScripts = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) collectScripts(path); + else if (/\.(?:mjs|cjs|js)$/u.test(entry.name)) scriptPaths.push(path); + } + }; + collectScripts(join(repoRoot, 'scripts')); + const targets = new Map(); + for (const scriptPath of scriptPaths) { + const source = readFileSync(scriptPath, 'utf8'); + for (const [literal, packageName, distPath] of source.matchAll( + /['"`]node_modules\/(@maka\/[^/'"`]+)\/(dist\/[^'"`]+\.js)['"`]/gu, + )) { + const workspaceDir = workspaceDirByPackageName.get(packageName); + assert.ok(workspaceDir, `${literal} in ${scriptPath} names an unknown workspace package`); + targets.set( + join(repoRoot, 'packages', workspaceDir, distPath), + `${scriptPath}: ${literal}`, + ); + } + for (const [literal, workspaceDir, distPath] of source.matchAll( + /['"`](?:\.\.\/)*packages\/([^/'"`]+)\/(dist\/[^'"`]+\.js)['"`]/gu, + )) { + assert.ok( + existsSync(join(repoRoot, 'packages', workspaceDir)), + `${literal} in ${scriptPath} names an unknown workspace directory`, + ); + targets.set( + join(repoRoot, 'packages', workspaceDir, distPath), + `${scriptPath}: ${literal}`, + ); + } + } + assert.ok(targets.size > 0, 'expected scripts to reference workspace dist files'); + for (const [builtPath, reference] of targets) { + assert.ok(existsSync(builtPath), `${reference} names a module the build no longer emits`); + } + }); +}); diff --git a/scripts/smoke-release-cli-package.mjs b/scripts/smoke-release-cli-package.mjs index a05401fd9f..70202b0301 100644 --- a/scripts/smoke-release-cli-package.mjs +++ b/scripts/smoke-release-cli-package.mjs @@ -716,7 +716,10 @@ async function readRequestBody(request) { } async function resolveInstalledDataRoots(packageRoot, environment, home) { - const storage = await importInstalled(packageRoot, 'node_modules/@maka/storage/dist/index.js'); + const storage = await importInstalled( + packageRoot, + 'node_modules/@maka/storage/dist/workspace-root.js', + ); return storage.resolveMakaDataRoots({ env: environment, homeDir: home, profileName: 'Maka' }); } From f37f5c3f299f2ffba490cf436bf57da24e7b2f2c Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Mon, 24 Aug 2026 20:28:41 +0800 Subject: [PATCH 006/386] fix(desktop): snapshot linked children for side conversations (#3669) * fix(desktop): snapshot linked children for side conversations Closes #3654 Generated-by: Codex * refactor(runtime): reuse snapshot artifact mapping Generated-by: Codex * fix(desktop): keep side conversation retries quiet Generated-by: Codex * fix(side-chat): keep source execution independent Avoid restarting eager setup when the source Session projection refreshes. Allow Side Conversations to snapshot a settled boundary while newer source work continues, while still waiting for retained child and Graph state by exact identity. Generated-by: Codex * fix(runtime): preserve owned runs in side snapshots Generated-by: Codex * refactor(side-chat): address review follow-ups Generated-by: Codex --- .../__tests__/quote-companion-cleanup.test.ts | 43 ++- .../quote-companion-disposal.test.ts | 23 +- .../__tests__/quote-companion-retry.test.ts | 261 ++++++++++++++ .../__tests__/runtime-host-client-uds.test.ts | 2 + .../runtime-host-desktop-candidate.test.ts | 2 + ...host-session-catalog-running-turns.test.ts | 2 + ...me-host-session-execution-ipc-main.test.ts | 85 ++++- .../src/main/quote-companion-cleanup.ts | 19 +- .../main/runtime-host-desktop-candidate.ts | 4 +- ...runtime-host-session-execution-ipc-main.ts | 66 ++-- apps/desktop/src/preload/bridge-contract.d.ts | 13 +- apps/desktop/src/preload/preload.ts | 37 +- .../src/renderer/features/workbar/ports.ts | 9 +- .../src/renderer/features/workbar/testing.ts | 2 + .../tools/side-chat/quote-companion-core.ts | 12 +- .../tools/side-chat/use-quote-companion.ts | 79 ++++- .../src/renderer/locales/conversation-copy.ts | 9 + .../desktop/create-workbar-services.ts | 1 + .../stories/session-workbar.stories.tsx | 3 +- packages/core/src/session.ts | 5 +- .../src/__tests__/protocol.test.ts | 7 + .../session-revision-graph-references.test.ts | 95 +++++- .../session-revision-protocol.test.ts | 57 ++++ .../session-revision-two-client-uds.test.ts | 228 +++++++++++++ packages/runtime-host/src/protocol/index.ts | 4 +- .../src/protocol/session-revision.ts | 35 +- .../server/session-revision-coordinator.ts | 134 ++++++-- .../session-revision-graph-references.ts | 100 ++++-- .../src/__tests__/conversation-copy.test.ts | 322 ++++++++++++++++++ packages/runtime/src/conversation-copy.ts | 127 ++++++- .../src/__tests__/artifact-store.test.ts | 57 ++++ .../__tests__/task-ledger-authority.test.ts | 92 +++++ packages/storage/src/artifact-store.ts | 42 ++- packages/storage/src/artifact-stores.ts | 15 + .../storage/src/session-conversation-copy.ts | 1 + packages/storage/src/task-ledger-authority.ts | 1 + packages/storage/src/task-ledger-store.ts | 51 ++- 37 files changed, 1868 insertions(+), 177 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/quote-companion-retry.test.ts diff --git a/apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts b/apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts index 90a268e9f6..0c9b9cac64 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-cleanup.test.ts @@ -38,6 +38,44 @@ afterEach(async () => { }); describe('quote companion cleanup authority', () => { + it('forgets a known rejected creation without trying to resume or remove it', async () => { + const workspaceRoot = await createWorkspace(); + let resumes = 0; + let removals = 0; + const authority = createSessionCopyCleanupAuthority({ + workspaceRoot, + resumeSessionCopy: async () => { + resumes += 1; + }, + removeSession: async () => { + removals += 1; + }, + }); + const creation = { + sessionId: 'fork-rejected', + kind: 'branch' as const, + sourceSessionId: 'source-session', + sourceTurnId: 'source-turn', + intent: 'side_conversation' as const, + ownerId: 'web-contents:1', + }; + + await assert.rejects( + authority.ownCreation(creation, async () => { + throw new Error('session busy'); + }), + /session busy/, + ); + assert.deepEqual(await readPendingIds(workspaceRoot), ['fork-rejected']); + + await authority.rejectCreation('fork-rejected'); + + assert.deepEqual(await readPendingIds(workspaceRoot), []); + assert.equal(resumes, 0); + assert.equal(removals, 0); + assert.equal(await authority.ownCreation(creation, async () => 'retried'), 'retried'); + }); + it('releases a rejected creation lease so the same identity can retry', async () => { const workspaceRoot = await createWorkspace(); let removalFails = true; @@ -124,6 +162,7 @@ describe('quote companion cleanup authority', () => { kind: 'branch', sourceSessionId: 'source-session', sourceTurnId: 'source-turn', + intent: 'side_conversation', ownerId: 'web-contents:2', }, async () => { @@ -138,7 +177,7 @@ describe('quote companion cleanup authority', () => { workspaceRoot, processId: 'process-after-crash', resumeSessionCopy: async (creation) => { - events.push(`resume:${creation.sessionId}:${creation.sourceTurnId}`); + events.push(`resume:${creation.sessionId}:${creation.sourceTurnId}:${creation.intent}`); }, removeSession: async (sessionId) => { events.push(`remove:${sessionId}`); @@ -150,7 +189,7 @@ describe('quote companion cleanup authority', () => { failed: [], }); assert.deepEqual(events, [ - 'resume:fork-unknown-create:source-turn', + 'resume:fork-unknown-create:source-turn:side_conversation', 'remove:fork-unknown-create', ]); assert.deepEqual(await readPendingIds(workspaceRoot), []); diff --git a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts index 6bebbd8a21..8553509e2d 100644 --- a/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts +++ b/apps/desktop/src/main/__tests__/quote-companion-disposal.test.ts @@ -23,6 +23,7 @@ import type { SessionSummary, TurnRecord } from '@maka/core/session'; import { abandonPendingCompanionCopy, createFakeWorkbarServices, + ensureCompanionFork, performCompanionTurn, type PerformCompanionTurnDeps, type WorkbarServices, @@ -92,6 +93,26 @@ afterEach(async () => { }); describe('quote companion disposal fencing', () => { + it('preserves a retryable busy reason from Side Conversation creation', async () => { + const defaults = createFakeWorkbarServices(); + const sideChat = { + ...defaults.sideChat, + listTurns: async () => [settledTurn('source-turn')], + branchFromTurn: async () => ({ ok: false as const, reason: 'session_busy' as const }), + }; + + assert.deepEqual( + await ensureCompanionFork({ + api: sideChat, + sourceSession, + panelId, + name: 'Side chat', + isDisposed: () => false, + }), + { status: 'error', code: 'fork_source_busy' }, + ); + }); + it('does not start a send when the panel was disposed after fork setup', async () => { let sends = 0; let armed = 0; @@ -152,7 +173,7 @@ describe('quote companion disposal fencing', () => { const sideChat = { ...defaults.sideChat, listTurns: async () => [settledTurn('source-turn')], - branchFromTurn: () => pendingFork.promise, + branchFromTurn: async () => ({ ok: true as const, session: await pendingFork.promise }), cleanupSessionCopy: async (sessionId: string) => { cleaned.push(sessionId); }, diff --git a/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts new file mode 100644 index 0000000000..4070741470 --- /dev/null +++ b/apps/desktop/src/main/__tests__/quote-companion-retry.test.ts @@ -0,0 +1,261 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { SessionChangedEvent, SessionSummary, TurnRecord } from '@maka/core/session'; +import { + createFakeWorkbarServices, + useQuoteCompanion, + WorkbarServicesProvider, + type WorkbarServices, +} from '../../renderer/features/workbar/testing.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + Event: globalThis.Event, + Node: globalThis.Node, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; +const SOURCE_SESSION = session('source-session'); + +afterEach(async () => { + if (mountedRoot) { + await act(async () => { + mountedRoot?.unmount(); + await Promise.resolve(); + }); + } + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +test('retries a busy Side Conversation at the newest settled boundary and clears its banner', async () => { + const parsed = parseHTML('

'); + const { document, window } = parsed; + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + let listCount = 0; + let sessionChange: ((event: SessionChangedEvent) => void) | undefined; + let releaseRetry: (() => void) | undefined; + const branchInputs: Array<{ sourceTurnId: string; copyId: string }> = []; + const defaults = createFakeWorkbarServices(); + const services: WorkbarServices = { + ...defaults, + sideChat: { + ...defaults.sideChat, + listTurns: async () => { + listCount += 1; + return listCount === 1 + ? [settledTurn('turn-before-busy')] + : [settledTurn('turn-before-busy'), settledTurn('turn-after-busy')]; + }, + branchFromTurn: async (_sessionId, input) => { + branchInputs.push({ sourceTurnId: input.sourceTurnId, copyId: input.copyId }); + if (branchInputs.length === 1) { + return { ok: false as const, reason: 'session_busy' as const }; + } + await new Promise((resolve) => { + releaseRetry = resolve; + }); + return { ok: true as const, session: session('side-conversation') }; + }, + subscribeSessionChanges: (handler) => { + sessionChange = handler; + return () => { + if (sessionChange === handler) sessionChange = undefined; + }; + }, + }, + }; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + await act(async () => { + root.render( + createElement(WorkbarServicesProvider, { + services, + children: createElement(QuoteCompanionProbe), + }), + ); + await Promise.resolve(); + }); + await waitUntil(() => branchInputs.length === 1 && sessionChange !== undefined); + assert.match(container.textContent, /main conversation or a linked task is still running/i); + const probe = container.firstElementChild; + assert.ok(probe); + + await act(async () => { + sessionChange?.({ + reason: 'turn-status-change', + sessionId: 'source-session', + turnId: 'turn-after-busy', + ts: Date.now(), + }); + await Promise.resolve(); + }); + await waitUntil(() => branchInputs.length === 2 && releaseRetry !== undefined); + assert.equal(probe.getAttribute('data-preparing'), 'false'); + assert.match(container.textContent, /main conversation or a linked task is still running/i); + + await act(async () => { + releaseRetry?.(); + await Promise.resolve(); + }); + await waitUntil( + () => probe.getAttribute('data-companion-id') === 'side-conversation', + () => + `branch inputs: ${JSON.stringify(branchInputs)}; companion: ${probe.getAttribute('data-companion-id')}; error: ${probe.getAttribute('data-error')}`, + ); + + assert.deepEqual( + branchInputs.map(({ sourceTurnId }) => sourceTurnId), + ['turn-before-busy', 'turn-after-busy'], + ); + assert.notEqual(branchInputs[0]?.copyId, branchInputs[1]?.copyId); + assert.equal(probe.getAttribute('data-error'), ''); +}); + +test('does not restart foreground setup when the source Session object refreshes', async () => { + const parsed = parseHTML('
'); + const { document, window } = parsed; + Object.assign(globalThis, { + document, + window, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, + Node: window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + let branchCount = 0; + const defaults = createFakeWorkbarServices(); + const services: WorkbarServices = { + ...defaults, + sideChat: { + ...defaults.sideChat, + listTurns: async () => [settledTurn('settled-turn')], + branchFromTurn: async () => { + branchCount += 1; + if (branchCount === 1) { + return { ok: false as const, reason: 'session_busy' as const }; + } + return await new Promise(() => undefined); + }, + }, + }; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + + const render = (sourceSession: SessionSummary) => + root.render( + createElement(WorkbarServicesProvider, { + services, + children: createElement(QuoteCompanionProbe, { sourceSession }), + }), + ); + + await act(async () => { + render(session('source-session')); + await Promise.resolve(); + }); + const probe = container.firstElementChild; + assert.ok(probe); + await waitUntil( + () => branchCount === 1 && probe.getAttribute('data-preparing') === 'false', + ); + + await act(async () => { + render(session('source-session')); + await Promise.resolve(); + }); + + assert.equal(branchCount, 1); + assert.equal(probe.getAttribute('data-preparing'), 'false'); +}); + +function QuoteCompanionProbe(props: { sourceSession?: SessionSummary }) { + const companion = useQuoteCompanion({ + panelId: 'retry-panel', + pendingQuotes: [], + sourceSession: props.sourceSession ?? SOURCE_SESSION, + locale: 'en', + onQuotesConsumed: () => undefined, + }); + return createElement('div', { + 'data-error': companion.error ?? '', + 'data-companion-id': companion.companionSession?.id ?? '', + 'data-preparing': String(companion.preparing), + }, companion.error); +} + +function session(id: string): SessionSummary { + return { + id, + name: id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'test', + connectionLocked: false, + model: 'test-model', + permissionMode: 'ask', + }; +} + +function settledTurn(turnId: string): TurnRecord { + return { turnId, status: 'completed', partialOutputRetained: false }; +} + +async function waitUntil(predicate: () => boolean, diagnostics?: () => string): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (predicate()) return; + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + } + assert.fail( + `Timed out waiting for the Side Conversation state${diagnostics ? ` (${diagnostics()})` : ''}`, + ); +} 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 941b9d72e4..887a852605 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 @@ -257,6 +257,7 @@ test('drives the renderer Session catalog facade through real UDS framing', asyn completeComputerUseTurn() {}, createSessionCopyCleanup: () => ({ ownCreation: (_creation, operation) => operation(), + rejectCreation: async () => undefined, cleanup: async () => undefined, schedule: async () => undefined, abandonOwner: async () => undefined, @@ -571,6 +572,7 @@ function ipcHarness() { function unusedSessionCopyCleanup() { return { ownCreation: async (_creation: unknown, operation: () => Promise) => operation(), + async rejectCreation() {}, async cleanup() {}, async schedule() {}, async abandonOwner() {}, 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 a576c9c170..ea2d4b24b7 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 @@ -524,6 +524,7 @@ test('does not release or report a Revision the Host retained during cleanup', a removeSessionCopy = removeSession; return { ownCreation: (_creation, operation) => operation(), + rejectCreation: async () => undefined, cleanup: async () => undefined, schedule: async () => undefined, abandonOwner: async () => undefined, @@ -864,6 +865,7 @@ function deps( completeComputerUseTurn() {}, createSessionCopyCleanup: () => ({ ownCreation: (_creation, operation) => operation(), + rejectCreation: async () => undefined, cleanup: async () => undefined, schedule: async () => undefined, abandonOwner: async () => undefined, diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts index 7d2fca2de3..cba9d1a395 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-catalog-running-turns.test.ts @@ -36,6 +36,7 @@ test('projects observed running Turn identities into renderer Session lists', as emitSessionsChanged() {}, releaseSessionResources() {}, sessionCopyCleanup: { + async rejectCreation() {}, recover: async () => ({ failed: [] }), } as never, }, @@ -76,6 +77,7 @@ test('merges catalog and observed running Turn identities in stable order', asyn emitSessionsChanged() {}, releaseSessionResources() {}, sessionCopyCleanup: { + async rejectCreation() {}, recover: async () => ({ failed: [] }), } as never, }, 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 b03be46a23..a59a82cf65 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 @@ -201,13 +201,15 @@ test("retries committed Branch and Revision copies with the renderer-owned ident assert.equal(committed.size, 3); }); -test("marks Runtime Host Branch copies as side conversations", async () => { +test("sends Side Conversation intent and metadata atomically to Runtime Host", async () => { + const copyInputs: unknown[] = []; const metadataUpdates: unknown[] = []; const abandonedOwners: string[] = []; const backgroundErrors: unknown[] = []; const ipc = ipcHarness(); const sessionCopyCleanup = { ownCreation: (_creation: unknown, operation: () => Promise) => operation(), + async rejectCreation() {}, async cleanup() {}, async schedule() {}, async abandonOwner(ownerId: string) { @@ -221,17 +223,20 @@ test("marks Runtime Host Branch copies as side conversations", async () => { registerExecutionIpc( { client: executionClient({ - copySession: async (_kind, input) => ({ - ...session(), - id: input.targetSessionId, - labels: ["source-label"], - }), + copySession: async (_kind, input) => { + copyInputs.push(input); + return { + ...session(), + id: input.targetSessionId, + labels: ["source-label", SIDE_CONVERSATION_SESSION_LABEL], + }; + }, updateSessionMetadata: async (sessionId, patch) => { metadataUpdates.push({ sessionId, patch }); return { ...session(), id: sessionId, - labels: patch.labels ?? [], + labels: patch.labels ?? ['source-label', SIDE_CONVERSATION_SESSION_LABEL], }; }, }), @@ -247,23 +252,26 @@ test("marks Runtime Host Branch copies as side conversations", async () => { ipc, ); - const branch = (await ipc.invoke("sessions:branchFromTurn", "source-session", { + const branchResult = (await ipc.invoke("sessions:branchFromTurn", "source-session", { sourceTurnId: "source-turn", copyId: "side-copy", name: "Side chat", sideConversation: true, - })) as { labels: string[] }; + })) as { ok: true; session: { labels: string[] } }; - assert.deepEqual(metadataUpdates, [ + assert.deepEqual(copyInputs, [ { - sessionId: "side-copy", - patch: { - name: "Side chat", - labels: ["source-label", SIDE_CONVERSATION_SESSION_LABEL], - }, + sourceSessionId: 'source-session', + targetSessionId: 'side-copy', + sourceTurnId: 'source-turn', + intent: 'side_conversation', }, ]); - assert.deepEqual(branch.labels, [ + assert.deepEqual(metadataUpdates, [ + { sessionId: 'side-copy', patch: { name: 'Side chat' } }, + ]); + assert.equal(branchResult.ok, true); + assert.deepEqual(branchResult.session.labels, [ "source-label", SIDE_CONVERSATION_SESSION_LABEL, ]); @@ -276,6 +284,50 @@ test("marks Runtime Host Branch copies as side conversations", async () => { ]); }); +test('returns structured Side Conversation setup failures across IPC', async () => { + for (const reason of ['session_busy', 'operation_unavailable'] as const) { + const ipc = ipcHarness(); + const rejectedCreations: string[] = []; + registerExecutionIpc( + { + client: executionClient({ + copySession: async () => { + throw new RuntimeHostOperationError( + 'session.branch.create', + reason, + 'Side Conversation setup failed', + ); + }, + }), + observer: unusedObserver(), + attachmentApprovals: createAttachmentApprovalRegistry(), + emitSessionsChanged() {}, + stat: async () => ({ size: 0 }), + resizeImage: async (bytes) => bytes, + beforeStop() {}, + newId: () => 'id-1', + sessionCopyCleanup: { + ...unusedSessionCopyCleanup(), + async rejectCreation(sessionId) { + rejectedCreations.push(sessionId); + }, + }, + }, + ipc, + ); + + assert.deepEqual( + await ipc.invoke('sessions:branchFromTurn', 'source-session', { + sourceTurnId: 'source-turn', + copyId: `side-copy-${reason}`, + sideConversation: true, + }), + { ok: false, reason }, + ); + assert.deepEqual(rejectedCreations, [`side-copy-${reason}`]); + } +}); + test("sends canonical content and uploads owned Attachment bytes through the Host", async () => { const starts: unknown[] = []; const uploads: unknown[] = []; @@ -1089,6 +1141,7 @@ function registerExecutionIpc( function unusedSessionCopyCleanup(): RuntimeHostSessionExecutionIpcDeps['sessionCopyCleanup'] { return { ownCreation: async (_creation, operation) => operation(), + async rejectCreation() {}, async cleanup() {}, async schedule() {}, async abandonOwner() {}, diff --git a/apps/desktop/src/main/quote-companion-cleanup.ts b/apps/desktop/src/main/quote-companion-cleanup.ts index 21f5017c15..ebc8327469 100644 --- a/apps/desktop/src/main/quote-companion-cleanup.ts +++ b/apps/desktop/src/main/quote-companion-cleanup.ts @@ -25,6 +25,7 @@ export interface SessionCopyCreationLease { kind: 'branch' | 'revision'; sourceSessionId: string; sourceTurnId: string; + intent?: 'side_conversation'; ownerId: string; } @@ -61,6 +62,7 @@ export interface SessionCopyCleanupRecovery { export interface SessionCopyCleanupAuthority { ownCreation(creation: SessionCopyCreationLease, operation: () => Promise): Promise; + rejectCreation(sessionId: string): Promise; cleanup(sessionId: string): Promise; schedule(sessionId: string): Promise; abandonOwner(ownerId: string): Promise; @@ -125,6 +127,17 @@ class SessionCopyCleanupAuthorityImpl implements SessionCopyCleanupAuthority { return task; } + async rejectCreation(sessionId: string): Promise { + const normalized = normalizeSessionId(sessionId); + await this.creations.get(normalized)?.operation.catch(() => undefined); + const record = await this.store.read(normalized); + if (!record) return; + if (record.phase !== 'creating') { + throw new Error(`Session copy ${normalized} is no longer awaiting creation`); + } + await this.store.forget(normalized); + } + async cleanup(sessionId: string): Promise { const normalized = normalizeSessionId(sessionId); const active = this.cleanups.get(normalized); @@ -258,6 +271,7 @@ class SqliteSessionCopyCleanupStore implements SessionCopyCleanupStore { kind: creation.kind, sourceSessionId: creation.sourceSessionId, sourceTurnId: creation.sourceTurnId, + ...(creation.intent ? { intent: creation.intent } : {}), }, }; }); @@ -396,6 +410,7 @@ function normalizeCreationLease(creation: SessionCopyCreationLease): SessionCopy kind: creation.kind, sourceSessionId: normalizeSessionId(creation.sourceSessionId), sourceTurnId: normalizeSessionId(creation.sourceTurnId), + ...(creation.intent === 'side_conversation' ? { intent: creation.intent } : {}), ownerId: normalizeOwnerId(creation.ownerId), }; } @@ -409,6 +424,7 @@ function sameCreation( left.kind === right.kind && left.sourceSessionId === right.sourceSessionId && left.sourceTurnId === right.sourceTurnId && + left.intent === right.intent && left.ownerId === right.ownerId ); } @@ -420,7 +436,8 @@ function samePersistedCreation( return ( left.kind === right.kind && left.sourceSessionId === right.sourceSessionId && - left.sourceTurnId === right.sourceTurnId + left.sourceTurnId === right.sourceTurnId && + left.intent === right.intent ); } diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 37396022b1..071e86bd8a 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -130,6 +130,7 @@ export interface DesktopRuntimeHostCandidateDeps { kind: 'branch' | 'revision'; sourceSessionId: string; sourceTurnId: string; + intent?: 'side_conversation'; }) => Promise; }) => SessionCopyCleanupAuthority; readonly registerClientIpc?: ( @@ -638,11 +639,12 @@ export async function createDesktopRuntimeHostCandidate( emitSessionsChanged("deleted", sessionId); return disposition; }, - resumeSessionCopy: async ({ sessionId, kind, sourceSessionId, sourceTurnId }) => { + resumeSessionCopy: async ({ sessionId, kind, sourceSessionId, sourceTurnId, intent }) => { await client.copySession(kind, { sourceSessionId, targetSessionId: sessionId, sourceTurnId, + ...(intent ? { intent } : {}), }); }, }); diff --git a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts index a6653fedb0..3be6608f6a 100644 --- a/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-execution-ipc-main.ts @@ -26,7 +26,6 @@ import { type SessionChangedEvent, type SessionChangedReason, } from '@maka/core/session'; -import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import { type ActiveInteractionRequestEvent, type AttachmentRef } from '@maka/core/events'; import { type PermissionMode } from '@maka/core/permission'; import { type SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; @@ -60,6 +59,10 @@ import type { DesktopTranscriptRangeRequest } from '../preload/transcript-contra import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { mergeWorkspaceFileInlineReferences } from "./session-workspace-inline-references.js"; +type SideConversationBranchResult = + | { readonly ok: true; readonly session: ReturnType } + | { readonly ok: false; readonly reason: 'session_busy' | 'operation_unavailable' }; + type RuntimeHostSessionExecutionClient = Pick< DesktopRuntimeHostClient, | "answerInteraction" @@ -624,36 +627,47 @@ export function registerRuntimeHostSessionExecutionIpc( sourceSessionId: sessionId, targetSessionId: normalized.copyId, sourceTurnId: normalized.sourceTurnId, + ...(normalized.sideConversation ? { intent: 'side_conversation' as const } : {}), }); - let branch = normalized.sideConversation - ? await deps.sessionCopyCleanup.ownCreation( - { - sessionId: normalized.copyId, - kind: 'branch', - sourceSessionId: sessionId, - sourceTurnId: normalized.sourceTurnId, - ownerId: bindCopyOwner(event), - }, - createBranch, - ) - : await createBranch(); - if (normalized.name || normalized.sideConversation) { + let branch; + try { + branch = normalized.sideConversation + ? await deps.sessionCopyCleanup.ownCreation( + { + sessionId: normalized.copyId, + kind: 'branch', + sourceSessionId: sessionId, + sourceTurnId: normalized.sourceTurnId, + intent: 'side_conversation', + ownerId: bindCopyOwner(event), + }, + createBranch, + ) + : await createBranch(); + } catch (error) { + if ( + normalized.sideConversation && + error instanceof RuntimeHostOperationError && + (error.code === 'session_busy' || error.code === 'operation_unavailable') + ) { + await deps.sessionCopyCleanup.rejectCreation(normalized.copyId); + return { + ok: false, + reason: error.code, + } satisfies SideConversationBranchResult; + } + throw error; + } + if (normalized.name) { branch = await deps.client.updateSessionMetadata(branch.id, { - ...(normalized.name ? { name: normalized.name } : {}), - ...(normalized.sideConversation - ? { - labels: [ - ...new Set([ - ...branch.labels, - SIDE_CONVERSATION_SESSION_LABEL, - ]), - ], - } - : {}), + name: normalized.name, }); } deps.emitSessionsChanged("created", branch.id); - return toDesktopHostSessionSummary(branch); + const summary = toDesktopHostSessionSummary(branch); + return normalized.sideConversation + ? ({ ok: true, session: summary } satisfies SideConversationBranchResult) + : summary; }, ); ipcMain.handle( diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 1c5be02f41..d9615ba464 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -200,6 +200,10 @@ export type DesktopBranchFromTurnInput = BranchFromTurnInput & { copyId: string; }; +export type DesktopSideConversationBranchResult = + | { ok: true; session: DesktopSessionSummary } + | { ok: false; reason: 'session_busy' | 'operation_unavailable' }; + export type DesktopReviseBeforeTurnInput = ReviseBeforeTurnInput & { /** Stable target identity for retrying one Desktop copy action. */ copyId: string; @@ -772,7 +776,14 @@ export interface MakaBridge { | { disposition: 'park'; rejectionReasons: string[]; diagnostics: unknown[] } >; regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise; - branchFromTurn(sessionId: string, input: DesktopBranchFromTurnInput): Promise; + branchFromTurn( + sessionId: string, + input: DesktopBranchFromTurnInput & { sideConversation: true }, + ): Promise; + branchFromTurn( + sessionId: string, + input: DesktopBranchFromTurnInput & { sideConversation?: false }, + ): Promise; reviseBeforeTurn(sessionId: string, input: DesktopReviseBeforeTurnInput): Promise; respondToSandboxBoundary(sessionId: string, response: SandboxBoundaryResponse): Promise; respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 6711fa73c0..e909783bcd 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -30,6 +30,7 @@ import type { PermissionOverlayStartResult, RendererIngestInput, DesktopBranchFromTurnInput, + DesktopSideConversationBranchResult, DesktopReviseBeforeTurnInput, AppUpdateInstallRequest, AppUpdateInstallResult, @@ -608,6 +609,34 @@ async function invokeSessionSummary( return projectSessionSummary(session.scope, summary); } +async function invokeBranchFromTurn( + sessionId: string, + input: DesktopBranchFromTurnInput & { sideConversation: true }, +): Promise; +async function invokeBranchFromTurn( + sessionId: string, + input: DesktopBranchFromTurnInput & { sideConversation?: false }, +): Promise; +async function invokeBranchFromTurn( + sessionId: string, + input: DesktopBranchFromTurnInput, +): Promise { + const ref = await runtimeHostSessionRef(sessionId); + const result = await ipcRenderer.invoke( + 'sessions:branchFromTurn', + ref.scope, + ref.sessionId, + input, + ) as SessionSummary | { ok: true; session: SessionSummary } | { ok: false; reason: string }; + if (input.sideConversation) { + if (!('ok' in result) || result.ok === false) { + return result as DesktopSideConversationBranchResult; + } + return { ok: true, session: projectSessionSummary(ref.scope, result.session) }; + } + return projectSessionSummary(ref.scope, result as SessionSummary); +} + async function invokeSessionInput( channel: string, input: I, @@ -1619,13 +1648,7 @@ const makaBridge = { regenerateTurn(sessionId: string, input: RegenerateTurnInput): Promise { return invokeSessionRuntimeHost('sessions:regenerateTurn', sessionId, input); }, - async branchFromTurn(sessionId: string, input: DesktopBranchFromTurnInput): Promise { - const ref = await runtimeHostSessionRef(sessionId); - const summary = await ipcRenderer.invoke( - 'sessions:branchFromTurn', ref.scope, ref.sessionId, input, - ) as SessionSummary; - return projectSessionSummary(ref.scope, summary); - }, + branchFromTurn: invokeBranchFromTurn, async reviseBeforeTurn(sessionId: string, input: DesktopReviseBeforeTurnInput): Promise { const ref = await runtimeHostSessionRef(sessionId); const summary = await ipcRenderer.invoke( diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index ca959adc65..fb14676742 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -36,6 +36,7 @@ import type { PermissionMode } from '@maka/core/permission'; import type { RegenerateTurnInput } from '@maka/core/runtime-inputs'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { + SessionChangedEvent, SessionSummary, StoredMessage, TurnRecord, @@ -212,9 +213,12 @@ export interface SideChatSessionPort { sourceTurnId: string; name?: string; copyId: string; - sideConversation?: boolean; + sideConversation: true; }, - ): Promise; + ): Promise< + | { ok: true; session: SessionSummary } + | { ok: false; reason: 'session_busy' | 'operation_unavailable' } + >; cleanupSessionCopy(sessionId: string): Promise; abandonSessionCopy(sourceSessionId: string, copyId: string): Promise; send( @@ -246,6 +250,7 @@ export interface SideChatSessionPort { sessionId: string, handler: (event: SessionEvent) => void, ): WorkbarUnsubscribe; + subscribeSessionChanges(handler: (event: SessionChangedEvent) => void): WorkbarUnsubscribe; } export interface WorkbarServices { diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 576cd77fde..f2e3e940b5 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -37,6 +37,7 @@ export * from './tools/inspector/session-inspector-overview-model.js'; export * from './tools/side-chat/quote-companion-panel-state.js'; export * from './tools/side-chat/quote-companion-core.js'; export * from './tools/side-chat/quote-companion-visibility.js'; +export { useQuoteCompanion } from './tools/side-chat/use-quote-companion.js'; export * from './tools/terminal/session-terminal-hydration.js'; export * from './tools/terminal/session-terminal-query.js'; export * from './tools/terminal/session-terminal-frame.js'; @@ -136,6 +137,7 @@ export function createFakeWorkbarServices( respondToSandboxBoundary: async () => undefined, respondToUserQuestion: async () => undefined, subscribeEvents: noopSubscription, + subscribeSessionChanges: noopSubscription, }, ...overrides, }; diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts index ec5897b7cc..dceb846301 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-core.ts @@ -50,6 +50,8 @@ import { sessionEventErrorMessage } from '../../../../model-connection-errors.js * `{ok:false}`. */ export type CompanionErrorCode = | 'fork_setup_failed' + | 'fork_source_busy' + | 'fork_unsupported' | 'send_failed' | 'send_rejected'; @@ -247,12 +249,20 @@ export async function ensureCompanionFork( ) { return { status: 'error', code: 'fork_setup_failed' }; } - created = await api.branchFromTurn(sourceSession.id, { + const result = await api.branchFromTurn(sourceSession.id, { sourceTurnId: copyAttempt.sourceTurnId, name, copyId: copyAttempt.copyId, sideConversation: true, }); + if (!result.ok) { + copyAttempt.complete(); + return { + status: 'error', + code: result.reason === 'session_busy' ? 'fork_source_busy' : 'fork_unsupported', + }; + } + created = result.session; } catch { return { status: 'error', code: 'fork_setup_failed' }; } diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts index 8ba10b32bb..7c4bcb5be8 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts @@ -51,15 +51,15 @@ import { performCompanionTurn, type CompanionErrorCode, type EnsureCompanionForkResult, -} from './quote-companion-core'; +} from './quote-companion-core.js'; import { mergeSettledMessages } from '../../../../settled-message-merge.js'; import { getDesktopConversationCopy } from '../../../../locales/conversation-copy.js'; import { snapshotCompanionQuotes, type CompanionQuoteSnapshot, type StagedCompanionQuote, -} from './quote-companion-panel-state'; -import type { CompanionForkVisibilityEvent } from './quote-companion-visibility'; +} from './quote-companion-panel-state.js'; +import type { CompanionForkVisibilityEvent } from './quote-companion-visibility.js'; export interface UseQuoteCompanionInput { /** Stable owner for the currently mounted panel generation. */ @@ -151,8 +151,11 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan // A created fork is hidden immediately, but is not considered usable until // onForkCommitted promotes it. const pendingForkIdRef = useRef(null); + const sourceSessionRef = useRef(sourceSession); + sourceSessionRef.current = sourceSession; + const sourceSessionId = sourceSession?.id; const sourceSessionIdRef = useRef(sourceSession?.id); - sourceSessionIdRef.current = sourceSession?.id; + sourceSessionIdRef.current = sourceSessionId; const forkSetupPromiseRef = useRef | null>(null); const stopRequestedRef = useRef(false); const activeTurnIdRef = useRef(null); @@ -178,6 +181,7 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ); const [hasContent, setHasContent] = useState(false); const [error, setError] = useState(null); + const [forkRetryPending, setForkRetryPending] = useState(false); // Bumped whenever the own-turn set changes so the render picks up the new // filter result (the set lives in a ref to stay stable for the event handler). const [, setOwnTurnTick] = useState(0); @@ -264,18 +268,23 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan ); const ensureFork = useCallback( - (name: string): Promise => { + ( + name: string, + options: { readonly showPreparing?: boolean } = {}, + ): Promise => { const existing = companionRef.current; if (existing) return Promise.resolve({ status: 'ready', session: existing }); if (forkSetupPromiseRef.current) return forkSetupPromiseRef.current; - if (!sourceSession) { + const currentSourceSession = sourceSessionRef.current; + if (!currentSourceSession) { return Promise.resolve({ status: 'error', code: 'fork_setup_failed' }); } - setPreparing(true); + const showPreparing = options.showPreparing ?? true; + if (showPreparing) setPreparing(true); const promise = ensureCompanionFork({ api: sideChat, - sourceSession, + sourceSession: currentSourceSession, panelId, name, isDisposed: () => !mountedRef.current, @@ -298,25 +307,67 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan }) .then((result) => { if (result.status === 'ready' && mountedRef.current) { + setForkRetryPending(false); + setError(null); commitFork(result.session); } else if (result.status === 'error' && mountedRef.current) { - setError(copyRef.current.errors.forkSetupFailed); + setForkRetryPending(result.code === 'fork_source_busy'); + const errors = copyRef.current.errors; + setError( + result.code === 'fork_source_busy' + ? errors.forkSourceBusy + : result.code === 'fork_unsupported' + ? errors.forkUnsupported + : errors.forkSetupFailed, + ); } return result; }) .finally(() => { forkSetupPromiseRef.current = null; - if (mountedRef.current) setPreparing(false); + if (showPreparing && mountedRef.current) setPreparing(false); }); forkSetupPromiseRef.current = promise; return promise; }, - [commitFork, mountedRef, panelId, sideChat, sourceSession], + [commitFork, mountedRef, panelId, sideChat], ); useEffect(() => { - if (sourceSession) void ensureFork(copyRef.current.defaultName); - }, [ensureFork, sourceSession]); + if (sourceSessionId) void ensureFork(copyRef.current.defaultName); + }, [ensureFork, sourceSessionId]); + + useEffect(() => { + if (!sourceSessionId || !forkRetryPending) return; + let retrying = false; + const retry = () => { + if (retrying || !mountedRef.current || companionRef.current) return; + retrying = true; + const currentSetup = forkSetupPromiseRef.current; + void (async () => { + if (currentSetup) await currentSetup; + if (!mountedRef.current || companionRef.current) return; + await ensureFork(copyRef.current.defaultName, { showPreparing: false }); + })().finally(() => { + retrying = false; + }); + }; + const unsubscribe = sideChat.subscribeSessionChanges((event) => { + if ( + event.sessionId === sourceSessionId && + (event.reason === 'turn-status-change' || + event.reason === 'status-change' || + event.reason === 'message-appended') + ) { + retry(); + } + }); + const retryTimer = globalThis.setInterval(retry, 2_000); + return () => { + globalThis.clearInterval(retryTimer); + unsubscribe(); + }; + }, [ensureFork, forkRetryPending, mountedRef, sideChat, sourceSessionId]); // The fork is ephemeral (用完即弃): when the panel is dismissed — 退出, // switching source session — unsubscribe and remove the fork so it never @@ -427,6 +478,8 @@ export function useQuoteCompanion(input: UseQuoteCompanionInput): UseQuoteCompan const errors = copyRef.current.errors; const byCode: Record = { fork_setup_failed: errors.forkSetupFailed, + fork_source_busy: errors.forkSourceBusy, + fork_unsupported: errors.forkUnsupported, send_failed: errors.sendFailed, send_rejected: errors.sendRejected, }; diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 9fc6c9f9a8..f4ad6a5e3e 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -280,6 +280,10 @@ export interface DesktopConversationCopy { errors: { /** Reading the source boundary or creating the companion fork failed. */ forkSetupFailed: string; + /** The source or one of its linked child runs is still active. */ + forkSourceBusy: string; + /** The retained source context cannot be represented safely. */ + forkUnsupported: string; /** `sessions.send` was rejected without throwing (e.g. an unresolved skill). */ sendRejected: string; /** `sessions.send` threw / the turn could not be started. */ @@ -558,6 +562,8 @@ const COPY = { }, errors: { forkSetupFailed: '无法创建侧边对话,请稍后重试。', + forkSourceBusy: '主对话或子任务仍在运行,请等待完成后重试。', + forkUnsupported: '当前对话上下文暂不支持创建侧边对话。', sendRejected: '追问未能开始,请稍后重试。', sendFailed: '追问失败,请稍后重试。', settlementFailed: '运行已结束,但消息加载失败。请重试或重新打开侧边对话。', @@ -761,6 +767,9 @@ const COPY = { }, errors: { forkSetupFailed: 'Could not open the side chat. Please try again.', + forkSourceBusy: + 'The main conversation or a linked task is still running. Try again when it finishes.', + forkUnsupported: 'This conversation context cannot be opened as a side chat yet.', sendRejected: 'The companion could not start. Please try again.', sendFailed: 'The companion request failed. Please try again.', settlementFailed: 'The run ended, but its messages could not be loaded. Retry or reopen the side chat.', diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 3f22d6e2c9..86c9d0ef00 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -132,6 +132,7 @@ export function createDesktopWorkbarServices( bridge.sessions.respondToUserQuestion(sessionId, response), subscribeEvents: (sessionId, handler) => bridge.sessions.subscribeEvents(sessionId, handler), + subscribeSessionChanges: (handler) => bridge.sessions.subscribeChanges(handler), }, }; } diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index f2f6d14fd6..600fd6ebfa 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -706,7 +706,7 @@ function bridge(options: { }, ], readSettledMessages: async () => ({ messages: [], settled: true }), - branchFromTurn: async () => SIDE_CHAT_SESSION, + branchFromTurn: async () => ({ ok: true, session: SIDE_CHAT_SESSION }), cleanupSessionCopy: async () => undefined, abandonSessionCopy: async () => undefined, send: async () => ({ ok: true }), @@ -720,6 +720,7 @@ function bridge(options: { respondToSandboxBoundary: async () => undefined, respondToUserQuestion: async () => undefined, subscribeEvents: unsubscribe, + subscribeSessionChanges: unsubscribe, }, }); return (Story) => ( diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 48b545754f..b1dd1e694c 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -158,6 +158,7 @@ export interface SessionConversationCopy { sourceTurnId: string; requestFingerprint: `sha256:${string}`; state: 'preparing' | 'committed'; + intent?: 'side_conversation'; } export type SubagentSessionRuntimeSummary = Omit< @@ -452,7 +453,7 @@ const SUBAGENT_SESSION_SPAWN_IDENTITY_SHAPE = defineObjectShape()( ['kind', 'sourceSessionId', 'sourceTurnId', 'requestFingerprint', 'state'], - [], + ['intent'], ); const SESSION_LINEAGE_ID_MAX_CHARS = 512; const SESSION_LINEAGE_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; @@ -551,6 +552,8 @@ export function isSessionConversationCopy(value: unknown): value is SessionConve isRecord(value) && hasExactShape(value, SESSION_CONVERSATION_COPY_SHAPE) && (value.kind === 'branch' || value.kind === 'revision') && + (value.intent === undefined || + (value.kind === 'branch' && value.intent === 'side_conversation')) && isSessionLineageId(value.sourceSessionId) && isSessionLineageId(value.sourceTurnId) && typeof value.requestFingerprint === 'string' && diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 6726905f71..c4f2fad83b 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -182,6 +182,13 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 46); }); + test('publishes a new compatibility epoch for Side Conversation copy intent', () => { + // Epoch 47 belongs to project registration preferences on current main. + // Side Conversation adds another closed branch-copy input and therefore + // needs its own later handshake boundary. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 47); + }); + test('adds credential rotation without changing existing credential inputs', () => { const issueInput = { principalKind: 'remote_owner', diff --git a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts index fa4aab83e2..1c61f354ba 100644 --- a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts @@ -72,6 +72,95 @@ test('Agent Graph revision references preserve only exact terminal provenance', assert.equal(archived.ok, true); }); +test('Side Conversation references accept terminal linked children as snapshots', async () => { + const accepted = await prepare({ kind: 'side_conversation' }); + assert.equal(accepted.ok, true); + if (!accepted.ok) assert.fail('Expected accepted Side Conversation references'); + assert.deepEqual([...accepted.references.keys()], [CHILD_SESSION_ID]); +}); + +test('Side Conversation references accept terminal non-Graph child Sessions as snapshots', async () => { + const accepted = await prepare({ + kind: 'side_conversation', + messages: [linkedSubagentResult('completed')], + sessionHeaders: [sessionHeader(ROOT_SESSION_ID), childHeader({ graph: false })], + }); + assert.equal(accepted.ok, true); + if (!accepted.ok) assert.fail('Expected accepted linked-child snapshot'); + assert.deepEqual([...accepted.references.keys()], [CHILD_SESSION_ID]); +}); + +test('Side Conversation references wait for live Graph and child state', async () => { + for (const input of [ + { graphState: 'live' as const }, + { childActive: true }, + { messages: [linkedSubagentResult('running')] }, + ]) { + const outcome = await prepare({ kind: 'side_conversation', ...input }); + assert.equal(outcome.ok, false); + if (!outcome.ok) assert.equal(outcome.code, 'session_busy'); + } +}); + +test('Side Conversation validates the retained Graph instead of a newer live Graph', async () => { + const sideConversation = await prepare({ + kind: 'side_conversation', + sessionGraphState: 'live', + graphState: 'terminal', + }); + assert.equal(sideConversation.ok, true); + + const revision = await prepare({ + sessionGraphState: 'live', + graphState: 'terminal', + }); + assert.deepEqual(revision, { + ok: false, + code: 'session_busy', + message: 'A retained Agent Graph is not terminal', + }); +}); + +test('Side Conversation rejects a retained child without a terminal result snapshot', async () => { + const outcome = await prepare({ + kind: 'side_conversation', + messages: [], + sessionHeaders: [sessionHeader(ROOT_SESSION_ID), childHeader({ graph: false })], + }); + assert.deepEqual(outcome, { + ok: false, + code: 'operation_unavailable', + message: 'Side Conversation requires a terminal result for every retained linked child', + }); +}); + +test('Side Conversation waits for a live retained child before its result is committed', async () => { + const outcome = await prepare({ + kind: 'side_conversation', + messages: [], + childActive: true, + sessionHeaders: [sessionHeader(ROOT_SESSION_ID), childHeader({ graph: false })], + }); + assert.deepEqual(outcome, { + ok: false, + code: 'session_busy', + message: 'A retained linked child is still active', + }); +}); + +test('Side Conversation waits for a live retained Graph before its result is committed', async () => { + const outcome = await prepare({ + kind: 'side_conversation', + messages: [], + graphState: 'live', + }); + assert.deepEqual(outcome, { + ok: false, + code: 'session_busy', + message: 'A retained Agent Graph is not terminal', + }); +}); + test('Agent Graph revision references reject incomplete or mismatched provenance', async () => { const cases: ReadonlyArray<{ name: string; @@ -228,11 +317,12 @@ test('Agent Graph revision admission includes only retained direct and reference }); interface PrepareOverrides { - readonly kind?: 'branch' | 'revision'; + readonly kind?: 'branch' | 'revision' | 'side_conversation'; readonly messages?: readonly StoredMessage[]; readonly archivedResults?: readonly string[]; readonly sessionHeaders?: readonly SessionHeader[]; readonly runs?: readonly AgentRunHeader[]; + readonly sessionGraphState?: 'absent' | 'live' | 'terminal'; readonly graphState?: 'absent' | 'live' | 'terminal'; readonly artifactTurnId?: string; readonly artifactStatus?: 'live' | 'deleted'; @@ -279,7 +369,8 @@ async function prepare(overrides: PrepareOverrides = {}) { }), }, graph: { - readSessionState: async () => overrides.graphState ?? 'terminal', + readSessionState: async () => + overrides.sessionGraphState ?? overrides.graphState ?? 'terminal', readGraphState: async (_rootSessionId, graphId) => { if (graphId !== agentGraphIdForRootSession(ROOT_SESSION_ID)) { throw new Error('Graph is not bound to this root Session'); diff --git a/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts b/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts index da25a96a24..46c50e0439 100644 --- a/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-protocol.test.ts @@ -28,6 +28,63 @@ import { } from '../protocol/index.js'; describe('Session revision protocol', () => { + test('accepts only the Side Conversation branch intent', () => { + assert.deepEqual( + decodeClientFrame({ + requestId: 'request-side-conversation', + operation: 'session.branch.create', + input: { + sourceSessionId: 'source-session', + targetSessionId: 'target-session', + sourceTurnId: 'turn-1', + expectedSourceRevision: 1, + intent: 'side_conversation', + }, + }), + { + requestId: 'request-side-conversation', + operation: 'session.branch.create', + input: { + sourceSessionId: 'source-session', + targetSessionId: 'target-session', + sourceTurnId: 'turn-1', + expectedSourceRevision: 1, + intent: 'side_conversation', + }, + }, + ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-invalid-purpose', + operation: 'session.branch.create', + input: { + sourceSessionId: 'source-session', + targetSessionId: 'target-session', + sourceTurnId: 'turn-1', + expectedSourceRevision: 1, + intent: 'ordinary', + }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeClientFrame({ + requestId: 'request-revision-purpose', + operation: 'session.revision.create', + input: { + sourceSessionId: 'source-session', + targetSessionId: 'target-session', + sourceTurnId: 'turn-1', + expectedSourceRevision: 1, + intent: 'side_conversation', + }, + }), + isInvalidFrame, + ); + }); + test('rejects aliasing, unknown fields, and mismatched response identities', () => { assert.throws( () => diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts index 20a7ecafa1..5ccf51c26a 100644 --- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts @@ -63,6 +63,10 @@ const ADMITTED_REVISION_TARGET_ID = 'admitted-revision-target'; const LINEAGE_REVISION_TARGET_ID = 'lineage-revision-target'; const LINEAGE_BRANCH_TARGET_ID = 'lineage-branch-target'; const GRAPH_REVISION_TARGET_ID = 'graph-revision-target'; +const GRAPH_SIDE_CONVERSATION_TARGET_ID = 'graph-side-conversation-target'; +const GRAPH_SIDE_CONVERSATION_REMOVAL_TARGET_ID = 'graph-side-conversation-removal-target'; +const ARCHIVED_SIDE_CONVERSATION_TARGET_ID = 'archived-side-conversation-target'; +const ACTIVE_SOURCE_SIDE_CONVERSATION_TARGET_ID = 'active-source-side-conversation-target'; test('two Clients share exact retryable Session branch and revision authority', { skip: process.platform === 'win32' ? 'Windows SQLite shutdown lifecycle' : false, @@ -116,6 +120,9 @@ test('two Clients share exact retryable Session branch and revision authority', LINEAGE_REVISION_TARGET_ID, LINEAGE_BRANCH_TARGET_ID, GRAPH_REVISION_TARGET_ID, + GRAPH_SIDE_CONVERSATION_TARGET_ID, + ARCHIVED_SIDE_CONVERSATION_TARGET_ID, + ACTIVE_SOURCE_SIDE_CONVERSATION_TARGET_ID, graphChildSessionId, ); } finally { @@ -178,6 +185,50 @@ async function verifyConcurrentRevisionAuthority( }), { kind: 'session', session: null }, ); + const sideConversation = await desktop.request('session.branch.create', { + sourceSessionId: linkedChildSourceSessionId, + targetSessionId: GRAPH_SIDE_CONVERSATION_TARGET_ID, + sourceTurnId: 'linked-turn', + expectedSourceRevision: linkedChildSource.revision, + intent: 'side_conversation', + }); + assert.equal(sideConversation.kind, 'committed'); + if (sideConversation.kind !== 'committed') { + assert.fail('Side Conversation must commit'); + } + const sideConversationSession = requireSessionProjection(sideConversation.session); + assert.ok(sideConversationSession.labels.includes('mode:side_conversation')); + await assert.rejects( + desktop.request('session.branch.create', { + sourceSessionId: linkedChildSourceSessionId, + targetSessionId: GRAPH_SIDE_CONVERSATION_TARGET_ID, + sourceTurnId: 'linked-turn', + expectedSourceRevision: linkedChildSource.revision, + }), + operationError('operation_conflict'), + ); + const removableSideConversation = await desktop.request('session.branch.create', { + sourceSessionId: linkedChildSourceSessionId, + targetSessionId: GRAPH_SIDE_CONVERSATION_REMOVAL_TARGET_ID, + sourceTurnId: 'linked-turn', + expectedSourceRevision: linkedChildSource.revision, + intent: 'side_conversation', + }); + assert.equal(removableSideConversation.kind, 'committed'); + if (removableSideConversation.kind !== 'committed') { + assert.fail('Removable Side Conversation must commit'); + } + const removableSideConversationSession = requireSessionProjection( + removableSideConversation.session, + ); + assert.deepEqual( + await desktop.request('session.remove', { + sessionId: GRAPH_SIDE_CONVERSATION_REMOVAL_TARGET_ID, + expectedRevision: removableSideConversationSession.revision, + }), + { kind: 'removed', sessionId: GRAPH_SIDE_CONVERSATION_REMOVAL_TARGET_ID }, + ); + assert.equal((await querySession(tui, graphChildSessionId)).id, graphChildSessionId); const graphRevision = await desktop.request('session.revision.create', { sourceSessionId: linkedChildSourceSessionId, targetSessionId: GRAPH_REVISION_TARGET_ID, @@ -240,6 +291,14 @@ async function verifyConcurrentRevisionAuthority( }), operationError('operation_unavailable'), ); + const archivedSideConversation = await desktop.request('session.branch.create', { + sourceSessionId: archivedOwnedSourceSessionId, + targetSessionId: ARCHIVED_SIDE_CONVERSATION_TARGET_ID, + sourceTurnId: 'archived-owned-turn', + expectedSourceRevision: archivedOwnedSource.revision, + intent: 'side_conversation', + }); + assert.equal(archivedSideConversation.kind, 'committed'); for (const sessionId of ['metadata-linked-copy-target', 'archived-owned-copy-target']) { assert.deepEqual( await tui.request('session.catalog.query', { @@ -409,6 +468,66 @@ async function verifyConcurrentRevisionAuthority( throw cleanupError; } if (assertionError !== undefined) throw assertionError; + + const activeSourceTurn = requireStartedTurn( + await desktop.startTurn({ + sessionId: sourceSessionId, + turnId: 'active-source-turn', + content: { text: FAKE_ASK_USER_QUESTION_PROMPT }, + }), + ); + assertionError = undefined; + try { + const activeSource = await querySession(desktop, sourceSessionId); + const historicalCopyInput = { + sourceSessionId, + sourceTurnId: 'turn-2', + expectedSourceRevision: activeSource.revision, + }; + await assert.rejects( + tui.request('session.branch.create', { + ...historicalCopyInput, + targetSessionId: 'active-source-ordinary-branch-target', + }), + operationError('session_busy'), + ); + const sideConversation = await tui.request('session.branch.create', { + ...historicalCopyInput, + targetSessionId: ACTIVE_SOURCE_SIDE_CONVERSATION_TARGET_ID, + intent: 'side_conversation', + }); + assert.equal(sideConversation.kind, 'committed'); + if (sideConversation.kind !== 'committed') { + assert.fail('Side Conversation must fork a settled Turn while the source keeps running'); + } + assert.ok( + requireSessionProjection(sideConversation.session).labels.includes( + 'mode:side_conversation', + ), + ); + } catch (error) { + assertionError = error; + } + try { + const stopped = await desktop.stopTurn( + { + sessionId: sourceSessionId, + turnId: 'active-source-turn', + runId: activeSourceTurn.runId, + }, + PROCESS_TIMEOUT_MS, + ); + assert.equal(stopped.status, 'cancelled'); + } catch (cleanupError) { + if (assertionError !== undefined) { + throw new AggregateError( + [assertionError, cleanupError], + 'active-source Side Conversation check failed and parked-turn cleanup failed', + ); + } + throw cleanupError; + } + if (assertionError !== undefined) throw assertionError; } finally { await Promise.allSettled([desktop.close(), tui.close()]); } @@ -1357,6 +1476,9 @@ async function verifyDurableBranch( lineageRevisionTargetId: string, lineageBranchTargetId: string, graphRevisionTargetId: string, + graphSideConversationTargetId: string, + archivedSideConversationTargetId: string, + activeSourceSideConversationTargetId: string, graphChildSessionId: string, ): Promise { const owner = await tryAcquireInteractiveRootOwner(capability); @@ -1408,6 +1530,112 @@ async function verifyDurableBranch( (await execution.sessionStore.readHeaderSnapshot(lineageBranchTargetId)).parentSessionId, lineageRevisionTargetId, ); + const sideConversationHeader = await execution.sessionStore.readHeaderSnapshot( + graphSideConversationTargetId, + ); + assert.equal(sideConversationHeader.conversationCopy?.intent, 'side_conversation'); + assert.ok(sideConversationHeader.labels.includes('mode:side_conversation')); + const sideConversationMessages = await execution.sessionStore.readMessagesSnapshot( + graphSideConversationTargetId, + ); + const sideConversationResult = sideConversationMessages.find( + (message) => message.type === 'tool_result' && message.content.kind === 'agent_swarm', + ); + assert.ok(sideConversationResult?.type === 'tool_result'); + if ( + sideConversationResult?.type !== 'tool_result' || + sideConversationResult.content.kind !== 'agent_swarm' + ) { + assert.fail('Side Conversation must retain the Agent Graph summary'); + } + assert.equal(sideConversationResult.content.items[0]?.summary, 'done'); + assert.equal(sideConversationResult.content.items[0]?.childSessionId, undefined); + assert.equal(sideConversationResult.content.items[0]?.runId, undefined); + const sideConversationArtifactId = sideConversationResult.content.items[0]?.artifactIds[0]; + assert.ok(sideConversationArtifactId); + const activeSourceSideConversationMessages = await execution.sessionStore.readMessagesSnapshot( + activeSourceSideConversationTargetId, + ); + assert.ok(activeSourceSideConversationMessages.some((message) => message.turnId === 'turn-2')); + assert.ok( + activeSourceSideConversationMessages.every( + (message) => message.turnId !== 'active-source-turn', + ), + ); + const sideConversationRuns = await execution.agentRunStore.listSessionRuns( + graphSideConversationTargetId, + ); + const sideConversationRun = sideConversationRuns.find((run) => run.turnId === 'linked-turn'); + assert.ok(sideConversationRun); + const sideConversationRuntimeResult = ( + await execution.runtimeEventStore.readRuntimeEvents( + graphSideConversationTargetId, + sideConversationRun.runId, + ) + ).find((event) => event.content?.kind === 'function_response')?.content; + assert.ok(sideConversationRuntimeResult?.kind === 'function_response'); + if (sideConversationRuntimeResult?.kind !== 'function_response') { + assert.fail('Side Conversation must retain its RuntimeEvent result snapshot'); + } + const runtimeSideConversationResult = decodeCanonicalToolResultContent( + sideConversationRuntimeResult.result, + ); + assert.equal(runtimeSideConversationResult.kind, 'agent_swarm'); + if (runtimeSideConversationResult.kind !== 'agent_swarm') { + assert.fail('Copied RuntimeEvent result must remain an Agent Graph result'); + } + assert.equal(runtimeSideConversationResult.items[0]?.childSessionId, undefined); + assert.equal(runtimeSideConversationResult.items[0]?.runId, undefined); + assert.deepEqual(runtimeSideConversationResult.items[0]?.artifactIds, [ + sideConversationArtifactId, + ]); + assert.deepEqual( + await artifacts.readTextInSession(graphSideConversationTargetId, sideConversationArtifactId), + { + ok: true, + text: 'graph child result', + }, + ); + const archivedSideConversationRuns = await execution.agentRunStore.listSessionRuns( + archivedSideConversationTargetId, + ); + const archivedSideConversationChildRun = archivedSideConversationRuns.find( + (run) => run.turnId === 'archived-owned-child-turn', + ); + assert.ok(archivedSideConversationChildRun); + const archivedSideConversationResult = ( + await execution.runtimeEventStore.readRuntimeEvents( + archivedSideConversationTargetId, + archivedSideConversationChildRun.runId, + ) + ).find((event) => event.content?.kind === 'function_response')?.content; + assert.ok(archivedSideConversationResult?.kind === 'function_response'); + if (archivedSideConversationResult?.kind !== 'function_response') { + assert.fail('Side Conversation must retain its archived tool result placeholder'); + } + const archivedSideConversationContent = decodeCanonicalToolResultContent( + archivedSideConversationResult.result, + ); + assert.equal(archivedSideConversationContent.kind, 'subagent'); + if (archivedSideConversationContent.kind !== 'subagent') { + assert.fail('Copied archived child result must be restored as a static snapshot'); + } + assert.notEqual(archivedSideConversationContent.runId, 'archived-owned-child-run'); + assert.deepEqual(archivedSideConversationContent, { + kind: 'subagent', + agentName: 'Worker', + turnId: 'archived-owned-child-turn', + runId: archivedSideConversationChildRun.runId, + status: 'completed', + permissionMode: 'ask', + summary: 'done', + artifactIds: [], + }); + const archivedSideConversationArtifacts = await artifacts.listPage( + archivedSideConversationTargetId, + { offset: 0, limit: 10 }, + ); + assert.equal(archivedSideConversationArtifacts.total, 0); const graphRevisionMessages = await execution.sessionStore.readMessagesSnapshot(graphRevisionTargetId); const graphResult = graphRevisionMessages.find( diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 2d3569b957..19d5dd1f5b 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,9 @@ 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 = 47 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 48 as const; +// 48: Session branch creation accepts an explicit Side Conversation intent. +// Older peers reject the strict input shape or cannot apply its snapshot semantics. // 47: Project registration can carry an explicit location preference. Epoch-46 // hosts reject that optional field on the closed registration input. // 46: Queued message content can be edited in place (queue.entry.update). diff --git a/packages/runtime-host/src/protocol/session-revision.ts b/packages/runtime-host/src/protocol/session-revision.ts index a61d995be9..c48f1fd533 100644 --- a/packages/runtime-host/src/protocol/session-revision.ts +++ b/packages/runtime-host/src/protocol/session-revision.ts @@ -17,7 +17,13 @@ * under the License. */ -import { requireCount, requireEntityId, requireExactRecord, requireRecord } from './codec.js'; +import { + requireCount, + requireEntityId, + requireExactRecord, + requireRecord, + requireShapedRecord, +} from './codec.js'; import { invalidProtocolFrame } from './errors.js'; import { defineOperation } from './operation-spec.js'; import { decodeSessionCatalogItem, type SessionCatalogItem } from './session-catalog.js'; @@ -40,6 +46,7 @@ export interface SessionConversationCopyInput { readonly targetSessionId: string; readonly sourceTurnId: string; readonly expectedSourceRevision: number; + readonly intent?: 'side_conversation'; } export type SessionConversationCopyResult = @@ -82,7 +89,7 @@ export const SESSION_REVISION_OPERATION_SPECS = { mode: 'command', availability: 'ready', errors: SESSION_COPY_ERRORS, - decodeInput: decodeSessionConversationCopyInput, + decodeInput: decodeSessionRevisionCopyInput, decodeOutput: decodeSessionConversationCopyResult, assertOutputForInput: assertConversationCopyOutput, }), @@ -104,6 +111,14 @@ export const SESSION_REVISION_OPERATION_SPECS = { }), } as const; +function decodeSessionRevisionCopyInput(value: unknown): SessionConversationCopyInput { + const input = decodeSessionConversationCopyInput(value); + if (input.intent !== undefined) { + throw invalidProtocolFrame('Session revision copy does not support an intent'); + } + return input; +} + function decodeSessionRevisionAbandonInput(value: unknown): SessionRevisionAbandonInput { const input = requireExactRecord(value, 'Session revision abandon input', ['targetSessionId']); return { targetSessionId: requireEntityId(input.targetSessionId, 'targetSessionId') }; @@ -124,17 +139,20 @@ function decodeSessionRevisionAbandonResult(value: unknown): SessionRevisionAban } export function decodeSessionConversationCopyInput(value: unknown): SessionConversationCopyInput { - const input = requireExactRecord(value, 'Session conversation-copy input', [ - 'sourceSessionId', - 'targetSessionId', - 'sourceTurnId', - 'expectedSourceRevision', - ]); + const input = requireShapedRecord( + value, + 'Session conversation-copy input', + ['sourceSessionId', 'targetSessionId', 'sourceTurnId', 'expectedSourceRevision'], + ['intent'], + ); const sourceSessionId = requireEntityId(input.sourceSessionId, 'sourceSessionId'); const targetSessionId = requireEntityId(input.targetSessionId, 'targetSessionId'); if (sourceSessionId === targetSessionId) { throw invalidProtocolFrame('Session conversation copy requires distinct Sessions'); } + if (input.intent !== undefined && input.intent !== 'side_conversation') { + throw invalidProtocolFrame('Invalid Session conversation-copy intent'); + } return { sourceSessionId, targetSessionId, @@ -143,6 +161,7 @@ export function decodeSessionConversationCopyInput(value: unknown): SessionConve input.expectedSourceRevision, 'expected source Session revision', ), + ...(input.intent === 'side_conversation' ? { intent: input.intent } : {}), }; } diff --git a/packages/runtime-host/src/server/session-revision-coordinator.ts b/packages/runtime-host/src/server/session-revision-coordinator.ts index be72f6fc29..4f3fc24538 100644 --- a/packages/runtime-host/src/server/session-revision-coordinator.ts +++ b/packages/runtime-host/src/server/session-revision-coordinator.ts @@ -19,6 +19,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { isDeepResearchSession } from '@maka/core/explore-agent'; +import { SIDE_CONVERSATION_SESSION_LABEL } from '@maka/core/side-conversation'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { CreateSessionInput } from '@maka/core/runtime-inputs'; import { @@ -28,6 +29,7 @@ import { type StoredMessage, } from '@maka/core/session'; import { + archivedToolResultContainsLinkedChildReferences, archivedToolResultContainsConversationOwnedReferences, cloneConversationRuntimeLedger, collectConversationCopyLinkedChildReferences, @@ -71,6 +73,7 @@ import { import { purgeSessionSidecars } from './session-sidecar-purge.js'; type ConversationCopyKind = 'branch' | 'revision'; +type ConversationCopySemanticKind = ConversationCopyKind | 'side_conversation'; type ConversationCopyOperationKey = Exclude< SessionRevisionOperationKey, 'session.revision.abandon' @@ -172,9 +175,10 @@ export class HostSessionRevisionCoordinator { kind: ConversationCopyKind, input: SessionConversationCopyInput, ): Promise { - const requestFingerprint = conversationCopyFingerprint(kind, input); + const semanticKind = conversationCopySemanticKind(kind, input); + const requestFingerprint = conversationCopyFingerprint(semanticKind, input); const retry = await this.options.admission.run(input.targetSessionId, async () => - this.#resolveExistingTarget(kind, input, requestFingerprint, true), + this.#resolveExistingTarget(semanticKind, input, requestFingerprint, true), ); if (retry) return retry; @@ -200,7 +204,7 @@ export class HostSessionRevisionCoordinator { // lanes and keep the complete lease through validation and publication. for (let pass = 0; pass < CONVERSATION_COPY_ADMISSION_PASSES; pass += 1) { const result = await this.options.admission.runMany([...admittedSessionIds], (lease) => - this.#copyAdmitted(kind, input, requestFingerprint, lease, admittedSessionIds), + this.#copyAdmitted(semanticKind, input, requestFingerprint, lease, admittedSessionIds), ); if ('ok' in result) return result; for (const sessionId of result.sessionIds) admittedSessionIds.add(sessionId); @@ -245,7 +249,7 @@ export class HostSessionRevisionCoordinator { } async #copyAdmitted( - kind: ConversationCopyKind, + kind: ConversationCopySemanticKind, input: SessionConversationCopyInput, requestFingerprint: `sha256:${string}`, lease: SessionAdmissionLease, @@ -293,7 +297,7 @@ export class HostSessionRevisionCoordinator { 'Deep Research Sessions cannot be copied without an exact research ledger boundary', ); } - if (this.options.isSessionActive(input.sourceSessionId)) { + if (kind !== 'side_conversation' && this.options.isSessionActive(input.sourceSessionId)) { return copyFailure('session_busy', 'Source Session has an active Turn'); } @@ -306,7 +310,7 @@ export class HostSessionRevisionCoordinator { const slice = createConversationCopySlice( source.messages, input.sourceTurnId, - kind === 'branch' ? 'through' : 'before', + kind === 'revision' ? 'before' : 'through', ); if (!slice) { return copyFailure('invalid_request', 'Source turn does not exist'); @@ -388,6 +392,7 @@ export class HostSessionRevisionCoordinator { return copyFailure(linkedReferences.code, linkedReferences.message); } if ( + kind !== 'side_conversation' && archivePreflight.serializedResults.some((serializedResult) => archivedToolResultContainsConversationOwnedReferences( serializedResult, @@ -447,10 +452,34 @@ export class HostSessionRevisionCoordinator { } try { + const archivedSnapshotResults = new Map( + archivePreflight.results + .filter( + ({ serializedResult }) => + archivedToolResultContainsLinkedChildReferences(serializedResult) || + archivedToolResultContainsConversationOwnedReferences( + serializedResult, + input.sourceSessionId, + linkedReferences.references, + ), + ) + .map(({ descriptor, serializedResult }) => [descriptor.artifactId, serializedResult]), + ); const artifactCopy = await this.#artifacts.copyConversationArtifacts({ sourceSessionId: input.sourceSessionId, targetSessionId: input.targetSessionId, turnIds: copyTurnIds, + ...(kind === 'side_conversation' && archivedSnapshotResults.size > 0 + ? { excludeArtifactIds: [...archivedSnapshotResults.keys()] } + : {}), + ...(kind === 'side_conversation' && linkedReferences.references.size > 0 + ? { + linkedArtifacts: [...linkedReferences.references].map(([sessionId, references]) => ({ + sessionId, + artifactIds: [...references.artifactIds], + })), + } + : {}), }); const references = { mode: 'exact' as const, @@ -459,12 +488,17 @@ export class HostSessionRevisionCoordinator { artifactIds: artifactCopy.artifactIds, relativePaths: artifactCopy.relativePaths, linkedChildren: - linkedReferences.references.size > 0 + kind === 'side_conversation' ? { - mode: 'preserve_validated' as const, - references: linkedReferences.references, + mode: 'snapshot' as const, + archivedResults: archivedSnapshotResults, } - : { mode: 'reject' as const }, + : linkedReferences.references.size > 0 + ? { + mode: 'preserve_validated' as const, + references: linkedReferences.references, + } + : { mode: 'reject' as const }, }; const runtimeCopy = await cloneConversationRuntimeLedger({ plan, @@ -481,6 +515,7 @@ export class HostSessionRevisionCoordinator { turnIds: copyTurnIds, ...(slice.beforeTs === undefined ? {} : { beforeTs: slice.beforeTs }), runIdMap: runtimeCopy.runIdMap, + ...(kind === 'side_conversation' ? { linkedChildren: 'snapshot' as const } : {}), }); if (copiedMessages.length > 0) { await this.#stores.sessionStore.appendMessages(input.targetSessionId, [...copiedMessages]); @@ -523,7 +558,14 @@ export class HostSessionRevisionCoordinator { copiedMessages: readonly StoredMessage[], copyTurnIds: readonly string[], ): Promise< - | { readonly ok: true; readonly serializedResults: readonly string[] } + | { + readonly ok: true; + readonly results: readonly { + readonly descriptor: ArchivedToolResultCopyDescriptor; + readonly serializedResult: string; + }[]; + readonly serializedResults: readonly string[]; + } | { readonly ok: false; readonly outcome: ConversationCopyOutcome } > { const archives = collectArchivedToolResultPlaceholders( @@ -537,7 +579,10 @@ export class HostSessionRevisionCoordinator { outcome: copyFailure('persistence_failed', 'Archived tool result metadata is invalid'), }; } - const serializedResults: string[] = []; + const results: Array<{ + descriptor: ArchivedToolResultCopyDescriptor; + serializedResult: string; + }> = []; for (const archive of archives) { const read = await this.#artifacts .readTextInSession(sourceSessionId, archive.artifactId, { @@ -557,13 +602,17 @@ export class HostSessionRevisionCoordinator { ), }; } - serializedResults.push(read.text); + results.push({ descriptor: archive, serializedResult: read.text }); } - return { ok: true, serializedResults }; + return { + ok: true, + results, + serializedResults: results.map(({ serializedResult }) => serializedResult), + }; } async #createInput( - kind: ConversationCopyKind, + kind: ConversationCopySemanticKind, input: SessionConversationCopyInput, requestFingerprint: `sha256:${string}`, source: SessionHeader, @@ -578,17 +627,21 @@ export class HostSessionRevisionCoordinator { collaborationMode: source.collaborationMode ?? 'agent', orchestrationMode: source.orchestrationMode ?? 'default', name: source.name, - labels: [...source.labels], + labels: + kind === 'side_conversation' + ? [...new Set([...source.labels, SIDE_CONVERSATION_SESSION_LABEL])] + : [...source.labels], conversationCopy: { - kind, + kind: persistedConversationCopyKind(kind), sourceSessionId: input.sourceSessionId, sourceTurnId: input.sourceTurnId, requestFingerprint, state: 'preparing', + ...(kind === 'side_conversation' ? { intent: kind } : {}), }, status: 'active', }; - if (kind === 'branch') { + if (kind !== 'revision') { return { ...common, parentSessionId: input.sourceSessionId, @@ -617,7 +670,7 @@ export class HostSessionRevisionCoordinator { } async #resolveExistingTarget( - kind: ConversationCopyKind, + kind: ConversationCopySemanticKind, input: SessionConversationCopyInput, requestFingerprint: `sha256:${string}`, discardPreparing: boolean, @@ -640,7 +693,8 @@ export class HostSessionRevisionCoordinator { } const copy = probe.record.header.conversationCopy; if ( - copy?.kind !== kind || + copy?.kind !== persistedConversationCopyKind(kind) || + copy.intent !== (kind === 'side_conversation' ? kind : undefined) || copy.sourceSessionId !== input.sourceSessionId || copy.sourceTurnId !== input.sourceTurnId || copy.requestFingerprint !== requestFingerprint @@ -679,7 +733,7 @@ export class HostSessionRevisionCoordinator { } async #rollbackIncompleteCopy( - kind: ConversationCopyKind, + kind: ConversationCopySemanticKind, input: SessionConversationCopyInput, requestFingerprint: `sha256:${string}`, message: string, @@ -703,7 +757,7 @@ export class HostSessionRevisionCoordinator { } async #unknownAfterCommitAttempt( - kind: ConversationCopyKind, + kind: ConversationCopySemanticKind, input: SessionConversationCopyInput, requestFingerprint: `sha256:${string}`, message: string, @@ -771,27 +825,24 @@ function isConversationRuntimeFactRewriteUnsupported(error: unknown): boolean { } function conversationCopyFingerprint( - kind: ConversationCopyKind, + kind: ConversationCopySemanticKind, input: SessionConversationCopyInput, ): `sha256:${string}` { // The optimistic source revision guards only the initial create. Once this // target exists, its stable identity must resolve the committed outcome even // if a reconnecting Client observes a newer source revision. - return `sha256:${createHash('sha256') - .update( - JSON.stringify([ - 'session.conversation-copy.v1', - kind, - input.sourceSessionId, - input.targetSessionId, - input.sourceTurnId, - ]), - ) - .digest('hex')}`; + const identity = [ + kind === 'side_conversation' ? 'session.conversation-copy.v2' : 'session.conversation-copy.v1', + kind, + input.sourceSessionId, + input.targetSessionId, + input.sourceTurnId, + ]; + return `sha256:${createHash('sha256').update(JSON.stringify(identity)).digest('hex')}`; } function conversationCopyStartNote( - kind: ConversationCopyKind, + kind: ConversationCopySemanticKind, input: SessionConversationCopyInput, createInput: ConversationCopyCreateInput, ): StoredMessage { @@ -801,7 +852,7 @@ function conversationCopyStartNote( ts: Date.now(), kind: 'session_start', data: - kind === 'branch' + kind !== 'revision' ? { parentSessionId: input.sourceSessionId, branchOfTurnId: input.sourceTurnId, @@ -816,6 +867,17 @@ function conversationCopyStartNote( }; } +function conversationCopySemanticKind( + kind: ConversationCopyKind, + input: SessionConversationCopyInput, +): ConversationCopySemanticKind { + return kind === 'branch' && input.intent === 'side_conversation' ? input.intent : kind; +} + +function persistedConversationCopyKind(kind: ConversationCopySemanticKind): ConversationCopyKind { + return kind === 'revision' ? 'revision' : 'branch'; +} + function isRevisionStartData(value: unknown): boolean { return ( !!value && diff --git a/packages/runtime-host/src/server/session-revision-graph-references.ts b/packages/runtime-host/src/server/session-revision-graph-references.ts index 6aba82b59c..9cd3070324 100644 --- a/packages/runtime-host/src/server/session-revision-graph-references.ts +++ b/packages/runtime-host/src/server/session-revision-graph-references.ts @@ -26,7 +26,7 @@ import { } from '@maka/runtime/conversation-copy'; import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores'; -type ConversationCopyKind = 'branch' | 'revision'; +type ConversationCopyKind = 'branch' | 'revision' | 'side_conversation'; export type AgentGraphRevisionReferencePreparation = | { @@ -98,49 +98,81 @@ export async function prepareAgentGraphRevisionReferences( return { ok: true, references: new Map() }; } const requestedChildIds = new Set(requests.map((request) => request.childSessionId)); + const unrepresentedChildren = directChildren.filter((child) => !requestedChildIds.has(child.id)); if ( - directChildren.some((child) => !child.subagentParent?.graph || !requestedChildIds.has(child.id)) + input.kind === 'side_conversation' && + unrepresentedChildren.some((child) => dependencies.isSessionActive(child.id)) ) { - return failure( - 'operation_unavailable', - 'Session revision requires a terminal result for every retained Agent Graph child', - ); + return failure('session_busy', 'A retained linked child is still active'); } - const headersById = new Map(input.sessionHeaders.map((header) => [header.id, header])); - try { - if ((await dependencies.graph.readSessionState(input.sourceSessionId)) === 'live') { - return failure('session_busy', 'A retained Agent Graph is not terminal'); - } - } catch { - return failure('operation_unavailable', 'Retained Agent Graph state is unavailable'); - } const referencedGraphs = new Map>(); - for (const request of requests) { - const parent = headersById.get(request.childSessionId)?.subagentParent; - if (!parent?.graph) continue; + const retainGraph = (header: SessionHeader | undefined) => { + const parent = header?.subagentParent; + if (!parent?.graph) return; const graphIds = referencedGraphs.get(parent.parentSessionId) ?? new Set(); graphIds.add(parent.graph.graphId); referencedGraphs.set(parent.parentSessionId, graphIds); + }; + for (const request of requests) retainGraph(headersById.get(request.childSessionId)); + if (input.kind === 'side_conversation') { + for (const child of directChildren) retainGraph(child); } - for (const [rootSessionId, graphIds] of referencedGraphs) { - for (const graphId of graphIds) { - let state: 'absent' | 'live' | 'terminal'; - try { - state = await dependencies.graph.readGraphState(rootSessionId, graphId); - } catch { - return failure('operation_unavailable', 'Retained Agent Graph state is unavailable'); - } - if (state === 'live') { + const retainedSessionGraphFailure = async () => { + try { + if ((await dependencies.graph.readSessionState(input.sourceSessionId)) === 'live') { return failure('session_busy', 'A retained Agent Graph is not terminal'); } - if (state === 'absent') { - return failure( - 'operation_unavailable', - 'Retained Agent Graph control state is unavailable', - ); + } catch { + return failure('operation_unavailable', 'Retained Agent Graph state is unavailable'); + } + return undefined; + }; + const retainedExactGraphFailure = async () => { + for (const [rootSessionId, graphIds] of referencedGraphs) { + for (const graphId of graphIds) { + let state: 'absent' | 'live' | 'terminal'; + try { + state = await dependencies.graph.readGraphState(rootSessionId, graphId); + } catch { + return failure('operation_unavailable', 'Retained Agent Graph state is unavailable'); + } + if (state === 'live') { + return failure('session_busy', 'A retained Agent Graph is not terminal'); + } + if (state === 'absent') { + return failure( + 'operation_unavailable', + 'Retained Agent Graph control state is unavailable', + ); + } } } + return undefined; + }; + if (input.kind === 'side_conversation') { + const graphFailure = await retainedExactGraphFailure(); + if (graphFailure) return graphFailure; + } + if ( + directChildren.some( + (child) => + (input.kind === 'revision' && !child.subagentParent?.graph) || + !requestedChildIds.has(child.id), + ) + ) { + return failure( + 'operation_unavailable', + input.kind === 'side_conversation' + ? 'Side Conversation requires a terminal result for every retained linked child' + : 'Session revision requires a terminal result for every retained Agent Graph child', + ); + } + if (input.kind === 'revision') { + const graphFailure = await retainedSessionGraphFailure(); + if (graphFailure) return graphFailure; + const exactGraphFailure = await retainedExactGraphFailure(); + if (exactGraphFailure) return exactGraphFailure; } const references = new Map(); @@ -151,9 +183,11 @@ export async function prepareAgentGraphRevisionReferences( const parent = child?.subagentParent; if ( !child || - !parent?.graph || + !parent || !familySessionIds.has(parent.parentSessionId) || - !referencedGraphs.get(parent.parentSessionId)?.has(parent.graph.graphId) || + (input.kind === 'revision' && !parent.graph) || + (parent.graph !== undefined && + !referencedGraphs.get(parent.parentSessionId)?.has(parent.graph.graphId)) || !retainedTurnIds.has(parent.spawnedBy.parentTurnId) ) { return failure( diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index db44f4c5d7..76346cf745 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -33,6 +33,7 @@ import { createSqliteAgentRunStore } from '@maka/storage/agent-run-store'; import { createWorkspaceRuntimeStore } from '@maka/storage/runtime-event-persistence'; import { createSqliteRuntimeStore } from '@maka/storage/sqlite-runtime-store'; import { + archivedToolResultContainsLinkedChildReferences, archivedToolResultContainsConversationOwnedReferences, cloneConversationRuntimeLedger, collectConversationCopyLinkedChildReferences, @@ -229,6 +230,327 @@ test('conversation copy discovers linked children in persisted retired tool resu ); }); +test('Side Conversation preflight identifies linked-child archive bodies', () => { + assert.equal( + archivedToolResultContainsLinkedChildReferences( + JSON.stringify({ + kind: 'subagent', + childSessionId: 'child-session', + agentName: 'Researcher', + turnId: 'child-turn', + runId: 'child-run', + status: 'completed', + permissionMode: 'ask', + summary: 'done', + artifactIds: ['child-artifact'], + }), + ), + true, + ); + assert.equal( + archivedToolResultContainsLinkedChildReferences( + JSON.stringify({ kind: 'text', text: 'safe result' }), + ), + false, + ); +}); + +test('Side Conversation snapshots remove linked child ownership identifiers', () => { + const message: Extract = { + type: 'tool_result', + id: 'linked-result', + turnId: 'turn-1', + ts: 1, + toolUseId: 'linked-call', + isError: false, + content: { + kind: 'agent_swarm', + status: 'completed', + items: [ + { + itemId: 'item-1', + index: 0, + profile: 'default', + started: true, + childSessionId: 'child-session', + turnId: 'child-turn', + runId: 'child-run', + resumedFromRunId: 'child-parent-run', + status: 'completed', + summary: 'The delegated review found one issue.', + artifactIds: ['child-artifact'], + }, + ], + startedAt: 1, + completedAt: 2, + durationMs: 1, + }, + }; + const rewritten = rewriteConversationCopyMessage(message, { + mode: 'exact', + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([['child-artifact', 'child-artifact-snapshot']]), + relativePaths: new Map(), + linkedChildren: { + mode: 'snapshot', + archivedResults: new Map(), + }, + runIds: new Map(), + runtimeEventIds: new Map(), + providerTraceIds: new Map(), + }); + + assert.equal(rewritten.type, 'tool_result'); + if (rewritten.type !== 'tool_result' || rewritten.content.kind !== 'agent_swarm') { + assert.fail('Expected the Agent Graph result snapshot'); + } + assert.deepEqual(rewritten.content.items, [ + { + itemId: 'item-1', + index: 0, + profile: 'default', + started: true, + turnId: 'child-turn', + status: 'completed', + summary: 'The delegated review found one issue.', + artifactIds: ['child-artifact-snapshot'], + }, + ]); +}); + +test('Side Conversation snapshots rewrite source-owned Agent Swarm identities', () => { + const message: Extract = { + type: 'tool_result', + id: 'source-owned-result', + turnId: 'turn-1', + ts: 1, + toolUseId: 'source-owned-call', + isError: false, + content: { + kind: 'agent_swarm', + status: 'completed', + items: [ + { + itemId: 'item-1', + index: 0, + profile: 'default', + started: true, + runId: 'run-source', + resumedFromRunId: 'run-parent-source', + status: 'completed', + summary: 'The source-owned run completed.', + artifactIds: ['artifact-source'], + }, + ], + startedAt: 1, + completedAt: 2, + durationMs: 1, + }, + }; + const rewritten = rewriteConversationCopyMessage(message, { + mode: 'exact', + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([['artifact-source', 'artifact-target']]), + relativePaths: new Map(), + linkedChildren: { + mode: 'snapshot', + archivedResults: new Map(), + }, + runIds: new Map([ + ['run-source', 'run-target'], + ['run-parent-source', 'run-parent-target'], + ]), + runtimeEventIds: new Map(), + providerTraceIds: new Map(), + }); + + assert.equal(rewritten.type, 'tool_result'); + if (rewritten.type !== 'tool_result' || rewritten.content.kind !== 'agent_swarm') { + assert.fail('Expected the source-owned Agent Swarm result'); + } + assert.deepEqual(rewritten.content.items, [ + { + itemId: 'item-1', + index: 0, + profile: 'default', + started: true, + runId: 'run-target', + resumedFromRunId: 'run-parent-target', + status: 'completed', + summary: 'The source-owned run completed.', + artifactIds: ['artifact-target'], + }, + ]); +}); + +test('Side Conversation snapshots rewrite source-owned subagent identities', () => { + const message: Extract = { + type: 'tool_result', + id: 'source-owned-result', + turnId: 'turn-1', + ts: 1, + toolUseId: 'source-owned-call', + isError: false, + content: { + kind: 'subagent', + agentName: 'Researcher', + turnId: 'turn-1', + runId: 'run-source', + status: 'completed', + permissionMode: 'ask', + summary: 'The source-owned run completed.', + artifactIds: ['artifact-source'], + }, + }; + const rewritten = rewriteConversationCopyMessage(message, { + mode: 'exact', + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([['artifact-source', 'artifact-target']]), + relativePaths: new Map(), + linkedChildren: { + mode: 'snapshot', + archivedResults: new Map(), + }, + runIds: new Map([['run-source', 'run-target']]), + runtimeEventIds: new Map(), + providerTraceIds: new Map(), + }); + + assert.equal(rewritten.type, 'tool_result'); + if (rewritten.type !== 'tool_result' || rewritten.content.kind !== 'subagent') { + assert.fail('Expected the source-owned subagent result'); + } + assert.equal(rewritten.content.runId, 'run-target'); + assert.deepEqual(rewritten.content.artifactIds, ['artifact-target']); +}); + +test('Side Conversation snapshots preserve ordinary archived tool results', () => { + const message: Extract = { + type: 'tool_result', + id: 'archived-result', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + isError: false, + content: { + kind: 'json', + value: { + kind: 'maka.archived_tool_result', + rewriteVersion: 1, + artifactId: 'artifact-source', + runtimeEventId: 'event-source', + toolCallId: 'tool-1', + toolName: 'search', + bodySha256: 'a'.repeat(64), + originalEstimatedTokens: 42, + originalBytes: 128, + reason: 'stale_tool_result_pruned_before_compact', + }, + }, + }; + const rewritten = rewriteConversationCopyMessage(message, { + mode: 'exact', + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([['artifact-source', 'artifact-target']]), + relativePaths: new Map(), + linkedChildren: { + mode: 'snapshot', + archivedResults: new Map(), + }, + runIds: new Map(), + runtimeEventIds: new Map([['event-source', 'event-target']]), + providerTraceIds: new Map(), + }); + + assert.equal(rewritten.type, 'tool_result'); + if (rewritten.type !== 'tool_result' || rewritten.content.kind !== 'json') { + assert.fail('Expected an archived JSON tool result'); + } + assert.deepEqual(rewritten.content.value, { + kind: 'maka.archived_tool_result', + rewriteVersion: 1, + artifactId: 'artifact-target', + runtimeEventId: 'event-target', + toolCallId: 'tool-1', + toolName: 'search', + bodySha256: 'a'.repeat(64), + originalEstimatedTokens: 42, + originalBytes: 128, + reason: 'stale_tool_result_pruned_before_compact', + }); +}); + +test('Side Conversation snapshots retire archived linked-child results', () => { + const message: Extract = { + type: 'tool_result', + id: 'archived-result', + turnId: 'turn-1', + ts: 1, + toolUseId: 'tool-1', + isError: false, + content: { + kind: 'json', + value: { + kind: 'maka.archived_tool_result', + rewriteVersion: 1, + artifactId: 'artifact-source', + runtimeEventId: 'event-source', + toolCallId: 'tool-1', + toolName: 'subagent', + bodySha256: 'b'.repeat(64), + originalEstimatedTokens: 42, + originalBytes: 128, + reason: 'stale_tool_result_pruned_before_compact', + }, + }, + }; + const rewritten = rewriteConversationCopyMessage(message, { + mode: 'exact', + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map([['child-artifact', 'child-artifact-snapshot']]), + relativePaths: new Map(), + linkedChildren: { + mode: 'snapshot', + archivedResults: new Map([ + [ + 'artifact-source', + JSON.stringify({ + kind: 'subagent', + childSessionId: 'child-session', + agentName: 'Researcher', + turnId: 'child-turn', + runId: 'child-run', + status: 'completed', + permissionMode: 'ask', + summary: 'The archived review found one issue.', + artifactIds: ['child-artifact'], + }), + ], + ]), + }, + runIds: new Map(), + runtimeEventIds: new Map([['event-source', 'event-target']]), + providerTraceIds: new Map(), + }); + + assert.equal(rewritten.type, 'tool_result'); + if (rewritten.type !== 'tool_result') assert.fail('Expected a tool result'); + assert.deepEqual(rewritten.content, { + kind: 'subagent', + agentName: 'Researcher', + turnId: 'child-turn', + status: 'completed', + permissionMode: 'ask', + summary: 'The archived review found one issue.', + artifactIds: ['child-artifact-snapshot'], + }); +}); + test('conversation copy slices exact turns on inclusive and exclusive boundaries', () => { const messages = [ { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'first' }, diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index bff9cc5309..d013f0b7d7 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -87,6 +87,10 @@ export type ConversationCopyArtifactReferenceMap = readonly relativePaths: ReadonlyMap; readonly linkedChildren: | { readonly mode: 'reject' } + | { + readonly mode: 'snapshot'; + readonly archivedResults: ReadonlyMap; + } | { readonly mode: 'preserve_validated'; readonly references: ReadonlyMap; @@ -532,6 +536,20 @@ export function archivedToolResultContainsConversationOwnedReferences( return false; } +export function archivedToolResultContainsLinkedChildReferences(serializedResult: string): boolean { + const value = deserializeToolResultArchive(serializedResult); + if (isArchivedToolResultPlaceholder(value)) return false; + try { + return ( + conversationCopyLinkedChildReferences( + decodePersistedToolResultContent(markPersisted(value)), + ).length > 0 + ); + } catch { + return false; + } +} + export function conversationCopyLinkedChildReferences( content: ToolResultContent, ): readonly ConversationCopyLinkedChildReference[] { @@ -1010,9 +1028,11 @@ function rewriteRuntimeEventReferences( } : {}), ...(event.refs.artifactId - ? { - artifactId: rewriteOwnedArtifactId(event.refs.artifactId, references), - } + ? archivedSnapshotResult(event.refs.artifactId, references) !== undefined + ? {} + : { + artifactId: rewriteOwnedArtifactId(event.refs.artifactId, references), + } : {}), ...(event.refs.sourceInvocationId ? { @@ -1111,6 +1131,8 @@ function rewriteToolResultContent( return { ...content, ref: rewriteStorageRef(content.ref, references) }; } if (content.kind === 'archived_tool_result') { + const snapshot = rewriteArchivedSnapshot(content, references); + if (snapshot) return snapshot; return { ...content, runtimeEventId: rewriteOwnedId( @@ -1124,12 +1146,21 @@ function rewriteToolResultContent( }; } if (content.kind === 'json' && isArchivedToolResultPlaceholder(content.value)) { + const snapshot = rewriteArchivedSnapshot(content.value, references); + if (snapshot) return snapshot; return { ...content, value: rewriteArchivedToolResult(content.value, references), }; } if (content.kind === 'subagent') { + if (linkedChildrenAreSnapshots(references) && content.childSessionId) { + const { childSessionId: _childSessionId, runId: _runId, ...snapshot } = content; + return { + ...snapshot, + artifactIds: rewriteSnapshotArtifactIds(content.artifactIds, references), + }; + } return { ...content, ...(content.runId @@ -1153,6 +1184,18 @@ function rewriteToolResultContent( return { ...content, items: content.items.map((item) => { + if (linkedChildrenAreSnapshots(references) && item.childSessionId) { + const { + childSessionId: _childSessionId, + runId: _runId, + resumedFromRunId: _resumedFromRunId, + ...snapshot + } = item; + return { + ...snapshot, + artifactIds: rewriteSnapshotArtifactIds(item.artifactIds, references), + }; + } return { ...item, ...(item.runId @@ -1183,6 +1226,8 @@ function rewriteRuntimeToolResult( references: ConversationCopyMessageReferenceMap, ): unknown { if (isArchivedToolResultPlaceholder(value)) { + const snapshot = rewriteArchivedSnapshot(value, references); + if (snapshot) return snapshot; return rewriteArchivedToolResult(value, references); } let content: ToolResultContent; @@ -1206,6 +1251,7 @@ function validatedExternalChildReferences( references: ConversationCopyMessageReferenceMap, ): ConversationCopyExternalChildReferences | undefined { if (references.mode === 'preserve_external') return undefined; + if (references.linkedChildren.mode === 'snapshot') return undefined; if (references.linkedChildren.mode === 'reject') { throw new Error(`Conversation copy cannot retain linked child Session ${childSessionId}`); } @@ -1216,6 +1262,81 @@ function validatedExternalChildReferences( return external; } +function linkedChildrenAreSnapshots(references: ConversationCopyMessageReferenceMap): boolean { + return references.mode === 'exact' && references.linkedChildren.mode === 'snapshot'; +} + +function rewriteSnapshotArtifactIds( + artifactIds: readonly string[], + references: ConversationCopyMessageReferenceMap, +): readonly string[] { + if (references.mode !== 'exact' || references.linkedChildren.mode !== 'snapshot') { + return artifactIds; + } + return artifactIds.map((artifactId) => + requiredMappedId(references.artifactIds, artifactId, 'linked Artifact'), + ); +} + +function rewriteArchivedSnapshot( + value: + | ArchivedToolResultPlaceholder + | Extract, + references: ConversationCopyMessageReferenceMap, +): ToolResultContent | undefined { + const serializedResult = archivedSnapshotResult(value.artifactId, references); + if (serializedResult === undefined) return undefined; + const archived = deserializeToolResultArchive(serializedResult); + if (isArchivedToolResultPlaceholder(archived)) { + return unavailableArchivedToolResult(value, references); + } + try { + const decoded = decodePersistedToolResultContent(markPersisted(archived)); + return decoded.kind === 'archived_tool_result' + ? unavailableArchivedToolResult(value, references) + : rewriteToolResultContent(decoded, references); + } catch { + return unavailableArchivedToolResult(value, references); + } +} + +function archivedSnapshotResult( + artifactId: string | undefined, + references: ConversationCopyMessageReferenceMap, +): string | undefined { + if ( + artifactId === undefined || + references.mode !== 'exact' || + references.linkedChildren.mode !== 'snapshot' + ) { + return undefined; + } + return references.linkedChildren.archivedResults.get(artifactId); +} + +function unavailableArchivedToolResult( + value: + | ArchivedToolResultPlaceholder + | Extract, + references: ConversationCopyMessageReferenceMap, +): Extract { + return { + kind: 'archived_tool_result', + status: 'missing', + runtimeEventId: rewriteOwnedId( + value.runtimeEventId, + references.runtimeEventIds, + 'RuntimeEvent', + ), + toolCallId: value.toolCallId, + toolName: value.toolName, + originalEstimatedTokens: value.originalEstimatedTokens, + originalBytes: value.originalBytes, + rewriteVersion: value.rewriteVersion, + reason: value.reason, + }; +} + function rewriteLinkedRunId( sourceId: string, childSessionId: string | undefined, diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts index db16676269..245a57980b 100644 --- a/packages/storage/src/__tests__/artifact-store.test.ts +++ b/packages/storage/src/__tests__/artifact-store.test.ts @@ -322,6 +322,63 @@ describe('SQLite Artifact store', () => { }); }); + test('excludes selected Artifacts from a conversation snapshot', async () => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + await authority.recover(); + const { store } = authority; + await store.create({ + ...artifactInput('retained-artifact', 'retained', 10), + turnId: 'turn-retained', + }); + await store.create({ + ...artifactInput('excluded-archive', 'archived child result', 11), + turnId: 'turn-retained', + source: 'tool_result_archive', + }); + + const copied = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy', + turnIds: ['turn-retained'], + excludeArtifactIds: ['excluded-archive'], + }); + + assert.equal(copied.artifactIds.has('excluded-archive'), false); + assert.deepEqual( + (await store.list('session-copy')).map((record) => record.name), + ['retained-artifact.txt'], + ); + assert.equal((await store.get('excluded-archive'))?.sessionId, 'session-1'); + }); + }); + + test('copies explicit linked child Artifacts into a conversation snapshot', async () => { + await withWorkspace(async (root) => { + const authority = createArtifactStoreWriteAuthority(root); + await authority.recover(); + const { store } = authority; + await store.create({ + ...artifactInput('child-artifact', 'child result', 10), + sessionId: 'child-session', + turnId: 'child-turn', + }); + + const copied = await store.copyConversationArtifacts({ + sourceSessionId: 'session-1', + targetSessionId: 'session-copy', + turnIds: ['turn-retained'], + linkedArtifacts: [{ sessionId: 'child-session', artifactIds: ['child-artifact'] }], + }); + + const copiedId = copied.artifactIds.get('child-artifact'); + assert.ok(copiedId); + assert.deepEqual(await store.readText(copiedId), { ok: true, text: 'child result' }); + assert.equal((await store.get(copiedId))?.sessionId, 'session-copy'); + assert.equal((await store.get('child-artifact'))?.sessionId, 'child-session'); + }); + }); + test('user delete evaluates current-generation policy before tombstone state', async () => { await withWorkspace(async (root) => { const authority = createArtifactStoreWriteAuthority(root); diff --git a/packages/storage/src/__tests__/task-ledger-authority.test.ts b/packages/storage/src/__tests__/task-ledger-authority.test.ts index d4c9d5b7c6..d0ca59dc87 100644 --- a/packages/storage/src/__tests__/task-ledger-authority.test.ts +++ b/packages/storage/src/__tests__/task-ledger-authority.test.ts @@ -112,6 +112,98 @@ describe('interactive task ledger authority', () => { ); }); }); + + test('copies child-owned tasks into an ownership-free Side Conversation snapshot', async () => { + await withInteractiveOwner(async ({ writer }) => { + const sourceSessionId = 'source-session'; + const targetSessionId = 'side-conversation'; + const created = await writer.create(sourceSessionId, [{ subject: 'Delegated review' }], { + turnId: 'root-turn', + source: 'tool', + actor: 'main_agent', + }); + const taskId = created.created[0]!.id; + await writer.claim( + sourceSessionId, + taskId, + { + actor: 'child_agent', + sessionId: 'child-session', + agentId: 'reviewer', + runId: 'child-run', + turnId: 'child-turn', + }, + { + turnId: 'root-turn', + runId: 'child-run', + source: 'tool', + actor: 'child_agent', + }, + ); + const legacy = await writer.create(sourceSessionId, [{ subject: 'Legacy child note' }], { + turnId: 'root-turn', + runId: 'legacy-child-run', + source: 'tool', + actor: 'child_agent', + }); + + await writer.copyConversationTaskLedger({ + sourceSessionId, + targetSessionId, + turnIds: ['root-turn'], + runIdMap: [], + linkedChildren: 'snapshot', + }); + + const copied = await writer.list(targetSessionId); + assert.deepEqual( + copied.map((task) => ({ id: task.id, subject: task.subject, status: task.status })), + [ + { id: taskId, subject: 'Delegated review', status: 'in_progress' }, + { id: legacy.created[0]!.id, subject: 'Legacy child note', status: 'pending' }, + ], + ); + assert.ok(copied.every((task) => task.owner === undefined)); + assert.equal((await writer.get(sourceSessionId, taskId))?.owner?.sessionId, 'child-session'); + assert.equal((await writer.get(sourceSessionId, taskId))?.owner?.runId, 'child-run'); + }); + }); + + test('preserves main-agent ownership on child-authored snapshot events', async () => { + await withInteractiveOwner(async ({ writer }) => { + const sourceSessionId = 'source-main-owned'; + const targetSessionId = 'side-main-owned'; + const created = await writer.create(sourceSessionId, [{ subject: 'Parent-owned work' }], { + turnId: 'root-turn', + runId: 'root-run', + source: 'tool', + actor: 'main_agent', + }); + await writer.update( + sourceSessionId, + created.created[0]!.id, + { subject: 'Child reported progress' }, + { + turnId: 'root-turn', + runId: 'child-run', + source: 'tool', + actor: 'child_agent', + }, + ); + + await writer.copyConversationTaskLedger({ + sourceSessionId, + targetSessionId, + turnIds: ['root-turn'], + runIdMap: [{ sourceRunId: 'root-run', targetRunId: 'copied-root-run' }], + linkedChildren: 'snapshot', + }); + + const [copied] = await writer.list(targetSessionId); + assert.equal(copied?.owner?.actor, 'main_agent'); + assert.equal(copied?.owner?.runId, 'copied-root-run'); + }); + }); }); async function withInteractiveOwner( diff --git a/packages/storage/src/artifact-store.ts b/packages/storage/src/artifact-store.ts index bcc802d443..dc43a65241 100644 --- a/packages/storage/src/artifact-store.ts +++ b/packages/storage/src/artifact-store.ts @@ -158,6 +158,11 @@ export interface ConversationArtifactCopyInput { readonly sourceSessionId: string; readonly targetSessionId: string; readonly turnIds: readonly string[]; + readonly excludeArtifactIds?: readonly string[]; + readonly linkedArtifacts?: readonly { + readonly sessionId: string; + readonly artifactIds: readonly string[]; + }[]; } export interface ConversationArtifactCopyResult { @@ -383,21 +388,52 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { throw new Error('Artifact conversation copy requires distinct Sessions'); } const turnIds = new Set(input.turnIds); + const excludedArtifactIds = new Set(input.excludeArtifactIds ?? []); for (const turnId of turnIds) assertArtifactTurnKey(turnId); + const linkedArtifacts = input.linkedArtifacts ?? []; + const requestedLinkedArtifactIds = new Map>(); + for (const linked of linkedArtifacts) { + assertCanonicalArtifactEntityId(linked.sessionId, 'sessionId'); + if (linked.sessionId === input.targetSessionId) { + throw new Error('Linked Artifact copy requires a distinct source Session'); + } + const artifactIds = requestedLinkedArtifactIds.get(linked.sessionId) ?? new Set(); + for (const artifactId of linked.artifactIds) { + assertCanonicalArtifactEntityId(artifactId, 'id'); + artifactIds.add(artifactId); + } + requestedLinkedArtifactIds.set(linked.sessionId, artifactIds); + } const records = await this.enqueue(async () => { await this.load(); - return this.records + const selected = this.records .filter( - (record) => record.sessionId === input.sourceSessionId && turnIds.has(record.turnId), + (record) => + record.sessionId === input.sourceSessionId && + turnIds.has(record.turnId) && + !excludedArtifactIds.has(record.id), ) .map((record) => ({ ...record })); + for (const [sessionId, artifactIds] of requestedLinkedArtifactIds) { + for (const artifactId of artifactIds) { + const record = this.records.find( + (candidate) => + candidate.sessionId === sessionId && + candidate.id === artifactId && + candidate.status !== 'deleted', + ); + if (!record) throw new Error(`Linked Artifact ${artifactId} could not be copied`); + selected.push({ ...record }); + } + } + return selected; }); const artifactIds = new Map(); const relativePaths = new Map(); for (const record of records) { const targetId = conversationCopyArtifactId( - input.sourceSessionId, + record.sessionId, input.targetSessionId, record.id, ); diff --git a/packages/storage/src/artifact-stores.ts b/packages/storage/src/artifact-stores.ts index 4cdd20ae1d..feaa5a94e2 100644 --- a/packages/storage/src/artifact-stores.ts +++ b/packages/storage/src/artifact-stores.ts @@ -152,6 +152,21 @@ function createWriterFacade( const acceptedInput: ConversationArtifactCopyInput = Object.freeze({ ...input, turnIds: Object.freeze([...input.turnIds]), + ...(input.excludeArtifactIds + ? { excludeArtifactIds: Object.freeze([...input.excludeArtifactIds]) } + : {}), + ...(input.linkedArtifacts + ? { + linkedArtifacts: Object.freeze( + input.linkedArtifacts.map((linked) => + Object.freeze({ + sessionId: linked.sessionId, + artifactIds: Object.freeze([...linked.artifactIds]), + }), + ), + ), + } + : {}), }); return run(() => store.copyConversationArtifacts(acceptedInput)); }, diff --git a/packages/storage/src/session-conversation-copy.ts b/packages/storage/src/session-conversation-copy.ts index db33111d7d..eff3083ae3 100644 --- a/packages/storage/src/session-conversation-copy.ts +++ b/packages/storage/src/session-conversation-copy.ts @@ -41,6 +41,7 @@ export function isValidConversationCopyTransition( previous.sourceSessionId === next.sourceSessionId && previous.sourceTurnId === next.sourceTurnId && previous.requestFingerprint === next.requestFingerprint && + previous.intent === next.intent && (previous.state !== 'committed' || next.state === 'committed') && (previous.state !== 'preparing' || next.state === 'preparing' || next.state === 'committed') ); diff --git a/packages/storage/src/task-ledger-authority.ts b/packages/storage/src/task-ledger-authority.ts index 9d2f2d8e47..b63328b706 100644 --- a/packages/storage/src/task-ledger-authority.ts +++ b/packages/storage/src/task-ledger-authority.ts @@ -145,6 +145,7 @@ function createInteractiveWriterFacade( ...input, turnIds: Object.freeze([...input.turnIds]), runIdMap: Object.freeze(input.runIdMap.map((entry) => Object.freeze({ ...entry }))), + ...(input.linkedChildren ? { linkedChildren: input.linkedChildren } : {}), }); return run(() => store.copyConversationTaskLedger(acceptedInput)); }, diff --git a/packages/storage/src/task-ledger-store.ts b/packages/storage/src/task-ledger-store.ts index 78d40d508b..ceb62d1df6 100644 --- a/packages/storage/src/task-ledger-store.ts +++ b/packages/storage/src/task-ledger-store.ts @@ -65,6 +65,7 @@ export interface ConversationTaskLedgerCopyInput { readonly sourceRunId: string; readonly targetRunId: string; }[]; + readonly linkedChildren?: 'preserve' | 'snapshot'; } export interface TaskLedgerAuthorityStore extends TaskLedgerStore { @@ -174,7 +175,13 @@ class SqliteTaskLedgerStoreImpl implements SqliteTaskLedgerStore { throw new Error('Task Ledger events cross the conversation-copy boundary'); } selected.push( - rewriteConversationTaskEvent(event, input.sourceSessionId, input.targetSessionId, runIds), + rewriteConversationTaskEvent( + event, + input.sourceSessionId, + input.targetSessionId, + runIds, + input.linkedChildren ?? 'preserve', + ), ); } if (selected.length === 0) return; @@ -787,29 +794,37 @@ function rewriteConversationTaskEvent( sourceSessionId: string, targetSessionId: string, runIds: ReadonlyMap, + linkedChildren: 'preserve' | 'snapshot', ): TaskLedgerEvent { - const owner = event.task.owner; + const { owner, ...task } = event.task; + const snapshotChildOwner = linkedChildren === 'snapshot' && owner?.actor === 'child_agent'; + const snapshotChildRun = linkedChildren === 'snapshot' && event.actor === 'child_agent'; const rewrittenOwner = owner === undefined ? undefined - : { - ...owner, - ...(owner.sessionId === sourceSessionId ? { sessionId: targetSessionId } : {}), - ...(owner.runId - ? { - runId: requiredConversationCopyRunId(runIds, owner.runId), - } - : {}), - }; + : snapshotChildOwner + ? undefined + : { + ...owner, + ...(owner.sessionId === sourceSessionId ? { sessionId: targetSessionId } : {}), + ...(owner.runId + ? { + runId: requiredConversationCopyRunId(runIds, owner.runId), + } + : {}), + }; const refs = event.refs === undefined ? undefined - : { - ...event.refs, - ...(event.refs.runId - ? { runId: requiredConversationCopyRunId(runIds, event.refs.runId) } - : {}), - }; + : (() => { + const { runId: sourceRunId, ...preserved } = event.refs; + return { + ...preserved, + ...(sourceRunId && !snapshotChildRun + ? { runId: requiredConversationCopyRunId(runIds, sourceRunId) } + : {}), + }; + })(); return { ...event, eventId: `task-copy-${createHash('sha256') @@ -817,7 +832,7 @@ function rewriteConversationTaskEvent( .digest('hex')}`, sessionId: targetSessionId, task: { - ...event.task, + ...task, ...(rewrittenOwner ? { owner: rewrittenOwner } : {}), }, ...(refs ? { refs } : {}), From cba9e277c2d75eb29e065cdf2ee6debfb6f92812 Mon Sep 17 00:00:00 2001 From: Haoqing Wang <78337154+hqhq1025@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:11:39 +0800 Subject: [PATCH 007/386] fix(runtime): avoid implicit computer-use images (#3585) * fix(runtime): avoid implicit computer-use images Generated-by: Codex * fix(runtime): bind CU image output to executed input Generated-by: Codex --- .../src/__tests__/computer-use-tools.test.ts | 56 +++++++++++- packages/runtime/src/computer-use-tools.ts | 86 +++++++++++++++---- 2 files changed, 126 insertions(+), 16 deletions(-) diff --git a/packages/runtime/src/__tests__/computer-use-tools.test.ts b/packages/runtime/src/__tests__/computer-use-tools.test.ts index 09bb2989ef..badb671c63 100644 --- a/packages/runtime/src/__tests__/computer-use-tools.test.ts +++ b/packages/runtime/src/__tests__/computer-use-tools.test.ts @@ -19,11 +19,12 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import type { CuAction } from '@maka/core/computer-use'; +import { CU_TOOL_ACTION_TYPES, type CuAction } from '@maka/core/computer-use'; import { zodSchema } from 'ai'; import { adaptToCuAction, buildComputerUseTools, + COMPUTER_USE_MODEL_SCREENSHOT_POLICY, snapshotComputerParams, type CuDispatchBackend, type CuObservation, @@ -2609,6 +2610,59 @@ describe('buildComputerUseTools — the `maka_computer` MakaTool', () => { assert.doesNotMatch(output.value[0]?.text ?? '', /super-secret-value/); }); + test('keeps PiP-only screenshots out of semantic action model output', () => { + const [tool] = buildComputerUseTools({ backend: fakeBackend() }); + const output = (includeScreenshotInModelOutput: boolean) => ({ + text: 'persisted result', + modelText: 'fresh semantic observation', + screenshot: { base64: 'AA==', mimeType: 'image/png' }, + includeScreenshotInModelOutput, + }); + const project = (includeScreenshotInModelOutput: boolean) => + tool.toModelOutput?.({ + toolCallId: 'tool-1', + input: {}, + output: output(includeScreenshotInModelOutput), + }) as { value: Array<{ type: string }> }; + + assert.deepEqual(project(false).value, [{ type: 'text', text: 'fresh semantic observation' }]); + assert.equal(project(true).value[1]?.type, 'file'); + }); + + test('classifies every canonical action for model-visible screenshots', () => { + assert.deepEqual(Object.keys(COMPUTER_USE_MODEL_SCREENSHOT_POLICY), [...CU_TOOL_ACTION_TYPES]); + }); + + test('binds model-visible screenshots to the immutable executed invocation', async () => { + const projectAfterMutation = async ( + includeScreenshot: boolean, + mutatedIncludeScreenshot: boolean, + ) => { + const backend = fakeBackend() as CuDispatchBackend & { + observeApp: NonNullable; + }; + backend.observeApp = async () => observation(); + const [tool] = buildComputerUseTools({ backend }); + const input = { + action: 'observe' as const, + app: 'Fixture', + include_screenshot: includeScreenshot, + }; + const output = await tool.impl(input, ctx()); + input.include_screenshot = mutatedIncludeScreenshot; + return tool.toModelOutput?.({ + toolCallId: 'tool-1', + input, + output, + }) as { value: Array<{ type: string }> }; + }; + + assert.equal((await projectAfterMutation(true, false)).value[1]?.type, 'file'); + const nonVisual = await projectAfterMutation(false, true); + assert.equal(nonVisual.value.length, 1); + assert.equal(nonVisual.value[0]?.type, 'text'); + }); + test('S18: an already-aborted signal short-circuits before any dispatch', async () => { const ac = new AbortController(); ac.abort(); diff --git a/packages/runtime/src/computer-use-tools.ts b/packages/runtime/src/computer-use-tools.ts index aad8286e0f..6784476e1e 100644 --- a/packages/runtime/src/computer-use-tools.ts +++ b/packages/runtime/src/computer-use-tools.ts @@ -35,6 +35,7 @@ import { isCuObservingAction, type CuAction, type CuPoint, + type CuToolActionType, type ComputerUseErrorCode, type ComputerUseWindowIdentity, } from '@maka/core/computer-use'; @@ -330,8 +331,9 @@ export const computerWireParams = z * Raw result of the `computer` tool. `text` is the S16-safe summary the runtime * records to session history (via coerceResultContent's text-only projection: * this object has no `kind`, so only `text` survives). `screenshot`, when - * present, rides along ONLY to feed `toModelOutput` — it never enters `text`, so - * the bounded frame base64 stays out of session history. + * present, feeds the local presentation layer and, only for explicitly visual + * actions, `toModelOutput`. It never enters `text`, so the bounded frame base64 + * stays out of session history. */ interface ComputerToolResult { text: string; @@ -339,6 +341,46 @@ interface ComputerToolResult { error?: ComputerUseErrorCode; failureClass?: 'ambiguous_target'; screenshot?: { base64: string; mimeType: string }; + includeScreenshotInModelOutput?: boolean; +} + +export const COMPUTER_USE_MODEL_SCREENSHOT_POLICY = { + list_apps: 'never', + launch_app: 'never', + observe: 'explicit', + click_element: 'never', + set_value: 'never', + select_text: 'never', + secondary_action: 'never', + scroll_element: 'never', + window_action: 'never', + element_sequence: 'never', + press_key: 'never', + screenshot: 'always', + cursor_position: 'never', + mouse_move: 'always', + left_click: 'always', + right_click: 'always', + middle_click: 'always', + double_click: 'always', + triple_click: 'always', + left_mouse_down: 'always', + left_mouse_up: 'always', + left_click_drag: 'always', + type: 'always', + key: 'always', + hold_key: 'always', + scroll: 'always', + wait: 'never', + zoom: 'always', +} as const satisfies Record; + +function shouldSendScreenshotToModel(input: ComputerParams): boolean { + const policy = COMPUTER_USE_MODEL_SCREENSHOT_POLICY[input.action]; + return ( + policy === 'always' || + (policy === 'explicit' && input.action === 'observe' && input.include_screenshot === true) + ); } export interface ComputerUseToolSet extends Array { @@ -984,6 +1026,7 @@ export function buildComputerUseTools(deps: { function deliveredWithoutFreshObservation( action: ComputerSummaryAction, result: CuRunResult, + includeScreenshotInModelOutput = false, ): ComputerToolResult { const evidence = summarizeEvidence(result.outcome.evidence); const hostEvidence = summarizeEvidence(result.outcome.evidence, 'host'); @@ -1008,6 +1051,7 @@ export function buildComputerUseTools(deps: { base64: screenshot.base64, mimeType: screenshot.mimeType, }, + includeScreenshotInModelOutput, } : {}), }; @@ -1596,6 +1640,7 @@ export function buildComputerUseTools(deps: { ): Promise => { if (abortSignal.aborted) return { text: 'computer aborted before start' }; const input = snapshotComputerParams(computerParams.parse(args)); + const includeScreenshotInModelOutput = shouldSendScreenshotToModel(input); // Before anything is claimed against a frame or dispatched: an argument // holding one of this host's own withheld-value placeholders is a replay // of the record, not a value, and every path below would have typed it. @@ -1604,7 +1649,7 @@ export function buildComputerUseTools(deps: { const invocationGeneration = presentationGenerations.get(sessionId) ?? 0; const releasePendingInvocation = trackPendingInvocation(sessionId, turnId); try { - return await withInvocationQueue(sessionId, abortSignal, async () => { + return await withInvocationQueue(sessionId, abortSignal, async () => { if ((presentationGenerations.get(sessionId) ?? 0) !== invocationGeneration) { return sessionFailure('user_stopped'); } @@ -2275,6 +2320,7 @@ export function buildComputerUseTools(deps: { text: persistedObservationText(observation), modelText: observationText({ ...observation, screenshot }), screenshot: { base64: screenshot.base64, mimeType: screenshot.mimeType }, + includeScreenshotInModelOutput, } : { text: persistedObservationText(observation), @@ -2340,6 +2386,7 @@ export function buildComputerUseTools(deps: { base64: screenshotObservation.screenshot.base64, mimeType: screenshotObservation.screenshot.mimeType, }, + includeScreenshotInModelOutput, }; } if ( @@ -2711,11 +2758,11 @@ export function buildComputerUseTools(deps: { state.reobserveRequired(); } } - // Carry the screenshot base64 on the raw result (which becomes the ai-sdk - // tool `output`) so `toModelOutput` below can hand the vision model an image - // block. Kept OFF `text`: coerceResultContent projects this object to a - // text-only session-log entry (no `kind` ⇒ only `text` survives), so the - // bounded frame never bloats history. + // Carry the screenshot base64 on the raw result for the local mirror. + // `toModelOutput` below sends it to the provider only for actions that + // explicitly need pixels. Kept OFF `text`: coerceResultContent projects + // this object to a text-only session-log entry (no `kind` => only `text` + // survives), so the bounded frame never bloats durable history. let bindingResult: BindingFailureReason | undefined; if (boundAction) bindingResult = consumeBoundAction(record, boundAction); if (bindingResult && !hasUncertainDeliveredOutcome(result)) { @@ -2741,11 +2788,19 @@ export function buildComputerUseTools(deps: { : undefined; } catch { presentation?.finish(result); - return deliveredWithoutFreshObservation(modelAction, result); + return deliveredWithoutFreshObservation( + modelAction, + result, + includeScreenshotInModelOutput, + ); } if (actionLease && result.outcome.ok && !freshObservation) { presentation?.finish(result); - return deliveredWithoutFreshObservation(modelAction, result); + return deliveredWithoutFreshObservation( + modelAction, + result, + includeScreenshotInModelOutput, + ); } presentation?.finish(withMirrorFrame(result, freshObservation)); const modelRefresh = freshObservation @@ -2772,6 +2827,7 @@ export function buildComputerUseTools(deps: { ...(!result.outcome.ok ? { error: result.outcome.error } : {}), ...(failureClass ? { failureClass } : {}), screenshot: { base64: screenshot.base64, mimeType: screenshot.mimeType }, + includeScreenshotInModelOutput, } : { text, @@ -2785,10 +2841,10 @@ export function buildComputerUseTools(deps: { releasePendingInvocation(); } }, - // Map the raw result into model-visible content: the summary as text, plus the - // screenshot as a native file block when present. Robust to the runtime's synthetic - // failure return shape ({ error }) from permission/loop-gate blocks, which - // reaches here as `output` too. + // Map the raw result into model-visible content. Semantic actions already + // return a fresh accessibility observation, so their automatically captured + // PiP frame stays local. Explicit visual requests and legacy coordinate + // actions still receive the native image block. toModelOutput: ({ output }) => { const o = (output ?? {}) as Partial & { error?: unknown }; const text = @@ -2803,7 +2859,7 @@ export function buildComputerUseTools(deps: { type: 'content', value: [ { type: 'text', text }, - ...(o.screenshot + ...(o.screenshot && o.includeScreenshotInModelOutput === true ? [ { type: 'file' as const, From 95842fe842662f9ed7ffb4a6c7545b386a35df20 Mon Sep 17 00:00:00 2001 From: likun666661 <90952590+likun666661@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:20:33 +0800 Subject: [PATCH 008/386] refactor(runtime): make Fiber the sole lifecycle owner (#3479) Generated-by: Codex Co-authored-by: likun --- .../src/__tests__/plugin-kernel.test.ts | 96 ++++++++ packages/runtime/src/plugin-kernel.ts | 212 +++++++++++------- 2 files changed, 222 insertions(+), 86 deletions(-) diff --git a/packages/runtime/src/__tests__/plugin-kernel.test.ts b/packages/runtime/src/__tests__/plugin-kernel.test.ts index cc631e8714..0634530c9f 100644 --- a/packages/runtime/src/__tests__/plugin-kernel.test.ts +++ b/packages/runtime/src/__tests__/plugin-kernel.test.ts @@ -471,6 +471,85 @@ test('Fiber cleanup exhausts Effects before reporting disposer failures', async await root.fiber.dispose(); }); +test('Service loss preserves consumer cleanup failure on the owning Fiber', async () => { + const root = new Context(); + const removeService = root.provide('fixture', { value: 'ready' }); + let activations = 0; + const consumer = root.plugin( + Object.assign( + () => { + activations += 1; + return () => { + throw new Error('cleanup boom'); + }; + }, + { inject: ['fixture'] }, + ), + ); + await consumer.await(); + assert.equal(consumer.state, FiberState.ACTIVE); + + await removeService(); + + assert.equal(consumer.state, FiberState.FAILED); + const error = await consumer.await().then( + () => undefined, + (reason: unknown) => reason, + ); + assert.ok(error instanceof AggregateError); + const fiberCleanup = error.errors[0]; + assert.ok(fiberCleanup instanceof AggregateError); + const effectCleanup = fiberCleanup.errors[0]; + assert.ok(effectCleanup instanceof AggregateError); + assert.match(String(effectCleanup.errors[0]), /cleanup boom/u); + + root.provide('fixture', { value: 'replacement' }); + await assert.rejects(consumer.await(), AggregateError); + assert.equal(consumer.state, FiberState.FAILED); + assert.equal(activations, 1); + await root.fiber.dispose(); +}); + +test('Fiber owns derived Context views, child Fibers, and their Effects', async () => { + const root = new Context(); + const lifecycle: string[] = []; + let accountA!: Context; + let accountB!: Context; + let child!: ReturnType; + + const owner = root.plugin((ctx) => { + accountA = ctx.extend({ account: 'a' }); + accountB = ctx.extend({ account: 'b' }); + accountA.effect(() => () => lifecycle.push('account-a'), 'account-a'); + accountB.effect(() => () => lifecycle.push('account-b'), 'account-b'); + child = accountB.plugin(() => undefined); + }); + await owner.await(); + await child.await(); + + assert.equal(owner.context.fiber, owner); + assert.equal(accountA.fiber, owner); + assert.equal(accountB.fiber, owner); + assert.equal(child.parent, owner); + assert.throws( + () => child.deriveContext(accountA), + /Cannot derive a Context view from another Fiber/u, + ); + assert.throws( + () => child.mount(accountB, () => undefined), + /Cannot mount a child through a Context owned by another Fiber/u, + ); + assert.deepEqual( + owner.getEffects().map(({ label }) => label), + ['account-a', 'account-b'], + ); + + await owner.dispose(); + assert.equal(child.state, FiberState.DISPOSED); + assert.deepEqual(lifecycle, ['account-b', 'account-a']); + await root.fiber.dispose(); +}); + test('concurrent Effect disposal shares the same completion task', async () => { const root = new Context(); let release!: () => void; @@ -523,6 +602,23 @@ test('child accessors cannot shadow metadata inherited from parent Contexts', as await root.fiber.dispose(); }); +test('Context metadata cannot replace Fiber ownership or environment topology', async () => { + const root = new Context(); + + for (const field of ['fiber', 'parent', 'root', 'logger']) { + assert.throws( + () => root.extend({ [field]: undefined }), + new RegExp(`Context metadata cannot overwrite owned field: ${field}`, 'u'), + ); + } + + const child = root.extend({ account: 'fixture' }); + assert.equal(child.fiber, root.fiber); + assert.equal(child.parent, root); + assert.equal(child.root, root); + await root.fiber.dispose(); +}); + test('Services cannot shadow metadata inherited from parent Contexts', async () => { const root = new Context(); root.provide('entryMetadata', { value: 'service' }); diff --git a/packages/runtime/src/plugin-kernel.ts b/packages/runtime/src/plugin-kernel.ts index 423968b729..a0bc964963 100644 --- a/packages/runtime/src/plugin-kernel.ts +++ b/packages/runtime/src/plugin-kernel.ts @@ -130,19 +130,22 @@ export interface LoggerService extends Logger { } export interface Context { - root: Context; - parent?: Context; - fiber: Fiber; + readonly root: Context; + readonly parent?: Context; + readonly fiber: Fiber; readonly logger: LoggerService; [Context.filter]?: (listenerContext: Context) => boolean; } +/** A non-owning capability view whose lifecycle and resources belong to exactly one Fiber. */ export class Context { static readonly effect = effectMeta; static readonly filter = Symbol('maka.plugin-kernel.filter'); readonly [contextBrand] = true; readonly #kernel: KernelState; + readonly #parent?: Context; + #fiber!: Fiber; readonly #isolation: Readonly>; readonly #intercepts: Readonly>; readonly #proxy: Context; @@ -168,21 +171,18 @@ export class Context { intercepts?: Readonly>, meta: object = {}, ) { - this.parent = parent; + this.#parent = parent; this.#isolation = isolation ?? parent?._isolation() ?? freezeRecord(); this.#intercepts = intercepts ?? parent?._intercepts() ?? freezeRecord(); + this.#kernel = kernel ?? ({} as KernelState); + this.#proxy = new Proxy(this, contextProxy); if (kernel) { - this.#kernel = kernel; - this.root = kernel.root; - this.fiber = fiber ?? parent?.fiber ?? kernel.root.fiber; + this.#fiber = fiber ?? parent?.fiber ?? kernel.root.fiber; } else { - const placeholder = {} as KernelState; - this.#kernel = placeholder; - this.root = this; - const rootFiber = Fiber.root(this); - this.fiber = rootFiber; - Object.assign(placeholder, { - root: this, + const rootFiber = Fiber.root(this.#proxy); + this.#fiber = rootFiber; + Object.assign(this.#kernel, { + root: this.#proxy, services: new Map(), serviceLabels: new Map(), runtimes: new WeakMap(), @@ -193,34 +193,36 @@ export class Context { closed: false, } satisfies KernelState); } - Object.assign(this, meta); + assignContextMetadata(this, meta); Object.defineProperty(this, 'logger', { enumerable: true, configurable: false, value: createLoggerService(() => this.fiber.name), }); - this.#proxy = new Proxy(this, contextProxy); return this.#proxy; } + get root(): Context { + return this.#kernel.root; + } + + get parent(): Context | undefined { + return this.#parent; + } + + get fiber(): Fiber { + return this.#fiber; + } + extend(meta: object = {}): this { this.#assertOpen(); - return new Context( - this.#kernel, - this, - this.fiber, - this.#isolation, - this.#intercepts, - meta, - ) as this; + return this.fiber.deriveContext(this.#proxy, this.#isolation, this.#intercepts, meta) as this; } isolate(name: string, label = Symbol(name)): this { validateServiceName(name); - return new Context( - this.#kernel, - this, - this.fiber, + return this.fiber.deriveContext( + this.#proxy, freezeRecord({ ...this.#isolation, [name]: label }), this.#intercepts, ) as this; @@ -229,55 +231,24 @@ export class Context { intercept(name: string, config: unknown): this { validateServiceName(name); const existing = this.#intercepts[name] ?? []; - return new Context( - this.#kernel, - this, - this.fiber, + return this.fiber.deriveContext( + this.#proxy, this.#isolation, freezeRecord({ ...this.#intercepts, [name]: Object.freeze([...existing, config]) }), ) as this; } - plugin

(plugin: P, config?: unknown): Fiber & PromiseLike { + plugin

(plugin: P, config?: unknown): Fiber { this.#assertOpen(); - const callback = resolvePlugin(plugin); - let runtime = this.#kernel.runtimes.get(plugin); - if (!runtime) { - runtime = { - callback, - fibers: new Set(), - name: plugin.name, - Config: plugin.Config, - }; - this.#kernel.runtimes.set(plugin, runtime); - } - const fiber = new Fiber(this, plugin, config, normalizeInject(plugin.inject), runtime); - return new Proxy(fiber, { - get(target, property, receiver) { - if (property === 'then') { - return ( - onFulfilled?: ((value: Fiber) => TResult1 | PromiseLike) | null, - onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): PromiseLike => - target - .await() - .then( - () => (onFulfilled ? onFulfilled(target) : (target as unknown as TResult1)), - onRejected ?? undefined, - ); - } - const value = Reflect.get(target, property, target); - return typeof value === 'function' ? value.bind(target) : value; - }, - }) as Fiber & PromiseLike; + return this.fiber.mount(this.#proxy, plugin, config); } - inject(inject: Inject, callback: Plugin.Function): Fiber & PromiseLike { + inject(inject: Inject, callback: Plugin.Function): Fiber { return this.plugin(Object.assign(callback, { inject })); } effect(execute: () => unknown, label = 'anonymous'): Disposable> { - return this.fiber.effect(execute, label); + return this.fiber.own(execute, label); } provide(name: string, value?: unknown, check?: () => boolean): Disposable> { @@ -387,7 +358,7 @@ export class Context { ): Disposable { this.#assertOpen(); const normalized = typeof options === 'boolean' ? { prepend: options } : options; - const hook: Hook = { context: this, listener, global: normalized.global === true }; + const hook: Hook = { context: this.#proxy, listener, global: normalized.global === true }; const hooks = this.#kernel.listeners.get(name) ?? []; let active = true; const unregister = () => { @@ -560,10 +531,21 @@ export class Context { } } +function assignContextMetadata(context: Context, meta: object): void { + for (const key of Reflect.ownKeys(meta)) { + const descriptor = Object.getOwnPropertyDescriptor(meta, key); + if (!descriptor?.enumerable) continue; + if (key === 'logger' || Reflect.has(context, key)) { + throw new Error(`Context metadata cannot overwrite owned field: ${String(key)}`); + } + } + Object.assign(context, meta); +} + const contextProxy: ProxyHandler = { get(target, property, receiver) { if (Reflect.has(target, property)) { - const value = Reflect.get(target, property, receiver); + const value = Reflect.get(target, property, target); return typeof value === 'function' && Object.hasOwn(Context.prototype, property) ? value.bind(target) : value; @@ -579,7 +561,7 @@ const contextProxy: ProxyHandler = { } }, set(target, property, value, receiver) { - if (Reflect.has(target, property)) return Reflect.set(target, property, value, receiver); + if (Reflect.has(target, property)) return Reflect.set(target, property, value, target); const accessor = target._kernel().accessors.get(property); if (accessor?.set) return accessor.set.call(receiver as Context, value, receiver); if (typeof property === 'string' && target.get(property, false) !== undefined) { @@ -604,10 +586,11 @@ function hasAncestorProperty(context: Context | undefined, property: PropertyKey return false; } +/** The sole lifecycle, child-instance, dependency, error, and resource owner at runtime. */ export class Fiber { readonly id: number; - readonly ctx: Context; - readonly parent: Context; + readonly context: Context; + readonly parent?: Fiber; readonly plugin?: Plugin; readonly inject: Readonly>; state: FiberState; @@ -620,6 +603,7 @@ export class Fiber { readonly #effects: Array> & { [effectMeta]?: EffectMeta }> = []; readonly #services = new Map(); #disposed = false; + #cleanupFailed = false; #dependencyRefreshQueued = false; #disposeTask?: Promise; #transition: Promise = Promise.resolve(); @@ -629,30 +613,78 @@ export class Fiber { } constructor( - parent: Context, + parentContext: Context, plugin: Plugin | undefined, config: unknown, inject: Readonly>, runtime: PluginRuntime | undefined, root = false, ) { - this.parent = parent; + this.parent = root ? undefined : parentContext.fiber; this.plugin = plugin; this.config = config; this.inject = inject; this.#runtime = runtime; - const kernel = parent._kernel(); + const kernel = parentContext._kernel(); this.id = root ? 0 : ++kernel.nextFiberId; this.state = root ? FiberState.ACTIVE : FiberState.PENDING; - this.ctx = root ? parent : new Context(kernel, parent, this, undefined, undefined); + this.context = root + ? parentContext + : new Context(kernel, parentContext, this, undefined, undefined); if (!root) { kernel.fibers.add(this); runtime?.fibers.add(this); - parent.fiber.#children.add(this); + if (this.parent) this.parent.#children.add(this); this.refreshDependencies(); } } + deriveContext( + parent: Context = this.context, + isolation?: Readonly>, + intercepts?: Readonly>, + meta: object = {}, + ): Context { + if (parent.fiber !== this) { + throw new Error('Cannot derive a Context view from another Fiber'); + } + if ( + this.#disposed || + this.state === FiberState.DISPOSED || + this.state === FiberState.UNLOADING + ) { + throw new Error('Cannot derive a Context view from an inactive Fiber'); + } + return new Context(parent._kernel(), parent, this, isolation, intercepts, meta); + } + + mount

(context: Context, plugin: P, config?: unknown): Fiber { + if (context.fiber !== this) { + throw new Error('Cannot mount a child through a Context owned by another Fiber'); + } + if ( + this.#disposed || + this.state === FiberState.DISPOSED || + this.state === FiberState.UNLOADING + ) { + throw new Error('Cannot mount a child on an inactive Fiber'); + } + const kernel = context._kernel(); + const callback = resolvePlugin(plugin); + let runtime = kernel.runtimes.get(plugin); + if (!runtime) { + runtime = { + callback, + fibers: new Set(), + name: plugin.name, + Config: plugin.Config, + }; + kernel.runtimes.set(plugin, runtime); + } + const fiber = new Fiber(context, plugin, config, normalizeInject(plugin.inject), runtime); + return fiber; + } + get name(): string { return ( this.#runtime?.name || this.plugin?.name || (this.id === 0 ? 'root' : `plugin-${this.id}`) @@ -664,7 +696,7 @@ export class Fiber { } serviceLabel(name: string): symbol { - return this.ctx._label(name); + return this.context._label(name); } refreshDependencies(): void { @@ -683,7 +715,7 @@ export class Fiber { for (const name of Object.keys(this.inject)) { let implementation: unknown; try { - implementation = this.ctx.get(name); + implementation = this.context.get(name); } catch (error) { const errors = [error]; this.#services.clear(); @@ -725,7 +757,7 @@ export class Fiber { } } - effect(execute: () => unknown, label = 'anonymous'): Disposable> { + own(execute: () => unknown, label = 'anonymous'): Disposable> { if (this.#disposed || this.state === FiberState.UNLOADING) { throw new Error('Cannot create an Effect on an inactive Fiber'); } @@ -833,10 +865,10 @@ export class Fiber { try { await this.#unload(FiberState.DISPOSED); } finally { - const kernel = this.parent._kernel(); + const kernel = this.context._kernel(); kernel.fibers.delete(this); this.#runtime?.fibers.delete(this); - this.parent.fiber.#children.delete(this); + if (this.parent) this.parent.#children.delete(this); if (this.id === 0) kernel.closed = true; } }); @@ -855,18 +887,20 @@ export class Fiber { } async #restart(): Promise { + if (this.#cleanupFailed) throw this.error; await this.#unload(FiberState.PENDING); if (this.#dependenciesAvailable()) await this.#load(); } async #load(): Promise { if (this.#disposed || !this.plugin || !this.#dependenciesAvailable()) return; + if (this.#cleanupFailed) throw this.error; this.error = undefined; this.#setState(FiberState.LOADING); try { const config = await validateConfig(this.#runtime?.Config, this.config); - const output = await invokePlugin(this.plugin, this.ctx, config); - if (typeof output === 'function') this.effect(() => output, `plugin:${this.name}`); + const output = await invokePlugin(this.plugin, this.context, config); + if (typeof output === 'function') this.own(() => output, `plugin:${this.name}`); else if ( output !== undefined && output !== null && @@ -880,6 +914,7 @@ export class Fiber { try { await this.#disposeEffects(); } catch (cleanupError) { + this.#cleanupFailed = true; errors.push(cleanupError); } this.error = @@ -905,8 +940,13 @@ export class Fiber { } catch (error) { errors.push(error); } + if (errors.length) { + this.#cleanupFailed = true; + this.error = new AggregateError(errors, `Fiber ${this.name} cleanup failed`); + this.#setState(nextState === FiberState.DISPOSED ? FiberState.DISPOSED : FiberState.FAILED); + throw this.error; + } this.#setState(nextState); - if (errors.length) throw new AggregateError(errors, `Fiber ${this.name} cleanup failed`); } async #disposeEffects(): Promise { @@ -922,16 +962,16 @@ export class Fiber { } #dependenciesAvailable(): boolean { - return Object.keys(this.inject).every((name) => this.ctx.get(name) !== undefined); + return Object.keys(this.inject).every((name) => this.context.get(name) !== undefined); } #setState(state: FiberState): void { const previous = this.state; this.state = state; if (state === FiberState.ACTIVE) { - notifyProvidedServices(this.parent._kernel(), this); + notifyProvidedServices(this.context._kernel(), this); } - if (previous !== state) this.ctx.emit('internal/status', this, previous); + if (previous !== state) this.context.emit('internal/status', this, previous); } } From 1e4f38385f4df6773e2d07f7149029a3f19cb189 Mon Sep 17 00:00:00 2001 From: Xinhao Xu <84456268+xxhZs@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:22:16 +0800 Subject: [PATCH 009/386] feat(runtime): add global conversation history search tools (#2679) * feat(runtime): add agent history search tools * feat(runtime): inherit history tools in child agents * refactor(runtime): expose global conversation search * fix(runtime): preserve explicit subagent tool ceilings * fix(runtime): make history search lifecycle explicit * fix(runtime): preserve history search contracts * fix(runtime): harden bounded history search Match only redacted history projections, add stable session continuation cursors, and preserve newest messages when unanchored reads hit their byte budget. Generated-by: Codex * fix(desktop): narrow thread search facade exports Generated-by: Codex --- .../src/main/__tests__/thread-search.test.ts | 246 +++++++- .../src/main/runtime-host-search-ipc-main.ts | 25 +- apps/desktop/src/main/search/thread-search.ts | 409 +----------- .../stories/command-search.stories.tsx | 1 - packages/core/package.json | 1 + packages/core/src/search.ts | 36 +- packages/core/src/thread-search.ts | 597 ++++++++++++++++++ packages/core/src/tool-catalog.ts | 2 + .../execution-model-composition.test.ts | 2 + .../src/server/execution-composition.ts | 20 +- packages/runtime/package.json | 1 + .../src/__tests__/history-tools.test.ts | 478 ++++++++++++++ packages/runtime/src/history-tools.ts | 538 ++++++++++++++++ 13 files changed, 1934 insertions(+), 422 deletions(-) create mode 100644 packages/core/src/thread-search.ts create mode 100644 packages/runtime/src/__tests__/history-tools.test.ts create mode 100644 packages/runtime/src/history-tools.ts diff --git a/apps/desktop/src/main/__tests__/thread-search.test.ts b/apps/desktop/src/main/__tests__/thread-search.test.ts index df2299ca58..612eb53676 100644 --- a/apps/desktop/src/main/__tests__/thread-search.test.ts +++ b/apps/desktop/src/main/__tests__/thread-search.test.ts @@ -111,8 +111,8 @@ function makeDeps(entries: Record, privacyPayload: unknown = { in } function expectResults(outcome: SearchOutcome) { - if (!Array.isArray(outcome)) assert.fail(`expected results, got ${outcome.reason}`); - return outcome; + if (!outcome.ok) assert.fail(`expected results, got ${outcome.reason}`); + return outcome.results; } describe('runThreadSearch', () => { @@ -130,8 +130,8 @@ describe('runThreadSearch', () => { ]; for (const [request, reason] of cases) { const outcome = await runThreadSearch(request, makeDeps({})); - assert.equal(Array.isArray(outcome), false); - if (!Array.isArray(outcome)) assert.equal(outcome.reason, reason); + assert.equal(outcome.ok, false); + if (!outcome.ok) assert.equal(outcome.reason, reason); } }); @@ -151,6 +151,119 @@ describe('runThreadSearch', () => { assert.equal(hits.at(-1)?.truncated, true); }); + it('continues beyond the session scan ceiling without gaps', async () => { + const entries: Record = {}; + for (let index = 0; index < 201; index += 1) { + const id = `session-${String(index).padStart(3, '0')}`; + entries[id] = { + // The stable id tie-breaker is part of the cursor contract. + session: session({ id, lastMessageAt: 10_000 }), + messages: index === 200 ? [userMessage('only-oldest-match')] : [], + }; + } + const first = await runThreadSearch( + { source: 'thread', query: 'only-oldest-match', limit: 5 }, + makeDeps(entries), + ); + assert.equal(first.ok, true); + if (!first.ok) return; + assert.deepEqual(first.results, []); + assert.equal(first.truncated, true); + assert.equal(typeof first.nextCursor, 'string'); + + const second = await runThreadSearch( + { + source: 'thread', + query: 'only-oldest-match', + limit: 5, + cursor: first.nextCursor, + }, + makeDeps(entries), + ); + assert.equal(second.ok, true); + if (!second.ok) return; + assert.deepEqual( + second.results.map((result) => + result.target?.kind === 'thread' ? result.target.sessionId : undefined, + ), + ['session-200'], + ); + assert.equal(second.truncated, false); + assert.equal(second.nextCursor, undefined); + + const mismatched = await runThreadSearch( + { source: 'thread', query: 'another-query', limit: 5, cursor: first.nextCursor }, + makeDeps(entries), + ); + assert.equal(mismatched.ok, false); + if (!mismatched.ok) assert.equal(mismatched.reason, 'invalid_query'); + }); + + it('checks cancellation between transcript reads', async () => { + const controller = new AbortController(); + let reads = 0; + const outcome = await runThreadSearch( + { source: 'thread', query: 'needle', limit: 5 }, + { + ...makeDeps({ + newest: { session: session({ id: 'newest', lastMessageAt: 2 }), messages: [] }, + older: { + session: session({ id: 'older', lastMessageAt: 1 }), + messages: [userMessage('needle')], + }, + }), + async readMessages(sessionId, signal) { + reads += 1; + assert.equal(signal, controller.signal); + if (sessionId === 'newest') controller.abort(); + return []; + }, + }, + { abortSignal: controller.signal }, + ); + assert.equal(outcome.ok, false); + if (!outcome.ok) assert.equal(outcome.reason, 'aborted'); + assert.equal(reads, 1); + }); + + it('yields to cancellation while scanning a large transcript', async () => { + const controller = new AbortController(); + const messages = Array.from({ length: 2_000 }, (_, index) => + userMessage(`ordinary message ${index}`, `turn-${index}`, `message-${index}`), + ); + setImmediate(() => controller.abort()); + const outcome = await runThreadSearch( + { source: 'thread', query: 'missing needle', limit: 5 }, + makeDeps({ large: { session: session({ id: 'large' }), messages } }), + { abortSignal: controller.signal }, + ); + assert.equal(outcome.ok, false); + if (!outcome.ok) assert.equal(outcome.reason, 'aborted'); + }); + + it('excludes the active Turn only inside the active Session', async () => { + const hits = expectResults( + await runThreadSearch( + { source: 'thread', query: 'copied text', limit: 10 }, + makeDeps({ + active: { + session: session({ id: 'active', lastMessageAt: 2 }), + messages: [userMessage('copied text active', 'shared-turn', 'active-message')], + }, + branch: { + session: session({ id: 'branch', lastMessageAt: 1 }), + messages: [userMessage('copied text branch', 'shared-turn', 'branch-message')], + }, + }), + { activeSessionId: 'active', excludeTurnIds: new Set(['shared-turn']) }, + ), + ); + assert.deepEqual( + hits.map((hit) => (hit.target?.kind === 'thread' ? hit.target.sessionId : undefined)), + ['branch'], + ); + }); + it('redacts snippets and excludes fake-backend and archived sessions', async () => { const hits = expectResults( await runThreadSearch( @@ -180,6 +293,83 @@ describe('runThreadSearch', () => { assert.equal(hits[0]?.snippet?.includes('sk-ant-test-secret-token-12345'), false); }); + it('matches only redacted projections and rejects secret-shaped queries', async () => { + const entries = { + title: { + session: session({ id: 'title', name: 'password=title-secret-value' }), + messages: [], + }, + message: { + session: session({ id: 'message' }), + messages: [userMessage('token=message-secret-value')], + }, + intent: { + session: session({ id: 'intent' }), + messages: [toolCall('api_key=intent-secret-value')], + }, + result: { + session: session({ id: 'result' }), + messages: [toolResult({ password: 'result-secret-value' })], + }, + }; + + for (const query of [ + 'title-secret-value', + 'title-wrong-value', + 'message-secret-value', + 'message-wrong-value', + 'intent-secret-value', + 'intent-wrong-value', + 'result-secret-value', + 'result-wrong-value', + ]) { + assert.deepEqual( + expectResults( + await runThreadSearch({ source: 'thread', query, limit: 10 }, makeDeps(entries)), + ), + [], + ); + } + + for (const query of [ + 'sk-ant-correctsecret12345678', + 'sk-ant-wrongsecret123456789', + ]) { + const outcome = await runThreadSearch( + { source: 'thread', query, limit: 5 }, + makeDeps(entries), + ); + assert.equal(outcome.ok, false); + if (!outcome.ok) assert.equal(outcome.reason, 'invalid_query'); + } + }); + + it('includes archived sessions only when the caller explicitly opts in', async () => { + const entries = { + archived: { + session: session({ id: 'archived', isArchived: true }), + messages: [userMessage('archived needle')], + }, + }; + assert.deepEqual( + expectResults( + await runThreadSearch( + { source: 'thread', query: 'archived needle', limit: 5 }, + makeDeps(entries), + ), + ), + [], + ); + const optedIn = expectResults( + await runThreadSearch( + { source: 'thread', query: 'archived needle', limit: 5 }, + makeDeps(entries), + { includeArchived: true }, + ), + ); + assert.equal(optedIn[0]?.target?.kind === 'thread' && optedIn[0].target.sessionId, 'archived'); + }); + it('searches only the current committed conversation revision', async () => { const hits = expectResults( await runThreadSearch( @@ -233,7 +423,11 @@ describe('runThreadSearch', () => { const titleHit = expectResults( await runThreadSearch({ source: 'thread', query: 'roadmap', limit: 5 }, makeDeps(entries)), )[0]!; - assert.deepEqual(titleHit.target, { kind: 'thread', sessionId: 's1' }); + assert.deepEqual(titleHit.target, { + kind: 'thread', + sessionId: 's1', + matchKind: 'session_title', + }); assert.equal(titleHit.summary, '任务标题'); assert.equal(titleHit.url, undefined); assert.match(titleHit.snippet ?? '', /\[redacted\]/); @@ -247,6 +441,9 @@ describe('runThreadSearch', () => { sessionId: 's1', turnId: 'turn-user', sequence: 0, + messageId: 'u1', + matchKind: 'user_message', + messageTimestamp: 1_700_000_000_000, }); assert.equal(messageHit.summary, '用户消息'); assert.equal(messageHit.url, undefined); @@ -259,9 +456,11 @@ describe('runThreadSearch', () => { const deps = makeDeps(entries); assert.deepEqual( - await runThreadSearch( + expectResults( + await runThreadSearch( { source: 'thread', query: 'diagnostic', limit: 5 }, { ...deps, readMessages: async () => null }, + ), ), [], ); @@ -293,8 +492,8 @@ describe('runThreadSearch', () => { }, }, ); - assert.equal(Array.isArray(outcome), false); - if (!Array.isArray(outcome)) { + assert.equal(outcome.ok, false); + if (!outcome.ok) { assert.equal(outcome.reason, 'incognito_active'); assert.match( outcome.message, @@ -321,11 +520,22 @@ describe('thread search text projection', () => { assert.ok(capped.endsWith('…')); }); - it('bounds serialized tool results', () => { + it('bounds and classifies serialized tool results', async () => { assert.equal(collectSearchableText(toolResult({ result: 'short' })), '{"result":"short"}'); const extracted = collectSearchableText(toolResult({ data: 'X'.repeat(100_000) })); assert.ok(extracted); assert.ok(Buffer.byteLength(extracted, 'utf8') <= TOOL_RESULT_SCAN_CAP_BYTES); + + const hits = expectResults( + await runThreadSearch( + { source: 'thread', query: 'short', limit: 5 }, + makeDeps({ + s1: { session: session({ id: 's1' }), messages: [toolResult({ result: 'short' })] }, + }), + ), + ); + assert.equal(hits[0]?.target?.matchKind, 'tool_result'); + assert.equal(hits[0]?.target?.messageId, 'tr1'); }); it('indexes tool intent but not tool names or display names', async () => { @@ -343,15 +553,15 @@ describe('thread search text projection', () => { [], ); } - assert.equal( - expectResults( - await runThreadSearch( - { source: 'thread', query: 'disk usage', limit: 5 }, - makeDeps(entries), - ), - ).length, - 1, + const hits = expectResults( + await runThreadSearch( + { source: 'thread', query: 'disk usage', limit: 5 }, + makeDeps(entries), + ), ); + assert.equal(hits.length, 1); + assert.equal(hits[0]?.target?.matchKind, 'tool_intent'); + assert.equal(hits[0]?.target?.messageId, 'tc1'); }); it('indexes assistant answers without exposing thinking', async () => { @@ -378,6 +588,8 @@ describe('thread search text projection', () => { ), ); assert.equal(visible.length, 1); + assert.equal(visible[0]?.target?.matchKind, 'assistant_message'); + assert.equal(visible[0]?.target?.messageId, 'a1'); assert.equal(visible[0]?.snippet?.includes('private reasoning'), false); }); diff --git a/apps/desktop/src/main/runtime-host-search-ipc-main.ts b/apps/desktop/src/main/runtime-host-search-ipc-main.ts index 8323baca9d..6fe5e4b07f 100644 --- a/apps/desktop/src/main/runtime-host-search-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-search-ipc-main.ts @@ -17,7 +17,7 @@ * under the License. */ -import { runThreadSearch } from './search/thread-search.js'; +import type { SearchResult } from '@maka/core/search'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import { toDesktopHostSessionSummary } from './runtime-host-session-catalog-ipc-main.js'; import { @@ -25,6 +25,7 @@ import { readWithFallback, type ReconnectableReadIpcMain, } from './ipc-reconnect-policy.js'; +import { runThreadSearch } from './search/thread-search.js'; interface RuntimeHostSearchIpcDeps { readonly ipcMain: ReconnectableReadIpcMain; @@ -37,8 +38,8 @@ interface RuntimeHostSearchIpcDeps { export function registerRuntimeHostSearchIpc( deps: RuntimeHostSearchIpcDeps, ): void { - handleReconnectableRead(deps.ipcMain, 'search:thread', (_event, request: unknown) => - runThreadSearch(request, { + handleReconnectableRead(deps.ipcMain, 'search:thread', async (_event, request: unknown) => { + const result = await runThreadSearch(request, { listSessions: async () => (await deps.client.listSessions()).map(toDesktopHostSessionSummary), readMessages: (sessionId) => @@ -54,6 +55,20 @@ export function registerRuntimeHostSearchIpc( incognitoActive: (await deps.client.queryRuntimePolicy()).policy.privacy .incognitoActive, }), - }), - ); + }); + return result.ok ? result.results.map(projectDesktopSearchResult) : result; + }); +} + +function projectDesktopSearchResult(result: SearchResult): SearchResult { + if (!result.target) return result; + return { + ...result, + target: { + kind: result.target.kind, + sessionId: result.target.sessionId, + ...(result.target.turnId !== undefined ? { turnId: result.target.turnId } : {}), + ...(result.target.sequence !== undefined ? { sequence: result.target.sequence } : {}), + }, + }; } diff --git a/apps/desktop/src/main/search/thread-search.ts b/apps/desktop/src/main/search/thread-search.ts index 43d4d1c8e5..55fee8142f 100644 --- a/apps/desktop/src/main/search/thread-search.ts +++ b/apps/desktop/src/main/search/thread-search.ts @@ -18,399 +18,16 @@ */ /** - * Local thread / session search — bounded scan, no FTS5. - * - * Anchors: - * - Current behavior is pinned by the focused thread-search tests. - * - Contract: `@maka/core/search` (PR-SEARCH-0 + PR-SEARCH-1.5 `SearchResultTarget`). - * - Implementation lane greenlight: xuan msg `074714c7`. - * - * Scope (this module, PR-SEARCH-2): - * - Pure helper. Accepts an injected `ThreadSearchDeps` so unit tests can - * supply fake `listSessions` / `readMessages` without an Electron runtime. - * - Bounded substring scan over user-visible message types only: - * UserMessage / AssistantMessage / ToolCallMessage / ToolResultMessage. - * Excluded: SystemNoteMessage / TokenUsageMessage / TurnStateMessage / - * PermissionDecisionMessage. - * - Excludes sessions with `backend === 'fake'` (retired local simulation, - * plus the e2e fixtures that still seed it) and archived sessions - * (managed in Settings › 活动 › 已归档任务). - * - Snippets are redacted via `@maka/core/redaction.redactSecrets()`. - * - `ToolResultMessage.content` is JSON-serialized for scan and capped to - * the first `TOOL_RESULT_SCAN_CAP_BYTES` bytes (worst-case bound). - * - Result limits come from `@maka/core/search.normalizeSearchLimit` - * (default 5, max `SEARCH_MAX_LIMIT=10`). - * - Total payload bytes (sum of snippets) capped at `TOTAL_PAYLOAD_CAP_BYTES`. - * - Per-result snippet capped at `SNIPPET_MAX_CODE_POINTS`. - * - Returns `SearchResult[]` per PR-SEARCH-0 shape with - * `source: 'thread'` and `target: { kind:'thread', sessionId, turnId? }` - * per PR-SEARCH-1.5. `url` is left undefined (thread navigation does NOT - * use `maka://session` — see `packages/ui/src/maka-uri.ts:24`). - * - * Hard no-go (enforced by source gate at review): - * - No `fetch` / `XMLHttpRequest` / `new WebSocket` / `BrowserWindow`. - * - No `electron` imports — runs in main but stays Electron-agnostic via DI. - * - No FTS5 / SQLite / better-sqlite3. - * - No telemetry emission of query body. - * - No `maka://session` URI construction. - */ - -import { collapseSessionRevisions } from '@maka/core/session-revisions'; - -import { normalizeSearchLimit, normalizeSearchQuery } from '@maka/core/search'; - -import { redactSecrets } from '@maka/core/redaction'; - -import { validateWorkspacePrivacyContext } from '@maka/core/incognito'; -import type { SearchErrorReason, SearchResult } from '@maka/core/search'; - -import type { SessionSummary, StoredMessage } from '@maka/core/session'; - -/** Max scan bytes per ToolResultMessage.content (JSON-serialized). */ -export const TOOL_RESULT_SCAN_CAP_BYTES = 10_240; - -/** Max code points retained in a result snippet. */ -export const SNIPPET_MAX_CODE_POINTS = 240; - -/** Half-window of snippet context characters on each side of the match. */ -export const SNIPPET_CONTEXT_HALF = 80; - -/** Cap on total snippet bytes (UTF-8) summed across all results. */ -export const TOTAL_PAYLOAD_CAP_BYTES = 64 * 1024; - -/** Max sessions scanned per query (newest first by lastMessageAt). */ -export const MAX_SESSIONS_SCANNED = 200; - -/** Returned source kind — locked to `'thread'` in v1. */ -export const THREAD_SOURCE = 'thread' as const; - -/** - * Pure dependency injection. Production wiring binds these to the real - * runtime; tests pass in-memory fakes. - * - * PR-SEARCH-2.5 (@xuan msg `2c55b975`): `getPrivacyContext` returns the - * main-authority workspace privacy snapshot. Source is `unknown` - * because even though the production wiring controls it, the helper - * itself MUST validate via `validateWorkspacePrivacyContext` — a - * future swap to a real authority (settings IPC etc.) must not bypass - * the validator. Renderer payloads MUST NOT reach this dep; production - * wiring binds it to a main-side authority only. - */ -export interface ThreadSearchDeps { - listSessions(): Promise; - readMessages(sessionId: string): Promise; - /** - * Main-authority workspace privacy snapshot. Returned as `unknown` - * deliberately — the helper validates the payload with - * `validateWorkspacePrivacyContext` before reading any field. Source - * MUST be main-side (settings, workspace owner). Renderer payloads - * MUST NOT flow into this dep. - */ - getPrivacyContext(): Promise; -} - -/** - * Public API surface. The IPC handler in `main.ts` wraps this; nothing - * else should call it directly. - * - * Accepts `unknown` because the IPC payload crosses a process boundary — - * TypeScript's `SearchRequest` annotation in the handler is compile-time - * only. A renderer can send anything; malformed input must fail closed - * with an error envelope. Dependency adapters project ordinary I/O - * failures before calling this function. Same defense pattern as PR-MEMORY-1 - * `validateMemoryWriteRequest` and PR-UI-IPC-1 baseUrl normalize - * (@xuan msg `2f1aba55` fixup). - */ -export async function runThreadSearch( - request: unknown, - deps: ThreadSearchDeps, -): Promise { - // L1: runtime shape guard. Renderer payload is untrusted across the - // IPC boundary. Null / non-object / missing fields → typed reject. - if (typeof request !== 'object' || request === null || Array.isArray(request)) { - return { ok: false, reason: 'invalid_query', message: 'search request must be an object' }; - } - const record = request as Record; - - // L2: source enum gate — this module only handles `'thread'`. The - // shape check above already rejected non-objects, so reading - // `record.source` is safe. - if (record.source !== THREAD_SOURCE) { - return { ok: false, reason: 'disabled', message: 'thread search only handles source=thread' }; - } - - // L3: query / limit normalization via @maka/core helpers — single - // chokepoint, never bypass. Both already guard typeof + finite. - const queryResult = normalizeSearchQuery(record.query); - if (!queryResult.ok) { - return queryResult; - } - const limitResult = normalizeSearchLimit(record.limit); - if (!limitResult.ok) { - return limitResult; - } - - // L4: privacy gate (PR-SEARCH-2.5 @xuan `2c55b975`). Main-owned - // privacy authority. Two early-return paths share the same - // `reason:'incognito_active'` to avoid an extra UI state: - // - active incognito (user toggled on): `incognitoActive === true` - // - malformed authority payload (system fail-closed): validator - // reject treated as if incognito were active - // Both paths MUST NOT touch `listSessions` / `readMessages`. - // Distinguishing message wording is kept for diagnostics; consumers - // can read `message` if they need to differentiate. - const privacyPayload = await deps.getPrivacyContext(); - const privacyResult = validateWorkspacePrivacyContext(privacyPayload); - if (!privacyResult.ok) { - return { - ok: false, - reason: 'incognito_active', - message: 'Search is disabled because workspace privacy state could not be verified.', - }; - } - if (privacyResult.value.incognitoActive) { - return { - ok: false, - reason: 'incognito_active', - message: 'Search is disabled while incognito is active.', - }; - } - - const queryFolded = foldForMatch(queryResult.value); - const maxResults = limitResult.value; - - const sessions = collapseSessionRevisions(await deps.listSessions()) - // Exclude fake-backend sessions. The rail still shows them (marked - // stale) because they are task records, but their transcripts are - // simulator output — returning fabricated text as a hit on the user's own - // history is worse than returning nothing. Retiring the backend (#3211) - // did not make that content real, so the filter stays. - // - // Archived tasks are excluded for the same reason the command palette - // skips them: archiving a task says it is out of the working set, and a - // result you cannot land on anywhere but Settings is not a chat hit. They - // also stop consuming the `MAX_SESSIONS_SCANNED` budget, which they shared - // with active tasks while being unreachable from the rail. - .filter((session) => session.backend !== 'fake' && !session.isArchived) - // Newest first by lastMessageAt; secondary by id for determinism. - .sort((a, b) => { - const ts = (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0); - if (ts !== 0) return ts; - return a.id.localeCompare(b.id); - }) - .slice(0, MAX_SESSIONS_SCANNED); - - const results: SearchResult[] = []; - let totalBytes = 0; - let truncated = false; - - for (const session of sessions) { - if (results.length >= maxResults) { - truncated = true; - break; - } - - const titleHit = findMatch(session.name, queryFolded); - if (titleHit !== undefined) { - const snippet = capCodePoints( - redactSecrets(buildSnippet(session.name, titleHit, SNIPPET_CONTEXT_HALF)), - SNIPPET_MAX_CODE_POINTS, - ); - const snippetBytes = Buffer.byteLength(snippet, 'utf8'); - if (totalBytes + snippetBytes > TOTAL_PAYLOAD_CAP_BYTES) { - truncated = true; - break; - } - totalBytes += snippetBytes; - results.push({ - source: THREAD_SOURCE, - title: session.name, - summary: '任务标题', - snippet, - target: { - kind: 'thread', - sessionId: session.id, - }, - }); - if (results.length >= maxResults) { - truncated = true; - break; - } - } - - const messages = await deps.readMessages(session.id); - if (!messages) continue; - - for (const [sequence, message] of messages.entries()) { - if (results.length >= maxResults) { - truncated = true; - break; - } - - const candidate = collectSearchableText(message); - if (candidate === undefined) continue; - - const hit = findMatch(candidate, queryFolded); - if (hit === undefined) continue; - - // Build the snippet, redact secrets, cap length. - const snippet = capCodePoints( - redactSecrets(buildSnippet(candidate, hit, SNIPPET_CONTEXT_HALF)), - SNIPPET_MAX_CODE_POINTS, - ); - - const snippetBytes = Buffer.byteLength(snippet, 'utf8'); - if (totalBytes + snippetBytes > TOTAL_PAYLOAD_CAP_BYTES) { - truncated = true; - break; - } - totalBytes += snippetBytes; - - const turnId = (message as { turnId?: string }).turnId; - results.push({ - source: THREAD_SOURCE, - title: session.name, - summary: formatSearchResultSummary(message), - snippet, - // PR-SEARCH-1.5: navigation target via discriminated union; no - // `url` field for thread results (maka://session is deferred). - target: { - kind: 'thread', - sessionId: session.id, - ...(turnId ? { turnId } : {}), - sequence, - }, - }); - } - } - - if (truncated && results.length > 0) { - results[results.length - 1] = { ...results[results.length - 1]!, truncated: true }; - } - - return results; -} - -export function formatSearchResultSummary(message: StoredMessage): string { - switch (message.type) { - case 'user': - return '用户消息'; - case 'assistant': - return '助手回复'; - case 'tool_call': - return message.displayName ? `工具调用:${message.displayName}` : `工具调用:${message.toolName}`; - case 'tool_result': - return message.isError ? '工具结果:失败' : '工具结果:成功'; - case 'permission_decision': - return '权限记录'; - case 'token_usage': - return '用量记录'; - case 'turn_state': - return '回合状态'; - case 'system_note': - return '系统记录'; - } -} - -/** - * Extract user-visible answer text from a stored message. Returns `undefined` - * for excluded message kinds (system notes, token usage, turn state, - * permission decisions). This is the only "what counts as searchable - * transcript content" gate; adding new searchable surfaces requires - * extending this switch + a corresponding test. - * - * For ToolResultMessage, the `content` is JSON-serialized and capped - * at `TOOL_RESULT_SCAN_CAP_BYTES` so a 100 MB tool result doesn't - * inflate scan time. - */ -export function collectSearchableText(message: StoredMessage): string | undefined { - switch (message.type) { - case 'user': - // Prefer the human-facing view so skill-invocation envelopes do not - // dominate local search hits for what the user actually typed. - return message.displayText ?? message.text; - case 'assistant': - // Search result snippets are a transcript surface. Assistant - // reasoning/thinking may be rendered separately in the live chat, - // but it is not answer text and must not leak into local search. - return message.text; - case 'tool_call': - // PR-SEARCH-2 review fixup (@xuan `2f1aba55`): index ONLY - // `intent` — the user-visible description of what the tool call - // is doing. `toolName` (e.g. `Bash`) and `displayName` are - // internal labels and would let searches for `Bash` match every - // bash invocation regardless of intent. The PR-SEARCH-1 plan - // already locked `intent` as the only searchable field on - // `ToolCallMessage`; the previous draft over-indexed by mistake. - return message.intent && message.intent.length > 0 ? message.intent : undefined; - case 'tool_result': { - // Bounded JSON-serialize. The cap protects against pathological - // multi-MB tool outputs (file dumps, etc.). - let serialized: string; - try { - serialized = JSON.stringify(message.content); - } catch { - return undefined; - } - if (Buffer.byteLength(serialized, 'utf8') > TOOL_RESULT_SCAN_CAP_BYTES) { - // Truncate to the cap. Use byte-safe slice via Buffer. - const buf = Buffer.from(serialized, 'utf8').subarray(0, TOOL_RESULT_SCAN_CAP_BYTES); - return buf.toString('utf8'); - } - return serialized; - } - case 'permission_decision': - case 'token_usage': - case 'turn_state': - case 'system_note': - // Excluded — not user-typed / not user-visible content. - return undefined; - } -} - -/** - * NFC + lowercase canonicalization for substring match. NOT a security - * boundary — purely for case-insensitive + composed-form matching. - * - * Public for tests; production callers use `runThreadSearch` only. - */ -export function foldForMatch(value: string): string { - return value.normalize('NFC').toLowerCase(); -} - -/** - * Find the index of the first occurrence of `queryFolded` in `text` - * (after the same fold operation). Returns the index in the original - * (unfolded) text — JS `String.prototype.toLowerCase()` preserves - * code-point indexing for ASCII and most CJK, which is what we need - * for snippet extraction. Returns `undefined` on no match. - */ -export function findMatch(text: string, queryFolded: string): number | undefined { - const folded = foldForMatch(text); - const idx = folded.indexOf(queryFolded); - return idx >= 0 ? idx : undefined; -} - -/** - * Extract a context window around the match. Pure substring + ellipsis - * marker; no HTML, no markup. Caller is responsible for redaction + - * length cap afterward. - */ -export function buildSnippet(text: string, matchIndex: number, halfWindow: number): string { - const start = Math.max(0, matchIndex - halfWindow); - const end = Math.min(text.length, matchIndex + halfWindow); - const prefix = start > 0 ? '…' : ''; - const suffix = end < text.length ? '…' : ''; - return prefix + text.slice(start, end).replace(/\s+/g, ' ').trim() + suffix; -} - -/** - * Cap a string to at most `maxCodePoints` code points. Uses - * `Array.from` so surrogate pairs (emoji) are not split. Appends - * an ellipsis when truncated. - */ -export function capCodePoints(value: string, maxCodePoints: number): string { - const codePoints = Array.from(value); - if (codePoints.length <= maxCodePoints) return value; - return codePoints.slice(0, maxCodePoints - 1).join('') + '…'; -} + * Desktop compatibility facade for the shared local transcript search. + * Runtime Host Agent tools and the Desktop search modal intentionally use the + * same bounded, redacted implementation. + */ +export { + SNIPPET_MAX_CODE_POINTS, + TOOL_RESULT_SCAN_CAP_BYTES, + capCodePoints, + collectSearchableText, + findMatch, + foldForMatch, + runThreadSearch, +} from '@maka/core/thread-search'; diff --git a/apps/desktop/stories/command-search.stories.tsx b/apps/desktop/stories/command-search.stories.tsx index bb57ce62c0..03d79a56a6 100644 --- a/apps/desktop/stories/command-search.stories.tsx +++ b/apps/desktop/stories/command-search.stories.tsx @@ -232,4 +232,3 @@ export const SearchModalResults: Story = { await enterQuery(canvasElement, '[data-maka-contract="search-modal"] input', 'benchmark'); }, }; - diff --git a/packages/core/package.json b/packages/core/package.json index 33a91f50ea..88bb6d7655 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -109,6 +109,7 @@ "./session-send-projection": "./dist/session-send-projection.js", "./session-name": "./dist/session-name.js", "./tool-catalog": "./dist/tool-catalog.js", + "./thread-search": "./dist/thread-search.js", "./agent-graph-timeline": "./dist/agent-graph-timeline.js", "./agent-swarm": "./dist/agent-swarm.js", "./bot-chat-settings": "./dist/bot-chat-settings.js", diff --git a/packages/core/src/search.ts b/packages/core/src/search.ts index f3171d96b8..b3a0a58bc4 100644 --- a/packages/core/src/search.ts +++ b/packages/core/src/search.ts @@ -28,6 +28,15 @@ const TRACKING_PARAM_NAMES = new Set(['fbclid', 'gclid', 'yclid', 'mc_cid', 'mc_ export type SearchSourceKind = 'web' | 'web_fetch' | 'thread' | 'memory' | 'activity' | 'tool'; +export const THREAD_SEARCH_MATCH_KINDS = [ + 'session_title', + 'user_message', + 'assistant_message', + 'tool_intent', + 'tool_result', +] as const; +export type ThreadSearchMatchKind = (typeof THREAD_SEARCH_MATCH_KINDS)[number]; + export type SearchProviderKind = 'disabled' | 'api' | 'browser_scrape' | 'local'; export type SearchErrorReason = @@ -64,6 +73,8 @@ export interface SearchRequest { source: SearchSourceKind; query: string; limit: number; + /** Opaque continuation returned by a previous bounded search page. */ + cursor?: string; allowedDomains?: string[]; blockedDomains?: string[]; includeMarkdown?: boolean; @@ -78,12 +89,35 @@ export interface WebFetchRequest { refresh?: boolean; } -/** Non-URL navigation target; web results continue to use `url`. */ +/** + * Optional navigation target for a `SearchResult`. + * + * PR-SEARCH-1.5 (@xuan msg `772d8198`): a closed discriminated union so + * source-kind-specific identifiers (thread sessionId / turnId, future + * memory entry id, future activity timestamp range, etc.) stay typed and + * isolated. Adding a new variant is an explicit contract change. + * + * Today only `'thread'` exists. `web` / `web_fetch` results continue to + * use `SearchResult.url` for navigation; they do NOT need a `target`. + * + * Note: thread navigation deliberately does NOT use `maka://session/` + * URIs — `packages/ui/src/maka-uri.ts:24` defers that scheme until a real + * session navigation contract exists. Consumers of `SearchResultTarget` + * route via the existing renderer-side session-pane state (sessionId → + * load session, turnId → scroll-into-view), NOT via a URL router. + */ export type SearchResultTarget = { kind: 'thread'; sessionId: string; turnId?: string; + /** Existing transcript pagination/navigation coordinate used by Desktop. */ sequence?: number; + /** Stable message anchor for Agent reads; absent for session-title matches. */ + messageId?: string; + /** Stable machine-readable classification of the matched transcript surface. */ + matchKind?: ThreadSearchMatchKind; + /** Timestamp of the matched stored message; absent for session-title matches. */ + messageTimestamp?: number; }; export interface SearchResult { diff --git a/packages/core/src/thread-search.ts b/packages/core/src/thread-search.ts new file mode 100644 index 0000000000..881124d3e5 --- /dev/null +++ b/packages/core/src/thread-search.ts @@ -0,0 +1,597 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Local thread / session search — bounded scan, no FTS5. + * + * Anchors: + * - Current behavior is pinned by the focused thread-search tests. + * - Contract: `@maka/core/search` (PR-SEARCH-0 + PR-SEARCH-1.5 `SearchResultTarget`). + * - Implementation lane greenlight: xuan msg `074714c7`. + * + * Scope (this module, PR-SEARCH-2): + * - Pure helper. Accepts an injected `ThreadSearchDeps` so unit tests can + * supply fake `listSessions` / `readMessages` without an Electron runtime. + * - Bounded substring scan over user-visible message types only: + * UserMessage / AssistantMessage / ToolCallMessage / ToolResultMessage. + * Excluded: SystemNoteMessage / TokenUsageMessage / TurnStateMessage / + * PermissionDecisionMessage. + * - Excludes sessions with `backend === 'fake'` (retired local simulation, + * plus the e2e fixtures that still seed it). Desktop also excludes archived + * sessions; Agent global history opts in to them explicitly. + * - Snippets are redacted via `@maka/core/redaction.redactSecrets()`. + * - `ToolResultMessage.content` is JSON-serialized for scan and capped to + * the first `TOOL_RESULT_SCAN_CAP_BYTES` bytes (worst-case bound). + * - Result limits come from `@maka/core/search.normalizeSearchLimit` + * (default 5, max `SEARCH_MAX_LIMIT=10`). + * - Total payload bytes (sum of snippets) capped at `TOTAL_PAYLOAD_CAP_BYTES`. + * - Per-result snippet capped at `SNIPPET_MAX_CODE_POINTS`. + * - Returns a success envelope containing `SearchResult[]` plus an explicit + * scan-truncation bit, with each result following the PR-SEARCH-0 shape and + * `source: 'thread'` and `target: { kind:'thread', sessionId, turnId? }` + * per PR-SEARCH-1.5, extended with stable message id, match kind, and + * timestamp anchors for Agent global search. `url` is left undefined + * (thread navigation does NOT use `maka://session`). + * + * Hard no-go (enforced by source gate at review): + * - No `fetch` / `XMLHttpRequest` / `new WebSocket` / `BrowserWindow`. + * - No `electron` imports — runs in main but stays Electron-agnostic via DI. + * - No FTS5 / SQLite / better-sqlite3. + * - No telemetry emission of query body. + * - No `maka://session` URI construction. + */ + +import { validateWorkspacePrivacyContext } from './incognito.js'; +import { redactSecrets } from './redaction.js'; +import { normalizeSearchLimit, normalizeSearchQuery } from './search.js'; +import type { SearchErrorReason, SearchResult, ThreadSearchMatchKind } from './search.js'; +import { collapseSessionRevisions } from './session-revisions.js'; +import type { SessionSummary, StoredMessage } from './session.js'; + +/** Max scan bytes per ToolResultMessage.content (JSON-serialized). */ +export const TOOL_RESULT_SCAN_CAP_BYTES = 10_240; + +/** Max code points retained in a result snippet. */ +export const SNIPPET_MAX_CODE_POINTS = 240; + +/** Half-window of snippet context characters on each side of the match. */ +export const SNIPPET_CONTEXT_HALF = 80; + +/** Cap on total snippet bytes (UTF-8) summed across all results. */ +export const TOTAL_PAYLOAD_CAP_BYTES = 64 * 1024; + +/** Max sessions scanned per query (newest first by lastMessageAt). */ +export const MAX_SESSIONS_SCANNED = 200; + +/** Max encoded bytes accepted for an opaque thread-search continuation. */ +export const THREAD_SEARCH_CURSOR_MAX_CHARS = 2_048; + +/** Returned source kind — locked to `'thread'` in v1. */ +export const THREAD_SOURCE = 'thread' as const; + +/** + * Pure dependency injection. Production wiring binds these to the real + * runtime; tests pass in-memory fakes. + * + * PR-SEARCH-2.5 (@xuan msg `2c55b975`): `getPrivacyContext` returns the + * Host-authority workspace privacy snapshot. Source is `unknown` + * because even though production wiring controls it, the helper + * itself MUST validate via `validateWorkspacePrivacyContext` — a + * future swap to a real authority (settings IPC etc.) must not bypass + * the validator. Renderer payloads MUST NOT reach this dep; production + * wiring binds it to a main-side authority only. + */ +export interface ThreadSearchDeps { + listSessions(): Promise; + readMessages(sessionId: string, abortSignal?: AbortSignal): Promise; + /** + * Host-authority workspace privacy snapshot. Returned as `unknown` + * deliberately — the helper validates the payload with + * `validateWorkspacePrivacyContext` before reading any field. Source + * MUST be Host-side (Runtime Host policy, Desktop settings authority, + * or workspace owner). Untrusted request payloads MUST NOT flow into this dep. + */ + getPrivacyContext(): Promise; +} + +export interface ThreadSearchSuccess { + readonly ok: true; + readonly results: SearchResult[]; + readonly truncated: boolean; + /** Present only when another complete session-scan page is reachable. */ + readonly nextCursor?: string; +} + +interface ThreadSearchCursor { + readonly version: 1; + readonly query: string; + readonly lastMessageAt: number; + readonly sessionId: string; +} + +/** + * Shared API surface. Desktop IPC and Runtime Host Agent tools wrap this + * helper with their own authority-owned dependencies. + * + * Accepts `unknown` because the IPC payload crosses a process boundary — + * TypeScript's `SearchRequest` annotation in the handler is compile-time + * only. A renderer can send anything; malformed input must fail closed + * with an error envelope. Dependency adapters project ordinary I/O + * failures before calling this function. Same defense pattern as PR-MEMORY-1 + * `validateMemoryWriteRequest` and PR-UI-IPC-1 baseUrl normalize + * (@xuan msg `2f1aba55` fixup). + */ +export async function runThreadSearch( + request: unknown, + deps: ThreadSearchDeps, + options: { + readonly activeSessionId?: string; + readonly excludeSessionIds?: ReadonlySet; + /** Desktop excludes archived tasks by default; Agent global history opts in explicitly. */ + readonly includeArchived?: boolean; + /** Keeps Agent global search from matching the user/tool text of its active turn. */ + readonly excludeTurnIds?: ReadonlySet; + readonly abortSignal?: AbortSignal; + } = {}, +): Promise { + if (options.abortSignal?.aborted) return abortedSearch(); + // L1: runtime shape guard. Renderer payload is untrusted across the + // IPC boundary. Null / non-object / missing fields → typed reject. + if (typeof request !== 'object' || request === null || Array.isArray(request)) { + return { ok: false, reason: 'invalid_query', message: 'search request must be an object' }; + } + const record = request as Record; + + // L2: source enum gate — this module only handles `'thread'`. The + // shape check above already rejected non-objects, so reading + // `record.source` is safe. + if (record.source !== THREAD_SOURCE) { + return { ok: false, reason: 'disabled', message: 'thread search only handles source=thread' }; + } + + // L3: query / limit normalization via @maka/core helpers — single + // chokepoint, never bypass. Both already guard typeof + finite. + const queryResult = normalizeSearchQuery(record.query); + if (!queryResult.ok) { + return queryResult; + } + const limitResult = normalizeSearchLimit(record.limit); + if (!limitResult.ok) { + return limitResult; + } + + // Matching a secret-shaped query against raw history would expose a + // hit/no-hit membership oracle even if the returned snippet were redacted. + // Reject such queries before touching the history authority, and match every + // searchable field only after applying the same redaction projection. + const redactedQuery = redactSecrets(queryResult.value); + if (redactedQuery !== queryResult.value) { + return { + ok: false, + reason: 'invalid_query', + message: 'Search query contains credential material and cannot be searched.', + }; + } + const queryFolded = foldForMatch(redactedQuery); + const cursorResult = decodeThreadSearchCursor(record.cursor, queryFolded); + if (!cursorResult.ok) return cursorResult; + + // L4: privacy gate (PR-SEARCH-2.5 @xuan `2c55b975`). Host-owned + // privacy authority. Two early-return paths share the same + // `reason:'incognito_active'` to avoid an extra UI state: + // - active incognito (user toggled on): `incognitoActive === true` + // - malformed authority payload (system fail-closed): validator + // reject treated as if incognito were active + // Both paths MUST NOT touch `listSessions` / `readMessages`. + // Distinguishing message wording is kept for diagnostics; consumers + // can read `message` if they need to differentiate. + const privacyPayload = await deps.getPrivacyContext(); + if (options.abortSignal?.aborted) return abortedSearch(); + const privacyResult = validateWorkspacePrivacyContext(privacyPayload); + if (!privacyResult.ok) { + return { + ok: false, + reason: 'incognito_active', + message: 'Search is disabled because workspace privacy state could not be verified.', + }; + } + if (privacyResult.value.incognitoActive) { + return { + ok: false, + reason: 'incognito_active', + message: 'Search is disabled while incognito is active.', + }; + } + + const maxResults = limitResult.value; + + const eligibleSessions = collapseSessionRevisions( + await deps.listSessions(), + options.activeSessionId, + ) + // Exclude fake-backend sessions. The rail still shows them (marked stale) + // because they are task records, but their transcripts are simulator output; + // returning fabricated text as a hit on the user's own history is worse than + // returning nothing. Retiring the backend (#3211) did not make that content + // real, so the filter stays. + .filter( + (session) => + session.backend !== 'fake' && + (options.includeArchived === true || !session.isArchived) && + !options.excludeSessionIds?.has(session.id), + ) + // Newest first by lastMessageAt; secondary by id for determinism. + .sort((a, b) => { + const ts = (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0); + if (ts !== 0) return ts; + return a.id.localeCompare(b.id); + }); + if (options.abortSignal?.aborted) return abortedSearch(); + const remainingSessions = cursorResult.value + ? eligibleSessions.filter((session) => sessionIsAfterCursor(session, cursorResult.value!)) + : eligibleSessions; + const sessions = remainingSessions.slice(0, MAX_SESSIONS_SCANNED); + const hasMoreSessions = remainingSessions.length > sessions.length; + + const results: SearchResult[] = []; + let totalBytes = 0; + let truncated = hasMoreSessions; + let scannedCompletePage = true; + + sessionScan: for (const session of sessions) { + if (options.abortSignal?.aborted) return abortedSearch(); + if (results.length >= maxResults) { + truncated = true; + scannedCompletePage = false; + break; + } + + const searchableTitle = redactSecrets(session.name); + const titleHit = findMatch(searchableTitle, queryFolded); + if (titleHit !== undefined) { + const snippet = capCodePoints( + buildSnippet(searchableTitle, titleHit, SNIPPET_CONTEXT_HALF), + SNIPPET_MAX_CODE_POINTS, + ); + const snippetBytes = Buffer.byteLength(snippet, 'utf8'); + if (totalBytes + snippetBytes > TOTAL_PAYLOAD_CAP_BYTES) { + truncated = true; + scannedCompletePage = false; + break; + } + totalBytes += snippetBytes; + results.push({ + source: THREAD_SOURCE, + title: searchableTitle, + summary: '任务标题', + snippet, + target: { + kind: 'thread', + sessionId: session.id, + matchKind: 'session_title', + }, + }); + if (results.length >= maxResults) { + truncated = true; + scannedCompletePage = false; + break; + } + } + + const messages = await deps.readMessages(session.id, options.abortSignal); + if (options.abortSignal?.aborted) return abortedSearch(); + if (!messages) continue; + + for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) { + if (messageIndex > 0 && messageIndex % 256 === 0) { + await new Promise((resolve) => setImmediate(resolve)); + } + if (options.abortSignal?.aborted) return abortedSearch(); + const message = messages[messageIndex]!; + if (results.length >= maxResults) { + truncated = true; + scannedCompletePage = false; + break sessionScan; + } + + const turnId = (message as { turnId?: string }).turnId; + if (session.id === options.activeSessionId && turnId && options.excludeTurnIds?.has(turnId)) { + continue; + } + + const rawCandidate = collectSearchableText(message); + if (rawCandidate === undefined) continue; + const candidate = redactSecrets(rawCandidate); + + const hit = findMatch(candidate, queryFolded); + if (hit === undefined) continue; + + // Build the snippet, redact secrets, cap length. + const snippet = capCodePoints( + redactSecrets(buildSnippet(candidate, hit, SNIPPET_CONTEXT_HALF)), + SNIPPET_MAX_CODE_POINTS, + ); + + const snippetBytes = Buffer.byteLength(snippet, 'utf8'); + if (totalBytes + snippetBytes > TOTAL_PAYLOAD_CAP_BYTES) { + truncated = true; + scannedCompletePage = false; + break sessionScan; + } + totalBytes += snippetBytes; + + results.push({ + source: THREAD_SOURCE, + title: redactSecrets(session.name), + summary: formatSearchResultSummary(message), + snippet, + // PR-SEARCH-1.5: navigation target via discriminated union; no + // `url` field for thread results (maka://session is deferred). + target: { + kind: 'thread', + sessionId: session.id, + ...(turnId ? { turnId } : {}), + sequence: messageIndex, + messageId: message.id, + matchKind: threadSearchMatchKind(message), + messageTimestamp: message.ts, + }, + }); + } + } + + if (truncated && results.length > 0) { + results[results.length - 1] = { ...results[results.length - 1]!, truncated: true }; + } + + const lastSession = sessions.at(-1); + const nextCursor = + hasMoreSessions && scannedCompletePage && lastSession + ? encodeThreadSearchCursor({ + version: 1, + query: queryFolded, + lastMessageAt: sessionSortTime(lastSession), + sessionId: lastSession.id, + }) + : undefined; + return { ok: true, results, truncated, ...(nextCursor ? { nextCursor } : {}) }; +} + +function abortedSearch(): { ok: false; reason: 'aborted'; message: string } { + return { ok: false, reason: 'aborted', message: 'History search was aborted.' }; +} + +function decodeThreadSearchCursor( + input: unknown, + query: string, +): + | { readonly ok: true; readonly value: ThreadSearchCursor | undefined } + | { readonly ok: false; readonly reason: 'invalid_query'; readonly message: string } { + if (input === undefined) return { ok: true, value: undefined }; + if ( + typeof input !== 'string' || + input.length === 0 || + input.length > THREAD_SEARCH_CURSOR_MAX_CHARS || + input.trim() !== input + ) { + return invalidThreadSearchCursor(); + } + try { + const decoded = Buffer.from(input, 'base64url').toString('utf8'); + if (Buffer.from(decoded, 'utf8').toString('base64url') !== input) { + return invalidThreadSearchCursor(); + } + const value: unknown = JSON.parse(decoded); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return invalidThreadSearchCursor(); + } + const cursor = value as Record; + if ( + cursor.version !== 1 || + cursor.query !== query || + typeof cursor.lastMessageAt !== 'number' || + !Number.isFinite(cursor.lastMessageAt) || + typeof cursor.sessionId !== 'string' || + cursor.sessionId.length === 0 || + cursor.sessionId.length > 256 + ) { + return invalidThreadSearchCursor(); + } + return { + ok: true, + value: { + version: 1, + query, + lastMessageAt: cursor.lastMessageAt, + sessionId: cursor.sessionId, + }, + }; + } catch { + return invalidThreadSearchCursor(); + } +} + +function invalidThreadSearchCursor() { + return { + ok: false as const, + reason: 'invalid_query' as const, + message: 'Search cursor is invalid or belongs to another query.', + }; +} + +function encodeThreadSearchCursor(cursor: ThreadSearchCursor): string { + return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); +} + +function sessionSortTime(session: SessionSummary): number { + return session.lastMessageAt ?? 0; +} + +function sessionIsAfterCursor(session: SessionSummary, cursor: ThreadSearchCursor): boolean { + const timestamp = sessionSortTime(session); + return ( + timestamp < cursor.lastMessageAt || + (timestamp === cursor.lastMessageAt && session.id.localeCompare(cursor.sessionId) > 0) + ); +} + +/** Stable result classification shared by Desktop navigation and Agent tools. */ +export function threadSearchMatchKind(message: StoredMessage): ThreadSearchMatchKind { + switch (message.type) { + case 'user': + return 'user_message'; + case 'assistant': + return 'assistant_message'; + case 'tool_call': + return 'tool_intent'; + case 'tool_result': + return 'tool_result'; + case 'permission_decision': + case 'token_usage': + case 'turn_state': + case 'system_note': + throw new Error(`Message type ${message.type} is not searchable`); + } +} + +export function formatSearchResultSummary(message: StoredMessage): string { + switch (message.type) { + case 'user': + return '用户消息'; + case 'assistant': + return '助手回复'; + case 'tool_call': + return message.displayName + ? `工具调用:${message.displayName}` + : `工具调用:${message.toolName}`; + case 'tool_result': + return message.isError ? '工具结果:失败' : '工具结果:成功'; + case 'permission_decision': + return '权限记录'; + case 'token_usage': + return '用量记录'; + case 'turn_state': + return '回合状态'; + case 'system_note': + return '系统记录'; + } +} + +/** + * Extract user-visible answer text from a stored message. Returns `undefined` + * for excluded message kinds (system notes, token usage, turn state, + * permission decisions). This is the only "what counts as searchable + * transcript content" gate; adding new searchable surfaces requires + * extending this switch + a corresponding test. + * + * For ToolResultMessage, the `content` is JSON-serialized and capped + * at `TOOL_RESULT_SCAN_CAP_BYTES` so a 100 MB tool result doesn't + * inflate scan time. + */ +export function collectSearchableText(message: StoredMessage): string | undefined { + switch (message.type) { + case 'user': + // Prefer the human-facing view so skill-invocation envelopes do not + // dominate local search hits for what the user actually typed. + return message.displayText ?? message.text; + case 'assistant': + // Search result snippets are a transcript surface. Assistant + // reasoning/thinking may be rendered separately in the live chat, + // but it is not answer text and must not leak into local search. + return message.text; + case 'tool_call': + // PR-SEARCH-2 review fixup (@xuan `2f1aba55`): index ONLY + // `intent` — the user-visible description of what the tool call + // is doing. `toolName` (e.g. `Bash`) and `displayName` are + // internal labels and would let searches for `Bash` match every + // bash invocation regardless of intent. The PR-SEARCH-1 plan + // already locked `intent` as the only searchable field on + // `ToolCallMessage`; the previous draft over-indexed by mistake. + return message.intent && message.intent.length > 0 ? message.intent : undefined; + case 'tool_result': { + // Bounded JSON-serialize. The cap protects against pathological + // multi-MB tool outputs (file dumps, etc.). + let serialized: string; + try { + serialized = JSON.stringify(message.content); + } catch { + return undefined; + } + if (Buffer.byteLength(serialized, 'utf8') > TOOL_RESULT_SCAN_CAP_BYTES) { + // Truncate to the cap. Use byte-safe slice via Buffer. + const buf = Buffer.from(serialized, 'utf8').subarray(0, TOOL_RESULT_SCAN_CAP_BYTES); + return buf.toString('utf8'); + } + return serialized; + } + case 'permission_decision': + case 'token_usage': + case 'turn_state': + case 'system_note': + // Excluded — not user-typed / not user-visible content. + return undefined; + } +} + +/** + * NFC + lowercase canonicalization for substring match. NOT a security + * boundary — purely for case-insensitive + composed-form matching. + * + * Public for tests; production callers use `runThreadSearch` only. + */ +export function foldForMatch(value: string): string { + return value.normalize('NFC').toLowerCase(); +} + +/** + * Find the index of the first occurrence of `queryFolded` in `text` + * (after the same fold operation). Returns the index in the original + * (unfolded) text — JS `String.prototype.toLowerCase()` preserves + * code-point indexing for ASCII and most CJK, which is what we need + * for snippet extraction. Returns `undefined` on no match. + */ +export function findMatch(text: string, queryFolded: string): number | undefined { + const folded = foldForMatch(text); + const idx = folded.indexOf(queryFolded); + return idx >= 0 ? idx : undefined; +} + +/** + * Extract a context window around the match. Pure substring + ellipsis + * marker; no HTML, no markup. Caller is responsible for redaction + + * length cap afterward. + */ +export function buildSnippet(text: string, matchIndex: number, halfWindow: number): string { + const start = Math.max(0, matchIndex - halfWindow); + const end = Math.min(text.length, matchIndex + halfWindow); + const prefix = start > 0 ? '…' : ''; + const suffix = end < text.length ? '…' : ''; + return prefix + text.slice(start, end).replace(/\s+/g, ' ').trim() + suffix; +} + +/** + * Cap a string to at most `maxCodePoints` code points. Uses + * `Array.from` so surrogate pairs (emoji) are not split. Appends + * an ellipsis when truncated. + */ +export function capCodePoints(value: string, maxCodePoints: number): string { + const codePoints = Array.from(value); + if (codePoints.length <= maxCodePoints) return value; + return codePoints.slice(0, maxCodePoints - 1).join('') + '…'; +} diff --git a/packages/core/src/tool-catalog.ts b/packages/core/src/tool-catalog.ts index 2e7a9d6442..2ce94c7348 100644 --- a/packages/core/src/tool-catalog.ts +++ b/packages/core/src/tool-catalog.ts @@ -116,6 +116,8 @@ export const MAKA_CATALOG_TOOLS: readonly CatalogToolDef[] = Object.freeze( { name: 'SkillSearch' }, { name: 'WebFetch', effects: ['network'] as const }, { name: 'WebSearch' }, + { name: 'SearchHistory', effects: ['read'] as const }, + { name: 'ReadHistory', effects: ['read'] as const }, { name: 'MakaSettingsGet', effects: ['read'] as const }, { name: 'MakaSettingsUpdate', effects: ['write'] as const }, { name: 'ExploreAgent' }, diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 9ebdd64937..674e3efcad 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -1209,7 +1209,9 @@ test('production Host executes a canonical ai-sdk Session against a real provide 'MakaSettingsGet', 'MakaSettingsUpdate', 'Read', + 'ReadHistory', 'ScheduledTask', + 'SearchHistory', 'Skill', 'SkillSearch', 'StopBackgroundTask', diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 153468a1e2..639a8beb7e 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -37,6 +37,7 @@ import { } from '@maka/runtime/session-manager'; import { buildToolsForAgentDefinition } from '@maka/runtime/agent-catalog'; import { buildHostCapabilitiesFromBinding } from '@maka/runtime/tool-catalog-derive'; +import { buildHistoryTools } from '@maka/runtime/history-tools'; import { createLocalContinuationSafetyInspector } from '@maka/runtime/continuation-safety'; import { createConfiguredSubagentCatalog } from '@maka/runtime/configured-subagent-catalog'; import { @@ -358,15 +359,30 @@ export async function createExecutionRuntimeHostComposition( const webFetchService = createHostWebFetchService({ policy: runtimePolicyStores.operations, }); - const hostTools = [ + const historyTools = buildHistoryTools({ + listSessions: () => requireSessionManager(manager).listSessions(), + readMessages: async (sessionId, abortSignal) => { + if (abortSignal?.aborted) return null; + const messages = await requireSessionManager(manager) + .getMessages(sessionId) + .catch(() => null); + return abortSignal?.aborted ? null : messages; + }, + getPrivacyContext: async () => ({ + incognitoActive: (await runtimePolicyStores.runtimePolicy.getSnapshot()).policy.privacy + .incognitoActive, + }), + }); + const childHostTools = [ createHostWebSearchToolFromService(webSearchService), createHostWebFetchToolFromService(webFetchService), ...runtimePolicy.modelTools, ]; + const hostTools = [...childHostTools, ...historyTools]; const childAgentTools = createHostChildAgentToolComposition({ taskLedger, builtinTools, - hostTools, + hostTools: childHostTools, worktreePatchWriteBackAvailable: true, }); const openedGraphControlStore = createAgentGraphControlStore( diff --git a/packages/runtime/package.json b/packages/runtime/package.json index eb4db917d4..be46c9f8ac 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -60,6 +60,7 @@ "./goal-turn-lifecycle": "./dist/goal-turn-lifecycle.js", "./history-compact-checkpoint": "./dist/history-compact-checkpoint.js", "./history-compact-ledger": "./dist/history-compact-ledger.js", + "./history-tools": "./dist/history-tools.js", "./history-compact-summarizer": "./dist/history-compact-summarizer.js", "./openai-codex-history-compactor": "./dist/openai-codex-history-compactor.js", "./interaction-authority": "./dist/interaction-authority.js", diff --git a/packages/runtime/src/__tests__/history-tools.test.ts b/packages/runtime/src/__tests__/history-tools.test.ts new file mode 100644 index 0000000000..5fbfbdd384 --- /dev/null +++ b/packages/runtime/src/__tests__/history-tools.test.ts @@ -0,0 +1,478 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import type { ZodType } from 'zod'; +import { + buildHistoryTools, + buildReadHistoryTool, + buildSearchHistoryTool, + HISTORY_READ_MAX_BYTES, +} from '../history-tools.js'; +import type { MakaToolContext } from '../tool-runtime.js'; + +test('history tools expose strict global-search and anchored-read schemas', () => { + const deps = historyDeps([], new Map()); + const tools = buildHistoryTools(deps); + assert.deepEqual( + tools.map((tool) => tool.name), + ['SearchHistory', 'ReadHistory'], + ); + + const search = tools[0]!.parameters as ZodType; + const read = tools[1]!.parameters as ZodType; + assert.deepEqual(search.parse({ query: 'release notes' }), { query: 'release notes' }); + assert.deepEqual(search.parse({ query: 'release notes', cursor: 'opaque-cursor' }), { + query: 'release notes', + cursor: 'opaque-cursor', + }); + assert.throws(() => search.parse({ query: '', session_id: 'past' })); + assert.deepEqual( + read.parse({ + session_id: 'past', + message_id: 'assistant-turn-1', + turn_id: 'turn-1', + before: 1, + after: 1, + }), + { + session_id: 'past', + message_id: 'assistant-turn-1', + turn_id: 'turn-1', + before: 1, + after: 1, + }, + ); + assert.throws(() => read.parse({ session_id: 'past', before: 4, after: 4 })); + assert.throws(() => read.parse({ session_id: 'past', max_turns: 2 })); +}); + +test('SearchHistory returns typed message hits from current and other sessions', async () => { + const sessions = [ + { ...session('current', 'Current session', 3), revisionRootSessionId: 'current-family' }, + { + ...session('current-newer-revision', 'Current newer revision', 4), + revisionRootSessionId: 'current-family', + revisionParentSessionId: 'current', + revisionState: 'committed' as const, + }, + session('past', 'Deployment sk-ant-title-secret-12345 work', 2), + { ...session('archived', 'Archived deployment', 1), isArchived: true }, + session('fixture', 'Fixture', 1, 'fake'), + ]; + const messages = new Map([ + [ + 'current', + [ + user('deploy from an older current-session turn', 'current-old-turn'), + user('deploy from the active turn must not match itself', 'current-turn'), + ], + ], + ['current-newer-revision', [user('deploy sibling revision', 'sibling-turn')]], + [ + 'past', + [ + user('Please deploy the service with token sk-ant-test-secret-token-12345', 'past-turn'), + assistant('Deployment completed.', 'past-turn'), + ], + ], + ['fixture', [user('deploy fixture', 'fixture-turn')]], + ['archived', [user('deploy archived history', 'archived-turn')]], + ]); + const tool = buildSearchHistoryTool(historyDeps(sessions, messages)); + + const result = (await tool.impl({ query: 'deploy', limit: 10 }, context())) as { + kind: string; + truncated: boolean; + rows: Array>; + }; + + assert.equal(result.kind, 'history_search'); + assert.equal(result.truncated, false); + assert.ok(result.rows.length > 0); + assert.deepEqual( + new Set(result.rows.map((row) => row.session_id)), + new Set(['current', 'past', 'archived']), + ); + assert.ok(result.rows.every((row) => row.session_id !== 'current-newer-revision')); + const messageRows = result.rows.filter((row) => row.match_kind !== 'session_title'); + assert.ok(messageRows.every((row) => typeof row.message_id === 'string')); + assert.ok(messageRows.every((row) => typeof row.message_timestamp === 'number')); + assert.deepEqual( + new Set(result.rows.map((row) => row.match_kind)), + new Set(['session_title', 'user_message', 'assistant_message']), + ); + assert.equal(result.rows.find((row) => row.session_id === 'current')?.is_current_session, true); + assert.ok(result.rows.every((row) => row.turn_id !== 'current-turn')); + assert.ok( + result.rows + .filter((row) => row.session_id === 'past') + .every((row) => row.is_current_session === false), + ); + assert.match(JSON.stringify(result.rows), /\[redacted\]/u); + assert.doesNotMatch(JSON.stringify(result.rows), /sk-ant-test-secret-token-12345/u); +}); + +test('SearchHistory exposes and consumes an opaque session continuation', async () => { + const sessions = Array.from({ length: 201 }, (_, index) => + session(`session-${String(index).padStart(3, '0')}`, `Session ${index}`, 1), + ); + const messages = new Map([ + ['session-200', [user('only second page contains this needle', 'oldest-turn')]], + ]); + const tool = buildSearchHistoryTool(historyDeps(sessions, messages)); + + const first = (await tool.impl({ query: 'second page', limit: 5 }, context())) as { + next_cursor?: string; + rows: Array>; + }; + assert.deepEqual(first.rows, []); + assert.equal(typeof first.next_cursor, 'string'); + + const second = (await tool.impl( + { query: 'second page', limit: 5, cursor: first.next_cursor }, + context(), + )) as { next_cursor?: string; rows: Array> }; + assert.deepEqual( + second.rows.map((row) => row.session_id), + ['session-200'], + ); + assert.equal(second.next_cursor, undefined); +}); + +test('ReadHistory returns a bounded visible excerpt without reasoning or raw tool data', async () => { + const huge = `finished ${'x'.repeat(HISTORY_READ_MAX_BYTES * 2)}`; + const messages = new Map([ + [ + 'past', + [ + user('Use token sk-ant-test-secret-token-12345', 'turn-1'), + { + type: 'assistant', + id: 'assistant-thinking', + turnId: 'turn-1', + ts: 2, + text: huge, + thinking: { text: 'private chain of thought' }, + modelId: 'test-model', + }, + { + type: 'tool_call', + id: 'tool-call', + turnId: 'turn-1', + ts: 3, + toolName: 'Bash', + intent: 'Check deployment status', + args: { password: 'raw-tool-secret' }, + }, + { + type: 'tool_result', + id: 'tool-result', + turnId: 'turn-1', + ts: 4, + toolUseId: 'tool-call', + isError: false, + content: { password: 'raw-result-secret' } as never, + }, + ], + ], + ]); + const tool = buildReadHistoryTool( + historyDeps([session('past', 'Past sk-ant-title-secret-12345 work', 1)], messages), + ); + + const result = await tool.impl( + { session_id: 'past', message_id: 'tool-result', before: 0, after: 0 }, + context(), + ); + const serialized = JSON.stringify(result); + + assert.match(serialized, /history_read/u); + assert.match(serialized, /\[redacted\]/u); + assert.match(serialized, /Check deployment status/u); + assert.match(serialized, /"anchor_message_id":"tool-result"/u); + assert.match(serialized, /"message_id":"assistant-thinking"/u); + assert.match(serialized, /"match_kind":"assistant_message"/u); + assert.doesNotMatch( + serialized, + /private chain of thought|raw-tool-secret|raw-result-secret|sk-ant-title-secret-12345/u, + ); + assert.ok(Buffer.byteLength(serialized, 'utf8') < HISTORY_READ_MAX_BYTES + 2_000); + assert.match(serialized, /"truncated":true/u); +}); + +test('ReadHistory can open the current session around a message anchor', async () => { + const messages = new Map([ + [ + 'current', + [ + user('first question', 'turn-1'), + assistant('first answer', 'turn-1'), + user('second question', 'turn-2'), + assistant('second answer', 'turn-2'), + user('third question', 'turn-3'), + assistant('third answer', 'turn-3'), + ], + ], + ]); + const tool = buildReadHistoryTool(historyDeps([session('current', 'Current', 3)], messages)); + + const result = (await tool.impl( + { session_id: 'current', message_id: 'assistant-turn-2', before: 1, after: 0 }, + context(), + )) as Record; + + assert.equal(result.kind, 'history_read'); + assert.equal(result.is_current_session, true); + assert.equal(result.anchor_turn_id, 'turn-2'); + assert.equal(result.has_more_before, false); + assert.equal(result.has_more_after, true); + assert.deepEqual( + (result.turns as Array<{ turn_id: string }>).map((turn) => turn.turn_id), + ['turn-1', 'turn-2'], + ); +}); + +test('ReadHistory excludes the currently executing turn only in the current session', async () => { + const messages = new Map([ + ['current', [user('active secret', 'current-turn'), user('older visible', 'older-turn')]], + ['past', [user('copied active id remains readable', 'current-turn')]], + ]); + const tool = buildReadHistoryTool( + historyDeps([session('current', 'Current', 2), session('past', 'Past', 1)], messages), + ); + + assert.match( + JSON.stringify( + await tool.impl({ session_id: 'current', message_id: 'user-current-turn' }, context()), + ), + /message_not_found/u, + ); + assert.match( + JSON.stringify( + await tool.impl({ session_id: 'past', message_id: 'user-current-turn' }, context()), + ), + /copied active id remains readable/u, + ); +}); + +test('ReadHistory propagates cancellation across privacy and transcript awaits', async () => { + const privacyAbort = new AbortController(); + let privacyListCalls = 0; + const privacyResult = await buildReadHistoryTool({ + listSessions: async () => { + privacyListCalls += 1; + return [session('past', 'Past', 1)]; + }, + readMessages: async () => [user('visible', 'turn-1')], + getPrivacyContext: async () => { + privacyAbort.abort(); + return { incognitoActive: false }; + }, + }).impl({ session_id: 'past' }, context(privacyAbort.signal)); + assert.match(JSON.stringify(privacyResult), /aborted/u); + assert.equal(privacyListCalls, 0); + + const readAbort = new AbortController(); + let receivedSignal: AbortSignal | undefined; + const readResult = await buildReadHistoryTool({ + listSessions: async () => [session('past', 'Past', 1)], + readMessages: async (_sessionId: string, signal?: AbortSignal) => { + receivedSignal = signal; + readAbort.abort(); + return [user('visible', 'turn-1')]; + }, + getPrivacyContext: async () => ({ incognitoActive: false }), + }).impl({ session_id: 'past' }, context(readAbort.signal)); + assert.equal(receivedSignal, readAbort.signal); + assert.match(JSON.stringify(readResult), /aborted/u); +}); + +test('ReadHistory preserves the requested anchor when earlier context fills the byte cap', async () => { + const huge = 'x'.repeat(HISTORY_READ_MAX_BYTES); + const prior: StoredMessage[] = Array.from({ length: 5 }, (_, index) => ({ + type: 'assistant', + id: `prior-${index}`, + turnId: 'prior-turn', + ts: index, + text: huge, + modelId: 'test-model', + })); + const anchor: StoredMessage = { + type: 'assistant', + id: 'requested-anchor', + turnId: 'anchor-turn', + ts: 10, + text: 'anchor must survive', + modelId: 'test-model', + }; + const tool = buildReadHistoryTool( + historyDeps([session('past', 'Past', 1)], new Map([['past', [...prior, anchor]]])), + ); + + const result = await tool.impl( + { session_id: 'past', message_id: 'requested-anchor', before: 1, after: 0 }, + context(), + ); + assert.match(JSON.stringify(result), /anchor must survive/u); + assert.match(JSON.stringify(result), /"truncated":true/u); +}); + +test('ReadHistory spends an unanchored byte budget on the newest messages first', async () => { + const messages: StoredMessage[] = [ + { + type: 'assistant', + id: 'older-large', + turnId: 'older-turn', + ts: 1, + text: 'x'.repeat(HISTORY_READ_MAX_BYTES * 2), + modelId: 'test-model', + }, + user('latest question survives', 'latest-turn'), + assistant('latest answer survives', 'latest-turn'), + ]; + const tool = buildReadHistoryTool( + historyDeps([session('past', 'Past', 1)], new Map([['past', messages]])), + ); + + const result = await tool.impl({ session_id: 'past' }, context()); + const serialized = JSON.stringify(result); + assert.match(serialized, /latest question survives/u); + assert.match(serialized, /latest answer survives/u); + assert.match(serialized, /"truncated":true/u); + assert.ok( + serialized.indexOf('latest question survives') < serialized.indexOf('latest answer survives'), + ); +}); + +test('ReadHistory rejects mismatched or hidden message anchors', async () => { + const messages = new Map([ + [ + 'past', + [ + user('visible', 'turn-1'), + { + type: 'system_note', + id: 'hidden-note', + ts: 2, + kind: 'session_start', + data: {}, + }, + ], + ], + ]); + const tool = buildReadHistoryTool(historyDeps([session('past', 'Past', 1)], messages)); + + assert.match( + JSON.stringify( + await tool.impl( + { session_id: 'past', message_id: 'user-turn-1', turn_id: 'turn-other' }, + context(), + ), + ), + /anchor_mismatch/u, + ); + assert.match( + JSON.stringify(await tool.impl({ session_id: 'past', message_id: 'hidden-note' }, context())), + /message_not_found/u, + ); +}); + +test('history access fails closed before transcript reads in incognito mode', async () => { + let listCalls = 0; + let readCalls = 0; + const deps = { + listSessions: async () => { + listCalls += 1; + return [session('past', 'Past', 1)]; + }, + readMessages: async () => { + readCalls += 1; + return [user('secret', 'turn-1')]; + }, + getPrivacyContext: async () => ({ incognitoActive: true }), + }; + + const search = await buildSearchHistoryTool(deps).impl({ query: 'secret' }, context()); + const read = await buildReadHistoryTool(deps).impl({ session_id: 'past' }, context()); + + assert.match(JSON.stringify(search), /incognito_active/u); + assert.match(JSON.stringify(read), /incognito_active/u); + assert.equal(listCalls, 0); + assert.equal(readCalls, 0); +}); + +function historyDeps(sessions: SessionSummary[], messages: ReadonlyMap) { + return { + listSessions: async () => sessions, + readMessages: async (sessionId: string) => messages.get(sessionId) ?? null, + getPrivacyContext: async () => ({ incognitoActive: false }), + }; +} + +function session( + id: string, + name: string, + lastMessageAt: number, + backend: SessionSummary['backend'] = 'ai-sdk', +): SessionSummary { + return { + id, + name, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + lastMessageAt, + status: 'active', + backend, + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test-model', + permissionMode: 'ask', + }; +} + +function user(text: string, turnId: string): StoredMessage { + return { type: 'user', id: `user-${turnId}`, turnId, ts: 1, text }; +} + +function assistant(text: string, turnId: string): StoredMessage { + return { + type: 'assistant', + id: `assistant-${turnId}`, + turnId, + ts: 2, + text, + modelId: 'test-model', + }; +} + +function context(abortSignal: AbortSignal = new AbortController().signal): MakaToolContext { + return { + sessionId: 'current', + turnId: 'current-turn', + cwd: '/tmp', + toolCallId: 'tool-call', + abortSignal, + emitOutput: () => {}, + }; +} diff --git a/packages/runtime/src/history-tools.ts b/packages/runtime/src/history-tools.ts new file mode 100644 index 0000000000..67357be10e --- /dev/null +++ b/packages/runtime/src/history-tools.ts @@ -0,0 +1,538 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + SEARCH_DEFAULT_LIMIT, + SEARCH_MAX_LIMIT, + SEARCH_QUERY_MAX_CHARS, + normalizeSearchLimit, + normalizeSearchQuery, + type SearchError, + type ThreadSearchMatchKind, +} from '@maka/core/search'; +import { collapseSessionRevisions } from '@maka/core/session-revisions'; +import { redactSecrets } from '@maka/core/redaction'; +import { validateWorkspacePrivacyContext } from '@maka/core/incognito'; +import type { SessionSummary, StoredMessage } from '@maka/core/session'; +import { + collectSearchableText, + runThreadSearch, + THREAD_SEARCH_CURSOR_MAX_CHARS, + type ThreadSearchDeps, +} from '@maka/core/thread-search'; +import { z } from 'zod'; +import type { MakaTool } from './tool-runtime.js'; + +export const SEARCH_HISTORY_TOOL_NAME = 'SearchHistory'; +export const READ_HISTORY_TOOL_NAME = 'ReadHistory'; +export const HISTORY_READ_MAX_TURNS = 5; +export const HISTORY_READ_DEFAULT_BEFORE_TURNS = 1; +export const HISTORY_READ_DEFAULT_AFTER_TURNS = 1; +export const HISTORY_READ_MAX_BYTES = 32 * 1024; +export const HISTORY_READ_MAX_MESSAGE_BYTES = 8 * 1024; + +export type HistoryToolDeps = ThreadSearchDeps; + +type HistoryReadErrorReason = + | 'incognito_active' + | 'session_not_found' + | 'message_not_found' + | 'anchor_mismatch' + | 'turn_not_found' + | 'empty_transcript' + | 'aborted'; + +interface HistoryTurnMessage { + readonly messageId: string; + readonly matchKind: Exclude; + readonly role: 'user' | 'assistant' | 'tool'; + readonly text: string; + readonly timestamp: number; +} + +interface HistoryTurn { + readonly turnId: string; + readonly messages: readonly HistoryTurnMessage[]; +} + +/** + * Builds the read-only global conversation search surface. Search returns + * message-level hits from every logical Session; reading nearby turns is an + * optional follow-up rather than a required second phase. + */ +export function buildHistoryTools(deps: HistoryToolDeps): readonly MakaTool[] { + return [buildSearchHistoryTool(deps), buildReadHistoryTool(deps)]; +} + +export function buildSearchHistoryTool(deps: HistoryToolDeps): MakaTool { + return { + name: SEARCH_HISTORY_TOOL_NAME, + displayName: 'Search conversation history', + activityKind: 'read', + categoryHint: 'read', + description: + 'Search all Maka conversation sessions, including the current session, by visible title, user text, assistant text, tool intent, or bounded tool result. Returns redacted message-level hits. Use ReadHistory only when a hit needs surrounding context.', + parameters: z + .object({ + query: z + .string() + .trim() + .min(1) + .max(SEARCH_QUERY_MAX_CHARS) + .describe('Text to find globally in session titles or visible transcript content.'), + limit: z + .number() + .int() + .min(1) + .max(SEARCH_MAX_LIMIT) + .optional() + .describe(`Maximum matches; defaults to ${SEARCH_DEFAULT_LIMIT}.`), + cursor: z + .string() + .trim() + .min(1) + .max(THREAD_SEARCH_CURSOR_MAX_CHARS) + .optional() + .describe( + 'Opaque continuation returned as next_cursor by an earlier SearchHistory page.', + ), + }) + .strict(), + impl: async ({ query, limit, cursor }, context) => { + if (context.abortSignal.aborted) { + return historySearchError({ + ok: false, + reason: 'aborted', + message: 'History search was aborted.', + }); + } + const normalizedQuery = normalizeSearchQuery(query); + if (!normalizedQuery.ok) return historySearchError(normalizedQuery); + const normalizedLimit = normalizeSearchLimit(limit); + if (!normalizedLimit.ok) return historySearchError(normalizedLimit); + + let sessions: SessionSummary[] = []; + const result = await runThreadSearch( + { + source: 'thread', + query: normalizedQuery.value, + limit: normalizedLimit.value, + ...(cursor ? { cursor } : {}), + }, + { + ...deps, + listSessions: async () => { + sessions = await deps.listSessions(); + return sessions; + }, + }, + { + activeSessionId: context.sessionId, + excludeTurnIds: new Set([context.turnId]), + includeArchived: true, + abortSignal: context.abortSignal, + }, + ); + if (!result.ok) return historySearchError(result); + if (context.abortSignal.aborted) { + return historySearchError({ + ok: false, + reason: 'aborted', + message: 'History search was aborted.', + }); + } + + const sessionById = new Map(sessions.map((session) => [session.id, session])); + return { + kind: 'history_search' as const, + query: normalizedQuery.value, + truncated: result.truncated, + ...(result.nextCursor ? { next_cursor: result.nextCursor } : {}), + rows: result.results.flatMap((row) => { + if (row.target?.kind !== 'thread') return []; + const session = sessionById.get(row.target.sessionId); + return [ + { + session_id: row.target.sessionId, + ...(row.target.turnId ? { turn_id: row.target.turnId } : {}), + ...(row.target.messageId ? { message_id: row.target.messageId } : {}), + ...(row.target.matchKind ? { match_kind: row.target.matchKind } : {}), + ...(row.target.messageTimestamp !== undefined + ? { message_timestamp: row.target.messageTimestamp } + : {}), + is_current_session: row.target.sessionId === context.sessionId, + title: row.title, + summary: redactSecrets(row.summary ?? ''), + snippet: row.snippet ?? '', + ...(session?.lastMessageAt !== undefined + ? { last_message_at: session.lastMessageAt } + : {}), + ...(row.truncated ? { truncated: true } : {}), + }, + ]; + }), + }; + }, + }; +} + +export function buildReadHistoryTool(deps: HistoryToolDeps): MakaTool { + return { + name: READ_HISTORY_TOOL_NAME, + displayName: 'Read conversation history', + activityKind: 'read', + categoryHint: 'read', + description: + 'Optionally read bounded visible turns around a message-level SearchHistory hit, from any session including the current one. Use message_id as the preferred anchor. Hidden reasoning, permission records, and raw tool arguments/results are never returned.', + parameters: z + .object({ + session_id: z.string().trim().min(1).max(256).describe('Session id from SearchHistory.'), + message_id: z + .string() + .trim() + .min(1) + .max(256) + .optional() + .describe('Preferred message id anchor from SearchHistory; absent for title matches.'), + turn_id: z + .string() + .trim() + .min(1) + .max(256) + .optional() + .describe('Optional turn id anchor from SearchHistory, retained for title/legacy hits.'), + before: z + .number() + .int() + .min(0) + .max(HISTORY_READ_MAX_TURNS - 1) + .optional() + .describe( + `Visible turns before the anchor; defaults to ${HISTORY_READ_DEFAULT_BEFORE_TURNS}.`, + ), + after: z + .number() + .int() + .min(0) + .max(HISTORY_READ_MAX_TURNS - 1) + .optional() + .describe( + `Visible turns after the anchor; defaults to ${HISTORY_READ_DEFAULT_AFTER_TURNS}.`, + ), + }) + .strict() + .superRefine((value, ctx) => { + const before = value.before ?? HISTORY_READ_DEFAULT_BEFORE_TURNS; + const after = value.after ?? HISTORY_READ_DEFAULT_AFTER_TURNS; + if (before + 1 + after > HISTORY_READ_MAX_TURNS) { + ctx.addIssue({ + code: 'custom', + path: ['after'], + message: `before + anchor + after must be at most ${HISTORY_READ_MAX_TURNS} turns`, + }); + } + }), + impl: async ( + { + session_id: sessionId, + message_id: messageId, + turn_id: requestedTurnId, + before = HISTORY_READ_DEFAULT_BEFORE_TURNS, + after = HISTORY_READ_DEFAULT_AFTER_TURNS, + }, + context, + ) => { + if (context.abortSignal.aborted) return historyError('aborted', 'History read was aborted.'); + + const privacyPayload = await deps.getPrivacyContext(); + if (context.abortSignal.aborted) return historyError('aborted', 'History read was aborted.'); + const privacy = validateWorkspacePrivacyContext(privacyPayload); + if (!privacy.ok) { + return historyError( + 'incognito_active', + 'History is unavailable because workspace privacy state could not be verified.', + ); + } + if (privacy.value.incognitoActive) { + return historyError( + 'incognito_active', + 'History is unavailable while incognito is active.', + ); + } + + const sessions = collapseSessionRevisions(await deps.listSessions(), context.sessionId); + if (context.abortSignal.aborted) return historyError('aborted', 'History read was aborted.'); + const session = sessions.find( + (candidate) => candidate.id === sessionId && candidate.backend !== 'fake', + ); + if (!session) { + return historyError('session_not_found', 'The requested session was not found.'); + } + if (context.abortSignal.aborted) return historyError('aborted', 'History read was aborted.'); + + const messages = await deps.readMessages(sessionId, context.abortSignal); + if (context.abortSignal.aborted) return historyError('aborted', 'History read was aborted.'); + if (!messages) { + return historyError('session_not_found', 'The requested session was not found.'); + } + const readableMessages = + sessionId === context.sessionId + ? messages.filter( + (message) => !('turnId' in message) || message.turnId !== context.turnId, + ) + : messages; + const anchor = resolveHistoryAnchor(readableMessages, messageId, requestedTurnId); + if (!anchor.ok) return historyError(anchor.reason, anchor.message); + const turns = projectHistoryTurns(readableMessages); + if (turns.length === 0) { + return historyError('empty_transcript', 'The requested session has no visible transcript.'); + } + const selected = selectHistoryTurns(turns, anchor.turnId, before, after); + if (!selected) { + return historyError('turn_not_found', 'The requested turn was not found in that session.'); + } + const bounded = boundHistoryTurns( + selected.turns, + HISTORY_READ_MAX_BYTES, + anchor.turnId, + messageId, + ); + return { + kind: 'history_read' as const, + session_id: session.id, + is_current_session: session.id === context.sessionId, + ...(messageId ? { anchor_message_id: messageId } : {}), + ...(anchor.turnId ? { anchor_turn_id: anchor.turnId } : {}), + title: redactSecrets(session.name), + ...(session.lastMessageAt !== undefined ? { last_message_at: session.lastMessageAt } : {}), + turns: bounded.turns.map((turn) => ({ + turn_id: turn.turnId, + messages: turn.messages.map(({ messageId: id, matchKind, ...message }) => ({ + message_id: id, + match_kind: matchKind, + ...message, + })), + })), + has_more_before: selected.hasMoreBefore, + has_more_after: selected.hasMoreAfter, + ...(bounded.truncated ? { truncated: true } : {}), + }; + }, + }; +} + +export function projectHistoryTurns(messages: readonly StoredMessage[]): HistoryTurn[] { + const turns = new Map(); + for (const message of messages) { + const projected = projectHistoryMessage(message); + if (!projected || !('turnId' in message) || !message.turnId) continue; + const turn = turns.get(message.turnId) ?? []; + turn.push(projected); + turns.set(message.turnId, turn); + } + return [...turns].map(([turnId, turnMessages]) => ({ turnId, messages: turnMessages })); +} + +function projectHistoryMessage(message: StoredMessage): HistoryTurnMessage | undefined { + switch (message.type) { + case 'user': + return { + messageId: message.id, + matchKind: 'user_message', + role: 'user', + text: redactSecrets(message.displayText ?? message.text), + timestamp: message.ts, + }; + case 'assistant': + if (!message.text.trim()) return undefined; + return { + messageId: message.id, + matchKind: 'assistant_message', + role: 'assistant', + text: redactSecrets(message.text), + timestamp: message.ts, + }; + case 'tool_call': + if (!message.intent?.trim()) return undefined; + return { + messageId: message.id, + matchKind: 'tool_intent', + role: 'tool', + text: redactSecrets(message.intent), + timestamp: message.ts, + }; + case 'tool_result': + case 'permission_decision': + case 'token_usage': + case 'turn_state': + case 'system_note': + return undefined; + } +} + +function resolveHistoryAnchor( + messages: readonly StoredMessage[], + messageId: string | undefined, + requestedTurnId: string | undefined, +): + | { readonly ok: true; readonly turnId: string | undefined } + | { + readonly ok: false; + readonly reason: Extract; + readonly message: string; + } { + if (!messageId) return { ok: true, turnId: requestedTurnId }; + const message = messages.find( + (candidate) => candidate.id === messageId && collectSearchableText(candidate) !== undefined, + ); + if (!message || !('turnId' in message) || !message.turnId) { + return { + ok: false, + reason: 'message_not_found', + message: 'The requested searchable message was not found in that session.', + }; + } + if (requestedTurnId && requestedTurnId !== message.turnId) { + return { + ok: false, + reason: 'anchor_mismatch', + message: 'The requested message_id and turn_id do not identify the same result.', + }; + } + return { ok: true, turnId: message.turnId }; +} + +function selectHistoryTurns( + turns: readonly HistoryTurn[], + turnId: string | undefined, + before: number, + after: number, +): + | { + readonly turns: HistoryTurn[]; + readonly hasMoreBefore: boolean; + readonly hasMoreAfter: boolean; + } + | undefined { + if (!turnId) { + const start = Math.max(0, turns.length - (before + 1 + after)); + return { + turns: turns.slice(start), + hasMoreBefore: start > 0, + hasMoreAfter: false, + }; + } + const target = turns.findIndex((turn) => turn.turnId === turnId); + if (target < 0) return undefined; + const start = Math.max(0, target - before); + const end = Math.min(turns.length, target + after + 1); + return { + turns: turns.slice(start, end), + hasMoreBefore: start > 0, + hasMoreAfter: end < turns.length, + }; +} + +function boundHistoryTurns( + turns: readonly HistoryTurn[], + maxBytes: number, + anchorTurnId: string | undefined, + anchorMessageId: string | undefined, +): { turns: HistoryTurn[]; truncated: boolean } { + const boundedMessages = new Map>(); + let remaining = maxBytes; + let truncated = false; + + const candidates = turns.flatMap((turn, turnIndex) => + turn.messages.map((_message, messageIndex) => ({ turnIndex, messageIndex })), + ); + const anchorTurnIndex = turns.findIndex((turn) => turn.turnId === anchorTurnId); + const requestedAnchorIndex = + anchorTurnIndex < 0 + ? -1 + : turns[anchorTurnIndex]!.messages.findIndex( + (message) => message.messageId === anchorMessageId, + ); + const anchorMessageIndex = Math.max(0, requestedAnchorIndex); + if (anchorTurnIndex >= 0 && turns[anchorTurnIndex]!.messages.length > 0) { + candidates.unshift({ turnIndex: anchorTurnIndex, messageIndex: anchorMessageIndex }); + } else { + // An unanchored read means "show me the latest history". Spend the fixed + // byte budget newest-first, then restore chronological order below. + candidates.reverse(); + } + + const seen = new Set(); + for (const { turnIndex, messageIndex } of candidates) { + const key = `${turnIndex}:${messageIndex}`; + if (seen.has(key)) continue; + seen.add(key); + const message = turns[turnIndex]!.messages[messageIndex]!; + const overhead = Buffer.byteLength(JSON.stringify({ ...message, text: '' }), 'utf8'); + if (remaining <= overhead) { + truncated = true; + continue; + } + const messageBudget = Math.min(remaining - overhead, HISTORY_READ_MAX_MESSAGE_BYTES); + const text = truncateUtf8(message.text, messageBudget); + const bytes = overhead + Buffer.byteLength(text, 'utf8'); + const byMessage = boundedMessages.get(turnIndex) ?? new Map(); + byMessage.set(messageIndex, { ...message, text }); + boundedMessages.set(turnIndex, byMessage); + remaining -= bytes; + if (text !== message.text) truncated = true; + } + + const bounded = turns.flatMap((turn, turnIndex) => { + const byMessage = boundedMessages.get(turnIndex); + if (!byMessage) return []; + return [ + { + turnId: turn.turnId, + messages: [...byMessage.entries()] + .sort(([left], [right]) => left - right) + .map(([, message]) => message), + }, + ]; + }); + return { turns: bounded, truncated }; +} + +function truncateUtf8(value: string, maxBytes: number): string { + if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value; + if (maxBytes <= 3) return ''; + const body = Buffer.from(value, 'utf8') + .subarray(0, maxBytes - 3) + .toString('utf8') + .replace(/\uFFFD+$/u, ''); + return `${body}…`; +} + +function historySearchError(error: SearchError) { + return { + kind: 'history_search_error' as const, + ok: false as const, + reason: error.reason, + message: error.message, + }; +} + +function historyError(reason: HistoryReadErrorReason, message: string) { + return { kind: 'history_read_error' as const, ok: false as const, reason, message }; +} From 2265b6bb94b140721ba89316148697f353b9429b Mon Sep 17 00:00:00 2001 From: Wang Date: Mon, 24 Aug 2026 22:55:54 +0800 Subject: [PATCH 010/386] fix(desktop): retire the owned local Host on full quit (#3706) * fix(desktop): retire local Host before full quit Treat full Desktop quit as an explicit retirement intent for the Desktop-owned ephemeral Host. Share exact-host retirement across Desktop update and managed service adapters, while leaving service and remote Hosts alone.\n\nCancel quit and surface trusted Host facts when safe retirement fails.\n\nGenerated-by: Codex * fix(desktop): preserve quit retirement authority A concurrent update retirement may be refused because work is active, but that weak result must not absorb an authorized quit. Track the mode with the in-flight retirement and retry the exact Host with interrupt authority only when the weak request is refused. Treat an active-work response to an authorized retirement as a retirement failure so the existing diagnostic wrapper retains Host identity and PID details. Keep the bounded exit wait terminology independent of the initiating lifecycle operation. Generated-by: Codex --- .../__tests__/app-quit-coordinator.test.ts | 65 ++++- .../runtime-host-desktop-manager.test.ts | 225 +++++++++++++++--- .../__tests__/runtime-host-quit-copy.test.ts | 54 +++++ apps/desktop/src/main/app-quit-coordinator.ts | 55 +++-- apps/desktop/src/main/runtime-host-boot.ts | 31 ++- apps/desktop/src/main/runtime-host-client.ts | 14 +- .../main/runtime-host-desktop-candidate.ts | 7 + .../src/main/runtime-host-desktop-manager.ts | 116 +++++++-- .../src/main/runtime-host-quit-copy.ts | 75 ++++++ .../cli/src/runtime-host-service-manager.ts | 13 +- .../src/__tests__/host-retirement.test.ts | 54 +++++ .../src/client/host-retirement.ts | 41 ++++ packages/runtime-host/src/client/index.ts | 5 + 13 files changed, 674 insertions(+), 81 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts create mode 100644 apps/desktop/src/main/runtime-host-quit-copy.ts create mode 100644 packages/runtime-host/src/__tests__/host-retirement.test.ts create mode 100644 packages/runtime-host/src/client/host-retirement.ts diff --git a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts index a5b249259f..e3bab13b5e 100644 --- a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts +++ b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts @@ -26,8 +26,10 @@ describe('app quit coordinator', () => { let resumeQuitCount = 0; let preventedCount = 0; const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: async () => {}, focusOrCreateWindow: () => {}, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: () => {}, resumeQuit: () => { @@ -48,7 +50,7 @@ describe('app quit coordinator', () => { assert.equal(resumeQuitCount, 0); assert.equal(preventedCount, 2); - await new Promise((resolve) => setImmediate(resolve)); + await flushQuitCoordinator(); assert.equal(resumeQuitCount, 1); }); @@ -62,6 +64,7 @@ describe('app quit coordinator', () => { releaseCleanup = resolve; }); const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: async () => { cleanupCount += 1; await cleanupPending; @@ -69,6 +72,7 @@ describe('app quit coordinator', () => { focusOrCreateWindow: () => { focusOrCreateCount += 1; }, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: () => {}, resumeQuit: () => { @@ -84,6 +88,7 @@ describe('app quit coordinator', () => { coordinator.handleBeforeQuit(event); coordinator.handleBeforeQuit(event); + await flushQuitCoordinator(); assert.equal(cleanupCount, 1); assert.equal(preventedCount, 2); assert.equal(resumeQuitCount, 0); @@ -91,7 +96,7 @@ describe('app quit coordinator', () => { releaseCleanup(); await cleanupPending; await Promise.resolve(); - await new Promise((resolve) => setImmediate(resolve)); + await flushQuitCoordinator(); assert.equal(resumeQuitCount, 1); @@ -108,11 +113,13 @@ describe('app quit coordinator', () => { let focusOrCreateCount = 0; let windowCreationSignal: AbortSignal | undefined; const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: () => new Promise(() => {}), focusOrCreateWindow: (signal) => { focusOrCreateCount += 1; windowCreationSignal = signal; }, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: () => {}, resumeQuit: () => {}, @@ -130,10 +137,12 @@ describe('app quit coordinator', () => { const failure = new Error('window load failed'); const reportedErrors: unknown[] = []; const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => {}, cleanup: async () => {}, focusOrCreateWindow: async () => { throw failure; }, + onPreparationError: () => {}, onCleanupError: () => {}, onWindowCreationError: (error) => reportedErrors.push(error), resumeQuit: () => {}, @@ -146,18 +155,62 @@ describe('app quit coordinator', () => { assert.deepEqual(reportedErrors, [failure]); }); + it('cancels quit without closing resources when Host retirement preparation fails', async () => { + const preparationError = new Error('retirement failed'); + const reportedErrors: unknown[] = []; + let preparationCount = 0; + let cleanupCount = 0; + let focusOrCreateCount = 0; + let resumeQuitCount = 0; + const coordinator = createAppQuitCoordinator({ + prepareToQuit: async () => { + preparationCount += 1; + if (preparationCount === 1) throw preparationError; + }, + cleanup: async () => { + cleanupCount += 1; + }, + focusOrCreateWindow: () => { + focusOrCreateCount += 1; + }, + onPreparationError: (error) => reportedErrors.push(error), + onCleanupError: () => {}, + onWindowCreationError: () => {}, + resumeQuit: () => { + resumeQuitCount += 1; + }, + }); + + coordinator.handleBeforeQuit({ preventDefault: () => {} }); + await flushQuitCoordinator(); + + assert.deepEqual(reportedErrors, [preparationError]); + assert.equal(cleanupCount, 0); + assert.equal(resumeQuitCount, 0); + assert.equal(focusOrCreateCount, 1); + + coordinator.handleBeforeQuit({ preventDefault: () => {} }); + await flushQuitCoordinator(); + + assert.equal(preparationCount, 2); + assert.equal(cleanupCount, 1); + assert.equal(resumeQuitCount, 1); + }); + it('reports cleanup failure without leaking an unhandled rejection', async () => { const cleanupError = new Error('close failed'); const reportedErrors: unknown[] = []; let focusOrCreateCount = 0; let resumeQuitCount = 0; const deps = { + prepareToQuit: async () => {}, cleanup: async () => { throw cleanupError; }, focusOrCreateWindow: () => { focusOrCreateCount += 1; }, + onPreparationError: () => {}, onCleanupError: (error: unknown) => { reportedErrors.push(error); }, @@ -169,8 +222,7 @@ describe('app quit coordinator', () => { const coordinator = createAppQuitCoordinator(deps); coordinator.handleBeforeQuit({ preventDefault: () => {} }); - await Promise.resolve(); - await new Promise((resolve) => setImmediate(resolve)); + await flushQuitCoordinator(); let secondQuitPrevented = false; coordinator.focusOrCreateWindow(); coordinator.handleBeforeQuit({ @@ -185,3 +237,8 @@ describe('app quit coordinator', () => { assert.equal(secondQuitPrevented, false); }); }); + +async function flushQuitCoordinator(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 219d099a7a..4f9cdef921 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -35,6 +35,7 @@ import type { DesktopRuntimeHostCandidateStartResult, } from '../runtime-host-desktop-candidate.js'; import { + DesktopLocalHostRetirementError, RuntimeHostPairingFinalizationInterruptedError, RuntimeHostUpgradeCancelledError, startRuntimeHostDesktopManager, @@ -128,18 +129,144 @@ test('quiesces reconnect and waits for the Host process before update install', }, }); - const preparation = await owner.prepareForUpdate(false); - assert.equal(preparation.kind, 'prepared'); - assert.equal(current.prepareUpgradeCalls, 1); - assert.deepEqual(current.prepareUpgradeAuthorities, [false]); + const retirement = await owner.retireOwnedLocalHost('refuse_active_work'); + assert.equal(retirement.kind, 'retired'); + assert.equal(current.prepareRetirementCalls, 1); + assert.deepEqual(current.retirementModes, ['refuse_active_work']); assert.equal(waitedForPid, 42); assert.equal(starts, 1); - if (preparation.kind === 'prepared') preparation.rollback(); + if (retirement.kind === 'retired') retirement.resume(); await reconnected; assert.equal(starts, 2); await owner.close(); }); +test('retires the owned ephemeral Host before Desktop quit', async () => { + const events: string[] = []; + const current = candidateHarness({ + activeTasks: true, + disconnectOnPrepare: true, + onPrepare: () => events.push('prepare-host'), + }); + const owner = await startRuntimeHostDesktopManager({ + candidateLaunchBarrier: { + connect: async () => assert.fail('mocked candidate startup bypasses the barrier'), + pause: () => events.push('pause-launches'), + retireExcept: async (pid: number) => { + events.push(`retire-except:${pid}`); + }, + resume: () => events.push('resume-launches'), + release: () => events.push('release-launches'), + }, + } as unknown as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async (pid) => { + events.push(`wait:${pid}`); + }, + }); + + await owner.retireOwnedLocalHost('interrupt_active_work'); + + assert.deepEqual(current.retirementModes, ['interrupt_active_work']); + assert.deepEqual(events, [ + 'pause-launches', + 'retire-except:42', + 'prepare-host', + 'wait:42', + ]); + await owner.close(); + assert.equal(events.at(-1), 'release-launches'); + assert.ok(!events.includes('resume-launches')); +}); + +test('does not retire the local Host twice when an update handoff triggers quit', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + const waitedFor: number[] = []; + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async (pid) => { + waitedFor.push(pid); + }, + }); + + const update = await owner.retireOwnedLocalHost('refuse_active_work'); + assert.equal(update.kind, 'retired'); + await owner.retireOwnedLocalHost('interrupt_active_work'); + + assert.equal(current.prepareRetirementCalls, 1); + assert.deepEqual(waitedFor, [42]); + await owner.close(); +}); + +test('coalesces concurrent retirement intents onto one exact Host request', async () => { + const current = candidateHarness({ disconnectOnPrepare: true }); + let releaseExitWait!: () => void; + let reportExitWait!: () => void; + const exitWaitStarted = new Promise((resolve) => { + reportExitWait = resolve; + }); + const exitWait = new Promise((resolve) => { + releaseExitWait = resolve; + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async () => { + reportExitWait(); + await exitWait; + }, + }); + + const update = owner.retireOwnedLocalHost('refuse_active_work'); + await exitWaitStarted; + const quit = owner.retireOwnedLocalHost('interrupt_active_work'); + releaseExitWait(); + assert.deepEqual( + (await Promise.all([update, quit])).map(({ kind }) => kind), + ['retired', 'retired'], + ); + + assert.equal(current.prepareRetirementCalls, 1); + assert.deepEqual(current.retirementModes, ['refuse_active_work']); + await owner.close(); +}); + +test('reissues a concurrent strong retirement when weak retirement is refused', async () => { + let reportWeakPrepare!: () => void; + let releaseWeakPrepare!: () => void; + const weakPrepareStarted = new Promise((resolve) => { + reportWeakPrepare = resolve; + }); + const weakPrepareGate = new Promise((resolve) => { + releaseWeakPrepare = resolve; + }); + const current = candidateHarness({ + activeTasks: true, + disconnectOnPrepare: true, + onPrepare: async (mode) => { + if (mode !== 'refuse_active_work') return; + reportWeakPrepare(); + await weakPrepareGate; + }, + }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + waitForHostExit: async () => {}, + }); + + const update = owner.retireOwnedLocalHost('refuse_active_work'); + await weakPrepareStarted; + const quit = owner.retireOwnedLocalHost('interrupt_active_work'); + releaseWeakPrepare(); + + assert.deepEqual(await update, { kind: 'active_tasks' }); + assert.equal((await quit).kind, 'retired'); + assert.deepEqual(current.retirementModes, [ + 'refuse_active_work', + 'interrupt_active_work', + ]); + await owner.close(); +}); + test('retires unadopted candidates before draining the tracked Host', async () => { const events: string[] = []; const current = candidateHarness({ @@ -163,15 +290,15 @@ test('retires unadopted candidates before draining the tracked Host', async () = }, }); - const preparation = await owner.prepareForUpdate(false); - assert.equal(preparation.kind, 'prepared'); + const retirement = await owner.retireOwnedLocalHost('refuse_active_work'); + assert.equal(retirement.kind, 'retired'); assert.deepEqual(events, [ 'pause-launches', 'retire-except:42', 'prepare-host', 'wait:42', ]); - if (preparation.kind === 'prepared') preparation.rollback(); + if (retirement.kind === 'retired') retirement.resume(); assert.equal(events.at(-1), 'resume-launches'); await owner.close(); assert.equal(events.at(-1), 'release-launches'); @@ -194,12 +321,36 @@ test('resumes candidate launches when active tasks block the update', async () = startCandidate: async () => ready(current.candidate), }); - assert.deepEqual(await owner.prepareForUpdate(false), { kind: 'active_tasks' }); + assert.deepEqual(await owner.retireOwnedLocalHost('refuse_active_work'), { + kind: 'active_tasks', + }); assert.deepEqual(events, ['pause', 'retire', 'resume']); await owner.close(); assert.equal(events.at(-1), 'release'); }); +test('preserves Host facts when authorized retirement is refused', async () => { + const current = candidateHarness({ activeTasks: 'always' }); + const owner = await startRuntimeHostDesktopManager({ + rootPath: '/test-root', + } as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + }); + + await assert.rejects( + owner.retireOwnedLocalHost('interrupt_active_work'), + (error: unknown) => + error instanceof DesktopLocalHostRetirementError && + error.facts.hostId === 'test-host' && + error.facts.hostEpoch === 'test-host-epoch' && + error.facts.rootPath === '/test-root' && + error.facts.pid === 42 && + error.cause instanceof Error && + error.cause.message === 'Runtime Host refused authorized retirement', + ); + await owner.close(); +}); + test('resumes candidate launches when candidate retirement fails', async () => { const events: string[] = []; const current = candidateHarness(); @@ -218,7 +369,14 @@ test('resumes candidate launches when candidate retirement fails', async () => { startCandidate: async () => ready(current.candidate), }); - await assert.rejects(owner.prepareForUpdate(false), /retirement failed/); + await assert.rejects( + owner.retireOwnedLocalHost('refuse_active_work'), + (error: unknown) => + error instanceof DesktopLocalHostRetirementError && + error.facts.pid === 42 && + error.cause instanceof Error && + error.cause.message === 'retirement failed', + ); assert.deepEqual(events, ['pause', 'retire', 'resume']); await owner.handleBotIncomingMessage({ text: 'still connected' } as BotIncomingMessage); assert.equal(current.botMessages, 1); @@ -235,12 +393,14 @@ test('keeps active-task confirmation bound to the current Host', async () => { }, }); - assert.deepEqual(await owner.prepareForUpdate(false), { kind: 'active_tasks' }); + assert.deepEqual(await owner.retireOwnedLocalHost('refuse_active_work'), { + kind: 'active_tasks', + }); await owner.handleBotIncomingMessage({ text: 'still connected' } as BotIncomingMessage); assert.equal(current.botMessages, 1); - const authorized = await owner.prepareForUpdate(true); - assert.equal(authorized.kind, 'prepared'); - assert.deepEqual(current.prepareUpgradeAuthorities, [false, true]); + const authorized = await owner.retireOwnedLocalHost('interrupt_active_work'); + assert.equal(authorized.kind, 'retired'); + assert.deepEqual(current.retirementModes, ['refuse_active_work', 'interrupt_active_work']); assert.deepEqual(waitedFor, [42]); await owner.close(); }); @@ -253,10 +413,9 @@ for (const lifecycleMode of ['service', 'remote'] as const) { waitForHostExit: async () => assert.fail(`${lifecycleMode} Host exit must not be awaited`), }); - const preparation = await owner.prepareForUpdate(false); - assert.equal(preparation.kind, 'prepared'); - assert.equal(current.prepareUpgradeCalls, 0); - if (preparation.kind === 'prepared') preparation.rollback(); + const retirement = await owner.retireOwnedLocalHost('refuse_active_work'); + assert.equal(retirement.kind, 'not_owned'); + assert.equal(current.prepareRetirementCalls, 0); await owner.handleBotIncomingMessage({ text: 'still connected' } as BotIncomingMessage); assert.equal(current.botMessages, 1); await owner.close(); @@ -901,13 +1060,13 @@ function candidateHarness( options: { delayDisconnect?: boolean; disconnectOnPrepare?: boolean; - activeTasks?: boolean; + activeTasks?: boolean | 'always'; lifecycleMode?: 'ephemeral' | 'service' | 'remote'; hostId?: string; hostEpoch?: string; finalizeFailures?: Error[]; disconnectOnFinalizeFailure?: boolean; - onPrepare?: () => void; + onPrepare?: (mode: string) => unknown | Promise; } = {}, ) { let resolveClosed: (() => void) | undefined; @@ -918,13 +1077,14 @@ function candidateHarness( let botMessages = 0; const stoppedSessions: string[] = []; let lifecycleState: 'ready' | 'unavailable' = 'ready'; - let prepareUpgradeCalls = 0; + let prepareRetirementCalls = 0; let finalizeCalls = 0; const finalizeTimeouts: number[] = []; - const prepareUpgradeAuthorities: boolean[] = []; + const retirementModes: string[] = []; const candidate = { closed, hostLifecycleMode: options.lifecycleMode ?? 'ephemeral', + hostPid: 42, client: { hostId: options.hostId ?? 'test-host', hostEpoch: options.hostEpoch ?? 'test-host-epoch', @@ -934,11 +1094,14 @@ function candidateHarness( async queryHostDiagnostics() { return { pid: 42 }; }, - async prepareHostUpgrade(allowInterruptActiveTasks: boolean) { - options.onPrepare?.(); - prepareUpgradeCalls += 1; - prepareUpgradeAuthorities.push(allowInterruptActiveTasks); - if (options.activeTasks && !allowInterruptActiveTasks) { + async prepareHostRetirement(mode: string) { + prepareRetirementCalls += 1; + retirementModes.push(mode); + await options.onPrepare?.(mode); + if ( + (options.activeTasks && mode === 'refuse_active_work') || + options.activeTasks === 'always' + ) { return { kind: 'active_tasks' as const }; } if (options.disconnectOnPrepare) { @@ -991,11 +1154,11 @@ function candidateHarness( get stoppedSessions() { return stoppedSessions; }, - get prepareUpgradeCalls() { - return prepareUpgradeCalls; + get prepareRetirementCalls() { + return prepareRetirementCalls; }, - get prepareUpgradeAuthorities() { - return prepareUpgradeAuthorities; + get retirementModes() { + return retirementModes; }, get finalizeCalls() { return finalizeCalls; diff --git a/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts b/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts new file mode 100644 index 0000000000..bf1aab2f04 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-quit-copy.test.ts @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DesktopLocalHostRetirementError } from '../runtime-host-desktop-manager.js'; +import { buildRuntimeHostQuitFailureDialog } from '../runtime-host-quit-copy.js'; + +const failure = new DesktopLocalHostRetirementError( + { + hostId: 'root-id', + hostEpoch: 'host-epoch', + lifecycleMode: 'ephemeral', + rootPath: '/state/root', + pid: 4242, + }, + { cause: new Error('writer release timed out') }, +); + +for (const locale of ['en', 'zh'] as const) { + test(`quit failure copy exposes actionable Host facts in ${locale}`, () => { + const dialog = buildRuntimeHostQuitFailureDialog(failure, locale); + + assert.match(dialog.detail ?? '', /4242/); + assert.match(dialog.detail ?? '', /host-epoch/); + assert.match(dialog.detail ?? '', /\/state\/root/); + assert.match(dialog.detail ?? '', /writer release timed out/); + }); +} + +test('manual recovery copy names a cross-platform process-management concept', () => { + const english = buildRuntimeHostQuitFailureDialog(failure, 'en').detail ?? ''; + const chinese = buildRuntimeHostQuitFailureDialog(failure, 'zh').detail ?? ''; + + assert.match(english, /operating system's process-management tool/); + assert.match(chinese, /操作系统的进程管理工具/); + assert.doesNotMatch(`${english}\n${chinese}`, /Activity Monitor|Task Manager|活动监视器|任务管理器/); +}); diff --git a/apps/desktop/src/main/app-quit-coordinator.ts b/apps/desktop/src/main/app-quit-coordinator.ts index 6aef75305a..e6c997da77 100644 --- a/apps/desktop/src/main/app-quit-coordinator.ts +++ b/apps/desktop/src/main/app-quit-coordinator.ts @@ -27,35 +27,39 @@ export interface AppQuitCoordinator { } export interface AppQuitCoordinatorDeps { + prepareToQuit(): Promise; cleanup(): Promise; focusOrCreateWindow(signal: AbortSignal): void | Promise; + onPreparationError(error: unknown): void; onCleanupError(error: unknown): void; onWindowCreationError(error: unknown): void; resumeQuit(): void; } -type AppQuitPhase = 'running' | 'cleaning' | 'ready-to-exit'; +type AppQuitPhase = 'running' | 'preparing' | 'cleaning' | 'ready-to-exit'; export function createAppQuitCoordinator(deps: AppQuitCoordinatorDeps): AppQuitCoordinator { let phase: AppQuitPhase = 'running'; - const windowCreationAbort = new AbortController(); + let windowCreationAbort = new AbortController(); + + const focusOrCreateWindow = (): void => { + if (phase !== 'running') return; + try { + void Promise.resolve(deps.focusOrCreateWindow(windowCreationAbort.signal)).catch( + deps.onWindowCreationError, + ); + } catch (error) { + deps.onWindowCreationError(error); + } + }; return { - focusOrCreateWindow(): void { - if (phase !== 'running') return; - try { - void Promise.resolve(deps.focusOrCreateWindow(windowCreationAbort.signal)).catch( - deps.onWindowCreationError, - ); - } catch (error) { - deps.onWindowCreationError(error); - } - }, + focusOrCreateWindow, handleBeforeQuit(event): void { if (phase === 'ready-to-exit') return; event.preventDefault(); - if (phase === 'cleaning') return; - phase = 'cleaning'; + if (phase !== 'running') return; + phase = 'preparing'; windowCreationAbort.abort(); const finishCleanup = () => { // `before-quit` was cancelled inside Electron's native quit transaction. @@ -68,10 +72,25 @@ export function createAppQuitCoordinator(deps: AppQuitCoordinatorDeps): AppQuitC deps.resumeQuit(); }); }; - void deps.cleanup().then(finishCleanup, (error) => { - deps.onCleanupError(error); - finishCleanup(); - }); + void Promise.resolve() + .then(() => deps.prepareToQuit()) + .then( + () => { + phase = 'cleaning'; + return Promise.resolve() + .then(() => deps.cleanup()) + .then(finishCleanup, (error) => { + deps.onCleanupError(error); + finishCleanup(); + }); + }, + (error) => { + phase = 'running'; + windowCreationAbort = new AbortController(); + deps.onPreparationError(error); + focusOrCreateWindow(); + }, + ); }, }; } diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 5dafdc7aff..930515c2e8 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -155,6 +155,7 @@ import { startRuntimeHostDesktopManager, type RuntimeHostDesktopManager, } from "./runtime-host-desktop-manager.js"; +import { buildRuntimeHostQuitFailureDialog } from "./runtime-host-quit-copy.js"; import { createRuntimeHostUpgradePrompts } from "./runtime-host-upgrade-dialog.js"; import { registerRuntimeHostMemoryIpc } from "./runtime-host-memory-ipc-main.js"; import { @@ -651,7 +652,14 @@ const updateService = createAppUpdateService({ mainWindowController.send("app:updateStatusChanged", status), prepareInstall: async (input) => { if (!runtimeHostManager) throw new Error("Runtime Host manager is unavailable"); - return runtimeHostManager.prepareForUpdate(input.allowInterruptActiveTasks); + const retirement = await runtimeHostManager.retireOwnedLocalHost( + input.allowInterruptActiveTasks ? "interrupt_active_work" : "refuse_active_work", + ); + if (retirement.kind === "active_tasks") return retirement; + return { + kind: "prepared", + rollback: retirement.kind === "retired" ? retirement.resume : () => {}, + }; }, }); mcpManager.onChange(() => { @@ -1519,11 +1527,18 @@ function emitSessionsChanged( function wireLifecycle(): void { const quitCoordinator = createAppQuitCoordinator({ + prepareToQuit: prepareRuntimeHostDesktopQuit, cleanup: closeRuntimeHostDesktop, focusOrCreateWindow: (signal) => { if (mainWindowController.hasOpenWindows()) mainWindowController.focus(); else return mainWindowController.createWindow(signal); }, + onPreparationError: (error) => { + console.error("[runtime-host] quit retirement failed:", error); + void showRuntimeHostQuitFailure(error).catch((dialogError) => + console.error("[runtime-host] quit failure dialog failed:", dialogError), + ); + }, onCleanupError: (error) => console.error("[runtime-host] shutdown failed:", error), onWindowCreationError: (error) => @@ -1551,6 +1566,20 @@ function wireLifecycle(): void { quitCoordinator.focusOrCreateWindow(); } +async function prepareRuntimeHostDesktopQuit(): Promise { + const retirement = await runtimeHostManager?.retireOwnedLocalHost( + "interrupt_active_work", + ); + if (retirement?.kind === "active_tasks") { + throw new Error("Runtime Host refused authorized quit retirement"); + } +} + +async function showRuntimeHostQuitFailure(error: unknown): Promise { + const locale = await desktopLocale.resolve(); + await dialog.showMessageBox(buildRuntimeHostQuitFailureDialog(error, locale)); +} + async function closeRuntimeHostDesktop(): Promise { clientSettingsWatcher.stop(); updateService.dispose(); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 7fd5c647d0..de21a46047 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -46,9 +46,12 @@ import { type DecodedSessionTranscriptPage, type DirectRequestOperationKey, type RuntimeHostConnection, + type RuntimeHostRetirementMode, + type RuntimeHostRetirementPreparation, type RuntimeHostSessionSubscription, RuntimeHostCatalogReadError, RuntimeHostOperationError, + prepareConnectedRuntimeHostRetirement, readRuntimeHostAgentGraphEpochs, readRuntimeHostConnectionCatalog, readRuntimeHostInvocableSkills, @@ -1150,13 +1153,10 @@ export class DesktopRuntimeHostClient { return this.connection.queryHostDiagnostics(2_000); } - prepareHostUpgrade( - allowInterruptActiveTasks: boolean, - ): Promise> { - return this.request("host.upgrade.prepare", { - expectedHostEpoch: this.connection.hostEpoch, - allowInterruptActiveTasks, - }); + prepareHostRetirement( + mode: RuntimeHostRetirementMode, + ): Promise { + return prepareConnectedRuntimeHostRetirement(this.connection, mode); } stopTurn( diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 071e86bd8a..40cae3d71e 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -186,6 +186,7 @@ export interface DesktopRuntimeHostCandidate { readonly client: DesktopRuntimeHostClient; readonly closed: Promise; readonly hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + readonly hostPid?: number; stopSession(sessionId: string): Promise; close(): Promise; } @@ -195,6 +196,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { readonly client: DesktopRuntimeHostClient; readonly closed: Promise; readonly hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + readonly hostPid: number | undefined; readonly #client: DesktopRuntimeHostClient; readonly #observer: RuntimeHostSessionObserver; readonly #ipc: ScopedIpcMain; @@ -220,6 +222,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { closeSessionObservations: () => Promise; connectionClosed: Promise; hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote"; + hostPid?: number; hasRegisteredCapabilities: () => boolean; stopSession: (sessionId: string) => Promise; }) { @@ -237,6 +240,7 @@ class DesktopRuntimeHostCandidateImpl implements DesktopRuntimeHostCandidate { this.#stopSession = input.stopSession; this.botIncoming = input.botIncoming; this.hostLifecycleMode = input.hostLifecycleMode; + this.hostPid = input.hostPid; this.closed = input.connectionClosed.then(() => this.close()); } @@ -302,6 +306,7 @@ export async function startDesktopRuntimeHostCandidate( observationRegistry, connection.registration.lifecycleMode, "local", + connection.registration.pid, ), }; } catch (error) { @@ -404,6 +409,7 @@ export async function createDesktopRuntimeHostCandidate( observationRegistry: RuntimeHostSessionObservationRegistry | undefined, hostLifecycleMode: HostRegistration["lifecycleMode"] | "remote", targetKind: DesktopRuntimeHostTargetPolicy["kind"], + hostPid?: number, ): Promise { const target: DesktopRuntimeHostTargetPolicy = { kind: targetKind, @@ -726,6 +732,7 @@ export async function createDesktopRuntimeHostCandidate( : Promise.resolve(), connectionClosed: connection.closed, hostLifecycleMode, + ...(hostPid === undefined ? {} : { hostPid }), hasRegisteredCapabilities: () => capabilitiesRegistered, stopSession, }); diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index eec13f75f0..76c5c82aaa 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -30,6 +30,7 @@ import { type ResolvedRuntimeHostProfile, type RuntimeHostReconnectBackoff, type RuntimeHostReconnectLifecycle, + type RuntimeHostRetirementMode, type RuntimeHostSshInteraction, } from '@maka/runtime-host/client'; import type { HostRegistration } from '@maka/runtime-host/protocol'; @@ -70,9 +71,7 @@ export interface RuntimeHostDesktopManager { signal?: AbortSignal, ): Promise; setDefaultProfile(profileId: string): void; - prepareForUpdate( - allowInterruptActiveTasks: boolean, - ): Promise; + retireOwnedLocalHost(mode: RuntimeHostRetirementMode): Promise; close(): Promise; } @@ -105,9 +104,33 @@ export type RuntimeHostDesktopTargetState = readonly error: Error; }; -export type RuntimeHostUpdatePreparation = +export type DesktopLocalHostRetirement = | { readonly kind: 'active_tasks' } - | { readonly kind: 'prepared'; rollback(): void }; + | { readonly kind: 'not_owned' } + | { readonly kind: 'retired'; resume(): void }; + +interface DesktopLocalHostRetirementTask { + readonly mode: RuntimeHostRetirementMode; + readonly result: Promise; +} + +export interface DesktopLocalHostRetirementFacts { + readonly hostId: string; + readonly hostEpoch: string; + readonly lifecycleMode: 'ephemeral'; + readonly rootPath: string; + readonly pid?: number; +} + +export class DesktopLocalHostRetirementError extends Error { + constructor( + readonly facts: DesktopLocalHostRetirementFacts, + options: ErrorOptions, + ) { + super('Unable to retire the Desktop-owned local Runtime Host', options); + this.name = 'DesktopLocalHostRetirementError'; + } +} export type RuntimeHostRestartDecision = 'restart' | 'wait' | 'cancel'; export type RuntimeHostWaitDecision = 'wait' | 'cancel'; @@ -206,6 +229,8 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { readonly #baseInput: DesktopRuntimeHostCandidateStartInput; readonly #pairingFinalizationShutdown = new AbortController(); #defaultProfileId: string = LOCAL_RUNTIME_HOST_PROFILE.id; + #localHostRetirement: Extract | undefined; + #localHostRetirementTask: DesktopLocalHostRetirementTask | undefined; #closed = false; #closeTask: Promise | undefined; @@ -496,13 +521,43 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { this.onDefaultProfileChanged?.(profileId); } - async prepareForUpdate( - allowInterruptActiveTasks: boolean, - ): Promise { + retireOwnedLocalHost( + mode: RuntimeHostRetirementMode, + ): Promise { + if (this.#localHostRetirement) return Promise.resolve(this.#localHostRetirement); + + const activeTask = this.#localHostRetirementTask; + if (activeTask) { + if ( + activeTask.mode === 'refuse_active_work' && + mode === 'interrupt_active_work' + ) { + return activeTask.result.then((result) => + result.kind === 'active_tasks' + ? this.retireOwnedLocalHost(mode) + : result, + ); + } + return activeTask.result; + } + + const result = this.#retireOwnedLocalHost(mode).finally(() => { + if (this.#localHostRetirementTask?.result === result) { + this.#localHostRetirementTask = undefined; + } + }); + this.#localHostRetirementTask = { mode, result }; + return result; + } + + async #retireOwnedLocalHost( + mode: RuntimeHostRetirementMode, + ): Promise { const lifecycle = this.#requireLifecycle( this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id), ); const quiescence = lifecycle.quiesce(); + let hostPid = quiescence.current.hostPid; let launchBarrierPaused = false; const resume = () => { if (launchBarrierPaused) { @@ -516,29 +571,60 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { quiescence.current.hostLifecycleMode === 'service' || quiescence.current.hostLifecycleMode === 'remote' ) { - return { kind: 'prepared', rollback: resume }; + resume(); + return { kind: 'not_owned' }; } this.#baseInput.candidateLaunchBarrier?.pause(); launchBarrierPaused = this.#baseInput.candidateLaunchBarrier !== undefined; const diagnostics = await quiescence.current.client.queryHostDiagnostics(); + hostPid = diagnostics.pid; // The adopted Host still owns the root here, so every other owned launch // can be settled without allowing it to become a late election winner. await this.#baseInput.candidateLaunchBarrier?.retireExcept(diagnostics.pid); - const result = await quiescence.current.client.prepareHostUpgrade( - allowInterruptActiveTasks, - ); + const result = await quiescence.current.client.prepareHostRetirement(mode); if (result.kind === 'active_tasks') { + if (mode === 'interrupt_active_work') { + throw new Error('Runtime Host refused authorized retirement'); + } resume(); return result; } await this.waitForHostExit(result.pid); - return { kind: 'prepared', rollback: resume }; + return this.#completeLocalHostRetirement(resume); } catch (error) { resume(); - throw error; + throw new DesktopLocalHostRetirementError( + { + hostId: quiescence.current.client.hostId, + hostEpoch: quiescence.current.client.hostEpoch, + lifecycleMode: 'ephemeral', + rootPath: this.#baseInput.rootPath, + ...(hostPid === undefined ? {} : { pid: hostPid }), + }, + { cause: error }, + ); } } + #completeLocalHostRetirement( + resume: () => void, + ): Extract { + let active = true; + const retirement = { + kind: 'retired' as const, + resume: () => { + if (!active) return; + active = false; + if (this.#localHostRetirement !== retirement) return; + this.#localHostRetirement = undefined; + if (this.#closed) return; + resume(); + }, + }; + this.#localHostRetirement = retirement; + return retirement; + } + close(): Promise { this.#closeTask ??= this.#close(); return this.#closeTask; @@ -902,7 +988,7 @@ async function waitForProcessRetirement( async function waitForProcessExit(pid: number): Promise { const deadline = Date.now() + 10_000; while (isProcessAlive(pid)) { - if (Date.now() >= deadline) throw new Error('Runtime Host did not exit before update'); + if (Date.now() >= deadline) throw new Error('Runtime Host did not exit before retirement'); await new Promise((resolve) => setTimeout(resolve, 50)); } } diff --git a/apps/desktop/src/main/runtime-host-quit-copy.ts b/apps/desktop/src/main/runtime-host-quit-copy.ts new file mode 100644 index 0000000000..f9bfff703d --- /dev/null +++ b/apps/desktop/src/main/runtime-host-quit-copy.ts @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiLocale } from '@maka/core/ui-locale'; +import type { MessageBoxOptions } from 'electron'; +import { DesktopLocalHostRetirementError } from './runtime-host-desktop-manager.js'; + +export function buildRuntimeHostQuitFailureDialog( + error: unknown, + locale: UiLocale, +): MessageBoxOptions { + const retirement = error instanceof DesktopLocalHostRetirementError ? error : undefined; + const copy = COPY[locale]; + const details: string[] = [copy.detail]; + if (retirement) { + details.push(`State Root: ${retirement.facts.rootPath}`); + details.push(`Host epoch: ${retirement.facts.hostEpoch}`); + if (retirement.facts.pid !== undefined) { + details.push(copy.process(retirement.facts.pid), copy.manual); + } + } + const cause = error instanceof Error && error.cause instanceof Error + ? error.cause.message + : error instanceof Error + ? error.message + : String(error); + details.push(`${copy.cause}: ${cause}`); + return { + type: 'error', + title: copy.title, + message: copy.message, + detail: details.join('\n'), + buttons: [copy.button], + defaultId: 0, + noLink: true, + }; +} + +const COPY = { + en: { + title: 'Unable to quit Maka safely', + message: 'The local Runtime Host could not stop safely. Maka is still running.', + detail: 'Quit was cancelled. Try again, or inspect diagnostics if the problem persists.', + process: (pid: number) => `Runtime Host process PID: ${pid}`, + manual: + "If retry still fails, confirm that no execution must be preserved before stopping this PID with the operating system's process-management tool.", + cause: 'Cause', + button: 'OK', + }, + zh: { + title: '无法安全退出 Maka', + message: '本地 Runtime Host 未能安全停止,Maka 仍在运行。', + detail: '退出已取消。请重试;如果问题持续存在,请查看诊断信息。', + process: (pid: number) => `Runtime Host 进程 PID:${pid}`, + manual: '如果重试仍然失败,请先确认没有需要保留的执行,再通过操作系统的进程管理工具停止该 PID。', + cause: '原因', + button: '好', + }, +} as const; diff --git a/packages/cli/src/runtime-host-service-manager.ts b/packages/cli/src/runtime-host-service-manager.ts index d6b73d196a..a7b5f7a565 100644 --- a/packages/cli/src/runtime-host-service-manager.ts +++ b/packages/cli/src/runtime-host-service-manager.ts @@ -29,7 +29,10 @@ import { PROJECT_DIRECTORY_ROOT_LABEL_MAX_BYTES, RUNTIME_HOST_PROTOCOL_VERSION, } from '@maka/runtime-host/protocol'; -import { connectExistingRuntimeHost } from '@maka/runtime-host/client'; +import { + connectExistingRuntimeHost, + prepareConnectedRuntimeHostRetirement, +} from '@maka/runtime-host/client'; import { RUNTIME_HOST_SERVICE_LOG_MAX_BYTES } from '@maka/runtime-host/operator'; import { withLegacyFileUpdateLockLease, @@ -1011,10 +1014,10 @@ async function prepareRuntimeHostRetirement( 'The State Root is owned by a different Runtime Host process', ); } - const prepared = await connected.connection.request('host.upgrade.prepare', { - expectedHostEpoch: hostEpoch, - allowInterruptActiveTasks, - }); + const prepared = await prepareConnectedRuntimeHostRetirement( + connected.connection, + allowInterruptActiveTasks ? 'interrupt_active_work' : 'refuse_active_work', + ); if (prepared.kind === 'active_tasks') return prepared; if (prepared.pid !== expectedPid) { throw new RuntimeHostServiceManagerError( diff --git a/packages/runtime-host/src/__tests__/host-retirement.test.ts b/packages/runtime-host/src/__tests__/host-retirement.test.ts new file mode 100644 index 0000000000..890aad0359 --- /dev/null +++ b/packages/runtime-host/src/__tests__/host-retirement.test.ts @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { RuntimeHostConnection } from '../client/connection.js'; +import { prepareConnectedRuntimeHostRetirement } from '../client/host-retirement.js'; + +test('retirement binds both interruption policy choices to the authenticated Host epoch', async () => { + const requests: unknown[] = []; + const connection = { + hostEpoch: 'authenticated-host', + request: async (operation: string, input: unknown) => { + requests.push({ operation, input }); + return { kind: 'prepared', pid: 42 }; + }, + } as unknown as RuntimeHostConnection; + + await prepareConnectedRuntimeHostRetirement(connection, 'refuse_active_work'); + await prepareConnectedRuntimeHostRetirement(connection, 'interrupt_active_work'); + + assert.deepEqual(requests, [ + { + operation: 'host.upgrade.prepare', + input: { + expectedHostEpoch: 'authenticated-host', + allowInterruptActiveTasks: false, + }, + }, + { + operation: 'host.upgrade.prepare', + input: { + expectedHostEpoch: 'authenticated-host', + allowInterruptActiveTasks: true, + }, + }, + ]); +}); diff --git a/packages/runtime-host/src/client/host-retirement.ts b/packages/runtime-host/src/client/host-retirement.ts new file mode 100644 index 0000000000..4ea0bf52b4 --- /dev/null +++ b/packages/runtime-host/src/client/host-retirement.ts @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { OperationOutput } from '../protocol/index.js'; +import type { RuntimeHostConnection } from './connection.js'; + +export type RuntimeHostRetirementMode = 'refuse_active_work' | 'interrupt_active_work'; +export type RuntimeHostRetirementPreparation = OperationOutput<'host.upgrade.prepare'>; + +/** + * Requests retirement of the exact authenticated Host behind `connection`. + * + * `host.upgrade.prepare` is the current wire identifier. Keep that historical + * transport detail here so lifecycle owners can model the operation as + * retirement instead of spreading update-specific authority. + */ +export function prepareConnectedRuntimeHostRetirement( + connection: RuntimeHostConnection, + mode: RuntimeHostRetirementMode, +): Promise { + return connection.request('host.upgrade.prepare', { + expectedHostEpoch: connection.hostEpoch, + allowInterruptActiveTasks: mode === 'interrupt_active_work', + }); +} diff --git a/packages/runtime-host/src/client/index.ts b/packages/runtime-host/src/client/index.ts index 203d051726..168ed8b7a2 100644 --- a/packages/runtime-host/src/client/index.ts +++ b/packages/runtime-host/src/client/index.ts @@ -26,6 +26,11 @@ export { type RuntimeHostConnection, type DirectRequestOperationKey, } from './connection.js'; +export { + prepareConnectedRuntimeHostRetirement, + type RuntimeHostRetirementMode, + type RuntimeHostRetirementPreparation, +} from './host-retirement.js'; export { LOCAL_RUNTIME_HOST_PROFILE, RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, From 863d7ae2c437045cf1922997afeab34b363200ab Mon Sep 17 00:00:00 2001 From: QuinnWan <144975606+somewan820@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:22:03 +0800 Subject: [PATCH 011/386] feat(desktop): add Work Board Phase 1 capture/list MVP (#3135) * feat(desktop): expose the Work Board store through main-process IPC Phase 1 slice 1: the Desktop main process owns WorkBoardStore and registers workBoard:list/create/update/archive/unarchive/remove handlers plus a workBoard:changed signal. Renderer code stays read-only through IPC; Runtime Host and model tools are not involved. Generated-by: Codex * feat(desktop): expose Work Board IPC through the preload bridge Phase 1 slice 2: shared IPC result/change types, window.maka.workBoard namespace in the preload bridge, and a renderer useWorkBoard hook that reloads on the workBoard:changed signal. Generated-by: Codex * feat(desktop): add the Work Board panel as a workbar tab Phase 1 slice 3: compact capture/list MVP in the session workbar with Inbox / current-project filtering, manual create, rename, move, complete, reopen, archive, restore, and delete. The panel is a read-only renderer projection over the main-process WorkBoardStore IPC. Generated-by: Codex * docs(work-board): add the Phase 1 capture/list MVP page Phase 1 slice 4: document the workbar surface, boundary, and main-process IPC ownership for the capture/list MVP. Generated-by: Codex * fix(desktop): address Phase 1 review and refresh surface inventory - accept the persisted work-board tab kind in isSessionWorkbarTabKind; - keep create/rename drafts when a mutation fails; - drop the incomplete tablist role and derive the panel aria-label from the filter; - rely on the workBoard:changed signal as the single reload path after mutations; - move Work Board panel copy into DesktopConversationCopy; - remove the branch-specific status from the Phase 1 doc; - regenerate the Astryx surface inventory for the new panel and stylesheet. Generated-by: Codex * docs(work-board): justify the dedicated store and resequence validation Add the maintainer-requested rationale for a store over a project file (typed provenance, stable identity/CAS under concurrent writers, Session linking and result refs as the load-bearing reasons) and record the plan to validate a thin capture -> revisit -> start-as-task loop before Phases 2/4. Generated-by: Codex * docs(work-board): record the assumption and the Phase 3 spike gate Per maintainer checklist: state provenance + Session linking as the explicit justification for the store, write down the assumption Phase 3 must prove, and gate Phases 2/4 behind a thin flag-gated start-as-task spike. Generated-by: Codex * fix(desktop): keep pagination, scope, and IME-safe inputs in the Work Board panel Address Astro-Han P2/P3: - useWorkBoard retains nextCursor and exposes a bounded loadMore path; - the panel resets to Inbox when the current project disappears, keeping the filter, label, query, and create scope identical; - create and rename ignore Enter while an IME composition is active. Generated-by: Codex * fix(desktop): keep items visible and retry the same cursor on continuation failures Address CodeRabbit: refresh or loadMore failures no longer replace the list with a fatal error when items already exist; a non-fatal banner keeps the items visible and retry re-runs the failed cursor (or the first page for refresh failures). Generated-by: Codex * fix(desktop): address Work Board review findings - close the WorkBoardStore during desktop shutdown - pass revision CAS guards through all renderer mutations - preserve loaded pagination during mutation refreshes - use Astryx TextInput with IME-safe create and rename handling Generated-by: Codex * fix(work-board): include relinked project aliases Query Work Board project scopes across canonical and absorbed project identities so relinking does not hide existing items. Normalize the identity set and bind pagination cursors to the complete scope. Generated-by: Codex * fix(work-board): freeze rename draft revision Keep the optimistic-concurrency revision captured when rename starts, so a change signal cannot turn a stale draft into an accepted overwrite. Generated-by: Codex * ci: retrigger Work Board checks The pull request became ready after the last synchronize event; retrigger the required CI workflow for the verified head. Generated-by: Codex * fix(work-board): bound relinked scope cursors Hash the normalized project identity filter before placing it in an opaque pagination cursor. This preserves the full alias set as the query authority without generating cursors that exceed their own validation limits. Generated-by: Codex * style(work-board): format paginated store assertion * fix(desktop): sync workbar and composer contracts * fix(desktop): keep Work Board data behind Workbar host * chore(ci): refresh Astryx surface inventory * fix(desktop): tighten Work Board row and host contracts * test(desktop): cover Work Board paginated refresh * fix(desktop): close Work Board and Quote Companion review gaps * fix(storage): recognize Work Board entrypoint consumer --- .../src/main/__tests__/use-work-board.test.ts | 132 ++++++ .../__tests__/work-board-ipc-main.test.ts | 227 ++++++++++ .../main/__tests__/work-board-panel.test.ts | 163 ++++++++ .../main/__tests__/workbar-boundary.test.ts | 58 +++ .../main/__tests__/workbar-controller.test.ts | 16 + apps/desktop/src/main/runtime-host-boot.ts | 10 + apps/desktop/src/main/work-board-ipc-main.ts | 179 ++++++++ apps/desktop/src/preload/bridge-contract.d.ts | 23 ++ apps/desktop/src/preload/preload.ts | 27 ++ apps/desktop/src/renderer/app-shell.tsx | 2 + .../controller/use-workbar-controller.ts | 17 +- .../features/workbar/model/workbar-tabs.ts | 3 + .../workbar/model/workbar-tool-definitions.ts | 8 + .../tools/side-chat/quote-companion-panel.tsx | 13 +- .../features/workbar/ui/workbar-host.tsx | 15 +- .../features/workbar/ui/workbar-surface.tsx | 126 ++++-- .../src/renderer/locales/conversation-copy.ts | 75 ++++ apps/desktop/src/renderer/styles.css | 1 + .../src/renderer/styles/work-board.css | 100 +++++ apps/desktop/src/renderer/use-work-board.ts | 258 ++++++++++++ .../desktop/src/renderer/work-board-panel.tsx | 390 ++++++++++++++++++ apps/desktop/src/renderer/workhub-surface.tsx | 1 - apps/desktop/src/shared/work-board-ipc.ts | 33 ++ docs/README.md | 1 + docs/astryx-surface-file-inventory.md | 4 +- docs/astryx-surface-file-inventory.paths | 2 + docs/work-board-phase1.md | 69 ++++ .../core/src/__tests__/work-board.test.ts | 24 ++ packages/core/src/work-board.ts | 30 +- .../src/__tests__/public-entrypoints.test.ts | 1 - .../src/__tests__/work-board-store.test.ts | 73 ++++ packages/storage/src/work-board-list-query.ts | 17 +- packages/ui/src/components.tsx | 1 + packages/ui/src/composer.tsx | 3 + packages/ui/src/index.ts | 1 + 35 files changed, 2037 insertions(+), 66 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/use-work-board.test.ts create mode 100644 apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts create mode 100644 apps/desktop/src/main/__tests__/work-board-panel.test.ts create mode 100644 apps/desktop/src/main/work-board-ipc-main.ts create mode 100644 apps/desktop/src/renderer/styles/work-board.css create mode 100644 apps/desktop/src/renderer/use-work-board.ts create mode 100644 apps/desktop/src/renderer/work-board-panel.tsx create mode 100644 apps/desktop/src/shared/work-board-ipc.ts create mode 100644 docs/work-board-phase1.md diff --git a/apps/desktop/src/main/__tests__/use-work-board.test.ts b/apps/desktop/src/main/__tests__/use-work-board.test.ts new file mode 100644 index 0000000000..f01c0fde21 --- /dev/null +++ b/apps/desktop/src/main/__tests__/use-work-board.test.ts @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { act, createElement } from 'react'; +import type { WorkBoardItem } from '@maka/core/work-board'; +import type { + WorkBoardChangedEvent, + WorkBoardIpcResult, +} from '../../shared/work-board-ipc.js'; +import { cleanupFakeDom, installReactRenderer } from './fake-dom.js'; +import { useWorkBoard } from '../../renderer/use-work-board.js'; + +interface Harness { + listCalls: Array<{ cursor?: string; limit?: number }>; + emitChanged(): void; +} + +function item(id: number): WorkBoardItem { + return { + schemaVersion: 1, + id: `item-${id}`, + revision: 1, + scope: { kind: 'inbox' }, + title: `Item ${id}`, + state: 'todo', + creator: { kind: 'user' }, + provenance: { kind: 'manual' }, + createdAt: id, + updatedAt: id, + archived: false, + }; +} + +function installHarness(): Harness { + const allItems = Array.from({ length: 60 }, (_, index) => item(index)); + const listCalls: Harness['listCalls'] = []; + let changed: ((event: WorkBoardChangedEvent) => void) | undefined; + const list = async (query?: { cursor?: string; limit?: number }): Promise> => { + listCalls.push({ cursor: query?.cursor, limit: query?.limit }); + const start = query?.cursor ? Number(query.cursor) : 0; + const limit = query?.limit ?? 50; + const end = Math.min(start + limit, allItems.length); + return { + ok: true, + value: { + items: allItems.slice(start, end), + nextCursor: end < allItems.length ? String(end) : undefined, + }, + }; + }; + const unexpectedMutation = async (): Promise => { + throw new Error('mutation is not expected in this test'); + }; + (globalThis.window as unknown as { maka: unknown }).maka = { + workBoard: { + list, + create: unexpectedMutation, + update: unexpectedMutation, + archive: unexpectedMutation, + unarchive: unexpectedMutation, + remove: unexpectedMutation, + subscribeChanges(listener: (event: WorkBoardChangedEvent) => void) { + changed = listener; + return () => { + changed = undefined; + }; + }, + }, + }; + return { + listCalls, + emitChanged() { + changed?.({ type: 'work_board_changed', ts: 1 }); + }, + }; +} + +function Probe(props: { onValue(value: ReturnType): void }) { + props.onValue(useWorkBoard()); + return null; +} + +describe('useWorkBoard', () => { + afterEach(() => { + cleanupFakeDom(); + }); + + it('preserves the loaded window when a mutation change signal refreshes the board', async () => { + const { root } = installReactRenderer(); + const harness = installHarness(); + let board: ReturnType | undefined; + + await act(async () => { + root.render(createElement(Probe, { onValue: (value) => (board = value) })); + }); + await act(async () => board?.loadMore()); + + assert.equal(board?.items.length, 60); + assert.equal(board?.nextCursor, undefined); + assert.deepEqual(harness.listCalls, [ + { cursor: undefined, limit: undefined }, + { cursor: '50', limit: undefined }, + ]); + + await act(async () => harness.emitChanged()); + + assert.equal(board?.items.length, 60); + assert.equal(board?.nextCursor, undefined); + assert.deepEqual(harness.listCalls.at(-1), { cursor: undefined, limit: 60 }); + }); +}); diff --git a/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts b/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts new file mode 100644 index 0000000000..aaabba2f51 --- /dev/null +++ b/apps/desktop/src/main/__tests__/work-board-ipc-main.test.ts @@ -0,0 +1,227 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import type { IpcMain } from 'electron'; +import { + registerWorkBoardIpc, + type WorkBoardChangedEvent, + type WorkBoardIpcResult, +} from '../work-board-ipc-main.js'; + +interface FakeIpcMain { + handle(channel: string, handler: (...args: unknown[]) => unknown): void; + invoke(channel: string, ...args: unknown[]): Promise; +} + +function createFakeIpcMain(): FakeIpcMain & { readonly channels: string[] } { + const handlers = new Map unknown>(); + const channels: string[] = []; + return { + channels, + handle(channel, handler) { + channels.push(channel); + handlers.set(channel, handler); + }, + invoke(channel: string, ...args: unknown[]): Promise { + const handler = handlers.get(channel); + if (!handler) throw new Error(`No handler registered for ${channel}`); + return Promise.resolve(handler(undefined, ...args)) as Promise; + }, + }; +} + +function createFakeWindowController(): { + readonly events: Array<{ channel: string; args: unknown[] }>; + send(channel: string, ...args: unknown[]): void; +} { + const events: Array<{ channel: string; args: unknown[] }> = []; + return { + events, + send(channel, ...args) { + events.push({ channel, args }); + }, + }; +} + +function itemInput(): { + scope: { kind: 'inbox' }; + title: string; + creator: { kind: 'user' }; + provenance: { kind: 'manual' }; +} { + return { + scope: { kind: 'inbox' }, + title: 'Review auth', + creator: { kind: 'user' }, + provenance: { kind: 'manual' }, + }; +} + +async function withTempRoot(run: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-work-board-ipc-')); + try { + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +describe('Work Board IPC', () => { + test('creates and lists items and emits change signals', async () => { + await withTempRoot(async (root) => { + const ipc = createFakeIpcMain(); + const window = createFakeWindowController(); + const registration = registerWorkBoardIpc({ + ipcMain: ipc as unknown as Pick, + workspaceRoot: root, + mainWindowController: window, + }); + try { + const created = await ipc.invoke>( + 'workBoard:create', + itemInput(), + ); + assert.equal(created.ok, true); + assert.ok(created.ok && created.value.id); + + const page = await ipc.invoke }>>( + 'workBoard:list', + {}, + ); + assert.equal(page.ok, true); + assert.ok(page.ok); + assert.equal(page.value.items.length, 1); + assert.equal(page.value.items[0]?.id, created.ok ? created.value.id : undefined); + + const changed = window.events.filter( + (event) => event.channel === 'workBoard:changed', + ); + assert.equal(changed.length, 1); + const event = changed[0]?.args[0] as WorkBoardChangedEvent; + assert.equal(event.type, 'work_board_changed'); + assert.ok(typeof event.ts === 'number'); + assert.deepEqual(ipc.channels, [ + 'workBoard:list', + 'workBoard:create', + 'workBoard:update', + 'workBoard:archive', + 'workBoard:unarchive', + 'workBoard:remove', + ]); + } finally { + registration.close(); + } + }); + }); + + test('applies lifecycle mutations and fails closed on invalid input', async () => { + await withTempRoot(async (root) => { + const ipc = createFakeIpcMain(); + const window = createFakeWindowController(); + const registration = registerWorkBoardIpc({ + ipcMain: ipc as unknown as Pick, + workspaceRoot: root, + mainWindowController: window, + }); + try { + const created = await ipc.invoke>( + 'workBoard:create', + itemInput(), + ); + assert.ok(created.ok); + const id = created.ok ? created.value.id : ''; + + const renamed = await ipc.invoke< + WorkBoardIpcResult<{ title: string; revision: number; state: string }> + >('workBoard:update', id, { title: 'Review auth v2' }); + assert.ok(renamed.ok); + assert.equal(renamed.ok && renamed.value.revision, 2); + + const staleRename = await ipc.invoke>( + 'workBoard:update', + id, + { title: 'stale write' }, + { expectedRevision: 1 }, + ); + assert.equal(staleRename.ok, false); + if (!staleRename.ok) assert.equal(staleRename.code, 'operation_conflict'); + + const removedBeforeArchive = await ipc.invoke>( + 'workBoard:remove', + id, + ); + assert.equal(removedBeforeArchive.ok, false); + if (!removedBeforeArchive.ok) { + assert.equal(removedBeforeArchive.code, 'must_archive_first'); + } + + const archived = await ipc.invoke< + WorkBoardIpcResult<{ archived: boolean; revision: number }> + >('workBoard:archive', id); + assert.ok(archived.ok); + assert.equal(archived.ok && archived.value.archived, true); + + const reopened = await ipc.invoke< + WorkBoardIpcResult<{ archived: boolean; revision: number }> + >('workBoard:unarchive', id); + assert.ok(reopened.ok); + assert.equal(reopened.ok && reopened.value.archived, false); + + const invalidPatch = await ipc.invoke>( + 'workBoard:update', + id, + { titel: 'x' }, + ); + assert.equal(invalidPatch.ok, false); + if (!invalidPatch.ok) assert.equal(invalidPatch.code, 'invalid_input'); + + const invalidCreate = await ipc.invoke>( + 'workBoard:create', + { ...itemInput(), notes: null }, + ); + assert.equal(invalidCreate.ok, false); + if (!invalidCreate.ok) assert.equal(invalidCreate.code, 'invalid_input'); + + await ipc.invoke('workBoard:archive', id); + const removed = await ipc.invoke>('workBoard:remove', id); + assert.ok(removed.ok); + + const page = await ipc.invoke>( + 'workBoard:list', + {}, + ); + assert.ok(page.ok); + assert.equal(page.ok && page.value.items.length, 0); + + // create, update, archive, unarchive, archive, remove = 6 mutations + const changed = window.events.filter( + (event) => event.channel === 'workBoard:changed', + ); + assert.equal(changed.length, 6); + } finally { + registration.close(); + } + }); + }); +}); diff --git a/apps/desktop/src/main/__tests__/work-board-panel.test.ts b/apps/desktop/src/main/__tests__/work-board-panel.test.ts new file mode 100644 index 0000000000..0a2bcf4eef --- /dev/null +++ b/apps/desktop/src/main/__tests__/work-board-panel.test.ts @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { afterEach, test } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { WorkBoardItem } from '@maka/core/work-board'; +import { LocaleProvider } from '@maka/ui'; +import type { WorkBoardIpcResult } from '../../shared/work-board-ipc.js'; +import { WorkBoardPanel } from '../../renderer/work-board-panel.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + matchMedia: globalThis.matchMedia, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }).IS_REACT_ACT_ENVIRONMENT, +}; +const mountedRoots: Root[] = []; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function item(): WorkBoardItem { + return { + schemaVersion: 1, + id: 'created-item', + revision: 1, + scope: { kind: 'inbox' }, + title: 'Later', + state: 'todo', + creator: { kind: 'user' }, + provenance: { kind: 'manual' }, + createdAt: 1, + updatedAt: 1, + archived: false, + }; +} + +async function renderPanel(create: (input: unknown) => Promise>) { + const { document, window } = parseHTML('

'); + const matchMedia = () => ({ + matches: false, + media: '', + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => false, + }); + Object.assign(window, { matchMedia }); + Object.assign(globalThis, { + document, + window, + matchMedia, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => undefined, + IS_REACT_ACT_ENVIRONMENT: true, + }); + (globalThis.window as unknown as { maka: unknown }).maka = { + workBoard: { + list: async () => ({ ok: true, value: { items: [], nextCursor: undefined } }), + create, + update: async () => ({ ok: true, value: item() }), + archive: async () => ({ ok: true, value: item() }), + unarchive: async () => ({ ok: true, value: item() }), + remove: async () => ({ ok: true, value: undefined }), + subscribeChanges: () => () => undefined, + }, + }; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + await act(async () => { + root.render( + createElement( + LocaleProvider, + { locale: 'en', children: createElement(WorkBoardPanel, { projectId: null }) }, + ), + ); + await Promise.resolve(); + }); + return { container, window }; +} + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, originalGlobals); +}); + +test('prevents a second Work Board create while the first request is pending', async () => { + const createResult = deferred>(); + let createCalls = 0; + const harness = await renderPanel(async () => { + createCalls += 1; + return createResult.promise; + }); + const input = harness.container.querySelector('input'); + assert.ok(input); + input.value = 'Later'; + const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); + assert.ok(propsKey, 'missing React props on input'); + const props = (input as unknown as Record)[propsKey] as { + onChange?: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void; + }; + assert.ok(props.onChange, 'missing React change handler'); + await act(async () => { + props.onChange?.({ target: input, defaultPrevented: false }); + await Promise.resolve(); + }); + + const add = Array.from(harness.container.querySelectorAll('button')).find( + (button) => button.textContent === 'Add', + ); + assert.ok(add); + await act(async () => { + add.click(); + add.click(); + }); + + assert.equal(createCalls, 1); + assert.equal(add.getAttribute('disabled'), ''); + + await act(async () => { + createResult.resolve({ ok: true, value: item() }); + await createResult.promise; + await new Promise((resolve) => setImmediate(resolve)); + }); + assert.equal(createCalls, 1); + assert.equal(input.getAttribute('disabled'), null); +}); diff --git a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts index 11b49820f1..352a9e7dde 100644 --- a/apps/desktop/src/main/__tests__/workbar-boundary.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-boundary.test.ts @@ -113,4 +113,62 @@ describe('Workbar feature boundary', () => { true, ); }); + + it('projects Work Board project identity through the controller-owned host model', () => { + const appShell = readFileSync( + join(desktopRoot, 'src', 'renderer', 'app-shell.tsx'), + 'utf8', + ); + const controller = readFileSync( + join(featureRoot, 'controller', 'use-workbar-controller.ts'), + 'utf8', + ); + const host = readFileSync( + join(featureRoot, 'ui', 'workbar-host.tsx'), + 'utf8', + ); + + assert.equal(appShell.includes('projectId: currentProjectId'), true); + assert.equal( + appShell.includes('projectAliases: currentProject?.aliases ?? []'), + true, + ); + assert.equal(controller.includes('projectId: input.projectId'), true); + assert.equal( + controller.includes('projectAliases: input.projectAliases'), + true, + ); + assert.equal( + host.includes('projectAliases={props.projectAliases}'), + true, + ); + }); + + it('keeps Quote Companion catalog state and localized scroll copy at the seam', () => { + const quotePanel = readFileSync( + join( + featureRoot, + 'tools', + 'side-chat', + 'quote-companion-panel.tsx', + ), + 'utf8', + ); + assert.equal( + quotePanel.includes( + 'scrollToBottomLabel={copy.scrollToBottom}', + ), + true, + ); + assert.equal( + quotePanel.includes( + 'mentionSkillsUnavailable={props.mentionSkillsUnavailable}', + ), + true, + ); + assert.equal( + quotePanel.includes('mentionSkillsLoading={props.mentionSkillsLoading}'), + true, + ); + }); }); diff --git a/apps/desktop/src/main/__tests__/workbar-controller.test.ts b/apps/desktop/src/main/__tests__/workbar-controller.test.ts index 292800d55b..286aaa25c7 100644 --- a/apps/desktop/src/main/__tests__/workbar-controller.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-controller.test.ts @@ -126,6 +126,8 @@ function input( return { available: true, activeSession, + projectId: activeSession?.projectId, + projectAliases: [], authoritativeSessionIds: new Set(activeSession ? [activeSession.id] : []), shellObscured: false, modelChoices: [], @@ -141,6 +143,20 @@ describe('useWorkbarController', () => { delete (globalThis as { window?: unknown }).window; }); + it('projects the canonical project and absorbed aliases into the host model', async () => { + const { root } = installReactRenderer(); + const controllerInput = input(session('a')); + controllerInput.projectId = 'project-canonical'; + controllerInput.projectAliases = ['project-absorbed']; + + await act(async () => + renderController(root, createFakeWorkbarServices(), controllerInput), + ); + + assert.equal(controller().host.projectId, 'project-canonical'); + assert.deepEqual(controller().host.projectAliases, ['project-absorbed']); + }); + it('keeps the initial Session active after StrictMode replays mount effects', async () => { const { root } = installReactRenderer(); const starts: string[] = []; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 930515c2e8..32ac4b5c9e 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -54,6 +54,7 @@ import { } from "@maka/runtime-host/client"; import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; import { createCredentialMcpOAuthStorage, McpClientManager } from "@maka/mcp"; +import { createWorkBoardStore } from "@maka/storage/work-board-store"; import { createFileCredentialStore } from "@maka/storage/credential-store"; import { createMcpConfigStore } from "@maka/storage/mcp-config-store"; import { createSettingsStore } from "@maka/storage/settings-store"; @@ -119,6 +120,7 @@ import { import { registerNotificationsIpc } from "./notifications-ipc-main.js"; import { registerMarkdownSaveIpc } from "./markdown-save-ipc-main.js"; import { registerPetPackIpc } from "./pet-pack-import.js"; +import { registerWorkBoardIpc } from "./work-board-ipc-main.js"; import { createPermissionOverlayMain, registerPermissionOverlayIpc, @@ -307,6 +309,7 @@ const desktopLocale = createDesktopLocaleAuthority({ preferredSystemLanguages: () => app.getPreferredSystemLanguages(), }); const mcpConfigStore = createMcpConfigStore(workspaceRoot); +const workBoardStore = createWorkBoardStore(workspaceRoot); const mcpManager = new McpClientManager({ clientName: "maka-desktop", clientVersion: app.getVersion(), @@ -671,6 +674,12 @@ 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, @@ -1589,6 +1598,7 @@ async function closeRuntimeHostDesktop(): Promise { Promise.resolve().then(() => runtimeHostManagement.close()), runtimeHostManager?.close(), runtimeHostOnboarding.close(), + Promise.resolve().then(() => workBoardIpc.close()), runtimeHostSshTerminal.close(), botRegistry.stopAll(), mcpManager.close(), diff --git a/apps/desktop/src/main/work-board-ipc-main.ts b/apps/desktop/src/main/work-board-ipc-main.ts new file mode 100644 index 0000000000..780c35e196 --- /dev/null +++ b/apps/desktop/src/main/work-board-ipc-main.ts @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { IpcMain } from 'electron'; +import type { WorkBoardItem, WorkBoardPage } from '@maka/core/work-board'; +import { + createWorkBoardStore, + WorkBoardStoreError, + type WorkBoardStore, + type WorkBoardStoreErrorCode, + type WorkBoardMutationOptions, +} from '@maka/storage/work-board-store'; +import type { createMainWindowController } from './main-window.js'; +import type { WorkBoardChangedEvent, WorkBoardIpcResult } from '../shared/work-board-ipc.js'; + +export type { WorkBoardChangedEvent, WorkBoardIpcResult } from '../shared/work-board-ipc.js'; + +type MainWindowController = Pick, 'send'>; + +export interface WorkBoardIpcRegistration { + close(): void; +} + +/** + * Desktop main process owns the Work Board store (the v1 mutation boundary). + * Renderer code reads a projection through IPC and reloads on the change + * signal; the Runtime Host and model tools are intentionally not involved. + */ +export function registerWorkBoardIpc(input: { + readonly ipcMain: Pick; + readonly workspaceRoot: string; + readonly mainWindowController: MainWindowController; + readonly store?: WorkBoardStore; + readonly now?: () => number; +}): WorkBoardIpcRegistration { + const store = input.store ?? createWorkBoardStore(input.workspaceRoot); + const now = input.now ?? Date.now; + const emitChanged = (): void => { + input.mainWindowController.send('workBoard:changed', { + type: 'work_board_changed', + ts: now(), + } satisfies WorkBoardChangedEvent); + }; + + input.ipcMain.handle( + 'workBoard:list', + async (_event, query: unknown): Promise> => { + try { + return { ok: true, value: await store.list(query) }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + input.ipcMain.handle( + 'workBoard:create', + async (_event, item: unknown): Promise> => { + try { + const created = await store.create(item); + emitChanged(); + return { ok: true, value: created }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + input.ipcMain.handle( + 'workBoard:update', + async ( + _event, + id: unknown, + patch: unknown, + options?: unknown, + ): Promise> => { + try { + const updated = await store.update( + requireWorkBoardId(id), + patch, + options as WorkBoardMutationOptions | undefined, + ); + emitChanged(); + return { ok: true, value: updated }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + input.ipcMain.handle( + 'workBoard:archive', + async (_event, id: unknown, options?: unknown): Promise> => { + try { + const archived = await store.archive( + requireWorkBoardId(id), + options as WorkBoardMutationOptions | undefined, + ); + emitChanged(); + return { ok: true, value: archived }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + input.ipcMain.handle( + 'workBoard:unarchive', + async (_event, id: unknown, options?: unknown): Promise> => { + try { + const unarchived = await store.unarchive( + requireWorkBoardId(id), + options as WorkBoardMutationOptions | undefined, + ); + emitChanged(); + return { ok: true, value: unarchived }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + input.ipcMain.handle( + 'workBoard:remove', + async ( + _event, + id: unknown, + options?: unknown, + ): Promise> => { + try { + await store.remove(requireWorkBoardId(id), options as WorkBoardMutationOptions | undefined); + emitChanged(); + return { ok: true, value: null }; + } catch (error) { + return { ok: false, ...workBoardFailure(error) }; + } + }, + ); + + return { + close: () => store.close(), + }; +} + +function requireWorkBoardId(id: unknown): string { + if (typeof id !== 'string') { + throw new WorkBoardStoreError('invalid_input', 'Work Board item id must be a string'); + } + return id; +} + +function workBoardFailure(error: unknown): { + readonly code: WorkBoardStoreErrorCode | 'unknown'; + readonly message: string; +} { + if (error instanceof WorkBoardStoreError) { + return { code: error.code, message: error.message }; + } + return { + code: 'unknown', + message: error instanceof Error ? error.message : 'Work Board operation failed', + }; +} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d9615ba464..226e27a324 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -101,6 +101,8 @@ import type { DesktopTranscriptHandle, } from './transcript-contract.js'; import type { PetPackManifestV1 } from '@maka/core/pet'; +import type { WorkBoardItem, WorkBoardListQuery, WorkBoardPage } from '@maka/core/work-board'; +import type { WorkBoardMutationOptions } from '@maka/storage/work-board-store'; import type { OperationInput, OperationOutput, @@ -149,6 +151,7 @@ export type AppIconImportResult = }; export type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; +export type { WorkBoardChangedEvent, WorkBoardIpcResult } from '../shared/work-board-ipc.js'; import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js'; import type { DesktopExternalSessionCatalogItem } from './external-session-catalog.js'; import type { DesktopDiagnosticInput } from './diagnostics-contract.js'; @@ -661,6 +664,26 @@ export interface MakaBridge { subscribeChanges(handler: (event: PetPackChangedEvent) => void): () => void; }; + workBoard: { + list(query?: WorkBoardListQuery): Promise>; + create(item: unknown): Promise>; + update( + id: string, + patch: unknown, + options?: WorkBoardMutationOptions, + ): Promise>; + archive( + id: string, + options?: WorkBoardMutationOptions, + ): Promise>; + unarchive( + id: string, + options?: WorkBoardMutationOptions, + ): Promise>; + remove(id: string, options?: WorkBoardMutationOptions): Promise>; + subscribeChanges(handler: (event: WorkBoardChangedEvent) => void): () => void; + }; + tasks: { list(sessionId: string): Promise; subscribeChanges(handler: (event: TaskLedgerChangedEvent) => void): () => void; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e909783bcd..ab3725f04c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -37,6 +37,7 @@ import type { AppUpdateStatus, WindowCommand, PetPackChangedEvent, + WorkBoardChangedEvent, DesktopRuntimeHostProfileAddInput, DesktopRuntimeHostProfileChangedEvent, DesktopRuntimeHostProfileSnapshot, @@ -1370,6 +1371,32 @@ const makaBridge = { return () => ipcRenderer.off('pets:changed', listener); }, }, + workBoard: { + list(query) { + return ipcRenderer.invoke('workBoard:list', query); + }, + create(item) { + return ipcRenderer.invoke('workBoard:create', item); + }, + update(id, patch, options) { + return ipcRenderer.invoke('workBoard:update', id, patch, options); + }, + archive(id, options) { + return ipcRenderer.invoke('workBoard:archive', id, options); + }, + unarchive(id, options) { + return ipcRenderer.invoke('workBoard:unarchive', id, options); + }, + remove(id, options) { + return ipcRenderer.invoke('workBoard:remove', id, options); + }, + subscribeChanges(handler: (event: WorkBoardChangedEvent) => void): () => void { + const listener = (_event: Electron.IpcRendererEvent, payload: WorkBoardChangedEvent) => + handler(payload); + ipcRenderer.on('workBoard:changed', listener); + return () => ipcRenderer.off('workBoard:changed', listener); + }, + }, tasks: { list(sessionId: string): Promise { return invokeProjectedSessionRuntimeHost('tasks:list', sessionId); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 29a97fb25b..e9198f8b27 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1720,6 +1720,8 @@ function AppShellContent({ const workbar = useWorkbarController({ available: workbarAvailable, activeSession: activeSessionForView, + projectId: currentProjectId, + projectAliases: currentProject?.aliases ?? [], authoritativeSessionIds: authoritativeSessionIds ?? undefined, shellObscured, modelChoices: chatModelChoices, diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index cfb88583e5..4b829e148f 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -28,8 +28,9 @@ import { } from 'react'; import type { QuoteRef } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; -import type { ChatModelChoice, Composer } from '@maka/ui'; -import { useUiLocale } from '@maka/ui'; +import { Composer, useUiLocale } from '@maka/ui'; +import type { ChatModelChoice } from '@maka/ui'; +import type { ComposerProps } from '../../../../../../../packages/ui/dist/composer.d.ts'; import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-storage.js'; import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js'; import { localizedShellErrorMessage } from '../../../locales/shell-copy.js'; @@ -86,13 +87,15 @@ export interface UseWorkbarControllerInput { /** Whether the Session workspace (rather than a module page) owns the shell. */ available: boolean; activeSession: SessionSummary | undefined; + projectId: string | null | undefined; + projectAliases: readonly string[]; authoritativeSessionIds: ReadonlySet | undefined; shellObscured: boolean; modelChoices: readonly ChatModelChoice[]; - mentionSkills?: ComponentProps['mentionSkills']; - mentionSkillsUnavailable?: ComponentProps['mentionSkillsUnavailable']; - mentionSkillsLoading?: ComponentProps['mentionSkillsLoading']; - searchMentionFiles?: ComponentProps['onSearchMentionFiles']; + mentionSkills?: ComposerProps['mentionSkills']; + mentionSkillsUnavailable?: ComposerProps['mentionSkillsUnavailable']; + mentionSkillsLoading?: ComposerProps['mentionSkillsLoading']; + searchMentionFiles?: ComposerProps['onSearchMentionFiles']; reportError(title: string, description: string, sessionId: string): void; } @@ -674,6 +677,8 @@ export function useWorkbarController( }, host: { activeId: input.available ? activeSessionId : undefined, + projectId: input.projectId, + projectAliases: input.projectAliases, rightCollapsed: layout.workbarCollapsed, bottomOpen: layout.bottomPanelOpen, hidden: input.shellObscured, diff --git a/apps/desktop/src/renderer/features/workbar/model/workbar-tabs.ts b/apps/desktop/src/renderer/features/workbar/model/workbar-tabs.ts index b6b49b5b0e..c73d253ad0 100644 --- a/apps/desktop/src/renderer/features/workbar/model/workbar-tabs.ts +++ b/apps/desktop/src/renderer/features/workbar/model/workbar-tabs.ts @@ -24,6 +24,7 @@ export type SessionWorkbarTabKind = | 'review' | 'terminal' | 'tasks' + | 'work-board' | 'browser' | 'files' | 'inspector' @@ -133,6 +134,7 @@ const STATIC_TAB_IDS: Record, string review: 'workbar:review', terminal: 'workbar:terminal', tasks: 'workbar:tasks', + 'work-board': 'workbar:work-board', browser: 'workbar:browser', files: 'workbar:files', inspector: 'workbar:inspector', @@ -657,6 +659,7 @@ export function isSessionWorkbarTabKind(value: unknown): value is SessionWorkbar value === 'review' || value === 'terminal' || value === 'tasks' || + value === 'work-board' || value === 'browser' || value === 'files' || value === 'inspector' || diff --git a/apps/desktop/src/renderer/features/workbar/model/workbar-tool-definitions.ts b/apps/desktop/src/renderer/features/workbar/model/workbar-tool-definitions.ts index bb4af2352e..09de048b1f 100644 --- a/apps/desktop/src/renderer/features/workbar/model/workbar-tool-definitions.ts +++ b/apps/desktop/src/renderer/features/workbar/model/workbar-tool-definitions.ts @@ -99,6 +99,14 @@ const WORKBAR_TOOL_DEFINITION_BY_KIND = { singleton: true, defaultPlacement: 'right', }, + 'work-board': { + kind: 'work-board', + labelKey: 'work-board', + icon: 'list-todo', + persisted: true, + singleton: true, + defaultPlacement: 'right', + }, inspector: { kind: 'inspector', labelKey: 'inspector', diff --git a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx index d0e162356f..05e6e3bc07 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx @@ -31,6 +31,7 @@ import { type ChatModelChoice, type ComposerHandle, } from '@maka/ui'; +import type { ComposerProps } from '../../../../../../../../packages/ui/dist/composer.d.ts'; import type { SessionSummary } from '@maka/core/session'; import { useQuoteCompanion } from './use-quote-companion'; import { useComposerAttachments } from '../../../../use-composer-attachments'; @@ -66,10 +67,10 @@ export function QuoteCompanionPanel(props: { sourceSession: SessionSummary | undefined; /** Shared global choice list, only used to render the inherited model's label. */ modelChoices: readonly ChatModelChoice[]; - mentionSkills?: ComponentProps['mentionSkills']; - mentionSkillsUnavailable?: ComponentProps['mentionSkillsUnavailable']; - mentionSkillsLoading?: ComponentProps['mentionSkillsLoading']; - onSearchMentionFiles?: ComponentProps['onSearchMentionFiles']; + mentionSkills?: ComposerProps['mentionSkills']; + mentionSkillsUnavailable?: ComposerProps['mentionSkillsUnavailable']; + mentionSkillsLoading?: ComposerProps['mentionSkillsLoading']; + onSearchMentionFiles?: ComposerProps['onSearchMentionFiles']; onQuotesConsumed: (snapshot: CompanionQuoteSnapshot) => void; onRemoveQuote?: (target: CompanionQuoteTarget) => void; onForkVisibilityChange?: (event: CompanionForkVisibilityEvent) => void; @@ -268,10 +269,10 @@ export function QuoteCompanionPanel(props: { pendingAttachments={pendingAttachments} onRemoveAttachment={removeAttachment} mentionSkills={props.mentionSkills} - mentionSkillsUnavailable={props.mentionSkillsUnavailable} - mentionSkillsLoading={props.mentionSkillsLoading} onSearchMentionFiles={props.onSearchMentionFiles} pendingQuotes={props.quotes.map((quote) => quote.value)} + mentionSkillsUnavailable={props.mentionSkillsUnavailable} + mentionSkillsLoading={props.mentionSkillsLoading} contextDrawerDefaultCollapsed showStaticModelUnavailableStatus={false} onRemoveQuote={(index) => { diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx index 566fe0d94a..39b551a566 100644 --- a/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx +++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-host.tsx @@ -21,7 +21,8 @@ import { lazy, Suspense, type ComponentProps, type CSSProperties } from 'react'; import { Card } from '@astryxdesign/core/Card'; import { ResizeHandle, type ResizableProps } from '@astryxdesign/core/Resizable'; import { Spinner } from '@astryxdesign/core/Spinner'; -import { useUiLocale, type Composer } from '@maka/ui'; +import { Composer, useUiLocale } from '@maka/ui'; +import type { ComposerProps } from '../../../../../../../packages/ui/dist/composer.d.ts'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import type { SessionSummary } from '@maka/core/session'; import { getShellCopy } from '../../../locales/shell-copy'; @@ -69,6 +70,8 @@ function SessionWorkbarFallback() { export interface WorkbarHostModel { activeId?: string; + projectId?: string | null; + projectAliases?: readonly string[]; rightCollapsed: boolean; bottomOpen: boolean; hidden: boolean; @@ -114,10 +117,10 @@ export interface WorkbarHostModel { activeSideChatPanelIds?: ReadonlySet; sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; - mentionSkills?: ComponentProps['mentionSkills']; - mentionSkillsUnavailable?: ComponentProps['mentionSkillsUnavailable']; - mentionSkillsLoading?: ComponentProps['mentionSkillsLoading']; - onSearchMentionFiles?: ComponentProps['onSearchMentionFiles']; + mentionSkills?: ComposerProps['mentionSkills']; + mentionSkillsUnavailable?: ComposerProps['mentionSkillsUnavailable']; + mentionSkillsLoading?: ComposerProps['mentionSkillsLoading']; + onSearchMentionFiles?: ComposerProps['onSearchMentionFiles']; closeConfirmation: { key: string; open: boolean; @@ -164,6 +167,8 @@ export function WorkbarHost({ model: props }: { model: WorkbarHostModel }) {