Darkwing's round 2 candidate, approved by Filbert (#1518 comment 26730). build-r2.patch a27890d5, manifest 869168c7, 34 files, applied on HEAD and checked 34/34. Integration gate on an export of HEAD plus the patch: business 60/60 on Node 24 and 26, every package test and every scripts/test-*.sh green, test-task 98/98 with the live-provider cases. Conductor, queue, conversation and discord confirmed in git worktrees of HEAD with and without the patch, identical results. Lead decision 63 accepts the vocabulary location, the example path and the business branch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
205 lines
10 KiB
JavaScript
205 lines
10 KiB
JavaScript
// The business file, <configDir>/businesses/<id>.json (REQ-ROLE-3, note
|
|
// section 2, addendum A sections 7 and 8). Jason writes it; the stack reads
|
|
// it and never writes it. A missing or invalid file refuses (lead decision
|
|
// 46, 6.1).
|
|
|
|
import { dirname, isAbsolute, join, normalize } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { refuse } from "./errors.mjs";
|
|
import { LAUNCH_CEILING } from "./vocabulary.mjs";
|
|
import { checkVars } from "./vars.mjs";
|
|
import { parseCredentialRef } from "./credentials.mjs";
|
|
import { loadRole } from "./role.mjs";
|
|
import {
|
|
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
|
} from "./util.mjs";
|
|
|
|
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
|
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch"];
|
|
|
|
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
|
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
|
export function configDir(env = process.env) {
|
|
const file = env.MOSAIC_CONFIG || join(homedir(), ".config", "mosaic-dev", "config.json");
|
|
return dirname(file);
|
|
}
|
|
|
|
export function businessFilePath(id, dir = configDir()) {
|
|
requireId(id, "business id");
|
|
return join(dir, "businesses", `${id}.json`);
|
|
}
|
|
|
|
function checkBot(tracker, where) {
|
|
requireObject(tracker, where);
|
|
rejectUnknownKeys(tracker, ["bot", "botId"], where);
|
|
if (typeof tracker.bot !== "string" || !BOT_NAME.test(tracker.bot)) refuse(`${where}.bot must be a Vikunja bot username starting with "bot-"`);
|
|
requirePositiveInt(tracker.botId, `${where}.botId`);
|
|
return { bot: tracker.bot, botId: tracker.botId };
|
|
}
|
|
|
|
function checkCredentialMap(map, services, where) {
|
|
requireObject(map, where);
|
|
const names = Object.keys(map).sort();
|
|
const wanted = [...services].sort();
|
|
if (names.join(",") !== wanted.join(",")) {
|
|
refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
|
|
}
|
|
const out = {};
|
|
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`);
|
|
return out;
|
|
}
|
|
|
|
function checkTracker(tracker, file) {
|
|
const where = `${file} tracker`;
|
|
requireObject(tracker, where);
|
|
rejectUnknownKeys(tracker, ["sync", "labels"], where);
|
|
requireObject(tracker.sync, `${where}.sync`);
|
|
rejectUnknownKeys(tracker.sync, ["bot", "botId", "credentials"], `${where}.sync`);
|
|
const sync = {
|
|
...checkBot({ bot: tracker.sync.bot, botId: tracker.sync.botId }, `${where}.sync`),
|
|
credentials: checkCredentialMap(tracker.sync.credentials, ["vikunja"], `${where}.sync.credentials`),
|
|
};
|
|
let labels = {};
|
|
if (tracker.labels !== undefined) {
|
|
requireObject(tracker.labels, `${where}.labels`);
|
|
// fromEntries defines own properties, so a "__proto__" title stays data.
|
|
labels = Object.fromEntries(Object.entries(tracker.labels).map(([title, id]) => [
|
|
requireString(title, `${where}.labels title`, { max: 100 }),
|
|
requirePositiveInt(id, `${where}.labels.${title}`),
|
|
]));
|
|
}
|
|
return { sync, labels };
|
|
}
|
|
|
|
function checkRoles(roles, file, rolesDir) {
|
|
const where = `${file} roles`;
|
|
requireObject(roles, where);
|
|
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
|
|
const out = {};
|
|
const definitions = {};
|
|
const bots = new Set();
|
|
const botIds = new Set();
|
|
for (const [instance, entry] of Object.entries(roles)) {
|
|
requireId(instance, `${where} instance name`);
|
|
const at = `${where}.${instance}`;
|
|
requireObject(entry, at);
|
|
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], at);
|
|
requireId(entry.definition, `${at}.definition`);
|
|
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
|
|
definitions[entry.definition] = definition;
|
|
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
|
|
const services = definition.credentials.map((c) => c.service);
|
|
let tracker = null;
|
|
if (services.includes("vikunja")) {
|
|
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
|
|
tracker = checkBot(entry.tracker, `${at}.tracker`);
|
|
if (bots.has(tracker.bot)) refuse(`${at}.tracker.bot ${tracker.bot} is used by another instance`);
|
|
if (botIds.has(tracker.botId)) refuse(`${at}.tracker.botId ${tracker.botId} is used by another instance`);
|
|
bots.add(tracker.bot);
|
|
botIds.add(tracker.botId);
|
|
} else if (entry.tracker !== undefined) {
|
|
refuse(`${at} has "tracker" but ${entry.definition} uses no vikunja credential`);
|
|
}
|
|
out[instance] = {
|
|
definition: entry.definition,
|
|
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
|
|
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
|
|
tracker,
|
|
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`),
|
|
};
|
|
}
|
|
return { roles: out, definitions };
|
|
}
|
|
|
|
function checkLaunch(launch, roles, definitions, file) {
|
|
const where = `${file} launch`;
|
|
requireObject(launch, where);
|
|
rejectUnknownKeys(launch, ["by", "instances", "max"], where);
|
|
requireId(launch.by, `${where}.by`);
|
|
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
|
|
const launcher = definitions[roles[launch.by].definition];
|
|
if (!launcher.authority.withinRole.includes("role.launch")) {
|
|
refuse(`${where}.by names ${launch.by}, whose role ${launcher.name} doesn't hold role.launch within-role`);
|
|
}
|
|
const instances = requireDistinctList(launch.instances, `${where}.instances`, (name) => {
|
|
requireId(name, `${where}.instances entry`);
|
|
if (!Object.hasOwn(roles, name)) refuse(`${where}.instances names ${name}, which the business file doesn't declare`);
|
|
if (name === launch.by) refuse(`${where}.instances can't include the launcher itself (${name})`);
|
|
}, { nonEmpty: true });
|
|
requireObject(launch.max, `${where}.max`);
|
|
if (Object.keys(launch.max).length === 0) refuse(`${where}.max must name at least one model family`);
|
|
const max = {};
|
|
for (const [family, count] of Object.entries(launch.max)) {
|
|
const ceiling = Object.hasOwn(LAUNCH_CEILING, family) ? LAUNCH_CEILING[family] : undefined;
|
|
if (ceiling === undefined) refuse(`${where}.max names unknown model family ${JSON.stringify(family)} (known: ${Object.keys(LAUNCH_CEILING).join(", ")})`);
|
|
if (!Number.isSafeInteger(count) || count < 0 || count > ceiling) refuse(`${where}.max.${family} must be an integer from 0 to ${ceiling}`);
|
|
max[family] = count;
|
|
}
|
|
return { by: launch.by, instances, max };
|
|
}
|
|
|
|
// Validate a parsed business document read from `file`. `rolesDir` is where
|
|
// role definitions live. Returns a frozen business with its definitions.
|
|
export function validateBusinessDocument(document, file, { rolesDir }) {
|
|
requireObject(document, "business file");
|
|
rejectUnknownKeys(document, TOP_KEYS, "business file");
|
|
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
|
|
requireId(document.id, "business id");
|
|
const base = file.split("/").pop().replace(/\.json$/, "");
|
|
if (document.id !== base) refuse(`business "id" (${document.id}) must match its filename (${base}.json)`);
|
|
for (const key of ["human", "arbiters", "projects", "tracker", "roles"]) {
|
|
if (document[key] === undefined) refuse(`business file requires "${key}" (${file})`);
|
|
}
|
|
const human = requireId(document.human, "business human");
|
|
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
|
|
const { roles, definitions } = checkRoles(document.roles, file, rolesDir);
|
|
|
|
requireObject(document.arbiters, `${file} arbiters`);
|
|
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
|
|
const arbiters = {};
|
|
for (const kind of ["delivery", "technical"]) {
|
|
const name = document.arbiters[kind];
|
|
requireId(name, `${file} arbiters.${kind}`);
|
|
if (!Object.hasOwn(roles, name)) refuse(`${file} arbiters.${kind} names ${name}, which the business file doesn't declare`);
|
|
arbiters[kind] = name;
|
|
}
|
|
|
|
requireObject(document.projects, `${file} projects`);
|
|
if (Object.keys(document.projects).length === 0) refuse(`${file} projects must name at least one project`);
|
|
const projects = {};
|
|
for (const [id, entry] of Object.entries(document.projects)) {
|
|
requireId(id, `${file} project id`);
|
|
requireObject(entry, `${file} projects.${id}`);
|
|
rejectUnknownKeys(entry, ["root"], `${file} projects.${id}`);
|
|
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
|
|
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
|
|
}
|
|
projects[id] = { root: entry.root };
|
|
}
|
|
|
|
const tracker = checkTracker(document.tracker, file);
|
|
for (const [instance, role] of Object.entries(roles)) {
|
|
if (role.tracker && (role.tracker.bot === tracker.sync.bot || role.tracker.botId === tracker.sync.botId)) {
|
|
refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
|
|
}
|
|
}
|
|
const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file);
|
|
|
|
return deepFreeze({
|
|
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, definitions,
|
|
});
|
|
}
|
|
|
|
// Load businesses/<id>.json from the config directory. The file holds
|
|
// authority, so it must belong to this user and not be writable by group
|
|
// or other.
|
|
export function loadBusiness(id, { dir = configDir(), rolesDir }) {
|
|
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
|
|
const file = businessFilePath(id, dir);
|
|
const document = readJsonFile(file, "business file", (stat) => {
|
|
if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
|
|
if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
|
});
|
|
return validateBusinessDocument(document, file, { rolesDir });
|
|
}
|