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]>
67 lines
3.8 KiB
JavaScript
67 lines
3.8 KiB
JavaScript
// Sage's row 39 gate check, not part of the candidate: the S4 host boots the
|
|
// S3 adapter through the real process.mjs when bootConfig emits trackers.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { Client } from "../../bus/src/client.mjs";
|
|
import { bootConfig, loadSystem } from "../src/config.mjs";
|
|
import { startHost, startTimeOf } from "../src/host.mjs";
|
|
import { writeJson } from "../../business/tests/helpers.mjs";
|
|
import { FakeVikunja } from "../../tasks/src/fake.mjs";
|
|
import { SCOPES } from "../../tasks/tests/world.mjs";
|
|
import { fixture, tmp } from "./helpers.mjs";
|
|
|
|
test("bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja", async (t) => {
|
|
const root = tmp(t);
|
|
const fake = new FakeVikunja();
|
|
const srv = await fake.listen();
|
|
t.after(() => srv.close());
|
|
const owner = fake.user("svc-acme");
|
|
const bots = {};
|
|
for (const r of ["sync", "pm", "cto", "coder", "reviewer"]) bots[r] = fake.user(`bot-acme-${r}`, { owner });
|
|
const project = fake.project("Acme", owner);
|
|
for (const r of ["pm", "cto", "coder", "reviewer"]) fake.share(project, bots[r], 1);
|
|
fake.share(project, bots.sync, 0);
|
|
fake.install(project);
|
|
|
|
const f = fixture(root, "acme", (doc) => {
|
|
doc.vars["tracker.baseUrl"] = srv.url;
|
|
doc.tracker.sync.botId = bots.sync;
|
|
for (const r of Object.keys(doc.roles)) doc.roles[r].tracker.botId = bots[r];
|
|
return doc;
|
|
});
|
|
const put = (file, token) => {
|
|
writeFileSync(file, token + "\n");
|
|
chmodSync(file, 0o600);
|
|
};
|
|
put(f.doc.tracker.sync.credentials.vikunja.file, fake.token(bots.sync, SCOPES.sync));
|
|
for (const r of Object.keys(f.doc.roles)) put(f.doc.roles[r].credentials.vikunja.file, fake.token(bots[r], r === "pm" ? SCOPES.pm : SCOPES.worker));
|
|
writeJson(join(root, "project", ".mosaic", "project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": project } });
|
|
|
|
mkdirSync(f.dataRoot, { recursive: true, mode: 0o700 });
|
|
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
|
|
assert.deepEqual(boot.trackers, { acme: { baseUrl: srv.url, project, pollSeconds: 60, reconcileMinutes: 60, descendants: false } });
|
|
const host = await startHost({ boot, business: "acme", log: () => {} });
|
|
t.after(() => host.close(0));
|
|
|
|
const launch = await host.bindLaunch({ business: "acme", role: "pm", run: "pm-run", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
|
const pm = new Client({ path: host.path, cap: launch.cap });
|
|
await pm.call("role.claim");
|
|
let code;
|
|
const end = Date.now() + 15000;
|
|
do {
|
|
code = await pm.call("task.close", {}).then(() => "ok", (e) => e.code);
|
|
if (code !== "tracker-starting") break;
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
} while (Date.now() < end);
|
|
console.log(`task.close {} answered: ${code}; fake saw ${fake.requests.length} requests, first ${fake.requests.slice(0, 3).map((r) => `${r.method} ${r.path} ${r.status}`).join(", ")}`);
|
|
assert.ok(fake.requests.some((r) => r.path === "/info"), "the adapter called the fake");
|
|
assert.doesNotMatch(code, /^(tracker-|credential-|scope-too-broad)/, "the adapter is ready, so the verb fails on its own arguments");
|
|
const before = fake.requests.length;
|
|
const missing = await pm.call("task.close", { task_ref: `vikunja:${project}/999`, verdict: "gate check" }).then(() => "ok", (e) => e.code);
|
|
console.log(`task.close on a missing task answered: ${missing}; it made ${fake.requests.slice(before).map((r) => `${r.method} ${r.path} ${r.status}`).join(", ")}`);
|
|
assert.ok(fake.requests.length > before, "a well-formed verb reached Vikunja through the adapter");
|
|
assert.equal(await host.close(0), 0);
|
|
});
|