Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e65ff3f8b | ||
|
|
9cfb5f64fe | ||
|
|
2af12340ae | ||
|
|
c0a60ed3ae | ||
|
|
dd878cefcf | ||
|
|
2404729232 | ||
|
|
2912482cd0 | ||
|
|
bf65ea9802 |
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# System-type toolsets at launch
|
||||
|
||||
Superseded 2026-11-12 by `docs/plans/2026-11-12_starter-packs-design.md` under Jason's starter-packs ruling; do not build this design.
|
||||
|
||||
Status: design only. Nothing here is built and no `roles/` or contract file is
|
||||
changed. Prepared 2026-11-10 against `refactor` `8630b8dfc` for phase P2 of
|
||||
#1873 (unified workspace). PRD requirement: REQ-SYS-2, "An agent working in a
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,9 +11,9 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
|
||||
import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs";
|
||||
import { checkDefinition, checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs";
|
||||
import { END_PROTECTED, parseRepoRemote } from "./vocabulary.mjs";
|
||||
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
|
||||
import { checkPlugins, checkUsageSettings, NAME as KIND_NAME } from "../../plugins/src/kinds.mjs";
|
||||
import { hasModule } from "../../plugins/src/loader.mjs";
|
||||
import { readPluginLock } from "../../plugins/src/pinned.mjs";
|
||||
import { checkMapping } from "../../library/src/mapping.mjs";
|
||||
@@ -29,7 +29,148 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy", "copyBindings"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy", "copyBindings", "packs", "board", "workflows"];
|
||||
|
||||
// Starter packs SP3 (design dd878cefc sections 2, 3 and 8): the board style, the workflows and the
|
||||
// record of each pack applied. The task tracker's states are fixed (REQ-TASK-1); a board names
|
||||
// each once, with a label and an order. These are the bus store's states (broker.mjs STORE_STATES).
|
||||
const STORE_STATES = Object.freeze(["todo", "in-progress", "in-review", "blocked", "done"]);
|
||||
const BOARD_LAYOUTS = Object.freeze(["kanban", "list"]);
|
||||
const PACK_ORIGINS = Object.freeze(["shipped", "installed", "saved"]);
|
||||
const PACK_KEYS = Object.freeze(["id", "version", "sha256", "origin", "scope", "project", "at", "by", "added", "proposedHires"]);
|
||||
const HEX64 = /^[0-9a-f]{64}$/;
|
||||
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
|
||||
// A workflow, step role or pack name: the plug-in kinds' NAME, at most 64 characters (ID_PATTERN's
|
||||
// length, so a step role can be read as a template or definition name at write time).
|
||||
const isKindName = (value) => typeof value === "string" && value.length <= 64 && KIND_NAME.test(value);
|
||||
// What a pack apply wrote, as item paths (design section 3, re-apply).
|
||||
const ADDED_PATH = /^(definitions\.[a-z0-9][a-z0-9._-]{0,63}|board|workflows\.[a-z][a-z0-9-]{0,63}|projects\.[a-z0-9][a-z0-9._-]{0,63}\.(board|workflows\.[a-z][a-z0-9-]{0,63}))$/;
|
||||
// A proposed hire's pin (design section 3): {template, sha256} for a template reference,
|
||||
// {definition, rev} for a definition the apply wrote, {inline, sha256} for a pack definition it did
|
||||
// not write (inline names it; sha256 is the canonical hash of the full entry stored beside the pin).
|
||||
const PIN_SHAPES = Object.freeze([["template", "sha256"], ["definition", "rev"], ["inline", "sha256"]]);
|
||||
|
||||
// board: {columns: [{state, label}] x5, layout}. Returns a copy.
|
||||
export function checkBoard(board, where) {
|
||||
requireObject(board, where);
|
||||
rejectUnknownKeys(board, ["columns", "layout"], where);
|
||||
if (!Array.isArray(board.columns) || board.columns.length !== STORE_STATES.length) refuse(`${where}.columns must list the ${STORE_STATES.length} task states`);
|
||||
const seen = new Set();
|
||||
const columns = board.columns.map((column, i) => {
|
||||
const at = `${where}.columns[${i}]`;
|
||||
requireObject(column, at);
|
||||
rejectUnknownKeys(column, ["state", "label"], at);
|
||||
if (!STORE_STATES.includes(column.state)) refuse(`${at}.state must be one of ${STORE_STATES.join(", ")}`);
|
||||
if (seen.has(column.state)) refuse(`${where}.columns names ${column.state} twice`);
|
||||
seen.add(column.state);
|
||||
requireString(column.label, `${at}.label`, { max: 32 });
|
||||
return { state: column.state, label: column.label };
|
||||
});
|
||||
if (!BOARD_LAYOUTS.includes(board.layout)) refuse(`${where}.layout must be ${BOARD_LAYOUTS.join(" or ")}`);
|
||||
return { columns, layout: board.layout };
|
||||
}
|
||||
|
||||
// workflows: {<name>: {title?, description?, steps: [{title, state, roles}]}}. The load checks each
|
||||
// step role's shape only: a role that no longer resolves (a definition removed, a template changed)
|
||||
// must not fail the whole organization. applyPack resolves the roles of the workflows it writes.
|
||||
export function checkWorkflows(workflows, where) {
|
||||
requireObject(workflows, where);
|
||||
const out = {};
|
||||
Object.entries(workflows).forEach(([name, workflow], n) => {
|
||||
if (!isKindName(name)) refuse(`${where} key ${n + 1} must be a workflow name matching ${KIND_NAME}, at most 64 characters`);
|
||||
const at = `${where}.${name}`;
|
||||
requireObject(workflow, at);
|
||||
rejectUnknownKeys(workflow, ["title", "description", "steps"], at);
|
||||
if (Object.hasOwn(workflow, "title")) requireString(workflow.title, `${at}.title`, { max: 120 });
|
||||
if (Object.hasOwn(workflow, "description")) requireString(workflow.description, `${at}.description`, { max: 1000 });
|
||||
if (!Array.isArray(workflow.steps) || workflow.steps.length < 1 || workflow.steps.length > 20) refuse(`${at}.steps must list 1 to 20 steps`);
|
||||
const steps = workflow.steps.map((step, i) => {
|
||||
const s = `${at}.steps[${i}]`;
|
||||
requireObject(step, s);
|
||||
rejectUnknownKeys(step, ["title", "state", "roles"], s);
|
||||
requireString(step.title, `${s}.title`, { max: 120 });
|
||||
if (!STORE_STATES.includes(step.state)) refuse(`${s}.state must be one of ${STORE_STATES.join(", ")}`);
|
||||
if (!Array.isArray(step.roles) || step.roles.length < 1 || step.roles.length > 8) refuse(`${s}.roles must list 1 to 8 roles`);
|
||||
const roles = requireDistinctList(step.roles, `${s}.roles`, (role) => {
|
||||
if (!isKindName(role)) refuse(`${s}.roles entries must be role names matching ${KIND_NAME}, or human`);
|
||||
});
|
||||
return { title: step.title, state: step.state, roles };
|
||||
});
|
||||
out[name] = {
|
||||
...(Object.hasOwn(workflow, "title") ? { title: workflow.title } : {}),
|
||||
...(Object.hasOwn(workflow, "description") ? { description: workflow.description } : {}), steps,
|
||||
};
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function checkPin(pin, where) {
|
||||
requireObject(pin, where);
|
||||
const keys = Object.keys(pin);
|
||||
const shape = PIN_SHAPES.find((s) => s.length === keys.length && s.every((k) => keys.includes(k)));
|
||||
if (!shape) refuse(`${where} must be {template, sha256}, {definition, rev} or {inline}`);
|
||||
if (shape[0] === "template") {
|
||||
requireId(pin.template, `${where}.template`);
|
||||
if (typeof pin.sha256 !== "string" || !HEX64.test(pin.sha256)) refuse(`${where}.sha256 must be 64 lowercase hex characters`);
|
||||
} else if (shape[0] === "definition") {
|
||||
checkDefinitionName(pin.definition, `${where}.definition`);
|
||||
requirePositiveInt(pin.rev, `${where}.rev`);
|
||||
} else {
|
||||
checkDefinitionName(pin.inline, `${where}.inline`);
|
||||
if (typeof pin.sha256 !== "string" || !HEX64.test(pin.sha256)) refuse(`${where}.sha256 must be 64 lowercase hex characters`);
|
||||
}
|
||||
return { ...pin };
|
||||
}
|
||||
|
||||
// packs: the record of each pack applied, oldest first. Each entry is history: its project is not
|
||||
// checked against today's projects, and its proposals are pins, not hires.
|
||||
export function checkPacks(packs, where) {
|
||||
if (!Array.isArray(packs)) refuse(`${where} must be a list`);
|
||||
return packs.map((entry, i) => {
|
||||
const at = `${where}[${i}]`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, PACK_KEYS, at);
|
||||
for (const key of PACK_KEYS) if (!Object.hasOwn(entry, key)) refuse(`${at} requires "${key}"`);
|
||||
if (!isKindName(entry.id)) refuse(`${at}.id must match ${KIND_NAME}, at most 64 characters`);
|
||||
if (typeof entry.version !== "string" || entry.version.length > 64 || !SEMVER.test(entry.version)) refuse(`${at}.version must be a semver version`);
|
||||
if (typeof entry.sha256 !== "string" || !HEX64.test(entry.sha256)) refuse(`${at}.sha256 must be 64 lowercase hex characters`);
|
||||
if (!PACK_ORIGINS.includes(entry.origin)) refuse(`${at}.origin must be one of ${PACK_ORIGINS.join(", ")}`);
|
||||
if (entry.scope === "org") { if (entry.project !== null) refuse(`${at}.project must be null for an org pack`); }
|
||||
else if (entry.scope === "project") requireId(entry.project, `${at}.project`);
|
||||
else refuse(`${at}.scope must be org or project`);
|
||||
if (typeof entry.at !== "string" || Number.isNaN(Date.parse(entry.at)) || new Date(entry.at).toISOString() !== entry.at) refuse(`${at}.at must be an ISO timestamp`);
|
||||
requireId(entry.by, `${at}.by`);
|
||||
const added = requireDistinctList(entry.added, `${at}.added`, (path) => {
|
||||
if (typeof path !== "string" || !ADDED_PATH.test(path)) refuse(`${at}.added entries must be item paths`);
|
||||
});
|
||||
if (!Array.isArray(entry.proposedHires) || entry.proposedHires.length > 8) refuse(`${at}.proposedHires must list at most 8 hires`);
|
||||
const names = new Set();
|
||||
const proposedHires = entry.proposedHires.map((hire, j) => {
|
||||
const h = `${at}.proposedHires[${j}]`;
|
||||
requireObject(hire, h);
|
||||
rejectUnknownKeys(hire, ["name", "reason", "pin", "entry"], h);
|
||||
requireId(hire.name, `${h}.name`);
|
||||
if (names.has(hire.name)) refuse(`${at}.proposedHires names ${hire.name} twice`);
|
||||
names.add(hire.name);
|
||||
if (Object.hasOwn(hire, "reason")) requireString(hire.reason, `${h}.reason`, { max: 500 });
|
||||
if (!Object.hasOwn(hire, "pin")) refuse(`${h} requires "pin"`);
|
||||
const pin = checkPin(hire.pin, `${h}.pin`);
|
||||
// An inline proposal keeps the full role entry, so the raise can read it back and check its
|
||||
// hash; no other pin carries one. It is bounded as a definition entry is (keys, then
|
||||
// checkComposition), with no rev of its own required. The hash is not checked here: a stale
|
||||
// proposal is the raise's refusal, not a reason the organization fails to load.
|
||||
if (Object.hasOwn(pin, "inline") !== Object.hasOwn(hire, "entry")) refuse(`${h} carries an entry exactly when its pin is inline`);
|
||||
let entry;
|
||||
if (Object.hasOwn(hire, "entry")) {
|
||||
requireObject(hire.entry, `${h}.entry`);
|
||||
checkDefinition(pin.inline, Object.hasOwn(hire.entry, "rev") ? hire.entry : { ...hire.entry, rev: 1 }, { where: `${h}.entry` });
|
||||
entry = structuredClone(hire.entry);
|
||||
}
|
||||
return { name: hire.name, ...(Object.hasOwn(hire, "reason") ? { reason: hire.reason } : {}), pin, ...(entry === undefined ? {} : { entry }) };
|
||||
});
|
||||
return { id: entry.id, version: entry.version, sha256: entry.sha256, origin: entry.origin, scope: entry.scope, project: entry.project, at: entry.at, by: entry.by, added, proposedHires };
|
||||
});
|
||||
}
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -303,7 +444,7 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
|
||||
continue;
|
||||
}
|
||||
rejectUnknownKeys(entry, ["root", "repo", "remote", "git", "agents"], `${file} projects.${id}`);
|
||||
rejectUnknownKeys(entry, ["root", "repo", "remote", "git", "agents", "board", "workflows"], `${file} projects.${id}`);
|
||||
// #1873 A1: the project's own agent entries. Checked so a bad entry refuses the whole file, but
|
||||
// not copied below: nothing a bus loads sees them; only the Console's agent-levels read does.
|
||||
if (Object.hasOwn(entry, "agents")) checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents`, definitions:document.definitions });
|
||||
@@ -327,7 +468,9 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
}
|
||||
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
|
||||
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}),
|
||||
...(Object.hasOwn(entry, "git") ? { git: { ...entry.git } } : {}) };
|
||||
...(Object.hasOwn(entry, "git") ? { git: { ...entry.git } } : {}),
|
||||
...(Object.hasOwn(entry, "board") ? { board: checkBoard(entry.board, `${file} projects.${id}.board`) } : {}),
|
||||
...(Object.hasOwn(entry, "workflows") ? { workflows: checkWorkflows(entry.workflows, `${file} projects.${id}.workflows`) } : {}) };
|
||||
}
|
||||
for (const [instance, role] of Object.entries(roles)) {
|
||||
for (const project of role.publish ?? []) {
|
||||
@@ -375,11 +518,15 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
}
|
||||
const appearance = checkAppearance(document.appearance, `${file} appearance`);
|
||||
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
|
||||
const board = document.board === undefined ? undefined : checkBoard(document.board, `${file} board`);
|
||||
const workflows = document.workflows === undefined ? undefined : checkWorkflows(document.workflows, `${file} workflows`);
|
||||
const packs = document.packs === undefined ? undefined : checkPacks(document.packs, `${file} packs`);
|
||||
|
||||
return deepFreeze({
|
||||
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
|
||||
...(autonomy === undefined ? {} : { autonomy }), ...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
|
||||
...(copyBindings === undefined ? {} : { copyBindings }),
|
||||
...(board === undefined ? {} : { board }), ...(workflows === undefined ? {} : { workflows }), ...(packs === undefined ? {} : { packs }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -441,6 +441,199 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
return { ...out, rev: logged.rev };
|
||||
}
|
||||
|
||||
// Starter packs SP3 (design dd878cefc sections 3 and 8): the one writer of `board`, `workflows`,
|
||||
// each code project's `board` and `workflows`, and `packs`, and a pack's way to add definitions.
|
||||
// Plan keys, and nothing else (invalid-request):
|
||||
// definitions {name: entry without rev}: adds only, at rev 1; needs a packs entry
|
||||
// board a board, or null to remove it
|
||||
// workflows {name: workflow, or null to remove it}, merged by name
|
||||
// projects.<p>.board as board, for code project p
|
||||
// projects.<p>.workflows as workflows, for code project p
|
||||
// packs the one entry to append, without at, by and added: this writer stamps them
|
||||
// A plan with a packs entry only adds: it overwrites and removes nothing, an org pack writes no
|
||||
// project key, and a project pack writes only its own project's keys. A board or workflow write is
|
||||
// a plan holding that key alone. One commit: a refusal or a fault leaves all of it or none.
|
||||
// setDefinition's rules hold for each definition: an agent or the tester never writes one
|
||||
// (human-required), a name an instance uses refuses definition-in-use, the credential store's
|
||||
// check is required and checkDefinition runs it with checkComposition. An agent never applies a
|
||||
// saved template either. The rest is organization.change's, with no decision (Jason, 11-12).
|
||||
// assertClean, the credential store's check, is required on every call and runs on every key and
|
||||
// string in the plan before anything else reads it: workflow text reaches agents' prompts and a
|
||||
// pack's inline proposals are role entries. Each workflow written must name
|
||||
// roles that resolve now: an org definition (after this plan), a V2 template, or human. The load
|
||||
// checks only their shape, so a later removal never fails the file.
|
||||
const PLAN_TOP = ["definitions", "board", "workflows", "packs"];
|
||||
const PLAN_PROJECT = /^projects\.([a-z0-9][a-z0-9._-]{0,63})\.(board|workflows)$/;
|
||||
const PLAN_PACK_KEYS = ["id", "version", "sha256", "origin", "scope", "project", "proposedHires"];
|
||||
const PLAN_NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const planObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v));
|
||||
function applyPack(id, plan, { revision, actor, assertClean, detail }) {
|
||||
if (typeof assertClean !== "function") fail("definition-secret-check-required", "a pack, board or workflow write needs the credential store's secret check; write it through the bus");
|
||||
if (!planObject(plan) || Object.keys(plan).length === 0) fail("invalid-request", "a pack plan is an object naming at least one item");
|
||||
const scan = (value) => {
|
||||
if (typeof value === "string") assertClean(value);
|
||||
else if (value !== null && typeof value === "object") for (const [k, v] of Object.entries(value)) { assertClean(k); scan(v); }
|
||||
};
|
||||
scan(plan);
|
||||
const projectKeys = [];
|
||||
Object.keys(plan).forEach((key, i) => {
|
||||
const match = PLAN_PROJECT.exec(key);
|
||||
if (match) projectKeys.push({ key, project: match[1], item: match[2] });
|
||||
else if (!PLAN_TOP.includes(key)) fail("invalid-request", `plan key ${i + 1} is not one a pack writes (definitions, board, workflows, projects.<p>.board, projects.<p>.workflows, packs)`);
|
||||
});
|
||||
const pack = plan.packs;
|
||||
if (pack !== undefined) {
|
||||
if (!planObject(pack)) fail("invalid-request", "plan packs must be one entry");
|
||||
for (const key of Object.keys(pack)) {
|
||||
if (["at", "by", "added"].includes(key)) fail("invalid-request", `plan packs carries ${key}, which the writer stamps`);
|
||||
if (!PLAN_PACK_KEYS.includes(key)) fail("invalid-request", "plan packs carries an unknown key");
|
||||
}
|
||||
if (pack.scope === "project") {
|
||||
if (Object.hasOwn(plan, "board") || Object.hasOwn(plan, "workflows")) fail("invalid-request", "a project pack writes only its project's board and workflows");
|
||||
if (projectKeys.some((k) => k.project !== pack.project)) fail("invalid-request", "a project pack writes only its own project's keys");
|
||||
} else if (projectKeys.length) fail("invalid-request", "an org pack writes no project key");
|
||||
} else if (Object.hasOwn(plan, "definitions")) {
|
||||
fail("invalid-request", "definitions arrive only with a packs entry; write one definition through setDefinition");
|
||||
}
|
||||
const at = new Date().toISOString();
|
||||
const logged = { ...detail, verb: pack ? "pack.apply" : "organization.layout", pack: pack ? pack.id : null, added: null };
|
||||
const build = (old) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
|
||||
const agent = actor === "tester" || agents.includes(actor) || (old.retired ?? []).includes(actor);
|
||||
if (agent && (Object.hasOwn(plan, "definitions") || pack?.origin === "saved")) fail("human-required");
|
||||
const next = structuredClone(old);
|
||||
const added = [];
|
||||
const written = [];
|
||||
if (Object.hasOwn(plan, "definitions")) {
|
||||
if (!planObject(plan.definitions) || Object.keys(plan.definitions).length === 0) fail("invalid-request", "plan definitions must name at least one definition");
|
||||
const definitions = { ...(old.definitions ?? {}) };
|
||||
for (const [name, entry] of Object.entries(plan.definitions)) {
|
||||
checkDefinitionName(name, "plan definition name");
|
||||
if (Object.hasOwn(definitions, name)) {
|
||||
const users = instancesNaming(old, name);
|
||||
if (users.length) fail("definition-in-use", `definition ${name} is named by ${users.join(", ")}; a change to a hired agent's role is a change decision`);
|
||||
fail("definition-exists", `definitions already declares ${name}; a pack apply overwrites nothing`);
|
||||
}
|
||||
if (!planObject(entry)) fail("invalid-request", `plan definition ${name} must be an object`);
|
||||
if (Object.hasOwn(entry, "rev")) fail("definition-rev-conflict", `a pack definition carries no rev; apply writes ${name} at rev 1`);
|
||||
const value = { ...structuredClone(entry), rev: 1 };
|
||||
checkDefinition(name, value, { assertClean });
|
||||
definitions[name] = value;
|
||||
added.push(`definitions.${name}`);
|
||||
}
|
||||
next.definitions = definitions;
|
||||
}
|
||||
const setBoard = (target, value, path) => {
|
||||
if (value === null) {
|
||||
if (pack) fail("invalid-request", "a pack apply removes nothing");
|
||||
if (!Object.hasOwn(target, "board")) fail("invalid-request", `${path} is not set`);
|
||||
delete target.board;
|
||||
return;
|
||||
}
|
||||
if (pack && Object.hasOwn(target, "board")) fail("invalid-request", `${path} is already set; a pack apply overwrites nothing`);
|
||||
target.board = structuredClone(value);
|
||||
added.push(path);
|
||||
};
|
||||
const setWorkflows = (target, value, path) => {
|
||||
if (!planObject(value) || Object.keys(value).length === 0) fail("invalid-request", `${path} must name at least one workflow`);
|
||||
const workflows = { ...(target.workflows ?? {}) };
|
||||
for (const [name, workflow] of Object.entries(value)) {
|
||||
if (!PLAN_NAME.test(name)) fail("invalid-request", `${path} keys must be workflow names matching ${PLAN_NAME}`);
|
||||
const exists = Object.hasOwn(workflows, name);
|
||||
if (workflow === null) {
|
||||
if (pack) fail("invalid-request", "a pack apply removes nothing");
|
||||
if (!exists) fail("invalid-request", `${path}.${name} is not set`);
|
||||
delete workflows[name];
|
||||
continue;
|
||||
}
|
||||
if (pack && exists) fail("invalid-request", `${path}.${name} is already set; a pack apply overwrites nothing`);
|
||||
workflows[name] = structuredClone(workflow);
|
||||
added.push(`${path}.${name}`);
|
||||
written.push({ path: `${path}.${name}`, workflow: workflows[name] });
|
||||
}
|
||||
if (Object.keys(workflows).length) target.workflows = workflows;
|
||||
else delete target.workflows;
|
||||
};
|
||||
if (Object.hasOwn(plan, "board")) setBoard(next, plan.board, "board");
|
||||
if (Object.hasOwn(plan, "workflows")) setWorkflows(next, plan.workflows, "workflows");
|
||||
for (const { key, project, item } of projectKeys) {
|
||||
const entry = Object.hasOwn(next.projects ?? {}, project) ? next.projects[project] : null;
|
||||
if (!planObject(entry) || entry.kind === "library") fail("invalid-request", `projects declares no code project ${project}`);
|
||||
if (item === "board") setBoard(entry, plan[key], `projects.${project}.board`);
|
||||
else setWorkflows(entry, plan[key], `projects.${project}.workflows`);
|
||||
}
|
||||
// Each role a written workflow names resolves now. A role that isn't a name is left to the
|
||||
// file check, which refuses it; only a name reaches readTemplate.
|
||||
const templates = new Map();
|
||||
const template = (name) => {
|
||||
if (!templates.has(name)) {
|
||||
try { readTemplate(rolesDir, name); templates.set(name, true); } catch (error) {
|
||||
if (error?.name !== "BusinessError") throw error;
|
||||
templates.set(name, false);
|
||||
}
|
||||
}
|
||||
return templates.get(name);
|
||||
};
|
||||
for (const { path, workflow } of written) {
|
||||
(Array.isArray(workflow?.steps) ? workflow.steps : []).forEach((step, i) => {
|
||||
for (const role of Array.isArray(step?.roles) ? step.roles : []) {
|
||||
if (role === "human" || typeof role !== "string" || !PLAN_NAME.test(role)) continue;
|
||||
if (Object.hasOwn(next.definitions ?? {}, role) || template(role)) continue;
|
||||
fail("invalid-request", `${path} step ${i + 1} names ${role}, which is not an org definition, a V2 template or human`);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (pack) {
|
||||
// A {definition, rev} pin names a definition this apply wrote; an {inline, sha256} pin is the
|
||||
// canonical hash of the entry stored beside it (design section 3). The file check bounds the
|
||||
// entry's shape; this is the write's own check that the pin means what it says.
|
||||
for (const hire of Array.isArray(pack.proposedHires) ? pack.proposedHires : []) {
|
||||
const pin = hire?.pin;
|
||||
if (planObject(pin) && Object.hasOwn(pin, "definition") && !(added.includes(`definitions.${pin.definition}`) && pin.rev === 1)) {
|
||||
fail("invalid-request", "a {definition, rev} proposal pins a definition this apply writes, at rev 1");
|
||||
}
|
||||
if (planObject(pin) && Object.hasOwn(pin, "inline") && planObject(hire.entry) && pin.sha256 !== sha256(canonicalJson(hire.entry))) {
|
||||
fail("invalid-request", "an {inline, sha256} proposal's sha256 is the canonical hash of its entry");
|
||||
}
|
||||
}
|
||||
next.packs = [...(old.packs ?? []), {
|
||||
id: pack.id, version: pack.version, sha256: pack.sha256, origin: pack.origin, scope: pack.scope, project: pack.project,
|
||||
at, by: actor, added, proposedHires: structuredClone(pack.proposedHires),
|
||||
}];
|
||||
}
|
||||
logged.added = added;
|
||||
return { next, added };
|
||||
};
|
||||
let result;
|
||||
const guard = (old, document) => {
|
||||
if (canonicalJson(document) !== canonicalJson(build(old).next)) fail("invalid-request", "the document is not the plan's");
|
||||
// The key fence itself, whatever build does: only the plan's keys change, definitions and
|
||||
// packs only grow, and a project changes only in a planned board or workflows.
|
||||
for (const key of new Set([...Object.keys(old), ...Object.keys(document)])) {
|
||||
if (key === "projects" || PLAN_TOP.includes(key)) continue;
|
||||
if (canonicalJson(old[key] ?? null) !== canonicalJson(document[key] ?? null)) fail("invalid-request", "a pack plan changes only definitions, board, workflows, packs and project boards and workflows");
|
||||
}
|
||||
for (const [name, entry] of Object.entries(old.definitions ?? {})) {
|
||||
if (canonicalJson(entry) !== canonicalJson(document.definitions?.[name] ?? null)) fail("organization-definitions-only", "a pack apply changes no existing definition");
|
||||
}
|
||||
const oldPacks = old.packs ?? [];
|
||||
if (canonicalJson(oldPacks) !== canonicalJson((document.packs ?? []).slice(0, oldPacks.length))) fail("invalid-request", "a pack apply keeps every earlier packs entry");
|
||||
const planned = new Set(projectKeys.map((k) => `${k.project}.${k.item}`));
|
||||
const projects = (doc) => doc?.projects ?? {};
|
||||
for (const project of new Set([...Object.keys(projects(old)), ...Object.keys(projects(document))])) {
|
||||
const a = projects(old)[project] ?? null;
|
||||
const b = projects(document)[project] ?? null;
|
||||
const rest = (e) => (planObject(e) ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== "board" && k !== "workflows")) : e);
|
||||
if (canonicalJson(rest(a)) !== canonicalJson(rest(b))) fail("organization-projects-protected");
|
||||
for (const item of ["board", "workflows"]) {
|
||||
if (canonicalJson(a?.[item] ?? null) !== canonicalJson(b?.[item] ?? null) && !planned.has(`${project}.${item}`)) fail("organization-projects-protected");
|
||||
}
|
||||
}
|
||||
};
|
||||
const out = commit(id, (old) => { result = build(old); return result.next; }, { revision, actor, guard, pathsOnly: true, detail: logged });
|
||||
return { ...out, added: result.added };
|
||||
}
|
||||
|
||||
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
|
||||
// addition: one new key under the managed product layout, a receipt naming the request and plan, and
|
||||
// no other change. It compares both the document it built and the bytes it will publish. A stale
|
||||
@@ -779,5 +972,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, stageCopyGrant, commitCopyGrant, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyPack, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, stageCopyGrant, commitCopyGrant, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// Starter packs SP3 (design dd878cefc sections 3 and 8, fix round 3 c0a60ed3a): the organization file's packs, board and
|
||||
// workflows keys, each project's board and workflows, and the writer's applyPack operation.
|
||||
// Fixtures live in a temp directory; nothing reads the real config.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { organizationWriter } from "../src/organization.mjs";
|
||||
import { validateBusinessDocument } from "../src/organization-config.mjs";
|
||||
import { businessDoc, rolesCopy, tmp, writeJson } from "./helpers.mjs";
|
||||
import { canonicalJson, sha256 } from "../src/util.mjs";
|
||||
|
||||
const SECRET = "sk-test-NOT-A-SECRET-1234";
|
||||
const assertClean = (value) => { if (String(value).includes(SECRET)) throw new Error("credential-leak"); };
|
||||
const HASH = "a".repeat(64);
|
||||
const PLAIN = { role: { tools: ["read"], network: "none", authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: "Read and report.\n" };
|
||||
// An inline proposal: a pack definition the apply did not write, kept whole beside its pin.
|
||||
const WRITER = { ...PLAIN, instructions: "Write the docs.\n" };
|
||||
const WRITER_HASH = sha256(canonicalJson(WRITER));
|
||||
const BOARD = {
|
||||
columns: [
|
||||
{ state: "todo", label: "Backlog" }, { state: "in-progress", label: "Building" },
|
||||
{ state: "in-review", label: "Review" }, { state: "blocked", label: "Blocked" }, { state: "done", label: "Merged" },
|
||||
],
|
||||
layout: "kanban",
|
||||
};
|
||||
const LIST_BOARD = { ...structuredClone(BOARD), layout: "list" };
|
||||
const WORKFLOWS = {
|
||||
change: {
|
||||
title: "Change", description: "One change from task to merge.",
|
||||
steps: [
|
||||
{ title: "Write the task", state: "todo", roles: ["pm"] },
|
||||
{ title: "Implement", state: "in-progress", roles: ["coder"] },
|
||||
{ title: "Approve the merge", state: "done", roles: ["human"] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const PACK = {
|
||||
id: "coding", version: "1.0.0", sha256: HASH, origin: "shipped", scope: "org", project: null,
|
||||
at: "2026-11-12T20:00:00.000Z", by: "jason", added: ["definitions.scout", "board", "workflows.change"],
|
||||
proposedHires: [
|
||||
{ name: "coder-1", reason: "writes the code", pin: { template: "coder", sha256: HASH } },
|
||||
{ name: "scout-1", pin: { definition: "scout", rev: 1 } },
|
||||
{ name: "writer-1", pin: { inline: "writer", sha256: WRITER_HASH }, entry: WRITER },
|
||||
],
|
||||
};
|
||||
|
||||
function fixture(t, edit = (doc) => doc, barrier) {
|
||||
const root = tmp();
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, "config");
|
||||
const rolesDir = rolesCopy(root);
|
||||
const document = edit(businessDoc(root));
|
||||
const file = writeJson(join(dir, "businesses/acme.json"), document);
|
||||
return { root, dir, file, rolesDir, document, writer: organizationWriter({ dir, rolesDir, barrier }) };
|
||||
}
|
||||
const rev = (f) => f.writer.read("acme").revision;
|
||||
const doc = (f) => JSON.parse(readFileSync(f.file, "utf8"));
|
||||
const bytes = (f) => readFileSync(f.file, "utf8");
|
||||
const load = (f, document) => validateBusinessDocument(document, f.file, { rolesDir: f.rolesDir });
|
||||
const without = (object, ...keys) => Object.fromEntries(Object.entries(object).filter(([k]) => !keys.includes(k)));
|
||||
// An undefined value in `extra` drops that key from the plan.
|
||||
const packPlan = (extra = {}) => Object.fromEntries(Object.entries({
|
||||
definitions: { scout: PLAIN }, board: BOARD, workflows: WORKFLOWS,
|
||||
packs: without({ ...PACK, proposedHires: PACK.proposedHires.slice(0, 2) }, "at", "by", "added"), ...extra,
|
||||
}).filter(([, value]) => value !== undefined));
|
||||
|
||||
test("SP3 red before: a file with a board key loads, and the loaded organization keeps it", (t) => {
|
||||
const f = fixture(t);
|
||||
const loaded = load(f, { ...f.document, board: BOARD });
|
||||
assert.deepEqual(loaded.board, BOARD);
|
||||
});
|
||||
|
||||
test("SP3 valid packs, board and workflows load, and the organization and each project keep them", (t) => {
|
||||
const f = fixture(t);
|
||||
const document = {
|
||||
...f.document, board: BOARD, workflows: WORKFLOWS, packs: [PACK, { ...PACK, origin: "saved", scope: "project", project: "stack", added: ["projects.stack.board", "projects.stack.workflows.change"], proposedHires: [] }],
|
||||
projects: { stack: { ...f.document.projects.stack, board: LIST_BOARD, workflows: WORKFLOWS } },
|
||||
};
|
||||
const loaded = load(f, document);
|
||||
assert.deepEqual(loaded.board, BOARD);
|
||||
assert.deepEqual(loaded.workflows, WORKFLOWS);
|
||||
assert.deepEqual(loaded.packs, document.packs);
|
||||
assert.deepEqual(loaded.projects.stack.board, LIST_BOARD);
|
||||
assert.deepEqual(loaded.projects.stack.workflows, WORKFLOWS);
|
||||
assert.ok(Object.isFrozen(loaded.projects.stack.board));
|
||||
// Title and description are optional; the absent keys stay absent.
|
||||
const bare = load(f, { ...f.document, workflows: { quick: { steps: [{ title: "Do it", state: "done", roles: ["human"] }] } } });
|
||||
assert.deepEqual(bare.workflows, { quick: { steps: [{ title: "Do it", state: "done", roles: ["human"] }] } });
|
||||
// A file without the keys loads as it did, with none of them on the loaded object.
|
||||
const plain = load(f, f.document);
|
||||
for (const key of ["board", "workflows", "packs"]) assert.ok(!Object.hasOwn(plain, key), key);
|
||||
assert.ok(!Object.hasOwn(plain.projects.stack, "board"));
|
||||
});
|
||||
|
||||
test("SP3 a workflow naming a role that does not exist still loads (the load checks shape only)", (t) => {
|
||||
const f = fixture(t);
|
||||
const ghost = { change: { steps: [{ title: "Haunt", state: "todo", roles: ["ghost-role", "no-such-template"] }] } };
|
||||
assert.deepEqual(load(f, { ...f.document, workflows: ghost }).workflows, ghost);
|
||||
assert.deepEqual(load(f, { ...f.document, projects: { stack: { ...f.document.projects.stack, workflows: ghost } } }).projects.stack.workflows, ghost);
|
||||
});
|
||||
|
||||
test("SP3 a definition a workflow names can still be removed, and the file then still loads", (t) => {
|
||||
// Design c0a60ed3a SP3 acceptance: "an org file whose step names a definition removed since loads,
|
||||
// and removing that definition succeeds".
|
||||
const f = fixture(t);
|
||||
const scouting = { scouting: { steps: [{ title: "Scout", state: "todo", roles: ["scout", "human"] }] } };
|
||||
const packs = { ...packPlan().packs, proposedHires: [] };
|
||||
f.writer.applyPack("acme", { definitions: { scout: PLAIN }, workflows: scouting, packs }, { revision: rev(f), actor: "jason", assertClean });
|
||||
f.writer.applyPack("acme", { "projects.stack.workflows": scouting }, { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(doc(f).definitions.scout, { ...PLAIN, rev: 1 });
|
||||
f.writer.setDefinition("acme", "scout", () => null, { revision: rev(f), actor: "jason", assertClean });
|
||||
const after = doc(f);
|
||||
assert.equal(after.definitions?.scout, undefined);
|
||||
assert.deepEqual(after.workflows, scouting);
|
||||
assert.deepEqual(after.projects.stack.workflows, scouting);
|
||||
const loaded = load(f, after);
|
||||
assert.deepEqual(loaded.workflows, scouting);
|
||||
assert.deepEqual(loaded.projects.stack.workflows, scouting);
|
||||
// The writer reads it back too; only a new write of that workflow resolves its roles again.
|
||||
assert.equal(f.writer.read("acme").revision, rev(f));
|
||||
assert.throws(() => f.writer.applyPack("acme", { workflows: { again: scouting.scouting } }, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
|
||||
});
|
||||
|
||||
test("SP3 each invalid shape refuses", (t) => {
|
||||
const f = fixture(t);
|
||||
const col = (i, patch) => ({ ...BOARD, columns: BOARD.columns.map((c, j) => (j === i ? { ...c, ...patch } : c)) });
|
||||
const step = (patch) => ({ change: { ...WORKFLOWS.change, steps: [{ ...WORKFLOWS.change.steps[0], ...patch }] } });
|
||||
const pack = (patch) => [{ ...PACK, ...patch }];
|
||||
const steps = (n) => Array.from({ length: n }, () => ({ title: "Step", state: "todo", roles: ["human"] }));
|
||||
const cases = {
|
||||
"board not an object": { board: [] },
|
||||
"board unknown key": { board: { ...BOARD, colour: "red" } },
|
||||
"board missing layout": { board: without(BOARD, "layout") },
|
||||
"board four columns": { board: { ...BOARD, columns: BOARD.columns.slice(0, 4) } },
|
||||
"board six columns": { board: { ...BOARD, columns: [...BOARD.columns, BOARD.columns[0]] } },
|
||||
"board duplicate state": { board: col(1, { state: "todo" }) },
|
||||
"board unknown state": { board: col(0, { state: "backlog" }) },
|
||||
"board column unknown key": { board: col(0, { colour: "red" }) },
|
||||
"board label empty": { board: col(0, { label: "" }) },
|
||||
"board label 33 characters": { board: col(0, { label: "x".repeat(33) }) },
|
||||
"board layout grid": { board: { ...BOARD, layout: "grid" } },
|
||||
"workflows not an object": { workflows: [] },
|
||||
"workflow name not NAME": { workflows: { Change_1: WORKFLOWS.change } },
|
||||
"workflow unknown key": { workflows: { change: { ...WORKFLOWS.change, owner: "pm" } } },
|
||||
"workflow no steps": { workflows: { change: { ...WORKFLOWS.change, steps: [] } } },
|
||||
"workflow 21 steps": { workflows: { change: { steps: steps(21) } } },
|
||||
"workflow title 121 characters": { workflows: { change: { ...WORKFLOWS.change, title: "x".repeat(121) } } },
|
||||
"workflow description 1001 characters": { workflows: { change: { ...WORKFLOWS.change, description: "x".repeat(1001) } } },
|
||||
"step unknown key": { workflows: step({ id: "plan" }) },
|
||||
"step missing roles": { workflows: { change: { steps: [{ title: "Step", state: "todo" }] } } },
|
||||
"step title 121 characters": { workflows: step({ title: "x".repeat(121) }) },
|
||||
"step unknown state": { workflows: step({ state: "doing" }) },
|
||||
"step roles empty": { workflows: step({ roles: [] }) },
|
||||
"step nine roles": { workflows: step({ roles: ["a", "b", "c", "d", "e", "f", "g", "h", "i"] }) },
|
||||
"step role not NAME": { workflows: step({ roles: ["Coder_1"] }) },
|
||||
"step duplicate role": { workflows: step({ roles: ["pm", "pm"] }) },
|
||||
"packs not a list": { packs: {} },
|
||||
"pack unknown key": { packs: pack({ hires: [] }) },
|
||||
"pack missing key": { packs: [without(PACK, "sha256")] },
|
||||
"pack origin unknown": { packs: pack({ origin: "borrowed" }) },
|
||||
"pack scope root": { packs: pack({ scope: "root" }) },
|
||||
"org pack with project": { packs: pack({ project: "stack" }) },
|
||||
"project pack without project": { packs: pack({ scope: "project", project: null }) },
|
||||
"pack id not NAME": { packs: pack({ id: "Coding" }) },
|
||||
"pack version not semver": { packs: pack({ version: "v1" }) },
|
||||
"pack sha256 not hex": { packs: pack({ sha256: "A".repeat(64) }) },
|
||||
"pack at not a timestamp": { packs: pack({ at: "yesterday" }) },
|
||||
"pack by not an id": { packs: pack({ by: "Not An Id" }) },
|
||||
"pack added bad path": { packs: pack({ added: ["plugins.theme"] }) },
|
||||
"pack nine proposed hires": { packs: pack({ proposedHires: Array.from({ length: 9 }, (_, i) => ({ name: `coder-${i}`, pin: { template: "coder", sha256: HASH } })) }) },
|
||||
"proposed hire two pins": { packs: pack({ proposedHires: [{ name: "coder-1", pin: { template: "coder", sha256: HASH, inline: "writer" } }] }) },
|
||||
"proposed hire no pin": { packs: pack({ proposedHires: [{ name: "coder-1" }] }) },
|
||||
"proposed hire unknown key": { packs: pack({ proposedHires: [{ name: "coder-1", level: "org", pin: { template: "coder", sha256: HASH } }] }) },
|
||||
"proposed hire duplicate name": { packs: pack({ proposedHires: [{ name: "c", pin: { template: "coder", sha256: HASH } }, { name: "c", pin: { template: "coder", sha256: HASH } }] }) },
|
||||
"inline pin old hash-only shape": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: HASH }, entry: WRITER }] }) },
|
||||
"inline pin without entry": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH } }] }) },
|
||||
"inline pin name not an id": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "Writer One", sha256: WRITER_HASH }, entry: WRITER }] }) },
|
||||
"inline pin sha256 not hex": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: "x" }, entry: WRITER }] }) },
|
||||
"template pin with entry": { packs: pack({ proposedHires: [{ name: "w", pin: { template: "coder", sha256: HASH }, entry: WRITER }] }) },
|
||||
"inline entry not an object": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH }, entry: "Write the docs." }] }) },
|
||||
"inline entry unknown key": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH }, entry: { ...WRITER, level: "org" } }] }) },
|
||||
"inline entry unknown tool": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH }, entry: { ...WRITER, role: { ...WRITER.role, tools: ["teleport"] } } }] }) },
|
||||
"inline entry without role": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH }, entry: without(WRITER, "role") }] }) },
|
||||
"inline entry rev zero": { packs: pack({ proposedHires: [{ name: "w", pin: { inline: "writer", sha256: WRITER_HASH }, entry: { ...WRITER, rev: 0 } }] }) },
|
||||
"proposed hire rev zero": { packs: pack({ proposedHires: [{ name: "c", pin: { definition: "scout", rev: 0 } }] }) },
|
||||
};
|
||||
for (const [name, patch] of Object.entries(cases)) {
|
||||
assert.throws(() => load(f, { ...f.document, ...patch }), { name: "BusinessError" }, name);
|
||||
}
|
||||
// The project keys refuse the same shapes, and other project keys still refuse.
|
||||
const project = (patch) => ({ ...f.document, projects: { stack: { ...f.document.projects.stack, ...patch } } });
|
||||
for (const [name, patch] of Object.entries({
|
||||
"project board layout": { board: { ...BOARD, layout: "grid" } },
|
||||
"project workflow 0 steps": { workflows: { change: { steps: [] } } },
|
||||
"project unknown key": { packs: [] },
|
||||
})) assert.throws(() => load(f, project(patch)), { name: "BusinessError" }, name);
|
||||
});
|
||||
|
||||
test("SP3 applyPack writes definitions, board, workflows and the packs entry in one commit", (t) => {
|
||||
const f = fixture(t);
|
||||
const before = doc(f);
|
||||
const out = f.writer.applyPack("acme", packPlan(), { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(out.added, ["definitions.scout", "board", "workflows.change"]);
|
||||
assert.equal(out.revision, rev(f));
|
||||
const after = doc(f);
|
||||
assert.deepEqual(after.definitions.scout, { ...PLAIN, rev: 1 });
|
||||
assert.deepEqual(after.board, BOARD);
|
||||
assert.deepEqual(after.workflows, WORKFLOWS);
|
||||
assert.equal(after.packs.length, 1);
|
||||
const entry = after.packs[0];
|
||||
assert.deepEqual(without(entry, "at"), { ...without(PACK, "at"), by: "jason", added: out.added, proposedHires: PACK.proposedHires.slice(0, 2) });
|
||||
assert.match(entry.at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
// Nothing else changed.
|
||||
assert.deepEqual(without(after, "definitions", "board", "workflows", "packs"), without(before, "definitions", "board", "workflows", "packs"));
|
||||
// One prepared and one committed receipt, and the receipt logs paths, not values.
|
||||
const log = readFileSync(f.writer.logFile("acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.deepEqual(log.map((e) => e.state), ["prepared", "committed"]);
|
||||
assert.ok(!JSON.stringify(log).includes("Backlog"));
|
||||
// A second pack appends; the first entry stays.
|
||||
const second = { packs: { ...packPlan().packs, id: "review", proposedHires: [] }, workflows: { review: WORKFLOWS.change } };
|
||||
f.writer.applyPack("acme", second, { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(doc(f).packs.map((p) => p.id), ["coding", "review"]);
|
||||
assert.deepEqual(doc(f).packs[0], entry);
|
||||
assert.deepEqual(doc(f).packs[1].added, ["workflows.review"]);
|
||||
});
|
||||
|
||||
test("SP3 applyPack writes everything or nothing", (t) => {
|
||||
// A plan item after the definitions is bad: the valid definition is not written either.
|
||||
const f = fixture(t);
|
||||
const original = bytes(f);
|
||||
const bad = packPlan({ workflows: { change: { steps: [{ title: "Haunt", state: "todo", roles: ["ghost-role"] }] } } });
|
||||
assert.throws(() => f.writer.applyPack("acme", bad, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
|
||||
assert.equal(bytes(f), original);
|
||||
const badBoard = packPlan({ board: { ...BOARD, layout: "grid" } });
|
||||
assert.throws(() => f.writer.applyPack("acme", badBoard, { revision: rev(f), actor: "jason", assertClean }), { name: "BusinessError" });
|
||||
assert.equal(bytes(f), original);
|
||||
// A fault after the candidate is prepared, with every item checked, leaves the file unchanged.
|
||||
const crash = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir, barrier: (stage) => { if (stage === "prepared") throw new Error("fixture crash"); } });
|
||||
assert.throws(() => crash.applyPack("acme", packPlan(), { revision: rev(f), actor: "jason", assertClean }), /fixture crash/);
|
||||
assert.equal(bytes(f), original);
|
||||
f.writer.recover("acme");
|
||||
assert.equal(bytes(f), original);
|
||||
assert.equal(JSON.parse(readFileSync(f.writer.logFile("acme"), "utf8").trim().split("\n").at(-1)).state, "aborted");
|
||||
// The same plan then lands whole.
|
||||
f.writer.applyPack("acme", packPlan(), { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.ok(doc(f).definitions.scout && doc(f).board && doc(f).workflows && doc(f).packs);
|
||||
});
|
||||
|
||||
test("SP3 an agent actor with a definition in the plan refuses human-required; board and workflow writes pass", (t) => {
|
||||
const f = fixture(t);
|
||||
const original = bytes(f);
|
||||
for (const actor of ["coder", "chief-of-staff.chief", "tester"]) {
|
||||
assert.throws(() => f.writer.applyPack("acme", packPlan(), { revision: rev(f), actor, assertClean }), { code: "human-required" }, actor);
|
||||
}
|
||||
// A saved template carries invented roles; an agent never applies one.
|
||||
const saved = { packs: { ...packPlan().packs, origin: "saved", proposedHires: [] }, board: BOARD };
|
||||
assert.throws(() => f.writer.applyPack("acme", saved, { revision: rev(f), actor: "chief-of-staff.chief", assertClean }), { code: "human-required" });
|
||||
assert.equal(bytes(f), original);
|
||||
// The non-definition half of a pack, and a board write, need no human (Jason's answer 2).
|
||||
const half = { packs: { ...packPlan().packs, proposedHires: [{ name: "coder-1", pin: { template: "coder", sha256: HASH } }] }, workflows: WORKFLOWS };
|
||||
f.writer.applyPack("acme", half, { revision: rev(f), actor: "chief-of-staff.chief", assertClean });
|
||||
assert.equal(doc(f).packs[0].by, "chief-of-staff.chief");
|
||||
f.writer.applyPack("acme", { board: BOARD }, { revision: rev(f), actor: "chief-of-staff.chief", assertClean });
|
||||
assert.deepEqual(doc(f).board, BOARD);
|
||||
});
|
||||
|
||||
test("SP3 a plan holding any other key refuses", (t) => {
|
||||
const f = fixture(t);
|
||||
const original = bytes(f);
|
||||
for (const [name, plan] of Object.entries({
|
||||
plugins: { plugins: { theme: ["dark"] } },
|
||||
roles: { roles: { pm: { definition: "pm" } } },
|
||||
limits: { limits: { total: 3 } },
|
||||
credentials: { credentials: { gitea: { file: "/x" } } },
|
||||
launch: { launch: {} },
|
||||
copyBindings: { copyBindings: {} },
|
||||
autonomy: { autonomy: "near" },
|
||||
"projects whole": { projects: { stack: { root: "/x" } } },
|
||||
"projects root": { "projects.stack.root": "/x" },
|
||||
"projects agents": { "projects.stack.agents": {} },
|
||||
"board beside plugins": { board: BOARD, plugins: {} },
|
||||
"dotted workflow item": { "workflows.change": WORKFLOWS.change },
|
||||
"empty plan": {},
|
||||
"not an object": [],
|
||||
})) {
|
||||
assert.throws(() => f.writer.applyPack("acme", plan, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" }, name);
|
||||
}
|
||||
assert.equal(bytes(f), original);
|
||||
});
|
||||
|
||||
test("SP3 a board-only plan changes only board, and a project board only that project's board", (t) => {
|
||||
const f = fixture(t);
|
||||
const before = doc(f);
|
||||
f.writer.applyPack("acme", { board: BOARD }, { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(without(doc(f), "board"), before);
|
||||
assert.deepEqual(doc(f).board, BOARD);
|
||||
// board.set replaces a board, and null removes it.
|
||||
f.writer.applyPack("acme", { board: LIST_BOARD }, { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(doc(f).board, LIST_BOARD);
|
||||
f.writer.applyPack("acme", { board: null }, { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(doc(f), before);
|
||||
// A project's board.
|
||||
f.writer.applyPack("acme", { "projects.stack.board": BOARD }, { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(doc(f).projects.stack, { ...before.projects.stack, board: BOARD });
|
||||
assert.deepEqual(without(doc(f), "projects"), without(before, "projects"));
|
||||
assert.deepEqual(f.writer.read("acme").document.projects.stack.board, BOARD);
|
||||
// Workflows merge by name; null removes one; a project's workflows likewise.
|
||||
f.writer.applyPack("acme", { workflows: WORKFLOWS }, { revision: rev(f), actor: "jason", assertClean });
|
||||
f.writer.applyPack("acme", { workflows: { review: WORKFLOWS.change } }, { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(Object.keys(doc(f).workflows), ["change", "review"]);
|
||||
f.writer.applyPack("acme", { workflows: { change: null } }, { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(Object.keys(doc(f).workflows), ["review"]);
|
||||
f.writer.applyPack("acme", { workflows: { review: null } }, { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.ok(!Object.hasOwn(doc(f), "workflows"));
|
||||
f.writer.applyPack("acme", { "projects.stack.workflows": WORKFLOWS }, { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(doc(f).projects.stack.workflows, WORKFLOWS);
|
||||
// Removing what isn't there refuses.
|
||||
assert.throws(() => f.writer.applyPack("acme", { board: null }, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
|
||||
assert.throws(() => f.writer.applyPack("acme", { workflows: { nope: null } }, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
|
||||
});
|
||||
|
||||
test("SP3 a workflow in the plan must name an org definition, a V2 template or human", (t) => {
|
||||
const f = fixture(t);
|
||||
const original = bytes(f);
|
||||
const flow = (roles) => ({ workflows: { change: { steps: [{ title: "Step", state: "todo", roles }] } } });
|
||||
for (const roles of [["ghost-role"], ["pm", "ghost-role"], ["conductor-policy"]]) {
|
||||
assert.throws(() => f.writer.applyPack("acme", flow(roles), { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" }, roles.join());
|
||||
assert.throws(() => f.writer.applyPack("acme", { "projects.stack.workflows": flow(roles).workflows }, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
|
||||
}
|
||||
assert.equal(bytes(f), original);
|
||||
// A template, human, and a definition the same plan adds.
|
||||
f.writer.applyPack("acme", flow(["pm", "human"]), { revision: rev(f), actor: "jason", assertClean });
|
||||
f.writer.applyPack("acme", packPlan({ board: undefined, workflows: { scouting: { steps: [{ title: "Scout", state: "todo", roles: ["scout"] }] } } }), { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(Object.keys(doc(f).workflows), ["change", "scouting"]);
|
||||
});
|
||||
|
||||
test("SP3 applyPack keeps setDefinition's definition rules", (t) => {
|
||||
const f = fixture(t, (d) => ({ ...d, definitions: { scout: { ...PLAIN, rev: 1 }, spare: { ...PLAIN, rev: 2 } }, roles: { ...d.roles, looker: { definition: "org:scout" } } }));
|
||||
const original = bytes(f);
|
||||
const plan = (definitions) => packPlan({ definitions, board: undefined, workflows: undefined });
|
||||
assert.throws(() => f.writer.applyPack("acme", plan({ scout: PLAIN }), { revision: rev(f), actor: "jason", assertClean }), { code: "definition-in-use" });
|
||||
assert.throws(() => f.writer.applyPack("acme", plan({ spare: PLAIN }), { revision: rev(f), actor: "jason", assertClean }), { code: "definition-exists" });
|
||||
assert.throws(() => f.writer.applyPack("acme", plan({ fresh: { ...PLAIN, rev: 1 } }), { revision: rev(f), actor: "jason", assertClean }), { code: "definition-rev-conflict" });
|
||||
// The credential store's check is required on every apply, not only one that writes definitions.
|
||||
assert.throws(() => f.writer.applyPack("acme", plan({ fresh: PLAIN }), { revision: rev(f), actor: "jason" }), { code: "definition-secret-check-required" });
|
||||
for (const bare of [{ board: BOARD }, { workflows: WORKFLOWS }, { "projects.stack.board": BOARD }, { packs: { ...packPlan().packs, proposedHires: [] }, workflows: WORKFLOWS }]) {
|
||||
assert.throws(() => f.writer.applyPack("acme", bare, { revision: rev(f), actor: "jason" }), { code: "definition-secret-check-required" }, Object.keys(bare).join());
|
||||
}
|
||||
// A secret in workflow text, a plan key or an inline proposal's entry refuses before anything is written.
|
||||
const leak = { ...WORKFLOWS.change, description: SECRET };
|
||||
assert.throws(() => f.writer.applyPack("acme", { workflows: { change: leak } }, { revision: rev(f), actor: "jason", assertClean }), /credential-leak/);
|
||||
assert.throws(() => f.writer.applyPack("acme", { [SECRET]: BOARD }, { revision: rev(f), actor: "jason", assertClean }), /credential-leak/);
|
||||
assert.throws(() => f.writer.applyPack("acme", plan({ fresh: { ...PLAIN, instructions: `token ${SECRET}\n` } }), { revision: rev(f), actor: "jason", assertClean }), /credential-leak/);
|
||||
assert.throws(() => f.writer.applyPack("acme", plan({ fresh: { ...PLAIN, role: { ...PLAIN.role, tools: ["no-such-tool"] } } }), { revision: rev(f), actor: "jason", assertClean }), { name: "BusinessError" });
|
||||
// checkComposition runs: a hired composition never holds role.hire.
|
||||
const hiring = { ...PLAIN, role: { ...PLAIN.role, authority: { withinRole: [], crossRole: ["role.hire"] } } };
|
||||
assert.throws(() => f.writer.applyPack("acme", plan({ fresh: hiring }), { revision: rev(f), actor: "jason", assertClean }));
|
||||
// A definition needs a packs entry, and a {definition, rev} pin names a definition this apply writes at rev 1.
|
||||
assert.throws(() => f.writer.applyPack("acme", { definitions: { fresh: PLAIN } }, { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
|
||||
const pin = (p) => ({ ...plan({ fresh: PLAIN }), packs: { ...packPlan().packs, proposedHires: [{ name: "x-1", pin: p }] } });
|
||||
assert.throws(() => f.writer.applyPack("acme", pin({ definition: "spare", rev: 2 }), { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
|
||||
assert.throws(() => f.writer.applyPack("acme", pin({ definition: "fresh", rev: 2 }), { revision: rev(f), actor: "jason", assertClean }), { code: "invalid-request" });
|
||||
// A held secret in a board label or workflow text refuses when the caller passes the check.
|
||||
assert.throws(() => f.writer.applyPack("acme", { board: { ...BOARD, columns: [{ state: "todo", label: SECRET }, ...BOARD.columns.slice(1)] } }, { revision: rev(f), actor: "jason", assertClean }), /credential-leak/);
|
||||
assert.equal(bytes(f), original);
|
||||
f.writer.applyPack("acme", pin({ definition: "fresh", rev: 1 }), { revision: rev(f), actor: "jason", assertClean });
|
||||
assert.deepEqual(doc(f).definitions.fresh, { ...PLAIN, rev: 1 });
|
||||
});
|
||||
|
||||
test("SP3 a pack apply only adds, and its scope fences the project keys", (t) => {
|
||||
const f = fixture(t, (d) => ({ ...d, board: BOARD, workflows: WORKFLOWS }));
|
||||
const original = bytes(f);
|
||||
// No definitions in these plans, so no {definition, rev} pin: each refusal below is the one named.
|
||||
const packs = { ...packPlan().packs, proposedHires: PACK.proposedHires.slice(0, 1) };
|
||||
const go = (plan) => f.writer.applyPack("acme", plan, { revision: rev(f), actor: "jason", assertClean });
|
||||
// Nothing is overwritten or removed by an apply.
|
||||
assert.throws(() => go({ packs, board: LIST_BOARD }), { code: "invalid-request" });
|
||||
assert.throws(() => go({ packs, workflows: { change: WORKFLOWS.change } }), { code: "invalid-request" });
|
||||
assert.throws(() => go({ packs, workflows: { other: null } }), { code: "invalid-request" });
|
||||
// The writer stamps at, by and added.
|
||||
for (const key of ["at", "by", "added"]) assert.throws(() => go({ packs: { ...packs, [key]: PACK[key] }, workflows: { other: WORKFLOWS.change } }), { code: "invalid-request" }, key);
|
||||
// An org pack writes no project key; a project pack writes only its project's keys.
|
||||
assert.throws(() => go({ packs, "projects.stack.board": BOARD }), { code: "invalid-request" });
|
||||
const projectPack = { ...packs, scope: "project", project: "stack" };
|
||||
assert.throws(() => go({ packs: projectPack, workflows: { other: WORKFLOWS.change } }), { code: "invalid-request" });
|
||||
assert.throws(() => go({ packs: { ...projectPack, project: "nowhere" }, "projects.nowhere.board": BOARD }), { code: "invalid-request" });
|
||||
assert.throws(() => go({ "projects.nowhere.board": BOARD }), { code: "invalid-request" });
|
||||
assert.equal(bytes(f), original);
|
||||
go({ packs: projectPack, "projects.stack.board": BOARD, "projects.stack.workflows": WORKFLOWS });
|
||||
assert.deepEqual(doc(f).packs[0].added, ["projects.stack.board", "projects.stack.workflows.change"]);
|
||||
assert.deepEqual(doc(f).projects.stack.board, BOARD);
|
||||
});
|
||||
|
||||
test("SP3 the generic writer still refuses project board changes", (t) => {
|
||||
const f = fixture(t);
|
||||
const next = structuredClone(f.document);
|
||||
next.projects.stack.board = BOARD;
|
||||
assert.throws(() => f.writer.write("acme", next, { revision: rev(f), actor: "jason" }), { code: "organization-projects-protected" });
|
||||
});
|
||||
|
||||
test("SP3 an inline proposal keeps its full role entry beside the pin, and the pin is the entry's hash", (t) => {
|
||||
const f = fixture(t);
|
||||
const inline = PACK.proposedHires[2];
|
||||
// The loaded organization keeps the entry, so the raise can read it back.
|
||||
const loaded = load(f, { ...f.document, packs: [{ ...PACK, proposedHires: [inline] }] });
|
||||
assert.deepEqual(loaded.packs[0].proposedHires[0], inline);
|
||||
// A recorded hash that no longer matches still loads: staleness is the raise's refusal.
|
||||
const stale = { ...inline, pin: { ...inline.pin, sha256: HASH } };
|
||||
assert.deepEqual(load(f, { ...f.document, packs: [{ ...PACK, proposedHires: [stale] }] }).packs[0].proposedHires[0], stale);
|
||||
// An apply records the entry, and refuses a pin that is not the entry's canonical hash.
|
||||
const original = bytes(f);
|
||||
const plan = (hire) => ({ packs: { ...packPlan().packs, proposedHires: [hire] }, workflows: WORKFLOWS });
|
||||
assert.throws(() => f.writer.applyPack("acme", plan(stale), { revision: rev(f), actor: "chief-of-staff.chief", assertClean }), { code: "invalid-request" });
|
||||
assert.throws(() => f.writer.applyPack("acme", plan({ ...inline, entry: { ...WRITER, instructions: "Something else.\n" } }), { revision: rev(f), actor: "chief-of-staff.chief", assertClean }), { code: "invalid-request" });
|
||||
// A held secret in the entry refuses when the caller passes the check.
|
||||
const leaky = { ...WRITER, instructions: SECRET };
|
||||
assert.throws(() => f.writer.applyPack("acme", plan({ ...inline, pin: { ...inline.pin, sha256: sha256(canonicalJson(leaky)) }, entry: leaky }), { revision: rev(f), actor: "chief-of-staff.chief", assertClean }), /credential-leak/);
|
||||
assert.equal(bytes(f), original);
|
||||
// An agent apply writes no definition, so its hires are template or inline pins.
|
||||
f.writer.applyPack("acme", plan(inline), { revision: rev(f), actor: "chief-of-staff.chief", assertClean });
|
||||
assert.deepEqual(doc(f).packs[0].proposedHires, [inline]);
|
||||
assert.equal(doc(f).definitions?.writer, undefined);
|
||||
assert.deepEqual(load(f, doc(f)).packs[0].proposedHires, [inline]);
|
||||
});
|
||||
Reference in new issue
Block a user