Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 51 additions & 7 deletions docs/devteam/scripts/devteam.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ if (Number(process.versions.node.split('.')[0]) < 18) {
process.exit(1);
}

// A worktree's branch carries the kit as it was when the branch forked, but every session writes the
// one live log and answers to the rules on main, so in a project the main checkout's copy runs: a
// worker on an old branch otherwise gets old commands (an "unknown command" for one the docs name)
// and folds the log by old rules. Only where the main checkout holds a project (not the kit's own
// repo, whose worktrees are where the kit is changed); DEVTEAM_OWN_KIT=1 runs this copy anyway.
{
const self = fileURLToPath(import.meta.url);
let common = null;
try { common = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: path.dirname(self), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
const kitMain = common && path.join(path.dirname(common), 'docs', 'devteam');
const mainScript = kitMain && path.join(kitMain, 'scripts', 'devteam.mjs');
if (mainScript && !process.env.DEVTEAM_OWN_KIT && path.resolve(mainScript) !== path.resolve(self) && fs.existsSync(path.join(kitMain, 'project')) && fs.existsSync(mainScript) && fs.readFileSync(mainScript, 'utf8') !== fs.readFileSync(self, 'utf8')) {
const r = spawnSync(process.execPath, [mainScript, ...process.argv.slice(2)], { stdio: 'inherit' });
process.exit(r.status ?? 1);
}
}

// Object.groupBy is Node 21+.
const groupBy = (xs, key) => { const o = {}; for (const x of xs) (o[key(x)] ??= []).push(x); return o; };

Expand Down Expand Up @@ -137,8 +154,12 @@ const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(

const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd();
// The kit of the checkout you are in (a worktree has its own copy), whichever copy of this script ran.
// The kit folder of the checkout you are in (a worktree has its own copy): its project/ is the
// branch's tickets and plans. Its rules are not read from here (kitDir, below).
const devteamDir = fs.existsSync(path.join(repoRoot, 'docs', 'devteam', 'scripts', 'devteam.mjs')) ? path.join(repoRoot, 'docs', 'devteam') : path.resolve(here, '..');
// The rules (schema, constitution) and the dashboard come from the kit that is running: in a
// project that is the main checkout's (the re-exec above), so every session folds by the same rules.
const kitDir = path.resolve(here, '..');
const commonDir = git(['rev-parse', '--path-format=absolute', '--git-common-dir']);
const mainRoot = commonDir ? path.dirname(commonDir) : repoRoot;
const projectDir = path.resolve(flagStr('project') ?? process.env.DEVTEAM_PROJECT ?? path.join(devteamDir, 'project'));
Expand Down Expand Up @@ -259,7 +280,7 @@ function loadSettings() {
let ladderCache;
function ladder() {
if (ladderCache) return ladderCache;
const m = readText(path.join(devteamDir, 'governance', 'constitution.md'))?.match(/```json devteam\s*\n([\s\S]*?)\n```/);
const m = readText(path.join(kitDir, 'governance', 'constitution.md'))?.match(/```json devteam\s*\n([\s\S]*?)\n```/);
let a = { classes: {}, constitutional: [], owner_only: [], reasons: [], promotion: {} };
if (m) {
try {
Expand All @@ -279,7 +300,7 @@ const RUNG = { 1: 'ask', 2: 'ask with a default', 3: 'act and report' };

// ===== 3. the schema and the log =====

const SCHEMA = readJson(path.join(devteamDir, 'schema', 'log.schema.json'));
const SCHEMA = readJson(path.join(kitDir, 'schema', 'log.schema.json'));
const ENVELOPE = ['v', 'id', 'type', 'op', 'at', 'by', 'channel'];
const LINE_MAX = 4096;
const TYPES = Object.keys(SCHEMA.$defs.types);
Expand Down Expand Up @@ -2065,6 +2086,26 @@ function checkPr({ headRef, sha, title, body, forge = false }) {
// A gate run for a ticket's merge knows which ticket it checks (DEVTEAM_GATE_TICKET), so validate
// can fail it only for what is its own (D4 below).
const gateEnv = (ticket) => { const cache = path.join(runDir, 'cache'); fs.mkdirSync(cache, { recursive: true }); return { ...process.env, DEVTEAM_MAIN: mainRoot, DEVTEAM_CACHE: cache, ...(ticket ? { DEVTEAM_GATE_TICKET: ticket } : {}) }; };
// The tools .tool-versions pins that this machine's asdf or mise hasn't installed. On a new machine
// worktree_setup otherwise fails under whatever runtime the system has, which reads as a project bug.
// Empty when there is no .tool-versions or neither manager is here (there is nothing to ask).
function missingPins() {
const pins = (readText(path.join(mainRoot, '.tool-versions')) ?? '').split('\n').map((l) => l.replace(/#.*/, '').trim().split(/\s+/)).filter((p) => p[0] && p[1] && !['system', 'latest'].includes(p[1]));
// The first manager that is installed answers for every pin; one that isn't (ENOENT) passes to the
// next. A probe that times out on a loaded machine proves nothing, so it counts as installed.
for (const [bin, args] of [['asdf', (t, v) => ['where', t, v]], ['mise', (t, v) => ['where', `${t}@${v}`]]]) {
const missing = [];
let absent = false;
for (const [t, v] of pins) {
const r = spawnSync(bin, args(t, v), { cwd: mainRoot, stdio: 'ignore', timeout: 60000 });
if (r.error?.code === 'ENOENT') { absent = true; break; }
if (!r.error && r.status !== 0) missing.push(`${t} ${v}`);
}
if (!absent) return missing;
}
return [];
}
const pinsHint = (miss = missingPins()) => (miss.length ? `this machine lacks the tools .tool-versions pins (${miss.join(', ')}): run asdf install (or mise install) in ${mainRoot}, then try again` : null);
function seedWorktree(wt, { setup = true } = {}) {
const s = loadSettings();
for (const rel of s.worktree_files ?? []) {
Expand All @@ -2077,7 +2118,8 @@ function seedWorktree(wt, { setup = true } = {}) {
try {
execFileSync('sh', ['-c', s.worktree_setup], { cwd: wt, stdio: ['ignore', 'inherit', 'inherit'], env: gateEnv() });
} catch {
die(`worktree_setup failed in ${wt}`);
const hint = pinsHint();
die(`worktree_setup failed in ${wt}${hint ? `; ${hint}` : ''}`);
}
}
}
Expand Down Expand Up @@ -5162,7 +5204,7 @@ function cmdDashboard() {
process.on('uncaughtException', (e) => { log(`crashed: ${e?.stack ?? e}`); process.stderr.write(`devteam dashboard crashed: ${e?.stack ?? e}\n`); process.exit(1); });
process.on('unhandledRejection', (e) => { log(`crashed (unhandled rejection): ${e?.stack ?? e}`); process.stderr.write(`devteam dashboard crashed: ${e?.stack ?? e}\n`); process.exit(1); });
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(sig, () => { log(`stopped by ${sig}`); process.exit(0); });
const page = path.join(devteamDir, 'dashboard', 'index.html');
const page = path.join(kitDir, 'dashboard', 'index.html');
const self = fileURLToPath(import.meta.url);
const ownerEnv = { ...process.env, DEVTEAM_CHANNEL: 'dashboard' };
delete ownerEnv.DEVTEAM_ROLE;
Expand Down Expand Up @@ -5273,7 +5315,7 @@ function cmdDashboard() {
if (seg !== 'preview' && !ID.test(seg)) return send(res, 404, 'text/plain', 'not found');
if (file === 'devteam-ui.css' || file === 'devteam-ui.js') {
res.writeHead(200, { 'content-type': file.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
return res.end(fs.readFileSync(path.join(devteamDir, 'dashboard', file)));
return res.end(fs.readFileSync(path.join(kitDir, 'dashboard', file)));
}
if (file !== '') return send(res, 404, 'text/plain', 'not found');
// Opened top-level it would be same-origin with the owner API; it is served only into a frame
Expand Down Expand Up @@ -6168,6 +6210,8 @@ function fleetUndo(mode) {
logCache = null;
out(`un${mode === 'park' ? 'parked' : 'drained'} the fleet${op ? ` (${op})` : ''}${legacy ? `, and lifted the old drain ${legacy.id}` : ''}: ${restored.length ? restored.join('; ') : 'no goal to put back'}`);
const rows = restartCommands().map((x) => ` devteam.mjs ${x.cmd.padEnd(36)} ${x.handoff ? 'its handoff note goes into its prompt' : 'no current handoff note'}`);
const hint = rows.length ? pinsHint() : null;
if (hint) out(`before restarting anything: ${hint}`);
out(rows.length ? `restart the parked sessions that may run again, as the supervisor:\n${rows.join('\n')}` : 'no parked session to restart');
const stillParked = liveSessions().filter((x) => lastPhaseOf(x.name)?.to === 'parked' && ['park', 'let-go'].includes(sessionMode(x.name))).map((x) => x.name);
if (stillParked.length) out(`still parked, because their goal is: ${stillParked.join(', ')}`);
Expand All @@ -6194,7 +6238,7 @@ function drainedPreface() {
const here = liveHere && (!host || host === os.hostname());
return `The fleet is ${what}${host ? `, on ${host}` : ''}. ${here
? 'This is the machine it was parked on. If a move is under way, carry on with it (supervisor.md §12): node docs/devteam/scripts/devteam.mjs fleet status until it says "ready to move", landing the log with log-sync and merge --chore log; start nothing. Run fleet unpark here only if the owner says to resume on this machine.'
: 'This looks like the machine it moved to. Before anything else: (1) DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs fleet unpark (it rebuilds run/log from the landed log, prints the parity fingerprint, puts every goal back as it was and prints the restart command for each parked session); (2) run each of those commands; (3) make sure the dashboard is running (supervisor.md §11) and tell the owner its address; then carry on from supervisor.md §1.'}`;
: `This looks like the machine it moved to. Before anything else: ${(() => { const h = pinsHint(); return h ? `(0) ${h}; ` : ''; })()}(1) DEVTEAM_ROLE=supervisor node docs/devteam/scripts/devteam.mjs fleet unpark (it rebuilds run/log from the landed log, prints the parity fingerprint, puts every goal back as it was and prints the restart command for each parked session); (2) run each of those commands; (3) make sure the dashboard is running (supervisor.md §11) and tell the owner its address; then carry on from supervisor.md §1.`}`;
}

// wait [--minutes n]: block between ticks until something needs the supervisor, then say what.
Expand Down
28 changes: 23 additions & 5 deletions test/kit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import assert from 'node:assert/strict';
import { execFileSync, spawn, spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
Expand All @@ -23,6 +24,9 @@ process.env.TMUX_TMPDIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dt-tmux-'));
delete process.env.TMUX;
delete process.env.TMUX_PANE;
const hasTmux = spawnSync('tmux', ['-V']).status === 0;
// A port the OS says is free: a random one in a range can land on a machine's own listener (macOS's
// AirPlay receiver holds 5000 and answers 403), which then passes the test's startup probe.
const freePort = () => new Promise((resolve, reject) => { const v = net.createServer(); v.on('error', reject); v.listen(0, '127.0.0.1', () => { const { port } = v.address(); v.close(() => resolve(port)); }); });

function dt(args, { cwd = repo, role = 'owner', input } = {}) {
const env = { ...process.env, ...ROLE[role] };
Expand Down Expand Up @@ -1395,7 +1399,7 @@ test('devteam kit', async (t) => {
assert.match(must(['questions', '--id', 'q-20991231-235959000-supervisor', '--json']), /an unreadable sink/);
must(['dashboard-data']);
// the dashboard: browser headers required, a dead child doesn't kill the server, bytes decode whole
const port = 4400 + Math.floor(Math.random() * 500);
const port = await freePort();
const srv = spawn(process.execPath, [DT, 'dashboard', '--port', String(port)], { cwd: repo, env: { ...process.env, DEVTEAM_ROLE: '' }, stdio: 'ignore' });
try {
for (let i = 0; i < 50; i++) { try { await fetch(`http://127.0.0.1:${port}/`); break; } catch { await new Promise((r) => setTimeout(r, 100)); } }
Expand Down Expand Up @@ -1948,7 +1952,7 @@ test('devteam kit', async (t) => {
fs.appendFileSync(supFile, line({ ...askLine, id: Qt, at: '2099-12-31T23:59:58.000Z', review: { ...askLine.review, page: { ...askLine.review.page, sha256: sha('another page') } } }));
const view = (id) => json(must(['questions', '--id', id, '--json']))[0];

const port = 4900 + Math.floor(Math.random() * 500);
const port = await freePort();
const B = `http://127.0.0.1:${port}`;
const srv = spawn(process.execPath, [DT, 'dashboard', '--port', String(port)], { cwd: repo, env: { ...process.env, DEVTEAM_ROLE: '' }, stdio: 'ignore' });
try {
Expand Down Expand Up @@ -2236,7 +2240,7 @@ test('devteam kit', async (t) => {
assert.equal(sha(must(['review', 'result', Qp, '--json'], { role: 'sup' }) + '\n'), ownerSha, 'the result still reads');
// the dashboard serves the landed page from main, byte for byte
{
const port = 4900 + Math.floor(Math.random() * 500);
const port = await freePort();
const B = `http://127.0.0.1:${port}`;
const srv = spawn(process.execPath, [DT, 'dashboard', '--port', String(port)], { cwd: repo, env: { ...process.env, DEVTEAM_ROLE: '' }, stdio: 'ignore' });
try {
Expand Down Expand Up @@ -2380,7 +2384,12 @@ test('fleet park: every session stops with a handoff, and a clone elsewhere pick
// the worker does what the ruling says: commit, park without a handoff first (not ready), then the handoff
g(['add', '-A'], ww);
g(['commit', '-qm', 'WIP: park'], ww);
assert.match(run(ww, ['handoff'], WRK, '').err, /the note is empty/);
// a branch that forked before a kit upgrade carries an older script: the main checkout's runs
const wtScript = path.join(ww, 'docs/devteam/scripts/devteam.mjs');
fs.writeFileSync(wtScript, fs.readFileSync(wtScript, 'utf8').replace('handoff: cmdHandoff, ', ''));
assert.match(run(ww, ['handoff'], { ...WRK, DEVTEAM_OWN_KIT: '1' }, '').err, /unknown command "handoff"/, 'the stale copy lacks the command');
assert.match(run(ww, ['handoff'], WRK, '').err, /the note is empty/, 'the main checkout\'s kit ran');
g(['checkout', '--', 'docs/devteam/scripts/devteam.mjs'], ww);
ok(ww, ['phase', 'parked', '--note', 'parked'], WRK);
st = json(ok(old, ['fleet', 'status', '--json'], SUP));
assert.deepEqual(st.sessions.map((x) => [x.parked, x.pushed, x.clean, x.handoff]), [[true, false, true, false]]);
Expand Down Expand Up @@ -2433,7 +2442,16 @@ test('fleet park: every session stops with a handoff, and a clone elsewhere pick
for (const [k, v] of [['user.email', 't@t'], ['user.name', 't']]) g(['config', k, v], neu);
assert.ok(!fs.existsSync(path.join(neu, 'docs/devteam/run/log')));
assert.match(ok(neu, ['prompt', 'supervisor']), /^The fleet is parked .*This looks like the machine it moved to\. Before anything else: \(1\) DEVTEAM_ROLE=supervisor node docs\/devteam\/scripts\/devteam\.mjs fleet unpark/, 'the standard prompt sees the parked fleet, and that this is a fresh clone');
const up = ok(neu, ['fleet', 'resume'], SUP);
// a pinned tool this machine's asdf hasn't installed is named before anything restarts
const fakeBin = path.join(base, 'bin');
write(path.join(fakeBin, 'asdf'), '#!/bin/sh\n[ "$1" = where ] && [ "$3" = 9.9.9 ] && exit 1\nexit 0\n');
fs.chmodSync(path.join(fakeBin, 'asdf'), 0o755);
write(path.join(neu, '.tool-versions'), 'nodejs 18.20.4\nruby 9.9.9 # pinned\n');
const PINS = { PATH: `${fakeBin}${path.delimiter}${process.env.PATH}` };
assert.match(ok(neu, ['prompt', 'supervisor'], PINS), /Before anything else: \(0\) this machine lacks the tools \.tool-versions pins \(ruby 9\.9\.9\): run asdf install/);
const up = ok(neu, ['fleet', 'resume'], { ...SUP, ...PINS });
assert.match(up, /before restarting anything: this machine lacks the tools \.tool-versions pins \(ruby 9\.9\.9\)/);
fs.rmSync(path.join(neu, '.tool-versions'));
assert.match(up, /run\/log was empty: rebuilt \d+ file\(s\)/);
assert.equal(/parity: ([0-9a-f]{16})/.exec(up)?.[1], oldParity, 'the new machine holds exactly the log the old one did');
assert.match(up, /## Handoff from the previous supervisor[\s\S]*Next: restart ch-01-tidy\./);
Expand Down
Loading