@stravica-ai/digital-operator-pa 0.1.69 → 0.1.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/engineering-pa-cli/bin/pa +18 -1
- package/dist/engineering-pa-cli/src/pa/board-check.mjs +82 -12
- package/dist/engineering-pa-cli/src/pa/cli.mjs +5 -2
- package/dist/engineering-pa-cli/src/pa/dispatch-cli.mjs +76 -21
- package/dist/engineering-pa-cli/src/pa/status-board-cli.mjs +532 -44
- package/dist/engineering-pa-cli/src/pa/status-board.mjs +587 -17
- package/dist/engineering-pa-core/knowledge/work-management/status-board.md +26 -6
- package/dist/engineering-pa-scaffold/src/generator.mjs +6 -0
- package/dist/engineering-pa-workspace/README.md +8 -6
- package/dist/engineering-pa-workspace/app/banner.js +11 -10
- package/dist/engineering-pa-workspace/app/status-board.html +536 -250
- package/dist/engineering-pa-workspace/src/api.mjs +178 -12
- package/package.json +1 -1
|
@@ -1,2 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
// Each verb returns its exit code; the process ends only once stdout and
|
|
3
|
+
// stderr have taken every byte. A write to a pipe is asynchronous on some
|
|
4
|
+
// platforms, so a process.exit straight after main returned cut a long
|
|
5
|
+
// output at the pipe's buffer (a board piped to `wc -c` gave 65536 bytes).
|
|
6
|
+
// An empty write's callback runs after every write queued before it, and
|
|
7
|
+
// process.exit then still ends a verb that left a handle open.
|
|
8
|
+
import('../src/pa/cli.mjs').then(async (m) => {
|
|
9
|
+
const code = await m.main(process.argv.slice(2));
|
|
10
|
+
process.exitCode = code;
|
|
11
|
+
let open = 2;
|
|
12
|
+
const flushed = () => { open -= 1; if (open === 0) process.exit(code); };
|
|
13
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
14
|
+
// A reader that closes early (EPIPE) must not turn into a crash.
|
|
15
|
+
stream.on('error', () => {});
|
|
16
|
+
let called = false;
|
|
17
|
+
stream.write('', () => { if (!called) { called = true; flushed(); } });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
// when the instance keeps initiatives records, with the open questions under
|
|
7
7
|
// initiatives/<id>/questions/. It writes nothing anywhere: no cache, no lock.
|
|
8
8
|
// It reads the spawn-log's lock file (agents/<agent>/spawn-log.json.lock) and
|
|
9
|
-
// never writes, breaks
|
|
9
|
+
// the board's lock file (the board path plus .lock) and never writes, breaks
|
|
10
|
+
// or sweeps either.
|
|
10
11
|
//
|
|
11
12
|
// One problem line each for:
|
|
12
13
|
// - the board's updatedAt 90 minutes old or more (the Status page's red
|
|
13
14
|
// threshold) while a dispatch is open, or no board at all while one is;
|
|
14
|
-
// - an open dispatch with no board item (an item in groups[].items[]
|
|
15
|
-
//
|
|
15
|
+
// - an open dispatch with no board item (an item in groups[].items[], or
|
|
16
|
+
// on a schema v2 board a lane's running item or one of its queued items,
|
|
17
|
+
// any lane, paused or not, whose ids holds the dispatch id; a mention in
|
|
18
|
+
// a title does not count, nor an id held only by onMe, forMe or done);
|
|
16
19
|
// - an open dispatch that has been open 24 to 48 hours (close it or check it);
|
|
17
20
|
// - the spawn-log lock held for longer than the stale window
|
|
18
21
|
// (LOCK_STALE_AFTER_MS, one minute): the line leads with
|
|
@@ -31,6 +34,14 @@
|
|
|
31
34
|
// `unrecognised status` warnings: the human output gives one line per word
|
|
32
35
|
// with its count and at most five ids, `--json` lists every one. They never
|
|
33
36
|
// change the exit code.
|
|
37
|
+
// A board lock held by a live pid on this host whose lock file is more than
|
|
38
|
+
// BOARD_LOCK_LONG_HELD_MS (10 minutes) old by the check's clock is one more
|
|
39
|
+
// warning: one line naming the board, the pid, the age and the manual clear
|
|
40
|
+
// (`rm <lock path>` once the pid is checked not to be a board writer), the
|
|
41
|
+
// `boardLockWarning` object in `--json` (present only when it fires) and a
|
|
42
|
+
// warning line in `--hook-envelope`. Such a lock is never broken, by the
|
|
43
|
+
// check or by a writer. A foreign-host or ownerless board lock gives no
|
|
44
|
+
// warning: the next writer breaks it as stale after BOARD_LOCK_STALE_MS.
|
|
34
45
|
//
|
|
35
46
|
// Exit codes: 0 clean | 1 usage error | 2 one or more problems |
|
|
36
47
|
// 3 the check could not run (no repo root, the spawn-log or the board
|
|
@@ -60,11 +71,15 @@ import {
|
|
|
60
71
|
|
|
61
72
|
import { getRepoRoot } from './repo-root.mjs';
|
|
62
73
|
import {
|
|
63
|
-
|
|
74
|
+
BOARD_LOCK_LONG_HELD_MS,
|
|
75
|
+
BOARD_LOCK_WAIT_MS,
|
|
64
76
|
boardRelPath,
|
|
77
|
+
formatLockAge,
|
|
78
|
+
inspectBoardLock,
|
|
79
|
+
isLongHeldBoardLock,
|
|
65
80
|
isValidAgentName,
|
|
66
81
|
readBoard,
|
|
67
|
-
|
|
82
|
+
resolveAgentBoard,
|
|
68
83
|
} from './status-board.mjs';
|
|
69
84
|
|
|
70
85
|
export const BOARD_STALE_MINUTES = 90;
|
|
@@ -79,7 +94,8 @@ Usage:
|
|
|
79
94
|
|
|
80
95
|
Reports, one line each:
|
|
81
96
|
- the board not updated for ${BOARD_STALE_MINUTES} minutes (or missing) while a dispatch is open
|
|
82
|
-
- an open dispatch with no board item (
|
|
97
|
+
- an open dispatch with no board item (a group item, or a lane's running or
|
|
98
|
+
queued item, whose ids hold the dispatch id)
|
|
83
99
|
- a dispatch open 24 to 48 hours (close it or check it)
|
|
84
100
|
- the spawn-log lock held for more than ${LOCK_STALE_AFTER_MS / 60000} minute, naming its holder and
|
|
85
101
|
pa dispatch unlock (writers wait on it and then fail)
|
|
@@ -87,6 +103,9 @@ Reports, one line each:
|
|
|
87
103
|
dispatch with no stage, and an open question with no on-me entry
|
|
88
104
|
Unrecognised status or outcome words are warnings (exit 0): one line per word,
|
|
89
105
|
with its count and at most 5 ids; --json lists every one.
|
|
106
|
+
A board lock held by a live process on this host for more than
|
|
107
|
+
${BOARD_LOCK_LONG_HELD_MS / 60000} minutes is a warning (exit 0) naming its pid, its age and the
|
|
108
|
+
manual clear; nothing breaks it.
|
|
90
109
|
|
|
91
110
|
Flags:
|
|
92
111
|
--json One JSON object on stdout
|
|
@@ -94,7 +113,8 @@ Flags:
|
|
|
94
113
|
--agent <name> The agent folder (default: preferences paName, else the
|
|
95
114
|
single agents/* folder holding next-session.md)
|
|
96
115
|
|
|
97
|
-
Read-only: never writes a file, and never breaks or removes the spawn-log lock
|
|
116
|
+
Read-only: never writes a file, and never breaks or removes the spawn-log lock
|
|
117
|
+
or the board lock.
|
|
98
118
|
|
|
99
119
|
Exit codes:
|
|
100
120
|
0 clean | 1 usage error | 2 problems found | 3 the check could not run
|
|
@@ -173,6 +193,11 @@ function boardIdSet(board, key) {
|
|
|
173
193
|
};
|
|
174
194
|
if (key === 'groups') {
|
|
175
195
|
for (const g of Array.isArray(board.groups) ? board.groups : []) if (isPlainObject(g)) add(g.items);
|
|
196
|
+
for (const l of Array.isArray(board.lanes) ? board.lanes : []) {
|
|
197
|
+
if (!isPlainObject(l)) continue;
|
|
198
|
+
add([l.running]);
|
|
199
|
+
add(l.queued);
|
|
200
|
+
}
|
|
176
201
|
} else {
|
|
177
202
|
add(board.onMe);
|
|
178
203
|
}
|
|
@@ -191,6 +216,9 @@ function boardIdSet(board, key) {
|
|
|
191
216
|
* @param {object|null} [input.lock] the spawn-log lock as read (readSpawnLogLock),
|
|
192
217
|
* or null/absent when there is none or it was not read
|
|
193
218
|
* @param {string} [input.lockRelPath] the lock's path for the message
|
|
219
|
+
* @param {object|null} [input.boardLock] the board lock as inspected (inspectBoardLock),
|
|
220
|
+
* or null/absent when there is none or it was not read
|
|
221
|
+
* @param {string} [input.boardRelPath] the board's path for the board-lock warning
|
|
194
222
|
* @param {Date|number|Function} [input.now]
|
|
195
223
|
*/
|
|
196
224
|
export function checkBoard(input = {}) {
|
|
@@ -303,6 +331,31 @@ export function checkBoard(input = {}) {
|
|
|
303
331
|
openNotOnBoard,
|
|
304
332
|
problems,
|
|
305
333
|
unrecognisedStatuses,
|
|
334
|
+
boardLockWarning: boardLockWarning(input.boardLock, input.boardRelPath, nowMs),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The board-lock warning, or null: a lock held by a live pid on this host
|
|
340
|
+
* whose file is more than BOARD_LOCK_LONG_HELD_MS old at nowMs. The message
|
|
341
|
+
* names the board, the pid, the age and the manual clear, and says nothing
|
|
342
|
+
* breaks it. A warning: it never changes the exit code.
|
|
343
|
+
*/
|
|
344
|
+
export function boardLockWarning(lock, boardRel, nowMs) {
|
|
345
|
+
if (!isPlainObject(lock) || !isLongHeldBoardLock(lock, nowMs)) return null;
|
|
346
|
+
const board = typeof boardRel === 'string' && boardRel ? boardRel : 'the status board';
|
|
347
|
+
const lockPath = board === 'the status board' ? 'the status board lock' : board + '.lock';
|
|
348
|
+
const ageMs = nowMs - lock.mtimeMs;
|
|
349
|
+
const age = formatLockAge(ageMs);
|
|
350
|
+
return {
|
|
351
|
+
kind: 'board-lock-held',
|
|
352
|
+
boardPath: board,
|
|
353
|
+
lockPath,
|
|
354
|
+
pid: lock.pid,
|
|
355
|
+
ageMinutes: Math.floor(ageMs / 60000),
|
|
356
|
+
message: 'board lock on ' + board + ' held by live pid ' + lock.pid + ' for ' + age
|
|
357
|
+
+ '; writers wait ' + (BOARD_LOCK_WAIT_MS / 1000) + ' s, then fail, and nothing breaks it.'
|
|
358
|
+
+ ' Once you have checked pid ' + lock.pid + ' is not a board writer, clear it with `rm ' + lockPath + '`',
|
|
306
359
|
};
|
|
307
360
|
}
|
|
308
361
|
|
|
@@ -351,7 +404,7 @@ function summaryLine(agent, result) {
|
|
|
351
404
|
const live = result.open.live.length;
|
|
352
405
|
const stale = result.open.stale.length;
|
|
353
406
|
const nProblems = result.problems.length;
|
|
354
|
-
const nWarnings = result.unrecognisedStatuses.length;
|
|
407
|
+
const nWarnings = result.unrecognisedStatuses.length + (result.boardLockWarning ? 1 : 0);
|
|
355
408
|
const head = nProblems === 0
|
|
356
409
|
? 'board check: clean for ' + agent
|
|
357
410
|
: 'board check: ' + plural(nProblems, 'problem', 'problems') + ' for ' + agent;
|
|
@@ -395,6 +448,7 @@ export function warningLines(unrecognisedStatuses) {
|
|
|
395
448
|
export function renderHuman(agent, result) {
|
|
396
449
|
const lines = [];
|
|
397
450
|
for (const p of result.problems) lines.push('problem: ' + p.message);
|
|
451
|
+
if (result.boardLockWarning) lines.push('warning: ' + result.boardLockWarning.message);
|
|
398
452
|
lines.push(...warningLines(result.unrecognisedStatuses));
|
|
399
453
|
lines.push(summaryLine(agent, result));
|
|
400
454
|
return lines.join('\n') + '\n';
|
|
@@ -467,6 +521,7 @@ export function buildHookEnvelope(agent, result, hasInitiatives) {
|
|
|
467
521
|
});
|
|
468
522
|
const problemLines = result.problems.map((p) => '- ' + oneLine(p.message, p.kind === 'lock-held' ? 360 : 160));
|
|
469
523
|
const footer = [];
|
|
524
|
+
if (result.boardLockWarning) footer.push('Warning: ' + oneLine(result.boardLockWarning.message, 360));
|
|
470
525
|
if (result.unrecognisedStatuses.length) {
|
|
471
526
|
footer.push('Unrecognised status words: ' + result.unrecognisedStatuses.length + ' (warnings; `pa board check` lists them).');
|
|
472
527
|
}
|
|
@@ -522,7 +577,7 @@ function parseFlags(args) {
|
|
|
522
577
|
return flags;
|
|
523
578
|
}
|
|
524
579
|
|
|
525
|
-
async function gather(repoRoot, agent) {
|
|
580
|
+
async function gather(repoRoot, agent, boardFile) {
|
|
526
581
|
const spawnLogFile = join(repoRoot, 'agents', agent, 'spawn-log.json');
|
|
527
582
|
let log;
|
|
528
583
|
try {
|
|
@@ -535,7 +590,7 @@ async function gather(repoRoot, agent) {
|
|
|
535
590
|
}
|
|
536
591
|
let parsed;
|
|
537
592
|
try {
|
|
538
|
-
parsed = await readBoard(
|
|
593
|
+
parsed = await readBoard(boardFile, { strictStatus: false });
|
|
539
594
|
} catch (e) {
|
|
540
595
|
if (e instanceof SyntaxError) throw new PreconditionError('board at ' + boardRelPath(agent) + ' is not valid JSON: ' + e.message);
|
|
541
596
|
throw new PreconditionError('could not read the board at ' + boardRelPath(agent) + ': ' + (e.message || e));
|
|
@@ -547,6 +602,9 @@ async function gather(repoRoot, agent) {
|
|
|
547
602
|
// Read-only: the lock file is read, never written, broken or swept.
|
|
548
603
|
let lock = null;
|
|
549
604
|
try { lock = await readSpawnLogLock(spawnLogFile); } catch { lock = null; }
|
|
605
|
+
// The board lock beside the board, read-only the same way.
|
|
606
|
+
let boardLock = null;
|
|
607
|
+
try { boardLock = await inspectBoardLock(boardFile); } catch { boardLock = null; }
|
|
550
608
|
return {
|
|
551
609
|
board: parsed ? parsed.board : null,
|
|
552
610
|
dispatches: log.dispatches,
|
|
@@ -554,6 +612,8 @@ async function gather(repoRoot, agent) {
|
|
|
554
612
|
hasInitiatives: initiatives.hasRecords,
|
|
555
613
|
lock,
|
|
556
614
|
lockRelPath: spawnLogRelPath(agent) + '.lock',
|
|
615
|
+
boardLock,
|
|
616
|
+
boardRelPath: boardRelPath(agent),
|
|
557
617
|
};
|
|
558
618
|
}
|
|
559
619
|
|
|
@@ -585,10 +645,18 @@ export async function run(argv, opts = {}) {
|
|
|
585
645
|
throw new PreconditionError(e.message || String(e));
|
|
586
646
|
}
|
|
587
647
|
}
|
|
588
|
-
|
|
648
|
+
// The one agent-board resolver every board reader and writer uses: a bad
|
|
649
|
+
// name or a board path that leads out of agents/<agent>/workspace/ is a
|
|
650
|
+
// usage error before anything is read.
|
|
651
|
+
const resolved = resolveAgentBoard(repoRoot, flags.agent);
|
|
652
|
+
if (!resolved.ok) {
|
|
653
|
+
throw new UsageError(resolved.message + (flags.agent === undefined ? ' (the agent from preferences)' : '')
|
|
654
|
+
+ ', got "' + resolved.agent + '"; nothing was read');
|
|
655
|
+
}
|
|
656
|
+
const agent = resolved.agent;
|
|
589
657
|
let input;
|
|
590
658
|
try {
|
|
591
|
-
input = await gather(repoRoot, agent);
|
|
659
|
+
input = await gather(repoRoot, agent, resolved.file);
|
|
592
660
|
} catch (e) {
|
|
593
661
|
if (e instanceof PreconditionError && flags.json) {
|
|
594
662
|
out.write(JSON.stringify({ ok: false, exitCode: 3, agent, error: e.message }, null, 2) + '\n');
|
|
@@ -612,6 +680,8 @@ export async function run(argv, opts = {}) {
|
|
|
612
680
|
problems: result.problems,
|
|
613
681
|
unrecognisedStatuses: result.unrecognisedStatuses,
|
|
614
682
|
};
|
|
683
|
+
// Present only when the warning fires, so a clean report keeps its shape.
|
|
684
|
+
if (result.boardLockWarning) payload.boardLockWarning = result.boardLockWarning;
|
|
615
685
|
out.write(JSON.stringify(payload, null, 2) + '\n');
|
|
616
686
|
return result.exitCode;
|
|
617
687
|
}
|
|
@@ -38,6 +38,9 @@ Usage:
|
|
|
38
38
|
pa status set [--file <p>] Replace this PA's daily status board from a JSON file or stdin
|
|
39
39
|
pa status patch [flags] Merge one board item or on-me entry, set statuses, append to done
|
|
40
40
|
pa status show [--json] Print the daily status board (never hand-edit its JSON)
|
|
41
|
+
pa status lane --key <k> Add or change a board lane (title, paused, its running item and queue)
|
|
42
|
+
pa status notice [flags] Add, change or dismiss a For-me notice (Risk, Money or Security)
|
|
43
|
+
pa status done [flags] Move a lane item to done, add a done entry, or prune old ones
|
|
41
44
|
pa board check [--json] [--hook-envelope] [--agent <n>]
|
|
42
45
|
Read-only: is the board current, and is every open dispatch on it?
|
|
43
46
|
Exit 0 clean | 1 usage error | 2 problems found | 3 could not run
|
|
@@ -121,8 +124,8 @@ export async function main(argv) {
|
|
|
121
124
|
return await doSession(rest);
|
|
122
125
|
case 'status':
|
|
123
126
|
// Bare `pa status` (and any argv that is not a board subverb) stays
|
|
124
|
-
// the environment probe; set | patch | show
|
|
125
|
-
// status board.
|
|
127
|
+
// the environment probe; set | patch | show | lane | notice | done
|
|
128
|
+
// write and read the daily status board.
|
|
126
129
|
if (STATUS_BOARD_SUBVERBS.includes(rest[0])) return await runStatusBoard(rest);
|
|
127
130
|
return await doStatus();
|
|
128
131
|
case 'board':
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// stage appends a checkpoint { at, line, stage } to the record, sets its
|
|
10
10
|
// stage, and refreshes the board item and the board's updatedAt;
|
|
11
11
|
// close sets completedAt and the closing outcome (a known closed word,
|
|
12
|
-
// done by default) and moves the item
|
|
12
|
+
// done by default) and moves the item to done, out of its group or
|
|
13
|
+
// its lane (running or queued), once;
|
|
13
14
|
// list prints the open dispatches by the shared rule and writes nothing;
|
|
14
15
|
// unlock prints the spawn-log lock's holder and removes the lock when its
|
|
15
16
|
// holder is provably dead (this host, pid gone), or with --force
|
|
@@ -23,7 +24,10 @@
|
|
|
23
24
|
//
|
|
24
25
|
// The spawn-log write and then the board patch run inside one hold of the
|
|
25
26
|
// spawn-log's lock (core spawn-log/write.mjs), so concurrent calls get
|
|
26
|
-
// distinct ids and lose no record and no board item.
|
|
27
|
+
// distinct ids and lose no record and no board item. The board patch also
|
|
28
|
+
// runs inside the board lock (status-board.mjs withBoardLock), the one
|
|
29
|
+
// `pa status` and the workspace dismiss route take, so no other board
|
|
30
|
+
// writer loses a change to it either. A failed board patch never undoes the
|
|
27
31
|
// spawn-log write: the record stays, stdout still prints what was written,
|
|
28
32
|
// stderr names the board error, the exit code is 2, and `pa board check`
|
|
29
33
|
// then names the dispatch as not on the board. A missing board is such a
|
|
@@ -64,13 +68,14 @@ import { dispatchLabel } from '../../../engineering-pa-core/src/dispatch/label.m
|
|
|
64
68
|
import { getRepoRoot } from './repo-root.mjs';
|
|
65
69
|
import {
|
|
66
70
|
BoardError,
|
|
71
|
+
BoardLockError,
|
|
67
72
|
STATUS_VALUES,
|
|
68
|
-
boardPath,
|
|
69
73
|
boardRelPath,
|
|
70
74
|
isValidAgentName,
|
|
71
75
|
readBoard,
|
|
72
|
-
|
|
76
|
+
resolveAgentBoard,
|
|
73
77
|
validateBoard,
|
|
78
|
+
withBoardLock,
|
|
74
79
|
writeBoardAtomic,
|
|
75
80
|
} from './status-board.mjs';
|
|
76
81
|
|
|
@@ -155,9 +160,12 @@ class ClosedDispatchError extends Error {
|
|
|
155
160
|
* A failed board patch. `state` is the board's: 'missing', 'unparsed' (not
|
|
156
161
|
* valid JSON), 'unreadable', 'structural' (off the schema's structure),
|
|
157
162
|
* 'status' (only a status value outside STATUS_VALUES), 'write' (the write
|
|
158
|
-
* itself failed)
|
|
159
|
-
* rename, so the fence skipped the write)
|
|
160
|
-
* the board
|
|
163
|
+
* itself failed), 'lost' (the spawn-log lock was lost before the board's
|
|
164
|
+
* rename, so the fence skipped the write), 'locked' (another board writer
|
|
165
|
+
* held the board lock for the whole wait, so the board was not read or
|
|
166
|
+
* written) or 'refused' (the agent-board resolver's fence, checked again
|
|
167
|
+
* inside the lock, refused the path, so the board was not read or written). `onBoard` says whether a board item (a group item, or a lane's
|
|
168
|
+
* running or queued item) holds the id, when the board parsed.
|
|
161
169
|
*/
|
|
162
170
|
class BoardPatchError extends OpError {
|
|
163
171
|
constructor(msg, state, onBoard = false) {
|
|
@@ -203,12 +211,12 @@ export async function run(argv, opts = {}) {
|
|
|
203
211
|
if (flags[k] === undefined || String(flags[k]).trim() === '') throw new UsageError('--' + k + ' is required');
|
|
204
212
|
}
|
|
205
213
|
const repoRoot = opts.repoRoot ?? await getRepoRoot();
|
|
206
|
-
const agent =
|
|
214
|
+
const { agent, file: board } = pickBoard(flags.agent, repoRoot);
|
|
207
215
|
const ctx = {
|
|
208
216
|
repoRoot,
|
|
209
217
|
agent,
|
|
210
218
|
spawnLog: join(repoRoot, 'agents', agent, 'spawn-log.json'),
|
|
211
|
-
board
|
|
219
|
+
board,
|
|
212
220
|
flags,
|
|
213
221
|
out,
|
|
214
222
|
err,
|
|
@@ -257,10 +265,21 @@ function parseFlags(args, allowed) {
|
|
|
257
265
|
return flags;
|
|
258
266
|
}
|
|
259
267
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
/**
|
|
269
|
+
* The agent and its board file, through the one resolver every board reader
|
|
270
|
+
* and writer uses (resolveAgentBoard in status-board.mjs): --agent when
|
|
271
|
+
* given, else the configured agent. A bad name or a board path that leads
|
|
272
|
+
* out of agents/<agent>/workspace/ is a usage error (exit 1) before the
|
|
273
|
+
* spawn-log or the board is read or written.
|
|
274
|
+
*/
|
|
275
|
+
function pickBoard(flag, repoRoot) {
|
|
276
|
+
if (flag !== undefined && !isValidAgentName(flag)) throw new UsageError('--agent must be a lowercase agent folder name, got "' + flag + '"');
|
|
277
|
+
const r = resolveAgentBoard(repoRoot, flag);
|
|
278
|
+
if (!r.ok) {
|
|
279
|
+
const from = flag === undefined ? ' (the agent from preferences)' : '';
|
|
280
|
+
throw new UsageError(r.message + from + ', got "' + r.agent + '"; nothing was read or written');
|
|
281
|
+
}
|
|
282
|
+
return r;
|
|
264
283
|
}
|
|
265
284
|
|
|
266
285
|
function stamp(now) {
|
|
@@ -293,10 +312,25 @@ export function stageText(stage) {
|
|
|
293
312
|
/**
|
|
294
313
|
* Load the board for a patch, apply `change(board)`, stamp updatedAt and
|
|
295
314
|
* write it atomically, with the spawn-log lock's `fence` run just before the
|
|
296
|
-
* board's rename. Throws OpError when the board
|
|
297
|
-
* the schema, the write is refused,
|
|
315
|
+
* board's rename, all inside the board lock. Throws OpError when the board
|
|
316
|
+
* is missing, not JSON, off the schema, the write is refused, the
|
|
317
|
+
* spawn-log lock was lost, or the board lock stayed held.
|
|
298
318
|
*/
|
|
299
319
|
async function patchBoard(ctx, id, change, fence) {
|
|
320
|
+
try {
|
|
321
|
+
return await withBoardLock(ctx.board, () => {
|
|
322
|
+
// The resolver's fence again, now the lock is held.
|
|
323
|
+
const again = resolveAgentBoard(ctx.repoRoot, ctx.agent);
|
|
324
|
+
if (!again.ok) throw new BoardPatchError(again.message + '; nothing was written', 'refused');
|
|
325
|
+
return patchBoardLocked(ctx, id, change, fence);
|
|
326
|
+
});
|
|
327
|
+
} catch (e) {
|
|
328
|
+
if (e instanceof BoardLockError) throw new BoardPatchError(e.message, 'locked');
|
|
329
|
+
throw e;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function patchBoardLocked(ctx, id, change, fence) {
|
|
300
334
|
let parsed;
|
|
301
335
|
try {
|
|
302
336
|
parsed = await readBoard(ctx.board, { strictStatus: true });
|
|
@@ -332,7 +366,7 @@ async function patchBoard(ctx, id, change, fence) {
|
|
|
332
366
|
}
|
|
333
367
|
|
|
334
368
|
function holdsId(board, id) {
|
|
335
|
-
return Boolean(board
|
|
369
|
+
return Boolean(board) && typeof board === 'object' && itemsWithId(board, id).length > 0;
|
|
336
370
|
}
|
|
337
371
|
|
|
338
372
|
/**
|
|
@@ -345,6 +379,8 @@ function boardPatchFailure(e, { id, agent, open }) {
|
|
|
345
379
|
let check;
|
|
346
380
|
if (e.state === 'unparsed' || e.state === 'unreadable' || e.state === 'structural') {
|
|
347
381
|
check = '`pa board check` exits 3 naming ' + file + ' until the board is fixed';
|
|
382
|
+
} else if (e.state === 'locked' || e.state === 'refused') {
|
|
383
|
+
check = 'the board at ' + file + ' was left as it was, so `pa board check` reads it unchanged';
|
|
348
384
|
} else if (!open) {
|
|
349
385
|
check = '`pa board check` does not name a closed dispatch';
|
|
350
386
|
} else if (e.onBoard) {
|
|
@@ -384,11 +420,23 @@ function refuseClosed(merged, id, verb, ctx) {
|
|
|
384
420
|
throw new ClosedDispatchError(id + ' is closed (' + why.join(', ') + '); pa dispatch ' + verb + ' refuses a closed dispatch and wrote nothing');
|
|
385
421
|
}
|
|
386
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Every board item holding the id: group items first ({ gi, ii, item }),
|
|
425
|
+
* then each lane's running item and queued items ({ lane, slot, item }).
|
|
426
|
+
* Safe on a board off the schema (anything not an object is passed over).
|
|
427
|
+
*/
|
|
387
428
|
function itemsWithId(board, id) {
|
|
388
429
|
const hits = [];
|
|
389
|
-
|
|
390
|
-
|
|
430
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
431
|
+
const holds = (it) => isObj(it) && Array.isArray(it.ids) && it.ids.includes(id);
|
|
432
|
+
(Array.isArray(board.groups) ? board.groups : []).forEach((g, gi) => (isObj(g) && Array.isArray(g.items) ? g.items : []).forEach((it, ii) => {
|
|
433
|
+
if (holds(it)) hits.push({ gi, ii, item: it });
|
|
391
434
|
}));
|
|
435
|
+
for (const lane of Array.isArray(board.lanes) ? board.lanes : []) {
|
|
436
|
+
if (!isObj(lane)) continue;
|
|
437
|
+
if (holds(lane.running)) hits.push({ lane, slot: 'running', item: lane.running });
|
|
438
|
+
for (const it of Array.isArray(lane.queued) ? lane.queued : []) if (holds(it)) hits.push({ lane, slot: 'queued', item: it });
|
|
439
|
+
}
|
|
392
440
|
return hits;
|
|
393
441
|
}
|
|
394
442
|
|
|
@@ -521,12 +569,19 @@ async function doClose(ctx) {
|
|
|
521
569
|
return result.after ? 0 : 2;
|
|
522
570
|
}
|
|
523
571
|
|
|
524
|
-
/**
|
|
572
|
+
/**
|
|
573
|
+
* Take the dispatch's items out of their groups and their lanes (running or
|
|
574
|
+
* queued) and add one done entry.
|
|
575
|
+
*/
|
|
525
576
|
function moveToDone(board, id, record, f, outcome, now) {
|
|
526
577
|
const hits = itemsWithId(board, id);
|
|
527
578
|
const base = hits.length ? hits[0].item : null;
|
|
528
|
-
const group = hits.length ? board.groups[hits[0].gi].label : null;
|
|
529
|
-
for (const
|
|
579
|
+
const group = hits.length && hits[0].lane === undefined ? board.groups[hits[0].gi].label : null;
|
|
580
|
+
for (const h of [...hits].reverse()) {
|
|
581
|
+
if (h.lane === undefined) board.groups[h.gi].items.splice(h.ii, 1);
|
|
582
|
+
else if (h.slot === 'running') delete h.lane.running;
|
|
583
|
+
else h.lane.queued.splice(h.lane.queued.indexOf(h.item), 1);
|
|
584
|
+
}
|
|
530
585
|
if (board.done.some((d) => Array.isArray(d.ids) && d.ids.includes(id))) return;
|
|
531
586
|
const entry = { title: base ? base.title : (str(record.label) || id), status: BOARD_DONE };
|
|
532
587
|
if (str(group)) entry.workstream = group;
|