@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.
- package/CHANGELOG.md +88 -0
- package/README.md +1 -1
- package/docs/DECISIONS.md +8 -0
- package/docs/ERRORS.md +42 -0
- package/docs/board-guide.md +129 -31
- package/docs/cli-contracts.md +42 -0
- package/docs/configuration.md +8 -2
- package/docs/faq.md +3 -1
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/fixtures/verified.json +1 -1
- package/package.json +1 -1
- package/scripts/board-jump-probe.mjs +335 -0
- package/scripts/build-docs-site.mjs +3 -3
- package/src/attach.mjs +60 -57
- package/src/board/board.css +84 -2
- package/src/board/board.js +349 -261
- package/src/board/entry.js +343 -0
- package/src/board/floor.html +51 -39
- package/src/board/floor.js +585 -73
- package/src/board/index.html +55 -38
- package/src/board/sessions.js +342 -144
- package/src/board/strip.js +163 -0
- package/src/models.mjs +265 -0
- package/src/preferences.mjs +69 -5
- package/src/server.mjs +81 -33
- package/src/taps/claude-usage.mjs +16 -1
- package/src/usage-poll.mjs +260 -0
- package/src/usage.mjs +33 -1
package/src/board/floor.js
CHANGED
|
@@ -1,11 +1,43 @@
|
|
|
1
|
-
// Leg floor, the scheduler-eye view: what
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
//
|
|
499
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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
|
})()
|