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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 47 additions & 21 deletions packages/cli/src/__tests__/runtime-host-operator-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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: {
Expand All @@ -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'],
);
});
});
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ function helpText(cliCommand: string): string {
` ${cliCommand} runtime-host access issue --kind capability-provider --principal <id>`,
` ${cliCommand} runtime-host access revoke --credential <id>`,
` ${cliCommand} runtime-host project list [--root <path>]`,
` ${cliCommand} runtime-host project add <path> [--root <path>]`,
` ${cliCommand} runtime-host project add <path> [--prefer] [--root <path>]`,
` ${cliCommand} runtime-host profile list`,
` ${cliCommand} runtime-host profile set --id <id> --name <name> --tls-url <wss-url> --expected-root <root-id> [--credential-env <name>]`,
` ${cliCommand} runtime-host profile set --id <id> --name <name> --ssh-destination <user@host> --ssh-remote-port <port> --expected-root <root-id> [--ssh-port <port>] [--credential-env <name>]`,
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/runtime-host-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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') {
Expand All @@ -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;
Expand All @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/runtime-host-project-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeHostConnection>;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -187,6 +227,33 @@ test('directory resolution failures cannot enter the unknown-commit drain path',
}
});

async function createGitRepositoryWithWorktree(
repository: string,
linkedWorktree: string,
): Promise<void> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 3 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
18 changes: 15 additions & 3 deletions packages/runtime-host/src/protocol/project-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
requireEntityId,
requireExactRecord,
requireRecord,
requireShapedRecord,
requireUtf8String,
} from './codec.js';
import { invalidProtocolFrame } from './errors.js';
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ export class HostProjectCatalogCoordinator {
): Promise<ProjectCatalogMutateResult> {
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(
Expand Down
Loading