@stravica-ai/digital-operator-pa 0.1.68 → 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.
@@ -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[] whose
15
- // ids holds the dispatch id; a mention in a title does not count);
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
- resolveAgentName,
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 (an item whose ids hold the dispatch id)
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(boardPath(repoRoot, agent), { strictStatus: false });
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
- const agent = flags.agent ?? resolveAgentName(repoRoot);
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 write and read the daily
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 from its group to done;
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. A failed board patch never undoes the
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
- resolveAgentName,
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) or 'lost' (the spawn-log lock was lost before the board's
159
- * rename, so the fence skipped the write). `onBoard` says whether a group item holds the id, when
160
- * the board parsed.
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 = pickAgent(flags.agent, repoRoot);
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: boardPath(repoRoot, agent),
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
- function pickAgent(flag, repoRoot) {
261
- if (flag === undefined) return resolveAgentName(repoRoot);
262
- if (!isValidAgentName(flag)) throw new UsageError('--agent must be a lowercase agent folder name, got "' + flag + '"');
263
- return flag;
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 is missing, not JSON, off
297
- * the schema, the write is refused, or the lock was lost.
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 && Array.isArray(board.groups)) && itemsWithId({ groups: board.groups.filter((g) => g && typeof g === 'object') }, id).length > 0;
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
- board.groups.forEach((g, gi) => (Array.isArray(g.items) ? g.items : []).forEach((it, ii) => {
390
- if (Array.isArray(it.ids) && it.ids.includes(id)) hits.push({ gi, ii, item: it });
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
- /** Take the dispatch's items out of their groups and add one done entry. */
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 { gi, ii } of [...hits].reverse()) board.groups[gi].items.splice(ii, 1);
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;