From 9a29febb6dc246c9d3ee2fd1c8aa7384664084b5 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 19 Oct 2026 14:24:57 -0500 Subject: [PATCH] bus, cli, webui: a thread project named like a business-file repo refuses project-name-is-repo unless --repo matches, with a clearer CLI refusal (row 171, #1668) --- packages/bus/src/broker.mjs | 1 + packages/bus/tests/channel-proof.test.mjs | 3 +- packages/bus/tests/threads.test.mjs | 30 +++++++++ packages/cli/README.md | 6 +- packages/cli/src/cli.mjs | 5 +- packages/cli/src/project.mjs | 10 ++- packages/cli/src/thread.mjs | 14 +++- packages/cli/tests/project-name-repo.test.mjs | 66 +++++++++++++++++++ packages/cli/tests/thread-cli.test.mjs | 2 +- packages/webui/src/bus.mjs | 1 + .../webui/tests/project-name-repo.test.mjs | 7 ++ 11 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 packages/cli/tests/project-name-repo.test.mjs create mode 100644 packages/webui/tests/project-name-repo.test.mjs diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index b18965feb..74aa3e52d 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -2253,6 +2253,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 3ed8916d0..557e73b78 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 ` 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 35a70a5d3..ee9e866de 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 '; export const PROJECT_BUS_USAGE = [ 'mosaic project create [--repo ] [--business ]', 'mosaic project list [--business ] [--json]', + 'A thread project named for a business repository must carry that repo: --repo .', ].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 ] [--business ] [--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); +});