Files
stack/packages/business/tests/vars.test.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

120 lines
6.7 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { BusinessError, LAYERS, REGISTRY, checkVars, mergeVars } from "../src/index.mjs";
function refuses(fn, pattern) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
return true;
});
}
test("every key names known layers and a merge rule", () => {
for (const [key, entry] of Object.entries(REGISTRY)) {
assert.ok(entry.layers.length > 0, key);
for (const layer of entry.layers) assert.ok(LAYERS.includes(layer), `${key}: ${layer}`);
assert.ok(["replace", "intersect"].includes(entry.merge), key);
if (entry.merge === "intersect") assert.ok(key.startsWith("limits."), key);
}
});
test("unknown keys and wrong layers refuse", () => {
refuses(() => checkVars({ colour: "blue" }, "business", "b.vars"), /b\.vars: unknown variable "colour"/);
refuses(() => checkVars({ harness: "pi" }, "business", "b.vars"), /can't be set at the business layer \(allowed: agent\)/);
refuses(() => checkVars({ "tracker.baseUrl": "http://x" }, "project", "p.vars"), /project layer/);
refuses(() => checkVars({ "tracker.project": 3 }, "agent", "a.vars"), /agent layer/);
refuses(() => checkVars({ dataRoot: "/x" }, "business", "b.vars"), /business layer/);
refuses(() => checkVars(["harness"], "agent", "a.vars"), /JSON object/);
refuses(() => checkVars({ constructor: 1 }, "agent", "a.vars"), /unknown variable "constructor"/);
assert.throws(() => checkVars({}, "fleet", "x"), /unknown layer/);
});
test("types", () => {
const ok = (vars, layer) => assert.deepEqual({ ...checkVars(vars, layer, "v") }, vars);
ok({ "tracker.baseUrl": "https://tasks.example:3456", "tracker.reconcileMinutes": 1, "tracker.pollSeconds": 10, "human.discordUserId": "123456789012345678" }, "business");
ok({ "tracker.project": 3, "git.workingBranch": "refactor", "git.protectedBranches": ["main", "next"], suites: ["scripts/test-task.sh"], "issues.repo": "mosaicstack/stack", "tracker.descendants": true }, "project");
ok({ harness: "claude-code", model: "claude-opus-5-5", thinking: "high", "limits.tools": [], "limits.network": "none", "limits.authority": ["message.send"] }, "agent");
const bad = [
[{ "tracker.baseUrl": "ftp://x" }, "business", /http or https/],
[{ "tracker.baseUrl": "https://x/" }, "business", /trailing slash/],
[{ "tracker.baseUrl": "https://u:p@x" }, "business", /no credentials/],
[{ "tracker.baseUrl": "https://x?a=1" }, "business", /query/],
[{ "tracker.baseUrl": "not a url" }, "business", /http or https URL/],
[{ "tracker.kind": "jira" }, "business", /one of: vikunja/],
[{ "tracker.pollSeconds": 9 }, "business", /at least 10/],
[{ "tracker.pollSeconds": "30" }, "project", /at least 10/],
[{ "tracker.reconcileMinutes": 0 }, "business", /at least 1/],
[{ "human.discordUserId": 123456789012345678 }, "business", /as a string/],
[{ "human.discordUserId": "0123456789012345678" }, "business", /Discord user id/],
[{ "tracker.project": 0 }, "project", /positive integer/],
[{ "tracker.descendants": "true" }, "project", /true or false/],
[{ "tracker.descendants": 1 }, "project", /true or false/],
[{ "tracker.descendants": true }, "business", /business layer \(allowed: project\)/],
[{ "git.workingBranch": "-x" }, "project", /git branch name/],
[{ "git.workingBranch": "a..b" }, "project", /git branch name/],
[{ "git.workingBranch": "x.lock" }, "project", /git branch name/],
[{ "git.workingBranch": "feat/" }, "project", /git branch name/],
[{ "git.protectedBranches": [] }, "project", /must not be empty/],
[{ "git.protectedBranches": ["main", "main"] }, "project", /duplicate/],
[{ suites: "scripts/test-task.sh" }, "project", /must be an array/],
[{ "issues.repo": "stack" }, "project", /owner\/name/],
[{ harness: "codex" }, "agent", /one of: pi, claude-code/],
[{ thinking: "max" }, "agent", /one of/],
[{ model: "" }, "agent", /non-empty string/],
[{ "limits.tools": ["render3d"] }, "agent", /unsupported tool/],
[{ "limits.network": "lan" }, "agent", /one of: none, api-only, open/],
[{ "limits.authority": ["deploy.prod"] }, "agent", /unknown action/],
];
for (const [vars, layer, pattern] of bad) refuses(() => checkVars(vars, layer, "v"), pattern);
});
test("merge: defaults, then the most specific layer wins", () => {
const { vars, provenance } = mergeVars([
{ layer: "business", source: "business", vars: checkVars({ "tracker.pollSeconds": 60 }, "business", "b") },
{ layer: "project", source: "project", vars: checkVars({ "tracker.pollSeconds": 20 }, "project", "p") },
]);
assert.equal(vars["tracker.pollSeconds"], 20);
assert.equal(provenance["tracker.pollSeconds"], "project");
assert.equal(vars["tracker.reconcileMinutes"], 60);
assert.equal(provenance["tracker.reconcileMinutes"], "default");
assert.equal(vars["tracker.kind"], "vikunja");
assert.equal(vars["tracker.descendants"], false);
assert.equal(provenance["tracker.descendants"], "default");
assert.equal(vars.harness, undefined);
assert.equal(provenance.harness, undefined);
});
test("merge: limits only narrow, and provenance lists each source", () => {
const layer = (source, layerName, v) => ({ layer: layerName, source, vars: checkVars(v, layerName, source) });
const { vars, provenance } = mergeVars([
layer("business", "business", { "limits.network": "none", "limits.tools": ["read", "grep", "bash"] }),
layer("project", "project", { "limits.network": "open", "limits.tools": ["read", "bash", "write"] }),
layer("agent", "agent", { "limits.tools": ["bash", "read", "edit"] }),
]);
assert.equal(vars["limits.network"], "none");
assert.deepEqual(provenance["limits.network"], ["business", "project"]);
assert.deepEqual(vars["limits.tools"], ["read", "bash"]);
assert.deepEqual(provenance["limits.tools"], ["business", "project", "agent"]);
const widen = mergeVars([
layer("business", "business", { "limits.authority": ["message.send"] }),
layer("agent", "agent", { "limits.authority": ["message.send", "task.close"] }),
]);
assert.deepEqual(widen.vars["limits.authority"], ["message.send"]);
const empty = mergeVars([layer("agent", "agent", { "limits.tools": [] })]);
assert.deepEqual(empty.vars["limits.tools"], []);
});
test("merge doesn't change its inputs", () => {
const business = checkVars({ "limits.tools": ["read", "bash"] }, "business", "b");
const snapshot = JSON.stringify(business);
mergeVars([
{ layer: "business", source: "business", vars: business },
{ layer: "agent", source: "agent", vars: checkVars({ "limits.tools": ["read"] }, "agent", "a") },
]);
assert.equal(JSON.stringify(business), snapshot);
});