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]>
169 lines
7.1 KiB
JavaScript
169 lines
7.1 KiB
JavaScript
// The variable key registry (REQ-VAR-1, note section 2, addendum A section
|
|
// 7). Every key is declared here once, with its type, the layers allowed to
|
|
// set it and its merge rule. A key that isn't here refuses, and so does a
|
|
// key set at a layer it isn't allowed in.
|
|
//
|
|
// Layers, least to most specific:
|
|
// system ~/.config/mosaic-dev/config.json, resolved by mosaic-config.mjs
|
|
// business vars in businesses/<id>.json
|
|
// project vars in <root>/.mosaic/project.json, then that file's
|
|
// roles.<instance>.vars (still the project layer, applied after)
|
|
// agent roles.<instance>.vars in the business file
|
|
//
|
|
// Merge rules:
|
|
// replace the most specific layer that sets the key wins
|
|
// intersect every layer that sets the key narrows it; nothing widens
|
|
|
|
import { refuse } from "./errors.mjs";
|
|
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
|
|
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
|
|
|
|
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
|
|
|
|
const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)(?!.*\.lock$)[A-Za-z0-9._/-]{1,100}(?<![./])$/;
|
|
|
|
function string(max = 200) {
|
|
return (v, key) => requireString(v, `variable ${key}`, { max });
|
|
}
|
|
|
|
function oneOf(values) {
|
|
return (v, key) => {
|
|
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`);
|
|
return v;
|
|
};
|
|
}
|
|
|
|
function integer(min) {
|
|
return (v, key) => {
|
|
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
|
|
return v;
|
|
};
|
|
}
|
|
|
|
// http or https, no user info, query or fragment, no trailing slash.
|
|
function baseUrl(v, key) {
|
|
requireString(v, `variable ${key}`, { max: 300 });
|
|
let url;
|
|
try {
|
|
url = new URL(v);
|
|
} catch {
|
|
refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`);
|
|
}
|
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) {
|
|
refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function branch(v, key) {
|
|
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
|
|
return v;
|
|
}
|
|
|
|
function list(check, { nonEmpty = true } = {}) {
|
|
return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
|
|
}
|
|
|
|
function absolutePath(v, key) {
|
|
if (typeof v !== "string" || !v.startsWith("/") || v.includes("\0")) refuse(`variable ${key} must be an absolute path`);
|
|
return v;
|
|
}
|
|
|
|
const def = (layers, check, extra = {}) => Object.freeze({ layers: Object.freeze(layers), merge: "replace", check, default: undefined, ...extra });
|
|
|
|
export const REGISTRY = Object.freeze({
|
|
// System. mosaic-config.mjs owns their validation; these checks only
|
|
// keep the resolver honest about types.
|
|
environment: def(["system"], string(64)),
|
|
dataRoot: def(["system"], absolutePath),
|
|
"execution.backend": def(["system"], string(64)),
|
|
"execution.provider": def(["system"], string(64)),
|
|
"execution.model": def(["system"], string(200)),
|
|
"execution.adapter": def(["system"], string(64)),
|
|
|
|
// Business.
|
|
"tracker.kind": def(["business"], oneOf(["vikunja"]), { default: "vikunja" }),
|
|
"tracker.baseUrl": def(["business"], baseUrl),
|
|
"tracker.reconcileMinutes": def(["business"], integer(1), { default: 60 }),
|
|
"tracker.pollSeconds": def(["business", "project"], integer(10), { default: 30 }),
|
|
"human.discordUserId": def(["business"], (v, key) => {
|
|
if (typeof v !== "string" || !/^[1-9][0-9]{16,19}$/.test(v)) refuse(`variable ${key} must be a Discord user id, 17 to 20 digits as a string`);
|
|
return v;
|
|
}),
|
|
"gitea.baseUrl": def(["business"], baseUrl),
|
|
|
|
// Project.
|
|
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
|
|
"git.workingBranch": def(["project"], branch),
|
|
"git.protectedBranches": def(["project"], list(branch)),
|
|
suites: def(["project"], list(string(300))),
|
|
"issues.repo": def(["project"], (v, key) => {
|
|
if (typeof v !== "string" || !/^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(v)) refuse(`variable ${key} must be owner/name`);
|
|
return v;
|
|
}),
|
|
|
|
// Agent: one role instance.
|
|
harness: def(["agent"], oneOf(["pi", "claude-code"])),
|
|
model: def(["agent"], string(200)),
|
|
thinking: def(["agent"], oneOf(["off", "minimal", "low", "medium", "high", "xhigh"])),
|
|
|
|
// Limits narrow the role definition's ceilings. limits.authority is an
|
|
// allowlist: a role action it doesn't name becomes gated.
|
|
"limits.tools": def(["business", "project", "agent"], list((v, key) => {
|
|
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`);
|
|
}, { nonEmpty: false }), { merge: "intersect" }),
|
|
"limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }),
|
|
"limits.authority": def(["business", "project", "agent"], list((v, key) => {
|
|
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`);
|
|
}, { nonEmpty: false }), { merge: "intersect" }),
|
|
});
|
|
|
|
// Check one layer's vars object. `where` names the file and path for the
|
|
// message. Returns a frozen copy with checked values.
|
|
export function checkVars(vars, layer, where) {
|
|
if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`);
|
|
if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`);
|
|
const out = {};
|
|
for (const [key, value] of Object.entries(vars)) {
|
|
const entry = Object.hasOwn(REGISTRY, key) ? REGISTRY[key] : null;
|
|
if (!entry) refuse(`${where}: unknown variable ${JSON.stringify(key)}`);
|
|
if (!entry.layers.includes(layer)) refuse(`${where}: variable ${key} can't be set at the ${layer} layer (allowed: ${entry.layers.join(", ")})`);
|
|
out[key] = entry.check(value, key);
|
|
}
|
|
return Object.freeze(out);
|
|
}
|
|
|
|
function narrower(a, b) {
|
|
return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b;
|
|
}
|
|
|
|
// Merge checked layers, given least specific first as [{ layer, source,
|
|
// vars }]. Returns { vars, provenance }. provenance[key] is the source
|
|
// that set a replace key, or the list of sources that narrowed a limit.
|
|
export function mergeVars(layers) {
|
|
const vars = {};
|
|
const provenance = {};
|
|
for (const [key, entry] of Object.entries(REGISTRY)) {
|
|
if (entry.default !== undefined) {
|
|
vars[key] = entry.default;
|
|
provenance[key] = "default";
|
|
}
|
|
}
|
|
for (const { source, vars: layerVars } of layers) {
|
|
for (const [key, value] of Object.entries(layerVars)) {
|
|
const entry = REGISTRY[key];
|
|
if (entry.merge === "replace" || vars[key] === undefined) {
|
|
vars[key] = value;
|
|
provenance[key] = entry.merge === "replace" ? source : [source];
|
|
} else if (key === "limits.network") {
|
|
vars[key] = narrower(vars[key], value);
|
|
provenance[key].push(source);
|
|
} else {
|
|
vars[key] = vars[key].filter((item) => value.includes(item));
|
|
provenance[key].push(source);
|
|
}
|
|
}
|
|
}
|
|
return { vars, provenance };
|
|
}
|