@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
package/src/launcher.mjs
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// launcher — the one command. `leg up` runs preflight, spawns the board
|
|
2
|
+
// server (scheduler + merge queue run inside it) as an argv child, waits for
|
|
3
|
+
// /api/health, opens the board, streams prefixed redacted logs, and tears
|
|
4
|
+
// everything down on Ctrl-C. `up --dry` prints what would run. `down`,
|
|
5
|
+
// `status`, `open` are the other three verbs. No shell anywhere.
|
|
6
|
+
import http from 'node:http'
|
|
7
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'node:fs'
|
|
9
|
+
import { join, dirname } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { redact } from './redact.mjs'
|
|
12
|
+
import { home, listCards, readRuns } from './store.mjs'
|
|
13
|
+
import { names as adapterNames, get as getAdapter, isFake } from './adapters/index.mjs'
|
|
14
|
+
import { resolveChb, chbVersion } from './handoff.mjs'
|
|
15
|
+
import { schedulerStatus, MAX_CONCURRENT } from './scheduler.mjs'
|
|
16
|
+
import { enabledSyncs } from './sync/index.mjs'
|
|
17
|
+
|
|
18
|
+
const SRC = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const SERVER = process.env.LEG_SERVER_SCRIPT || process.env.BATON_SERVER_SCRIPT || join(SRC, 'server.mjs')
|
|
20
|
+
const VERSION = JSON.parse(readFileSync(join(SRC, '..', 'package.json'), 'utf8')).version
|
|
21
|
+
const HEALTH_TIMEOUT_MS = Number(process.env.LEG_HEALTH_TIMEOUT_MS || process.env.BATON_HEALTH_TIMEOUT_MS || 20000)
|
|
22
|
+
|
|
23
|
+
// Every launcher line goes through here: one prefix, one redaction pass.
|
|
24
|
+
export function out(prefix, line, stream = process.stdout) {
|
|
25
|
+
stream.write(`[${prefix}] ${redact(String(line)).replace(/\r?\n$/, '')}\n`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pidfile() { const leg = join(home(), 'leg.pid'); const baton = join(home(), 'baton.pid'); if (existsSync(leg)) return leg; if (existsSync(baton)) return baton; return leg; }
|
|
29
|
+
|
|
30
|
+
function readPidfile() {
|
|
31
|
+
try { return JSON.parse(readFileSync(pidfile(), 'utf8')) } catch { return null }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pidAlive(pid) {
|
|
35
|
+
if (!pid) return false
|
|
36
|
+
try { process.kill(pid, 0); return true } catch { return false }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function version(bin, args = ['--version']) {
|
|
40
|
+
const r = spawnSync(bin, args, { windowsHide: true, encoding: 'utf8', timeout: 8000 })
|
|
41
|
+
if (r.error || r.status !== 0) return null
|
|
42
|
+
return (r.stdout || r.stderr).trim().split('\n')[0].slice(0, 60)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const INSTALL_HINT = {
|
|
46
|
+
claude: 'https://claude.com/claude-code (then `claude` to log in)',
|
|
47
|
+
codex: 'npm i -g @openai/codex (then `codex login`)',
|
|
48
|
+
agy: 'Antigravity CLI (`agy`), log in once interactively',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Rows: [name, status, detail]. Nothing here is fatal except "no adapter at all".
|
|
52
|
+
export async function preflight() {
|
|
53
|
+
const rows = []
|
|
54
|
+
rows.push(['node', 'ok', process.version])
|
|
55
|
+
rows.push(['git', version('git') ? 'ok' : 'missing', version('git') ?? 'install git'])
|
|
56
|
+
try { resolveChb(); rows.push(['context-handoff-bundle', 'ok', chbVersion() ?? 'version unknown']) } catch { rows.push(['context-handoff-bundle', 'missing', 'pip install -U context-handoff-bundle']) }
|
|
57
|
+
let present = 0
|
|
58
|
+
for (const name of adapterNames()) {
|
|
59
|
+
if (isFake(name)) continue
|
|
60
|
+
const a = await getAdapter(name)
|
|
61
|
+
const { bin, viaNode, entry } = a.resolve()
|
|
62
|
+
const v = viaNode ? version(process.execPath, [entry, '--version']) : version(bin)
|
|
63
|
+
if (v) { present += 1; rows.push([name, 'ok', `${v} (${viaNode ? entry : bin})`]) } else rows.push([name, 'missing', INSTALL_HINT[name] ?? 'not on PATH'])
|
|
64
|
+
}
|
|
65
|
+
rows.push(['fake adapters', 'ok', 'fake, fake-claude, fake-codex, fake-agy (tests and demo)'])
|
|
66
|
+
return { rows, adapters_present: present }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function printPreflight({ rows }) {
|
|
70
|
+
const w = Math.max(...rows.map((r) => r[0].length))
|
|
71
|
+
for (const [name, status, detail] of rows) out('preflight', `${name.padEnd(w)} ${status.padEnd(7)} ${detail}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function plannedProcesses({ port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), bind = process.env.LEG_BIND || process.env.BATON_BIND || '127.0.0.1' } = {}) {
|
|
75
|
+
const procs = [{
|
|
76
|
+
prefix: 'server', bin: process.execPath, argv: [SERVER], env: { BATON_PORT: String(port), BATON_BIND: bind },
|
|
77
|
+
note: 'board + API + scheduler + merge queue',
|
|
78
|
+
}]
|
|
79
|
+
const on = enabledSyncs()
|
|
80
|
+
const syncs = [
|
|
81
|
+
{ prefix: 'sync:workboard', enabled: on.includes('workboard'), note: 'OpenClaw Workboard mirror (BATON_SYNC_WORKBOARD=1); runs inside the ledger, no extra process' },
|
|
82
|
+
{ prefix: 'sync:dashclaw', enabled: on.includes('dashclaw'), note: 'DashClaw action recording (BATON_SYNC_DASHCLAW=1 + DASHCLAW_URL + DASHCLAW_API_KEY); runs inside the ledger' },
|
|
83
|
+
]
|
|
84
|
+
return { procs, syncs }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function openBoard(url) {
|
|
88
|
+
const argv = process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
|
89
|
+
: process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]]
|
|
90
|
+
try {
|
|
91
|
+
const child = spawn(argv[0], argv[1], { windowsHide: true, detached: true, stdio: 'ignore' })
|
|
92
|
+
child.on('error', () => {})
|
|
93
|
+
child.unref()
|
|
94
|
+
return true
|
|
95
|
+
} catch { return false }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function health(port, bind) {
|
|
99
|
+
return new Promise((resolvePromise) => {
|
|
100
|
+
const req = http.get({ host: bind === '0.0.0.0' ? '127.0.0.1' : bind, port, path: '/api/health', timeout: 2000 }, (res) => {
|
|
101
|
+
let d = ''
|
|
102
|
+
res.on('data', (c) => { d += c })
|
|
103
|
+
res.on('end', () => { try { resolvePromise(res.statusCode === 200 ? JSON.parse(d) : null) } catch { resolvePromise(null) } })
|
|
104
|
+
})
|
|
105
|
+
req.on('error', () => resolvePromise(null))
|
|
106
|
+
req.on('timeout', () => { req.destroy(); resolvePromise(null) })
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A pid is not a board: nothing clears the pidfile when the board dies with it,
|
|
111
|
+
// and after a reboot that pid usually belongs to something else. Something has
|
|
112
|
+
// to answer on the port — a 401 from a guarded board counts, so does a slow one.
|
|
113
|
+
function listening(port, bind) {
|
|
114
|
+
return new Promise((resolvePromise) => {
|
|
115
|
+
const req = http.get({ host: bind === '0.0.0.0' ? '127.0.0.1' : bind, port, path: '/api/health', timeout: 2000 }, (res) => { res.resume(); resolvePromise(true) })
|
|
116
|
+
req.on('socket', (s) => s.on('connect', () => resolvePromise(true)))
|
|
117
|
+
req.on('error', () => resolvePromise(false))
|
|
118
|
+
req.on('timeout', () => { req.destroy(); resolvePromise(false) })
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function boardAlive(pf) {
|
|
123
|
+
if (!pf || !pidAlive(pf.pid)) return false
|
|
124
|
+
return await listening(pf.port, pf.bind ?? '127.0.0.1')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function pipeLines(stream, prefix, onLine) {
|
|
128
|
+
let buf = ''
|
|
129
|
+
stream.setEncoding('utf8')
|
|
130
|
+
stream.on('data', (chunk) => {
|
|
131
|
+
buf += chunk
|
|
132
|
+
let i
|
|
133
|
+
while ((i = buf.indexOf('\n')) !== -1) {
|
|
134
|
+
const line = buf.slice(0, i)
|
|
135
|
+
buf = buf.slice(i + 1)
|
|
136
|
+
if (line.trim()) { out(prefix, line); onLine?.(line) }
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
stream.on('end', () => { if (buf.trim()) { out(prefix, buf); onLine?.(buf) } })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function killTree(pid) {
|
|
143
|
+
if (!pidAlive(pid)) return
|
|
144
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, encoding: 'utf8' })
|
|
145
|
+
else { try { process.kill(pid, 'SIGTERM') } catch {} setTimeout(() => { try { process.kill(pid, 'SIGKILL') } catch {} }, 2000).unref() }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Agents started by the orchestrator run under their own detached supervisors;
|
|
149
|
+
// on teardown kill every run that is still active so nothing burns tokens.
|
|
150
|
+
function killActiveAgents() {
|
|
151
|
+
let n = 0
|
|
152
|
+
for (const card of listCards()) {
|
|
153
|
+
for (const run of readRuns(card.card_id)) {
|
|
154
|
+
if (!['launching', 'running'].includes(run.status)) continue
|
|
155
|
+
for (const pid of [run.agent_pid, run.supervisor_pid]) if (pidAlive(pid)) { killTree(pid); n += 1 }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return n
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function up({ dry = false, open = true, port = Number(process.env.LEG_PORT || process.env.BATON_PORT || 4747), bind = process.env.LEG_BIND || process.env.BATON_BIND || '127.0.0.1' } = {}) {
|
|
162
|
+
out('baton', `baton ${VERSION} — home ${home()}`)
|
|
163
|
+
const pf = await preflight()
|
|
164
|
+
printPreflight(pf)
|
|
165
|
+
if (pf.adapters_present === 0) out('baton', 'no real coding-agent CLI found; fake adapters still work for the demo', process.stderr)
|
|
166
|
+
const plan = plannedProcesses({ port, bind })
|
|
167
|
+
for (const s of plan.syncs) out('baton', `${s.prefix}: ${s.enabled ? 'on' : 'off'} (${s.note})`)
|
|
168
|
+
if (dry) {
|
|
169
|
+
out('baton', 'dry run: nothing spawned. Would run:')
|
|
170
|
+
for (const p of plan.procs) out('baton', `${p.prefix}: ${JSON.stringify([p.bin, ...p.argv])} env ${JSON.stringify(p.env)}`)
|
|
171
|
+
out('baton', `then poll http://${bind}:${port}/api/health, ${open ? 'open the board' : 'not open the board'}, write ${pidfile()}`)
|
|
172
|
+
return 0
|
|
173
|
+
}
|
|
174
|
+
const existing = readPidfile()
|
|
175
|
+
if (existing && existing.pid !== process.pid && await boardAlive(existing)) {
|
|
176
|
+
out('baton', `already running (pid ${existing.pid}, port ${existing.port}); use \`baton down\` first`, process.stderr)
|
|
177
|
+
return 1
|
|
178
|
+
}
|
|
179
|
+
if (existing) { try { rmSync(pidfile(), { force: true }) } catch {} }
|
|
180
|
+
mkdirSync(home(), { recursive: true })
|
|
181
|
+
const [p] = plan.procs
|
|
182
|
+
const child = spawn(p.bin, p.argv, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...p.env, LEG_QUIET: '0', BATON_QUIET: '0' } })
|
|
183
|
+
let actualPort = port
|
|
184
|
+
const lastLines = []
|
|
185
|
+
const remember = (line) => { lastLines.push(line); if (lastLines.length > 12) lastLines.shift(); const m = /listening on http:\/\/[^:]+:(\d+)/.exec(line); if (m) actualPort = Number(m[1]) }
|
|
186
|
+
pipeLines(child.stdout, p.prefix, remember)
|
|
187
|
+
pipeLines(child.stderr, p.prefix, remember)
|
|
188
|
+
let exited = null
|
|
189
|
+
child.on('exit', (code) => { exited = code ?? -1 })
|
|
190
|
+
|
|
191
|
+
const t0 = Date.now()
|
|
192
|
+
let ok = null
|
|
193
|
+
while (Date.now() - t0 < HEALTH_TIMEOUT_MS && exited === null) {
|
|
194
|
+
ok = await health(actualPort, bind)
|
|
195
|
+
if (ok) break
|
|
196
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
197
|
+
}
|
|
198
|
+
if (!ok) {
|
|
199
|
+
out('baton', `server not healthy after ${Math.round((Date.now() - t0) / 1000)} s${exited !== null ? ` (exited ${exited})` : ''}; last lines:`, process.stderr)
|
|
200
|
+
for (const l of lastLines) out(p.prefix, l, process.stderr)
|
|
201
|
+
killTree(child.pid)
|
|
202
|
+
return 1
|
|
203
|
+
}
|
|
204
|
+
const url = `http://${bind === '0.0.0.0' ? '127.0.0.1' : bind}:${actualPort}`
|
|
205
|
+
writeFileSync(pidfile(), JSON.stringify({ pid: process.pid, port: actualPort, bind, children: [child.pid], started_at: new Date().toISOString() }, null, 2) + '\n')
|
|
206
|
+
out('baton', `ready ${url} (scheduler max ${MAX_CONCURRENT}, ${ok.cards} card${ok.cards === 1 ? '' : 's'})`)
|
|
207
|
+
if (open) out('baton', openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
|
|
208
|
+
out('baton', 'Ctrl-C stops everything')
|
|
209
|
+
|
|
210
|
+
return await new Promise((resolvePromise) => {
|
|
211
|
+
let stopping = false
|
|
212
|
+
const stop = (why) => {
|
|
213
|
+
if (stopping) return
|
|
214
|
+
stopping = true
|
|
215
|
+
out('baton', `stopping (${why})`)
|
|
216
|
+
const agents = killActiveAgents()
|
|
217
|
+
if (agents) out('baton', `killed ${agents} running agent/supervisor process(es)`)
|
|
218
|
+
killTree(child.pid)
|
|
219
|
+
try { rmSync(pidfile(), { force: true }) } catch {}
|
|
220
|
+
out('baton', 'stopped')
|
|
221
|
+
resolvePromise(0)
|
|
222
|
+
}
|
|
223
|
+
process.on('SIGINT', () => stop('SIGINT'))
|
|
224
|
+
process.on('SIGTERM', () => stop('SIGTERM'))
|
|
225
|
+
process.on('SIGBREAK', () => stop('SIGBREAK'))
|
|
226
|
+
child.on('exit', (code) => { if (!stopping) { out('baton', `server exited ${code}`, process.stderr); try { rmSync(pidfile(), { force: true }) } catch {} resolvePromise(code === 0 ? 0 : 1) } })
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Stop the board process and nothing else. Sharing the board with someone is a
|
|
231
|
+
// configuration change: it restarts the listener, it does not end the runs.
|
|
232
|
+
export async function stopBoard() {
|
|
233
|
+
const pf = readPidfile()
|
|
234
|
+
if (!pf) return { stopped: false, stale: false }
|
|
235
|
+
const alive = await boardAlive(pf)
|
|
236
|
+
if (alive) for (const pid of [...(pf.children ?? []), pf.pid]) killTree(pid)
|
|
237
|
+
try { rmSync(pidfile(), { force: true }) } catch {}
|
|
238
|
+
return { stopped: alive, stale: !alive, pid: pf.pid, port: pf.port }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function down() {
|
|
242
|
+
const pf = readPidfile()
|
|
243
|
+
if (!pf) { out('baton', 'not running'); return 0 }
|
|
244
|
+
const agents = killActiveAgents()
|
|
245
|
+
const board = await stopBoard()
|
|
246
|
+
out('baton', `stopped (pid ${pf.pid}, port ${pf.port}${agents ? `, ${agents} agent process(es) killed` : ''}${board.stale ? ', stale pidfile' : ''})`)
|
|
247
|
+
return 0
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function status() {
|
|
251
|
+
const pf = readPidfile()
|
|
252
|
+
const running = await boardAlive(pf)
|
|
253
|
+
if (pf && !running) { try { rmSync(pidfile(), { force: true }) } catch {} }
|
|
254
|
+
const cards = listCards()
|
|
255
|
+
const by = (key) => Object.entries(cards.reduce((m, c) => { m[c[key]] = (m[c[key]] ?? 0) + 1; return m }, {})).map(([k, v]) => `${k}=${v}`).join(' ') || '(none)'
|
|
256
|
+
if (running) {
|
|
257
|
+
const up = Math.round((Date.now() - Date.parse(pf.started_at)) / 1000)
|
|
258
|
+
out('baton', `running pid ${pf.pid} port ${pf.port} up ${Math.floor(up / 60)}m${up % 60}s http://127.0.0.1:${pf.port}`)
|
|
259
|
+
} else {
|
|
260
|
+
out('baton', pf ? `stopped (pid ${pf.pid} is not answering on port ${pf.port}; cleared the stale pidfile)` : 'stopped')
|
|
261
|
+
}
|
|
262
|
+
const s = schedulerStatus()
|
|
263
|
+
out('baton', `scheduler ${s.running ? `running (pid ${s.pid})` : 'stopped'} max concurrent ${MAX_CONCURRENT}`)
|
|
264
|
+
out('baton', `cards ${cards.length} by status: ${by('status')}`)
|
|
265
|
+
out('baton', `by station: ${by('station')}`)
|
|
266
|
+
return running ? 0 : 3
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function listHomeRuns() {
|
|
270
|
+
const root = join(home(), 'cards')
|
|
271
|
+
return existsSync(root) ? readdirSync(root).length : 0
|
|
272
|
+
}
|
package/src/leases.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// leases — pure helpers for deciding whether two cards' claimed leases (glob-ish
|
|
2
|
+
// path patterns) could touch the same files. Used to gate parallel station starts.
|
|
3
|
+
//
|
|
4
|
+
// The overlap check is a deliberate approximation biased toward false positives:
|
|
5
|
+
// when unsure we say overlapping, because a wrongly serialized card costs minutes
|
|
6
|
+
// and a wrongly parallel card can corrupt a merge.
|
|
7
|
+
|
|
8
|
+
// Backslashes to forward slashes, strip a leading './' or '/', collapse repeated
|
|
9
|
+
// slashes, strip a trailing '/'. Anything empty (or '**' itself) is the wildcard
|
|
10
|
+
// lease that overlaps everything.
|
|
11
|
+
export function normalize(p) {
|
|
12
|
+
if (p === null || p === undefined || p === '' || p === '**') return '**'
|
|
13
|
+
let s = String(p).replace(/\\/g, '/')
|
|
14
|
+
s = s.replace(/^\.\//, '').replace(/^\/+/, '')
|
|
15
|
+
s = s.replace(/\/+/g, '/')
|
|
16
|
+
s = s.replace(/\/$/, '')
|
|
17
|
+
return s === '' ? '**' : s
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Path segments before the first segment that contains a glob character.
|
|
21
|
+
export function literalPrefix(p) {
|
|
22
|
+
const s = normalize(p)
|
|
23
|
+
if (s === '**') return []
|
|
24
|
+
const segments = s.split('/')
|
|
25
|
+
const idx = segments.findIndex((seg) => seg.includes('*') || seg.includes('?'))
|
|
26
|
+
return idx === -1 ? segments : segments.slice(0, idx)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// See file header: approximation biased toward false positives.
|
|
30
|
+
export function overlap(a, b) {
|
|
31
|
+
const na = normalize(a)
|
|
32
|
+
const nb = normalize(b)
|
|
33
|
+
if (na === '**' || nb === '**') return true
|
|
34
|
+
if (na === nb) return true
|
|
35
|
+
const pa = literalPrefix(a)
|
|
36
|
+
const pb = literalPrefix(b)
|
|
37
|
+
const len = Math.min(pa.length, pb.length)
|
|
38
|
+
for (let i = 0; i < len; i++) {
|
|
39
|
+
if (pa[i] !== pb[i]) return false
|
|
40
|
+
}
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function anyOverlap(listA, listB) {
|
|
45
|
+
const a = listA && listA.length ? listA : ['**']
|
|
46
|
+
const b = listB && listB.length ? listB : ['**']
|
|
47
|
+
for (const x of a) {
|
|
48
|
+
for (const y of b) {
|
|
49
|
+
if (overlap(x, y)) return true
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Every overlapping (holder lease, candidate lease) pair, in running order.
|
|
56
|
+
export function conflicts(candidate, running) {
|
|
57
|
+
const candLeases = candidate.leases && candidate.leases.length ? candidate.leases : ['**']
|
|
58
|
+
const out = []
|
|
59
|
+
for (const holder of running) {
|
|
60
|
+
const holderLeases = holder.leases && holder.leases.length ? holder.leases : ['**']
|
|
61
|
+
for (const hLease of holderLeases) {
|
|
62
|
+
for (const cLease of candLeases) {
|
|
63
|
+
if (overlap(hLease, cLease)) {
|
|
64
|
+
out.push({ holder: holder.card_id, lease: hLease, against: cLease })
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// One row per lease of every running/handing_off card, sorted by card_id then lease.
|
|
73
|
+
export function held(cards) {
|
|
74
|
+
const out = []
|
|
75
|
+
for (const card of cards) {
|
|
76
|
+
if (card.status !== 'running' && card.status !== 'handing_off') continue
|
|
77
|
+
const leases = card.leases && card.leases.length ? card.leases : ['**']
|
|
78
|
+
for (const lease of leases) {
|
|
79
|
+
out.push({ lease, card_id: card.card_id, station: card.station, since: card.updated_at })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
out.sort((x, y) => (x.card_id === y.card_id
|
|
83
|
+
? (x.lease < y.lease ? -1 : x.lease > y.lease ? 1 : 0)
|
|
84
|
+
: (x.card_id < y.card_id ? -1 : 1)))
|
|
85
|
+
return out
|
|
86
|
+
}
|