Files
stack/packages/business/src/vars.mjs
T
jason.woltjeandClaude Opus 5.5 390d437ba2 feat(tasks): the adapter reads a project tree (#1555, row 62)
With tracker.descendants on, the tracker project is the root of a tree.
Each reconcile reads the sync bot's project list (archived projects and
negative pseudo-project ids left out) and walks it by parent_project_id.
A child that passes the install checks is read through its board, and
one that doesn't is watch-only (bucket 0) and never refuses the
business. A tick reads each project in the tree. A child answering 403
or 404 leaves the tree only if GET /projects/<id> then answers 403 or
404 too, and its open tasks get the one-by-one read. Task refs carry
the project, so a task moved inside the tree shows under its new ref
and the old ref gets its tombstone. tracker.descendants reaches the adapter
through the business var and packages/cli/src/config.mjs (lead decision
87). Off, the adapter reads as v0 did.

Darkwing built it (agents/darkwing/work/queue-62), in three rounds.
Round 3 was approved by Filbert (27317) and Dewey (27338) on manifest
8b81e836, 13 paths. Their non-blocking notes and Darkwing's follow-ups
(two surviving mutants in test 18, the fake's view-404 code) go to a
follow-up row.

Sage's gate on 918a4a19, sequential and teed: node suites tasks 71,
business 60, bus 80, cli 110 and queue 150, 0 failed; webui 39 of 40,
then 40 of 40 on three reruns (row 62 touches no webui file; a timing
test under load 17 with Dewey's gate running beside it); test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98, all 0 failed (Docker on). edc5de71..HEAD
touches none of the 13 paths.

Live read, 2026-10-11T21:28:03Z, tasks.woltje.com v2.7.0, root #19,
descendants on, sync key only, GETs only (the startup scope probes not
sent): reconcile 15 GETs, 42 snapshots; tick 8 GETs, 0 snapshots.
Children 18, 20 and 21 are watch-only. A missing view id on #18 answers
404 (code 3014) while #18 reads 200, as the README says. Record:
agents/sage/work/vikunja-setup/2026-10-11_tick-62.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:29:13 -05:00

176 lines
7.4 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 boolean(v, key) {
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`);
return v;
}
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}`)),
// On: the tracker project is the root of a tree, and sync reads its descendants too.
"tracker.descendants": def(["project"], boolean, { default: false }),
"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 };
}