Files
stack/packages/cli/src/thread.mjs
T

151 lines
8.3 KiB
JavaScript

// Row 128 (#1622, REQ-CLI-1, REQ-CLI-2, REQ-THREAD-2, REQ-USER-2): the human's side of the
// bus's project and thread ops (row 121). `mosaic threads`, `mosaic thread create|open|read`,
// and the thread resolver `mosaic talk --thread` and `mosaic follow --thread` (src/cli.mjs) use.
// The broker owns every rule: ownership, uniqueness, the one threaded instance. The CLI adds
// no check of those; it resolves a reference to an id and prints what the broker returns.
// Row 184 (#1682, Q173): each verb takes --tester and runs as the tester through tester-cli.mjs,
// on the tester's own threads (owner `tester`, row 177). The broker's ownership check is the only one.
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] [--tester]";
export const THREAD_USAGE = [
"mosaic thread create <project> <name> [--business <id>] [--tester]",
"mosaic thread open <thread> [--session <id>] [--business <id>] [--tester]",
"mosaic thread read <thread> [--session <id>] [--business <id>] [--json] [--tester]",
].join("\n ");
export const THREAD_TALK_USAGE = [
"mosaic talk --thread <thread> <text> [--session <id>] [--wait <seconds> | --follow] [--business <id>] [--tester]",
"mosaic follow --thread <thread> [--session <id>] [--business <id>] [--tester]",
].join("\n ");
// The parse shape callFor reads (flags, values, positional). Errors carry the verb's own usage.
export function parseArgs(args, usage, { flags = [], values = [] } = {}) {
const out = { positional: [], flags: new Set(), values: {} };
const refuse = (why) => new CliError(`${why}\n${usage}`, 4);
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (flags.includes(a)) out.flags.add(a);
else if (values.includes(a)) {
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw refuse(`${a} needs one value`);
out.values[a] = args[++i];
} else if (a.startsWith("--")) throw refuse(`unknown option ${a}`);
else out.positional.push(a);
}
return out;
}
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, { 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);
}
const labelOf = (projects, t) => `${projects.find((p) => p.id === t.project)?.name ?? shortId(t.project)}/${t.name}`;
// A thread is its id, an id prefix of at least 8 characters, or <project>/<name>. thread.list
// holds only the caller's threads, so a full id it doesn't list is passed on: the broker says
// thread-not-owned for another user's thread and thread-not-found for none (REQ-USER-2).
export async function resolveThread(call, ref) {
const threads = await call("thread.list");
const projects = await call("project.list");
const exact = threads.find((t) => t.id === ref) ?? threads.find((t) => labelOf(projects, t) === ref);
if (exact) return { id: exact.id, label: labelOf(projects, exact) };
if (UUID.test(ref)) return { id: ref, label: ref };
if (HEX.test(ref)) {
if (ref.length < 8) throw new CliError(`thread reference ${JSON.stringify(ref)} is too short; use at least 8 characters of the id, or <project>/<name>`, 2);
const hits = threads.filter((t) => t.id.startsWith(ref));
if (hits.length === 1) return { id: hits[0].id, label: labelOf(projects, hits[0]) };
if (hits.length > 1) throw new CliError(`${ref} matches ${hits.length} threads; use more of the id, or <project>/<name>`, 2);
}
throw new CliError(`no thread of yours matches ${JSON.stringify(ref)}; mosaic threads lists them as <project>/<name>`, 2);
}
const PAGE = 200;
// The thread's messages after `after`, oldest first, from every page of thread.messages.
// Reading writes no delivery or read row.
export async function threadMessages(call, thread, after, session) {
const out = [];
for (;;) {
const page = await call("thread.messages", { thread, ...(session ? { session } : {}), limit: PAGE, ...(after ? { after } : {}) });
out.push(...page);
if (page.length < PAGE) return out;
after = page.at(-1).id;
}
}
// A thread's message ids with their sequence numbers, kept current by refresh(). message.receive
// does not say which thread a message is in, so `--follow` receives first and asks the thread
// second; the broker records a message's thread when it stores the message, so a message it
// received is already listed.
export function threadMembers(call, thread, session) {
const seqs = new Map();
let last;
return {
seq: (id) => seqs.get(id),
async refresh() {
for (const m of await threadMessages(call, thread, last, session)) {
seqs.set(m.id, m.seq);
last = m.id;
}
},
};
}
const json = (io, value) => io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
// Row 175 (#1673, Q173): with --tester the tester lists threads. Since row 177 (#1675) those are
// its own threads (owner tester).
export async function threadsCommand(args, io, callFor) {
const parsed = parseArgs(args, THREADS_USAGE, { flags: ["--json", "--tester"], 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"], { 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");
const projects = await call("project.list");
io.stdout.write(threads.map((t) => `${labelOf(projects, t)} ${t.instance} ${shortId(t.id)}\n`).join(""));
}
// `format` is cli.mjs's formatMessage, the one line talk and inbox print a message with.
export async function threadCommand(args, io, callFor, format) {
const [sub, ...rest] = args;
if (!["create", "open", "read"].includes(sub)) throw new CliError(`usage:\n ${THREAD_USAGE}`, 4);
const parsed = parseArgs(rest, THREAD_USAGE, { flags: sub === "read" ? ["--json", "--tester"] : ["--tester"], values: ["--business", ...(sub === "create" ? [] : ["--session"])] });
if (parsed.values["--session"] !== undefined && !UUID.test(parsed.values["--session"])) throw new CliError("--session needs a full session id", 4);
if (parsed.positional.length !== (sub === "create" ? 2 : 1)) {
throw new CliError(`thread ${sub} takes ${sub === "create" ? "a project and a name" : "one thread"}\n${THREAD_USAGE}`, 4);
}
const call = callFor(parsed);
if (sub === "create") {
const [ref, name] = parsed.positional;
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`);
}
const thread = await resolveThread(call, parsed.positional[0]);
if (sub === "open") {
await call("thread.open", { thread: thread.id, ...(parsed.values["--session"] ? { session: parsed.values["--session"] } : {}) });
return io.stdout.write(`opened ${thread.label}\n`);
}
const messages = await threadMessages(call, thread.id, undefined, parsed.values["--session"]);
if (parsed.flags.has("--json")) return json(io, messages);
io.stdout.write(messages.length === 0 ? `${thread.label}: no messages\n` : messages.map(format).join(""));
}