Files
jason.woltje 068d868a99 lead: commit rocko's untracked evidence (4471 files: queue-124 queue-129 queue-171 queue-178 queue-280 queue-31 queue-84 queue-85 queue-94)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:15 -06:00

299 lines
16 KiB
Diff

diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
index d5e6f2bc3..f004c3ea1 100644
--- a/packages/bus/src/broker.mjs
+++ b/packages/bus/src/broker.mjs
@@ -2248,6 +2248,7 @@ export class Broker {
keys(a, ['name', 'repo'], ['name']);
if (!name(a.name)) fail('invalid-request');
if (a.repo !== undefined && !Object.hasOwn(b.projects ?? {}, identifier(a.repo))) fail('unknown-repo');
+ if (Object.hasOwn(b.projects ?? {}, a.name) && a.repo !== a.name) fail('project-name-is-repo');
if (this.#store.get('SELECT 1 FROM projects WHERE business=? AND name=?', s.business, a.name)) fail('project-exists');
const pid = randomUUID();
this.#store.run(
diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs
index 797a120dd..06f92e629 100644
--- a/packages/bus/tests/channel-proof.test.mjs
+++ b/packages/bus/tests/channel-proof.test.mjs
@@ -15,7 +15,8 @@ const baselines = {
// organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured,
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
// transport change. Row 155 (#1649) added eight for the assistant routes.
- "packages/webui/src/bus.mjs": "dfbc2289337376b94a399a61599fd4af402b802deb2267dcffc5cf23502c9a4c",
+ // Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
+ "packages/webui/src/bus.mjs": "76a1b57858c4aa41071746713a196299264851c18322a6f9869d553f0362d421",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
index f1dd100ee..311a65dbc 100644
--- a/packages/bus/tests/threads.test.mjs
+++ b/packages/bus/tests/threads.test.mjs
@@ -545,3 +545,33 @@ test('REQ-THREAD-2: thread.messages returns the thread\'s history in pages and w
for (const limit of [0, 201, 1.5, '5'])
assert.throws(() => call('human', 'thread.messages', { thread: A, limit }), { code: 'invalid-request' }, String(limit));
});
+
+// Row 171 (#1668): a thread project named after a declared repository must carry it.
+test('project.create refuses a repository name without its matching repo before any write', t => {
+ const { call, store } = setup(t, demo({ projects: { stack: {}, docs: {} } }));
+ const rows = () => store.all('SELECT * FROM projects');
+ for (const args of [{ name: 'stack' }, { name: 'stack', repo: 'docs' }]) {
+ assert.throws(() => call('human', 'project.create', args), { code: 'project-name-is-repo' });
+ assert.deepEqual(rows(), []);
+ }
+ // Unknown repo wins over the new refusal, which wins over an existing store name.
+ assert.throws(() => call('human', 'project.create', { name: 'stack', repo: 'missing' }), { code: 'unknown-repo' });
+ assert.deepEqual(rows(), []);
+ const made = call('human', 'project.create', { name: 'stack', repo: 'stack' });
+ assert.equal(rows().length, 1);
+ assert.equal(rows()[0].id, made.id);
+ assert.equal(rows()[0].repo, 'stack');
+ assert.throws(() => call('human', 'project.create', { name: 'stack' }), { code: 'project-name-is-repo' });
+ assert.throws(() => call('human', 'project.create', { name: 'stack', repo: 'stack' }), { code: 'project-exists' });
+ assert.equal(rows().length, 1);
+});
+
+test('project.create keeps unrelated names with and without a repository', t => {
+ const { call } = setup(t);
+ const loose = call('human', 'project.create', { name: 'notes' });
+ const linked = call('human', 'project.create', { name: 'work', repo: 'stack' });
+ assert.deepEqual(call('human', 'project.list').map(({ id, name, repo }) => ({ id, name, repo })), [
+ { id: loose.id, name: 'notes', repo: null },
+ { id: linked.id, name: 'work', repo: 'stack' },
+ ]);
+});
diff --git a/packages/cli/README.md b/packages/cli/README.md
index fb3ad8350..802a4b9bc 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -85,7 +85,11 @@ mosaic launches list [--json]
exits 1 and asks you to check `mosaic inbox` or `mosaic trail <id>`
first.
- Projects and threads (row 128, #1622) are the human's side of the bus's
- `project.*` and `thread.*` ops. `project create` and `project list` work on
+ `project.*` and `thread.*` ops. A thread project named for a business-file
+ repository must carry that same repo: `mosaic project create stack --repo stack`.
+ `project create stack` refuses with guidance to add `--repo stack` or choose
+ another name. An unresolved repository name explains how to create its thread
+ project, when the business file can be read. `project create` and `project list` work on
the bus record; `project init` and `project check` (`docs/TOOLS.md`, "Local
project records") work on the repository on disk and keep their arguments.
`project list` prints name, repo (`-` for none) and the first 8 characters of
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
index 6a4f68be6..6fa2f900f 100644
--- a/packages/cli/src/cli.mjs
+++ b/packages/cli/src/cli.mjs
@@ -186,6 +186,7 @@ function callFor(parsed, io, deps) {
if (!tester) refuseInsideAgent(io.env);
const system = deps.system ?? loadSystem({ env: io.env });
const business = businessFor(parsed, system.dataRoot);
+ parsed.business = business;
if (tester) return deps.testerTransport ? deps.testerTransport(business) : testerTransport({ socket: socketPath(system.dataRoot), business, env: io.env, spawn: deps.testerSpawn });
return deps.transport ? deps.transport(business) : humanTransport({ socket: socketPath(system.dataRoot), business, env: io.env });
}
@@ -197,7 +198,7 @@ function channelCallFor(parsed, io, deps) {
const channel = createChannelCaller({ name: "cli", call,
log: entry => io.stderr.write(`mosaic: channel module refused (${entry.code})\n`),
});
- return async (verb, args = {}) => {
+ return Object.assign(async (verb, args = {}) => {
try { return await channel(verb, args); }
catch (error) {
if (!(error instanceof PluginError)) throw error;
@@ -209,7 +210,7 @@ function channelCallFor(parsed, io, deps) {
refused.code = error.code;
throw refused;
}
- };
+ }, { business: parsed.business });
}
// The human's unread messages, newest first, from every page of `messages`.
diff --git a/packages/cli/src/project.mjs b/packages/cli/src/project.mjs
index 4e4d30059..ee2056ddd 100644
--- a/packages/cli/src/project.mjs
+++ b/packages/cli/src/project.mjs
@@ -12,6 +12,7 @@ export const PROJECT_USAGE = 'mosaic project init|check <business> <project>';
export const PROJECT_BUS_USAGE = [
'mosaic project create <name> [--repo <repo>] [--business <id>]',
'mosaic project list [--business <id>] [--json]',
+ 'A thread project named for a business repository must carry that repo: --repo <name>.',
].join('\n ');
export async function projectBusCommand(args, io, callFor) {
@@ -24,7 +25,14 @@ export async function projectBusCommand(args, io, callFor) {
const call = callFor(parsed);
if (sub === 'create') {
const [name] = parsed.positional;
- const made = await call('project.create', { name, ...(parsed.values['--repo'] === undefined ? {} : { repo: parsed.values['--repo'] }) });
+ let made;
+ try {
+ made = await call('project.create', { name, ...(parsed.values['--repo'] === undefined ? {} : { repo: parsed.values['--repo'] }) });
+ } catch (error) {
+ if (error.code === 'project-name-is-repo')
+ throw new CliError(`${JSON.stringify(name)} is a repository in the business file; add --repo ${name} to make its thread project, or pick another name`, 2);
+ throw error;
+ }
return io.stdout.write(`created project ${name} id ${made.id}\n`);
}
const projects = await call('project.list');
diff --git a/packages/cli/src/thread.mjs b/packages/cli/src/thread.mjs
index a8b58cfdd..5c55948ca 100644
--- a/packages/cli/src/thread.mjs
+++ b/packages/cli/src/thread.mjs
@@ -6,6 +6,8 @@
import { CliError } from "./errors.mjs";
import { shortId } from "./format.mjs";
+import { configDir, loadBusiness } from "../../business/src/index.mjs";
+import { rolesDir } from "./config.mjs";
export const THREADS_USAGE = "mosaic threads [--project <project>] [--business <id>] [--json]";
export const THREAD_USAGE = [
@@ -38,10 +40,16 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const HEX = /^[0-9a-f-]+$/i;
// A project is its name or its id.
-export async function resolveProject(call, ref) {
+export async function resolveProject(call, ref, { business, env = process.env } = {}) {
const projects = await call("project.list");
const hit = projects.find((p) => p.id === ref) ?? projects.find((p) => p.name === ref);
if (hit) return hit;
+ let repository = false;
+ try {
+ const b = loadBusiness(business, { dir: configDir(env), rolesDir: rolesDir(env) });
+ repository = Object.hasOwn(b.projects, ref);
+ } catch { /* A diagnostic read cannot replace the original refusal if config is unavailable. */ }
+ if (repository) throw new CliError(`${JSON.stringify(ref)} is a repository in the business file, not a thread project yet; mosaic project create ${ref} --repo ${ref} makes one`, 2);
throw new CliError(`no project is named or numbered ${JSON.stringify(ref)}; see mosaic project list`, 2);
}
@@ -103,7 +111,7 @@ export async function threadsCommand(args, io, callFor) {
const parsed = parseArgs(args, THREADS_USAGE, { flags: ["--json"], values: ["--business", "--project"] });
if (parsed.positional.length !== 0) throw new CliError(`threads takes no arguments\n${THREADS_USAGE}`, 4);
const call = callFor(parsed);
- const project = parsed.values["--project"] === undefined ? null : await resolveProject(call, parsed.values["--project"]);
+ const project = parsed.values["--project"] === undefined ? null : await resolveProject(call, parsed.values["--project"], { business: call.business, env: io.env });
const threads = await call("thread.list", project ? { project: project.id } : {});
if (parsed.flags.has("--json")) return json(io, threads);
if (threads.length === 0) return io.stdout.write("no threads\n");
@@ -122,7 +130,7 @@ export async function threadCommand(args, io, callFor, format) {
const call = callFor(parsed);
if (sub === "create") {
const [ref, name] = parsed.positional;
- const project = await resolveProject(call, ref);
+ const project = await resolveProject(call, ref, { business: call.business, env: io.env });
const made = await call("thread.create", { project: project.id, name });
return io.stdout.write(`created ${project.name}/${name} id ${made.id} instance ${made.instance}\n`);
}
diff --git a/packages/cli/tests/project-name-repo.test.mjs b/packages/cli/tests/project-name-repo.test.mjs
new file mode 100644
index 000000000..7ee55c7b7
--- /dev/null
+++ b/packages/cli/tests/project-name-repo.test.mjs
@@ -0,0 +1,66 @@
+// Row 171: diagnostic file reads stay in fixture config; project creation uses a real broker.
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { hostDir, hostFile, startTimeOf } from '../src/host.mjs';
+import { join } from 'node:path';
+import { run } from '../src/cli.mjs';
+import { broker, BUSINESSES, fixture, io, tmp } from './helpers.mjs';
+
+function setup(t) {
+ const root = tmp(t), config = fixture(root, 'demo');
+ const s = broker(t, { demo: { ...BUSINESSES.demo, projects: { stack: {} } } });
+ const reads = [];
+ const transport = business => async (verb, args) => {
+ reads.push(verb);
+ if (verb === 'plugins') return { business, revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } };
+ return s.transport(business)(verb, args);
+ };
+ const cli = async (args, explicit = true) => {
+ const x = io(config.env);
+ const code = await run([...args, ...(explicit ? ['--business', 'demo'] : [])], x, { system: { dataRoot: config.dataRoot }, transport });
+ return { code, out: x.out.text, err: x.err.text };
+ };
+ return { ...s, root, config, reads, cli };
+}
+
+test('threads project resolution explains a business repository and falls back if its file cannot load', async t => {
+ const s = setup(t);
+ const args = ['threads', '--project', 'stack'];
+ assert.deepEqual(await s.cli(args), {
+ code: 2, out: '',
+ err: 'mosaic: "stack" is a repository in the business file, not a thread project yet; mosaic project create stack --repo stack makes one\n',
+ });
+ assert.deepEqual(s.reads, ['plugins', 'project.list']);
+ // A stand-in host state names this test process; no host or unit is started.
+ mkdirSync(hostDir(s.config.dataRoot), { recursive: true, mode: 0o700 });
+ writeFileSync(hostFile(s.config.dataRoot), JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid), business: 'demo' }), { mode: 0o600 });
+ s.reads.length = 0;
+ const implicit = await s.cli(args, false);
+ assert.equal(implicit.code, 2);
+ assert.equal(implicit.err, 'mosaic: "stack" is a repository in the business file, not a thread project yet; mosaic project create stack --repo stack makes one\n');
+ assert.deepEqual(s.reads, ['plugins', 'project.list']);
+ const existing = setup(t);
+ assert.equal((await existing.cli(['project', 'create', 'stack', '--repo', 'stack'])).code, 0);
+ writeFileSync(join(existing.root, 'config/businesses/demo.json'), '{broken json');
+ assert.deepEqual(await existing.cli(args), { code: 0, out: 'no threads\n', err: '' });
+ assert.deepEqual(await existing.cli(['threads', '--project', 'other']), {
+ code: 2, out: '', err: 'mosaic: no project is named or numbered "other"; see mosaic project list\n',
+ });
+ s.reads.length = 0;
+ rmSync(join(s.root, 'config/businesses/demo.json'));
+ assert.deepEqual(await s.cli(args), {
+ code: 2, out: '', err: 'mosaic: no project is named or numbered "stack"; see mosaic project list\n',
+ });
+ assert.deepEqual(s.reads, ['plugins', 'project.list']);
+});
+
+test('project create prints the repository-name refusal and exits two without writing', async t => {
+ const s = setup(t);
+ assert.deepEqual(await s.cli(['project', 'create', 'stack']), {
+ code: 2, out: '',
+ err: 'mosaic: "stack" is a repository in the business file; add --repo stack to make its thread project, or pick another name\n',
+ });
+ assert.deepEqual(s.b.request(s.human, { verb: 'project.list' }), []);
+ assert.equal((await s.cli(['project', 'create', 'stack', '--repo', 'stack'])).code, 0);
+});
diff --git a/packages/cli/tests/thread-cli.test.mjs b/packages/cli/tests/thread-cli.test.mjs
index 13e32bc86..80d52db66 100644
--- a/packages/cli/tests/thread-cli.test.mjs
+++ b/packages/cli/tests/thread-cli.test.mjs
@@ -171,7 +171,7 @@ test("REQ-CLI-1 the business needs exactly one threaded instance to create a thr
[{ ...roles, second: roles.assistant }, "thread-instance-ambiguous"],
]) {
const s = setup(t, { businessRoles: roleSet });
- await s.cli(["project", "create", "stack"]);
+ assert.equal((await s.cli(["project", "create", "stack", "--repo", "stack"])).code, 0);
const before = s.writes();
const refused = await s.cli(["thread", "create", "stack", "x"]);
assert.deepEqual([refused.code, refused.err], [2, `mosaic: ${code}\n`]);
diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs
index fee95a829..490e1fe9d 100644
--- a/packages/webui/src/bus.mjs
+++ b/packages/webui/src/bus.mjs
@@ -90,6 +90,7 @@ const STATUS = {
// Row 148: the spec and docs editor. A stale base, a busy or dirty repository and an unchanged
// file are the user's to resolve by reloading; a path or content the project refuses is a 400.
'stale-base': 409, 'project-busy': 409, 'project-dirty': 409,
+ 'project-name-is-repo': 409,
'project-record-unchanged': 400, 'project-record-path': 400, 'project-record-invalid': 400,
'project-read-path': 400, 'project-not-declared': 400, 'project-read-missing': 404, 'project-not-configured': 503,
// Row 155 (#1649): the assistant's settings, seat names and stop. A name in use or no run to stop
diff --git a/packages/webui/tests/project-name-repo.test.mjs b/packages/webui/tests/project-name-repo.test.mjs
new file mode 100644
index 000000000..2519039a4
--- /dev/null
+++ b/packages/webui/tests/project-name-repo.test.mjs
@@ -0,0 +1,7 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { busStatus } from '../src/bus.mjs';
+
+test('a repository-name project refusal is an HTTP conflict', () => {
+ assert.equal(busStatus('project-name-is-repo'), 409);
+});