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)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user