@ucsandman/legcli 0.7.0
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 +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- package/src/worktree.mjs +207 -0
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
// Leg board: vanilla JS, no build step. Talks to /api/* (see src/server.mjs)
|
|
2
|
+
// and /api/events (SSE). Keeps one row per card_id, so a live push patches one
|
|
3
|
+
// row instead of re-rendering the list. Design: .design/BOARD-DESIGN.md. Ids,
|
|
4
|
+
// class names and source shapes: .design/BUILD-CONTRACT.md section 6.2. Three
|
|
5
|
+
// of those shapes are pinned by test/board-updates.test.mjs, which runs this
|
|
6
|
+
// file as source:
|
|
7
|
+
// 1. The export line at the bottom stays ONE line with no nested braces: the
|
|
8
|
+
// test rewrites it with /module\.exports = \{[^}]+\}/ and takes the FIRST
|
|
9
|
+
// match, so that text must not appear earlier in the file either.
|
|
10
|
+
// 2. upsertCard, scheduleDrawerRefresh, fetchCards, ensureLogLoaded and the
|
|
11
|
+
// 'hello' listener keep their headers and the calls named in the contract.
|
|
12
|
+
// 3. Every id is looked up by exactly the string the test's fake DOM
|
|
13
|
+
// registers. That fake node has no insertBefore, no querySelector that
|
|
14
|
+
// finds anything and no focus(), which is why this file appends rather
|
|
15
|
+
// than inserts and reads its own nodes back through `root.slots`.
|
|
16
|
+
(function () {
|
|
17
|
+
'use strict'
|
|
18
|
+
|
|
19
|
+
// 6.3: the nine shipped status words, unchanged, each beside the mark tone it
|
|
20
|
+
// takes. The word carries the meaning; the 7px mark is aria-hidden.
|
|
21
|
+
const STATUS_LABELS = {
|
|
22
|
+
running: 'running', handing_off: 'handing off', waiting_human: 'waiting human',
|
|
23
|
+
needs_approval: 'needs approval', paused: 'paused', queued: 'queued',
|
|
24
|
+
backlog: 'backlog', done: 'done', failed: 'failed', killed: 'killed',
|
|
25
|
+
}
|
|
26
|
+
const STATUS_TONE = {
|
|
27
|
+
running: 'run', handing_off: 'warn', waiting_human: 'warn', needs_approval: 'warn',
|
|
28
|
+
paused: 'idle', queued: 'idle', backlog: 'idle', done: 'idle', failed: 'danger', killed: 'danger',
|
|
29
|
+
}
|
|
30
|
+
// rows are ordered needs-you first, then the verdicts you have to answer, then
|
|
31
|
+
// work in flight, then the queue, then what is finished
|
|
32
|
+
const ROW_RANK = {
|
|
33
|
+
needs_approval: 0, waiting_human: 0, failed: 1, killed: 1, running: 2,
|
|
34
|
+
handing_off: 2, paused: 3, queued: 4, backlog: 5, done: 6,
|
|
35
|
+
}
|
|
36
|
+
// 6.5 G10: the button order is fixed and never reflows by availability
|
|
37
|
+
const ACTION_ORDER = ['approve', 'enqueue', 'resume', 'pause', 'handoff_now', 'rerun', 'reassign', 'kill']
|
|
38
|
+
const ACTION_LABELS = {
|
|
39
|
+
enqueue: 'Run', pause: 'Pause', resume: 'Resume', kill: 'Kill', reassign: 'Reassign',
|
|
40
|
+
handoff_now: 'Hand off now', approve: 'Approve', rerun: 'Rerun',
|
|
41
|
+
}
|
|
42
|
+
const ACTION_PATHS = {
|
|
43
|
+
enqueue: 'run', pause: 'pause', resume: 'resume', kill: 'kill', reassign: 'reassign',
|
|
44
|
+
handoff_now: 'handoff', approve: 'approve', rerun: 'rerun',
|
|
45
|
+
}
|
|
46
|
+
const ACTION_CLASS = { approve: 'btn-primary', enqueue: 'btn-primary', kill: 'btn-danger' }
|
|
47
|
+
// the permission modes come off /api/adapters as each CLI's own enum, in three
|
|
48
|
+
// spellings at once (acceptEdits, accept-edits, workspace-write), which is the
|
|
49
|
+
// one field in the dialog that decides what an agent may do to the repo. The
|
|
50
|
+
// value sent is still the enum; only the label the reader picks from is ours.
|
|
51
|
+
const MODE_LABELS = {
|
|
52
|
+
acceptEdits: 'edit files without asking',
|
|
53
|
+
'accept-edits': 'edit files without asking',
|
|
54
|
+
auto_edit: 'edit files without asking',
|
|
55
|
+
'workspace-write': 'write inside the worktree',
|
|
56
|
+
'read-only': 'read only',
|
|
57
|
+
plan: 'plan only',
|
|
58
|
+
}
|
|
59
|
+
const AGENT_IDS = ['claude', 'codex', 'agy']
|
|
60
|
+
const DEFAULT_BIND = '127.0.0.1:4747'
|
|
61
|
+
const TIMELINE_CAP = 12
|
|
62
|
+
// mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
|
|
63
|
+
// host arrives bracketed
|
|
64
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', '::1', 'localhost', '::ffff:127.0.0.1']
|
|
65
|
+
|
|
66
|
+
const state = {
|
|
67
|
+
cards: new Map(),
|
|
68
|
+
// the fake DOM in test/board-updates.test.mjs pre-populates this map before
|
|
69
|
+
// it calls fetchCards/upsertCard; the seven kanban columns it named are gone
|
|
70
|
+
// from the page, so nothing else writes to it
|
|
71
|
+
columnEls: new Map(),
|
|
72
|
+
cardNodes: new Map(),
|
|
73
|
+
rowOrder: '',
|
|
74
|
+
logState: new Map(),
|
|
75
|
+
drawerId: null,
|
|
76
|
+
drawerTimer: null,
|
|
77
|
+
drawerRequest: 0,
|
|
78
|
+
drawerDetail: null,
|
|
79
|
+
drawerEvents: [],
|
|
80
|
+
timelineEl: null,
|
|
81
|
+
timelineCap: TIMELINE_CAP,
|
|
82
|
+
adapters: null,
|
|
83
|
+
es: null,
|
|
84
|
+
retryMs: 1000,
|
|
85
|
+
sseRequest: 0,
|
|
86
|
+
authRequest: 0,
|
|
87
|
+
cardsRequest: 0,
|
|
88
|
+
boardRevision: 0,
|
|
89
|
+
logRequests: new Map(),
|
|
90
|
+
nextLogRequest: 0,
|
|
91
|
+
bind: DEFAULT_BIND,
|
|
92
|
+
// health answers "can anything but this machine reach the board": a
|
|
93
|
+
// loopback bind with share off is local only, and a token there is a field
|
|
94
|
+
// nobody can ever have a use for
|
|
95
|
+
shareOn: false,
|
|
96
|
+
isOwner: true,
|
|
97
|
+
// state.bind is DEFAULT_BIND until health says otherwise, so a board that
|
|
98
|
+
// 401s the first request would otherwise name 127.0.0.1:4747 to someone
|
|
99
|
+
// looking at a different server entirely. A guest's health is redacted and
|
|
100
|
+
// carries no bind at all, so answering is not the same as knowing where.
|
|
101
|
+
healthKnown: false,
|
|
102
|
+
bindKnown: false,
|
|
103
|
+
lastHello: null,
|
|
104
|
+
}
|
|
105
|
+
// a card push while an agent writes its log only moves these two
|
|
106
|
+
const VOLATILE_CARD_FIELDS = ['last_event', 'elapsed_ms']
|
|
107
|
+
const DRAWER_REFRESH_MS = 1000
|
|
108
|
+
const LOG_REFRESH_MS = 5000
|
|
109
|
+
|
|
110
|
+
// ---- helpers ----
|
|
111
|
+
// a shared link carries the person's token: keep it, then take it out of the
|
|
112
|
+
// address bar so it is not in a screenshot or the next copied URL
|
|
113
|
+
try {
|
|
114
|
+
const fromLink = new URL(location.href).searchParams.get('token')
|
|
115
|
+
if (fromLink) {
|
|
116
|
+
localStorage.setItem('legToken', fromLink)
|
|
117
|
+
const clean = new URL(location.href)
|
|
118
|
+
clean.searchParams.delete('token')
|
|
119
|
+
history.replaceState(null, '', clean.pathname + clean.search + clean.hash)
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
function getToken() { return localStorage.getItem('legToken') || localStorage.getItem('batonToken') || '' }
|
|
123
|
+
|
|
124
|
+
async function api(path, opts = {}) {
|
|
125
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
126
|
+
const token = getToken()
|
|
127
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
128
|
+
const res = await fetch(path, {
|
|
129
|
+
method: opts.method || 'GET',
|
|
130
|
+
headers,
|
|
131
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
132
|
+
})
|
|
133
|
+
const text = await res.text()
|
|
134
|
+
let data = null
|
|
135
|
+
if (text) { try { data = JSON.parse(text) } catch { data = null } }
|
|
136
|
+
if (!res.ok) throw new Error((data && data.error) || `${res.status} ${res.statusText}`)
|
|
137
|
+
return data
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function el(tag, attrs, children) {
|
|
141
|
+
const node = document.createElement(tag)
|
|
142
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
143
|
+
if (v == null || v === false) continue
|
|
144
|
+
if (k === 'class') node.className = v
|
|
145
|
+
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v)
|
|
146
|
+
else if (v === true) node.setAttribute(k, '')
|
|
147
|
+
else node.setAttribute(k, v)
|
|
148
|
+
}
|
|
149
|
+
for (const c of [].concat(children || [])) {
|
|
150
|
+
if (c == null) continue
|
|
151
|
+
node.appendChild(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c)
|
|
152
|
+
}
|
|
153
|
+
return node
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- 6.9 the system message ----
|
|
157
|
+
// One implementation for the whole page, published as window.batonMessage so
|
|
158
|
+
// sessions.js reaches it instead of writing a second one into the same box.
|
|
159
|
+
// One message at a time, deduped by exact text. A result that belongs to a
|
|
160
|
+
// terminal or a card is written into that object's sentence slot, not here.
|
|
161
|
+
// Errors never auto-dismiss, which is why `tone` defaults to danger: a bare
|
|
162
|
+
// call is an error, and an error the reader has not seen does not disappear.
|
|
163
|
+
const MESSAGE_WORD = { ok: 'done', warn: 'warning', danger: 'error' }
|
|
164
|
+
const message = { text: null, node: null, timer: null }
|
|
165
|
+
|
|
166
|
+
function dismissMessage() {
|
|
167
|
+
if (message.timer) { clearTimeout(message.timer); message.timer = null }
|
|
168
|
+
if (message.node) message.node.remove()
|
|
169
|
+
message.node = null
|
|
170
|
+
message.text = null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toast(msg, tone = 'danger') {
|
|
174
|
+
const text = String(msg)
|
|
175
|
+
const box = document.getElementById('toast')
|
|
176
|
+
if (!box || text === message.text) return
|
|
177
|
+
dismissMessage()
|
|
178
|
+
const word = MESSAGE_WORD[tone] || MESSAGE_WORD.danger
|
|
179
|
+
const item = el('div', { class: `sysmsg-item tone-${tone}` }, [
|
|
180
|
+
el('span', { class: 'status-word' }, [el('span', { class: `mark tone-${tone}`, 'aria-hidden': 'true' }), word]),
|
|
181
|
+
el('span', {}, [text]),
|
|
182
|
+
])
|
|
183
|
+
if (tone === 'danger') {
|
|
184
|
+
item.appendChild(el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': 'Dismiss message', onclick: dismissMessage }, ['Dismiss']))
|
|
185
|
+
} else {
|
|
186
|
+
message.timer = setTimeout(dismissMessage, 6000)
|
|
187
|
+
}
|
|
188
|
+
box.appendChild(item)
|
|
189
|
+
message.node = item
|
|
190
|
+
message.text = text
|
|
191
|
+
}
|
|
192
|
+
if (typeof window !== 'undefined') window.legMessage = toast;
|
|
193
|
+
if (typeof window !== 'undefined') window.batonMessage = toast;
|
|
194
|
+
|
|
195
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
196
|
+
|
|
197
|
+
function truncate(str, n) {
|
|
198
|
+
const s = String(str || '')
|
|
199
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/board/sessions.js OWNS THE TIME GRAMMAR FOR THE BOARD. elapsedClock()
|
|
203
|
+
// and clockAt() below are its functions character for character, because the
|
|
204
|
+
// three board files cannot import from each other and one fact must never
|
|
205
|
+
// print in two formats: `404:30` on a background card two regions under
|
|
206
|
+
// `06:44:30` on a terminal card was the same run measured two ways.
|
|
207
|
+
// G12: fixed form, zero padded, so a column of elapsed clocks aligns on its
|
|
208
|
+
// colon whether the terminal has run four minutes or four hours.
|
|
209
|
+
function elapsedClock(ms) {
|
|
210
|
+
const s = Math.max(0, Math.floor(ms / 1000))
|
|
211
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
212
|
+
const mm = pad(Math.floor(s / 60) % 60)
|
|
213
|
+
const ss = pad(s % 60)
|
|
214
|
+
const h = Math.floor(s / 3600)
|
|
215
|
+
return h ? `${pad(h)}:${mm}:${ss}` : `${mm}:${ss}`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// an idle card has no run to measure, and a run whose start did not parse is
|
|
219
|
+
// not a zero-length run: both print the placeholder rather than a number
|
|
220
|
+
// A running card counts up from its current run. A card that has finished
|
|
221
|
+
// still owes the reader the number: created_at to updated_at is what it took,
|
|
222
|
+
// and `--:--` in that column was three of the five demo stills saying nothing.
|
|
223
|
+
// Only a card that has never started keeps the placeholder.
|
|
224
|
+
function runElapsed(card) {
|
|
225
|
+
const from = card.active_run ? Date.parse(card.active_run.started_at) : NaN
|
|
226
|
+
if (Number.isFinite(from)) return elapsedClock(Date.now() - from)
|
|
227
|
+
if (!card.runs_count) return '--:--'
|
|
228
|
+
const started = Date.parse(card.created_at)
|
|
229
|
+
const ended = Date.parse(card.updated_at)
|
|
230
|
+
return Number.isFinite(started) && Number.isFinite(ended) && ended >= started
|
|
231
|
+
? elapsedClock(ended - started)
|
|
232
|
+
: '--:--'
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function clockAt(ms) {
|
|
236
|
+
if (!Number.isFinite(ms)) return 'unknown'
|
|
237
|
+
const d = new Date(ms)
|
|
238
|
+
const out = Math.abs(ms - Date.now())
|
|
239
|
+
if (out < 20 * 3600000) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
240
|
+
if (out < 6 * 86400000) return d.toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })
|
|
241
|
+
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// times are local and the head says so once, in .head-caption
|
|
245
|
+
function clock(ts) {
|
|
246
|
+
const at = ts ? Date.parse(ts) : NaN
|
|
247
|
+
return Number.isFinite(at) ? clockAt(at) : 'an unknown time'
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatLastEvent(last) {
|
|
251
|
+
if (!last) return 'no events yet'
|
|
252
|
+
return truncate(`${last.type}: ${last.summary}`, 90)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function formatActor(actor) {
|
|
256
|
+
if (!actor) return 'baton'
|
|
257
|
+
if (actor.type === 'human') return `human:${actor.id || 'local'}`
|
|
258
|
+
if (actor.type === 'agent') return `agent:${actor.adapter || '?'}`
|
|
259
|
+
return 'baton'
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function baseAdapterName(name) { return String(name || '').replace(/^fake-/, '') }
|
|
263
|
+
function agentClass(name) {
|
|
264
|
+
const base = baseAdapterName(name)
|
|
265
|
+
return AGENT_IDS.includes(base) ? base : 'fake'
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Kept by name: docs/board-guide.md references it. It returns the leg's state
|
|
269
|
+
// as a word now, because section 8 of the design deletes the check, the cross
|
|
270
|
+
// and the arrow from the copy, and the subset fonts carry none of them.
|
|
271
|
+
function stateGlyph(s) {
|
|
272
|
+
if (s === 'done') return 'done'
|
|
273
|
+
if (s === 'handed') return 'handed off'
|
|
274
|
+
if (s === 'failed') return 'failed'
|
|
275
|
+
if (s === 'active') return 'running'
|
|
276
|
+
return 'pending'
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---- SSE ----
|
|
280
|
+
function setSseState(s, waitMs) {
|
|
281
|
+
const rule = document.getElementById('sse-dot')
|
|
282
|
+
const text = document.getElementById('sse-text')
|
|
283
|
+
const banner = document.getElementById('banner')
|
|
284
|
+
rule.className = `sse-rule is-${s}`
|
|
285
|
+
text.textContent = s === 'live' ? 'live'
|
|
286
|
+
: s === 'reconnecting' ? `reconnecting, next attempt in ${Math.round((waitMs || state.retryMs) / 1000)}s`
|
|
287
|
+
: 'connecting'
|
|
288
|
+
banner.hidden = s === 'live'
|
|
289
|
+
if (banner.hidden) return
|
|
290
|
+
banner.textContent = s === 'reconnecting' && state.lastHello
|
|
291
|
+
? `Reconnecting to Leg. Last reading ${clock(state.lastHello)}.`
|
|
292
|
+
: `${s === 'reconnecting' ? 'Reconnecting' : 'Connecting'} to Leg on ${state.bind}.`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function connectSse() {
|
|
296
|
+
if (state.es) { try { state.es.close() } catch { /* ignore */ } }
|
|
297
|
+
const request = ++state.sseRequest
|
|
298
|
+
setSseState('connecting')
|
|
299
|
+
const token = getToken()
|
|
300
|
+
const url = token ? `/api/events?token=${encodeURIComponent(token)}` : '/api/events'
|
|
301
|
+
const es = new EventSource(url)
|
|
302
|
+
state.es = es
|
|
303
|
+
es.addEventListener('hello', (e) => {
|
|
304
|
+
if (state.es !== es || request !== state.sseRequest) return
|
|
305
|
+
state.retryMs = 1000
|
|
306
|
+
state.lastHello = new Date().toISOString()
|
|
307
|
+
setSseState('live')
|
|
308
|
+
const data = JSON.parse(e.data)
|
|
309
|
+
state.boardRevision += 1
|
|
310
|
+
state.cards = new Map(data.cards.map((c) => [c.card_id, c]))
|
|
311
|
+
renderBoard()
|
|
312
|
+
// nothing between the drop and this hello was replayed: an open detail
|
|
313
|
+
// region is as old as the gap
|
|
314
|
+
scheduleDrawerRefresh()
|
|
315
|
+
if (data.sessions) window.dispatchEvent(new CustomEvent('leg:sessions', { detail: data.sessions })); window.dispatchEvent(new CustomEvent('baton:sessions', { detail: data.sessions }));
|
|
316
|
+
})
|
|
317
|
+
es.addEventListener('sessions', (e) => { if (state.es === es && request === state.sseRequest) window.dispatchEvent(new CustomEvent('leg:sessions', { detail: JSON.parse(e.data) })); window.dispatchEvent(new CustomEvent('baton:sessions', { detail: JSON.parse(e.data) })); })
|
|
318
|
+
es.addEventListener('card', (e) => { if (state.es === es && request === state.sseRequest) upsertCard(JSON.parse(e.data)) })
|
|
319
|
+
es.addEventListener('removed', (e) => { if (state.es === es && request === state.sseRequest) dropCard(JSON.parse(e.data).card_id) })
|
|
320
|
+
es.addEventListener('event', (e) => { if (state.es === es && request === state.sseRequest) onLedgerEvent(JSON.parse(e.data)) })
|
|
321
|
+
es.addEventListener('health', (e) => { if (state.es === es && request === state.sseRequest) renderScheduler(JSON.parse(e.data).scheduler) })
|
|
322
|
+
es.onopen = () => { if (state.es === es && request === state.sseRequest) fetchCards() }
|
|
323
|
+
es.onerror = () => {
|
|
324
|
+
if (state.es !== es || request !== state.sseRequest) return
|
|
325
|
+
const wait = state.retryMs || 1000
|
|
326
|
+
setSseState('reconnecting', wait)
|
|
327
|
+
try { es.close() } catch { /* ignore */ }
|
|
328
|
+
setTimeout(() => { if (request === state.sseRequest) connectSse() }, wait)
|
|
329
|
+
state.retryMs = Math.min(wait * 2, 15000)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- the page's own status lines ----
|
|
334
|
+
function renderScheduler(sched) {
|
|
335
|
+
if (!sched) return
|
|
336
|
+
document.getElementById('sched-status').textContent =
|
|
337
|
+
`scheduler ${sched.running ? 'running' : 'stopped'}, ${sched.max_concurrent} max`
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// a guest on a shared board has no pipeline side: take it off the page
|
|
341
|
+
// instead of shouting 403 at them
|
|
342
|
+
const ownerOnly = (err) => /belongs to the owner of this machine/.test(err.message)
|
|
343
|
+
// `node`, not `el`: the loop variable shadowed this file's own el() DOM
|
|
344
|
+
// factory, so any line added inside these two loops that builds a node would
|
|
345
|
+
// fail against a name that is in scope everywhere else in the file
|
|
346
|
+
function guestMode() {
|
|
347
|
+
document.body.classList.add('guest')
|
|
348
|
+
state.isOwner = false
|
|
349
|
+
renderTokenMeta()
|
|
350
|
+
for (const node of [document.getElementById('board'), document.getElementById('new-card-btn'), document.querySelector('.masthead a[href="/floor"]')]) if (node) node.hidden = true
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function ownerMode() {
|
|
354
|
+
document.body.classList.remove('guest')
|
|
355
|
+
for (const node of [document.getElementById('board'), document.getElementById('new-card-btn'), document.querySelector('.masthead a[href="/floor"]')]) if (node) node.hidden = false
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const isGuest = () => document.body.classList.contains('guest')
|
|
359
|
+
|
|
360
|
+
async function fetchCards() {
|
|
361
|
+
if (isGuest()) return
|
|
362
|
+
const request = ++state.cardsRequest
|
|
363
|
+
const revision = state.boardRevision
|
|
364
|
+
try {
|
|
365
|
+
const data = await api('/api/cards')
|
|
366
|
+
if (request !== state.cardsRequest || revision !== state.boardRevision || isGuest()) return
|
|
367
|
+
state.cards = new Map(data.cards.map((c) => [c.card_id, c]))
|
|
368
|
+
renderBoard()
|
|
369
|
+
scheduleDrawerRefresh()
|
|
370
|
+
} catch (err) {
|
|
371
|
+
if (ownerOnly(err)) return guestMode()
|
|
372
|
+
toast(err.message)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function loadHealth() {
|
|
377
|
+
const request = ++state.authRequest
|
|
378
|
+
try {
|
|
379
|
+
const data = await api('/api/health')
|
|
380
|
+
if (request !== state.authRequest) return false
|
|
381
|
+
if (data.bind) { state.bind = `${data.bind}:${data.port}`; state.bindKnown = true }
|
|
382
|
+
state.shareOn = !!(data.you && data.you.share && data.you.share.on)
|
|
383
|
+
state.isOwner = !(data.you && data.you.role && data.you.role !== 'owner')
|
|
384
|
+
state.healthKnown = true
|
|
385
|
+
renderBoardFacts(data)
|
|
386
|
+
renderTokenMeta()
|
|
387
|
+
// a guest is told who they are by health, which is open to them
|
|
388
|
+
if (data.you && data.you.role && data.you.role !== 'owner') { guestMode(); return false }
|
|
389
|
+
ownerMode()
|
|
390
|
+
renderScheduler(data.scheduler)
|
|
391
|
+
return true
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (request !== state.authRequest) return false
|
|
394
|
+
if (ownerOnly(err)) return guestMode()
|
|
395
|
+
// health is the only thing that can say this board is local; without it
|
|
396
|
+
// the token field stays on screen, or a viewer whose token is missing or
|
|
397
|
+
// wrong has no way to type one
|
|
398
|
+
state.isOwner = false
|
|
399
|
+
state.healthKnown = false
|
|
400
|
+
state.bindKnown = false
|
|
401
|
+
renderTokenMeta()
|
|
402
|
+
toast(err.message)
|
|
403
|
+
return false
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---- 6.12 step 8: one row per card, no columns ----
|
|
408
|
+
// Background tasks are the third ledger cell: a count, a line of detail and a
|
|
409
|
+
// disclosure, the same shape as finished terminals and what landed. Nothing
|
|
410
|
+
// headless is what the board is for, so the rows live behind the button.
|
|
411
|
+
let cardsOpen = false
|
|
412
|
+
function toggleEmptyState() {
|
|
413
|
+
const empty = document.getElementById('empty-state')
|
|
414
|
+
const list = document.getElementById('columns')
|
|
415
|
+
const panel = document.getElementById('cards-drawer')
|
|
416
|
+
const hasCards = state.cards.size > 0
|
|
417
|
+
empty.hidden = hasCards
|
|
418
|
+
list.hidden = !hasCards
|
|
419
|
+
if (panel) panel.hidden = !(hasCards && cardsOpen)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function countCards(...statuses) {
|
|
423
|
+
let n = 0
|
|
424
|
+
for (const c of state.cards.values()) if (statuses.includes(c.status)) n += 1
|
|
425
|
+
return n
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// G14: a verdict carries its volume
|
|
429
|
+
function cardsMeta() {
|
|
430
|
+
const total = state.cards.size
|
|
431
|
+
if (!total) return ''
|
|
432
|
+
const running = countCards('running', 'handing_off')
|
|
433
|
+
const queued = countCards('queued')
|
|
434
|
+
const backlog = countCards('backlog')
|
|
435
|
+
const waiting = countCards('waiting_human', 'needs_approval')
|
|
436
|
+
const finished = countCards('done', 'failed', 'killed')
|
|
437
|
+
const parts = []
|
|
438
|
+
if (running) parts.push(`${running} running`)
|
|
439
|
+
if (queued) parts.push(`${queued} queued`)
|
|
440
|
+
if (backlog) parts.push(`${backlog} in backlog`)
|
|
441
|
+
if (waiting) parts.push(`${waiting} waiting on you`)
|
|
442
|
+
if (finished) parts.push(`${finished} finished`)
|
|
443
|
+
return parts.length ? parts.join(', ') : `${total} cards, nothing is running`
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function renderCardsMeta() {
|
|
447
|
+
const meta = document.querySelector('#board .region-meta')
|
|
448
|
+
const head = document.getElementById('cards-head')
|
|
449
|
+
const slot = document.querySelector('#board .ledger-actions')
|
|
450
|
+
const total = state.cards.size
|
|
451
|
+
if (meta) meta.textContent = total ? cardsMeta() : 'Nothing is queued. Leg starts the next login only when a terminal hands off.'
|
|
452
|
+
if (head) head.textContent = total ? `${total} background task${total === 1 ? '' : 's'}` : 'No background tasks'
|
|
453
|
+
if (!slot) return
|
|
454
|
+
const existing = document.getElementById('cards-toggle')
|
|
455
|
+
if (!total) { if (existing) existing.remove(); return }
|
|
456
|
+
const label = cardsOpen ? `Hide the ${total}` : `View ${total} card${total === 1 ? '' : 's'}`
|
|
457
|
+
if (existing) { existing.textContent = label; existing.setAttribute('aria-expanded', cardsOpen ? 'true' : 'false'); return }
|
|
458
|
+
const btn = el('button', { type: 'button', class: 'btn btn-secondary', id: 'cards-toggle', 'aria-expanded': cardsOpen ? 'true' : 'false', 'aria-controls': 'cards-drawer' }, [label])
|
|
459
|
+
btn.addEventListener('click', () => { cardsOpen = !cardsOpen; toggleEmptyState(); renderCardsMeta() })
|
|
460
|
+
slot.appendChild(btn)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function rowRank(card) {
|
|
464
|
+
const r = ROW_RANK[card.status]
|
|
465
|
+
return r === undefined ? 7 : r
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function startedAt(card) {
|
|
469
|
+
const at = (card.active_run && card.active_run.started_at) || card.updated_at || card.created_at
|
|
470
|
+
const ms = at ? Date.parse(at) : NaN
|
|
471
|
+
return Number.isNaN(ms) ? 0 : ms
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function orderedCards() {
|
|
475
|
+
return [...state.cards.values()].sort((a, b) => rowRank(a) - rowRank(b) || startedAt(a) - startedAt(b))
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Re-appending every row costs one DOM move each and can move a node out from
|
|
479
|
+
// under the cursor, so it happens only when the order string actually changed.
|
|
480
|
+
function orderRows() {
|
|
481
|
+
const list = document.getElementById('columns')
|
|
482
|
+
const order = orderedCards().map((c) => c.card_id)
|
|
483
|
+
const key = order.join(',')
|
|
484
|
+
if (key === state.rowOrder) return
|
|
485
|
+
state.rowOrder = key
|
|
486
|
+
for (const id of order) {
|
|
487
|
+
const row = state.cardNodes.get(id)
|
|
488
|
+
if (row) list.appendChild(row)
|
|
489
|
+
}
|
|
490
|
+
if (state.drawerId) placeDetail(state.drawerId)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function renderBoard() {
|
|
494
|
+
const list = document.getElementById('columns')
|
|
495
|
+
// the detail region is a child of this list while a row is expanded: park it
|
|
496
|
+
// back on the body so the wipe below does not take it out of the document
|
|
497
|
+
document.body.appendChild(document.getElementById('drawer'))
|
|
498
|
+
list.textContent = ''
|
|
499
|
+
state.cardNodes = new Map()
|
|
500
|
+
const order = orderedCards()
|
|
501
|
+
for (const card of order) renderRow(card)
|
|
502
|
+
state.rowOrder = order.map((c) => c.card_id).join(',')
|
|
503
|
+
if (state.drawerId) placeDetail(state.drawerId)
|
|
504
|
+
renderCardsMeta()
|
|
505
|
+
toggleEmptyState()
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// the board is pushed a card for every write under its directory, log bytes
|
|
509
|
+
// included, so a push is only worth two API calls when the drawer's own
|
|
510
|
+
// content moved with it
|
|
511
|
+
function cardSignature(card) {
|
|
512
|
+
const stable = {}
|
|
513
|
+
for (const [k, v] of Object.entries(card)) if (!VOLATILE_CARD_FIELDS.includes(k)) stable[k] = v
|
|
514
|
+
return JSON.stringify(stable)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function drawerRefreshNeeded(prev, next) {
|
|
518
|
+
return !prev || cardSignature(prev) !== cardSignature(next)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// the cached tail belongs to one run: a new run replaces it, and a running
|
|
522
|
+
// one is re-read at most every LOG_REFRESH_MS so it does not freeze
|
|
523
|
+
function staleLogTail(cache, card, now) {
|
|
524
|
+
if (!cache) return 0
|
|
525
|
+
const run = card.active_run ? card.active_run.run : null
|
|
526
|
+
const otherRun = cache.runs_count !== card.runs_count || cache.run !== run
|
|
527
|
+
const growing = Boolean(card.active_run) && now - cache.at >= LOG_REFRESH_MS
|
|
528
|
+
if (!otherRun && !growing) return 0
|
|
529
|
+
return cache.expanded ? 200 : 8
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function upsertCard(card) {
|
|
533
|
+
state.boardRevision += 1
|
|
534
|
+
const prev = state.cards.get(card.card_id)
|
|
535
|
+
const tail = staleLogTail(state.logState.get(card.card_id), card, Date.now())
|
|
536
|
+
renderRow(card)
|
|
537
|
+
orderRows()
|
|
538
|
+
renderCardsMeta()
|
|
539
|
+
toggleEmptyState()
|
|
540
|
+
if (tail) ensureLogLoaded(card.card_id, tail)
|
|
541
|
+
if (state.drawerId === card.card_id && drawerRefreshNeeded(prev, card)) scheduleDrawerRefresh()
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function dropCard(id) {
|
|
545
|
+
state.boardRevision += 1
|
|
546
|
+
state.cards.delete(id)
|
|
547
|
+
state.logState.delete(id)
|
|
548
|
+
const root = state.cardNodes.get(id)
|
|
549
|
+
if (root) { root.remove(); state.cardNodes.delete(id) }
|
|
550
|
+
state.rowOrder = ''
|
|
551
|
+
renderCardsMeta()
|
|
552
|
+
toggleEmptyState()
|
|
553
|
+
if (state.drawerId === id) closeDrawer()
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function onLedgerEvent(ev) {
|
|
557
|
+
const card = state.cards.get(ev.card_id)
|
|
558
|
+
if (card) {
|
|
559
|
+
card.last_event = { ts: ev.ts, type: ev.type, summary: ev.summary, actor: ev.actor }
|
|
560
|
+
const root = state.cardNodes.get(ev.card_id)
|
|
561
|
+
if (root && root.slots) {
|
|
562
|
+
const said = cardSentence(card)
|
|
563
|
+
root.slots.sentence.className = `sentence tone-${said.tone}`
|
|
564
|
+
root.slots.sentence.textContent = said.text
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (state.drawerId === ev.card_id) appendDrawerEvent(ev)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function tickElapsed() {
|
|
571
|
+
for (const [id, card] of state.cards) {
|
|
572
|
+
if (!card.active_run) continue
|
|
573
|
+
const root = state.cardNodes.get(id)
|
|
574
|
+
if (!root || !root.slots) continue
|
|
575
|
+
root.slots.elapsed.textContent = runElapsed(card)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ---- the row ----
|
|
580
|
+
// 6.3: a 7px square and one of the nine shipped words. The mark is decoration;
|
|
581
|
+
// deleting it loses nothing, which is why it is aria-hidden.
|
|
582
|
+
function statusWord(card) {
|
|
583
|
+
let label = STATUS_LABELS[card.status] || card.status
|
|
584
|
+
if (card.status === 'running' && card.station_kind === 'land') label = 'landing'
|
|
585
|
+
else if (card.status === 'waiting_human' && card.pr_url) label = 'PR open'
|
|
586
|
+
return el('span', { class: 'status-word' }, [
|
|
587
|
+
el('span', { class: `mark tone-${STATUS_TONE[card.status] || 'idle'}`, 'aria-hidden': 'true' }),
|
|
588
|
+
label,
|
|
589
|
+
])
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// R2 line 2, exactly one sentence, the highest-ranked thing true about the
|
|
593
|
+
// card. Every branch names a station, a time, a count or the server's own
|
|
594
|
+
// reason, so the reader can check it.
|
|
595
|
+
function cardSentence(card) {
|
|
596
|
+
const last = card.last_event
|
|
597
|
+
if (card.bounce_reason && ['queued', 'running', 'handing_off', 'needs_approval'].includes(card.status)) {
|
|
598
|
+
const attempt = card.land_attempts ? ` on attempt ${card.land_attempts}` : ''
|
|
599
|
+
return { tone: 'danger', text: `Land bounced${attempt}: ${card.bounce_reason}. The worktree still holds every commit; nothing was lost.` }
|
|
600
|
+
}
|
|
601
|
+
if (card.status === 'queued' && last && last.type === 'blocked_by') return { tone: 'warn', text: last.summary }
|
|
602
|
+
if (['failed', 'killed'].includes(card.status)) {
|
|
603
|
+
return { tone: 'danger', text: `${card.status} at station ${card.station} after ${plural(card.runs_count || 0, 'run')}: ${last ? last.summary : 'no events yet'}` }
|
|
604
|
+
}
|
|
605
|
+
if (['waiting_human', 'needs_approval'].includes(card.status)) {
|
|
606
|
+
return { tone: 'warn', text: `waiting on you at station ${card.station} since ${clock(card.updated_at)}` }
|
|
607
|
+
}
|
|
608
|
+
if (card.status === 'done') return { tone: 'ok', text: `done after ${plural(card.runs_count || 0, 'run')}, ${last ? last.summary : 'no events yet'}` }
|
|
609
|
+
return { tone: 'muted', text: `${formatLastEvent(last)}${last ? `, ${clock(last.ts)}` : ''}` }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function shortWorktree(card) {
|
|
613
|
+
if (!card.worktree) return ''
|
|
614
|
+
const parts = String(card.worktree).split(/[\\/]/).filter(Boolean)
|
|
615
|
+
let i = parts.lastIndexOf('.leg-worktrees'); if (i === -1) i = parts.lastIndexOf('.baton-worktrees');
|
|
616
|
+
return i > 0 ? parts.slice(i - 1).join('/') : parts.slice(-2).join('/')
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 6.11: flat inline tokens, middot-separated by CSS, each agent name printed
|
|
620
|
+
// beside its own colour so identity never rides on hue alone.
|
|
621
|
+
function buildChainRail(card) {
|
|
622
|
+
const rail = el('div', { class: 'chain-rail' })
|
|
623
|
+
for (const entry of card.chain_view || []) {
|
|
624
|
+
rail.appendChild(el('span', { class: `chip chip-id-${agentClass(entry.adapter)}` }, [entry.adapter]))
|
|
625
|
+
const tone = entry.state === 'done' ? 'chip-state-ok' : entry.state === 'failed' ? 'chip-state-bad' : 'chip'
|
|
626
|
+
rail.appendChild(el('span', { class: `chip ${tone}` }, [stateGlyph(entry.state)]))
|
|
627
|
+
}
|
|
628
|
+
return rail
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function buildLeases(card) {
|
|
632
|
+
const leases = card.leases && card.leases.length ? card.leases : ['**']
|
|
633
|
+
const blocked = card.status === 'queued' && card.last_event && card.last_event.type === 'blocked_by'
|
|
634
|
+
const wrap = el('div', { class: 'leases' }, leases.map((l) => el('span', { class: 'chip' }, [l])))
|
|
635
|
+
if (blocked) wrap.appendChild(el('span', { class: 'chip chip-state-warn' }, ['blocked by lease']))
|
|
636
|
+
return wrap
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// 6.5: no confirm() anywhere on this board. The buttons swap for a sentence
|
|
640
|
+
// and two controls on --e4, focus moves to Cancel, Escape cancels. sessions.js
|
|
641
|
+
// carries a copy of this function: these two files have no module system, so a
|
|
642
|
+
// change here is a prompt to change the copy there.
|
|
643
|
+
let openConfirm = null
|
|
644
|
+
function confirmRow(question, verb, onYes, onCancel) {
|
|
645
|
+
const cancel = el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => { openConfirm = null; onCancel() } }, ['Cancel'])
|
|
646
|
+
const row = el('div', { class: 'confirm-row' }, [
|
|
647
|
+
el('span', {}, [question]),
|
|
648
|
+
el('button', { type: 'button', class: 'btn btn-danger', onclick: () => { openConfirm = null; onYes() } }, [verb]),
|
|
649
|
+
cancel,
|
|
650
|
+
])
|
|
651
|
+
openConfirm = onCancel
|
|
652
|
+
row.cancelBtn = cancel
|
|
653
|
+
return row
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function runAction(card, action) {
|
|
657
|
+
try {
|
|
658
|
+
await api(`/api/cards/${encodeURIComponent(card.card_id)}/${ACTION_PATHS[action]}`, { method: 'POST', body: {} })
|
|
659
|
+
} catch (err) {
|
|
660
|
+
toast(err.message)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async function removeCardAction(card) {
|
|
665
|
+
try {
|
|
666
|
+
await api(`/api/cards/${encodeURIComponent(card.card_id)}`, { method: 'DELETE' })
|
|
667
|
+
dropCard(card.card_id)
|
|
668
|
+
} catch (err) {
|
|
669
|
+
toast(err.message)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function askRemove(card, wrap) {
|
|
674
|
+
const name = card.title || card.card_id
|
|
675
|
+
const row = confirmRow(`Remove ${name}? Its card record, events and runs are deleted; the worktree is kept.`, 'Remove', () => removeCardAction(card), () => renderRow(card))
|
|
676
|
+
wrap.textContent = ''
|
|
677
|
+
wrap.appendChild(row)
|
|
678
|
+
row.cancelBtn.focus()
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function buildActions(card) {
|
|
682
|
+
const wrap = el('div', { class: 'row-actions' })
|
|
683
|
+
const available = card.actions || []
|
|
684
|
+
for (const action of ACTION_ORDER) {
|
|
685
|
+
if (!available.includes(action) || !ACTION_LABELS[action]) continue
|
|
686
|
+
const label = ACTION_LABELS[action]
|
|
687
|
+
const cls = `btn ${ACTION_CLASS[action] || 'btn-secondary'}`
|
|
688
|
+
const run = action === 'reassign' ? () => openReassign(card, wrap) : () => runAction(card, action)
|
|
689
|
+
wrap.appendChild(el('button', { type: 'button', class: cls, 'aria-label': `${label} ${card.title || card.card_id}`, onclick: run }, [label]))
|
|
690
|
+
}
|
|
691
|
+
if (['done', 'failed', 'killed'].includes(card.status)) {
|
|
692
|
+
wrap.appendChild(el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': `Remove ${card.title || card.card_id}`, onclick: () => askRemove(card, wrap) }, ['Remove']))
|
|
693
|
+
}
|
|
694
|
+
return wrap
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// the reassign picker replaces the buttons in place, like the confirm row
|
|
698
|
+
async function openReassign(card, wrap) {
|
|
699
|
+
if (!state.adapters) {
|
|
700
|
+
try { state.adapters = (await api('/api/adapters')).adapters } catch (err) { toast(err.message); return }
|
|
701
|
+
}
|
|
702
|
+
const adapterSelect = el('select', { 'aria-label': `Reassign adapter for ${card.title || card.card_id}` })
|
|
703
|
+
const modeSelect = el('select', { 'aria-label': `Reassign mode for ${card.title || card.card_id}` })
|
|
704
|
+
for (const a of state.adapters) adapterSelect.appendChild(el('option', { value: a.name }, [a.name]))
|
|
705
|
+
if (card.active_adapter) adapterSelect.value = card.active_adapter
|
|
706
|
+
function populateModes() {
|
|
707
|
+
modeSelect.textContent = ''
|
|
708
|
+
const adapter = (state.adapters || []).find((a) => a.name === adapterSelect.value)
|
|
709
|
+
for (const m of adapter ? adapter.modes.allowed : []) modeSelect.appendChild(el('option', { value: m }, [m]))
|
|
710
|
+
if (adapter && adapter.modes.default) modeSelect.value = adapter.modes.default
|
|
711
|
+
}
|
|
712
|
+
adapterSelect.addEventListener('change', populateModes)
|
|
713
|
+
populateModes()
|
|
714
|
+
const apply = el('button', { type: 'button', class: 'btn btn-primary', onclick: async () => {
|
|
715
|
+
try {
|
|
716
|
+
await api(`/api/cards/${encodeURIComponent(card.card_id)}/reassign`, { method: 'POST', body: { adapter: adapterSelect.value, mode: modeSelect.value } })
|
|
717
|
+
renderRow(card)
|
|
718
|
+
} catch (err) { toast(err.message) }
|
|
719
|
+
} }, ['Apply'])
|
|
720
|
+
const cancel = el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => renderRow(card) }, ['Cancel'])
|
|
721
|
+
wrap.textContent = ''
|
|
722
|
+
wrap.appendChild(el('div', { class: 'reassign-picker' }, [adapterSelect, modeSelect, apply, cancel]))
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 5.1: R1 who, R2 what, R3 where, R4 when-act-verdict. No object type moves a
|
|
726
|
+
// field to a different x, which is why a card row reads down the same four
|
|
727
|
+
// columns as an account and a terminal.
|
|
728
|
+
function buildRow(card) {
|
|
729
|
+
const said = cardSentence(card)
|
|
730
|
+
const chain = (card.chain_view && card.chain_view.length ? card.chain_view : card.chain) || []
|
|
731
|
+
const agent = card.active_adapter || (chain[0] ? chain[0].adapter : null)
|
|
732
|
+
const title = el('button', {
|
|
733
|
+
type: 'button', class: 'row-title', 'aria-expanded': state.drawerId === card.card_id ? 'true' : 'false',
|
|
734
|
+
onclick: () => expandRow(card.card_id),
|
|
735
|
+
}, [card.title || truncate(card.task, 60) || card.card_id])
|
|
736
|
+
const sentence = el('p', { class: `sentence tone-${said.tone}` }, [said.text])
|
|
737
|
+
const elapsed = el('span', { class: 'elapsed' }, [runElapsed(card)])
|
|
738
|
+
const cells = [
|
|
739
|
+
el('div', { class: 'r1' }, [
|
|
740
|
+
el('span', { class: agent ? `chip chip-id-${agentClass(agent)}` : 'chip' }, [agent || 'no agent']),
|
|
741
|
+
card.station && card.station !== '-' ? el('span', { class: 'chip' }, [card.station]) : null,
|
|
742
|
+
statusWord(card),
|
|
743
|
+
]),
|
|
744
|
+
el('div', { class: 'r2' }, [title, sentence]),
|
|
745
|
+
el('div', { class: 'r3' }, [
|
|
746
|
+
el('p', { class: 'row-meta mono' }, [`${card.repo_name || 'no repo'}@${card.trunk || 'main'}`]),
|
|
747
|
+
card.worktree ? el('p', { class: 'row-meta mono', title: card.worktree }, [shortWorktree(card)]) : null,
|
|
748
|
+
buildChainRail(card),
|
|
749
|
+
buildLeases(card),
|
|
750
|
+
]),
|
|
751
|
+
el('div', { class: 'r4' }, [elapsed, buildActions(card)]),
|
|
752
|
+
]
|
|
753
|
+
return { cells, slots: { title, sentence, elapsed } }
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function renderRow(card) {
|
|
757
|
+
state.cards.set(card.card_id, card)
|
|
758
|
+
let root = state.cardNodes.get(card.card_id)
|
|
759
|
+
if (!root) {
|
|
760
|
+
root = el('article', { class: 'row', 'data-card-id': card.card_id })
|
|
761
|
+
state.cardNodes.set(card.card_id, root)
|
|
762
|
+
document.getElementById('columns').appendChild(root)
|
|
763
|
+
state.rowOrder = ''
|
|
764
|
+
} else {
|
|
765
|
+
while (root.firstChild) root.removeChild(root.firstChild)
|
|
766
|
+
}
|
|
767
|
+
const built = buildRow(card)
|
|
768
|
+
for (const cell of built.cells) root.appendChild(cell)
|
|
769
|
+
root.slots = built.slots
|
|
770
|
+
return root
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---- 6.6 the detail region: in flow under its row, never an overlay ----
|
|
774
|
+
async function ensureLogLoaded(id, tail) {
|
|
775
|
+
const cardAtRequest = state.cards.get(id)
|
|
776
|
+
if (!cardAtRequest) return
|
|
777
|
+
const runAtRequest = cardAtRequest.active_run ? cardAtRequest.active_run.run : null
|
|
778
|
+
const pending = state.logRequests.get(id)
|
|
779
|
+
if (pending && pending.tail >= tail && pending.run === runAtRequest && pending.runs_count === cardAtRequest.runs_count) return pending.promise
|
|
780
|
+
const request = { id: ++state.nextLogRequest, tail, run: runAtRequest, runs_count: cardAtRequest.runs_count }
|
|
781
|
+
const path = `/api/cards/${encodeURIComponent(id)}/log?tail=${tail}${runAtRequest == null ? '' : `&run=${encodeURIComponent(runAtRequest)}`}`
|
|
782
|
+
const promise = api(path).then((data) => {
|
|
783
|
+
const card = state.cards.get(id)
|
|
784
|
+
if (!card || state.logRequests.get(id) !== request || card.runs_count !== cardAtRequest.runs_count || (card.active_run ? card.active_run.run : null) !== runAtRequest) return
|
|
785
|
+
state.logState.set(id, {
|
|
786
|
+
lines: data.lines || [],
|
|
787
|
+
expanded: tail > 8,
|
|
788
|
+
at: Date.now(),
|
|
789
|
+
runs_count: card.runs_count,
|
|
790
|
+
run: runAtRequest,
|
|
791
|
+
})
|
|
792
|
+
if (state.drawerId === id) repaintDrawer()
|
|
793
|
+
}).catch((err) => {
|
|
794
|
+
if (state.logRequests.get(id) === request) toast(err.message)
|
|
795
|
+
}).finally(() => {
|
|
796
|
+
if (state.logRequests.get(id) === request) state.logRequests.delete(id)
|
|
797
|
+
})
|
|
798
|
+
request.promise = promise
|
|
799
|
+
state.logRequests.set(id, request)
|
|
800
|
+
return promise
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function detailSection(title, note, body) {
|
|
804
|
+
return el('section', { class: 'detail-section' }, [
|
|
805
|
+
el('h3', { class: 'detail-heading' }, [title, note ? el('span', { class: 'detail-sub' }, [note]) : null]),
|
|
806
|
+
].concat(body))
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function kvRow(key, value) {
|
|
810
|
+
return [el('span', { class: 'kv-key' }, [key]), el('span', { class: 'kv-val' }, [value])]
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function copyToClipboard(text) {
|
|
814
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
815
|
+
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text))
|
|
816
|
+
} else {
|
|
817
|
+
fallbackCopy(text)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function fallbackCopy(text) {
|
|
822
|
+
const ta = document.createElement('textarea')
|
|
823
|
+
ta.value = text
|
|
824
|
+
ta.style.position = 'fixed'
|
|
825
|
+
ta.style.opacity = '0'
|
|
826
|
+
document.body.appendChild(ta)
|
|
827
|
+
ta.select()
|
|
828
|
+
try { document.execCommand('copy') } catch { /* ignore */ }
|
|
829
|
+
document.body.removeChild(ta)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// G11: the machine strings in full, never shortened. The row prints the short
|
|
833
|
+
// form; this is where the reader comes for the one they can paste.
|
|
834
|
+
function whereBlock(card, bundle) {
|
|
835
|
+
const rows = []
|
|
836
|
+
.concat(kvRow('repo', card.repo || 'no repo'))
|
|
837
|
+
.concat(kvRow('trunk', card.trunk || 'main'))
|
|
838
|
+
.concat(kvRow('worktree', card.worktree || 'no worktree yet'))
|
|
839
|
+
.concat(kvRow('leases', (card.leases && card.leases.length ? card.leases : ['**']).join(', ')))
|
|
840
|
+
if (bundle && bundle.path) rows.push(...kvRow('bundle', bundle.path))
|
|
841
|
+
const copy = el('button', { type: 'button', class: 'btn btn-secondary', onclick: () => copyToClipboard(card.worktree || card.repo || '') }, ['Copy worktree path'])
|
|
842
|
+
return [el('div', { class: 'kv' }, rows), copy]
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function eventRow(e) {
|
|
846
|
+
return el('div', { class: 'turn' }, [
|
|
847
|
+
el('span', { class: 'turn-when' }, [clock(e.ts)]),
|
|
848
|
+
el('span', { class: 'turn-role' }, [`${e.type}, ${formatActor(e.actor)}`]),
|
|
849
|
+
el('p', {}, [e.summary]),
|
|
850
|
+
])
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// capped by count, and the cap names its volume: no nested scrollbar, the page
|
|
854
|
+
// just grows when the reader asks for more
|
|
855
|
+
function paintTimeline() {
|
|
856
|
+
const box = state.timelineEl
|
|
857
|
+
if (!box) return
|
|
858
|
+
box.textContent = ''
|
|
859
|
+
const all = state.drawerEvents
|
|
860
|
+
const shown = all.slice(-state.timelineCap).reverse()
|
|
861
|
+
for (const e of shown) box.appendChild(eventRow(e))
|
|
862
|
+
if (all.length > shown.length) {
|
|
863
|
+
box.appendChild(el('p', { class: 'cap-line' }, [
|
|
864
|
+
`showing ${shown.length} of ${plural(all.length, 'event')}`,
|
|
865
|
+
el('button', { type: 'button', class: 'btn btn-text', onclick: () => { state.timelineCap += 40; paintTimeline() } }, ['show 40 more']),
|
|
866
|
+
]))
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function appendDrawerEvent(ev) {
|
|
871
|
+
state.drawerEvents.push(ev)
|
|
872
|
+
paintTimeline()
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function renderDrawer(detail) {
|
|
876
|
+
state.drawerDetail = detail
|
|
877
|
+
const content = document.getElementById('drawer-content')
|
|
878
|
+
content.textContent = ''
|
|
879
|
+
const card = detail.card || {}
|
|
880
|
+
const id = card.card_id || state.drawerId
|
|
881
|
+
content.appendChild(el('h2', { class: 'detail-heading' }, [card.title || id]))
|
|
882
|
+
|
|
883
|
+
content.appendChild(detailSection('Task', `card ${id}`, el('p', { class: 'drawer-task' }, [card.task || 'no task recorded'])))
|
|
884
|
+
content.appendChild(detailSection('Where', 'full paths, never shortened', whereBlock(card, detail.bundle)))
|
|
885
|
+
|
|
886
|
+
const stations = card.pipeline || []
|
|
887
|
+
const at = stations.findIndex((s) => s.name === card.station) + 1
|
|
888
|
+
const pipeline = []
|
|
889
|
+
for (const st of stations) pipeline.push(...kvRow(st.name, `${st.kind}${st.name === card.station ? ', this station' : ''}`))
|
|
890
|
+
content.appendChild(detailSection('Pipeline', at ? `station ${at} of ${stations.length}` : `${plural(stations.length, 'station')}, none started`, el('div', { class: 'kv' }, pipeline)))
|
|
891
|
+
|
|
892
|
+
const runs = detail.runs || []
|
|
893
|
+
const runRows = []
|
|
894
|
+
for (const r of runs) runRows.push(...kvRow(`run ${r.run}`, `${r.adapter}, ${r.outcome ?? r.status}, signal ${r.signal ?? 'none'}, exit ${r.exit_code ?? 'none'}`))
|
|
895
|
+
content.appendChild(detailSection('Runs', plural(runs.length, 'run'), el('div', { class: 'kv' }, runRows.length ? runRows : kvRow('runs', 'no runs yet'))))
|
|
896
|
+
|
|
897
|
+
state.drawerEvents = detail.events || []
|
|
898
|
+
state.timelineEl = el('div', { class: 'drawer-timeline' })
|
|
899
|
+
content.appendChild(detailSection('Timeline', 'newest first', state.timelineEl))
|
|
900
|
+
paintTimeline()
|
|
901
|
+
|
|
902
|
+
const cache = state.logState.get(id)
|
|
903
|
+
const lines = cache ? cache.lines : []
|
|
904
|
+
content.appendChild(detailSection('Log', lines.length ? `last ${plural(lines.length, 'line')}` : 'no output yet',
|
|
905
|
+
el('pre', { class: 'log-pre' }, [lines.length ? lines.join('\n') : (card.runs_count ? 'the log for this run is empty' : 'no runs yet')])))
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function repaintDrawer() {
|
|
909
|
+
if (state.drawerDetail) renderDrawer(state.drawerDetail)
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function refreshDrawerAfterUpdate() {
|
|
913
|
+
// the card summary changed while its row is expanded; the station marker and
|
|
914
|
+
// the run list go stale with it, so refetch the detail
|
|
915
|
+
if (state.drawerId) openDrawer(state.drawerId)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// openDrawer costs two requests, one of them a 64 KB log read: a burst of
|
|
919
|
+
// pushes (or a reconnect, which refetches by two paths) gets one refresh
|
|
920
|
+
function scheduleDrawerRefresh() {
|
|
921
|
+
if (!state.drawerId || state.drawerTimer) return
|
|
922
|
+
state.drawerTimer = setTimeout(() => {
|
|
923
|
+
state.drawerTimer = null
|
|
924
|
+
refreshDrawerAfterUpdate()
|
|
925
|
+
}, DRAWER_REFRESH_MS)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// The detail region is MOVED under the row being expanded instead of floating
|
|
929
|
+
// over it, so the page keeps exactly one scroll container. A row that has not
|
|
930
|
+
// been rendered yet has nowhere to put it, and so does the stub DOM the tests
|
|
931
|
+
// run against, in which case it stays where the markup left it.
|
|
932
|
+
function placeDetail(id) {
|
|
933
|
+
const row = state.cardNodes.get(id)
|
|
934
|
+
const detail = document.getElementById('drawer')
|
|
935
|
+
if (row && typeof row.after === 'function') row.after(detail)
|
|
936
|
+
return detail
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function markRow(id, expanded) {
|
|
940
|
+
const root = id ? state.cardNodes.get(id) : null
|
|
941
|
+
if (!root || !root.slots) return null
|
|
942
|
+
if (expanded) root.classList.add('is-selected')
|
|
943
|
+
else root.classList.remove('is-selected')
|
|
944
|
+
root.slots.title.setAttribute('aria-expanded', expanded ? 'true' : 'false')
|
|
945
|
+
return root
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async function openDrawer(id) {
|
|
949
|
+
const previous = state.drawerId
|
|
950
|
+
if (previous && previous !== id) markRow(previous, false)
|
|
951
|
+
state.drawerId = id
|
|
952
|
+
state.timelineCap = TIMELINE_CAP
|
|
953
|
+
const request = ++state.drawerRequest
|
|
954
|
+
const drawer = document.getElementById('drawer')
|
|
955
|
+
drawer.hidden = false
|
|
956
|
+
drawer.setAttribute('aria-hidden', 'false')
|
|
957
|
+
placeDetail(id)
|
|
958
|
+
markRow(id, true)
|
|
959
|
+
try {
|
|
960
|
+
const [detail, log] = await Promise.all([
|
|
961
|
+
api(`/api/cards/${encodeURIComponent(id)}`),
|
|
962
|
+
api(`/api/cards/${encodeURIComponent(id)}/log?tail=2000`),
|
|
963
|
+
])
|
|
964
|
+
if (state.drawerId !== id || request !== state.drawerRequest) return
|
|
965
|
+
const card = state.cards.get(id)
|
|
966
|
+
state.logState.set(id, {
|
|
967
|
+
lines: log.lines || [],
|
|
968
|
+
expanded: true,
|
|
969
|
+
at: Date.now(),
|
|
970
|
+
runs_count: card ? card.runs_count : null,
|
|
971
|
+
run: card && card.active_run ? card.active_run.run : null,
|
|
972
|
+
})
|
|
973
|
+
renderDrawer(detail)
|
|
974
|
+
} catch (err) {
|
|
975
|
+
toast(err.message)
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function closeDrawer() {
|
|
980
|
+
const open = state.drawerId
|
|
981
|
+
state.drawerId = null
|
|
982
|
+
state.drawerRequest += 1
|
|
983
|
+
state.drawerDetail = null
|
|
984
|
+
state.timelineEl = null
|
|
985
|
+
const drawer = document.getElementById('drawer')
|
|
986
|
+
drawer.hidden = true
|
|
987
|
+
drawer.setAttribute('aria-hidden', 'true')
|
|
988
|
+
return markRow(open, false)
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// only one region is expanded at a time, and the control that opened it gets
|
|
992
|
+
// the focus back when it collapses
|
|
993
|
+
function expandRow(id) {
|
|
994
|
+
if (state.drawerId === id) return collapseRow()
|
|
995
|
+
openDrawer(id)
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function collapseRow() {
|
|
999
|
+
const root = closeDrawer()
|
|
1000
|
+
if (root && root.slots) root.slots.title.focus()
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ---- new card dialog ----
|
|
1004
|
+
function newCardDialogEls() {
|
|
1005
|
+
return {
|
|
1006
|
+
dialog: document.getElementById('new-card-dialog'),
|
|
1007
|
+
form: document.getElementById('new-card-form'),
|
|
1008
|
+
error: document.getElementById('new-card-error'),
|
|
1009
|
+
repo: document.getElementById('nc-repo'),
|
|
1010
|
+
task: document.getElementById('nc-task'),
|
|
1011
|
+
firstAgent: document.getElementById('nc-first-agent'),
|
|
1012
|
+
firstControls: document.getElementById('nc-first-controls'),
|
|
1013
|
+
testAdapter: document.getElementById('nc-test-adapter'),
|
|
1014
|
+
fallbackSummary: document.getElementById('nc-fallback-summary'),
|
|
1015
|
+
pipeline: document.getElementById('nc-pipeline'),
|
|
1016
|
+
customPipeline: document.getElementById('nc-custom-pipeline'),
|
|
1017
|
+
chainRows: document.getElementById('nc-chain-rows'),
|
|
1018
|
+
addRowBtn: document.getElementById('nc-add-row'),
|
|
1019
|
+
leases: document.getElementById('nc-leases'),
|
|
1020
|
+
trunk: document.getElementById('nc-trunk'),
|
|
1021
|
+
landMode: document.getElementById('nc-land-mode'),
|
|
1022
|
+
testCommand: document.getElementById('nc-test-command'),
|
|
1023
|
+
title: document.getElementById('nc-title'),
|
|
1024
|
+
queue: document.getElementById('nc-queue'),
|
|
1025
|
+
cancel: document.getElementById('nc-cancel'),
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function adapterLabel(adapter) { return adapter.fake ? `${adapter.name} (test/demo)` : adapter.name }
|
|
1030
|
+
|
|
1031
|
+
// the agents already named in this dialog: the first agent and every fallback
|
|
1032
|
+
// row already added. A fallback set to one of these can never fire.
|
|
1033
|
+
function chosenAdapters(ui) {
|
|
1034
|
+
const used = [ui.testAdapter.value || ui.firstAgent.value]
|
|
1035
|
+
for (const row of ui.chainRows.children) if (row.fields) used.push(row.fields.adapterSelect.value)
|
|
1036
|
+
return used.filter(Boolean)
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function addChainRow(ui, { adapter: preferred = null, first = false } = {}) {
|
|
1040
|
+
const adapterSelect = el('select', { 'aria-label': 'Chain adapter' })
|
|
1041
|
+
const adapters = [...(state.adapters || [])].sort((a, b) => Number(a.fake) - Number(b.fake))
|
|
1042
|
+
for (const a of adapters) adapterSelect.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
|
|
1043
|
+
// Add fallback agent used to default to the first option in the list, which
|
|
1044
|
+
// is normally the agent already chosen as First agent: the summary line then
|
|
1045
|
+
// read "Baton tries claude, then agy, then claude", a fallback that cannot
|
|
1046
|
+
// fire. rebuildDefaultFallbacks already applies this filter.
|
|
1047
|
+
if (!preferred && !first) {
|
|
1048
|
+
const used = chosenAdapters(ui)
|
|
1049
|
+
preferred = adapters.filter((a) => !a.fake).map((a) => a.name).find((name) => !used.includes(name)) || null
|
|
1050
|
+
}
|
|
1051
|
+
if (preferred && adapters.some((a) => a.name === preferred)) adapterSelect.value = preferred
|
|
1052
|
+
const modeSelect = el('select', { 'aria-label': 'Chain mode' })
|
|
1053
|
+
const approveCheckbox = el('input', { type: 'checkbox', 'aria-label': 'Approve before this leg' })
|
|
1054
|
+
const approveLabel = el('label', {}, [approveCheckbox, ' approval before start'])
|
|
1055
|
+
const turnsInput = el('input', { type: 'number', min: '0', 'aria-label': 'Max turns', placeholder: 'max turns' })
|
|
1056
|
+
const fakeInput = el('input', { type: 'text', 'aria-label': 'Scripted test behavior', placeholder: 'test behavior' })
|
|
1057
|
+
const removeBtn = first ? null : el('button', { type: 'button', class: 'btn btn-danger', 'aria-label': 'Remove fallback agent' }, ['Remove'])
|
|
1058
|
+
const title = el('span', { class: 'fallback-row-title' }, [first ? `First: ${preferred}` : `Fallback ${ui.chainRows.children.length + 1}`])
|
|
1059
|
+
const row = el('div', { class: `chain-row${first ? '' : ' fallback-row'}` }, [title, adapterSelect, modeSelect, approveLabel, turnsInput, fakeInput, removeBtn])
|
|
1060
|
+
if (first) adapterSelect.hidden = true
|
|
1061
|
+
if (removeBtn) removeBtn.addEventListener('click', () => { row.remove(); refreshFallbackSummary(ui) })
|
|
1062
|
+
|
|
1063
|
+
function populateModes() {
|
|
1064
|
+
modeSelect.textContent = ''
|
|
1065
|
+
const adapter = (state.adapters || []).find((a) => a.name === adapterSelect.value)
|
|
1066
|
+
const allowed = adapter ? adapter.modes.allowed : []
|
|
1067
|
+
for (const m of allowed) modeSelect.appendChild(el('option', { value: m }, [MODE_LABELS[m] ? `${MODE_LABELS[m]} (${m})` : m]))
|
|
1068
|
+
if (adapter && adapter.modes.default) modeSelect.value = adapter.modes.default
|
|
1069
|
+
fakeInput.hidden = !(adapter && adapter.fake)
|
|
1070
|
+
}
|
|
1071
|
+
adapterSelect.addEventListener('change', populateModes)
|
|
1072
|
+
// the summary sentence names the fallback agents by row, so it has to
|
|
1073
|
+
// follow a row whose agent the reader changed after it was added
|
|
1074
|
+
if (!first) adapterSelect.addEventListener('change', () => refreshFallbackSummary(ui))
|
|
1075
|
+
populateModes()
|
|
1076
|
+
|
|
1077
|
+
row.fields = { adapterSelect, modeSelect, approveCheckbox, turnsInput, fakeInput }
|
|
1078
|
+
;(first ? ui.firstControls : ui.chainRows).appendChild(row)
|
|
1079
|
+
refreshFallbackSummary(ui)
|
|
1080
|
+
return row
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function refreshFallbackSummary(ui) {
|
|
1084
|
+
const names = [...ui.chainRows.children].filter((row) => row.fields).map((row) => row.fields.adapterSelect.value)
|
|
1085
|
+
ui.fallbackSummary.textContent = names.length
|
|
1086
|
+
? `If the first agent cannot continue, Leg tries ${names.join(', then ')} in this order.`
|
|
1087
|
+
: 'No fallback agent is set. Add one under Advanced options if another agent should take over.'
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function rebuildFirstControls(ui) {
|
|
1091
|
+
ui.firstControls.textContent = ''
|
|
1092
|
+
addChainRow(ui, { adapter: ui.testAdapter.value || ui.firstAgent.value, first: true })
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function rebuildDefaultFallbacks(ui) {
|
|
1096
|
+
ui.chainRows.textContent = ''
|
|
1097
|
+
const first = ui.firstAgent.value
|
|
1098
|
+
const real = (state.adapters || []).filter((a) => !a.fake).map((a) => a.name)
|
|
1099
|
+
const preferred = AGENT_IDS.filter((name) => real.includes(name) && name !== first)
|
|
1100
|
+
for (const adapter of preferred) addChainRow(ui, { adapter })
|
|
1101
|
+
refreshFallbackSummary(ui)
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async function openNewCardDialog() {
|
|
1105
|
+
if (!state.adapters) {
|
|
1106
|
+
try { state.adapters = (await api('/api/adapters')).adapters } catch (err) { toast(err.message); return }
|
|
1107
|
+
}
|
|
1108
|
+
const ui = newCardDialogEls()
|
|
1109
|
+
ui.form.reset()
|
|
1110
|
+
ui.error.hidden = true
|
|
1111
|
+
ui.error.textContent = ''
|
|
1112
|
+
ui.customPipeline.hidden = ui.pipeline.value !== 'custom'
|
|
1113
|
+
const real = (state.adapters || []).filter((a) => !a.fake)
|
|
1114
|
+
ui.firstAgent.textContent = ''
|
|
1115
|
+
for (const adapter of real) ui.firstAgent.appendChild(el('option', { value: adapter.name }, [adapter.name]))
|
|
1116
|
+
if (real.some((a) => a.name === 'claude')) ui.firstAgent.value = 'claude'
|
|
1117
|
+
ui.testAdapter.textContent = ''
|
|
1118
|
+
ui.testAdapter.appendChild(el('option', { value: '' }, ['Use the real first agent above']))
|
|
1119
|
+
for (const adapter of (state.adapters || []).filter((a) => a.fake)) ui.testAdapter.appendChild(el('option', { value: adapter.name }, [adapterLabel(adapter)]))
|
|
1120
|
+
ui.firstControls.textContent = ''
|
|
1121
|
+
ui.chainRows.textContent = ''
|
|
1122
|
+
rebuildFirstControls(ui)
|
|
1123
|
+
rebuildDefaultFallbacks(ui)
|
|
1124
|
+
ui.dialog.showModal()
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async function submitNewCard(e) {
|
|
1128
|
+
e.preventDefault()
|
|
1129
|
+
const ui = newCardDialogEls()
|
|
1130
|
+
const rows = [...ui.firstControls.children, ...ui.chainRows.children]
|
|
1131
|
+
.filter((row) => row.fields)
|
|
1132
|
+
.map((row) => ({
|
|
1133
|
+
adapter: row.fields.adapterSelect.value,
|
|
1134
|
+
mode: row.fields.modeSelect.value,
|
|
1135
|
+
approve: row.fields.approveCheckbox.checked,
|
|
1136
|
+
turns: row.fields.turnsInput.value.trim(),
|
|
1137
|
+
fake: row.fields.fakeInput.hidden ? '' : row.fields.fakeInput.value.trim(),
|
|
1138
|
+
}))
|
|
1139
|
+
.filter((r) => r.adapter)
|
|
1140
|
+
|
|
1141
|
+
const body = {
|
|
1142
|
+
repo: ui.repo.value.trim(),
|
|
1143
|
+
task: ui.task.value.trim(),
|
|
1144
|
+
chain: rows.map((r) => r.adapter).join(','),
|
|
1145
|
+
pipeline: ui.pipeline.value === 'custom' ? ui.customPipeline.value.trim() : ui.pipeline.value,
|
|
1146
|
+
leases: ui.leases.value.trim(),
|
|
1147
|
+
trunk: ui.trunk.value.trim() || 'main',
|
|
1148
|
+
land_mode: ui.landMode.value,
|
|
1149
|
+
test_command: ui.testCommand.value.trim(),
|
|
1150
|
+
title: ui.title.value.trim(),
|
|
1151
|
+
mode: rows.filter((r) => r.mode).map((r) => `${r.adapter}=${r.mode}`).join(','),
|
|
1152
|
+
approve: rows.filter((r) => r.approve).map((r) => r.adapter).join(','),
|
|
1153
|
+
maxTurns: rows.filter((r) => r.turns).map((r) => `${r.adapter}=${r.turns}`).join(','),
|
|
1154
|
+
fake_mode: rows.filter((r) => r.fake).map((r) => `${r.adapter}=${r.fake}`).join(','),
|
|
1155
|
+
queue: ui.queue.checked,
|
|
1156
|
+
}
|
|
1157
|
+
try {
|
|
1158
|
+
const data = await api('/api/cards', { method: 'POST', body })
|
|
1159
|
+
ui.dialog.close()
|
|
1160
|
+
upsertCard(data.card)
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
ui.error.hidden = false
|
|
1163
|
+
ui.error.textContent = err.message
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function initNewCardDialog() {
|
|
1168
|
+
const ui = newCardDialogEls()
|
|
1169
|
+
ui.form.addEventListener('submit', submitNewCard)
|
|
1170
|
+
ui.cancel.addEventListener('click', () => ui.dialog.close())
|
|
1171
|
+
ui.pipeline.addEventListener('change', () => { ui.customPipeline.hidden = ui.pipeline.value !== 'custom' })
|
|
1172
|
+
ui.firstAgent.addEventListener('change', () => { ui.testAdapter.value = ''; rebuildFirstControls(ui); rebuildDefaultFallbacks(ui) })
|
|
1173
|
+
ui.testAdapter.addEventListener('change', () => rebuildFirstControls(ui))
|
|
1174
|
+
ui.addRowBtn.addEventListener('click', () => addChainRow(ui))
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// ---- 6.10 settings: the last region of the page, in flow ----
|
|
1178
|
+
// "<host>:<port>", possibly "[::1]:4747"
|
|
1179
|
+
function bindHost(bind) {
|
|
1180
|
+
const s = String(bind ?? '').trim()
|
|
1181
|
+
const bracketed = s.match(/^\[(.+)\]:\d+$/)
|
|
1182
|
+
if (bracketed) return bracketed[1].toLowerCase()
|
|
1183
|
+
return s.replace(/:\d+$/, '').toLowerCase()
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// A loopback bind with share off can only ever be reached from this machine,
|
|
1187
|
+
// and src/auth.mjs lets that request in with no token at all. Asking for one
|
|
1188
|
+
// there is a question with no answer, so the field is not drawn. Three things
|
|
1189
|
+
// put it back: a token already stored (which has to stay clearable), a guest
|
|
1190
|
+
// or a health call that never answered (neither has established local), and
|
|
1191
|
+
// any bind or share that lets a second machine in.
|
|
1192
|
+
function tokenPanel({ bind, shareOn, isOwner, token, healthKnown, bindKnown }) {
|
|
1193
|
+
const at = bindKnown ? ` to ${bind}` : ''
|
|
1194
|
+
// nothing has been heard back from the server: do not name an address that
|
|
1195
|
+
// is only this file's default, and do not claim the board is local
|
|
1196
|
+
if (!healthKnown) {
|
|
1197
|
+
return {
|
|
1198
|
+
hidden: false,
|
|
1199
|
+
meta: token ? 'That token was refused. Check it, or clear the field to sign out.' : 'This board wants a token before it will say anything.',
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
const localOnly = bindKnown && LOOPBACK_HOSTS.includes(bindHost(bind)) && !shareOn && isOwner && !token
|
|
1203
|
+
if (localOnly) return { hidden: true, meta: `Local only, ${bind} answers this machine and nothing else` }
|
|
1204
|
+
// share on, no token, and the server still called us the owner: auth.mjs
|
|
1205
|
+
// recognised this browser by its loopback address. Saying "unauthenticated"
|
|
1206
|
+
// there was wrong, and it was the line the owner read on their own board.
|
|
1207
|
+
if (shareOn && isOwner && !token) {
|
|
1208
|
+
return { hidden: false, meta: `Signed in as the owner from this machine. A token is only needed from somewhere else.` }
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
hidden: false,
|
|
1212
|
+
meta: token
|
|
1213
|
+
? `API token set, sent${at} as a bearer token`
|
|
1214
|
+
: `No API token set, requests reach${at || ' this board'} unauthenticated`,
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function renderTokenMeta() {
|
|
1219
|
+
const panel = tokenPanel({ bind: state.bind, shareOn: state.shareOn, isOwner: state.isOwner, token: getToken(), healthKnown: state.healthKnown, bindKnown: state.bindKnown })
|
|
1220
|
+
const field = document.getElementById('token-field')
|
|
1221
|
+
if (field) field.hidden = panel.hidden
|
|
1222
|
+
const meta = document.querySelector('.region-settings .region-meta')
|
|
1223
|
+
if (meta) meta.textContent = panel.meta
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function renderBoardFacts(health) {
|
|
1227
|
+
const box = document.querySelector('.region-settings .board-facts')
|
|
1228
|
+
if (!box) return
|
|
1229
|
+
box.textContent = ''
|
|
1230
|
+
const you = health.you || {}
|
|
1231
|
+
const share = you.share || { on: false, people: 0 }
|
|
1232
|
+
const sched = health.scheduler
|
|
1233
|
+
const lines = [
|
|
1234
|
+
`leg ${health.version}, bound to ${state.bind}`,
|
|
1235
|
+
`signed in as ${you.name || 'local'}, ${you.role || 'owner'}`,
|
|
1236
|
+
share.on ? `share on, ${share.people === 1 ? '1 person' : `${share.people} people`}` : 'share off, nobody invited',
|
|
1237
|
+
]
|
|
1238
|
+
// a guest is sent neither the scheduler nor the paths: print nothing rather
|
|
1239
|
+
// than a number this board was not told
|
|
1240
|
+
if (sched) lines.push(sched.running ? `scheduler running, pid ${sched.pid}, ${sched.max_concurrent} max` : `scheduler stopped, ${sched.max_concurrent} max`)
|
|
1241
|
+
if (health.home) lines.push(`board home ${health.home}`)
|
|
1242
|
+
for (const line of lines) box.appendChild(el('p', {}, [line]))
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function initSettings() {
|
|
1246
|
+
const input = document.getElementById('token-input')
|
|
1247
|
+
input.value = getToken()
|
|
1248
|
+
input.addEventListener('change', async () => {
|
|
1249
|
+
const v = input.value.trim()
|
|
1250
|
+
if (v) { localStorage.setItem('legToken', v); localStorage.setItem('batonToken', v); }
|
|
1251
|
+
else { localStorage.removeItem('legToken'); localStorage.removeItem('batonToken'); }
|
|
1252
|
+
renderTokenMeta()
|
|
1253
|
+
state.boardRevision += 1
|
|
1254
|
+
state.cardsRequest += 1
|
|
1255
|
+
state.sseRequest += 1
|
|
1256
|
+
if (state.es) { try { state.es.close() } catch { /* ignore */ } }
|
|
1257
|
+
state.es = null
|
|
1258
|
+
const owner = await loadHealth()
|
|
1259
|
+
if (owner) fetchCards()
|
|
1260
|
+
connectSse()
|
|
1261
|
+
})
|
|
1262
|
+
const show = document.getElementById('token-show')
|
|
1263
|
+
if (show) {
|
|
1264
|
+
show.addEventListener('click', () => {
|
|
1265
|
+
const hidden = input.type !== 'text'
|
|
1266
|
+
input.type = hidden ? 'text' : 'password'
|
|
1267
|
+
show.textContent = hidden ? 'Hide' : 'Show'
|
|
1268
|
+
})
|
|
1269
|
+
}
|
|
1270
|
+
const disclosure = document.querySelector('.region-settings .disclosure')
|
|
1271
|
+
const body = document.getElementById('settings-body')
|
|
1272
|
+
if (disclosure && body) {
|
|
1273
|
+
disclosure.addEventListener('click', () => {
|
|
1274
|
+
const opening = body.hidden
|
|
1275
|
+
body.hidden = !opening
|
|
1276
|
+
disclosure.setAttribute('aria-expanded', opening ? 'true' : 'false')
|
|
1277
|
+
disclosure.textContent = opening ? 'Hide settings' : 'Show settings'
|
|
1278
|
+
})
|
|
1279
|
+
}
|
|
1280
|
+
renderTokenMeta()
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// ---- init ----
|
|
1284
|
+
async function init() {
|
|
1285
|
+
initSettings()
|
|
1286
|
+
initNewCardDialog()
|
|
1287
|
+
document.getElementById('new-card-btn').addEventListener('click', () => openNewCardDialog())
|
|
1288
|
+
document.getElementById('empty-new-card-btn').addEventListener('click', () => openNewCardDialog())
|
|
1289
|
+
document.getElementById('drawer-close').addEventListener('click', () => collapseRow())
|
|
1290
|
+
document.addEventListener('keydown', (e) => {
|
|
1291
|
+
if (e.key !== 'Escape') return
|
|
1292
|
+
if (openConfirm) { const cancel = openConfirm; openConfirm = null; cancel(); return }
|
|
1293
|
+
if (state.drawerId) collapseRow()
|
|
1294
|
+
})
|
|
1295
|
+
// health first: it says whether this human owns the pipeline side at all
|
|
1296
|
+
await loadHealth()
|
|
1297
|
+
fetchCards()
|
|
1298
|
+
connectSse()
|
|
1299
|
+
setInterval(tickElapsed, 1000)
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
document.addEventListener('DOMContentLoaded', init)
|
|
1303
|
+
|
|
1304
|
+
// test seam: node:test runs this file with a stub document and reads the
|
|
1305
|
+
// pure update decisions back out; in a browser there is no `module`
|
|
1306
|
+
if (typeof module !== 'undefined') module.exports = { drawerRefreshNeeded, staleLogTail, tokenPanel, bindHost }
|
|
1307
|
+
})()
|