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]>
105 lines
4.7 KiB
JavaScript
105 lines
4.7 KiB
JavaScript
// The resolver (REQ-VAR-1 and 2). It joins one role instance's definition,
|
|
// the business file, the optional project file and the system config into
|
|
// the record a launcher or the broker uses. Plain values: the most specific
|
|
// layer wins. Limits: every layer narrows, nothing widens.
|
|
|
|
import { refuse } from "./errors.mjs";
|
|
import { ACTIONS, NETWORKS } from "./vocabulary.mjs";
|
|
import { checkVars, mergeVars } from "./vars.mjs";
|
|
import { canonicalJson, sha256, deepFreeze } from "./util.mjs";
|
|
import { projectFilePath } from "./project.mjs";
|
|
|
|
// The system layer from `mosaic-config.mjs validate` output.
|
|
export function systemVars(config) {
|
|
return checkVars({
|
|
environment: config.environment,
|
|
dataRoot: config.dataRoot,
|
|
"execution.backend": config.execution.backend,
|
|
"execution.provider": config.execution.provider,
|
|
"execution.model": config.execution.model,
|
|
"execution.adapter": config.execution.adapter,
|
|
}, "system", "system config");
|
|
}
|
|
|
|
function narrowerNetwork(a, b) {
|
|
return NETWORKS.indexOf(a) <= NETWORKS.indexOf(b) ? a : b;
|
|
}
|
|
|
|
// resolveInstance({ system, business, project, instance })
|
|
// system systemVars(...) output
|
|
// business loadBusiness(...) output
|
|
// project loadProject(...) output, or null
|
|
// instance a role instance the business file declares
|
|
export function resolveInstance({ system, business, project = null, instance }) {
|
|
const entry = Object.hasOwn(business.roles, instance) ? business.roles[instance] : null;
|
|
if (!entry) refuse(`business ${business.id} declares no role instance ${JSON.stringify(instance)}`);
|
|
const definition = business.definitions[entry.definition];
|
|
|
|
const layers = [
|
|
{ layer: "system", source: "system", vars: system },
|
|
{ layer: "business", source: `business:${business.id}`, vars: business.vars },
|
|
];
|
|
let projectId = null;
|
|
if (project) {
|
|
const declared = Object.entries(business.projects).find(([, p]) => projectFilePath(p.root) === project.file);
|
|
if (!declared || declared[0] !== project.id) {
|
|
refuse(`project ${project.id} (${project.file}) isn't declared under that id in business ${business.id}`);
|
|
}
|
|
projectId = project.id;
|
|
for (const name of Object.keys(project.roles)) {
|
|
if (!Object.hasOwn(business.roles, name)) refuse(`project ${project.id} sets vars for role instance ${name}, which business ${business.id} doesn't declare`);
|
|
}
|
|
layers.push({ layer: "project", source: `project:${project.id}`, vars: project.vars });
|
|
if (Object.hasOwn(project.roles, instance)) {
|
|
layers.push({ layer: "project", source: `project:${project.id}:roles.${instance}`, vars: project.roles[instance].vars });
|
|
}
|
|
}
|
|
layers.push({ layer: "agent", source: `business:${business.id}:roles.${instance}`, vars: entry.vars });
|
|
const { vars, provenance } = mergeVars(layers);
|
|
|
|
let tools = [...definition.tools];
|
|
let network = definition.network;
|
|
let withinRole = [...definition.authority.withinRole];
|
|
let crossRole = [...definition.authority.crossRole];
|
|
if (vars["limits.tools"]) tools = tools.filter((t) => vars["limits.tools"].includes(t));
|
|
if (vars["limits.network"]) network = narrowerNetwork(network, vars["limits.network"]);
|
|
if (vars["limits.authority"]) {
|
|
withinRole = withinRole.filter((a) => vars["limits.authority"].includes(a));
|
|
crossRole = crossRole.filter((a) => vars["limits.authority"].includes(a));
|
|
}
|
|
// role.launch needs the business file's launch block naming this
|
|
// instance (addendum A section 8), and the verb must survive
|
|
// limits.authority. Otherwise the verb is gated and `launch` is null,
|
|
// so the two never disagree.
|
|
const launch = business.launch && business.launch.by === instance && withinRole.includes("role.launch") ? business.launch : null;
|
|
if (!launch) {
|
|
withinRole = withinRole.filter((a) => a !== "role.launch");
|
|
crossRole = crossRole.filter((a) => a !== "role.launch");
|
|
}
|
|
|
|
const resolved = {
|
|
business: business.id,
|
|
project: projectId,
|
|
instance,
|
|
definition: definition.name,
|
|
holder: entry.holder,
|
|
contract: definition.contractPath,
|
|
vars,
|
|
provenance,
|
|
limits: { tools, network, authority: { withinRole, crossRole } },
|
|
credentials: entry.credentials,
|
|
tracker: entry.tracker,
|
|
launch,
|
|
};
|
|
return deepFreeze({ ...resolved, digest: sha256(canonicalJson(resolved)) });
|
|
}
|
|
|
|
// Classify one action for a resolved instance: "within", "cross" or
|
|
// "gated". An action outside the vocabulary refuses.
|
|
export function classify(resolved, action) {
|
|
if (!ACTIONS.includes(action)) refuse(`unknown action: ${JSON.stringify(action)}`);
|
|
if (resolved.limits.authority.withinRole.includes(action)) return "within";
|
|
if (resolved.limits.authority.crossRole.includes(action)) return "cross";
|
|
return "gated";
|
|
}
|