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);