@ucsandman/legcli 0.12.0 → 0.13.1

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.
@@ -1,11 +1,43 @@
1
- // Leg floor, the scheduler-eye view: what's running, waiting, queued, and
2
- // which leases block whom. Polls /api/floor, /api/trunk and /api/sessions
3
- // (for the instrument head's account rails) and refreshes on SSE.
1
+ // Leg floor, the scheduler-eye view: what is running, what is waiting on you,
2
+ // what is queued and behind what, and what landed. Polls /api/floor (the
3
+ // scheduler's own view: leases, blockers, counts), /api/cards (the rows),
4
+ // /api/trunk and /api/sessions, and refreshes on SSE.
5
+ //
6
+ // The floor starts work too: the one-line entry row under the capacity strip is
7
+ // src/board/entry.js, the same file and the same posted body as the board's.
4
8
  (function () {
5
9
  'use strict'
6
10
 
7
- const WAIT_LABELS = { approve: 'Approve', resume: 'Resume', kill: 'Kill' }
8
- const WAIT_BTN_CLASS = { approve: 'btn btn-primary', resume: 'btn btn-secondary', kill: 'btn btn-danger' }
11
+ // src/board/board.js OWNS THESE FOUR TABLES (its own comment: "the button
12
+ // order is fixed and never reflows by availability"). The floor draws the
13
+ // same card rows and cannot import from that file, so they are carried here
14
+ // and a change to either is a change to both in the same commit.
15
+ const ACTION_ORDER = ['approve', 'enqueue', 'resume', 'pause', 'handoff_now', 'rerun', 'reassign', 'kill']
16
+ const ACTION_LABELS = {
17
+ enqueue: 'Run', pause: 'Pause', resume: 'Resume', kill: 'Kill', reassign: 'Reassign',
18
+ handoff_now: 'Hand off now', approve: 'Approve', rerun: 'Rerun',
19
+ }
20
+ const ACTION_PATHS = {
21
+ enqueue: 'run', pause: 'pause', resume: 'resume', kill: 'kill', reassign: 'reassign',
22
+ handoff_now: 'handoff', approve: 'approve', rerun: 'rerun',
23
+ }
24
+ const ACTION_CLASS = { approve: 'btn-primary', enqueue: 'btn-primary', kill: 'btn-danger' }
25
+ const STATUS_TONE = {
26
+ running: 'run', handing_off: 'warn', waiting_human: 'warn', needs_approval: 'warn',
27
+ paused: 'idle', queued: 'idle', backlog: 'idle', done: 'idle', failed: 'danger', killed: 'danger',
28
+ }
29
+ // The five stations this page is: each one is a question ("what is going now",
30
+ // "what is stuck on me") and every live card is in exactly one of them.
31
+ const STATIONS = [
32
+ { key: 'running', head: 'running-head', box: 'running-rows', count: 'count-running', statuses: ['running', 'handing_off'], empty: 'Nothing running. A queued card starts here when its leases are free and the scheduler has a slot.' },
33
+ { key: 'waiting', head: 'waiting-head', box: 'waiting-rows', count: 'count-waiting', statuses: ['waiting_human', 'needs_approval', 'paused'], empty: 'Nothing is waiting on you. A card lands here when a station asks for approval, or a run stops for an answer.' },
34
+ { key: 'queued', head: 'queued-head', box: 'queued-rows', count: 'count-queued', statuses: ['queued'], empty: 'Nothing queued. Start one above; it waits here until a slot and its leases are free.' },
35
+ // the control this names is index.html's `<label for="nc-queue">… Run now`,
36
+ // the only checkbox involved: the line used to name a "queue box" that is
37
+ // on no screen, and inverted the tick a reader has to clear
38
+ { key: 'backlog', head: 'backlog-head', box: 'backlog-rows', count: 'count-backlog', statuses: ['backlog'], empty: 'Nothing in the backlog. A card made in More settings with Run now unticked waits here until you press Run.' },
39
+ { key: 'done', head: 'done-head', box: 'done-rows', count: 'count-done', statuses: ['done', 'failed', 'killed'], empty: 'Nothing finished today.' },
40
+ ]
9
41
 
10
42
  // `bind` starts as the address the reader actually reached this page on and is
11
43
  // replaced by the server's own bind at init. It used to be the literal
@@ -17,7 +49,15 @@
17
49
  // object; anything added inside them that needs it must reach for another
18
50
  // name. test/board-updates.test.mjs pins `state.stopped` in this file by
19
51
  // source text, which is why the module object keeps the name.
20
- const state = { es: null, retryMs: 1000, timers: [], stopped: false, sseRequest: 0, floorRequest: 0, trunkRequest: 0, headRequest: 0, lastReadingAt: null, bind: (typeof location !== 'undefined' && location.host) || '127.0.0.1:4747', pendingFloor: null }
52
+ // pinned to package.json by test/files-version.test.mjs; see board.js FILES_VERSION
53
+ const FILES_VERSION = '0.13.1'
54
+ const state = { es: null, retryMs: 1000, timers: [], stopped: false, sseRequest: 0, floorRequest: 0, trunkRequest: 0, headRequest: 0, cardsRequest: 0, lastReadingAt: null, bind: (typeof location !== 'undefined' && location.host) || '127.0.0.1:4747', pendingFloor: null, pendingCards: null, cards: new Map(), blockers: new Map(), doneOpen: false, ringId: null, scheduler: {}, trunkOff: false }
55
+
56
+ // What the entry row reads on every render: the terminals (for the repo it
57
+ // infers), the cards it has seen, each repo's default branch, the ladder, the
58
+ // installed agents and the model catalog. entry.js is handed this object
59
+ // live, never a copy.
60
+ const host = { sessions: [], cards: state.cards, repoTrunks: [], preferences: null, adapters: null, models: null }
21
61
 
22
62
  function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
23
63
 
@@ -180,6 +220,16 @@
180
220
  function accountLabel(a) { return a.label || (a.account === 'default' ? a.agent : `${a.agent}/${a.account}`) }
181
221
  function idOf(agent) { return IDS.includes(agent) ? agent : 'fake' }
182
222
 
223
+ // ---- the capacity strip ------------------------------------------------
224
+ // src/board/strip.js draws it, here and on the board, from the same accounts
225
+ // payload: the panels below are a drawer now, and the one line the reader
226
+ // sees first is the same line on both pages. It is a plain script too, so it
227
+ // takes this file's primitives instead of growing a copy of the time grammar.
228
+ // The guard is for the pure-logic harness in test/board-updates.test.mjs,
229
+ // which runs this file with no window at all.
230
+ const strip = () => (typeof window !== 'undefined' && window.legStrip) || null
231
+ if (strip()) strip().use({ el, accountLabel, idOf, acctState, worstWindow, until, clockAt, spoken })
232
+
183
233
  // ---- 6.1 the window rail ----------------------------------------------
184
234
  // One state value per account drives the .acct modifier, every rail cell, the
185
235
  // tier word and the spoken sentence, so those four cannot disagree.
@@ -353,6 +403,10 @@
353
403
  if (!box) return
354
404
  box.textContent = ''
355
405
  const list = accounts || []
406
+ // the strip is the only usage on screen until the reader opens the drawer,
407
+ // and it renders for an empty list too (it is then an empty band, not a
408
+ // page of panels)
409
+ if (strip()) { strip().capacityStrip(list); strip().renderCapacityToggle() }
356
410
  if (!list.length) return
357
411
  // The floor has no verdict sentence: it is the scheduler's view, and its own
358
412
  // heading says what page you are on. The login panels are identical to the
@@ -405,15 +459,6 @@
405
459
  if (meta) meta.textContent = text
406
460
  }
407
461
 
408
- async function runFloorAction(id, action) {
409
- try {
410
- await api(`/api/cards/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: {} })
411
- refreshFloor()
412
- } catch (err) {
413
- toast(err.message)
414
- }
415
- }
416
-
417
462
  function renderHeader(data) {
418
463
  // repo names only on the floor (the full path is the tooltip): a shared
419
464
  // floor should not print every operator's home directory
@@ -421,53 +466,409 @@
421
466
  reposEl.textContent = data.repos && data.repos.length ? data.repos.map((r) => String(r).split(/[\\/]/).filter(Boolean).pop() || r).join(', ') : '(none)'
422
467
  reposEl.title = data.repos && data.repos.length ? data.repos.join('\n') : ''
423
468
  const sched = data.scheduler || {}
469
+ // the rows need this too: a card queued while nothing is draining the queue
470
+ // is not waiting for a slot, and the masthead was the only place that said so
471
+ state.scheduler = sched
424
472
  document.getElementById('sched-status').textContent = `scheduler ${sched.running ? 'running' : 'stopped'}, ${sched.max_concurrent ?? '?'} max`
425
- document.getElementById('count-running').textContent = String(data.counts.running)
426
- document.getElementById('count-queued').textContent = String(data.counts.queued)
427
- document.getElementById('count-waiting').textContent = String(data.counts.waiting)
428
- document.getElementById('count-done').textContent = String(data.counts.done)
429
- }
430
-
431
- function renderRunning(list) {
432
- renderTable('running-body', list, (r) => el('tr', {}, [
433
- el('td', { 'data-label': 'Card', title: r.title || r.card_id }, [r.title || r.card_id]),
434
- el('td', { 'data-label': 'Station' }, [r.station || '']),
435
- el('td', { 'data-label': 'Agent / leg' }, [el('span', { class: 'chip' }, [`${r.adapter || '?'} leg ${r.leg}`])]),
436
- el('td', { 'data-label': 'Leases' }, [el('span', { class: 'chip' }, [(r.leases && r.leases.length ? r.leases : ['**']).join(', ')])]),
437
- el('td', { 'data-label': 'Last event', title: formatLastEvent(r.last_event) }, [formatLastEvent(r.last_event)]),
438
- el('td', { 'data-label': 'Elapsed' }, [el('span', { class: 'elapsed' }, [elapsedClock(r.elapsed_ms || 0)])]),
439
- el('td', { class: 'row-actions', 'data-label': 'Actions' }, [
440
- el('button', { type: 'button', class: 'btn btn-secondary', 'aria-label': `Pause ${r.title || r.card_id}`, onclick: () => runFloorAction(r.card_id, 'pause') }, ['Pause']),
441
- el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': `Kill ${r.title || r.card_id}`, onclick: () => runFloorAction(r.card_id, 'kill') }, ['Kill']),
442
- ]),
443
- ]), 'Nothing running. A queued card starts here when its leases are free and the scheduler has a slot.')
444
- }
445
-
446
- function renderWaiting(list) {
447
- renderTable('waiting-body', list, (w) => {
448
- const btns = (w.actions || [])
449
- .filter((a) => WAIT_LABELS[a])
450
- .map((a) => el('button', { type: 'button', class: WAIT_BTN_CLASS[a], 'aria-label': `${WAIT_LABELS[a]} ${w.title || w.card_id}`, onclick: () => runFloorAction(w.card_id, a) }, [WAIT_LABELS[a]]))
451
- return el('tr', {}, [
452
- el('td', { 'data-label': 'Card', title: w.title || w.card_id }, [w.title || w.card_id]),
453
- el('td', { 'data-label': 'Station' }, [w.station || '']),
454
- el('td', { 'data-label': 'Status' }, [STATUS_LABELS[w.status] || w.status]),
455
- el('td', { 'data-label': 'Since' }, [el('span', { class: 'chip' }, [formatTs(w.since)])]),
456
- el('td', { class: 'row-actions', 'data-label': 'Actions' }, btns),
457
- ])
458
- }, 'No cards waiting on a human. A card lands here when a station asks for approval or a run stops for an answer.')
473
+ // the counts live in the station headings now, beside the rows they count.
474
+ // They are counted off the rows themselves (renderStation): a header that
475
+ // counts one payload while the station under it lists another is one fact
476
+ // answering twice, and the floor had exactly that shape.
477
+ }
478
+
479
+ // ---- the stations ------------------------------------------------------
480
+ // src/board/board.js OWNS THE CARD ROW: its buildRow is R1 (state, station,
481
+ // repo on branch, agent/model), R2 (title, one sentence), R4 (the clock, the
482
+ // short id and at most four buttons), in the `.row` grid the terminal rows
483
+ // use. The floor draws that row, grouped into the five stations instead of
484
+ // ranked into one list, and the two files cannot import from each other: a
485
+ // change to the row's shape is a change to both in the same commit.
486
+
487
+ function truncate(str, n) {
488
+ const text = String(str || '')
489
+ return text.length > n ? `${text.slice(0, n - 1)}…` : text
490
+ }
491
+ function plural(n, word) { return `${n} ${word}${n === 1 ? '' : 's'}` }
492
+ function agoShort(ms) {
493
+ const s = Math.max(0, Math.floor(ms / 1000))
494
+ if (s < 60) return `${s}s`
495
+ const m = Math.floor(s / 60)
496
+ if (m < 60) return `${m}m`
497
+ const h = Math.floor(m / 60)
498
+ if (h < 48) return `${h}h`
499
+ return `${Math.floor(h / 24)}d`
500
+ }
501
+ function agoSince(ts) {
502
+ const at = ts ? Date.parse(ts) : NaN
503
+ return Number.isFinite(at) ? agoShort(Date.now() - at) : 'an unknown time'
504
+ }
505
+
506
+ function cardTitle(card) { return card.title || truncate(card.task, 60) || card.card_id }
507
+
508
+ function cardShortId(id) {
509
+ const parts = String(id || '').split('-').filter(Boolean)
510
+ if (!parts.length) return ''
511
+ let out = parts[parts.length - 1]
512
+ for (let i = parts.length - 2; i >= 0 && out.length < 4; i--) out = `${parts[i]}-${out}`
513
+ return out
514
+ }
515
+
516
+ function cardBranch(card) {
517
+ if (card.worktree_branch) return card.worktree_branch
518
+ if (card.worktree) return `leg/${card.card_id}`
519
+ return card.trunk || 'main'
520
+ }
521
+
522
+ function cardAgentModelText(card) {
523
+ const agent = (card.agent_model && card.agent_model.agent) || card.active_adapter || null
524
+ if (!agent) return null
525
+ const model = card.agent_model && card.agent_model.model
526
+ return model ? `${agent}/${model}` : agent
527
+ }
528
+
529
+ function statusWord(card) {
530
+ let label = STATUS_LABELS[card.status] || card.status
531
+ if (card.status === 'running' && card.station_kind === 'land') label = 'landing'
532
+ else if (card.status === 'waiting_human' && card.pr_url) label = 'PR open'
533
+ return el('span', { class: 'status-word' }, [
534
+ el('span', { class: `mark tone-${STATUS_TONE[card.status] || 'idle'}`, 'aria-hidden': 'true' }),
535
+ label,
536
+ ])
537
+ }
538
+
539
+ // board.js cardSentence, carried: one sentence, the highest-ranked thing true
540
+ // about the card, every branch naming a station, a time, a count or the
541
+ // server's own reason.
542
+ function cardSentence(card) {
543
+ const last = card.last_event
544
+ if (card.bounce_reason && ['queued', 'running', 'handing_off', 'needs_approval'].includes(card.status)) {
545
+ const attempt = card.land_attempts ? ` on attempt ${card.land_attempts}` : ''
546
+ return { tone: 'danger', text: `Land bounced${attempt}: ${card.bounce_reason}. The worktree still holds every commit; nothing was lost.` }
547
+ }
548
+ if (card.status === 'queued' && last && last.type === 'blocked_by') return { tone: 'warn', text: last.summary }
549
+ if (['failed', 'killed'].includes(card.status)) {
550
+ return { tone: 'danger', text: `${card.status} at station ${card.station} after ${plural(card.runs_count || 0, 'run')}: ${last ? last.summary : 'no events yet'}` }
551
+ }
552
+ if (['waiting_human', 'needs_approval'].includes(card.status)) {
553
+ return { tone: 'warn', text: `waiting on you at station ${card.station} since ${formatTs(card.updated_at)}` }
554
+ }
555
+ if (card.status === 'done') return { tone: 'ok', text: `done after ${plural(card.runs_count || 0, 'run')}, ${last ? last.summary : 'no events yet'}` }
556
+ if (card.status === 'running' && last && (last.type === 'leg_started' || last.summary === 'leg started')) {
557
+ return { tone: 'muted', text: `no message until this leg ends, started ${formatTs(last.ts)}` }
558
+ }
559
+ return { tone: 'muted', text: `${formatLastEvent(last)}${last ? `, ${formatTs(last.ts)}` : ''}` }
560
+ }
561
+
562
+ // A card that is running prints the clock of the run it is in; one that is
563
+ // not prints how long it has sat where it is, which is the question the floor
564
+ // is asked ("has that been stuck since lunch").
565
+ function cardClock(card) {
566
+ if (card.active_run && Number.isFinite(card.elapsed_ms)) return elapsedClock(card.elapsed_ms)
567
+ return `idle ${agoSince(card.updated_at)}`
568
+ }
569
+
570
+ // What a queued card is actually waiting for, in the scheduler's own words.
571
+ // scheduler.mjs writes `blocked by <holder> on <lease>` and this line is no
572
+ // longer under a column headed "Blocked by", so the summary is printed as it
573
+ // comes: a prefix here printed `blocked by blocked by card "X" on src/**`.
574
+ // A blocked_by event is written inside the scheduler's own tick, so a card
575
+ // queued while the scheduler is stopped never gets one and used to fall
576
+ // through to "waiting for a free slot" - a sentence about a slot that will
577
+ // never be taken. /api/floor says which it is.
578
+ function waitingFor(card) {
579
+ const blocker = state.blockers.get(card.card_id)
580
+ if (blocker) return blocker
581
+ if (state.scheduler && state.scheduler.running === false) return 'the scheduler is stopped'
582
+ return 'waiting for a free slot'
583
+ }
584
+
585
+ // The row's one sentence already carries the scheduler's reason when the card
586
+ // has a blocked_by event (cardSentence's queued branch), so the note under it
587
+ // says the position alone: printed twice, the reader reads it as two blockers.
588
+ function queueNote(card, index, total) {
589
+ const said = cardSentence(card).text
590
+ const why = waitingFor(card)
591
+ const place = `${index + 1} of ${total} in the queue`
592
+ return said === why ? place : `${place}, ${why}`
593
+ }
594
+
595
+ async function runFloorAction(id, action) {
596
+ try {
597
+ await api(`/api/cards/${encodeURIComponent(id)}/${ACTION_PATHS[action] || action}`, { method: 'POST', body: {} })
598
+ refreshCards()
599
+ refreshFloor()
600
+ } catch (err) {
601
+ toast(err.message)
602
+ }
603
+ }
604
+
605
+ // The board owns the card's detail region (the log tail, the chain rail, the
606
+ // leases, Take over and its one copyable command), and there is one copy of
607
+ // it. A title here is the link to that card on the board, which is also what
608
+ // Enter on the keyboard ring presses.
609
+ function cardHref(card) { return `/#card=${encodeURIComponent(card.card_id)}` }
610
+
611
+ // src/board/board.js OWNS THIS PICKER (its openReassign, and .reassign-picker
612
+ // in board.css): reassign is the one action whose POST carries a body, and
613
+ // src/chain.mjs refuses it without an adapter. The floor's button used to
614
+ // post `{}` straight from the row, so every press on this page was a
615
+ // "reassign needs an adapter" error toast and the floor could not reassign at
616
+ // all. The picker replaces the row's buttons in place, the way the board's
617
+ // does, and Cancel puts them back.
618
+ async function openReassign(card, wrap) {
619
+ if (!host.adapters) {
620
+ try { host.adapters = (await api('/api/adapters')).adapters } catch (err) { toast(err.message); return }
621
+ }
622
+ const name = cardTitle(card)
623
+ const adapterSelect = el('select', { 'aria-label': `Reassign adapter for ${name}` })
624
+ const modeSelect = el('select', { 'aria-label': `Reassign mode for ${name}` })
625
+ for (const a of host.adapters || []) adapterSelect.appendChild(el('option', { value: a.name }, [a.name]))
626
+ if (card.active_adapter) adapterSelect.value = card.active_adapter
627
+ function populateModes() {
628
+ modeSelect.textContent = ''
629
+ const adapter = (host.adapters || []).find((a) => a.name === adapterSelect.value)
630
+ const modes = (adapter && adapter.modes) || {}
631
+ for (const m of modes.allowed || []) modeSelect.appendChild(el('option', { value: m }, [m]))
632
+ if (modes.default) modeSelect.value = modes.default
633
+ }
634
+ adapterSelect.addEventListener('change', populateModes)
635
+ populateModes()
636
+ const apply = el('button', {
637
+ type: 'button', class: 'btn btn-primary', 'aria-label': `Reassign ${name} to the chosen adapter`,
638
+ onclick: async () => {
639
+ try {
640
+ await api(`/api/cards/${encodeURIComponent(card.card_id)}/reassign`, { method: 'POST', body: { adapter: adapterSelect.value, mode: modeSelect.value } })
641
+ restoreActions(card, wrap)
642
+ refreshCards()
643
+ refreshFloor()
644
+ } catch (err) { toast(err.message) }
645
+ },
646
+ }, ['Apply'])
647
+ const cancel = el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => restoreActions(card, wrap) }, ['Cancel'])
648
+ wrap.textContent = ''
649
+ wrap.appendChild(el('div', { class: 'reassign-picker' }, [adapterSelect, modeSelect, apply, cancel]))
650
+ // focus lands in the picker, which is also what holds this row against the
651
+ // next poll's rebuild (renderStation): the reader gets their 30 seconds
652
+ if (adapterSelect.focus) adapterSelect.focus()
653
+ }
654
+
655
+ function actionButtons(card, wrap) {
656
+ const available = card.actions || []
657
+ const shown = ACTION_ORDER.filter((a) => available.includes(a) && ACTION_LABELS[a]).slice(0, 4)
658
+ return shown.map((action) => el('button', {
659
+ type: 'button',
660
+ class: `btn ${ACTION_CLASS[action] || 'btn-secondary'}`,
661
+ 'aria-label': `${ACTION_LABELS[action]} ${cardTitle(card)}`,
662
+ 'data-action': action,
663
+ onclick: () => { if (action === 'reassign') openReassign(card, wrap); else runFloorAction(card.card_id, action) },
664
+ }, [ACTION_LABELS[action]]))
665
+ }
666
+
667
+ function restoreActions(card, wrap) {
668
+ wrap.textContent = ''
669
+ for (const btn of actionButtons(card, wrap)) wrap.appendChild(btn)
670
+ }
671
+
672
+ function buildActions(card) {
673
+ const wrap = el('div', { class: 'row-actions' })
674
+ restoreActions(card, wrap)
675
+ return wrap
676
+ }
677
+
678
+ function buildCardRow(card, index) {
679
+ const said = cardSentence(card)
680
+ const agentText = cardAgentModelText(card)
681
+ const row = el('article', { class: 'row', 'data-card-id': card.card_id })
682
+ row.appendChild(el('div', { class: 'r1' }, [
683
+ statusWord(card),
684
+ card.station && card.station !== '-' ? el('span', { class: 'chip' }, [card.station]) : null,
685
+ el('span', { class: 'row-meta mono', title: `${card.repo_name || 'no repo'} on ${cardBranch(card)}` }, [`${card.repo_name || 'no repo'} on ${truncate(cardBranch(card), 32)}`]),
686
+ agentText ? el('span', { class: 'chip' }, [agentText]) : null,
687
+ ]))
688
+ row.appendChild(el('div', { class: 'r2' }, [
689
+ el('a', { class: 'row-title', href: cardHref(card), 'aria-label': `Open ${cardTitle(card)} on the board` }, [cardTitle(card)]),
690
+ el('p', { class: `sentence tone-${said.tone}` }, [said.text]),
691
+ ]))
692
+ row.appendChild(el('div', { class: 'r3' }, card.status === 'queued'
693
+ ? [el('p', { class: 'row-meta' }, [queueNote(card, index, state.queueOrder.length)])]
694
+ : []))
695
+ row.appendChild(el('div', { class: 'r4' }, [
696
+ el('span', { class: 'elapsed' }, [cardClock(card)]),
697
+ el('span', { class: 'row-meta mono', title: card.card_id }, [cardShortId(card.card_id)]),
698
+ buildActions(card),
699
+ ]))
700
+ return row
701
+ }
702
+
703
+ // Everything on a row is text, so a station that would say exactly what it
704
+ // already says is left alone: a rebuilt row takes the reader's focus with it,
705
+ // and this page repaints every two seconds.
706
+ function rowSignature(card, index) {
707
+ return [card.status, card.station, cardTitle(card), cardSentence(card).text, cardClock(card), cardAgentModelText(card), (card.actions || []).join('/'), index].join('|')
708
+ }
709
+
710
+ function stationCards(station, cards) {
711
+ const list = cards.filter((c) => station.statuses.includes(c.status))
712
+ if (station.key === 'done') {
713
+ // "today" is this reader's own day, on the clock every other time on this
714
+ // page is printed in
715
+ const midnight = new Date()
716
+ midnight.setHours(0, 0, 0, 0)
717
+ return list
718
+ .filter((c) => (Date.parse(c.updated_at) || 0) >= midnight.getTime())
719
+ .sort((a, b) => (Date.parse(b.updated_at) || 0) - (Date.parse(a.updated_at) || 0))
720
+ }
721
+ if (station.key === 'queued') return list.sort((a, b) => state.queueOrder.indexOf(a.card_id) - state.queueOrder.indexOf(b.card_id))
722
+ return list.sort((a, b) => (Date.parse(a.updated_at) || 0) - (Date.parse(b.updated_at) || 0))
723
+ }
724
+
725
+ // A reader pressing a button inside a station keeps THAT row: the poll that
726
+ // arrives mid-press must not rebuild the node under their finger, and a
727
+ // reassign picker open in it must not vanish. The hold used to be the whole
728
+ // station and had no bound, so a click on Kill (the button keeps focus) froze
729
+ // running, waiting, queued or backlog for as long as the reader left focus
730
+ // there: the row still read `running`, the clock stopped, and the second
731
+ // press got an illegal-transition 409. One row, for 30 seconds.
732
+ const ROW_HOLD_MS = 30000
733
+ function heldRowId(box) {
734
+ const focused = (typeof document !== 'undefined' && document.activeElement) || null
735
+ if (!focused || !box.contains || !box.contains(focused)) { box.heldId = null; box.heldAt = 0; return null }
736
+ const row = [...(box.children || [])].find((r) => r === focused || (r.contains && r.contains(focused)))
737
+ const id = row && row.getAttribute ? row.getAttribute('data-card-id') : null
738
+ if (!id) return null
739
+ if (box.heldId !== id) { box.heldId = id; box.heldAt = Date.now() }
740
+ return Date.now() - box.heldAt >= ROW_HOLD_MS ? null : id
741
+ }
742
+
743
+ function renderStation(station, cards) {
744
+ const box = document.getElementById(station.box)
745
+ if (!box) return
746
+ const section = box.parentElement
747
+ const count = document.getElementById(station.count)
748
+ if (count) count.textContent = String(cards.length)
749
+ if (section && section.querySelector) { const old = section.querySelector('.empty-line'); if (old && old.remove) old.remove() }
750
+ if (!cards.length) {
751
+ box.textContent = ''
752
+ box.signature = ''
753
+ if (section && section.appendChild) section.appendChild(el('p', { class: 'empty-line' }, [station.empty]))
754
+ return
755
+ }
756
+ const key = cards.map((c, i) => `${c.card_id}:${rowSignature(c, i)}`).join(',')
757
+ if (box.signature === key) return
758
+ const held = heldRowId(box)
759
+ const focusedBefore = (typeof document !== 'undefined' && document.activeElement) || null
760
+ const existing = new Map()
761
+ for (const row of [...(box.children || [])]) {
762
+ const id = row.getAttribute && row.getAttribute('data-card-id')
763
+ if (id) existing.set(id, row)
764
+ }
765
+ // a row whose words have not changed keeps its node too, so the reader's
766
+ // scroll position, their text selection and the focus ring survive a poll
767
+ const next = cards.map((card, i) => {
768
+ const prev = existing.get(card.card_id)
769
+ const sig = rowSignature(card, i)
770
+ if (prev && (card.card_id === held || prev.signature === sig)) return prev
771
+ const row = buildCardRow(card, i)
772
+ row.signature = sig
773
+ return row
774
+ })
775
+ next.forEach((row, i) => { if (box.children[i] !== row) box.insertBefore(row, box.children[i] || null) })
776
+ while (box.children.length > next.length) box.removeChild(box.children[next.length])
777
+ // moving a node is a remove and an insert, which blurs it: put the reader
778
+ // back on the control they were on, without moving the viewport
779
+ if (focusedBefore && focusedBefore.focus && document.activeElement !== focusedBefore && box.contains && box.contains(focusedBefore)) {
780
+ focusedBefore.focus({ preventScroll: true })
781
+ }
782
+ // a held row is showing last poll's words, so the station is not caught up
783
+ // and must try again on the next one
784
+ box.signature = held ? '' : key
785
+ }
786
+
787
+ // The sub-line under a station heading answers "and what of it": the queue
788
+ // says what the front of it waits for, the rest say how long the oldest row
789
+ // has been where it is.
790
+ function stationMeta(station, list) {
791
+ if (!list.length) return ''
792
+ if (station.key === 'queued') return `first: ${waitingFor(list[0])}`
793
+ if (station.key === 'done') {
794
+ const count = (status) => list.filter((c) => c.status === status).length
795
+ return [count('done') ? `${count('done')} done` : null, count('failed') ? `${count('failed')} failed` : null, count('killed') ? `${count('killed')} killed` : null].filter(Boolean).join(', ')
796
+ }
797
+ return `oldest ${agoSince(list[0].updated_at)}`
798
+ }
799
+
800
+ function renderStations() {
801
+ const cards = [...state.cards.values()]
802
+ state.queueOrder = cards
803
+ .filter((c) => c.status === 'queued')
804
+ .sort((a, b) => (Date.parse(a.created_at || a.updated_at) || 0) - (Date.parse(b.created_at || b.updated_at) || 0))
805
+ .map((c) => c.card_id)
806
+ for (const station of STATIONS) {
807
+ const list = stationCards(station, cards)
808
+ renderStation(station, list)
809
+ setRegionMeta(station.head, stationMeta(station, list))
810
+ }
811
+ renderDoneToggle()
812
+ paintRing()
459
813
  }
460
814
 
461
- function renderQueued(list) {
462
- renderTable('queued-body', list, (q) => el('tr', {}, [
463
- el('td', { 'data-label': 'Card', title: q.title || q.card_id }, [q.title || q.card_id]),
464
- el('td', { 'data-label': 'Station' }, [q.station || '']),
465
- el('td', { 'data-label': 'Leases' }, [el('span', { class: 'chip' }, [(q.leases && q.leases.length ? q.leases : ['**']).join(', ')])]),
466
- // the scheduler's blocked_by summary already opens with "blocked by"
467
- // (scheduler.mjs), and the column is headed "Blocked by": prefixing it
468
- // here printed `blocked by blocked by card "X" on src/**`
469
- el('td', { 'data-label': 'Blocked by', title: q.blocked_by || '' }, [q.blocked_by || '']),
470
- ]), 'Nothing queued. New card on the board queues one; it waits here until a slot and its leases are free.')
815
+ function renderDoneToggle() {
816
+ const btn = document.getElementById('done-toggle')
817
+ const box = document.getElementById('done-rows')
818
+ if (btn) {
819
+ btn.setAttribute('aria-expanded', state.doneOpen ? 'true' : 'false')
820
+ btn.textContent = state.doneOpen ? 'Hide' : 'View'
821
+ }
822
+ if (box) box.hidden = !state.doneOpen
823
+ }
824
+
825
+ // ---- the keyboard ring -------------------------------------------------
826
+ // The three keys the board answers on its rows: j and k move, Enter opens the
827
+ // card. Every other board binding presses a control this page does not have,
828
+ // so it is not claimed here.
829
+ function ringRows() {
830
+ const out = []
831
+ for (const station of STATIONS) {
832
+ const box = document.getElementById(station.box)
833
+ if (!box || box.hidden || !box.children) continue
834
+ for (const row of [...box.children]) if (row.getAttribute && row.getAttribute('data-card-id')) out.push(row)
835
+ }
836
+ return out
837
+ }
838
+ function paintRing() {
839
+ const rows = ringRows()
840
+ const at = rows.findIndex((r) => r.getAttribute('data-card-id') === state.ringId)
841
+ if (state.ringId && at < 0) state.ringId = null
842
+ rows.forEach((r, i) => { if (r.classList) r.classList.toggle('is-focused', i === at) })
843
+ }
844
+ function moveRing(step) {
845
+ const rows = ringRows()
846
+ if (!rows.length) return
847
+ const at = rows.findIndex((r) => r.getAttribute('data-card-id') === state.ringId)
848
+ const next = at < 0 ? (step > 0 ? 0 : rows.length - 1) : Math.min(rows.length - 1, Math.max(0, at + step))
849
+ state.ringId = rows[next].getAttribute('data-card-id')
850
+ paintRing()
851
+ // the ring moves focus to the row's title, so a screen reader announces
852
+ // which card it landed on instead of a bare button word
853
+ const link = rows[next].querySelector && rows[next].querySelector('.row-title')
854
+ if (link && link.focus) link.focus()
855
+ }
856
+ function floorKey(e) {
857
+ if (e.metaKey || e.ctrlKey || e.altKey) return
858
+ const tag = e.target && e.target.tagName ? String(e.target.tagName).toLowerCase() : ''
859
+ // a reader typing a task into the entry row is typing, not steering
860
+ if (['input', 'textarea', 'select'].includes(tag)) return
861
+ if (e.key === 'j') { e.preventDefault(); moveRing(1) }
862
+ else if (e.key === 'k') { e.preventDefault(); moveRing(-1) }
863
+ else if (e.key === 'Enter' && state.ringId && typeof location !== 'undefined') {
864
+ // a focused control answers its own Enter. The ring used to swallow every
865
+ // Enter on the page for as long as it was set, so Approve, Start, View,
866
+ // the capacity disclosure and the Board link all navigated to the ringed
867
+ // card instead of doing their own job.
868
+ if (e.target && e.target.closest && e.target.closest('button, a, summary, [role="button"]')) return
869
+ const card = state.cards.get(state.ringId)
870
+ if (card) { e.preventDefault(); location.href = cardHref(card) }
871
+ }
471
872
  }
472
873
 
473
874
  function renderLeases(data) {
@@ -495,8 +896,13 @@
495
896
  ]), 'Nothing landed in the last hour.')
496
897
  }
497
898
 
498
- // the floor is owner-only: a guest (or anyone whose token has rotated) would
499
- // otherwise get two toasts every two seconds for as long as the tab is open
899
+ // The rows of this page are not owner-only: src/share.mjs mayUseCards lets an
900
+ // operator use /api/floor, /api/cards and /api/sessions, and only the machine
901
+ // map (/api/trunk) is kept for the owner. So a 401/403 from an endpoint the
902
+ // page cannot exist without locks it out - a guest, or anyone whose token has
903
+ // rotated, would otherwise get two toasts every two seconds for as long as
904
+ // the tab is open - and a 403 from the trunk lane alone stands that one table
905
+ // down (standDownTrunk).
500
906
  function boardHref(href) {
501
907
  try {
502
908
  const token = new URL(href).searchParams.get('token')
@@ -513,9 +919,13 @@
513
919
  document.getElementById('banner').hidden = true
514
920
  document.getElementById('sse-dot').className = 'sse-rule'
515
921
  document.getElementById('sse-text').textContent = 'stopped'
516
- const main = document.querySelector('.shell')
922
+ // `main.wrap` is this page's own container. It used to look for `.shell`,
923
+ // which no version of floor.html has ever had, so the one path that tells a
924
+ // locked-out reader how to get back in threw on its first line instead.
925
+ const main = document.querySelector('main.wrap')
926
+ if (!main) return
517
927
  main.textContent = ''
518
- main.appendChild(el('section', { class: 'region' }, [
928
+ main.appendChild(el('section', { class: 'region-floor' }, [
519
929
  el('h2', { class: 'region-title' }, ['Floor unavailable']),
520
930
  el('p', { class: 'empty-line' }, [message]),
521
931
  el('p', { class: 'empty-line' }, [
@@ -532,13 +942,12 @@
532
942
  if (state.stopped || request !== state.floorRequest) return
533
943
  state.lastReadingAt = new Date()
534
944
  renderHeader(data)
535
- renderRunning(data.running)
536
- renderWaiting(data.waiting)
537
- renderQueued(data.queued)
945
+ // the scheduler's own answer to "why is that one not moving": the rows
946
+ // themselves come from /api/cards, and this is the only place the reason
947
+ // a queued card is held exists
948
+ state.blockers = new Map((data.queued || []).filter((q) => q.blocked_by).map((q) => [q.card_id, q.blocked_by]))
538
949
  renderLeases(data)
539
- setRegionMeta('running-head', `${data.running.length} running`)
540
- setRegionMeta('waiting-head', `${data.waiting.length} waiting on a human`)
541
- setRegionMeta('queued-head', `${data.queued.length} queued`)
950
+ renderStations()
542
951
  setRegionMeta('leases-head', `${data.leases.length} held`)
543
952
  } catch (err) {
544
953
  if (state.stopped || request !== state.floorRequest) return
@@ -547,7 +956,30 @@
547
956
  }
548
957
  }
549
958
 
959
+ // /api/trunk is the map of the machine (every repository path on it), and the
960
+ // server keeps that for the owner while an operator may use every other
961
+ // endpoint this page draws. One 403 here used to replace the whole floor with
962
+ // "Floor unavailable", so the pipeline view an operator is entitled to was
963
+ // unusable. The lane goes off the page with a line saying whose it is, and
964
+ // nothing else on the floor changes.
965
+ function standDownTrunk(why) {
966
+ if (state.trunkOff) return
967
+ state.trunkOff = true
968
+ const body = document.getElementById('trunk-body')
969
+ const table = body && body.parentElement
970
+ const section = table && table.parentElement
971
+ if (body) body.textContent = ''
972
+ if (table) table.hidden = true
973
+ if (section && section.appendChild) {
974
+ const old = section.querySelector && section.querySelector('.empty-line')
975
+ if (old && old.remove) old.remove()
976
+ section.appendChild(el('p', { class: 'empty-line' }, [`Trunk lane not shown: ${why}`]))
977
+ }
978
+ setRegionMeta('trunk-lane-head', 'the owner of this machine only')
979
+ }
980
+
550
981
  async function refreshTrunk() {
982
+ if (state.trunkOff) return
551
983
  const request = ++state.trunkRequest
552
984
  try {
553
985
  const data = await api('/api/trunk?since=1h')
@@ -557,6 +989,27 @@
557
989
  setRegionMeta('trunk-lane-head', `${data.landed.length} landed in the last hour`)
558
990
  } catch (err) {
559
991
  if (state.stopped || request !== state.trunkRequest) return
992
+ if (err.status === 401 || err.status === 403) return standDownTrunk(err.message)
993
+ toast(err.message)
994
+ }
995
+ }
996
+
997
+ // The rows. /api/floor carries the scheduler's view (who holds what, who is
998
+ // blocked by whom) but not a card's register, its sentence or its buttons;
999
+ // /api/cards carries the same summarize() payload the board's rows are built
1000
+ // from, so the two pages cannot describe one card two ways.
1001
+ async function refreshCards() {
1002
+ const request = ++state.cardsRequest
1003
+ try {
1004
+ const data = await api('/api/cards')
1005
+ if (state.stopped || request !== state.cardsRequest) return
1006
+ state.lastReadingAt = new Date()
1007
+ state.cards = new Map((data.cards || []).map((c) => [c.card_id, c]))
1008
+ host.cards = state.cards
1009
+ renderStations()
1010
+ if (entry) entry.renderEntryLine()
1011
+ } catch (err) {
1012
+ if (state.stopped || request !== state.cardsRequest) return
560
1013
  if (err.status === 401 || err.status === 403) return lockOut(err.message)
561
1014
  toast(err.message)
562
1015
  }
@@ -564,6 +1017,8 @@
564
1017
 
565
1018
  // the instrument head's accounts are not on /api/floor's payload; /api/sessions
566
1019
  // carries them (sessionsView()) and is already reachable by an owner token.
1020
+ // The same payload carries the terminals, each repo's default branch and the
1021
+ // saved ladder, which is everything the entry row infers its sentence from.
567
1022
  async function refreshHead() {
568
1023
  const request = ++state.headRequest
569
1024
  try {
@@ -571,12 +1026,58 @@
571
1026
  if (state.stopped || request !== state.headRequest) return
572
1027
  state.lastReadingAt = new Date()
573
1028
  renderHead(data.accounts || [])
1029
+ host.sessions = data.sessions || []
1030
+ host.repoTrunks = data.trunk || []
1031
+ if (data.preferences) host.preferences = data.preferences
1032
+ if (entry) entry.renderEntryLine()
574
1033
  } catch (err) {
575
1034
  if (state.stopped || request !== state.headRequest) return
576
1035
  if (err.status === 401 || err.status === 403) return lockOut(err.message)
577
1036
  }
578
1037
  }
579
1038
 
1039
+ // ---- the entry row ------------------------------------------------------
1040
+ // src/board/entry.js, the same row and the same posted body as the board's.
1041
+ // "More settings" is the only thing this page cannot do itself: the New card
1042
+ // dialog's markup lives once, in index.html, so the link carries the sentence
1043
+ // over to the board in the hash rather than this page carrying a second copy
1044
+ // of a thirteen-field form.
1045
+ const entry = (typeof window !== 'undefined' && window.legEntry)
1046
+ ? window.legEntry.create({
1047
+ el,
1048
+ api,
1049
+ toast,
1050
+ host,
1051
+ boxId: 'card-entry',
1052
+ onCreated: (card) => {
1053
+ if (!card) return
1054
+ state.cards.set(card.card_id, card)
1055
+ renderStations()
1056
+ toast(`Queued ${card.title || card.card_id}. It is in ${card.status === 'backlog' ? 'Backlog' : 'Queued'} below.`, 'ok')
1057
+ if (entry) entry.renderEntryLine()
1058
+ refreshCards()
1059
+ },
1060
+ onMoreSettings: (entryState) => {
1061
+ const task = (entryState.task || '').trim()
1062
+ // the workflow the reader picked on this row rides along, or the
1063
+ // board's dialog would open on its default and lose the choice
1064
+ const pipeline = entryState.pipeline && entryState.pipeline !== 'build' ? `&pipeline=${encodeURIComponent(entryState.pipeline)}` : ''
1065
+ location.href = task ? `/#new-card=${encodeURIComponent(task)}${pipeline}` : `/#new-card${pipeline ? '?' + pipeline.slice(1) : ''}`
1066
+ },
1067
+ })
1068
+ : null
1069
+
1070
+ // the ladder sentence and both selects read these; without them the row still
1071
+ // draws, and Start says which of the two reasons it is off
1072
+ async function loadEntryCatalog() {
1073
+ await Promise.all([
1074
+ api('/api/settings').then((d) => { host.preferences = d.preferences || host.preferences }).catch(() => {}),
1075
+ api('/api/adapters').then((d) => { host.adapters = d.adapters || null }).catch(() => {}),
1076
+ api('/api/models').then((d) => { host.models = d.models || null }).catch(() => {}),
1077
+ ])
1078
+ if (entry) entry.renderEntryLine()
1079
+ }
1080
+
580
1081
  // The board is pushed a card for every write under its directory, log bytes
581
1082
  // included, so a busy agent turns an unconditional refresh-per-push into a
582
1083
  // continuous request stream against a single-threaded local server that is
@@ -585,7 +1086,7 @@
585
1086
  const FLOOR_REFRESH_MS = 250
586
1087
  function scheduleFloorRefresh() {
587
1088
  if (state.pendingFloor) return
588
- state.pendingFloor = setTimeout(() => { state.pendingFloor = null; refreshFloor() }, FLOOR_REFRESH_MS)
1089
+ state.pendingFloor = setTimeout(() => { state.pendingFloor = null; refreshFloor(); refreshCards() }, FLOOR_REFRESH_MS)
589
1090
  }
590
1091
 
591
1092
  // ---- SSE ----
@@ -618,6 +1119,7 @@
618
1119
  state.retryMs = 1000
619
1120
  setSseState('live')
620
1121
  refreshFloor()
1122
+ refreshCards()
621
1123
  refreshTrunk()
622
1124
  refreshHead()
623
1125
  })
@@ -644,27 +1146,37 @@
644
1146
  try {
645
1147
  const data = await api('/api/health')
646
1148
  if (data && data.bind) state.bind = `${data.bind}:${data.port}`
1149
+ // same note the board gives: the process is not the version these files are
1150
+ if (data && data.version && data.version !== FILES_VERSION) toast(`This board process runs leg ${data.version} and the page files are ${FILES_VERSION}. Restart it to match: leg down && leg up`)
647
1151
  } catch { /* the page's own host is already a correct answer */ }
648
1152
  }
649
1153
 
650
1154
  function init() {
1155
+ document.getElementById('capacity-toggle')?.addEventListener('click', () => { if (strip()) strip().toggleCapacity() })
1156
+ document.getElementById('done-toggle')?.addEventListener('click', () => { state.doneOpen = !state.doneOpen; renderDoneToggle() })
1157
+ document.addEventListener('keydown', floorKey)
1158
+ if (strip()) strip().renderCapacityToggle()
1159
+ renderDoneToggle()
1160
+ if (entry) entry.renderEntryLine()
651
1161
  loadBind()
1162
+ loadEntryCatalog()
652
1163
  connectSse()
653
1164
  refreshFloor()
1165
+ refreshCards()
654
1166
  refreshTrunk()
655
1167
  refreshHead()
656
1168
  // a backgrounded tab kept polling three endpoints every two seconds forever;
657
1169
  // the SSE stream wakes it with the work it missed when it comes back
658
1170
  state.timers.push(
659
- setInterval(() => { if (!document.hidden) refreshFloor() }, 2000),
1171
+ setInterval(() => { if (!document.hidden) { refreshFloor(); refreshCards() } }, 2000),
660
1172
  setInterval(() => { if (!document.hidden) { refreshTrunk(); refreshHead() } }, 2000),
661
1173
  )
662
- document.addEventListener('visibilitychange', () => { if (!document.hidden && !state.stopped) { refreshFloor(); refreshTrunk(); refreshHead() } })
1174
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && !state.stopped) { refreshFloor(); refreshCards(); refreshTrunk(); refreshHead() } })
663
1175
  }
664
1176
 
665
1177
  document.addEventListener('DOMContentLoaded', init)
666
1178
 
667
1179
  // test seam: node:test runs this file with a stub document; in a browser
668
1180
  // there is no `module`
669
- if (typeof module !== 'undefined') module.exports = { boardHref }
1181
+ if (typeof module !== 'undefined') module.exports = { boardHref, stationCards, stationMeta, waitingFor, queueNote, cardClock, cardHref, STATIONS }
670
1182
  })()