@stravica-ai/digital-operator-pa 0.1.69 → 0.1.70
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/src/pa/board-check.mjs +24 -9
- 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 +515 -42
- package/dist/engineering-pa-cli/src/pa/status-board.mjs +529 -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/status-board.html +536 -250
- package/dist/engineering-pa-workspace/src/api.mjs +178 -12
- package/package.json +1 -1
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
// One problem line each for:
|
|
12
12
|
// - the board's updatedAt 90 minutes old or more (the Status page's red
|
|
13
13
|
// 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
|
-
//
|
|
14
|
+
// - an open dispatch with no board item (an item in groups[].items[], or
|
|
15
|
+
// on a schema v2 board a lane's running item or one of its queued items,
|
|
16
|
+
// any lane, paused or not, whose ids holds the dispatch id; a mention in
|
|
17
|
+
// a title does not count, nor an id held only by onMe, forMe or done);
|
|
16
18
|
// - an open dispatch that has been open 24 to 48 hours (close it or check it);
|
|
17
19
|
// - the spawn-log lock held for longer than the stale window
|
|
18
20
|
// (LOCK_STALE_AFTER_MS, one minute): the line leads with
|
|
@@ -60,11 +62,10 @@ import {
|
|
|
60
62
|
|
|
61
63
|
import { getRepoRoot } from './repo-root.mjs';
|
|
62
64
|
import {
|
|
63
|
-
boardPath,
|
|
64
65
|
boardRelPath,
|
|
65
66
|
isValidAgentName,
|
|
66
67
|
readBoard,
|
|
67
|
-
|
|
68
|
+
resolveAgentBoard,
|
|
68
69
|
} from './status-board.mjs';
|
|
69
70
|
|
|
70
71
|
export const BOARD_STALE_MINUTES = 90;
|
|
@@ -79,7 +80,8 @@ Usage:
|
|
|
79
80
|
|
|
80
81
|
Reports, one line each:
|
|
81
82
|
- the board not updated for ${BOARD_STALE_MINUTES} minutes (or missing) while a dispatch is open
|
|
82
|
-
- an open dispatch with no board item (
|
|
83
|
+
- an open dispatch with no board item (a group item, or a lane's running or
|
|
84
|
+
queued item, whose ids hold the dispatch id)
|
|
83
85
|
- a dispatch open 24 to 48 hours (close it or check it)
|
|
84
86
|
- the spawn-log lock held for more than ${LOCK_STALE_AFTER_MS / 60000} minute, naming its holder and
|
|
85
87
|
pa dispatch unlock (writers wait on it and then fail)
|
|
@@ -173,6 +175,11 @@ function boardIdSet(board, key) {
|
|
|
173
175
|
};
|
|
174
176
|
if (key === 'groups') {
|
|
175
177
|
for (const g of Array.isArray(board.groups) ? board.groups : []) if (isPlainObject(g)) add(g.items);
|
|
178
|
+
for (const l of Array.isArray(board.lanes) ? board.lanes : []) {
|
|
179
|
+
if (!isPlainObject(l)) continue;
|
|
180
|
+
add([l.running]);
|
|
181
|
+
add(l.queued);
|
|
182
|
+
}
|
|
176
183
|
} else {
|
|
177
184
|
add(board.onMe);
|
|
178
185
|
}
|
|
@@ -522,7 +529,7 @@ function parseFlags(args) {
|
|
|
522
529
|
return flags;
|
|
523
530
|
}
|
|
524
531
|
|
|
525
|
-
async function gather(repoRoot, agent) {
|
|
532
|
+
async function gather(repoRoot, agent, boardFile) {
|
|
526
533
|
const spawnLogFile = join(repoRoot, 'agents', agent, 'spawn-log.json');
|
|
527
534
|
let log;
|
|
528
535
|
try {
|
|
@@ -535,7 +542,7 @@ async function gather(repoRoot, agent) {
|
|
|
535
542
|
}
|
|
536
543
|
let parsed;
|
|
537
544
|
try {
|
|
538
|
-
parsed = await readBoard(
|
|
545
|
+
parsed = await readBoard(boardFile, { strictStatus: false });
|
|
539
546
|
} catch (e) {
|
|
540
547
|
if (e instanceof SyntaxError) throw new PreconditionError('board at ' + boardRelPath(agent) + ' is not valid JSON: ' + e.message);
|
|
541
548
|
throw new PreconditionError('could not read the board at ' + boardRelPath(agent) + ': ' + (e.message || e));
|
|
@@ -585,10 +592,18 @@ export async function run(argv, opts = {}) {
|
|
|
585
592
|
throw new PreconditionError(e.message || String(e));
|
|
586
593
|
}
|
|
587
594
|
}
|
|
588
|
-
|
|
595
|
+
// The one agent-board resolver every board reader and writer uses: a bad
|
|
596
|
+
// name or a board path that leads out of agents/<agent>/workspace/ is a
|
|
597
|
+
// usage error before anything is read.
|
|
598
|
+
const resolved = resolveAgentBoard(repoRoot, flags.agent);
|
|
599
|
+
if (!resolved.ok) {
|
|
600
|
+
throw new UsageError(resolved.message + (flags.agent === undefined ? ' (the agent from preferences)' : '')
|
|
601
|
+
+ ', got "' + resolved.agent + '"; nothing was read');
|
|
602
|
+
}
|
|
603
|
+
const agent = resolved.agent;
|
|
589
604
|
let input;
|
|
590
605
|
try {
|
|
591
|
-
input = await gather(repoRoot, agent);
|
|
606
|
+
input = await gather(repoRoot, agent, resolved.file);
|
|
592
607
|
} catch (e) {
|
|
593
608
|
if (e instanceof PreconditionError && flags.json) {
|
|
594
609
|
out.write(JSON.stringify({ ok: false, exitCode: 3, agent, error: e.message }, null, 2) + '\n');
|
|
@@ -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;
|