omp-conductor 0.3.18 → 0.3.20
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/README.md +352 -182
- package/package.json +1 -1
- package/skills/conductor-onboarding/SKILL.md +43 -50
- package/skills/conductor-update/SKILL.md +15 -165
- package/src/board.ts +734 -0
- package/src/brief-upgrade.ts +102 -19
- package/src/briefs/orchestrator.md +32 -16
- package/src/briefs/worker.md +7 -2
- package/src/cli.ts +138 -53
- package/src/config.ts +22 -6
- package/src/daemon.ts +673 -126
- package/src/fleet.ts +64 -6
- package/src/graph-health.ts +296 -0
- package/src/graph.ts +6 -6
- package/src/omp.ts +15 -4
- package/src/orchestrator-tick.ts +157 -10
- package/src/orchestrator.ts +8 -1
- package/src/plugin.ts +173 -45
- package/src/release-policy.ts +202 -0
- package/src/setup-host.ts +285 -0
- package/src/setup.ts +24 -20
- package/src/store.ts +373 -13
- package/src/tracker/github.ts +171 -3
- package/src/transcript.ts +45 -0
- package/src/types.ts +145 -2
- package/src/unblock.ts +26 -14
- package/src/upgrade.ts +537 -0
- package/src/worker.ts +67 -30
- package/src/worktree.ts +66 -0
- package/systemd/omp-conductor.service.example +5 -3
package/src/board.ts
ADDED
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { emitKeypressEvents } from "node:readline";
|
|
4
|
+
import { findProject, loadConfig, resolveCaps } from "./config.ts";
|
|
5
|
+
import { statusSnapshotFromStore, type StatusSnapshot } from "./daemon.ts";
|
|
6
|
+
import {
|
|
7
|
+
codeGraphFromHealthz,
|
|
8
|
+
fleetLayers,
|
|
9
|
+
probeTelegramHealth,
|
|
10
|
+
type FleetLayers,
|
|
11
|
+
type TelegramHealth,
|
|
12
|
+
} from "./fleet.ts";
|
|
13
|
+
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
14
|
+
import { healthCheck, livingDaemon } from "./lifecycle.ts";
|
|
15
|
+
import { dbPath, openStore } from "./store.ts";
|
|
16
|
+
import { formatTranscriptLine } from "./transcript.ts";
|
|
17
|
+
import type { AdmissionHoldReason, ProjectConfig, RunRecord, RunState, Store } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
const REFRESH_MS = 1_000;
|
|
20
|
+
const HEALTH_REFRESH_MS = 10_000;
|
|
21
|
+
const MERGED_HISTORY_MS = 24 * 60 * 60_000;
|
|
22
|
+
const TRANSCRIPT_BYTES = 64 * 1024;
|
|
23
|
+
const MIN_WIDTH = 50;
|
|
24
|
+
const MIN_HEIGHT = 20;
|
|
25
|
+
const MIN_COLUMN_WIDTH = 22;
|
|
26
|
+
|
|
27
|
+
const CSI = "\x1b[";
|
|
28
|
+
const RESET = `${CSI}0m`;
|
|
29
|
+
const BOLD = `${CSI}1m`;
|
|
30
|
+
const DIM = `${CSI}2m`;
|
|
31
|
+
const REVERSE = `${CSI}7m`;
|
|
32
|
+
const CYAN = `${CSI}36m`;
|
|
33
|
+
const GREEN = `${CSI}32m`;
|
|
34
|
+
const YELLOW = `${CSI}33m`;
|
|
35
|
+
const RED = `${CSI}31m`;
|
|
36
|
+
const MAGENTA = `${CSI}35m`;
|
|
37
|
+
|
|
38
|
+
const COLUMN_DEFS = [
|
|
39
|
+
{ key: "queue", title: "QUEUE", states: [] },
|
|
40
|
+
{ key: "claimed", title: "CLAIMED", states: ["claimed"] },
|
|
41
|
+
{ key: "running", title: "RUNNING", states: ["running"] },
|
|
42
|
+
{ key: "green", title: "GREEN", states: ["pushed-pending", "pushed-green"] },
|
|
43
|
+
{ key: "blocked", title: "BLOCKED", states: ["blocked"] },
|
|
44
|
+
{ key: "failed", title: "FAILED", states: ["failed", "killed", "orphaned"] },
|
|
45
|
+
{ key: "merged", title: "MERGED", states: ["merged"] },
|
|
46
|
+
] as const satisfies readonly { key: string; title: string; states: readonly RunState[] }[];
|
|
47
|
+
|
|
48
|
+
type ColumnKey = (typeof COLUMN_DEFS)[number]["key"];
|
|
49
|
+
type DaemonBoardState = "stopped" | "ok" | "unreachable" | "other-project";
|
|
50
|
+
|
|
51
|
+
interface QueueCard {
|
|
52
|
+
kind: "queue";
|
|
53
|
+
issue: number;
|
|
54
|
+
reason: AdmissionHoldReason;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RunCard {
|
|
58
|
+
kind: "run";
|
|
59
|
+
run: RunRecord;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type BoardCard = QueueCard | RunCard;
|
|
63
|
+
|
|
64
|
+
export interface BoardHealth {
|
|
65
|
+
layers: FleetLayers;
|
|
66
|
+
telegram: TelegramHealth;
|
|
67
|
+
daemon: DaemonBoardState;
|
|
68
|
+
codeGraph?: CodeGraphHealth;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface BoardSnapshot {
|
|
72
|
+
project: ProjectConfig;
|
|
73
|
+
status: StatusSnapshot;
|
|
74
|
+
health: BoardHealth;
|
|
75
|
+
runs: RunRecord[];
|
|
76
|
+
now: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface BoardCursor {
|
|
80
|
+
column: number;
|
|
81
|
+
card: number;
|
|
82
|
+
detail: boolean;
|
|
83
|
+
transcriptOffset: number;
|
|
84
|
+
selection?: string;
|
|
85
|
+
selectionIssue?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface KeyInput {
|
|
89
|
+
name?: string;
|
|
90
|
+
sequence?: string;
|
|
91
|
+
ctrl?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ansiColor(key: ColumnKey): string {
|
|
95
|
+
switch (key) {
|
|
96
|
+
case "running":
|
|
97
|
+
return CYAN;
|
|
98
|
+
case "green":
|
|
99
|
+
case "merged":
|
|
100
|
+
return GREEN;
|
|
101
|
+
case "claimed":
|
|
102
|
+
case "blocked":
|
|
103
|
+
return YELLOW;
|
|
104
|
+
case "failed":
|
|
105
|
+
return RED;
|
|
106
|
+
default:
|
|
107
|
+
return MAGENTA;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function clip(value: string, width: number): string {
|
|
112
|
+
if (width < 1) return "";
|
|
113
|
+
if (value.length <= width) return value.padEnd(width);
|
|
114
|
+
if (width === 1) return value.slice(0, 1);
|
|
115
|
+
return `${value.slice(0, width - 1)}…`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function styledCell(value: string, width: number, style = ""): string {
|
|
119
|
+
const clipped = clip(value, width);
|
|
120
|
+
return style === "" ? clipped : `${style}${clipped}${RESET}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function wrapDelimited(value: string, width: number, delimiter: string): string[] {
|
|
124
|
+
const lines: string[] = [];
|
|
125
|
+
let current = "";
|
|
126
|
+
for (const segment of value.split(delimiter)) {
|
|
127
|
+
const candidate = current === "" ? segment : `${current}${delimiter}${segment}`;
|
|
128
|
+
if (candidate.length <= width) {
|
|
129
|
+
current = candidate;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (current !== "") lines.push(current);
|
|
133
|
+
const wrapped = wrap(segment, width);
|
|
134
|
+
lines.push(...wrapped.slice(0, -1));
|
|
135
|
+
current = wrapped.at(-1) ?? "";
|
|
136
|
+
}
|
|
137
|
+
if (current !== "") lines.push(current);
|
|
138
|
+
return lines.length === 0 ? [""] : lines;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function humanDuration(ms: number): string {
|
|
142
|
+
const minutes = Math.max(0, Math.floor(ms / 60_000));
|
|
143
|
+
if (minutes < 1) return "<1m";
|
|
144
|
+
if (minutes < 60) return `${minutes}m`;
|
|
145
|
+
const hours = Math.floor(minutes / 60);
|
|
146
|
+
if (hours < 24) return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
147
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function queueCards(snapshot: BoardSnapshot): QueueCard[] {
|
|
151
|
+
const cards: QueueCard[] = [];
|
|
152
|
+
const seen = new Set<number>();
|
|
153
|
+
for (const hold of snapshot.status.dispatch?.holds ?? []) {
|
|
154
|
+
for (const issue of hold.issues) {
|
|
155
|
+
if (seen.has(issue)) continue;
|
|
156
|
+
seen.add(issue);
|
|
157
|
+
cards.push({ kind: "queue", issue, reason: hold.reason });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return cards;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function cardsFor(snapshot: BoardSnapshot, key: ColumnKey): BoardCard[] {
|
|
164
|
+
if (key === "queue") return queueCards(snapshot);
|
|
165
|
+
const states: readonly RunState[] = COLUMN_DEFS.find((column) => column.key === key)?.states ?? [];
|
|
166
|
+
return snapshot.runs.filter((run) => states.includes(run.state)).map((run) => ({ kind: "run", run }));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function columnCount(snapshot: BoardSnapshot, key: ColumnKey): number {
|
|
170
|
+
if (key === "queue") return snapshot.status.dispatch?.ready ?? 0;
|
|
171
|
+
return cardsFor(snapshot, key).length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function cardIssue(card: BoardCard): number {
|
|
175
|
+
return card.kind === "queue" ? card.issue : card.run.issue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function cardKey(card: BoardCard): string {
|
|
179
|
+
return card.kind === "queue" ? `queue:${card.issue}` : `run:${card.run.id}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function focusedCard(snapshot: BoardSnapshot, cursor: BoardCursor): BoardCard | undefined {
|
|
183
|
+
const column = COLUMN_DEFS[cursor.column];
|
|
184
|
+
if (column === undefined) return undefined;
|
|
185
|
+
return cardsFor(snapshot, column.key)[cursor.card];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function selectCard(snapshot: BoardSnapshot, cursor: BoardCursor, requested: number): void {
|
|
189
|
+
const column = COLUMN_DEFS[cursor.column]!;
|
|
190
|
+
const cards = cardsFor(snapshot, column.key);
|
|
191
|
+
cursor.card = Math.max(0, Math.min(Math.max(0, cards.length - 1), requested));
|
|
192
|
+
const card = cards[cursor.card];
|
|
193
|
+
if (card === undefined) {
|
|
194
|
+
delete cursor.selection;
|
|
195
|
+
delete cursor.selectionIssue;
|
|
196
|
+
} else {
|
|
197
|
+
cursor.selection = cardKey(card);
|
|
198
|
+
cursor.selectionIssue = cardIssue(card);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeCursor(snapshot: BoardSnapshot, cursor: BoardCursor): void {
|
|
203
|
+
cursor.column = Math.max(0, Math.min(COLUMN_DEFS.length - 1, cursor.column));
|
|
204
|
+
const hadSelection = cursor.selection !== undefined || cursor.selectionIssue !== undefined;
|
|
205
|
+
let foundColumn = -1;
|
|
206
|
+
let foundCard = -1;
|
|
207
|
+
|
|
208
|
+
if (cursor.selection !== undefined) {
|
|
209
|
+
for (const [columnIndex, column] of COLUMN_DEFS.entries()) {
|
|
210
|
+
const cardIndex = cardsFor(snapshot, column.key).findIndex((card) => cardKey(card) === cursor.selection);
|
|
211
|
+
if (cardIndex < 0) continue;
|
|
212
|
+
foundColumn = columnIndex;
|
|
213
|
+
foundCard = cardIndex;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (foundColumn < 0 && cursor.selectionIssue !== undefined) {
|
|
218
|
+
for (const [columnIndex, column] of COLUMN_DEFS.entries()) {
|
|
219
|
+
const cardIndex = cardsFor(snapshot, column.key).findIndex((card) => cardIssue(card) === cursor.selectionIssue);
|
|
220
|
+
if (cardIndex < 0) continue;
|
|
221
|
+
foundColumn = columnIndex;
|
|
222
|
+
foundCard = cardIndex;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (foundColumn >= 0) {
|
|
228
|
+
cursor.column = foundColumn;
|
|
229
|
+
selectCard(snapshot, cursor, foundCard);
|
|
230
|
+
} else {
|
|
231
|
+
if (hadSelection) cursor.detail = false;
|
|
232
|
+
selectCard(snapshot, cursor, cursor.card);
|
|
233
|
+
}
|
|
234
|
+
cursor.transcriptOffset = Math.max(0, cursor.transcriptOffset);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function runCardLines(run: RunRecord, snapshot: BoardSnapshot): string[] {
|
|
238
|
+
const endedAt = run.endedAt ?? snapshot.now;
|
|
239
|
+
const duration = humanDuration(endedAt - run.startedAt);
|
|
240
|
+
const lines = [
|
|
241
|
+
`#${run.issue} · ${run.repo}`,
|
|
242
|
+
`attempt ${run.attempt} · ${run.turns}/${run.maxTurns}t`,
|
|
243
|
+
`$${run.spendUsd.toFixed(2)} · ${duration}`,
|
|
244
|
+
];
|
|
245
|
+
if (run.state === "pushed-pending") lines.push("checks pending");
|
|
246
|
+
else if (run.lastError !== undefined) lines.push(run.lastError.replace(/\s+/g, " "));
|
|
247
|
+
else if (run.prUrl !== undefined) lines.push(run.prUrl.replace(/^https?:\/\//, ""));
|
|
248
|
+
else lines.push(run.branch);
|
|
249
|
+
return lines;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function queueCardLines(card: QueueCard): string[] {
|
|
253
|
+
return [`#${card.issue}`, card.reason.replaceAll("-", " "), "held in latest tick"];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function renderColumn(
|
|
257
|
+
snapshot: BoardSnapshot,
|
|
258
|
+
key: ColumnKey,
|
|
259
|
+
title: string,
|
|
260
|
+
selectedColumn: boolean,
|
|
261
|
+
selectedCard: number,
|
|
262
|
+
width: number,
|
|
263
|
+
height: number,
|
|
264
|
+
): string[] {
|
|
265
|
+
const cards = cardsFor(snapshot, key);
|
|
266
|
+
const count = columnCount(snapshot, key);
|
|
267
|
+
const color = ansiColor(key);
|
|
268
|
+
const slots = Math.max(1, Math.floor(Math.max(0, height - 2) / 5));
|
|
269
|
+
const start = selectedColumn
|
|
270
|
+
? Math.max(0, Math.min(Math.max(0, cards.length - slots), selectedCard - Math.floor(slots / 2)))
|
|
271
|
+
: 0;
|
|
272
|
+
const shown = cards.slice(start, start + slots);
|
|
273
|
+
const lines = [
|
|
274
|
+
styledCell(` ${title} ${count}`, width, `${BOLD}${color}`),
|
|
275
|
+
styledCell(start === 0 ? "─".repeat(width) : ` ↑ ${start} earlier`, width, DIM),
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
if (cards.length === 0) {
|
|
279
|
+
const empty = key === "queue" && count > 0 ? `${count} ready; no hold sample` : "(empty)";
|
|
280
|
+
lines.push(styledCell(` ${empty}`, width, DIM));
|
|
281
|
+
} else {
|
|
282
|
+
for (const [offset, card] of shown.entries()) {
|
|
283
|
+
const raw = card.kind === "run" ? runCardLines(card.run, snapshot) : queueCardLines(card);
|
|
284
|
+
while (raw.length < 4) raw.push("");
|
|
285
|
+
const selected = selectedColumn && start + offset === selectedCard;
|
|
286
|
+
for (const [lineIndex, value] of raw.entries()) {
|
|
287
|
+
const prefix = lineIndex === 0 ? (selected ? "> " : " ") : " ";
|
|
288
|
+
lines.push(styledCell(`${prefix}${value}`, width, selected ? REVERSE : ""));
|
|
289
|
+
}
|
|
290
|
+
lines.push(" ".repeat(width));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const hiddenAfter = Math.max(0, cards.length - start - shown.length);
|
|
295
|
+
if (hiddenAfter > 0) lines[lines.length - 1] = styledCell(` ↓ ${hiddenAfter} more`, width, DIM);
|
|
296
|
+
while (lines.length < height) lines.push(" ".repeat(width));
|
|
297
|
+
return lines.slice(0, height);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function healthLine(snapshot: BoardSnapshot): string {
|
|
301
|
+
const { layers, daemon } = snapshot.health;
|
|
302
|
+
const dispatch = daemon === "other-project" ? "other-project" : layers.dispatch;
|
|
303
|
+
let nextTick = "";
|
|
304
|
+
if (layers.nextTickAt !== undefined) {
|
|
305
|
+
const parsed = Date.parse(layers.nextTickAt);
|
|
306
|
+
nextTick = ` next ${Number.isNaN(parsed) ? layers.nextTickAt : new Date(parsed).toISOString().slice(11, 19)}`;
|
|
307
|
+
}
|
|
308
|
+
return [
|
|
309
|
+
`dispatch ${dispatch}`,
|
|
310
|
+
`ticks ${layers.ticks}${nextTick}`,
|
|
311
|
+
`pane ${layers.pane}`,
|
|
312
|
+
`recovery ${layers.recovery}`,
|
|
313
|
+
`herdr ${layers.herdr}`,
|
|
314
|
+
`daemon ${daemon}`,
|
|
315
|
+
].join(" · ");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function integrationLine(snapshot: BoardSnapshot): string {
|
|
319
|
+
const { telegram, codeGraph } = snapshot.health;
|
|
320
|
+
const graph = codeGraph?.configured
|
|
321
|
+
? `${codeGraph.status} ${codeGraph.repos.filter((repo) => repo.index === "present").length}/${codeGraph.repos.length} indexed`
|
|
322
|
+
: "off";
|
|
323
|
+
const telegramDetail = telegram.detail?.replace(/\s+/g, " ").slice(0, 80);
|
|
324
|
+
return `graph ${graph} · telegram ${telegram.kind}${telegramDetail === undefined ? "" : ` — ${telegramDetail}`}`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function admissionLine(snapshot: BoardSnapshot): string {
|
|
328
|
+
const dispatch = snapshot.status.dispatch;
|
|
329
|
+
const cap = snapshot.status.caps.dailySpendUsd;
|
|
330
|
+
const queue =
|
|
331
|
+
dispatch === undefined
|
|
332
|
+
? "dispatch not recorded"
|
|
333
|
+
: `${dispatch.degraded ? "DEGRADED · " : ""}${dispatch.ready} ready · ${dispatch.routed} routed · ${dispatch.admitted} admitted`;
|
|
334
|
+
const holdText = dispatch?.holds.map((hold) => `${hold.reason} ${hold.count}`).join(", ");
|
|
335
|
+
const holds = holdText === undefined || holdText === "" ? "none" : holdText;
|
|
336
|
+
return `${queue} | holds ${holds} | workers ${snapshot.status.liveWorkers}/${snapshot.status.caps.maxConcurrentWorkers} | spend $${snapshot.status.spendTodayUsd.toFixed(2)}${cap === null ? "" : `/$${cap.toFixed(2)}`}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function header(snapshot: BoardSnapshot, width: number): string[] {
|
|
340
|
+
const at = new Date(snapshot.now).toISOString().slice(11, 19);
|
|
341
|
+
const degradedStyle = snapshot.status.dispatch?.degraded === true ? `${BOLD}${RED}` : DIM;
|
|
342
|
+
return [
|
|
343
|
+
styledCell(` CONDUCTOR ${snapshot.project.name.toUpperCase()} ${" ".repeat(Math.max(1, width - snapshot.project.name.length - 22))}${at} `, width, `${BOLD}${REVERSE}`),
|
|
344
|
+
...wrapDelimited(healthLine(snapshot), width, " · ").map((line) => styledCell(line, width)),
|
|
345
|
+
...wrapDelimited(integrationLine(snapshot), width, " · ").map((line) => styledCell(line, width)),
|
|
346
|
+
...wrapDelimited(admissionLine(snapshot), width, " | ").map((line) => styledCell(line, width, degradedStyle)),
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function renderBoardColumns(snapshot: BoardSnapshot, cursor: BoardCursor, width: number, height: number): string[] {
|
|
351
|
+
const visible = Math.max(1, Math.min(COLUMN_DEFS.length, Math.floor((width + 1) / (MIN_COLUMN_WIDTH + 1))));
|
|
352
|
+
const start = Math.max(0, Math.min(COLUMN_DEFS.length - visible, cursor.column - Math.floor(visible / 2)));
|
|
353
|
+
const shown = COLUMN_DEFS.slice(start, start + visible);
|
|
354
|
+
const columnWidth = Math.floor((width - (shown.length - 1)) / shown.length);
|
|
355
|
+
const columns = shown.map((column, offset) =>
|
|
356
|
+
renderColumn(
|
|
357
|
+
snapshot,
|
|
358
|
+
column.key,
|
|
359
|
+
column.title,
|
|
360
|
+
start + offset === cursor.column,
|
|
361
|
+
cursor.card,
|
|
362
|
+
columnWidth,
|
|
363
|
+
height,
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
return Array.from({ length: height }, (_, row) => columns.map((column) => column[row]).join(" "));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function readTranscript(run: RunRecord): string[] {
|
|
370
|
+
if (run.sessionFile === undefined) return [`No transcript yet (state: ${run.state}).`];
|
|
371
|
+
let fd: number | undefined;
|
|
372
|
+
try {
|
|
373
|
+
const size = statSync(run.sessionFile).size;
|
|
374
|
+
const start = Math.max(0, size - TRANSCRIPT_BYTES);
|
|
375
|
+
const buffer = Buffer.allocUnsafe(size - start);
|
|
376
|
+
fd = openSync(run.sessionFile, "r");
|
|
377
|
+
const bytes = readSync(fd, buffer, 0, buffer.length, start);
|
|
378
|
+
const raw = buffer.subarray(0, bytes).toString("utf8");
|
|
379
|
+
const source = start === 0 ? raw : raw.slice(Math.max(0, raw.indexOf("\n") + 1));
|
|
380
|
+
return source
|
|
381
|
+
.split("\n")
|
|
382
|
+
.flatMap((line) => formatTranscriptLine(line)?.split("\n") ?? []);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
return [`Transcript unavailable: ${err instanceof Error ? err.message : String(err)}`];
|
|
385
|
+
} finally {
|
|
386
|
+
if (fd !== undefined) closeSync(fd);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function wrap(value: string, width: number): string[] {
|
|
391
|
+
const words = value.replace(/\s+/g, " ").trim().split(" ");
|
|
392
|
+
const lines: string[] = [];
|
|
393
|
+
let line = "";
|
|
394
|
+
for (const word of words) {
|
|
395
|
+
if (line === "") {
|
|
396
|
+
line = word;
|
|
397
|
+
} else if (line.length + word.length + 1 <= width) {
|
|
398
|
+
line += ` ${word}`;
|
|
399
|
+
} else {
|
|
400
|
+
lines.push(line);
|
|
401
|
+
line = word;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (line !== "") lines.push(line);
|
|
405
|
+
return lines.length === 0 ? [""] : lines;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderDetail(snapshot: BoardSnapshot, cursor: BoardCursor, width: number, height: number): string[] {
|
|
409
|
+
const card = focusedCard(snapshot, cursor);
|
|
410
|
+
if (card === undefined) return [styledCell("No card selected.", width, DIM)];
|
|
411
|
+
if (card.kind === "queue") {
|
|
412
|
+
return [
|
|
413
|
+
styledCell(` QUEUED ISSUE #${card.issue} `, width, `${BOLD}${REVERSE}`),
|
|
414
|
+
styledCell(`Latest hold: ${card.reason}`, width),
|
|
415
|
+
"",
|
|
416
|
+
styledCell("No run exists yet, so there is no transcript to inspect.", width, DIM),
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const run = card.run;
|
|
421
|
+
const metadata = [
|
|
422
|
+
styledCell(` RUN #${run.issue} ${run.repo} ${run.state} `, width, `${BOLD}${REVERSE}`),
|
|
423
|
+
styledCell(`attempt ${run.attempt} · ${run.turns}/${run.maxTurns} turns · $${run.spendUsd.toFixed(2)} · ${humanDuration((run.endedAt ?? snapshot.now) - run.startedAt)}`, width),
|
|
424
|
+
styledCell(`branch ${run.branch}`, width, DIM),
|
|
425
|
+
styledCell(`worktree ${run.worktree || "removed"}`, width, DIM),
|
|
426
|
+
styledCell("─".repeat(width), width, DIM),
|
|
427
|
+
];
|
|
428
|
+
const bodyHeight = Math.max(1, height - metadata.length);
|
|
429
|
+
const wrapped = readTranscript(run).flatMap((line) => wrap(line, width));
|
|
430
|
+
const end = Math.max(0, wrapped.length - cursor.transcriptOffset);
|
|
431
|
+
const start = Math.max(0, end - bodyHeight);
|
|
432
|
+
const body = wrapped.slice(start, end).map((line) => styledCell(line, width, line.startsWith("tool:") ? CYAN : ""));
|
|
433
|
+
while (body.length < bodyHeight) body.unshift(" ".repeat(width));
|
|
434
|
+
return [...metadata, ...body];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function renderHelp(width: number, height: number): string[] {
|
|
438
|
+
const lines = [
|
|
439
|
+
"FLEET BOARD KEYS",
|
|
440
|
+
"",
|
|
441
|
+
"←/→ or h/l select column",
|
|
442
|
+
"↑/↓ or k/j select card",
|
|
443
|
+
"Enter inspect/follow transcript",
|
|
444
|
+
"u unblock selected failed or blocked issue",
|
|
445
|
+
"i open selected issue",
|
|
446
|
+
"p open selected pull request",
|
|
447
|
+
"r refresh health now",
|
|
448
|
+
"? close help",
|
|
449
|
+
"Esc back, then quit",
|
|
450
|
+
"q / Ctrl-C back, then quit",
|
|
451
|
+
];
|
|
452
|
+
const top = Math.max(0, Math.floor((height - lines.length) / 2));
|
|
453
|
+
return [
|
|
454
|
+
...Array.from({ length: top }, () => " ".repeat(width)),
|
|
455
|
+
...lines.map((line, index) => styledCell(line, width, index === 0 ? `${BOLD}${CYAN}` : "")),
|
|
456
|
+
].slice(0, height);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function stripAnsi(value: string): string {
|
|
460
|
+
return value.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function renderBoard(
|
|
464
|
+
snapshot: BoardSnapshot,
|
|
465
|
+
cursor: BoardCursor,
|
|
466
|
+
width: number,
|
|
467
|
+
height: number,
|
|
468
|
+
notice = "",
|
|
469
|
+
help = false,
|
|
470
|
+
): string {
|
|
471
|
+
normalizeCursor(snapshot, cursor);
|
|
472
|
+
if (width < MIN_WIDTH || height < MIN_HEIGHT) {
|
|
473
|
+
return [
|
|
474
|
+
styledCell("omp-conductor board", width, `${BOLD}${REVERSE}`),
|
|
475
|
+
styledCell(`Terminal too small: ${width}x${height}; need at least ${MIN_WIDTH}x${MIN_HEIGHT}.`, width),
|
|
476
|
+
].join("\n");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const top = header(snapshot, width);
|
|
480
|
+
const footerHeight = 2;
|
|
481
|
+
const bodyHeight = height - top.length - footerHeight;
|
|
482
|
+
const body = help
|
|
483
|
+
? renderHelp(width, bodyHeight)
|
|
484
|
+
: cursor.detail
|
|
485
|
+
? renderDetail(snapshot, cursor, width, bodyHeight)
|
|
486
|
+
: renderBoardColumns(snapshot, cursor, width, bodyHeight);
|
|
487
|
+
while (body.length < bodyHeight) body.push(" ".repeat(width));
|
|
488
|
+
const footer = cursor.detail
|
|
489
|
+
? "↑/↓ scroll Esc back u unblock i issue p PR r refresh ? help q quit"
|
|
490
|
+
: "←/→ column ↑/↓ card Enter inspect u unblock i issue p PR r refresh ? help q quit";
|
|
491
|
+
return [
|
|
492
|
+
...top,
|
|
493
|
+
...body.slice(0, bodyHeight),
|
|
494
|
+
styledCell(notice === "" ? " " : ` ${notice}`, width, notice.toLowerCase().includes("failed") ? RED : DIM),
|
|
495
|
+
styledCell(` ${footer}`, width, REVERSE),
|
|
496
|
+
].join("\n");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function probeBoardHealth(project: ProjectConfig): Promise<BoardHealth> {
|
|
500
|
+
const layers = fleetLayers(project.name);
|
|
501
|
+
const record = livingDaemon();
|
|
502
|
+
const wrongRecord = record?.project !== undefined && record.project !== project.name;
|
|
503
|
+
const [telegram, health] = await Promise.all([
|
|
504
|
+
probeTelegramHealth(project.name),
|
|
505
|
+
record === undefined || wrongRecord ? undefined : healthCheck(record.port),
|
|
506
|
+
]);
|
|
507
|
+
let daemon: DaemonBoardState;
|
|
508
|
+
if (record === undefined) daemon = "stopped";
|
|
509
|
+
else if (wrongRecord) daemon = "other-project";
|
|
510
|
+
else if (health?.ok !== true) daemon = "unreachable";
|
|
511
|
+
else {
|
|
512
|
+
try {
|
|
513
|
+
const payload = JSON.parse(health.body ?? "null") as { project?: unknown };
|
|
514
|
+
daemon = payload.project === project.name ? "ok" : "other-project";
|
|
515
|
+
} catch {
|
|
516
|
+
daemon = "unreachable";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const cachedGraph = daemon === "ok" ? codeGraphFromHealthz(health?.body, project.name) : undefined;
|
|
520
|
+
return { layers, telegram, daemon, codeGraph: cachedGraph ?? (await probeCodeGraph(project)) };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function issueUrl(project: ProjectConfig, issue: number): string {
|
|
524
|
+
return `https://github.com/${project.tracker.repo}/issues/${issue}`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function openUrl(url: string): Promise<boolean> {
|
|
528
|
+
const command = process.platform === "darwin" ? "open" : "xdg-open";
|
|
529
|
+
try {
|
|
530
|
+
return (await Bun.spawn([command, url], { stdin: "ignore", stdout: "ignore", stderr: "ignore" }).exited) === 0;
|
|
531
|
+
} catch {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function summarizeUnblockOutput(issue: number, output: string): string {
|
|
537
|
+
const lines = output
|
|
538
|
+
.split("\n")
|
|
539
|
+
.map((line) => line.trim())
|
|
540
|
+
.filter((line) => line !== "");
|
|
541
|
+
return lines.length === 0 ? `#${issue}: unblock completed` : `#${issue}: ${lines.at(-1)}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function unblock(project: ProjectConfig, issue: number): Promise<string> {
|
|
545
|
+
const child = Bun.spawn(
|
|
546
|
+
[process.execPath, join(import.meta.dir, "cli.ts"), "unblock", String(issue), "--project", project.name],
|
|
547
|
+
{ stdin: "ignore", stdout: "pipe", stderr: "pipe" },
|
|
548
|
+
);
|
|
549
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
550
|
+
child.exited,
|
|
551
|
+
new Response(child.stdout).text(),
|
|
552
|
+
new Response(child.stderr).text(),
|
|
553
|
+
]);
|
|
554
|
+
if (code !== 0) return (stderr || stdout).trim().replace(/\s+/g, " ") || `#${issue}: unblock failed`;
|
|
555
|
+
return summarizeUnblockOutput(issue, stdout);
|
|
556
|
+
}
|
|
557
|
+
function enqueue(queue: KeyInput[], key: KeyInput, wake: (() => void) | undefined): void {
|
|
558
|
+
queue.push(key);
|
|
559
|
+
wake?.();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function waitForInput(queue: KeyInput[], setWake: (wake?: () => void) => void): Promise<KeyInput> {
|
|
563
|
+
if (queue.length > 0) return queue.shift()!;
|
|
564
|
+
await new Promise<void>((resolve) => {
|
|
565
|
+
const timer = setTimeout(resolve, REFRESH_MS);
|
|
566
|
+
setWake(() => {
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
resolve();
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
setWake(undefined);
|
|
572
|
+
return queue.shift() ?? { name: "refresh" };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export async function runBoard(projectName?: string): Promise<void> {
|
|
576
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
577
|
+
throw new Error("board needs an interactive terminal (TTY)");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const cfg = loadConfig();
|
|
581
|
+
const project = findProject(cfg, projectName);
|
|
582
|
+
const caps = resolveCaps(project, cfg.defaults);
|
|
583
|
+
const store: Store = openStore(dbPath());
|
|
584
|
+
const cursor: BoardCursor = { column: 2, card: 0, detail: false, transcriptOffset: 0 };
|
|
585
|
+
const queue: KeyInput[] = [];
|
|
586
|
+
let wake: (() => void) | undefined;
|
|
587
|
+
let stopping = false;
|
|
588
|
+
let help = false;
|
|
589
|
+
let notice = "";
|
|
590
|
+
let health = await probeBoardHealth(project);
|
|
591
|
+
let healthAt = Date.now();
|
|
592
|
+
let healthRefresh: Promise<void> | undefined;
|
|
593
|
+
|
|
594
|
+
emitKeypressEvents(process.stdin);
|
|
595
|
+
const onKey = (_text: string, key: KeyInput): void => enqueue(queue, key, wake);
|
|
596
|
+
const onResize = (): void => enqueue(queue, { name: "resize" }, wake);
|
|
597
|
+
const onStop = (): void => {
|
|
598
|
+
stopping = true;
|
|
599
|
+
wake?.();
|
|
600
|
+
};
|
|
601
|
+
process.stdin.on("keypress", onKey);
|
|
602
|
+
process.stdout.on("resize", onResize);
|
|
603
|
+
process.on("SIGINT", onStop);
|
|
604
|
+
process.on("SIGTERM", onStop);
|
|
605
|
+
process.stdin.setRawMode(true);
|
|
606
|
+
process.stdin.resume();
|
|
607
|
+
process.stdout.write(`${CSI}?1049h${CSI}?25l${CSI}2J`);
|
|
608
|
+
|
|
609
|
+
try {
|
|
610
|
+
while (!stopping) {
|
|
611
|
+
const now = Date.now();
|
|
612
|
+
if (now - healthAt >= HEALTH_REFRESH_MS && healthRefresh === undefined) {
|
|
613
|
+
healthAt = now;
|
|
614
|
+
healthRefresh = probeBoardHealth(project)
|
|
615
|
+
.then((next) => {
|
|
616
|
+
health = next;
|
|
617
|
+
enqueue(queue, { name: "refresh" }, wake);
|
|
618
|
+
})
|
|
619
|
+
.catch((err: unknown) => {
|
|
620
|
+
notice = `health refresh failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
621
|
+
})
|
|
622
|
+
.finally(() => {
|
|
623
|
+
healthRefresh = undefined;
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
const snapshot: BoardSnapshot = {
|
|
627
|
+
project,
|
|
628
|
+
status: statusSnapshotFromStore(project, caps, store),
|
|
629
|
+
health,
|
|
630
|
+
runs: store.recentRuns(project.name, now - MERGED_HISTORY_MS),
|
|
631
|
+
now,
|
|
632
|
+
};
|
|
633
|
+
normalizeCursor(snapshot, cursor);
|
|
634
|
+
process.stdout.write(`${CSI}H${renderBoard(snapshot, cursor, process.stdout.columns, process.stdout.rows, notice, help)}${CSI}J`);
|
|
635
|
+
notice = "";
|
|
636
|
+
|
|
637
|
+
const key = await waitForInput(queue, (next) => {
|
|
638
|
+
wake = next;
|
|
639
|
+
});
|
|
640
|
+
const name = key.name ?? key.sequence;
|
|
641
|
+
if (name === "refresh" || name === "resize") continue;
|
|
642
|
+
if (name === "?" || key.sequence === "?") {
|
|
643
|
+
help = !help;
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (help) {
|
|
647
|
+
if (name === "escape" || name === "q") help = false;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (name === "q" || (key.ctrl === true && name === "c")) {
|
|
651
|
+
if (cursor.detail) cursor.detail = false;
|
|
652
|
+
else stopping = true;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (name === "escape") {
|
|
656
|
+
if (cursor.detail) cursor.detail = false;
|
|
657
|
+
else stopping = true;
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (name === "r") {
|
|
661
|
+
healthAt = 0;
|
|
662
|
+
notice = "refresh requested";
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (cursor.detail && (name === "up" || name === "k")) {
|
|
666
|
+
cursor.transcriptOffset += 1;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (cursor.detail && (name === "down" || name === "j")) {
|
|
670
|
+
cursor.transcriptOffset = Math.max(0, cursor.transcriptOffset - 1);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
if (!cursor.detail && (name === "left" || name === "h")) {
|
|
674
|
+
cursor.column = Math.max(0, cursor.column - 1);
|
|
675
|
+
delete cursor.selection;
|
|
676
|
+
selectCard(snapshot, cursor, 0);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (!cursor.detail && (name === "right" || name === "l")) {
|
|
680
|
+
cursor.column = Math.min(COLUMN_DEFS.length - 1, cursor.column + 1);
|
|
681
|
+
delete cursor.selection;
|
|
682
|
+
selectCard(snapshot, cursor, 0);
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (!cursor.detail && (name === "up" || name === "k")) {
|
|
686
|
+
selectCard(snapshot, cursor, cursor.card - 1);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (!cursor.detail && (name === "down" || name === "j")) {
|
|
690
|
+
selectCard(snapshot, cursor, cursor.card + 1);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const card = focusedCard(snapshot, cursor);
|
|
695
|
+
if (name === "return" || name === "enter") {
|
|
696
|
+
if (card === undefined) notice = "no card selected";
|
|
697
|
+
else {
|
|
698
|
+
cursor.detail = !cursor.detail;
|
|
699
|
+
cursor.transcriptOffset = 0;
|
|
700
|
+
}
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (name === "i" && card !== undefined) {
|
|
704
|
+
const url = issueUrl(project, cardIssue(card));
|
|
705
|
+
notice = (await openUrl(url)) ? `opened ${url}` : `failed to open ${url}`;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (name === "p") {
|
|
709
|
+
const url = card?.kind === "run" ? card.run.prUrl : undefined;
|
|
710
|
+
if (url === undefined) notice = "selected run has no pull request";
|
|
711
|
+
else notice = (await openUrl(url)) ? `opened ${url}` : `failed to open ${url}`;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (name === "u") {
|
|
715
|
+
const state = card?.kind === "run" ? card.run.state : undefined;
|
|
716
|
+
if (card === undefined || card.kind !== "run" || !["blocked", "failed", "killed", "orphaned"].includes(state!)) {
|
|
717
|
+
notice = "unblock is available for blocked or failed runs";
|
|
718
|
+
} else {
|
|
719
|
+
notice = await unblock(project, card.run.issue);
|
|
720
|
+
healthAt = 0;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
} finally {
|
|
725
|
+
process.stdin.setRawMode(false);
|
|
726
|
+
process.stdin.pause();
|
|
727
|
+
process.stdin.off("keypress", onKey);
|
|
728
|
+
process.stdout.off("resize", onResize);
|
|
729
|
+
process.off("SIGINT", onStop);
|
|
730
|
+
process.off("SIGTERM", onStop);
|
|
731
|
+
process.stdout.write(`${CSI}?25h${CSI}?1049l`);
|
|
732
|
+
store.close();
|
|
733
|
+
}
|
|
734
|
+
}
|