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.
299 lines
16 KiB
Diff
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);
|
|
+});
|