diff --git a/docs/devteam/scripts/devteam.mjs b/docs/devteam/scripts/devteam.mjs index bffbe3b..d9a5876 100644 --- a/docs/devteam/scripts/devteam.mjs +++ b/docs/devteam/scripts/devteam.mjs @@ -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; }; @@ -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')); @@ -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 { @@ -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); @@ -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 ?? []) { @@ -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}` : ''}`); } } } @@ -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; @@ -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 @@ -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(', ')}`); @@ -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. diff --git a/test/kit.test.mjs b/test/kit.test.mjs index 871ae57..de3edfb 100644 --- a/test/kit.test.mjs +++ b/test/kit.test.mjs @@ -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'; @@ -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] }; @@ -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)); } } @@ -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 { @@ -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 { @@ -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]]); @@ -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\./);