shipwright-cli 1.7.1 → 1.9.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/.claude/agents/code-reviewer.md +90 -0
- package/.claude/agents/devops-engineer.md +142 -0
- package/.claude/agents/pipeline-agent.md +80 -0
- package/.claude/agents/shell-script-specialist.md +150 -0
- package/.claude/agents/test-specialist.md +196 -0
- package/.claude/hooks/post-tool-use.sh +38 -0
- package/.claude/hooks/pre-tool-use.sh +25 -0
- package/.claude/hooks/session-started.sh +37 -0
- package/README.md +212 -814
- package/claude-code/CLAUDE.md.shipwright +54 -0
- package/claude-code/hooks/notify-idle.sh +2 -2
- package/claude-code/hooks/session-start.sh +24 -0
- package/claude-code/hooks/task-completed.sh +6 -2
- package/claude-code/settings.json.template +12 -0
- package/dashboard/public/app.js +4422 -0
- package/dashboard/public/index.html +816 -0
- package/dashboard/public/styles.css +4755 -0
- package/dashboard/server.ts +4315 -0
- package/docs/KNOWN-ISSUES.md +18 -10
- package/docs/TIPS.md +38 -26
- package/docs/patterns/README.md +33 -23
- package/package.json +9 -5
- package/scripts/adapters/iterm2-adapter.sh +1 -1
- package/scripts/adapters/tmux-adapter.sh +52 -23
- package/scripts/adapters/wezterm-adapter.sh +26 -14
- package/scripts/lib/compat.sh +200 -0
- package/scripts/lib/helpers.sh +72 -0
- package/scripts/postinstall.mjs +72 -13
- package/scripts/{cct → sw} +109 -21
- package/scripts/sw-adversarial.sh +274 -0
- package/scripts/sw-architecture-enforcer.sh +330 -0
- package/scripts/sw-checkpoint.sh +390 -0
- package/scripts/{cct-cleanup.sh → sw-cleanup.sh} +3 -1
- package/scripts/sw-connect.sh +619 -0
- package/scripts/{cct-cost.sh → sw-cost.sh} +368 -34
- package/scripts/{cct-daemon.sh → sw-daemon.sh} +2217 -204
- package/scripts/sw-dashboard.sh +477 -0
- package/scripts/sw-developer-simulation.sh +252 -0
- package/scripts/sw-docs.sh +635 -0
- package/scripts/sw-doctor.sh +907 -0
- package/scripts/{cct-fix.sh → sw-fix.sh} +10 -6
- package/scripts/{cct-fleet.sh → sw-fleet.sh} +498 -22
- package/scripts/sw-github-checks.sh +521 -0
- package/scripts/sw-github-deploy.sh +533 -0
- package/scripts/sw-github-graphql.sh +972 -0
- package/scripts/sw-heartbeat.sh +293 -0
- package/scripts/{cct-init.sh → sw-init.sh} +144 -11
- package/scripts/sw-intelligence.sh +1196 -0
- package/scripts/sw-jira.sh +643 -0
- package/scripts/sw-launchd.sh +364 -0
- package/scripts/sw-linear.sh +648 -0
- package/scripts/{cct-logs.sh → sw-logs.sh} +72 -2
- package/scripts/{cct-loop.sh → sw-loop.sh} +534 -44
- package/scripts/{cct-memory.sh → sw-memory.sh} +321 -38
- package/scripts/sw-patrol-meta.sh +417 -0
- package/scripts/sw-pipeline-composer.sh +455 -0
- package/scripts/{cct-pipeline.sh → sw-pipeline.sh} +2319 -178
- package/scripts/sw-predictive.sh +820 -0
- package/scripts/{cct-prep.sh → sw-prep.sh} +339 -49
- package/scripts/{cct-ps.sh → sw-ps.sh} +6 -4
- package/scripts/{cct-reaper.sh → sw-reaper.sh} +6 -4
- package/scripts/sw-remote.sh +687 -0
- package/scripts/sw-self-optimize.sh +947 -0
- package/scripts/sw-session.sh +519 -0
- package/scripts/sw-setup.sh +234 -0
- package/scripts/sw-status.sh +605 -0
- package/scripts/{cct-templates.sh → sw-templates.sh} +9 -4
- package/scripts/sw-tmux.sh +591 -0
- package/scripts/sw-tracker-jira.sh +277 -0
- package/scripts/sw-tracker-linear.sh +292 -0
- package/scripts/sw-tracker.sh +409 -0
- package/scripts/{cct-upgrade.sh → sw-upgrade.sh} +103 -46
- package/scripts/{cct-worktree.sh → sw-worktree.sh} +3 -0
- package/templates/pipelines/autonomous.json +27 -5
- package/templates/pipelines/full.json +12 -0
- package/templates/pipelines/standard.json +12 -0
- package/tmux/{claude-teams-overlay.conf → shipwright-overlay.conf} +27 -9
- package/tmux/templates/accessibility.json +34 -0
- package/tmux/templates/api-design.json +35 -0
- package/tmux/templates/architecture.json +1 -0
- package/tmux/templates/bug-fix.json +9 -0
- package/tmux/templates/code-review.json +1 -0
- package/tmux/templates/compliance.json +36 -0
- package/tmux/templates/data-pipeline.json +36 -0
- package/tmux/templates/debt-paydown.json +34 -0
- package/tmux/templates/devops.json +1 -0
- package/tmux/templates/documentation.json +1 -0
- package/tmux/templates/exploration.json +1 -0
- package/tmux/templates/feature-dev.json +1 -0
- package/tmux/templates/full-stack.json +8 -0
- package/tmux/templates/i18n.json +34 -0
- package/tmux/templates/incident-response.json +36 -0
- package/tmux/templates/migration.json +1 -0
- package/tmux/templates/observability.json +35 -0
- package/tmux/templates/onboarding.json +33 -0
- package/tmux/templates/performance.json +35 -0
- package/tmux/templates/refactor.json +1 -0
- package/tmux/templates/release.json +35 -0
- package/tmux/templates/security-audit.json +8 -0
- package/tmux/templates/spike.json +34 -0
- package/tmux/templates/testing.json +1 -0
- package/tmux/tmux.conf +98 -9
- package/scripts/cct-doctor.sh +0 -414
- package/scripts/cct-session.sh +0 -284
- package/scripts/cct-status.sh +0 -169
|
@@ -0,0 +1,4315 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFileSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
appendFileSync,
|
|
10
|
+
watch,
|
|
11
|
+
type FSWatcher,
|
|
12
|
+
} from "fs";
|
|
13
|
+
import { join, extname } from "path";
|
|
14
|
+
import { execSync } from "child_process";
|
|
15
|
+
|
|
16
|
+
// ─── Config ──────────────────────────────────────────────────────────
|
|
17
|
+
const PORT = parseInt(
|
|
18
|
+
process.argv[2] || process.env.SHIPWRIGHT_DASHBOARD_PORT || "8767",
|
|
19
|
+
);
|
|
20
|
+
const HOME = process.env.HOME || "";
|
|
21
|
+
const EVENTS_FILE = join(HOME, ".shipwright", "events.jsonl");
|
|
22
|
+
const DAEMON_STATE = join(HOME, ".shipwright", "daemon-state.json");
|
|
23
|
+
const LOGS_DIR = join(HOME, ".shipwright", "logs");
|
|
24
|
+
const HEARTBEAT_DIR = join(HOME, ".shipwright", "heartbeats");
|
|
25
|
+
const MACHINES_FILE = join(HOME, ".shipwright", "machines.json");
|
|
26
|
+
const COSTS_FILE = join(HOME, ".shipwright", "costs.json");
|
|
27
|
+
const BUDGET_FILE = join(HOME, ".shipwright", "budget.json");
|
|
28
|
+
const MEMORY_DIR = join(HOME, ".shipwright", "memory");
|
|
29
|
+
const PUBLIC_DIR = join(import.meta.dir, "public");
|
|
30
|
+
const WS_PUSH_INTERVAL_MS = 2000;
|
|
31
|
+
|
|
32
|
+
// ─── Auth Config ────────────────────────────────────────────────────
|
|
33
|
+
// Mode 1: Full OAuth — set GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET + DASHBOARD_REPO
|
|
34
|
+
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || "";
|
|
35
|
+
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || "";
|
|
36
|
+
// Mode 2: PAT-based — set GITHUB_PAT + DASHBOARD_REPO (simpler, single-admin)
|
|
37
|
+
const GITHUB_PAT = process.env.GITHUB_PAT || "";
|
|
38
|
+
const DASHBOARD_REPO = process.env.DASHBOARD_REPO || ""; // "owner/repo"
|
|
39
|
+
const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomUUID();
|
|
40
|
+
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
41
|
+
const ALLOWED_PERMISSIONS = ["admin", "write"];
|
|
42
|
+
|
|
43
|
+
// ─── ANSI helpers ────────────────────────────────────────────────────
|
|
44
|
+
const CYAN = "\x1b[38;2;0;212;255m";
|
|
45
|
+
const GREEN = "\x1b[38;2;74;222;128m";
|
|
46
|
+
const BOLD = "\x1b[1m";
|
|
47
|
+
const DIM = "\x1b[2m";
|
|
48
|
+
const ULINE = "\x1b[4m";
|
|
49
|
+
const RESET = "\x1b[0m";
|
|
50
|
+
|
|
51
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
52
|
+
interface DaemonEvent {
|
|
53
|
+
ts: string;
|
|
54
|
+
ts_epoch?: number;
|
|
55
|
+
type: string;
|
|
56
|
+
issue?: number;
|
|
57
|
+
stage?: string;
|
|
58
|
+
duration_s?: number;
|
|
59
|
+
pid?: number;
|
|
60
|
+
issues_found?: number;
|
|
61
|
+
active?: number;
|
|
62
|
+
from?: number;
|
|
63
|
+
to?: number;
|
|
64
|
+
max_by_cpu?: number;
|
|
65
|
+
max_by_mem?: number;
|
|
66
|
+
max_by_budget?: number;
|
|
67
|
+
cpu_cores?: number;
|
|
68
|
+
avail_mem_gb?: number;
|
|
69
|
+
result?: string;
|
|
70
|
+
[key: string]: unknown;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Pipeline {
|
|
74
|
+
issue: number;
|
|
75
|
+
title: string;
|
|
76
|
+
stage: string;
|
|
77
|
+
elapsed_s: number;
|
|
78
|
+
worktree: string;
|
|
79
|
+
iteration: number;
|
|
80
|
+
maxIterations: number;
|
|
81
|
+
stagesDone: string[];
|
|
82
|
+
linesWritten: number;
|
|
83
|
+
testsPassing: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface QueueItem {
|
|
87
|
+
issue: number;
|
|
88
|
+
title: string;
|
|
89
|
+
score: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface DoraMetric {
|
|
93
|
+
value: number;
|
|
94
|
+
unit: string;
|
|
95
|
+
grade: "Elite" | "High" | "Medium" | "Low";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface DoraGrades {
|
|
99
|
+
deploy_freq: DoraMetric;
|
|
100
|
+
lead_time: DoraMetric;
|
|
101
|
+
cfr: DoraMetric;
|
|
102
|
+
mttr: DoraMetric;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ConnectedDeveloper {
|
|
106
|
+
developer_id: string;
|
|
107
|
+
machine_name: string;
|
|
108
|
+
hostname: string;
|
|
109
|
+
platform: string;
|
|
110
|
+
last_heartbeat: number; // epoch ms
|
|
111
|
+
daemon_running: boolean;
|
|
112
|
+
daemon_pid: number | null;
|
|
113
|
+
active_jobs: Array<{ issue: number; title: string; stage: string }>;
|
|
114
|
+
queued: number[];
|
|
115
|
+
events_since: number; // last synced event timestamp
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface TeamState {
|
|
119
|
+
developers: Array<ConnectedDeveloper & { _presence?: string }>;
|
|
120
|
+
total_online: number;
|
|
121
|
+
total_active_pipelines: number;
|
|
122
|
+
total_queued: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface FleetState {
|
|
126
|
+
timestamp: string;
|
|
127
|
+
daemon: {
|
|
128
|
+
running: boolean;
|
|
129
|
+
pid: number | null;
|
|
130
|
+
uptime_s: number;
|
|
131
|
+
maxParallel: number;
|
|
132
|
+
pollInterval: number;
|
|
133
|
+
};
|
|
134
|
+
pipelines: Pipeline[];
|
|
135
|
+
queue: QueueItem[];
|
|
136
|
+
events: DaemonEvent[];
|
|
137
|
+
scale: {
|
|
138
|
+
from?: number;
|
|
139
|
+
to?: number;
|
|
140
|
+
maxByCpu?: number;
|
|
141
|
+
maxByMem?: number;
|
|
142
|
+
maxByBudget?: number;
|
|
143
|
+
cpuCores?: number;
|
|
144
|
+
availMemGb?: number;
|
|
145
|
+
};
|
|
146
|
+
metrics: {
|
|
147
|
+
cpuCores: number;
|
|
148
|
+
completed: number;
|
|
149
|
+
failed: number;
|
|
150
|
+
};
|
|
151
|
+
agents: AgentInfo[];
|
|
152
|
+
machines: MachineInfo[];
|
|
153
|
+
cost: CostInfo;
|
|
154
|
+
dora: DoraGrades;
|
|
155
|
+
team?: TeamState;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface HealthResponse {
|
|
159
|
+
status: "ok";
|
|
160
|
+
uptime_s: number;
|
|
161
|
+
connections: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface Session {
|
|
165
|
+
githubUser: string;
|
|
166
|
+
accessToken: string;
|
|
167
|
+
avatarUrl: string;
|
|
168
|
+
isAdmin: boolean;
|
|
169
|
+
expiresAt: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Session Store ──────────────────────────────────────────────────
|
|
173
|
+
const sessions = new Map<string, Session>();
|
|
174
|
+
|
|
175
|
+
function createSession(data: Omit<Session, "expiresAt">): string {
|
|
176
|
+
const sessionId = crypto.randomUUID();
|
|
177
|
+
sessions.set(sessionId, {
|
|
178
|
+
...data,
|
|
179
|
+
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
180
|
+
});
|
|
181
|
+
saveSessions();
|
|
182
|
+
return sessionId;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getSession(req: Request): Session | null {
|
|
186
|
+
const cookie = req.headers.get("cookie");
|
|
187
|
+
if (!cookie) return null;
|
|
188
|
+
|
|
189
|
+
const match = cookie.match(/fleet_session=([^;]+)/);
|
|
190
|
+
if (!match) return null;
|
|
191
|
+
|
|
192
|
+
const sessionId = match[1];
|
|
193
|
+
const session = sessions.get(sessionId);
|
|
194
|
+
if (!session) return null;
|
|
195
|
+
|
|
196
|
+
if (Date.now() > session.expiresAt) {
|
|
197
|
+
sessions.delete(sessionId);
|
|
198
|
+
saveSessions();
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return session;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getSessionFromCookie(cookie: string | null): Session | null {
|
|
206
|
+
if (!cookie) return null;
|
|
207
|
+
const match = cookie.match(/fleet_session=([^;]+)/);
|
|
208
|
+
if (!match) return null;
|
|
209
|
+
const session = sessions.get(match[1]);
|
|
210
|
+
if (!session || Date.now() > session.expiresAt) return null;
|
|
211
|
+
return session;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sessionCookie(sessionId: string): string {
|
|
215
|
+
return `fleet_session=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function clearSessionCookie(): string {
|
|
219
|
+
return "fleet_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── File-backed Sessions ───────────────────────────────────────────
|
|
223
|
+
const SESSIONS_FILE = join(HOME, ".shipwright", "sessions.json");
|
|
224
|
+
|
|
225
|
+
function loadSessions(): void {
|
|
226
|
+
try {
|
|
227
|
+
if (existsSync(SESSIONS_FILE)) {
|
|
228
|
+
const data = JSON.parse(readFileSync(SESSIONS_FILE, "utf-8"));
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
if (data && typeof data === "object") {
|
|
231
|
+
for (const [id, sess] of Object.entries(data)) {
|
|
232
|
+
const s = sess as Session;
|
|
233
|
+
if (s.expiresAt > now) {
|
|
234
|
+
sessions.set(id, s);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
/* start fresh */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function saveSessions(): void {
|
|
245
|
+
const dir = join(HOME, ".shipwright");
|
|
246
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
247
|
+
const obj: Record<string, Session> = {};
|
|
248
|
+
for (const [id, sess] of sessions) {
|
|
249
|
+
obj[id] = sess;
|
|
250
|
+
}
|
|
251
|
+
const tmp = SESSIONS_FILE + ".tmp";
|
|
252
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
|
253
|
+
renameSync(tmp, SESSIONS_FILE);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Developer Registry ─────────────────────────────────────────────
|
|
257
|
+
const DEVELOPER_REGISTRY_FILE = join(
|
|
258
|
+
HOME,
|
|
259
|
+
".shipwright",
|
|
260
|
+
"developer-registry.json",
|
|
261
|
+
);
|
|
262
|
+
const TEAM_EVENTS_FILE = join(HOME, ".shipwright", "team-events.jsonl");
|
|
263
|
+
const developerRegistry = new Map<string, ConnectedDeveloper>();
|
|
264
|
+
|
|
265
|
+
function loadDeveloperRegistry(): void {
|
|
266
|
+
try {
|
|
267
|
+
if (existsSync(DEVELOPER_REGISTRY_FILE)) {
|
|
268
|
+
const data = JSON.parse(readFileSync(DEVELOPER_REGISTRY_FILE, "utf-8"));
|
|
269
|
+
if (Array.isArray(data)) {
|
|
270
|
+
for (const dev of data) {
|
|
271
|
+
const key = `${dev.developer_id}@${dev.machine_name}`;
|
|
272
|
+
developerRegistry.set(key, dev);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
/* start fresh */
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function saveDeveloperRegistry(): void {
|
|
282
|
+
const dir = join(HOME, ".shipwright");
|
|
283
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
284
|
+
const data = JSON.stringify(Array.from(developerRegistry.values()), null, 2);
|
|
285
|
+
const tmp = DEVELOPER_REGISTRY_FILE + ".tmp";
|
|
286
|
+
writeFileSync(tmp, data);
|
|
287
|
+
renameSync(tmp, DEVELOPER_REGISTRY_FILE);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function getPresenceStatus(
|
|
291
|
+
lastHeartbeat: number,
|
|
292
|
+
): "online" | "idle" | "offline" {
|
|
293
|
+
const age = Date.now() - lastHeartbeat;
|
|
294
|
+
if (age < 30_000) return "online";
|
|
295
|
+
if (age < 120_000) return "idle";
|
|
296
|
+
return "offline";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function getTeamState(): TeamState {
|
|
300
|
+
const developers = Array.from(developerRegistry.values()).filter(
|
|
301
|
+
(d) => Date.now() - d.last_heartbeat < 86_400_000,
|
|
302
|
+
); // exclude >24h offline
|
|
303
|
+
const online = developers.filter(
|
|
304
|
+
(d) => getPresenceStatus(d.last_heartbeat) === "online",
|
|
305
|
+
);
|
|
306
|
+
return {
|
|
307
|
+
developers: developers.map((d) => ({
|
|
308
|
+
...d,
|
|
309
|
+
_presence: getPresenceStatus(d.last_heartbeat),
|
|
310
|
+
})),
|
|
311
|
+
total_online: online.length,
|
|
312
|
+
total_active_pipelines: developers.reduce(
|
|
313
|
+
(sum, d) => sum + d.active_jobs.length,
|
|
314
|
+
0,
|
|
315
|
+
),
|
|
316
|
+
total_queued: developers.reduce((sum, d) => sum + d.queued.length, 0),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Invite tokens (file-backed, separate from join-tokens)
|
|
321
|
+
const INVITE_TOKENS_FILE = join(HOME, ".shipwright", "invite-tokens.json");
|
|
322
|
+
const inviteTokens = new Map<
|
|
323
|
+
string,
|
|
324
|
+
{ token: string; created_at: string; expires_at: string }
|
|
325
|
+
>();
|
|
326
|
+
|
|
327
|
+
function loadInviteTokens(): void {
|
|
328
|
+
try {
|
|
329
|
+
if (existsSync(INVITE_TOKENS_FILE)) {
|
|
330
|
+
const data = JSON.parse(readFileSync(INVITE_TOKENS_FILE, "utf-8"));
|
|
331
|
+
if (Array.isArray(data)) {
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
for (const t of data) {
|
|
334
|
+
// Skip expired tokens on load
|
|
335
|
+
if (new Date(t.expires_at).getTime() > now) {
|
|
336
|
+
inviteTokens.set(t.token, t);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
/* start fresh */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function saveInviteTokens(): void {
|
|
347
|
+
const dir = join(HOME, ".shipwright");
|
|
348
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
349
|
+
const data = JSON.stringify(Array.from(inviteTokens.values()), null, 2);
|
|
350
|
+
const tmp = INVITE_TOKENS_FILE + ".tmp";
|
|
351
|
+
writeFileSync(tmp, data);
|
|
352
|
+
renameSync(tmp, INVITE_TOKENS_FILE);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ─── Auth check ─────────────────────────────────────────────────────
|
|
356
|
+
type AuthMode = "oauth" | "pat" | "none";
|
|
357
|
+
|
|
358
|
+
function getAuthMode(): AuthMode {
|
|
359
|
+
if (GITHUB_CLIENT_ID && GITHUB_CLIENT_SECRET && DASHBOARD_REPO)
|
|
360
|
+
return "oauth";
|
|
361
|
+
if (GITHUB_PAT && DASHBOARD_REPO) return "pat";
|
|
362
|
+
return "none";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function isAuthEnabled(): boolean {
|
|
366
|
+
return getAuthMode() !== "none";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─── Public routes (no auth required) ───────────────────────────────
|
|
370
|
+
function isPublicRoute(pathname: string): boolean {
|
|
371
|
+
return (
|
|
372
|
+
pathname === "/login" ||
|
|
373
|
+
pathname.startsWith("/auth/") ||
|
|
374
|
+
pathname === "/api/health" ||
|
|
375
|
+
pathname.startsWith("/api/join/") ||
|
|
376
|
+
pathname.startsWith("/api/connect/") ||
|
|
377
|
+
pathname === "/api/team" ||
|
|
378
|
+
pathname === "/api/team/activity" ||
|
|
379
|
+
pathname === "/api/team/invite" ||
|
|
380
|
+
pathname.startsWith("/api/team/invite/") ||
|
|
381
|
+
pathname === "/api/claim" ||
|
|
382
|
+
pathname === "/api/claim/release" ||
|
|
383
|
+
pathname === "/api/webhook/ci"
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── Login Page HTML ────────────────────────────────────────────────
|
|
388
|
+
function loginPageHTML(error?: string): string {
|
|
389
|
+
const mode = getAuthMode();
|
|
390
|
+
|
|
391
|
+
const errorHtml = error
|
|
392
|
+
? `<p style="color:#f43f5e;margin-bottom:1.5rem;font-size:0.9rem;">${error}</p>`
|
|
393
|
+
: "";
|
|
394
|
+
|
|
395
|
+
// PAT mode: show a username input form
|
|
396
|
+
const patForm = `
|
|
397
|
+
${errorHtml}
|
|
398
|
+
<form method="POST" action="/auth/pat-login" style="display:flex;flex-direction:column;gap:1rem;">
|
|
399
|
+
<input name="username" type="text" required
|
|
400
|
+
placeholder="GitHub username"
|
|
401
|
+
style="
|
|
402
|
+
background: rgba(0,212,255,0.04);
|
|
403
|
+
border: 1px solid rgba(0,212,255,0.15);
|
|
404
|
+
border-radius: 8px;
|
|
405
|
+
padding: 0.85rem 1rem;
|
|
406
|
+
color: #e8ecf4;
|
|
407
|
+
font-family: 'Plus Jakarta Sans', sans-serif;
|
|
408
|
+
font-size: 0.95rem;
|
|
409
|
+
outline: none;
|
|
410
|
+
transition: border-color 0.2s;
|
|
411
|
+
"
|
|
412
|
+
onfocus="this.style.borderColor='rgba(0,212,255,0.4)'"
|
|
413
|
+
onblur="this.style.borderColor='rgba(0,212,255,0.15)'"
|
|
414
|
+
/>
|
|
415
|
+
<button type="submit" class="btn-github" style="justify-content:center;">
|
|
416
|
+
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
|
|
417
|
+
Verify & Sign In
|
|
418
|
+
</button>
|
|
419
|
+
</form>`;
|
|
420
|
+
|
|
421
|
+
// OAuth mode: show redirect button
|
|
422
|
+
const oauthBtn = `
|
|
423
|
+
${errorHtml}
|
|
424
|
+
<a class="btn-github" href="/auth/github">
|
|
425
|
+
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
|
|
426
|
+
Sign in with GitHub
|
|
427
|
+
</a>`;
|
|
428
|
+
|
|
429
|
+
const actionBlock = mode === "pat" ? patForm : oauthBtn;
|
|
430
|
+
const subtitle =
|
|
431
|
+
mode === "pat"
|
|
432
|
+
? "Enter your GitHub username to verify access"
|
|
433
|
+
: "Sign in to access the dashboard";
|
|
434
|
+
|
|
435
|
+
return `<!DOCTYPE html>
|
|
436
|
+
<html lang="en">
|
|
437
|
+
<head>
|
|
438
|
+
<meta charset="UTF-8">
|
|
439
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
440
|
+
<title>Fleet Command \u2014 Sign In</title>
|
|
441
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
442
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
443
|
+
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
444
|
+
<style>
|
|
445
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
446
|
+
body {
|
|
447
|
+
background: #060a14;
|
|
448
|
+
min-height: 100vh;
|
|
449
|
+
display: flex;
|
|
450
|
+
align-items: center;
|
|
451
|
+
justify-content: center;
|
|
452
|
+
font-family: 'Plus Jakarta Sans', sans-serif;
|
|
453
|
+
color: #e8ecf4;
|
|
454
|
+
}
|
|
455
|
+
.card {
|
|
456
|
+
max-width: 400px;
|
|
457
|
+
width: 90%;
|
|
458
|
+
background: rgba(10, 22, 40, 0.8);
|
|
459
|
+
border: 1px solid rgba(0, 212, 255, 0.08);
|
|
460
|
+
border-radius: 16px;
|
|
461
|
+
padding: 3rem;
|
|
462
|
+
text-align: center;
|
|
463
|
+
}
|
|
464
|
+
.card .anchor { font-size: 2.5rem; margin-bottom: 1rem; opacity: 0.7; }
|
|
465
|
+
.card h1 {
|
|
466
|
+
font-family: 'Instrument Serif', serif;
|
|
467
|
+
font-size: 2rem;
|
|
468
|
+
font-weight: 400;
|
|
469
|
+
color: #e8ecf4;
|
|
470
|
+
margin-bottom: 0.5rem;
|
|
471
|
+
}
|
|
472
|
+
.card p {
|
|
473
|
+
font-size: 0.95rem;
|
|
474
|
+
color: #8899b8;
|
|
475
|
+
margin-bottom: 2rem;
|
|
476
|
+
line-height: 1.5;
|
|
477
|
+
}
|
|
478
|
+
.btn-github {
|
|
479
|
+
display: inline-flex;
|
|
480
|
+
align-items: center;
|
|
481
|
+
gap: 0.6rem;
|
|
482
|
+
background: linear-gradient(135deg, #00d4ff, #0066ff);
|
|
483
|
+
color: #060a14;
|
|
484
|
+
border: none;
|
|
485
|
+
border-radius: 8px;
|
|
486
|
+
padding: 0.85rem 2rem;
|
|
487
|
+
font-family: 'Plus Jakarta Sans', sans-serif;
|
|
488
|
+
font-size: 0.95rem;
|
|
489
|
+
font-weight: 700;
|
|
490
|
+
cursor: pointer;
|
|
491
|
+
text-decoration: none;
|
|
492
|
+
transition: opacity 0.2s, transform 0.15s;
|
|
493
|
+
width: 100%;
|
|
494
|
+
}
|
|
495
|
+
.btn-github:hover { opacity: 0.9; transform: translateY(-1px); }
|
|
496
|
+
.btn-github:active { transform: translateY(0); }
|
|
497
|
+
.btn-github svg { width: 20px; height: 20px; fill: #060a14; }
|
|
498
|
+
</style>
|
|
499
|
+
</head>
|
|
500
|
+
<body>
|
|
501
|
+
<div class="card">
|
|
502
|
+
<div class="anchor">\u2693</div>
|
|
503
|
+
<h1>Fleet Command</h1>
|
|
504
|
+
<p>${subtitle}</p>
|
|
505
|
+
${actionBlock}
|
|
506
|
+
</div>
|
|
507
|
+
</body>
|
|
508
|
+
</html>`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ─── Access Denied Page HTML ────────────────────────────────────────
|
|
512
|
+
function accessDeniedHTML(repo: string): string {
|
|
513
|
+
return `<!DOCTYPE html>
|
|
514
|
+
<html lang="en">
|
|
515
|
+
<head>
|
|
516
|
+
<meta charset="UTF-8">
|
|
517
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
518
|
+
<title>Fleet Command — Access Denied</title>
|
|
519
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
520
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
521
|
+
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
522
|
+
<style>
|
|
523
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
524
|
+
body {
|
|
525
|
+
background: #060a14;
|
|
526
|
+
min-height: 100vh;
|
|
527
|
+
display: flex;
|
|
528
|
+
align-items: center;
|
|
529
|
+
justify-content: center;
|
|
530
|
+
font-family: 'Plus Jakarta Sans', sans-serif;
|
|
531
|
+
color: #e8ecf4;
|
|
532
|
+
}
|
|
533
|
+
.card {
|
|
534
|
+
max-width: 400px;
|
|
535
|
+
width: 90%;
|
|
536
|
+
background: rgba(10, 22, 40, 0.8);
|
|
537
|
+
border: 1px solid rgba(0, 212, 255, 0.08);
|
|
538
|
+
border-radius: 16px;
|
|
539
|
+
padding: 3rem;
|
|
540
|
+
text-align: center;
|
|
541
|
+
}
|
|
542
|
+
.card .icon {
|
|
543
|
+
font-size: 2.5rem;
|
|
544
|
+
margin-bottom: 1rem;
|
|
545
|
+
opacity: 0.7;
|
|
546
|
+
}
|
|
547
|
+
.card h1 {
|
|
548
|
+
font-family: 'Instrument Serif', serif;
|
|
549
|
+
font-size: 2rem;
|
|
550
|
+
font-weight: 400;
|
|
551
|
+
color: #ff6b6b;
|
|
552
|
+
margin-bottom: 0.75rem;
|
|
553
|
+
}
|
|
554
|
+
.card p {
|
|
555
|
+
font-size: 0.95rem;
|
|
556
|
+
color: #8899b8;
|
|
557
|
+
margin-bottom: 2rem;
|
|
558
|
+
line-height: 1.5;
|
|
559
|
+
}
|
|
560
|
+
.card code {
|
|
561
|
+
background: rgba(0, 212, 255, 0.08);
|
|
562
|
+
padding: 0.15rem 0.5rem;
|
|
563
|
+
border-radius: 4px;
|
|
564
|
+
font-size: 0.9rem;
|
|
565
|
+
color: #00d4ff;
|
|
566
|
+
}
|
|
567
|
+
.link {
|
|
568
|
+
color: #00d4ff;
|
|
569
|
+
text-decoration: none;
|
|
570
|
+
font-weight: 600;
|
|
571
|
+
font-size: 0.95rem;
|
|
572
|
+
transition: opacity 0.2s;
|
|
573
|
+
}
|
|
574
|
+
.link:hover { opacity: 0.8; }
|
|
575
|
+
</style>
|
|
576
|
+
</head>
|
|
577
|
+
<body>
|
|
578
|
+
<div class="card">
|
|
579
|
+
<div class="icon">\u26D4</div>
|
|
580
|
+
<h1>Access Denied</h1>
|
|
581
|
+
<p>You need admin or write access to <code>${repo}</code> to view this dashboard.</p>
|
|
582
|
+
<a class="link" href="/auth/logout">Sign in with a different account</a>
|
|
583
|
+
</div>
|
|
584
|
+
</body>
|
|
585
|
+
</html>`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ─── WebSocket client tracking ───────────────────────────────────────
|
|
589
|
+
const wsClients = new Set<import("bun").ServerWebSocket<unknown>>();
|
|
590
|
+
const startTime = Date.now();
|
|
591
|
+
|
|
592
|
+
function broadcastToClients(data: FleetState): void {
|
|
593
|
+
const payload = JSON.stringify(data);
|
|
594
|
+
for (const ws of wsClients) {
|
|
595
|
+
try {
|
|
596
|
+
ws.send(payload);
|
|
597
|
+
} catch {
|
|
598
|
+
wsClients.delete(ws);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ─── Data Collection ─────────────────────────────────────────────────
|
|
604
|
+
function readEvents(): DaemonEvent[] {
|
|
605
|
+
if (!existsSync(EVENTS_FILE)) return [];
|
|
606
|
+
try {
|
|
607
|
+
const content = readFileSync(EVENTS_FILE, "utf-8").trim();
|
|
608
|
+
if (!content) return [];
|
|
609
|
+
return content
|
|
610
|
+
.split("\n")
|
|
611
|
+
.filter((l) => l.trim())
|
|
612
|
+
.map((l) => {
|
|
613
|
+
try {
|
|
614
|
+
return JSON.parse(l);
|
|
615
|
+
} catch {
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
})
|
|
619
|
+
.filter(Boolean) as DaemonEvent[];
|
|
620
|
+
} catch {
|
|
621
|
+
return [];
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function readDaemonState(): Record<string, unknown> | null {
|
|
626
|
+
if (!existsSync(DAEMON_STATE)) return null;
|
|
627
|
+
try {
|
|
628
|
+
return JSON.parse(readFileSync(DAEMON_STATE, "utf-8"));
|
|
629
|
+
} catch {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function getCpuCores(): number {
|
|
635
|
+
try {
|
|
636
|
+
if (process.platform === "darwin") {
|
|
637
|
+
return parseInt(
|
|
638
|
+
execSync("sysctl -n hw.ncpu", { encoding: "utf-8" }).trim(),
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
// Linux: read from /proc/cpuinfo
|
|
642
|
+
if (existsSync("/proc/cpuinfo")) {
|
|
643
|
+
const cpuinfo = readFileSync("/proc/cpuinfo", "utf-8");
|
|
644
|
+
const count = cpuinfo
|
|
645
|
+
.split("\n")
|
|
646
|
+
.filter((l) => l.startsWith("processor")).length;
|
|
647
|
+
if (count > 0) return count;
|
|
648
|
+
}
|
|
649
|
+
return parseInt(execSync("nproc", { encoding: "utf-8" }).trim());
|
|
650
|
+
} catch {
|
|
651
|
+
return 8;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function readLogIterations(issue: number): {
|
|
656
|
+
iteration: number;
|
|
657
|
+
maxIterations: number;
|
|
658
|
+
linesWritten: number;
|
|
659
|
+
testsPassing: boolean;
|
|
660
|
+
} {
|
|
661
|
+
const logFile = join(LOGS_DIR, `issue-${issue}.log`);
|
|
662
|
+
if (!existsSync(logFile))
|
|
663
|
+
return {
|
|
664
|
+
iteration: 0,
|
|
665
|
+
maxIterations: 20,
|
|
666
|
+
linesWritten: 0,
|
|
667
|
+
testsPassing: false,
|
|
668
|
+
};
|
|
669
|
+
try {
|
|
670
|
+
const content = readFileSync(logFile, "utf-8");
|
|
671
|
+
const iters = [...content.matchAll(/Iteration (\d+)\/(\d+)/g)];
|
|
672
|
+
const last = iters.length > 0 ? iters[iters.length - 1] : null;
|
|
673
|
+
const lineMatches = [...content.matchAll(/(\d+) insertions?\(\+\)/g)];
|
|
674
|
+
const linesWritten = lineMatches.reduce(
|
|
675
|
+
(sum, m) => sum + parseInt(m[1]),
|
|
676
|
+
0,
|
|
677
|
+
);
|
|
678
|
+
const testsPassing =
|
|
679
|
+
content.includes("Tests: passed") ||
|
|
680
|
+
content.toLowerCase().includes("tests passed");
|
|
681
|
+
return {
|
|
682
|
+
iteration: last ? parseInt(last[1]) : 0,
|
|
683
|
+
maxIterations: last ? parseInt(last[2]) : 20,
|
|
684
|
+
linesWritten,
|
|
685
|
+
testsPassing,
|
|
686
|
+
};
|
|
687
|
+
} catch {
|
|
688
|
+
return {
|
|
689
|
+
iteration: 0,
|
|
690
|
+
maxIterations: 20,
|
|
691
|
+
linesWritten: 0,
|
|
692
|
+
testsPassing: false,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function getFleetState(): FleetState {
|
|
698
|
+
const events = readEvents();
|
|
699
|
+
const daemonState = readDaemonState();
|
|
700
|
+
const now = Math.floor(Date.now() / 1000);
|
|
701
|
+
|
|
702
|
+
const state: FleetState = {
|
|
703
|
+
timestamp: new Date().toISOString(),
|
|
704
|
+
daemon: {
|
|
705
|
+
running: false,
|
|
706
|
+
pid: null,
|
|
707
|
+
uptime_s: 0,
|
|
708
|
+
maxParallel: 2,
|
|
709
|
+
pollInterval: 30,
|
|
710
|
+
},
|
|
711
|
+
pipelines: [],
|
|
712
|
+
queue: [],
|
|
713
|
+
events: events.slice(-25),
|
|
714
|
+
scale: {},
|
|
715
|
+
metrics: { cpuCores: getCpuCores(), completed: 0, failed: 0 },
|
|
716
|
+
agents: getAgents(),
|
|
717
|
+
machines: getMachines(),
|
|
718
|
+
cost: getCostInfo(),
|
|
719
|
+
dora: calculateDoraGrades(events, 7),
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
// Daemon state
|
|
723
|
+
if (daemonState) {
|
|
724
|
+
state.daemon.running = true;
|
|
725
|
+
state.daemon.pid = (daemonState.pid as number) || null;
|
|
726
|
+
state.daemon.maxParallel = (daemonState.max_parallel as number) || 2;
|
|
727
|
+
state.daemon.pollInterval = (daemonState.poll_interval as number) || 30;
|
|
728
|
+
const started = daemonState.started_at as string;
|
|
729
|
+
if (started) {
|
|
730
|
+
try {
|
|
731
|
+
state.daemon.uptime_s =
|
|
732
|
+
now - Math.floor(new Date(started).getTime() / 1000);
|
|
733
|
+
} catch {
|
|
734
|
+
/* ignore */
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Active jobs → pipelines
|
|
739
|
+
const activeJobs =
|
|
740
|
+
(daemonState.active_jobs as Array<Record<string, unknown>>) || [];
|
|
741
|
+
for (const job of activeJobs) {
|
|
742
|
+
const issue = (job.issue as number) || 0;
|
|
743
|
+
const logInfo = readLogIterations(issue);
|
|
744
|
+
state.pipelines.push({
|
|
745
|
+
issue,
|
|
746
|
+
title: (job.title as string) || "",
|
|
747
|
+
stage: (job.stage as string) || "build",
|
|
748
|
+
elapsed_s: now - ((job.started_epoch as number) || now),
|
|
749
|
+
worktree: `daemon-issue-${issue}`,
|
|
750
|
+
iteration: logInfo.iteration,
|
|
751
|
+
maxIterations: logInfo.maxIterations,
|
|
752
|
+
stagesDone: [],
|
|
753
|
+
linesWritten: logInfo.linesWritten,
|
|
754
|
+
testsPassing: logInfo.testsPassing,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Queued items — daemon stores these as plain issue numbers
|
|
759
|
+
const queued =
|
|
760
|
+
(daemonState.queued as Array<number | Record<string, unknown>>) || [];
|
|
761
|
+
for (const q of queued) {
|
|
762
|
+
if (typeof q === "number") {
|
|
763
|
+
state.queue.push({ issue: q, title: "", score: 0 });
|
|
764
|
+
} else {
|
|
765
|
+
state.queue.push({
|
|
766
|
+
issue: (q.issue as number) || 0,
|
|
767
|
+
title: (q.title as string) || "",
|
|
768
|
+
score: (q.score as number) || 0,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Extract latest scale info from events (most recent first)
|
|
775
|
+
for (const e of [...events].reverse()) {
|
|
776
|
+
if (e.type === "daemon.scale" && !state.scale.to) {
|
|
777
|
+
state.scale = {
|
|
778
|
+
from: e.from,
|
|
779
|
+
to: e.to,
|
|
780
|
+
maxByCpu: e.max_by_cpu,
|
|
781
|
+
maxByMem: e.max_by_mem,
|
|
782
|
+
maxByBudget: e.max_by_budget,
|
|
783
|
+
cpuCores: e.cpu_cores,
|
|
784
|
+
availMemGb: e.avail_mem_gb,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Build stage history per issue + pipeline metrics
|
|
790
|
+
const issueStages: Record<number, string[]> = {};
|
|
791
|
+
for (const e of events) {
|
|
792
|
+
if (e.issue && e.issue > 0 && e.type === "stage.completed") {
|
|
793
|
+
if (!issueStages[e.issue]) issueStages[e.issue] = [];
|
|
794
|
+
issueStages[e.issue].push(e.stage || "");
|
|
795
|
+
}
|
|
796
|
+
if (e.type === "pipeline.completed") {
|
|
797
|
+
if (e.result === "success") state.metrics.completed++;
|
|
798
|
+
else state.metrics.failed++;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
for (const p of state.pipelines) {
|
|
802
|
+
if (issueStages[p.issue]) p.stagesDone = issueStages[p.issue];
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Add team data if any developers are connected
|
|
806
|
+
if (developerRegistry.size > 0) {
|
|
807
|
+
state.team = getTeamState();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return state;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ─── Worktree Discovery ──────────────────────────────────────────────
|
|
814
|
+
function findWorktreeBase(issue: number): string | null {
|
|
815
|
+
const daemonState = readDaemonState();
|
|
816
|
+
if (!daemonState) return null;
|
|
817
|
+
|
|
818
|
+
// Check active jobs for worktree path + repo
|
|
819
|
+
const activeJobs =
|
|
820
|
+
(daemonState.active_jobs as Array<Record<string, unknown>>) || [];
|
|
821
|
+
for (const job of activeJobs) {
|
|
822
|
+
if ((job.issue as number) === issue) {
|
|
823
|
+
const worktree = (job.worktree as string) || "";
|
|
824
|
+
const repo = (job.repo as string) || "";
|
|
825
|
+
if (worktree) {
|
|
826
|
+
// If repo is set, combine; otherwise try common locations
|
|
827
|
+
if (repo) return join(repo, worktree);
|
|
828
|
+
return resolveWorktreePath(worktree);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Not in active jobs — try the default worktree naming convention
|
|
834
|
+
return resolveWorktreePath(`.worktrees/daemon-issue-${issue}`);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function resolveWorktreePath(relative: string): string | null {
|
|
838
|
+
// Try well-known repo locations
|
|
839
|
+
const candidates: string[] = [];
|
|
840
|
+
|
|
841
|
+
// Check env var
|
|
842
|
+
if (process.env.VOICEAI_REPO) {
|
|
843
|
+
candidates.push(join(process.env.VOICEAI_REPO, relative));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Scan daemon state for any repo paths from completed or active jobs
|
|
847
|
+
const daemonState = readDaemonState();
|
|
848
|
+
if (daemonState) {
|
|
849
|
+
const activeJobs =
|
|
850
|
+
(daemonState.active_jobs as Array<Record<string, unknown>>) || [];
|
|
851
|
+
for (const job of activeJobs) {
|
|
852
|
+
const repo = (job.repo as string) || "";
|
|
853
|
+
if (repo) candidates.push(join(repo, relative));
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Try common parent directories where worktrees might live
|
|
858
|
+
const homeDir = process.env.HOME || "";
|
|
859
|
+
const commonBases = [
|
|
860
|
+
join(homeDir, "Documents/voiceai"),
|
|
861
|
+
join(homeDir, "Documents/claude-code-teams-tmux"),
|
|
862
|
+
join(homeDir, "voiceai"),
|
|
863
|
+
];
|
|
864
|
+
for (const base of commonBases) {
|
|
865
|
+
candidates.push(join(base, relative));
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
for (const candidate of candidates) {
|
|
869
|
+
if (existsSync(candidate)) return candidate;
|
|
870
|
+
}
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function readFileOr(filePath: string, fallback: string): string {
|
|
875
|
+
try {
|
|
876
|
+
if (existsSync(filePath)) return readFileSync(filePath, "utf-8");
|
|
877
|
+
} catch {
|
|
878
|
+
// ignore
|
|
879
|
+
}
|
|
880
|
+
return fallback;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ─── Pipeline Detail ─────────────────────────────────────────────────
|
|
884
|
+
interface PipelineDetail {
|
|
885
|
+
issue: number;
|
|
886
|
+
title: string;
|
|
887
|
+
stage: string;
|
|
888
|
+
stageHistory: Array<{ stage: string; duration_s: number; ts: string }>;
|
|
889
|
+
plan: string;
|
|
890
|
+
design: string;
|
|
891
|
+
dod: string;
|
|
892
|
+
intake: Record<string, unknown> | null;
|
|
893
|
+
elapsed_s: number;
|
|
894
|
+
branch: string;
|
|
895
|
+
prLink: string;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function getPipelineDetail(issue: number): PipelineDetail {
|
|
899
|
+
const events = readEvents();
|
|
900
|
+
const daemonState = readDaemonState();
|
|
901
|
+
const worktreeBase = findWorktreeBase(issue);
|
|
902
|
+
|
|
903
|
+
// Gather stage history from events
|
|
904
|
+
const stageHistory: Array<{ stage: string; duration_s: number; ts: string }> =
|
|
905
|
+
[];
|
|
906
|
+
let currentStage = "";
|
|
907
|
+
let prLink = "";
|
|
908
|
+
let title = "";
|
|
909
|
+
let pipelineStartEpoch = 0;
|
|
910
|
+
|
|
911
|
+
for (const e of events) {
|
|
912
|
+
if (e.issue !== issue) continue;
|
|
913
|
+
if (e.type === "pipeline.started") {
|
|
914
|
+
pipelineStartEpoch = e.ts_epoch || 0;
|
|
915
|
+
}
|
|
916
|
+
if (e.type === "stage.completed") {
|
|
917
|
+
stageHistory.push({
|
|
918
|
+
stage: e.stage || "",
|
|
919
|
+
duration_s: e.duration_s || 0,
|
|
920
|
+
ts: e.ts,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
if (e.type === "stage.started") {
|
|
924
|
+
currentStage = e.stage || "";
|
|
925
|
+
}
|
|
926
|
+
if (e.type === "pipeline.completed" && e.pr_url) {
|
|
927
|
+
prLink = e.pr_url as string;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Get title from daemon state
|
|
932
|
+
if (daemonState) {
|
|
933
|
+
const activeJobs =
|
|
934
|
+
(daemonState.active_jobs as Array<Record<string, unknown>>) || [];
|
|
935
|
+
for (const job of activeJobs) {
|
|
936
|
+
if ((job.issue as number) === issue) {
|
|
937
|
+
title = (job.title as string) || "";
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Read artifacts from worktree
|
|
943
|
+
let plan = "";
|
|
944
|
+
let design = "";
|
|
945
|
+
let dod = "";
|
|
946
|
+
let intake: Record<string, unknown> | null = null;
|
|
947
|
+
let branch = "";
|
|
948
|
+
|
|
949
|
+
if (worktreeBase) {
|
|
950
|
+
const artifactsDir = join(worktreeBase, ".claude", "pipeline-artifacts");
|
|
951
|
+
plan = readFileOr(join(artifactsDir, "plan.md"), "");
|
|
952
|
+
design = readFileOr(join(artifactsDir, "design.md"), "");
|
|
953
|
+
dod = readFileOr(join(artifactsDir, "dod.md"), "");
|
|
954
|
+
|
|
955
|
+
const intakeRaw = readFileOr(join(artifactsDir, "intake.json"), "");
|
|
956
|
+
if (intakeRaw) {
|
|
957
|
+
try {
|
|
958
|
+
intake = JSON.parse(intakeRaw);
|
|
959
|
+
} catch {
|
|
960
|
+
// ignore malformed JSON
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Try to read current branch from worktree
|
|
965
|
+
try {
|
|
966
|
+
const headFile = join(worktreeBase, ".git");
|
|
967
|
+
if (existsSync(headFile)) {
|
|
968
|
+
const gitContent = readFileSync(headFile, "utf-8").trim();
|
|
969
|
+
if (gitContent.startsWith("gitdir:")) {
|
|
970
|
+
// It's a worktree — read HEAD from the gitdir
|
|
971
|
+
const gitDir = gitContent.replace("gitdir: ", "").trim();
|
|
972
|
+
const headPath = join(gitDir, "HEAD");
|
|
973
|
+
if (existsSync(headPath)) {
|
|
974
|
+
const ref = readFileSync(headPath, "utf-8").trim();
|
|
975
|
+
branch = ref.startsWith("ref: refs/heads/")
|
|
976
|
+
? ref.replace("ref: refs/heads/", "")
|
|
977
|
+
: ref.substring(0, 12);
|
|
978
|
+
}
|
|
979
|
+
} else {
|
|
980
|
+
// Normal .git directory — read HEAD directly
|
|
981
|
+
const headPath = join(worktreeBase, ".git", "HEAD");
|
|
982
|
+
if (existsSync(headPath)) {
|
|
983
|
+
const ref = readFileSync(headPath, "utf-8").trim();
|
|
984
|
+
branch = ref.startsWith("ref: refs/heads/")
|
|
985
|
+
? ref.replace("ref: refs/heads/", "")
|
|
986
|
+
: ref.substring(0, 12);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
} catch {
|
|
991
|
+
// ignore
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const now = Math.floor(Date.now() / 1000);
|
|
996
|
+
const elapsed_s = pipelineStartEpoch > 0 ? now - pipelineStartEpoch : 0;
|
|
997
|
+
|
|
998
|
+
return {
|
|
999
|
+
issue,
|
|
1000
|
+
title,
|
|
1001
|
+
stage: currentStage,
|
|
1002
|
+
stageHistory,
|
|
1003
|
+
plan,
|
|
1004
|
+
design,
|
|
1005
|
+
dod,
|
|
1006
|
+
intake,
|
|
1007
|
+
elapsed_s,
|
|
1008
|
+
branch,
|
|
1009
|
+
prLink,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// ─── Historical Metrics ──────────────────────────────────────────────
|
|
1014
|
+
interface MetricsHistory {
|
|
1015
|
+
success_rate: number;
|
|
1016
|
+
avg_duration_s: number;
|
|
1017
|
+
throughput_per_hour: number;
|
|
1018
|
+
total_completed: number;
|
|
1019
|
+
total_failed: number;
|
|
1020
|
+
stage_durations: Record<string, number>;
|
|
1021
|
+
daily_counts: Array<{ date: string; completed: number; failed: number }>;
|
|
1022
|
+
dora_grades: DoraGrades;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function getMetricsHistory(doraPeriodDays: number = 7): MetricsHistory {
|
|
1026
|
+
const events = readEvents();
|
|
1027
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1028
|
+
|
|
1029
|
+
let completed = 0;
|
|
1030
|
+
let failed = 0;
|
|
1031
|
+
let totalDuration = 0;
|
|
1032
|
+
const stageDurations: Record<string, number[]> = {};
|
|
1033
|
+
const dailyMap: Record<string, { completed: number; failed: number }> = {};
|
|
1034
|
+
|
|
1035
|
+
// Count completions in last 24h for throughput
|
|
1036
|
+
let completedLast24h = 0;
|
|
1037
|
+
const oneDayAgo = now - 86400;
|
|
1038
|
+
|
|
1039
|
+
// Initialize last 7 days
|
|
1040
|
+
for (let i = 6; i >= 0; i--) {
|
|
1041
|
+
const d = new Date((now - i * 86400) * 1000);
|
|
1042
|
+
const key = d.toISOString().split("T")[0];
|
|
1043
|
+
dailyMap[key] = { completed: 0, failed: 0 };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
for (const e of events) {
|
|
1047
|
+
if (e.type === "pipeline.completed") {
|
|
1048
|
+
const isSuccess = e.result === "success";
|
|
1049
|
+
if (isSuccess) {
|
|
1050
|
+
completed++;
|
|
1051
|
+
totalDuration += e.duration_s || 0;
|
|
1052
|
+
} else {
|
|
1053
|
+
failed++;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Throughput: count last 24h
|
|
1057
|
+
if ((e.ts_epoch || 0) >= oneDayAgo && isSuccess) {
|
|
1058
|
+
completedLast24h++;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Daily counts
|
|
1062
|
+
const dateKey = (e.ts || "").split("T")[0];
|
|
1063
|
+
if (dailyMap[dateKey]) {
|
|
1064
|
+
if (isSuccess) dailyMap[dateKey].completed++;
|
|
1065
|
+
else dailyMap[dateKey].failed++;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (e.type === "stage.completed" && e.stage) {
|
|
1070
|
+
if (!stageDurations[e.stage]) stageDurations[e.stage] = [];
|
|
1071
|
+
stageDurations[e.stage].push(e.duration_s || 0);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const total = completed + failed;
|
|
1076
|
+
const avgStageDurations: Record<string, number> = {};
|
|
1077
|
+
for (const [stage, durations] of Object.entries(stageDurations)) {
|
|
1078
|
+
const sum = durations.reduce((a, b) => a + b, 0);
|
|
1079
|
+
avgStageDurations[stage] = Math.round(sum / durations.length);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const daily_counts = Object.entries(dailyMap)
|
|
1083
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1084
|
+
.map(([date, counts]) => ({ date, ...counts }));
|
|
1085
|
+
|
|
1086
|
+
return {
|
|
1087
|
+
success_rate: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
|
|
1088
|
+
avg_duration_s: completed > 0 ? Math.round(totalDuration / completed) : 0,
|
|
1089
|
+
throughput_per_hour: Math.round((completedLast24h / 24) * 100) / 100,
|
|
1090
|
+
total_completed: completed,
|
|
1091
|
+
total_failed: failed,
|
|
1092
|
+
stage_durations: avgStageDurations,
|
|
1093
|
+
daily_counts,
|
|
1094
|
+
dora_grades: calculateDoraGrades(events, doraPeriodDays),
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ─── DORA Grades ────────────────────────────────────────────────────
|
|
1099
|
+
function calculateDoraGrades(
|
|
1100
|
+
events: DaemonEvent[],
|
|
1101
|
+
periodDays: number,
|
|
1102
|
+
): DoraGrades {
|
|
1103
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1104
|
+
const cutoff = now - periodDays * 86400;
|
|
1105
|
+
|
|
1106
|
+
// Filter to events within the period
|
|
1107
|
+
const recent = events.filter((e) => (e.ts_epoch || 0) >= cutoff);
|
|
1108
|
+
|
|
1109
|
+
// --- Deployment Frequency ---
|
|
1110
|
+
// Count successful completions in the period
|
|
1111
|
+
let completedCount = 0;
|
|
1112
|
+
for (const e of recent) {
|
|
1113
|
+
if (e.type === "pipeline.completed" && e.result === "success") {
|
|
1114
|
+
completedCount++;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const deploysPerDay =
|
|
1118
|
+
periodDays > 0 ? Math.round((completedCount / periodDays) * 100) / 100 : 0;
|
|
1119
|
+
|
|
1120
|
+
let deployGrade: DoraMetric["grade"];
|
|
1121
|
+
if (deploysPerDay >= 1) deployGrade = "Elite";
|
|
1122
|
+
else if (deploysPerDay >= 1 / 7) deployGrade = "High";
|
|
1123
|
+
else if (deploysPerDay >= 1 / 30) deployGrade = "Medium";
|
|
1124
|
+
else deployGrade = "Low";
|
|
1125
|
+
|
|
1126
|
+
// --- Lead Time ---
|
|
1127
|
+
// Average time from pipeline.started to pipeline.completed (success only)
|
|
1128
|
+
const leadTimes: number[] = [];
|
|
1129
|
+
const startEpochs: Record<number, number> = {};
|
|
1130
|
+
for (const e of recent) {
|
|
1131
|
+
if (e.type === "pipeline.started" && e.issue) {
|
|
1132
|
+
startEpochs[e.issue] = e.ts_epoch || 0;
|
|
1133
|
+
}
|
|
1134
|
+
if (e.type === "pipeline.completed" && e.result === "success" && e.issue) {
|
|
1135
|
+
const startEpoch = startEpochs[e.issue];
|
|
1136
|
+
if (startEpoch && startEpoch > 0) {
|
|
1137
|
+
const endEpoch = e.ts_epoch || 0;
|
|
1138
|
+
if (endEpoch > startEpoch) {
|
|
1139
|
+
leadTimes.push((endEpoch - startEpoch) / 3600); // hours
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
const avgLeadTime =
|
|
1145
|
+
leadTimes.length > 0
|
|
1146
|
+
? Math.round(
|
|
1147
|
+
(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) * 100,
|
|
1148
|
+
) / 100
|
|
1149
|
+
: 0;
|
|
1150
|
+
|
|
1151
|
+
let leadGrade: DoraMetric["grade"];
|
|
1152
|
+
if (leadTimes.length === 0) leadGrade = "Low";
|
|
1153
|
+
else if (avgLeadTime < 1) leadGrade = "Elite";
|
|
1154
|
+
else if (avgLeadTime < 24) leadGrade = "High";
|
|
1155
|
+
else if (avgLeadTime < 168) leadGrade = "Medium";
|
|
1156
|
+
else leadGrade = "Low";
|
|
1157
|
+
|
|
1158
|
+
// --- Change Failure Rate ---
|
|
1159
|
+
let totalCompleted = 0;
|
|
1160
|
+
let totalFailed = 0;
|
|
1161
|
+
for (const e of recent) {
|
|
1162
|
+
if (e.type === "pipeline.completed") {
|
|
1163
|
+
if (e.result === "success") totalCompleted++;
|
|
1164
|
+
else totalFailed++;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const total = totalCompleted + totalFailed;
|
|
1168
|
+
const cfr = total > 0 ? Math.round((totalFailed / total) * 10000) / 100 : 0;
|
|
1169
|
+
|
|
1170
|
+
let cfrGrade: DoraMetric["grade"];
|
|
1171
|
+
if (total === 0) cfrGrade = "Low";
|
|
1172
|
+
else if (cfr < 5) cfrGrade = "Elite";
|
|
1173
|
+
else if (cfr < 10) cfrGrade = "High";
|
|
1174
|
+
else if (cfr < 15) cfrGrade = "Medium";
|
|
1175
|
+
else cfrGrade = "Low";
|
|
1176
|
+
|
|
1177
|
+
// --- Mean Time to Recovery ---
|
|
1178
|
+
// For each issue that failed, find time between failure and next success
|
|
1179
|
+
const recoveryTimes: number[] = [];
|
|
1180
|
+
// Track the most recent failure epoch per issue
|
|
1181
|
+
const failureEpochs: Record<number, number> = {};
|
|
1182
|
+
for (const e of recent) {
|
|
1183
|
+
if (!e.issue) continue;
|
|
1184
|
+
if (e.type === "pipeline.completed" && e.result !== "success") {
|
|
1185
|
+
failureEpochs[e.issue] = e.ts_epoch || 0;
|
|
1186
|
+
}
|
|
1187
|
+
if (e.type === "pipeline.completed" && e.result === "success") {
|
|
1188
|
+
const failEpoch = failureEpochs[e.issue];
|
|
1189
|
+
if (failEpoch && failEpoch > 0) {
|
|
1190
|
+
const recoverEpoch = e.ts_epoch || 0;
|
|
1191
|
+
if (recoverEpoch > failEpoch) {
|
|
1192
|
+
recoveryTimes.push((recoverEpoch - failEpoch) / 3600); // hours
|
|
1193
|
+
}
|
|
1194
|
+
delete failureEpochs[e.issue];
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const avgMttr =
|
|
1199
|
+
recoveryTimes.length > 0
|
|
1200
|
+
? Math.round(
|
|
1201
|
+
(recoveryTimes.reduce((a, b) => a + b, 0) / recoveryTimes.length) *
|
|
1202
|
+
100,
|
|
1203
|
+
) / 100
|
|
1204
|
+
: 0;
|
|
1205
|
+
|
|
1206
|
+
let mttrGrade: DoraMetric["grade"];
|
|
1207
|
+
if (recoveryTimes.length === 0) mttrGrade = "Elite";
|
|
1208
|
+
else if (avgMttr < 1) mttrGrade = "Elite";
|
|
1209
|
+
else if (avgMttr < 24) mttrGrade = "High";
|
|
1210
|
+
else if (avgMttr < 168) mttrGrade = "Medium";
|
|
1211
|
+
else mttrGrade = "Low";
|
|
1212
|
+
|
|
1213
|
+
return {
|
|
1214
|
+
deploy_freq: { value: deploysPerDay, unit: "per day", grade: deployGrade },
|
|
1215
|
+
lead_time: { value: avgLeadTime, unit: "hours", grade: leadGrade },
|
|
1216
|
+
cfr: { value: cfr, unit: "%", grade: cfrGrade },
|
|
1217
|
+
mttr: { value: avgMttr, unit: "hours", grade: mttrGrade },
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// ─── Agent Heartbeats ────────────────────────────────────────────────
|
|
1222
|
+
interface AgentInfo {
|
|
1223
|
+
id: string;
|
|
1224
|
+
issue: number;
|
|
1225
|
+
title: string;
|
|
1226
|
+
machine: string;
|
|
1227
|
+
stage: string;
|
|
1228
|
+
iteration: number;
|
|
1229
|
+
activity: string;
|
|
1230
|
+
memory_mb: number;
|
|
1231
|
+
cpu_pct: number;
|
|
1232
|
+
status: "active" | "idle" | "stale" | "dead";
|
|
1233
|
+
heartbeat_age_s: number;
|
|
1234
|
+
started_at: string;
|
|
1235
|
+
elapsed_s: number;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
function getAgents(): AgentInfo[] {
|
|
1239
|
+
const agents: AgentInfo[] = [];
|
|
1240
|
+
const daemonState = readDaemonState();
|
|
1241
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1242
|
+
|
|
1243
|
+
// Build title map from active jobs
|
|
1244
|
+
const jobMap: Record<number, Record<string, unknown>> = {};
|
|
1245
|
+
if (daemonState) {
|
|
1246
|
+
const activeJobs =
|
|
1247
|
+
(daemonState.active_jobs as Array<Record<string, unknown>>) || [];
|
|
1248
|
+
for (const job of activeJobs) {
|
|
1249
|
+
const issue = (job.issue as number) || 0;
|
|
1250
|
+
if (issue) jobMap[issue] = job;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Read heartbeat files
|
|
1255
|
+
if (existsSync(HEARTBEAT_DIR)) {
|
|
1256
|
+
try {
|
|
1257
|
+
const files = readdirSync(HEARTBEAT_DIR).filter((f) =>
|
|
1258
|
+
f.endsWith(".json"),
|
|
1259
|
+
);
|
|
1260
|
+
for (const file of files) {
|
|
1261
|
+
try {
|
|
1262
|
+
const content = readFileSync(join(HEARTBEAT_DIR, file), "utf-8");
|
|
1263
|
+
const hb = JSON.parse(content);
|
|
1264
|
+
const updatedAt = hb.updated_at || "";
|
|
1265
|
+
let hbEpoch = 0;
|
|
1266
|
+
try {
|
|
1267
|
+
hbEpoch = Math.floor(new Date(updatedAt).getTime() / 1000);
|
|
1268
|
+
} catch {
|
|
1269
|
+
/* ignore */
|
|
1270
|
+
}
|
|
1271
|
+
const age = hbEpoch > 0 ? now - hbEpoch : 9999;
|
|
1272
|
+
|
|
1273
|
+
let status: AgentInfo["status"] = "active";
|
|
1274
|
+
if (age > 120) status = "stale";
|
|
1275
|
+
else if (age > 30) status = "idle";
|
|
1276
|
+
|
|
1277
|
+
// Check if PID is referenced in daemon active jobs
|
|
1278
|
+
const issue = (hb.issue as number) || 0;
|
|
1279
|
+
const job = issue ? jobMap[issue] : undefined;
|
|
1280
|
+
const startedAt = job ? (job.started_at as string) || "" : updatedAt;
|
|
1281
|
+
let elapsed = 0;
|
|
1282
|
+
if (startedAt) {
|
|
1283
|
+
try {
|
|
1284
|
+
elapsed = now - Math.floor(new Date(startedAt).getTime() / 1000);
|
|
1285
|
+
} catch {
|
|
1286
|
+
/* ignore */
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
agents.push({
|
|
1291
|
+
id: file.replace(".json", ""),
|
|
1292
|
+
issue,
|
|
1293
|
+
title: job ? (job.title as string) || "" : "",
|
|
1294
|
+
machine: (hb.machine as string) || "localhost",
|
|
1295
|
+
stage: (hb.stage as string) || "",
|
|
1296
|
+
iteration: (hb.iteration as number) || 0,
|
|
1297
|
+
activity: (hb.last_activity as string) || "",
|
|
1298
|
+
memory_mb: (hb.memory_mb as number) || 0,
|
|
1299
|
+
cpu_pct: (hb.cpu_pct as number) || 0,
|
|
1300
|
+
status,
|
|
1301
|
+
heartbeat_age_s: age,
|
|
1302
|
+
started_at: startedAt,
|
|
1303
|
+
elapsed_s: elapsed,
|
|
1304
|
+
});
|
|
1305
|
+
} catch {
|
|
1306
|
+
// Skip malformed heartbeat files
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
} catch {
|
|
1310
|
+
// Heartbeat dir read failed
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
return agents;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ─── Timeline ──────────────────────────────────────────────────────
|
|
1318
|
+
interface TimelineEntry {
|
|
1319
|
+
issue: number;
|
|
1320
|
+
title: string;
|
|
1321
|
+
segments: Array<{
|
|
1322
|
+
stage: string;
|
|
1323
|
+
start: string;
|
|
1324
|
+
end: string | null;
|
|
1325
|
+
status: "complete" | "running" | "failed";
|
|
1326
|
+
}>;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function getTimeline(rangeHours: number): TimelineEntry[] {
|
|
1330
|
+
const events = readEvents();
|
|
1331
|
+
const daemonState = readDaemonState();
|
|
1332
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1333
|
+
const cutoff = now - rangeHours * 3600;
|
|
1334
|
+
|
|
1335
|
+
// Build issue title map
|
|
1336
|
+
const titleMap: Record<number, string> = {};
|
|
1337
|
+
if (daemonState) {
|
|
1338
|
+
const activeJobs =
|
|
1339
|
+
(daemonState.active_jobs as Array<Record<string, unknown>>) || [];
|
|
1340
|
+
for (const job of activeJobs) {
|
|
1341
|
+
const issue = (job.issue as number) || 0;
|
|
1342
|
+
const title = (job.title as string) || "";
|
|
1343
|
+
if (issue && title) titleMap[issue] = title;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Group events by issue
|
|
1348
|
+
const issueEvents: Record<number, DaemonEvent[]> = {};
|
|
1349
|
+
for (const e of events) {
|
|
1350
|
+
if (!e.issue || e.issue <= 0) continue;
|
|
1351
|
+
const epoch = e.ts_epoch || 0;
|
|
1352
|
+
if (epoch < cutoff) continue;
|
|
1353
|
+
if (!issueEvents[e.issue]) issueEvents[e.issue] = [];
|
|
1354
|
+
issueEvents[e.issue].push(e);
|
|
1355
|
+
if (e.type === "pipeline.completed" && !titleMap[e.issue]) {
|
|
1356
|
+
titleMap[e.issue] = (e.title as string) || "";
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const timeline: TimelineEntry[] = [];
|
|
1361
|
+
for (const [issueStr, evts] of Object.entries(issueEvents)) {
|
|
1362
|
+
const issue = parseInt(issueStr);
|
|
1363
|
+
const segments: TimelineEntry["segments"] = [];
|
|
1364
|
+
|
|
1365
|
+
for (const e of evts) {
|
|
1366
|
+
if (e.type === "stage.started") {
|
|
1367
|
+
segments.push({
|
|
1368
|
+
stage: e.stage || "",
|
|
1369
|
+
start: e.ts,
|
|
1370
|
+
end: null,
|
|
1371
|
+
status: "running",
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
if (e.type === "stage.completed") {
|
|
1375
|
+
// Find matching running segment
|
|
1376
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1377
|
+
if (
|
|
1378
|
+
segments[i].stage === e.stage &&
|
|
1379
|
+
segments[i].status === "running"
|
|
1380
|
+
) {
|
|
1381
|
+
segments[i].end = e.ts;
|
|
1382
|
+
segments[i].status = "complete";
|
|
1383
|
+
break;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
if (e.type === "stage.failed") {
|
|
1388
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1389
|
+
if (
|
|
1390
|
+
segments[i].stage === e.stage &&
|
|
1391
|
+
segments[i].status === "running"
|
|
1392
|
+
) {
|
|
1393
|
+
segments[i].end = e.ts;
|
|
1394
|
+
segments[i].status = "failed";
|
|
1395
|
+
break;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
if (segments.length > 0) {
|
|
1402
|
+
timeline.push({
|
|
1403
|
+
issue,
|
|
1404
|
+
title: titleMap[issue] || "",
|
|
1405
|
+
segments,
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
return timeline;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// ─── Machines ──────────────────────────────────────────────────────
|
|
1414
|
+
interface MachineInfo {
|
|
1415
|
+
name: string;
|
|
1416
|
+
host: string;
|
|
1417
|
+
role: string;
|
|
1418
|
+
max_workers: number;
|
|
1419
|
+
active_workers: number;
|
|
1420
|
+
registered_at: string;
|
|
1421
|
+
ssh_user?: string;
|
|
1422
|
+
shipwright_path?: string;
|
|
1423
|
+
status: "online" | "degraded" | "offline";
|
|
1424
|
+
health: {
|
|
1425
|
+
daemon_running: boolean;
|
|
1426
|
+
heartbeat_count: number;
|
|
1427
|
+
last_heartbeat_s_ago: number;
|
|
1428
|
+
};
|
|
1429
|
+
join_token?: string;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
interface MachinesFileData {
|
|
1433
|
+
machines: Array<Record<string, unknown>>;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
const JOIN_TOKENS_FILE = join(HOME, ".shipwright", "join-tokens.json");
|
|
1437
|
+
const MACHINE_HEALTH_FILE = join(HOME, ".shipwright", "machine-health.json");
|
|
1438
|
+
|
|
1439
|
+
function readMachinesFile(): MachinesFileData {
|
|
1440
|
+
const raw = readFileOr(MACHINES_FILE, '{"machines":[]}');
|
|
1441
|
+
try {
|
|
1442
|
+
const data = JSON.parse(raw);
|
|
1443
|
+
return { machines: data.machines || [] };
|
|
1444
|
+
} catch {
|
|
1445
|
+
return { machines: [] };
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function writeMachinesFile(data: MachinesFileData): void {
|
|
1450
|
+
const dir = join(HOME, ".shipwright");
|
|
1451
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1452
|
+
const tmp = MACHINES_FILE + ".tmp";
|
|
1453
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
1454
|
+
renameSync(tmp, MACHINES_FILE);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function enrichMachineHealth(
|
|
1458
|
+
machine: Record<string, unknown>,
|
|
1459
|
+
agents: AgentInfo[],
|
|
1460
|
+
): MachineInfo {
|
|
1461
|
+
const name = (machine.name as string) || "";
|
|
1462
|
+
const host = (machine.host as string) || "";
|
|
1463
|
+
const activeWorkers = agents.filter(
|
|
1464
|
+
(a) => a.machine === name || a.machine === host,
|
|
1465
|
+
).length;
|
|
1466
|
+
|
|
1467
|
+
// Read health data if available
|
|
1468
|
+
let daemonRunning = false;
|
|
1469
|
+
let heartbeatCount = 0;
|
|
1470
|
+
let lastHeartbeatSAgo = 9999;
|
|
1471
|
+
|
|
1472
|
+
try {
|
|
1473
|
+
if (existsSync(MACHINE_HEALTH_FILE)) {
|
|
1474
|
+
const healthData = JSON.parse(readFileSync(MACHINE_HEALTH_FILE, "utf-8"));
|
|
1475
|
+
const mHealth = healthData[name] || healthData[host];
|
|
1476
|
+
if (mHealth) {
|
|
1477
|
+
daemonRunning = !!mHealth.daemon_running;
|
|
1478
|
+
heartbeatCount = (mHealth.heartbeat_count as number) || 0;
|
|
1479
|
+
if (mHealth.last_check) {
|
|
1480
|
+
const checkEpoch = Math.floor(
|
|
1481
|
+
new Date(mHealth.last_check as string).getTime() / 1000,
|
|
1482
|
+
);
|
|
1483
|
+
lastHeartbeatSAgo = Math.floor(Date.now() / 1000) - checkEpoch;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
} catch {
|
|
1488
|
+
// ignore health read errors
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// Also check heartbeat files for this machine
|
|
1492
|
+
const machineHeartbeats = agents.filter(
|
|
1493
|
+
(a) => a.machine === name || a.machine === host,
|
|
1494
|
+
);
|
|
1495
|
+
if (machineHeartbeats.length > 0) {
|
|
1496
|
+
heartbeatCount = machineHeartbeats.length;
|
|
1497
|
+
const minAge = Math.min(...machineHeartbeats.map((a) => a.heartbeat_age_s));
|
|
1498
|
+
if (minAge < lastHeartbeatSAgo) lastHeartbeatSAgo = minAge;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
let status: MachineInfo["status"] = "offline";
|
|
1502
|
+
if (lastHeartbeatSAgo < 60) status = "online";
|
|
1503
|
+
else if (lastHeartbeatSAgo < 300 || daemonRunning) status = "degraded";
|
|
1504
|
+
|
|
1505
|
+
return {
|
|
1506
|
+
name,
|
|
1507
|
+
host,
|
|
1508
|
+
role: (machine.role as string) || "worker",
|
|
1509
|
+
max_workers: (machine.max_workers as number) || 4,
|
|
1510
|
+
active_workers: activeWorkers,
|
|
1511
|
+
registered_at: (machine.registered_at as string) || "",
|
|
1512
|
+
ssh_user: (machine.ssh_user as string) || undefined,
|
|
1513
|
+
shipwright_path: (machine.shipwright_path as string) || undefined,
|
|
1514
|
+
status,
|
|
1515
|
+
health: {
|
|
1516
|
+
daemon_running: daemonRunning,
|
|
1517
|
+
heartbeat_count: heartbeatCount,
|
|
1518
|
+
last_heartbeat_s_ago: lastHeartbeatSAgo,
|
|
1519
|
+
},
|
|
1520
|
+
join_token: (machine.join_token as string) || undefined,
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
function getMachines(): MachineInfo[] {
|
|
1525
|
+
const data = readMachinesFile();
|
|
1526
|
+
if (data.machines.length === 0) return [];
|
|
1527
|
+
const agents = getAgents();
|
|
1528
|
+
return data.machines.map((m) => enrichMachineHealth(m, agents));
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function generateJoinScript(
|
|
1532
|
+
token: string,
|
|
1533
|
+
dashboardUrl: string,
|
|
1534
|
+
maxWorkers: number,
|
|
1535
|
+
): string {
|
|
1536
|
+
return `#!/usr/bin/env bash
|
|
1537
|
+
set -euo pipefail
|
|
1538
|
+
# Shipwright remote worker join script
|
|
1539
|
+
# Generated by Shipwright Dashboard
|
|
1540
|
+
|
|
1541
|
+
DASHBOARD_URL="${dashboardUrl}"
|
|
1542
|
+
JOIN_TOKEN="${token}"
|
|
1543
|
+
MAX_WORKERS="${maxWorkers}"
|
|
1544
|
+
|
|
1545
|
+
echo "==> Joining Shipwright cluster..."
|
|
1546
|
+
echo " Dashboard: \${DASHBOARD_URL}"
|
|
1547
|
+
echo " Max workers: \${MAX_WORKERS}"
|
|
1548
|
+
|
|
1549
|
+
# Verify shipwright is installed
|
|
1550
|
+
if ! command -v shipwright &>/dev/null && ! command -v sw &>/dev/null; then
|
|
1551
|
+
echo "ERROR: shipwright not found in PATH"
|
|
1552
|
+
echo "Install: curl -fsSL https://raw.githubusercontent.com/sethdford/shipwright/main/install.sh | bash"
|
|
1553
|
+
exit 1
|
|
1554
|
+
fi
|
|
1555
|
+
|
|
1556
|
+
SW=\$(command -v shipwright || command -v sw)
|
|
1557
|
+
|
|
1558
|
+
# Register this machine with the dashboard
|
|
1559
|
+
HOSTNAME=\$(hostname)
|
|
1560
|
+
\$SW remote add "\${HOSTNAME}" \\
|
|
1561
|
+
--host "\$(hostname -f 2>/dev/null || hostname)" \\
|
|
1562
|
+
--max-workers "\${MAX_WORKERS}" \\
|
|
1563
|
+
--join-token "\${JOIN_TOKEN}" 2>/dev/null || true
|
|
1564
|
+
|
|
1565
|
+
echo "==> Machine registered. Starting daemon..."
|
|
1566
|
+
\$SW daemon start --max-parallel "\${MAX_WORKERS}"
|
|
1567
|
+
`;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// ─── Cost Data ─────────────────────────────────────────────────────
|
|
1571
|
+
interface CostInfo {
|
|
1572
|
+
today_spent: number;
|
|
1573
|
+
daily_budget: number;
|
|
1574
|
+
pct_used: number;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function getCostInfo(): CostInfo {
|
|
1578
|
+
let todaySpent = 0;
|
|
1579
|
+
let dailyBudget = 0;
|
|
1580
|
+
|
|
1581
|
+
if (existsSync(COSTS_FILE)) {
|
|
1582
|
+
try {
|
|
1583
|
+
const data = JSON.parse(readFileSync(COSTS_FILE, "utf-8"));
|
|
1584
|
+
// Cost file format: {entries: [{cost_usd, ts_epoch, ...}]}
|
|
1585
|
+
// Sum entries from today (midnight UTC)
|
|
1586
|
+
const entries = (data.entries as Array<Record<string, unknown>>) || [];
|
|
1587
|
+
const todayMidnight = new Date();
|
|
1588
|
+
todayMidnight.setUTCHours(0, 0, 0, 0);
|
|
1589
|
+
const cutoff = Math.floor(todayMidnight.getTime() / 1000);
|
|
1590
|
+
for (const entry of entries) {
|
|
1591
|
+
const epoch = (entry.ts_epoch as number) || 0;
|
|
1592
|
+
if (epoch >= cutoff) {
|
|
1593
|
+
todaySpent += (entry.cost_usd as number) || 0;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
todaySpent = Math.round(todaySpent * 100) / 100;
|
|
1597
|
+
} catch {
|
|
1598
|
+
/* ignore */
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
if (existsSync(BUDGET_FILE)) {
|
|
1603
|
+
try {
|
|
1604
|
+
const data = JSON.parse(readFileSync(BUDGET_FILE, "utf-8"));
|
|
1605
|
+
// Budget file format: {daily_budget_usd: N, enabled: bool}
|
|
1606
|
+
dailyBudget = (data.daily_budget_usd as number) || 0;
|
|
1607
|
+
} catch {
|
|
1608
|
+
/* ignore */
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
const pctUsed =
|
|
1613
|
+
dailyBudget > 0 ? Math.round((todaySpent / dailyBudget) * 10000) / 100 : 0;
|
|
1614
|
+
return {
|
|
1615
|
+
today_spent: todaySpent,
|
|
1616
|
+
daily_budget: dailyBudget,
|
|
1617
|
+
pct_used: pctUsed,
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// ─── Plan Content ────────────────────────────────────────────────────
|
|
1622
|
+
function getPlanContent(issue: number): { content: string } {
|
|
1623
|
+
const worktreeBase = findWorktreeBase(issue);
|
|
1624
|
+
if (!worktreeBase) return { content: "" };
|
|
1625
|
+
const planPath = join(
|
|
1626
|
+
worktreeBase,
|
|
1627
|
+
".claude",
|
|
1628
|
+
"pipeline-artifacts",
|
|
1629
|
+
"plan.md",
|
|
1630
|
+
);
|
|
1631
|
+
return { content: readFileOr(planPath, "") };
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// ─── Activity Feed ───────────────────────────────────────────────────
|
|
1635
|
+
interface ActivityEvent extends DaemonEvent {
|
|
1636
|
+
issueTitle?: string;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
interface ActivityFeed {
|
|
1640
|
+
events: ActivityEvent[];
|
|
1641
|
+
total: number;
|
|
1642
|
+
hasMore: boolean;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function getActivityFeed(
|
|
1646
|
+
limit: number,
|
|
1647
|
+
offset: number,
|
|
1648
|
+
typeFilter: string,
|
|
1649
|
+
issueFilter: string,
|
|
1650
|
+
): ActivityFeed {
|
|
1651
|
+
const allEvents = readEvents();
|
|
1652
|
+
const daemonState = readDaemonState();
|
|
1653
|
+
|
|
1654
|
+
// Build issue title map from daemon state
|
|
1655
|
+
const titleMap: Record<number, string> = {};
|
|
1656
|
+
if (daemonState) {
|
|
1657
|
+
const activeJobs =
|
|
1658
|
+
(daemonState.active_jobs as Array<Record<string, unknown>>) || [];
|
|
1659
|
+
for (const job of activeJobs) {
|
|
1660
|
+
const issue = (job.issue as number) || 0;
|
|
1661
|
+
const title = (job.title as string) || "";
|
|
1662
|
+
if (issue && title) titleMap[issue] = title;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Filter events
|
|
1667
|
+
let filtered = allEvents;
|
|
1668
|
+
if (typeFilter && typeFilter !== "all") {
|
|
1669
|
+
filtered = filtered.filter(
|
|
1670
|
+
(e) => e.type === typeFilter || e.type.startsWith(typeFilter + "."),
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
if (issueFilter) {
|
|
1674
|
+
const issueNum = parseInt(issueFilter);
|
|
1675
|
+
if (!isNaN(issueNum)) {
|
|
1676
|
+
filtered = filtered.filter((e) => e.issue === issueNum);
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Reverse for newest-first
|
|
1681
|
+
filtered = [...filtered].reverse();
|
|
1682
|
+
const total = filtered.length;
|
|
1683
|
+
const page = filtered.slice(offset, offset + limit);
|
|
1684
|
+
|
|
1685
|
+
// Enrich with titles
|
|
1686
|
+
const enriched: ActivityEvent[] = page.map((e) => ({
|
|
1687
|
+
...e,
|
|
1688
|
+
issueTitle: e.issue ? titleMap[e.issue] || "" : undefined,
|
|
1689
|
+
}));
|
|
1690
|
+
|
|
1691
|
+
return {
|
|
1692
|
+
events: enriched,
|
|
1693
|
+
total,
|
|
1694
|
+
hasMore: offset + limit < total,
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// ─── Linear Integration Status ───────────────────────────────────────
|
|
1699
|
+
interface LinearStatus {
|
|
1700
|
+
configured: boolean;
|
|
1701
|
+
configSource: string;
|
|
1702
|
+
linkedIssues: Record<string, unknown>;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function getLinearStatus(): LinearStatus {
|
|
1706
|
+
const hasEnvKey = !!process.env.LINEAR_API_KEY;
|
|
1707
|
+
const configPath = join(HOME, ".shipwright", "tracker-config.json");
|
|
1708
|
+
const hasConfigFile = existsSync(configPath);
|
|
1709
|
+
|
|
1710
|
+
let linkedIssues: Record<string, unknown> = {};
|
|
1711
|
+
if (hasConfigFile) {
|
|
1712
|
+
try {
|
|
1713
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1714
|
+
linkedIssues = (config.linked_issues as Record<string, unknown>) || {};
|
|
1715
|
+
} catch {
|
|
1716
|
+
// ignore
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
return {
|
|
1721
|
+
configured: hasEnvKey || hasConfigFile,
|
|
1722
|
+
configSource: hasEnvKey ? "env" : hasConfigFile ? "file" : "none",
|
|
1723
|
+
linkedIssues,
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// ─── GitHub CLI Cache ────────────────────────────────────────────────
|
|
1728
|
+
const ghCache = new Map<string, { data: unknown; ts: number }>();
|
|
1729
|
+
const GH_CACHE_TTL_MS = 30_000;
|
|
1730
|
+
|
|
1731
|
+
function ghCached<T>(key: string, fn: () => T): T {
|
|
1732
|
+
const now = Date.now();
|
|
1733
|
+
const cached = ghCache.get(key);
|
|
1734
|
+
if (cached && now - cached.ts < GH_CACHE_TTL_MS) return cached.data as T;
|
|
1735
|
+
const data = fn();
|
|
1736
|
+
ghCache.set(key, { data, ts: now });
|
|
1737
|
+
return data;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
// ─── Memory System Helpers ──────────────────────────────────────────
|
|
1741
|
+
function readMemoryFiles(filename: string): unknown[] {
|
|
1742
|
+
if (!existsSync(MEMORY_DIR)) return [];
|
|
1743
|
+
const results: unknown[] = [];
|
|
1744
|
+
try {
|
|
1745
|
+
const subdirs = readdirSync(MEMORY_DIR);
|
|
1746
|
+
for (const sub of subdirs) {
|
|
1747
|
+
const filePath = join(MEMORY_DIR, sub, filename);
|
|
1748
|
+
if (existsSync(filePath)) {
|
|
1749
|
+
try {
|
|
1750
|
+
const content = readFileSync(filePath, "utf-8");
|
|
1751
|
+
const parsed = JSON.parse(content);
|
|
1752
|
+
if (Array.isArray(parsed)) results.push(...parsed);
|
|
1753
|
+
else results.push(parsed);
|
|
1754
|
+
} catch {
|
|
1755
|
+
// skip malformed files
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
} catch {
|
|
1760
|
+
// ignore
|
|
1761
|
+
}
|
|
1762
|
+
return results;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function stripAnsi(content: string): string {
|
|
1766
|
+
return content.replace(/\x1b\[[0-9;]*m/g, "");
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// ─── Model Pricing (per 1M tokens) ──────────────────────────────────
|
|
1770
|
+
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
|
1771
|
+
opus: { input: 15, output: 75 },
|
|
1772
|
+
sonnet: { input: 3, output: 15 },
|
|
1773
|
+
haiku: { input: 0.25, output: 1.25 },
|
|
1774
|
+
};
|
|
1775
|
+
|
|
1776
|
+
// ─── Static file serving ─────────────────────────────────────────────
|
|
1777
|
+
const MIME_TYPES: Record<string, string> = {
|
|
1778
|
+
".html": "text/html; charset=utf-8",
|
|
1779
|
+
".css": "text/css; charset=utf-8",
|
|
1780
|
+
".js": "application/javascript; charset=utf-8",
|
|
1781
|
+
".json": "application/json",
|
|
1782
|
+
".png": "image/png",
|
|
1783
|
+
".svg": "image/svg+xml",
|
|
1784
|
+
".ico": "image/x-icon",
|
|
1785
|
+
".woff2": "font/woff2",
|
|
1786
|
+
".woff": "font/woff",
|
|
1787
|
+
};
|
|
1788
|
+
|
|
1789
|
+
function serveStaticFile(pathname: string): Response | null {
|
|
1790
|
+
// Map / to /index.html
|
|
1791
|
+
const filePath =
|
|
1792
|
+
pathname === "/" || pathname === "/index.html"
|
|
1793
|
+
? join(PUBLIC_DIR, "index.html")
|
|
1794
|
+
: join(PUBLIC_DIR, pathname);
|
|
1795
|
+
|
|
1796
|
+
// Prevent directory traversal
|
|
1797
|
+
if (!filePath.startsWith(PUBLIC_DIR)) {
|
|
1798
|
+
return new Response("Forbidden", { status: 403 });
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const file = Bun.file(filePath);
|
|
1802
|
+
// Bun.file doesn't throw on missing files — check existence
|
|
1803
|
+
if (!existsSync(filePath)) return null;
|
|
1804
|
+
|
|
1805
|
+
const ext = extname(filePath);
|
|
1806
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
1807
|
+
|
|
1808
|
+
return new Response(file, {
|
|
1809
|
+
headers: {
|
|
1810
|
+
"Content-Type": contentType,
|
|
1811
|
+
"Cache-Control": "no-cache",
|
|
1812
|
+
},
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// ─── File watcher for events.jsonl ───────────────────────────────────
|
|
1817
|
+
let eventsWatcher: FSWatcher | null = null;
|
|
1818
|
+
|
|
1819
|
+
function startEventsWatcher(): void {
|
|
1820
|
+
// Watch the directory containing events.jsonl (file may not exist yet)
|
|
1821
|
+
const watchDir = join(HOME, ".shipwright");
|
|
1822
|
+
if (!existsSync(watchDir)) return;
|
|
1823
|
+
|
|
1824
|
+
try {
|
|
1825
|
+
eventsWatcher = watch(watchDir, (eventType, filename) => {
|
|
1826
|
+
if (filename === "events.jsonl" || filename === "daemon-state.json") {
|
|
1827
|
+
// Push fresh state to all connected clients immediately
|
|
1828
|
+
if (wsClients.size > 0) {
|
|
1829
|
+
broadcastToClients(getFleetState());
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
});
|
|
1833
|
+
} catch {
|
|
1834
|
+
// Watcher may fail on some systems — fall back to interval-only
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// ─── Periodic WebSocket push ─────────────────────────────────────────
|
|
1839
|
+
let lastPushedJson = "";
|
|
1840
|
+
|
|
1841
|
+
function periodicPush(): void {
|
|
1842
|
+
if (wsClients.size === 0) return;
|
|
1843
|
+
|
|
1844
|
+
const state = getFleetState();
|
|
1845
|
+
const json = JSON.stringify(state);
|
|
1846
|
+
// Skip push if nothing changed (file watcher already pushed)
|
|
1847
|
+
if (json === lastPushedJson) return;
|
|
1848
|
+
lastPushedJson = json;
|
|
1849
|
+
|
|
1850
|
+
broadcastToClients(state);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// ─── GitHub OAuth helpers ───────────────────────────────────────────
|
|
1854
|
+
async function exchangeCodeForToken(code: string): Promise<string | null> {
|
|
1855
|
+
try {
|
|
1856
|
+
const resp = await fetch("https://github.com/login/oauth/access_token", {
|
|
1857
|
+
method: "POST",
|
|
1858
|
+
headers: {
|
|
1859
|
+
"Content-Type": "application/json",
|
|
1860
|
+
Accept: "application/json",
|
|
1861
|
+
},
|
|
1862
|
+
body: JSON.stringify({
|
|
1863
|
+
client_id: GITHUB_CLIENT_ID,
|
|
1864
|
+
client_secret: GITHUB_CLIENT_SECRET,
|
|
1865
|
+
code,
|
|
1866
|
+
}),
|
|
1867
|
+
});
|
|
1868
|
+
const data = (await resp.json()) as { access_token?: string };
|
|
1869
|
+
return data.access_token || null;
|
|
1870
|
+
} catch {
|
|
1871
|
+
return null;
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
async function getGitHubUser(
|
|
1876
|
+
token: string,
|
|
1877
|
+
): Promise<{ login: string; avatar_url: string } | null> {
|
|
1878
|
+
try {
|
|
1879
|
+
const resp = await fetch("https://api.github.com/user", {
|
|
1880
|
+
headers: {
|
|
1881
|
+
Authorization: `Bearer ${token}`,
|
|
1882
|
+
Accept: "application/vnd.github+json",
|
|
1883
|
+
"User-Agent": "Shipwright-Fleet-Command",
|
|
1884
|
+
},
|
|
1885
|
+
});
|
|
1886
|
+
if (!resp.ok) return null;
|
|
1887
|
+
const data = (await resp.json()) as { login: string; avatar_url: string };
|
|
1888
|
+
return data;
|
|
1889
|
+
} catch {
|
|
1890
|
+
return null;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
async function checkRepoPermission(
|
|
1895
|
+
token: string,
|
|
1896
|
+
username: string,
|
|
1897
|
+
): Promise<string | null> {
|
|
1898
|
+
if (!DASHBOARD_REPO) return null;
|
|
1899
|
+
const [owner, repo] = DASHBOARD_REPO.split("/");
|
|
1900
|
+
if (!owner || !repo) return null;
|
|
1901
|
+
|
|
1902
|
+
try {
|
|
1903
|
+
const resp = await fetch(
|
|
1904
|
+
`https://api.github.com/repos/${owner}/${repo}/collaborators/${username}/permission`,
|
|
1905
|
+
{
|
|
1906
|
+
headers: {
|
|
1907
|
+
Authorization: `Bearer ${token}`,
|
|
1908
|
+
Accept: "application/vnd.github+json",
|
|
1909
|
+
"User-Agent": "Shipwright-Fleet-Command",
|
|
1910
|
+
},
|
|
1911
|
+
},
|
|
1912
|
+
);
|
|
1913
|
+
if (!resp.ok) return null;
|
|
1914
|
+
const data = (await resp.json()) as { permission?: string };
|
|
1915
|
+
return data.permission || null;
|
|
1916
|
+
} catch {
|
|
1917
|
+
return null;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
// ─── Auth route handlers ────────────────────────────────────────────
|
|
1922
|
+
function handleAuthGitHub(): Response {
|
|
1923
|
+
const params = new URLSearchParams({
|
|
1924
|
+
client_id: GITHUB_CLIENT_ID,
|
|
1925
|
+
scope: "read:org repo",
|
|
1926
|
+
redirect_uri: "", // GitHub uses the registered callback URL by default
|
|
1927
|
+
});
|
|
1928
|
+
// Remove empty redirect_uri — let GitHub use the app's registered callback
|
|
1929
|
+
params.delete("redirect_uri");
|
|
1930
|
+
|
|
1931
|
+
return new Response(null, {
|
|
1932
|
+
status: 302,
|
|
1933
|
+
headers: {
|
|
1934
|
+
Location: `https://github.com/login/oauth/authorize?${params.toString()}`,
|
|
1935
|
+
},
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
async function handleAuthCallback(url: URL): Promise<Response> {
|
|
1940
|
+
const code = url.searchParams.get("code");
|
|
1941
|
+
if (!code) {
|
|
1942
|
+
return new Response("Missing code parameter", { status: 400 });
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// Exchange code for access token
|
|
1946
|
+
const accessToken = await exchangeCodeForToken(code);
|
|
1947
|
+
if (!accessToken) {
|
|
1948
|
+
return new Response("Failed to exchange code for token", { status: 500 });
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// Get GitHub user info
|
|
1952
|
+
const user = await getGitHubUser(accessToken);
|
|
1953
|
+
if (!user) {
|
|
1954
|
+
return new Response("Failed to get user info", { status: 500 });
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// Check repo permission
|
|
1958
|
+
const permission = await checkRepoPermission(accessToken, user.login);
|
|
1959
|
+
const isAdmin = !!permission && ALLOWED_PERMISSIONS.includes(permission);
|
|
1960
|
+
|
|
1961
|
+
if (!isAdmin) {
|
|
1962
|
+
return new Response(accessDeniedHTML(DASHBOARD_REPO), {
|
|
1963
|
+
status: 403,
|
|
1964
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// Create session and redirect to dashboard
|
|
1969
|
+
const sessionId = createSession({
|
|
1970
|
+
githubUser: user.login,
|
|
1971
|
+
accessToken,
|
|
1972
|
+
avatarUrl: user.avatar_url,
|
|
1973
|
+
isAdmin,
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
return new Response(null, {
|
|
1977
|
+
status: 302,
|
|
1978
|
+
headers: {
|
|
1979
|
+
Location: "/",
|
|
1980
|
+
"Set-Cookie": sessionCookie(sessionId),
|
|
1981
|
+
},
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
function handleAuthLogout(req: Request): Response {
|
|
1986
|
+
// Remove session from store
|
|
1987
|
+
const cookie = req.headers.get("cookie");
|
|
1988
|
+
if (cookie) {
|
|
1989
|
+
const match = cookie.match(/fleet_session=([^;]+)/);
|
|
1990
|
+
if (match) {
|
|
1991
|
+
sessions.delete(match[1]);
|
|
1992
|
+
saveSessions();
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
return new Response(null, {
|
|
1997
|
+
status: 302,
|
|
1998
|
+
headers: {
|
|
1999
|
+
Location: "/login",
|
|
2000
|
+
"Set-Cookie": clearSessionCookie(),
|
|
2001
|
+
},
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// ─── PAT-based login handler ─────────────────────────────────────────
|
|
2006
|
+
async function handlePatLogin(req: Request): Promise<Response> {
|
|
2007
|
+
// Parse form body
|
|
2008
|
+
const formData = await req.formData();
|
|
2009
|
+
const username = ((formData.get("username") as string) || "").trim();
|
|
2010
|
+
|
|
2011
|
+
if (!username) {
|
|
2012
|
+
return new Response(loginPageHTML("Please enter a GitHub username"), {
|
|
2013
|
+
status: 400,
|
|
2014
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// Use the PAT to check this user's permission on the target repo
|
|
2019
|
+
const permission = await checkRepoPermission(GITHUB_PAT, username);
|
|
2020
|
+
const isAdmin = !!permission && ALLOWED_PERMISSIONS.includes(permission);
|
|
2021
|
+
|
|
2022
|
+
if (!isAdmin) {
|
|
2023
|
+
return new Response(accessDeniedHTML(DASHBOARD_REPO), {
|
|
2024
|
+
status: 403,
|
|
2025
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// Fetch their avatar URL for the dashboard
|
|
2030
|
+
let avatarUrl = "";
|
|
2031
|
+
try {
|
|
2032
|
+
const resp = await fetch(`https://api.github.com/users/${username}`, {
|
|
2033
|
+
headers: {
|
|
2034
|
+
Authorization: `Bearer ${GITHUB_PAT}`,
|
|
2035
|
+
Accept: "application/vnd.github+json",
|
|
2036
|
+
"User-Agent": "Shipwright-Fleet-Command",
|
|
2037
|
+
},
|
|
2038
|
+
});
|
|
2039
|
+
if (resp.ok) {
|
|
2040
|
+
const data = (await resp.json()) as {
|
|
2041
|
+
avatar_url?: string;
|
|
2042
|
+
name?: string;
|
|
2043
|
+
};
|
|
2044
|
+
avatarUrl = data.avatar_url || "";
|
|
2045
|
+
}
|
|
2046
|
+
} catch {
|
|
2047
|
+
// Non-critical — proceed without avatar
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const sessionId = createSession({
|
|
2051
|
+
githubUser: username,
|
|
2052
|
+
accessToken: "", // PAT mode doesn't give per-user tokens
|
|
2053
|
+
avatarUrl,
|
|
2054
|
+
isAdmin,
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
return new Response(null, {
|
|
2058
|
+
status: 302,
|
|
2059
|
+
headers: {
|
|
2060
|
+
Location: "/",
|
|
2061
|
+
"Set-Cookie": sessionCookie(sessionId),
|
|
2062
|
+
},
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// ─── HTTP + WebSocket server ─────────────────────────────────────────
|
|
2067
|
+
const CORS_HEADERS = {
|
|
2068
|
+
"Access-Control-Allow-Origin": "*",
|
|
2069
|
+
"Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
|
|
2070
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
2071
|
+
};
|
|
2072
|
+
|
|
2073
|
+
const server = Bun.serve({
|
|
2074
|
+
port: PORT,
|
|
2075
|
+
|
|
2076
|
+
async fetch(req, server) {
|
|
2077
|
+
const url = new URL(req.url);
|
|
2078
|
+
const pathname = url.pathname;
|
|
2079
|
+
|
|
2080
|
+
// CORS preflight
|
|
2081
|
+
if (req.method === "OPTIONS") {
|
|
2082
|
+
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// ── Public routes (no auth required) ──────────────────────────
|
|
2086
|
+
|
|
2087
|
+
// Health check — always public
|
|
2088
|
+
if (pathname === "/api/health") {
|
|
2089
|
+
const health: HealthResponse = {
|
|
2090
|
+
status: "ok",
|
|
2091
|
+
uptime_s: Math.floor((Date.now() - startTime) / 1000),
|
|
2092
|
+
connections: wsClients.size,
|
|
2093
|
+
};
|
|
2094
|
+
return new Response(JSON.stringify(health), {
|
|
2095
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
// GET /api/join/{token} — Serve join script (public, no auth required)
|
|
2100
|
+
if (pathname.startsWith("/api/join/") && req.method === "GET") {
|
|
2101
|
+
const token = pathname.split("/")[3] || "";
|
|
2102
|
+
if (!token) {
|
|
2103
|
+
return new Response("Missing token", { status: 400 });
|
|
2104
|
+
}
|
|
2105
|
+
try {
|
|
2106
|
+
let tokens: Array<Record<string, unknown>> = [];
|
|
2107
|
+
try {
|
|
2108
|
+
if (existsSync(JOIN_TOKENS_FILE)) {
|
|
2109
|
+
tokens = JSON.parse(readFileSync(JOIN_TOKENS_FILE, "utf-8"));
|
|
2110
|
+
}
|
|
2111
|
+
} catch {
|
|
2112
|
+
tokens = [];
|
|
2113
|
+
}
|
|
2114
|
+
const entry = tokens.find((t) => (t.token as string) === token);
|
|
2115
|
+
if (!entry) {
|
|
2116
|
+
return new Response(
|
|
2117
|
+
"#!/usr/bin/env bash\necho 'ERROR: Invalid or expired join token'\nexit 1\n",
|
|
2118
|
+
{
|
|
2119
|
+
headers: { "Content-Type": "text/plain", ...CORS_HEADERS },
|
|
2120
|
+
},
|
|
2121
|
+
);
|
|
2122
|
+
}
|
|
2123
|
+
const dashboardUrl = `${url.protocol}//${url.host}`;
|
|
2124
|
+
const maxWorkers = (entry.max_workers as number) || 4;
|
|
2125
|
+
const script = generateJoinScript(token, dashboardUrl, maxWorkers);
|
|
2126
|
+
|
|
2127
|
+
// Mark token as used
|
|
2128
|
+
entry.used = true;
|
|
2129
|
+
entry.used_at = new Date().toISOString();
|
|
2130
|
+
const dir = join(HOME, ".shipwright");
|
|
2131
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
2132
|
+
const tokensTmp = JOIN_TOKENS_FILE + ".tmp";
|
|
2133
|
+
writeFileSync(tokensTmp, JSON.stringify(tokens, null, 2), "utf-8");
|
|
2134
|
+
renameSync(tokensTmp, JOIN_TOKENS_FILE);
|
|
2135
|
+
|
|
2136
|
+
return new Response(script, {
|
|
2137
|
+
headers: { "Content-Type": "text/plain", ...CORS_HEADERS },
|
|
2138
|
+
});
|
|
2139
|
+
} catch (err) {
|
|
2140
|
+
return new Response(
|
|
2141
|
+
`#!/usr/bin/env bash\necho 'ERROR: ${String(err)}'\nexit 1\n`,
|
|
2142
|
+
{
|
|
2143
|
+
status: 500,
|
|
2144
|
+
headers: { "Content-Type": "text/plain", ...CORS_HEADERS },
|
|
2145
|
+
},
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// Auth routes
|
|
2151
|
+
if (pathname === "/login") {
|
|
2152
|
+
if (!isAuthEnabled()) {
|
|
2153
|
+
// No auth configured — serve dashboard directly
|
|
2154
|
+
const staticResponse = serveStaticFile("/");
|
|
2155
|
+
if (staticResponse) return staticResponse;
|
|
2156
|
+
return new Response("Dashboard not found", { status: 404 });
|
|
2157
|
+
}
|
|
2158
|
+
return new Response(loginPageHTML(), {
|
|
2159
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
if (pathname === "/auth/github") {
|
|
2164
|
+
if (getAuthMode() !== "oauth") {
|
|
2165
|
+
return new Response("OAuth not configured", { status: 500 });
|
|
2166
|
+
}
|
|
2167
|
+
return handleAuthGitHub();
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
if (pathname === "/auth/callback") {
|
|
2171
|
+
if (getAuthMode() !== "oauth") {
|
|
2172
|
+
return new Response("OAuth not configured", { status: 500 });
|
|
2173
|
+
}
|
|
2174
|
+
return handleAuthCallback(url);
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
if (pathname === "/auth/pat-login" && req.method === "POST") {
|
|
2178
|
+
if (getAuthMode() !== "pat") {
|
|
2179
|
+
return new Response("PAT auth not configured", { status: 500 });
|
|
2180
|
+
}
|
|
2181
|
+
return handlePatLogin(req);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
if (pathname === "/auth/logout") {
|
|
2185
|
+
return handleAuthLogout(req);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
// ── Auth gate ─────────────────────────────────────────────────
|
|
2189
|
+
// If auth is enabled, enforce it on all remaining routes
|
|
2190
|
+
if (isAuthEnabled()) {
|
|
2191
|
+
const session = getSession(req);
|
|
2192
|
+
if (!session) {
|
|
2193
|
+
// WebSocket upgrade attempt without auth
|
|
2194
|
+
if (pathname === "/ws") {
|
|
2195
|
+
return new Response("Unauthorized", { status: 401 });
|
|
2196
|
+
}
|
|
2197
|
+
return new Response(null, {
|
|
2198
|
+
status: 302,
|
|
2199
|
+
headers: { Location: "/login" },
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// ── Protected routes ──────────────────────────────────────────
|
|
2205
|
+
|
|
2206
|
+
// WebSocket upgrade
|
|
2207
|
+
if (pathname === "/ws") {
|
|
2208
|
+
const upgraded = server.upgrade(req);
|
|
2209
|
+
if (upgraded) return undefined as unknown as Response;
|
|
2210
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
// REST: fleet state
|
|
2214
|
+
if (pathname === "/api/status") {
|
|
2215
|
+
return new Response(JSON.stringify(getFleetState()), {
|
|
2216
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// REST: pipeline detail for a specific issue
|
|
2221
|
+
if (pathname.startsWith("/api/pipeline/")) {
|
|
2222
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
2223
|
+
if (!issueNum || isNaN(issueNum)) {
|
|
2224
|
+
return new Response(JSON.stringify({ error: "Invalid issue number" }), {
|
|
2225
|
+
status: 400,
|
|
2226
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2227
|
+
});
|
|
2228
|
+
}
|
|
2229
|
+
return new Response(JSON.stringify(getPipelineDetail(issueNum)), {
|
|
2230
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// REST: historical metrics aggregated from events.jsonl
|
|
2235
|
+
if (pathname === "/api/metrics/history") {
|
|
2236
|
+
const period = parseInt(url.searchParams.get("period") || "7");
|
|
2237
|
+
const doraPeriod = period > 0 && period <= 365 ? period : 7;
|
|
2238
|
+
return new Response(JSON.stringify(getMetricsHistory(doraPeriod)), {
|
|
2239
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// REST: plan markdown for a specific issue
|
|
2244
|
+
if (pathname.startsWith("/api/plans/")) {
|
|
2245
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
2246
|
+
if (!issueNum || isNaN(issueNum)) {
|
|
2247
|
+
return new Response(JSON.stringify({ error: "Invalid issue number" }), {
|
|
2248
|
+
status: 400,
|
|
2249
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
return new Response(JSON.stringify(getPlanContent(issueNum)), {
|
|
2253
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// REST: enhanced activity feed with pagination and filtering
|
|
2258
|
+
if (pathname === "/api/activity") {
|
|
2259
|
+
const limit = Math.min(
|
|
2260
|
+
parseInt(url.searchParams.get("limit") || "50"),
|
|
2261
|
+
200,
|
|
2262
|
+
);
|
|
2263
|
+
const offset = parseInt(url.searchParams.get("offset") || "0");
|
|
2264
|
+
const typeFilter = url.searchParams.get("type") || "all";
|
|
2265
|
+
const issueFilter = url.searchParams.get("issue") || "";
|
|
2266
|
+
return new Response(
|
|
2267
|
+
JSON.stringify(getActivityFeed(limit, offset, typeFilter, issueFilter)),
|
|
2268
|
+
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// REST: Agent heartbeats
|
|
2273
|
+
if (pathname === "/api/agents") {
|
|
2274
|
+
return new Response(JSON.stringify(getAgents()), {
|
|
2275
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// REST: Timeline (Gantt data)
|
|
2280
|
+
if (pathname === "/api/timeline") {
|
|
2281
|
+
const rangeParam = url.searchParams.get("range") || "24h";
|
|
2282
|
+
const hours = parseInt(rangeParam) || 24;
|
|
2283
|
+
return new Response(JSON.stringify(getTimeline(hours)), {
|
|
2284
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// REST: Bulk intervention (must be before generic /api/intervention/)
|
|
2289
|
+
if (pathname === "/api/intervention/bulk" && req.method === "POST") {
|
|
2290
|
+
try {
|
|
2291
|
+
const body = (await req.json()) as {
|
|
2292
|
+
issues: number[];
|
|
2293
|
+
action: string;
|
|
2294
|
+
};
|
|
2295
|
+
const { issues, action } = body;
|
|
2296
|
+
if (!Array.isArray(issues) || !action) {
|
|
2297
|
+
return new Response(
|
|
2298
|
+
JSON.stringify({ error: "Missing issues array or action" }),
|
|
2299
|
+
{
|
|
2300
|
+
status: 400,
|
|
2301
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2302
|
+
},
|
|
2303
|
+
);
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
const dState = readDaemonState();
|
|
2307
|
+
const activeJobsList = dState
|
|
2308
|
+
? (dState.active_jobs as Array<Record<string, unknown>>) || []
|
|
2309
|
+
: [];
|
|
2310
|
+
|
|
2311
|
+
const results: Array<{ issue: number; ok: boolean; error?: string }> =
|
|
2312
|
+
[];
|
|
2313
|
+
for (const issueNum of issues) {
|
|
2314
|
+
try {
|
|
2315
|
+
let pid: number | null = null;
|
|
2316
|
+
for (const job of activeJobsList) {
|
|
2317
|
+
if ((job.issue as number) === issueNum) {
|
|
2318
|
+
pid = (job.pid as number) || null;
|
|
2319
|
+
break;
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
if (!pid) {
|
|
2323
|
+
results.push({
|
|
2324
|
+
issue: issueNum,
|
|
2325
|
+
ok: false,
|
|
2326
|
+
error: "No active PID found",
|
|
2327
|
+
});
|
|
2328
|
+
continue;
|
|
2329
|
+
}
|
|
2330
|
+
switch (action) {
|
|
2331
|
+
case "pause":
|
|
2332
|
+
execSync(`kill -STOP ${pid}`);
|
|
2333
|
+
break;
|
|
2334
|
+
case "resume":
|
|
2335
|
+
execSync(`kill -CONT ${pid}`);
|
|
2336
|
+
break;
|
|
2337
|
+
case "abort":
|
|
2338
|
+
execSync(`kill -TERM ${pid}`);
|
|
2339
|
+
break;
|
|
2340
|
+
default:
|
|
2341
|
+
results.push({
|
|
2342
|
+
issue: issueNum,
|
|
2343
|
+
ok: false,
|
|
2344
|
+
error: `Unknown action: ${action}`,
|
|
2345
|
+
});
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
results.push({ issue: issueNum, ok: true });
|
|
2349
|
+
} catch (err) {
|
|
2350
|
+
results.push({
|
|
2351
|
+
issue: issueNum,
|
|
2352
|
+
ok: false,
|
|
2353
|
+
error: String(err),
|
|
2354
|
+
});
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
return new Response(JSON.stringify({ results }), {
|
|
2359
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2360
|
+
});
|
|
2361
|
+
} catch (err) {
|
|
2362
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
2363
|
+
status: 500,
|
|
2364
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
// REST: Intervention actions
|
|
2370
|
+
if (pathname.startsWith("/api/intervention/") && req.method === "POST") {
|
|
2371
|
+
const parts = pathname.split("/");
|
|
2372
|
+
const issueNum = parseInt(parts[3]);
|
|
2373
|
+
const action = parts[4]; // pause, resume, abort, message, skip
|
|
2374
|
+
if (!issueNum || !action) {
|
|
2375
|
+
return new Response(JSON.stringify({ error: "Invalid intervention" }), {
|
|
2376
|
+
status: 400,
|
|
2377
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// Find PID from daemon state
|
|
2382
|
+
const daemonState = readDaemonState();
|
|
2383
|
+
let pid: number | null = null;
|
|
2384
|
+
let worktreeBase: string | null = null;
|
|
2385
|
+
if (daemonState) {
|
|
2386
|
+
const activeJobs =
|
|
2387
|
+
(daemonState.active_jobs as Array<Record<string, unknown>>) || [];
|
|
2388
|
+
for (const job of activeJobs) {
|
|
2389
|
+
if ((job.issue as number) === issueNum) {
|
|
2390
|
+
pid = (job.pid as number) || null;
|
|
2391
|
+
const wt = (job.worktree as string) || "";
|
|
2392
|
+
const repo = (job.repo as string) || "";
|
|
2393
|
+
if (wt && repo) worktreeBase = join(repo, wt);
|
|
2394
|
+
break;
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
try {
|
|
2400
|
+
switch (action) {
|
|
2401
|
+
case "pause":
|
|
2402
|
+
if (pid) execSync(`kill -STOP ${pid}`);
|
|
2403
|
+
break;
|
|
2404
|
+
case "resume":
|
|
2405
|
+
if (pid) execSync(`kill -CONT ${pid}`);
|
|
2406
|
+
break;
|
|
2407
|
+
case "abort":
|
|
2408
|
+
if (pid) execSync(`kill -TERM ${pid}`);
|
|
2409
|
+
break;
|
|
2410
|
+
case "message": {
|
|
2411
|
+
const body = (await req.json()) as { message?: string };
|
|
2412
|
+
const msg = body.message || "";
|
|
2413
|
+
if (worktreeBase && msg) {
|
|
2414
|
+
const msgDir = join(
|
|
2415
|
+
worktreeBase,
|
|
2416
|
+
".claude",
|
|
2417
|
+
"pipeline-artifacts",
|
|
2418
|
+
);
|
|
2419
|
+
mkdirSync(msgDir, { recursive: true });
|
|
2420
|
+
const tmpFile = join(msgDir, "human-message.txt.tmp");
|
|
2421
|
+
const msgFile = join(msgDir, "human-message.txt");
|
|
2422
|
+
writeFileSync(tmpFile, msg, "utf-8");
|
|
2423
|
+
renameSync(tmpFile, msgFile);
|
|
2424
|
+
}
|
|
2425
|
+
break;
|
|
2426
|
+
}
|
|
2427
|
+
case "skip": {
|
|
2428
|
+
if (worktreeBase) {
|
|
2429
|
+
const artDir = join(
|
|
2430
|
+
worktreeBase,
|
|
2431
|
+
".claude",
|
|
2432
|
+
"pipeline-artifacts",
|
|
2433
|
+
);
|
|
2434
|
+
mkdirSync(artDir, { recursive: true });
|
|
2435
|
+
const tmpFile = join(artDir, "skip-stage.txt.tmp");
|
|
2436
|
+
const skipFile = join(artDir, "skip-stage.txt");
|
|
2437
|
+
writeFileSync(tmpFile, "skip", "utf-8");
|
|
2438
|
+
renameSync(tmpFile, skipFile);
|
|
2439
|
+
}
|
|
2440
|
+
break;
|
|
2441
|
+
}
|
|
2442
|
+
default:
|
|
2443
|
+
return new Response(JSON.stringify({ error: "Unknown action" }), {
|
|
2444
|
+
status: 400,
|
|
2445
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
return new Response(
|
|
2449
|
+
JSON.stringify({ ok: true, action, issue: issueNum }),
|
|
2450
|
+
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
2451
|
+
);
|
|
2452
|
+
} catch (err) {
|
|
2453
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
2454
|
+
status: 500,
|
|
2455
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// REST: Linear integration status
|
|
2461
|
+
if (pathname === "/api/linear/status") {
|
|
2462
|
+
return new Response(JSON.stringify(getLinearStatus()), {
|
|
2463
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// REST: current user info
|
|
2468
|
+
if (pathname === "/api/me") {
|
|
2469
|
+
if (!isAuthEnabled()) {
|
|
2470
|
+
return new Response(
|
|
2471
|
+
JSON.stringify({ username: "local", avatarUrl: "", isAdmin: true }),
|
|
2472
|
+
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
2473
|
+
);
|
|
2474
|
+
}
|
|
2475
|
+
const session = getSession(req);
|
|
2476
|
+
if (!session) {
|
|
2477
|
+
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
|
2478
|
+
status: 401,
|
|
2479
|
+
headers: { "Content-Type": "application/json" },
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
return new Response(
|
|
2483
|
+
JSON.stringify({
|
|
2484
|
+
username: session.githubUser,
|
|
2485
|
+
avatarUrl: session.avatarUrl,
|
|
2486
|
+
isAdmin: session.isAdmin,
|
|
2487
|
+
}),
|
|
2488
|
+
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
// ── Phase 1: Pipeline Deep-Dive endpoints ─────────────────────
|
|
2493
|
+
|
|
2494
|
+
// REST: Pipeline build logs
|
|
2495
|
+
if (pathname.startsWith("/api/logs/")) {
|
|
2496
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
2497
|
+
if (!issueNum || isNaN(issueNum)) {
|
|
2498
|
+
return new Response(JSON.stringify({ error: "Invalid issue number" }), {
|
|
2499
|
+
status: 400,
|
|
2500
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
const logFile = join(LOGS_DIR, `issue-${issueNum}.log`);
|
|
2504
|
+
const raw = readFileOr(logFile, "");
|
|
2505
|
+
return new Response(JSON.stringify({ content: stripAnsi(raw) }), {
|
|
2506
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// REST: Pipeline artifacts (plan, design, dod, test-results, review, coverage)
|
|
2511
|
+
if (pathname.startsWith("/api/artifacts/")) {
|
|
2512
|
+
const parts = pathname.split("/");
|
|
2513
|
+
const issueNum = parseInt(parts[3] || "0");
|
|
2514
|
+
const artifactType = parts[4] || "";
|
|
2515
|
+
if (!issueNum || isNaN(issueNum) || !artifactType) {
|
|
2516
|
+
return new Response(
|
|
2517
|
+
JSON.stringify({ error: "Invalid issue or artifact type" }),
|
|
2518
|
+
{
|
|
2519
|
+
status: 400,
|
|
2520
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2521
|
+
},
|
|
2522
|
+
);
|
|
2523
|
+
}
|
|
2524
|
+
const worktreeBase = findWorktreeBase(issueNum);
|
|
2525
|
+
let content = "";
|
|
2526
|
+
let fileType = "md";
|
|
2527
|
+
if (worktreeBase) {
|
|
2528
|
+
const artifactsDir = join(
|
|
2529
|
+
worktreeBase,
|
|
2530
|
+
".claude",
|
|
2531
|
+
"pipeline-artifacts",
|
|
2532
|
+
);
|
|
2533
|
+
// Try .md, .log, .json extensions
|
|
2534
|
+
for (const ext of [".md", ".log", ".json"]) {
|
|
2535
|
+
const filePath = join(artifactsDir, `${artifactType}${ext}`);
|
|
2536
|
+
if (existsSync(filePath)) {
|
|
2537
|
+
content = readFileOr(filePath, "");
|
|
2538
|
+
fileType = ext.slice(1);
|
|
2539
|
+
break;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
return new Response(JSON.stringify({ content, type: fileType }), {
|
|
2544
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// REST: GitHub issue + PR info
|
|
2549
|
+
if (pathname.startsWith("/api/github/")) {
|
|
2550
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
2551
|
+
if (!issueNum || isNaN(issueNum)) {
|
|
2552
|
+
return new Response(JSON.stringify({ error: "Invalid issue number" }), {
|
|
2553
|
+
status: 400,
|
|
2554
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
const data = ghCached(`github-${issueNum}`, () => {
|
|
2558
|
+
try {
|
|
2559
|
+
const issueRaw = execSync(
|
|
2560
|
+
`gh issue view ${issueNum} --json title,state,labels,assignees,url`,
|
|
2561
|
+
{ encoding: "utf-8", timeout: 10000 },
|
|
2562
|
+
);
|
|
2563
|
+
const prRaw = execSync(
|
|
2564
|
+
`gh pr list --search "issue-${issueNum}" --json number,state,url,statusCheckRollup,reviews`,
|
|
2565
|
+
{ encoding: "utf-8", timeout: 10000 },
|
|
2566
|
+
);
|
|
2567
|
+
const issue = JSON.parse(issueRaw);
|
|
2568
|
+
const prs = JSON.parse(prRaw) as Array<Record<string, unknown>>;
|
|
2569
|
+
const pr = prs.length > 0 ? prs[0] : null;
|
|
2570
|
+
const checks = pr
|
|
2571
|
+
? (
|
|
2572
|
+
(pr.statusCheckRollup as Array<Record<string, string>>) || []
|
|
2573
|
+
).map((c) => ({
|
|
2574
|
+
name: c.name || c.context || "",
|
|
2575
|
+
status: (c.conclusion || c.state || "pending").toLowerCase(),
|
|
2576
|
+
}))
|
|
2577
|
+
: [];
|
|
2578
|
+
return {
|
|
2579
|
+
configured: true,
|
|
2580
|
+
issue_title: issue.title || "",
|
|
2581
|
+
issue_state: (issue.state || "").toLowerCase(),
|
|
2582
|
+
issue_url: issue.url || "",
|
|
2583
|
+
pr_number: pr ? (pr.number as number) : null,
|
|
2584
|
+
pr_state: pr ? ((pr.state as string) || "").toLowerCase() : null,
|
|
2585
|
+
pr_url: pr ? (pr.url as string) || "" : null,
|
|
2586
|
+
checks,
|
|
2587
|
+
};
|
|
2588
|
+
} catch {
|
|
2589
|
+
return { configured: false };
|
|
2590
|
+
}
|
|
2591
|
+
});
|
|
2592
|
+
return new Response(JSON.stringify(data), {
|
|
2593
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
// REST: Events filtered by issue
|
|
2598
|
+
if (pathname.startsWith("/api/events/")) {
|
|
2599
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
2600
|
+
if (!issueNum || isNaN(issueNum)) {
|
|
2601
|
+
return new Response(JSON.stringify({ error: "Invalid issue number" }), {
|
|
2602
|
+
status: 400,
|
|
2603
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2604
|
+
});
|
|
2605
|
+
}
|
|
2606
|
+
const allEvents = readEvents();
|
|
2607
|
+
const filtered = allEvents.filter((e) => e.issue === issueNum);
|
|
2608
|
+
return new Response(JSON.stringify({ events: filtered }), {
|
|
2609
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
// REST: Memory failure patterns for a specific issue
|
|
2614
|
+
if (pathname.startsWith("/api/memory/failures/")) {
|
|
2615
|
+
const issueNum = parseInt(pathname.split("/")[4] || "0");
|
|
2616
|
+
if (!issueNum || isNaN(issueNum)) {
|
|
2617
|
+
return new Response(JSON.stringify({ error: "Invalid issue number" }), {
|
|
2618
|
+
status: 400,
|
|
2619
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
const allPatterns = readMemoryFiles("failures.json") as Array<
|
|
2623
|
+
Record<string, unknown>
|
|
2624
|
+
>;
|
|
2625
|
+
const matched = allPatterns.filter((p) => {
|
|
2626
|
+
const issues = (p.issues as number[]) || [];
|
|
2627
|
+
const issue = p.issue as number;
|
|
2628
|
+
return issues.includes(issueNum) || issue === issueNum;
|
|
2629
|
+
});
|
|
2630
|
+
return new Response(JSON.stringify({ patterns: matched }), {
|
|
2631
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2632
|
+
});
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// ── Phase 2: Queue Intelligence + Cost Analytics ─────────────
|
|
2636
|
+
|
|
2637
|
+
// REST: Detailed queue with triage scores
|
|
2638
|
+
if (pathname === "/api/queue/detailed") {
|
|
2639
|
+
const daemonState = readDaemonState();
|
|
2640
|
+
const events = readEvents();
|
|
2641
|
+
const queued = daemonState
|
|
2642
|
+
? (daemonState.queued as Array<number | Record<string, unknown>>) || []
|
|
2643
|
+
: [];
|
|
2644
|
+
|
|
2645
|
+
// Build triage score map from most recent daemon.triage events
|
|
2646
|
+
const triageMap: Record<number, Record<string, unknown>> = {};
|
|
2647
|
+
for (const e of events) {
|
|
2648
|
+
if (e.type === "daemon.triage" && e.issue) {
|
|
2649
|
+
triageMap[e.issue] = {
|
|
2650
|
+
complexity: e.complexity,
|
|
2651
|
+
impact: e.impact,
|
|
2652
|
+
priority: e.priority,
|
|
2653
|
+
age: e.age,
|
|
2654
|
+
dependency: e.dependency,
|
|
2655
|
+
memory: e.memory,
|
|
2656
|
+
score: e.score,
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
const enriched = queued.map((q) => {
|
|
2662
|
+
const issue = typeof q === "number" ? q : (q.issue as number) || 0;
|
|
2663
|
+
const title = typeof q === "number" ? "" : (q.title as string) || "";
|
|
2664
|
+
const score = typeof q === "number" ? 0 : (q.score as number) || 0;
|
|
2665
|
+
const triage = triageMap[issue] || {};
|
|
2666
|
+
return { issue, title, score, ...triage };
|
|
2667
|
+
});
|
|
2668
|
+
|
|
2669
|
+
return new Response(JSON.stringify({ queue: enriched }), {
|
|
2670
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// REST: Cost breakdown by stage, model, issue
|
|
2675
|
+
if (pathname === "/api/costs/breakdown") {
|
|
2676
|
+
const period = parseInt(url.searchParams.get("period") || "7");
|
|
2677
|
+
const events = readEvents();
|
|
2678
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2679
|
+
const cutoff = now - period * 86400;
|
|
2680
|
+
|
|
2681
|
+
const byStage: Record<string, number> = {};
|
|
2682
|
+
const byModel: Record<string, number> = {};
|
|
2683
|
+
const byIssue: Record<number, number> = {};
|
|
2684
|
+
let total = 0;
|
|
2685
|
+
|
|
2686
|
+
for (const e of events) {
|
|
2687
|
+
if ((e.ts_epoch || 0) < cutoff) continue;
|
|
2688
|
+
if (e.type !== "pipeline.cost" && e.type !== "cost.record") continue;
|
|
2689
|
+
|
|
2690
|
+
let cost = (e.cost_usd as number) || 0;
|
|
2691
|
+
if (!cost) {
|
|
2692
|
+
// Calculate from tokens if cost not directly recorded
|
|
2693
|
+
const inputTokens = (e.input_tokens as number) || 0;
|
|
2694
|
+
const outputTokens = (e.output_tokens as number) || 0;
|
|
2695
|
+
const model = ((e.model as string) || "sonnet").toLowerCase();
|
|
2696
|
+
const pricing = MODEL_PRICING[model] || MODEL_PRICING["sonnet"];
|
|
2697
|
+
cost =
|
|
2698
|
+
(inputTokens / 1_000_000) * pricing.input +
|
|
2699
|
+
(outputTokens / 1_000_000) * pricing.output;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
if (cost <= 0) continue;
|
|
2703
|
+
total += cost;
|
|
2704
|
+
|
|
2705
|
+
const stage = (e.stage as string) || "unknown";
|
|
2706
|
+
byStage[stage] = (byStage[stage] || 0) + cost;
|
|
2707
|
+
|
|
2708
|
+
const model = (e.model as string) || "unknown";
|
|
2709
|
+
byModel[model] = (byModel[model] || 0) + cost;
|
|
2710
|
+
|
|
2711
|
+
if (e.issue) {
|
|
2712
|
+
byIssue[e.issue] = (byIssue[e.issue] || 0) + cost;
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
// Round all values
|
|
2717
|
+
for (const k of Object.keys(byStage))
|
|
2718
|
+
byStage[k] = Math.round(byStage[k] * 100) / 100;
|
|
2719
|
+
for (const k of Object.keys(byModel))
|
|
2720
|
+
byModel[k] = Math.round(byModel[k] * 100) / 100;
|
|
2721
|
+
for (const k of Object.keys(byIssue))
|
|
2722
|
+
byIssue[parseInt(k)] = Math.round(byIssue[parseInt(k)] * 100) / 100;
|
|
2723
|
+
|
|
2724
|
+
return new Response(
|
|
2725
|
+
JSON.stringify({
|
|
2726
|
+
byStage,
|
|
2727
|
+
byModel,
|
|
2728
|
+
byIssue,
|
|
2729
|
+
total: Math.round(total * 100) / 100,
|
|
2730
|
+
}),
|
|
2731
|
+
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// REST: Cost trend (daily aggregation)
|
|
2736
|
+
if (pathname === "/api/costs/trend") {
|
|
2737
|
+
const period = parseInt(url.searchParams.get("period") || "30");
|
|
2738
|
+
const events = readEvents();
|
|
2739
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2740
|
+
const cutoff = now - period * 86400;
|
|
2741
|
+
|
|
2742
|
+
const dailyMap: Record<string, number> = {};
|
|
2743
|
+
// Initialize all days
|
|
2744
|
+
for (let i = period - 1; i >= 0; i--) {
|
|
2745
|
+
const d = new Date((now - i * 86400) * 1000);
|
|
2746
|
+
dailyMap[d.toISOString().split("T")[0]] = 0;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
for (const e of events) {
|
|
2750
|
+
if ((e.ts_epoch || 0) < cutoff) continue;
|
|
2751
|
+
if (e.type !== "pipeline.cost" && e.type !== "cost.record") continue;
|
|
2752
|
+
let cost = (e.cost_usd as number) || 0;
|
|
2753
|
+
if (!cost) {
|
|
2754
|
+
const inputTokens = (e.input_tokens as number) || 0;
|
|
2755
|
+
const outputTokens = (e.output_tokens as number) || 0;
|
|
2756
|
+
const model = ((e.model as string) || "sonnet").toLowerCase();
|
|
2757
|
+
const pricing = MODEL_PRICING[model] || MODEL_PRICING["sonnet"];
|
|
2758
|
+
cost =
|
|
2759
|
+
(inputTokens / 1_000_000) * pricing.input +
|
|
2760
|
+
(outputTokens / 1_000_000) * pricing.output;
|
|
2761
|
+
}
|
|
2762
|
+
if (cost <= 0) continue;
|
|
2763
|
+
const dateKey = (e.ts || "").split("T")[0];
|
|
2764
|
+
if (dateKey in dailyMap) {
|
|
2765
|
+
dailyMap[dateKey] += cost;
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
const daily = Object.entries(dailyMap)
|
|
2770
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
2771
|
+
.map(([date, cost]) => ({ date, cost: Math.round(cost * 100) / 100 }));
|
|
2772
|
+
|
|
2773
|
+
return new Response(JSON.stringify({ daily }), {
|
|
2774
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// REST: DORA trend (weekly sliding windows)
|
|
2779
|
+
if (pathname === "/api/metrics/dora-trend") {
|
|
2780
|
+
const period = parseInt(url.searchParams.get("period") || "30");
|
|
2781
|
+
const events = readEvents();
|
|
2782
|
+
const weeks: Array<{ week: string; grades: DoraGrades }> = [];
|
|
2783
|
+
|
|
2784
|
+
// Create weekly windows
|
|
2785
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2786
|
+
const numWeeks = Math.ceil(period / 7);
|
|
2787
|
+
for (let i = numWeeks - 1; i >= 0; i--) {
|
|
2788
|
+
const weekEnd = now - i * 7 * 86400;
|
|
2789
|
+
const weekStart = weekEnd - 7 * 86400;
|
|
2790
|
+
const weekEvents = events.filter(
|
|
2791
|
+
(e) => (e.ts_epoch || 0) >= weekStart && (e.ts_epoch || 0) < weekEnd,
|
|
2792
|
+
);
|
|
2793
|
+
const weekDate = new Date(weekEnd * 1000).toISOString().split("T")[0];
|
|
2794
|
+
weeks.push({
|
|
2795
|
+
week: weekDate,
|
|
2796
|
+
grades: calculateDoraGrades(weekEvents, 7),
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
return new Response(JSON.stringify({ weeks }), {
|
|
2801
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
// ── Phase 3: Memory + Patrol + Failure Heatmap ──────────────
|
|
2806
|
+
|
|
2807
|
+
// REST: All memory failure patterns aggregated
|
|
2808
|
+
if (pathname === "/api/memory/patterns") {
|
|
2809
|
+
const allPatterns = readMemoryFiles("failures.json") as Array<
|
|
2810
|
+
Record<string, unknown>
|
|
2811
|
+
>;
|
|
2812
|
+
// Aggregate by pattern signature (error message or pattern field)
|
|
2813
|
+
const freqMap: Record<
|
|
2814
|
+
string,
|
|
2815
|
+
{ pattern: string; frequency: number; rootCause: string; fix: string }
|
|
2816
|
+
> = {};
|
|
2817
|
+
for (const p of allPatterns) {
|
|
2818
|
+
const key = (p.pattern as string) || (p.error as string) || "unknown";
|
|
2819
|
+
if (!freqMap[key]) {
|
|
2820
|
+
freqMap[key] = {
|
|
2821
|
+
pattern: key,
|
|
2822
|
+
frequency: 0,
|
|
2823
|
+
rootCause:
|
|
2824
|
+
(p.root_cause as string) || (p.rootCause as string) || "",
|
|
2825
|
+
fix: (p.fix as string) || (p.resolution as string) || "",
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
freqMap[key].frequency++;
|
|
2829
|
+
}
|
|
2830
|
+
const patterns = Object.values(freqMap).sort(
|
|
2831
|
+
(a, b) => b.frequency - a.frequency,
|
|
2832
|
+
);
|
|
2833
|
+
return new Response(JSON.stringify({ patterns }), {
|
|
2834
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2835
|
+
});
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// REST: Memory decisions
|
|
2839
|
+
if (pathname === "/api/memory/decisions") {
|
|
2840
|
+
const decisions = readMemoryFiles("decisions.json");
|
|
2841
|
+
return new Response(JSON.stringify({ decisions }), {
|
|
2842
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
// REST: Global memory/learnings
|
|
2847
|
+
if (pathname === "/api/memory/global") {
|
|
2848
|
+
const globalPath = join(MEMORY_DIR, "global.json");
|
|
2849
|
+
let learnings: unknown[] = [];
|
|
2850
|
+
if (existsSync(globalPath)) {
|
|
2851
|
+
try {
|
|
2852
|
+
const data = JSON.parse(readFileSync(globalPath, "utf-8"));
|
|
2853
|
+
learnings = Array.isArray(data) ? data : data.learnings || [];
|
|
2854
|
+
} catch {
|
|
2855
|
+
// ignore
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
return new Response(JSON.stringify({ learnings }), {
|
|
2859
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// REST: Recent patrol findings
|
|
2864
|
+
if (pathname === "/api/patrol/recent") {
|
|
2865
|
+
const events = readEvents();
|
|
2866
|
+
const findings: DaemonEvent[] = [];
|
|
2867
|
+
const runs: DaemonEvent[] = [];
|
|
2868
|
+
for (const e of events) {
|
|
2869
|
+
if (e.type === "patrol.finding") findings.push(e);
|
|
2870
|
+
if (e.type === "patrol.completed") runs.push(e);
|
|
2871
|
+
}
|
|
2872
|
+
return new Response(
|
|
2873
|
+
JSON.stringify({
|
|
2874
|
+
findings: findings.slice(-50),
|
|
2875
|
+
runs: runs.slice(-20),
|
|
2876
|
+
}),
|
|
2877
|
+
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
2878
|
+
);
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
// REST: Failure heatmap (stage x day)
|
|
2882
|
+
if (pathname === "/api/metrics/failure-heatmap") {
|
|
2883
|
+
const events = readEvents();
|
|
2884
|
+
const heatmap: Record<string, Record<string, number>> = {};
|
|
2885
|
+
for (const e of events) {
|
|
2886
|
+
if (e.type !== "stage.failed") continue;
|
|
2887
|
+
const stage = e.stage || "unknown";
|
|
2888
|
+
const date = (e.ts || "").split("T")[0];
|
|
2889
|
+
if (!date) continue;
|
|
2890
|
+
if (!heatmap[stage]) heatmap[stage] = {};
|
|
2891
|
+
heatmap[stage][date] = (heatmap[stage][date] || 0) + 1;
|
|
2892
|
+
}
|
|
2893
|
+
return new Response(JSON.stringify({ heatmap }), {
|
|
2894
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2895
|
+
});
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// ── Phase 4: Performance Analytics ──────────────────────────
|
|
2899
|
+
|
|
2900
|
+
// REST: Per-stage performance stats
|
|
2901
|
+
if (pathname === "/api/metrics/stage-performance") {
|
|
2902
|
+
const period = parseInt(url.searchParams.get("period") || "7");
|
|
2903
|
+
const events = readEvents();
|
|
2904
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2905
|
+
const cutoff = now - period * 86400;
|
|
2906
|
+
const halfCutoff = now - Math.floor(period / 2) * 86400;
|
|
2907
|
+
|
|
2908
|
+
const stageData: Record<
|
|
2909
|
+
string,
|
|
2910
|
+
{
|
|
2911
|
+
durations: number[];
|
|
2912
|
+
costs: number[];
|
|
2913
|
+
firstHalf: number[];
|
|
2914
|
+
secondHalf: number[];
|
|
2915
|
+
}
|
|
2916
|
+
> = {};
|
|
2917
|
+
|
|
2918
|
+
for (const e of events) {
|
|
2919
|
+
if ((e.ts_epoch || 0) < cutoff) continue;
|
|
2920
|
+
if (e.type === "stage.completed" && e.stage) {
|
|
2921
|
+
const stage = e.stage;
|
|
2922
|
+
if (!stageData[stage]) {
|
|
2923
|
+
stageData[stage] = {
|
|
2924
|
+
durations: [],
|
|
2925
|
+
costs: [],
|
|
2926
|
+
firstHalf: [],
|
|
2927
|
+
secondHalf: [],
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
const dur = e.duration_s || 0;
|
|
2931
|
+
stageData[stage].durations.push(dur);
|
|
2932
|
+
if ((e.ts_epoch || 0) < halfCutoff) {
|
|
2933
|
+
stageData[stage].firstHalf.push(dur);
|
|
2934
|
+
} else {
|
|
2935
|
+
stageData[stage].secondHalf.push(dur);
|
|
2936
|
+
}
|
|
2937
|
+
const cost = (e.cost_usd as number) || 0;
|
|
2938
|
+
if (cost > 0) stageData[stage].costs.push(cost);
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
const stages = Object.entries(stageData).map(([name, data]) => {
|
|
2943
|
+
const sum = data.durations.reduce((a, b) => a + b, 0);
|
|
2944
|
+
const avg = data.durations.length > 0 ? sum / data.durations.length : 0;
|
|
2945
|
+
const firstAvg =
|
|
2946
|
+
data.firstHalf.length > 0
|
|
2947
|
+
? data.firstHalf.reduce((a, b) => a + b, 0) / data.firstHalf.length
|
|
2948
|
+
: avg;
|
|
2949
|
+
const secondAvg =
|
|
2950
|
+
data.secondHalf.length > 0
|
|
2951
|
+
? data.secondHalf.reduce((a, b) => a + b, 0) /
|
|
2952
|
+
data.secondHalf.length
|
|
2953
|
+
: avg;
|
|
2954
|
+
const trend =
|
|
2955
|
+
firstAvg > 0
|
|
2956
|
+
? Math.round(((secondAvg - firstAvg) / firstAvg) * 100)
|
|
2957
|
+
: 0;
|
|
2958
|
+
const costSum = data.costs.reduce((a, b) => a + b, 0);
|
|
2959
|
+
|
|
2960
|
+
return {
|
|
2961
|
+
name,
|
|
2962
|
+
avgDuration: Math.round(avg),
|
|
2963
|
+
minDuration: Math.min(...data.durations),
|
|
2964
|
+
maxDuration: Math.max(...data.durations),
|
|
2965
|
+
count: data.durations.length,
|
|
2966
|
+
cost: Math.round(costSum * 100) / 100,
|
|
2967
|
+
trend,
|
|
2968
|
+
};
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
return new Response(JSON.stringify({ stages }), {
|
|
2972
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
// REST: Bottleneck analysis
|
|
2977
|
+
if (pathname === "/api/metrics/bottlenecks") {
|
|
2978
|
+
const events = readEvents();
|
|
2979
|
+
const now = Math.floor(Date.now() / 1000);
|
|
2980
|
+
const cutoff = now - 7 * 86400;
|
|
2981
|
+
|
|
2982
|
+
const stageDurs: Record<string, number[]> = {};
|
|
2983
|
+
for (const e of events) {
|
|
2984
|
+
if ((e.ts_epoch || 0) < cutoff) continue;
|
|
2985
|
+
if (e.type === "stage.completed" && e.stage) {
|
|
2986
|
+
if (!stageDurs[e.stage]) stageDurs[e.stage] = [];
|
|
2987
|
+
stageDurs[e.stage].push(e.duration_s || 0);
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
const bottlenecks = Object.entries(stageDurs)
|
|
2992
|
+
.map(([stage, durs]) => {
|
|
2993
|
+
const avg = durs.reduce((a, b) => a + b, 0) / durs.length;
|
|
2994
|
+
return { stage, avgDuration: Math.round(avg), count: durs.length };
|
|
2995
|
+
})
|
|
2996
|
+
.sort((a, b) => b.avgDuration - a.avgDuration)
|
|
2997
|
+
.slice(0, 5)
|
|
2998
|
+
.map((b) => ({
|
|
2999
|
+
stage: b.stage,
|
|
3000
|
+
avgDuration: b.avgDuration,
|
|
3001
|
+
impact:
|
|
3002
|
+
b.avgDuration > 600
|
|
3003
|
+
? "high"
|
|
3004
|
+
: b.avgDuration > 300
|
|
3005
|
+
? "medium"
|
|
3006
|
+
: "low",
|
|
3007
|
+
suggestion:
|
|
3008
|
+
b.avgDuration > 600
|
|
3009
|
+
? `${b.stage} averages ${Math.round(b.avgDuration / 60)}min — consider parallelization or caching`
|
|
3010
|
+
: b.avgDuration > 300
|
|
3011
|
+
? `${b.stage} is moderately slow — review for optimization opportunities`
|
|
3012
|
+
: `${b.stage} is performing well`,
|
|
3013
|
+
}));
|
|
3014
|
+
|
|
3015
|
+
return new Response(JSON.stringify({ bottlenecks }), {
|
|
3016
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
// REST: Throughput trend (issues per hour, daily)
|
|
3021
|
+
if (pathname === "/api/metrics/throughput-trend") {
|
|
3022
|
+
const period = parseInt(url.searchParams.get("period") || "30");
|
|
3023
|
+
const events = readEvents();
|
|
3024
|
+
const now = Math.floor(Date.now() / 1000);
|
|
3025
|
+
const cutoff = now - period * 86400;
|
|
3026
|
+
|
|
3027
|
+
const dailyMap: Record<string, number> = {};
|
|
3028
|
+
for (let i = period - 1; i >= 0; i--) {
|
|
3029
|
+
const d = new Date((now - i * 86400) * 1000);
|
|
3030
|
+
dailyMap[d.toISOString().split("T")[0]] = 0;
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
for (const e of events) {
|
|
3034
|
+
if ((e.ts_epoch || 0) < cutoff) continue;
|
|
3035
|
+
if (e.type === "pipeline.completed" && e.result === "success") {
|
|
3036
|
+
const dateKey = (e.ts || "").split("T")[0];
|
|
3037
|
+
if (dateKey in dailyMap) dailyMap[dateKey]++;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
const daily = Object.entries(dailyMap)
|
|
3042
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
3043
|
+
.map(([date, count]) => ({
|
|
3044
|
+
date,
|
|
3045
|
+
throughput: Math.round((count / 24) * 100) / 100,
|
|
3046
|
+
}));
|
|
3047
|
+
|
|
3048
|
+
return new Response(JSON.stringify({ daily }), {
|
|
3049
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3050
|
+
});
|
|
3051
|
+
}
|
|
3052
|
+
|
|
3053
|
+
// REST: Capacity estimation
|
|
3054
|
+
if (pathname === "/api/metrics/capacity") {
|
|
3055
|
+
const daemonState = readDaemonState();
|
|
3056
|
+
const events = readEvents();
|
|
3057
|
+
const now = Math.floor(Date.now() / 1000);
|
|
3058
|
+
|
|
3059
|
+
// Queue depth
|
|
3060
|
+
const queued = daemonState
|
|
3061
|
+
? ((daemonState.queued as Array<unknown>) || []).length
|
|
3062
|
+
: 0;
|
|
3063
|
+
|
|
3064
|
+
// Calculate current rate (completions per hour in last 24h)
|
|
3065
|
+
const oneDayAgo = now - 86400;
|
|
3066
|
+
let completedLast24h = 0;
|
|
3067
|
+
for (const e of events) {
|
|
3068
|
+
if (
|
|
3069
|
+
e.type === "pipeline.completed" &&
|
|
3070
|
+
e.result === "success" &&
|
|
3071
|
+
(e.ts_epoch || 0) >= oneDayAgo
|
|
3072
|
+
) {
|
|
3073
|
+
completedLast24h++;
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
const currentRate = Math.round((completedLast24h / 24) * 100) / 100;
|
|
3077
|
+
const estimatedClearTime =
|
|
3078
|
+
currentRate > 0
|
|
3079
|
+
? `${Math.round(queued / currentRate)}h`
|
|
3080
|
+
: queued > 0
|
|
3081
|
+
? "unknown"
|
|
3082
|
+
: "0h";
|
|
3083
|
+
|
|
3084
|
+
return new Response(
|
|
3085
|
+
JSON.stringify({
|
|
3086
|
+
queueDepth: queued,
|
|
3087
|
+
currentRate,
|
|
3088
|
+
estimatedClearTime,
|
|
3089
|
+
}),
|
|
3090
|
+
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
3091
|
+
);
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// ── Phase 5: Alerts + Bulk Actions + Emergency ──────────────
|
|
3095
|
+
|
|
3096
|
+
// REST: Computed alerts
|
|
3097
|
+
if (pathname === "/api/alerts") {
|
|
3098
|
+
const events = readEvents();
|
|
3099
|
+
const daemonState = readDaemonState();
|
|
3100
|
+
const costInfo = getCostInfo();
|
|
3101
|
+
const now = Math.floor(Date.now() / 1000);
|
|
3102
|
+
const alerts: Array<{
|
|
3103
|
+
type: string;
|
|
3104
|
+
severity: string;
|
|
3105
|
+
message: string;
|
|
3106
|
+
issue?: number;
|
|
3107
|
+
actions?: string[];
|
|
3108
|
+
}> = [];
|
|
3109
|
+
|
|
3110
|
+
// Stuck pipelines: no stage change > 30min
|
|
3111
|
+
if (daemonState) {
|
|
3112
|
+
const activeJobs =
|
|
3113
|
+
(daemonState.active_jobs as Array<Record<string, unknown>>) || [];
|
|
3114
|
+
for (const job of activeJobs) {
|
|
3115
|
+
const issue = (job.issue as number) || 0;
|
|
3116
|
+
// Find most recent stage event for this issue
|
|
3117
|
+
let lastStageEpoch = 0;
|
|
3118
|
+
for (const e of events) {
|
|
3119
|
+
if (
|
|
3120
|
+
e.issue === issue &&
|
|
3121
|
+
(e.type === "stage.started" || e.type === "stage.completed")
|
|
3122
|
+
) {
|
|
3123
|
+
lastStageEpoch = Math.max(lastStageEpoch, e.ts_epoch || 0);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
if (lastStageEpoch > 0 && now - lastStageEpoch > 1800) {
|
|
3127
|
+
alerts.push({
|
|
3128
|
+
type: "stuck_pipeline",
|
|
3129
|
+
severity: "warning",
|
|
3130
|
+
message: `Pipeline for issue #${issue} has had no stage change for ${Math.round((now - lastStageEpoch) / 60)}min`,
|
|
3131
|
+
issue,
|
|
3132
|
+
actions: ["pause", "abort", "message"],
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
// Budget warning (>80%)
|
|
3139
|
+
if (costInfo.pct_used > 80) {
|
|
3140
|
+
alerts.push({
|
|
3141
|
+
type: "budget_warning",
|
|
3142
|
+
severity: costInfo.pct_used > 95 ? "critical" : "warning",
|
|
3143
|
+
message: `Budget usage at ${costInfo.pct_used}% ($${costInfo.today_spent}/$${costInfo.daily_budget})`,
|
|
3144
|
+
actions: ["pause_daemon"],
|
|
3145
|
+
});
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
// Queue depth (>10)
|
|
3149
|
+
const queueDepth = daemonState
|
|
3150
|
+
? ((daemonState.queued as Array<unknown>) || []).length
|
|
3151
|
+
: 0;
|
|
3152
|
+
if (queueDepth > 10) {
|
|
3153
|
+
alerts.push({
|
|
3154
|
+
type: "queue_depth",
|
|
3155
|
+
severity: queueDepth > 20 ? "critical" : "warning",
|
|
3156
|
+
message: `Queue depth is ${queueDepth} issues`,
|
|
3157
|
+
actions: ["scale_up"],
|
|
3158
|
+
});
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
// Failure spike (>3 failures/hr)
|
|
3162
|
+
const oneHourAgo = now - 3600;
|
|
3163
|
+
let failuresLastHour = 0;
|
|
3164
|
+
for (const e of events) {
|
|
3165
|
+
if (
|
|
3166
|
+
e.type === "pipeline.completed" &&
|
|
3167
|
+
e.result !== "success" &&
|
|
3168
|
+
(e.ts_epoch || 0) >= oneHourAgo
|
|
3169
|
+
) {
|
|
3170
|
+
failuresLastHour++;
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
if (failuresLastHour > 3) {
|
|
3174
|
+
alerts.push({
|
|
3175
|
+
type: "failure_spike",
|
|
3176
|
+
severity: "critical",
|
|
3177
|
+
message: `${failuresLastHour} pipeline failures in the last hour`,
|
|
3178
|
+
actions: ["emergency_brake", "review_logs"],
|
|
3179
|
+
});
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
// Stale heartbeat (>5min)
|
|
3183
|
+
if (existsSync(HEARTBEAT_DIR)) {
|
|
3184
|
+
try {
|
|
3185
|
+
const files = readdirSync(HEARTBEAT_DIR).filter((f) =>
|
|
3186
|
+
f.endsWith(".json"),
|
|
3187
|
+
);
|
|
3188
|
+
for (const file of files) {
|
|
3189
|
+
try {
|
|
3190
|
+
const hb = JSON.parse(
|
|
3191
|
+
readFileSync(join(HEARTBEAT_DIR, file), "utf-8"),
|
|
3192
|
+
);
|
|
3193
|
+
const updatedAt = hb.updated_at || "";
|
|
3194
|
+
let hbEpoch = 0;
|
|
3195
|
+
try {
|
|
3196
|
+
hbEpoch = Math.floor(new Date(updatedAt).getTime() / 1000);
|
|
3197
|
+
} catch {
|
|
3198
|
+
/* ignore */
|
|
3199
|
+
}
|
|
3200
|
+
if (hbEpoch > 0 && now - hbEpoch > 300) {
|
|
3201
|
+
const issue = (hb.issue as number) || 0;
|
|
3202
|
+
alerts.push({
|
|
3203
|
+
type: "stale_heartbeat",
|
|
3204
|
+
severity: "warning",
|
|
3205
|
+
message: `Agent heartbeat for ${file.replace(".json", "")} is ${Math.round((now - hbEpoch) / 60)}min stale`,
|
|
3206
|
+
issue: issue || undefined,
|
|
3207
|
+
actions: ["abort", "investigate"],
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
} catch {
|
|
3211
|
+
// skip
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
} catch {
|
|
3215
|
+
// ignore
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
return new Response(JSON.stringify({ alerts }), {
|
|
3220
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3221
|
+
});
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
// REST: Emergency brake — pause all active pipelines + clear queue
|
|
3225
|
+
if (pathname === "/api/emergency-brake" && req.method === "POST") {
|
|
3226
|
+
try {
|
|
3227
|
+
const daemonState = readDaemonState();
|
|
3228
|
+
let paused = 0;
|
|
3229
|
+
let queued = 0;
|
|
3230
|
+
|
|
3231
|
+
if (daemonState) {
|
|
3232
|
+
const activeJobs =
|
|
3233
|
+
(daemonState.active_jobs as Array<Record<string, unknown>>) || [];
|
|
3234
|
+
for (const job of activeJobs) {
|
|
3235
|
+
const pid = (job.pid as number) || 0;
|
|
3236
|
+
if (pid) {
|
|
3237
|
+
try {
|
|
3238
|
+
execSync(`kill -STOP ${pid}`);
|
|
3239
|
+
paused++;
|
|
3240
|
+
} catch {
|
|
3241
|
+
// process may already be gone
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
queued = ((daemonState.queued as Array<unknown>) || []).length;
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
return new Response(JSON.stringify({ paused, queued }), {
|
|
3249
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3250
|
+
});
|
|
3251
|
+
} catch (err) {
|
|
3252
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
3253
|
+
status: 500,
|
|
3254
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// ── Machine Management endpoints ──────────────────────────────
|
|
3260
|
+
|
|
3261
|
+
// POST /api/machines — Register a new machine
|
|
3262
|
+
if (pathname === "/api/machines" && req.method === "POST") {
|
|
3263
|
+
try {
|
|
3264
|
+
const body = (await req.json()) as Record<string, unknown>;
|
|
3265
|
+
const name = (body.name as string) || "";
|
|
3266
|
+
const host = (body.host as string) || "";
|
|
3267
|
+
if (!name || !host) {
|
|
3268
|
+
return new Response(
|
|
3269
|
+
JSON.stringify({ error: "name and host are required" }),
|
|
3270
|
+
{
|
|
3271
|
+
status: 400,
|
|
3272
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3273
|
+
},
|
|
3274
|
+
);
|
|
3275
|
+
}
|
|
3276
|
+
const data = readMachinesFile();
|
|
3277
|
+
if (data.machines.some((m) => (m.name as string) === name)) {
|
|
3278
|
+
return new Response(
|
|
3279
|
+
JSON.stringify({ error: `Machine "${name}" already exists` }),
|
|
3280
|
+
{
|
|
3281
|
+
status: 409,
|
|
3282
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3283
|
+
},
|
|
3284
|
+
);
|
|
3285
|
+
}
|
|
3286
|
+
const newMachine: Record<string, unknown> = {
|
|
3287
|
+
name,
|
|
3288
|
+
host,
|
|
3289
|
+
role: (body.role as string) || "worker",
|
|
3290
|
+
max_workers: (body.max_workers as number) || 4,
|
|
3291
|
+
ssh_user: (body.ssh_user as string) || undefined,
|
|
3292
|
+
shipwright_path: (body.shipwright_path as string) || undefined,
|
|
3293
|
+
registered_at: new Date().toISOString(),
|
|
3294
|
+
};
|
|
3295
|
+
data.machines.push(newMachine);
|
|
3296
|
+
writeMachinesFile(data);
|
|
3297
|
+
const agents = getAgents();
|
|
3298
|
+
return new Response(
|
|
3299
|
+
JSON.stringify(enrichMachineHealth(newMachine, agents)),
|
|
3300
|
+
{
|
|
3301
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3302
|
+
},
|
|
3303
|
+
);
|
|
3304
|
+
} catch (err) {
|
|
3305
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
3306
|
+
status: 500,
|
|
3307
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
// GET /api/machines — List all machines with enriched health
|
|
3313
|
+
if (pathname === "/api/machines" && req.method === "GET") {
|
|
3314
|
+
return new Response(JSON.stringify(getMachines()), {
|
|
3315
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3316
|
+
});
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
// PATCH /api/machines/{name} — Scale workers or update fields
|
|
3320
|
+
if (
|
|
3321
|
+
pathname.startsWith("/api/machines/") &&
|
|
3322
|
+
!pathname.includes("/health-check") &&
|
|
3323
|
+
req.method === "PATCH"
|
|
3324
|
+
) {
|
|
3325
|
+
const machineName = decodeURIComponent(pathname.split("/")[3] || "");
|
|
3326
|
+
if (!machineName) {
|
|
3327
|
+
return new Response(
|
|
3328
|
+
JSON.stringify({ error: "Machine name is required" }),
|
|
3329
|
+
{
|
|
3330
|
+
status: 400,
|
|
3331
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3332
|
+
},
|
|
3333
|
+
);
|
|
3334
|
+
}
|
|
3335
|
+
try {
|
|
3336
|
+
const body = (await req.json()) as Record<string, unknown>;
|
|
3337
|
+
const data = readMachinesFile();
|
|
3338
|
+
const idx = data.machines.findIndex(
|
|
3339
|
+
(m) => (m.name as string) === machineName,
|
|
3340
|
+
);
|
|
3341
|
+
if (idx === -1) {
|
|
3342
|
+
return new Response(
|
|
3343
|
+
JSON.stringify({ error: `Machine "${machineName}" not found` }),
|
|
3344
|
+
{
|
|
3345
|
+
status: 400,
|
|
3346
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3347
|
+
},
|
|
3348
|
+
);
|
|
3349
|
+
}
|
|
3350
|
+
// Update allowed fields
|
|
3351
|
+
if (body.max_workers !== undefined)
|
|
3352
|
+
data.machines[idx].max_workers = body.max_workers;
|
|
3353
|
+
if (body.role !== undefined) data.machines[idx].role = body.role;
|
|
3354
|
+
if (body.ssh_user !== undefined)
|
|
3355
|
+
data.machines[idx].ssh_user = body.ssh_user;
|
|
3356
|
+
if (body.shipwright_path !== undefined)
|
|
3357
|
+
data.machines[idx].shipwright_path = body.shipwright_path;
|
|
3358
|
+
|
|
3359
|
+
// If scaling, attempt to send command to remote machine
|
|
3360
|
+
if (body.max_workers !== undefined) {
|
|
3361
|
+
const machine = data.machines[idx];
|
|
3362
|
+
const sshUser = (machine.ssh_user as string) || "";
|
|
3363
|
+
const mHost = (machine.host as string) || "";
|
|
3364
|
+
const swPath = (machine.shipwright_path as string) || "shipwright";
|
|
3365
|
+
if (
|
|
3366
|
+
sshUser &&
|
|
3367
|
+
mHost &&
|
|
3368
|
+
mHost !== "localhost" &&
|
|
3369
|
+
mHost !== "127.0.0.1"
|
|
3370
|
+
) {
|
|
3371
|
+
try {
|
|
3372
|
+
execSync(
|
|
3373
|
+
`ssh -o ConnectTimeout=5 ${sshUser}@${mHost} "${swPath} daemon scale ${body.max_workers}" 2>/dev/null`,
|
|
3374
|
+
{ timeout: 10000 },
|
|
3375
|
+
);
|
|
3376
|
+
} catch {
|
|
3377
|
+
// Remote scale command failed — update saved anyway
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
writeMachinesFile(data);
|
|
3383
|
+
const agents = getAgents();
|
|
3384
|
+
return new Response(
|
|
3385
|
+
JSON.stringify(enrichMachineHealth(data.machines[idx], agents)),
|
|
3386
|
+
{
|
|
3387
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3388
|
+
},
|
|
3389
|
+
);
|
|
3390
|
+
} catch (err) {
|
|
3391
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
3392
|
+
status: 500,
|
|
3393
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3394
|
+
});
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
// POST /api/machines/{name}/health-check — On-demand health check
|
|
3399
|
+
if (
|
|
3400
|
+
pathname.match(/^\/api\/machines\/[^/]+\/health-check$/) &&
|
|
3401
|
+
req.method === "POST"
|
|
3402
|
+
) {
|
|
3403
|
+
const machineName = decodeURIComponent(pathname.split("/")[3] || "");
|
|
3404
|
+
try {
|
|
3405
|
+
const data = readMachinesFile();
|
|
3406
|
+
const machine = data.machines.find(
|
|
3407
|
+
(m) => (m.name as string) === machineName,
|
|
3408
|
+
);
|
|
3409
|
+
if (!machine) {
|
|
3410
|
+
return new Response(
|
|
3411
|
+
JSON.stringify({ error: `Machine "${machineName}" not found` }),
|
|
3412
|
+
{
|
|
3413
|
+
status: 400,
|
|
3414
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3415
|
+
},
|
|
3416
|
+
);
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
const sshUser = (machine.ssh_user as string) || "";
|
|
3420
|
+
const mHost = (machine.host as string) || "";
|
|
3421
|
+
const swPath = (machine.shipwright_path as string) || "shipwright";
|
|
3422
|
+
let daemonRunning = false;
|
|
3423
|
+
let reachable = false;
|
|
3424
|
+
|
|
3425
|
+
if (!mHost || mHost === "localhost" || mHost === "127.0.0.1") {
|
|
3426
|
+
// Local machine — check daemon state directly
|
|
3427
|
+
reachable = true;
|
|
3428
|
+
try {
|
|
3429
|
+
const dState = readFileOr(DAEMON_STATE, "");
|
|
3430
|
+
if (dState) {
|
|
3431
|
+
const parsed = JSON.parse(dState);
|
|
3432
|
+
daemonRunning = !!parsed.pid;
|
|
3433
|
+
}
|
|
3434
|
+
} catch {
|
|
3435
|
+
// ignore
|
|
3436
|
+
}
|
|
3437
|
+
} else if (sshUser) {
|
|
3438
|
+
try {
|
|
3439
|
+
const result = execSync(
|
|
3440
|
+
`ssh -o ConnectTimeout=5 -o BatchMode=yes ${sshUser}@${mHost} "${swPath} ps 2>/dev/null || echo OFFLINE"`,
|
|
3441
|
+
{ timeout: 10000, encoding: "utf-8" },
|
|
3442
|
+
);
|
|
3443
|
+
reachable = true;
|
|
3444
|
+
daemonRunning =
|
|
3445
|
+
!result.includes("OFFLINE") && !result.includes("No daemon");
|
|
3446
|
+
} catch {
|
|
3447
|
+
reachable = false;
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
|
|
3451
|
+
// Save health data
|
|
3452
|
+
let healthData: Record<string, Record<string, unknown>> = {};
|
|
3453
|
+
try {
|
|
3454
|
+
if (existsSync(MACHINE_HEALTH_FILE)) {
|
|
3455
|
+
healthData = JSON.parse(readFileSync(MACHINE_HEALTH_FILE, "utf-8"));
|
|
3456
|
+
}
|
|
3457
|
+
} catch {
|
|
3458
|
+
// ignore
|
|
3459
|
+
}
|
|
3460
|
+
healthData[machineName] = {
|
|
3461
|
+
daemon_running: daemonRunning,
|
|
3462
|
+
reachable,
|
|
3463
|
+
last_check: new Date().toISOString(),
|
|
3464
|
+
};
|
|
3465
|
+
const healthTmp = MACHINE_HEALTH_FILE + ".tmp";
|
|
3466
|
+
writeFileSync(healthTmp, JSON.stringify(healthData, null, 2), "utf-8");
|
|
3467
|
+
renameSync(healthTmp, MACHINE_HEALTH_FILE);
|
|
3468
|
+
|
|
3469
|
+
const agents = getAgents();
|
|
3470
|
+
return new Response(
|
|
3471
|
+
JSON.stringify({
|
|
3472
|
+
machine: enrichMachineHealth(machine, agents),
|
|
3473
|
+
reachable,
|
|
3474
|
+
daemon_running: daemonRunning,
|
|
3475
|
+
checked_at: new Date().toISOString(),
|
|
3476
|
+
}),
|
|
3477
|
+
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
3478
|
+
);
|
|
3479
|
+
} catch (err) {
|
|
3480
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
3481
|
+
status: 500,
|
|
3482
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3483
|
+
});
|
|
3484
|
+
}
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
// DELETE /api/machines/{name} — Remove a machine
|
|
3488
|
+
if (pathname.startsWith("/api/machines/") && req.method === "DELETE") {
|
|
3489
|
+
const machineName = decodeURIComponent(pathname.split("/")[3] || "");
|
|
3490
|
+
if (!machineName) {
|
|
3491
|
+
return new Response(
|
|
3492
|
+
JSON.stringify({ error: "Machine name is required" }),
|
|
3493
|
+
{
|
|
3494
|
+
status: 400,
|
|
3495
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3496
|
+
},
|
|
3497
|
+
);
|
|
3498
|
+
}
|
|
3499
|
+
try {
|
|
3500
|
+
const data = readMachinesFile();
|
|
3501
|
+
const idx = data.machines.findIndex(
|
|
3502
|
+
(m) => (m.name as string) === machineName,
|
|
3503
|
+
);
|
|
3504
|
+
if (idx === -1) {
|
|
3505
|
+
return new Response(
|
|
3506
|
+
JSON.stringify({ error: `Machine "${machineName}" not found` }),
|
|
3507
|
+
{
|
|
3508
|
+
status: 400,
|
|
3509
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3510
|
+
},
|
|
3511
|
+
);
|
|
3512
|
+
}
|
|
3513
|
+
data.machines.splice(idx, 1);
|
|
3514
|
+
writeMachinesFile(data);
|
|
3515
|
+
return new Response(
|
|
3516
|
+
JSON.stringify({ ok: true, removed: machineName }),
|
|
3517
|
+
{
|
|
3518
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3519
|
+
},
|
|
3520
|
+
);
|
|
3521
|
+
} catch (err) {
|
|
3522
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
3523
|
+
status: 500,
|
|
3524
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3525
|
+
});
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
// POST /api/join-token — Generate a join token and command
|
|
3530
|
+
if (pathname === "/api/join-token" && req.method === "POST") {
|
|
3531
|
+
try {
|
|
3532
|
+
const body = (await req.json()) as Record<string, unknown>;
|
|
3533
|
+
const maxWorkers = (body.max_workers as number) || 4;
|
|
3534
|
+
const label = (body.label as string) || "";
|
|
3535
|
+
const token = crypto.randomUUID();
|
|
3536
|
+
const dashboardUrl = `${url.protocol}//${url.host}`;
|
|
3537
|
+
|
|
3538
|
+
// Save token
|
|
3539
|
+
let tokens: Array<Record<string, unknown>> = [];
|
|
3540
|
+
try {
|
|
3541
|
+
if (existsSync(JOIN_TOKENS_FILE)) {
|
|
3542
|
+
const raw = readFileSync(JOIN_TOKENS_FILE, "utf-8");
|
|
3543
|
+
tokens = JSON.parse(raw);
|
|
3544
|
+
}
|
|
3545
|
+
} catch {
|
|
3546
|
+
tokens = [];
|
|
3547
|
+
}
|
|
3548
|
+
tokens.push({
|
|
3549
|
+
token,
|
|
3550
|
+
label,
|
|
3551
|
+
max_workers: maxWorkers,
|
|
3552
|
+
created_at: new Date().toISOString(),
|
|
3553
|
+
used: false,
|
|
3554
|
+
});
|
|
3555
|
+
const tokensTmp = JOIN_TOKENS_FILE + ".tmp";
|
|
3556
|
+
writeFileSync(tokensTmp, JSON.stringify(tokens, null, 2), "utf-8");
|
|
3557
|
+
renameSync(tokensTmp, JOIN_TOKENS_FILE);
|
|
3558
|
+
|
|
3559
|
+
const joinUrl = `${dashboardUrl}/api/join/${token}`;
|
|
3560
|
+
const joinCmd = `curl -fsSL "${joinUrl}" | bash`;
|
|
3561
|
+
|
|
3562
|
+
return new Response(
|
|
3563
|
+
JSON.stringify({
|
|
3564
|
+
token,
|
|
3565
|
+
join_url: joinUrl,
|
|
3566
|
+
join_cmd: joinCmd,
|
|
3567
|
+
max_workers: maxWorkers,
|
|
3568
|
+
}),
|
|
3569
|
+
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
3570
|
+
);
|
|
3571
|
+
} catch (err) {
|
|
3572
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
3573
|
+
status: 500,
|
|
3574
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3575
|
+
});
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
// GET /api/join-tokens — List active join tokens
|
|
3580
|
+
if (pathname === "/api/join-tokens" && req.method === "GET") {
|
|
3581
|
+
try {
|
|
3582
|
+
let tokens: Array<Record<string, unknown>> = [];
|
|
3583
|
+
try {
|
|
3584
|
+
if (existsSync(JOIN_TOKENS_FILE)) {
|
|
3585
|
+
tokens = JSON.parse(readFileSync(JOIN_TOKENS_FILE, "utf-8"));
|
|
3586
|
+
}
|
|
3587
|
+
} catch {
|
|
3588
|
+
tokens = [];
|
|
3589
|
+
}
|
|
3590
|
+
return new Response(JSON.stringify(tokens), {
|
|
3591
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3592
|
+
});
|
|
3593
|
+
} catch (err) {
|
|
3594
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
3595
|
+
status: 500,
|
|
3596
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3597
|
+
});
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
// ── Daemon Control endpoints ─────────────────────────────────
|
|
3602
|
+
|
|
3603
|
+
// POST /api/daemon/start — Start daemon in background
|
|
3604
|
+
if (pathname === "/api/daemon/start" && req.method === "POST") {
|
|
3605
|
+
try {
|
|
3606
|
+
execSync("shipwright daemon start --detach", {
|
|
3607
|
+
timeout: 10000,
|
|
3608
|
+
stdio: "pipe",
|
|
3609
|
+
});
|
|
3610
|
+
return new Response(JSON.stringify({ ok: true, action: "started" }), {
|
|
3611
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3612
|
+
});
|
|
3613
|
+
} catch (err) {
|
|
3614
|
+
return new Response(JSON.stringify({ ok: false, error: String(err) }), {
|
|
3615
|
+
status: 500,
|
|
3616
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3617
|
+
});
|
|
3618
|
+
}
|
|
3619
|
+
}
|
|
3620
|
+
|
|
3621
|
+
// POST /api/daemon/stop — Stop daemon by PID
|
|
3622
|
+
if (pathname === "/api/daemon/stop" && req.method === "POST") {
|
|
3623
|
+
try {
|
|
3624
|
+
let pid = 0;
|
|
3625
|
+
try {
|
|
3626
|
+
if (existsSync(DAEMON_STATE)) {
|
|
3627
|
+
const state = JSON.parse(readFileSync(DAEMON_STATE, "utf-8"));
|
|
3628
|
+
pid = state.pid || 0;
|
|
3629
|
+
}
|
|
3630
|
+
} catch {
|
|
3631
|
+
// state file may be corrupt
|
|
3632
|
+
}
|
|
3633
|
+
if (pid > 0) {
|
|
3634
|
+
try {
|
|
3635
|
+
execSync(`kill -TERM ${pid}`, { timeout: 5000, stdio: "pipe" });
|
|
3636
|
+
} catch {
|
|
3637
|
+
// process may already be gone
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
// Also try the daemon stop command
|
|
3641
|
+
try {
|
|
3642
|
+
execSync("shipwright daemon stop", { timeout: 10000, stdio: "pipe" });
|
|
3643
|
+
} catch {
|
|
3644
|
+
// may fail if already stopped
|
|
3645
|
+
}
|
|
3646
|
+
return new Response(
|
|
3647
|
+
JSON.stringify({ ok: true, action: "stopped", pid }),
|
|
3648
|
+
{
|
|
3649
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3650
|
+
},
|
|
3651
|
+
);
|
|
3652
|
+
} catch (err) {
|
|
3653
|
+
return new Response(JSON.stringify({ ok: false, error: String(err) }), {
|
|
3654
|
+
status: 500,
|
|
3655
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3656
|
+
});
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
// POST /api/daemon/pause — Pause daemon polling
|
|
3661
|
+
if (pathname === "/api/daemon/pause" && req.method === "POST") {
|
|
3662
|
+
try {
|
|
3663
|
+
const flagPath = join(HOME, ".shipwright", "daemon-pause.flag");
|
|
3664
|
+
const dir = join(HOME, ".shipwright");
|
|
3665
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
3666
|
+
writeFileSync(
|
|
3667
|
+
flagPath,
|
|
3668
|
+
JSON.stringify({ paused: true, at: new Date().toISOString() }),
|
|
3669
|
+
);
|
|
3670
|
+
return new Response(JSON.stringify({ ok: true, action: "paused" }), {
|
|
3671
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3672
|
+
});
|
|
3673
|
+
} catch (err) {
|
|
3674
|
+
return new Response(JSON.stringify({ ok: false, error: String(err) }), {
|
|
3675
|
+
status: 500,
|
|
3676
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3677
|
+
});
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
// POST /api/daemon/resume — Resume daemon polling
|
|
3682
|
+
if (pathname === "/api/daemon/resume" && req.method === "POST") {
|
|
3683
|
+
try {
|
|
3684
|
+
const flagPath = join(HOME, ".shipwright", "daemon-pause.flag");
|
|
3685
|
+
if (existsSync(flagPath)) {
|
|
3686
|
+
unlinkSync(flagPath);
|
|
3687
|
+
}
|
|
3688
|
+
return new Response(JSON.stringify({ ok: true, action: "resumed" }), {
|
|
3689
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3690
|
+
});
|
|
3691
|
+
} catch (err) {
|
|
3692
|
+
return new Response(JSON.stringify({ ok: false, error: String(err) }), {
|
|
3693
|
+
status: 500,
|
|
3694
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3695
|
+
});
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
// GET /api/daemon/config — Return daemon configuration
|
|
3700
|
+
if (pathname === "/api/daemon/config" && req.method === "GET") {
|
|
3701
|
+
try {
|
|
3702
|
+
// Look for daemon-config.json in common locations
|
|
3703
|
+
const configPaths = [
|
|
3704
|
+
join(process.cwd(), ".claude", "daemon-config.json"),
|
|
3705
|
+
join(HOME, ".claude", "daemon-config.json"),
|
|
3706
|
+
];
|
|
3707
|
+
let config: Record<string, unknown> = {};
|
|
3708
|
+
for (const p of configPaths) {
|
|
3709
|
+
if (existsSync(p)) {
|
|
3710
|
+
config = JSON.parse(readFileSync(p, "utf-8"));
|
|
3711
|
+
break;
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
// Add budget info
|
|
3715
|
+
let budget: Record<string, unknown> = {};
|
|
3716
|
+
try {
|
|
3717
|
+
if (existsSync(BUDGET_FILE)) {
|
|
3718
|
+
budget = JSON.parse(readFileSync(BUDGET_FILE, "utf-8"));
|
|
3719
|
+
}
|
|
3720
|
+
} catch {
|
|
3721
|
+
// no budget set
|
|
3722
|
+
}
|
|
3723
|
+
// Check pause state
|
|
3724
|
+
const pauseFlag = join(HOME, ".shipwright", "daemon-pause.flag");
|
|
3725
|
+
const paused = existsSync(pauseFlag);
|
|
3726
|
+
|
|
3727
|
+
return new Response(JSON.stringify({ config, budget, paused }), {
|
|
3728
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3729
|
+
});
|
|
3730
|
+
} catch (err) {
|
|
3731
|
+
return new Response(JSON.stringify({ ok: false, error: String(err) }), {
|
|
3732
|
+
status: 500,
|
|
3733
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3734
|
+
});
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
// POST /api/daemon/patrol — Trigger a one-off patrol run
|
|
3739
|
+
if (pathname === "/api/daemon/patrol" && req.method === "POST") {
|
|
3740
|
+
try {
|
|
3741
|
+
// Run patrol in background (don't block the response)
|
|
3742
|
+
execSync("nohup shipwright daemon patrol --once > /dev/null 2>&1 &", {
|
|
3743
|
+
timeout: 5000,
|
|
3744
|
+
stdio: "pipe",
|
|
3745
|
+
shell: "/bin/bash",
|
|
3746
|
+
});
|
|
3747
|
+
return new Response(
|
|
3748
|
+
JSON.stringify({ ok: true, action: "patrol_triggered" }),
|
|
3749
|
+
{
|
|
3750
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3751
|
+
},
|
|
3752
|
+
);
|
|
3753
|
+
} catch (err) {
|
|
3754
|
+
return new Response(JSON.stringify({ ok: false, error: String(err) }), {
|
|
3755
|
+
status: 500,
|
|
3756
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3757
|
+
});
|
|
3758
|
+
}
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
// ── Multi-Developer Platform endpoints ──────────────────────────
|
|
3762
|
+
|
|
3763
|
+
// POST /api/connect/heartbeat — Update developer presence
|
|
3764
|
+
if (pathname === "/api/connect/heartbeat" && req.method === "POST") {
|
|
3765
|
+
try {
|
|
3766
|
+
const body = (await req.json()) as any;
|
|
3767
|
+
|
|
3768
|
+
// Optional auth: if invite tokens exist, require a valid one
|
|
3769
|
+
if (inviteTokens.size > 0) {
|
|
3770
|
+
const authToken =
|
|
3771
|
+
body.invite_token ||
|
|
3772
|
+
(req.headers.get("authorization") || "").replace("Bearer ", "");
|
|
3773
|
+
if (authToken) {
|
|
3774
|
+
const entry = inviteTokens.get(authToken);
|
|
3775
|
+
if (!entry || new Date(entry.expires_at).getTime() < Date.now()) {
|
|
3776
|
+
return new Response(
|
|
3777
|
+
JSON.stringify({ error: "Invalid or expired invite token" }),
|
|
3778
|
+
{
|
|
3779
|
+
status: 403,
|
|
3780
|
+
headers: {
|
|
3781
|
+
"Content-Type": "application/json",
|
|
3782
|
+
...CORS_HEADERS,
|
|
3783
|
+
},
|
|
3784
|
+
},
|
|
3785
|
+
);
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
// If no token provided but tokens exist, check if developer is already registered
|
|
3789
|
+
else {
|
|
3790
|
+
const existingKey = `${body.developer_id}@${body.machine_name}`;
|
|
3791
|
+
if (!developerRegistry.has(existingKey)) {
|
|
3792
|
+
return new Response(
|
|
3793
|
+
JSON.stringify({
|
|
3794
|
+
error: "Invite token required for new developers",
|
|
3795
|
+
hint: "Run: shipwright connect join --url <dashboard> --token <token>",
|
|
3796
|
+
}),
|
|
3797
|
+
{
|
|
3798
|
+
status: 403,
|
|
3799
|
+
headers: {
|
|
3800
|
+
"Content-Type": "application/json",
|
|
3801
|
+
...CORS_HEADERS,
|
|
3802
|
+
},
|
|
3803
|
+
},
|
|
3804
|
+
);
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
const key = `${body.developer_id}@${body.machine_name}`;
|
|
3810
|
+
|
|
3811
|
+
developerRegistry.set(key, {
|
|
3812
|
+
developer_id: body.developer_id,
|
|
3813
|
+
machine_name: body.machine_name,
|
|
3814
|
+
hostname: body.hostname || body.machine_name,
|
|
3815
|
+
platform: body.platform || "unknown",
|
|
3816
|
+
last_heartbeat: Date.now(),
|
|
3817
|
+
daemon_running: body.daemon_running || false,
|
|
3818
|
+
daemon_pid: body.daemon_pid || null,
|
|
3819
|
+
active_jobs: body.active_jobs || [],
|
|
3820
|
+
queued: body.queued || [],
|
|
3821
|
+
events_since: body.events_since || 0,
|
|
3822
|
+
});
|
|
3823
|
+
|
|
3824
|
+
// Append incoming events to team events log
|
|
3825
|
+
if (body.events && Array.isArray(body.events)) {
|
|
3826
|
+
const dir = join(HOME, ".shipwright");
|
|
3827
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
3828
|
+
const enriched = body.events
|
|
3829
|
+
.map((e: any) =>
|
|
3830
|
+
JSON.stringify({
|
|
3831
|
+
...e,
|
|
3832
|
+
from_developer: body.developer_id,
|
|
3833
|
+
from_machine: body.machine_name,
|
|
3834
|
+
}),
|
|
3835
|
+
)
|
|
3836
|
+
.join("\n");
|
|
3837
|
+
if (enriched) {
|
|
3838
|
+
appendFileSync(TEAM_EVENTS_FILE, enriched + "\n");
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
saveDeveloperRegistry();
|
|
3843
|
+
|
|
3844
|
+
// Broadcast updated state to dashboard clients
|
|
3845
|
+
if (wsClients.size > 0) {
|
|
3846
|
+
broadcastToClients(getFleetState());
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3849
|
+
return new Response(
|
|
3850
|
+
JSON.stringify({ ok: true, team_size: developerRegistry.size }),
|
|
3851
|
+
{
|
|
3852
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3853
|
+
},
|
|
3854
|
+
);
|
|
3855
|
+
} catch (err) {
|
|
3856
|
+
return new Response(JSON.stringify({ ok: false, error: String(err) }), {
|
|
3857
|
+
status: 500,
|
|
3858
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3859
|
+
});
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
// POST /api/connect/disconnect — Mark developer offline
|
|
3864
|
+
if (pathname === "/api/connect/disconnect" && req.method === "POST") {
|
|
3865
|
+
try {
|
|
3866
|
+
const body = (await req.json()) as any;
|
|
3867
|
+
const key = `${body.developer_id}@${body.machine_name}`;
|
|
3868
|
+
const dev = developerRegistry.get(key);
|
|
3869
|
+
if (dev) {
|
|
3870
|
+
dev.last_heartbeat = 0; // mark as offline immediately
|
|
3871
|
+
dev.daemon_running = false;
|
|
3872
|
+
dev.daemon_pid = null;
|
|
3873
|
+
dev.active_jobs = [];
|
|
3874
|
+
developerRegistry.set(key, dev);
|
|
3875
|
+
saveDeveloperRegistry();
|
|
3876
|
+
}
|
|
3877
|
+
|
|
3878
|
+
if (wsClients.size > 0) {
|
|
3879
|
+
broadcastToClients(getFleetState());
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
3883
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3884
|
+
});
|
|
3885
|
+
} catch (err) {
|
|
3886
|
+
return new Response(JSON.stringify({ ok: false, error: String(err) }), {
|
|
3887
|
+
status: 500,
|
|
3888
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3889
|
+
});
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
// GET /api/team — Return all connected developers with presence
|
|
3894
|
+
if (pathname === "/api/team" && req.method === "GET") {
|
|
3895
|
+
return new Response(JSON.stringify(getTeamState()), {
|
|
3896
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3897
|
+
});
|
|
3898
|
+
}
|
|
3899
|
+
|
|
3900
|
+
// GET /api/team/activity — Return last 100 team events
|
|
3901
|
+
if (pathname === "/api/team/activity" && req.method === "GET") {
|
|
3902
|
+
try {
|
|
3903
|
+
let events: unknown[] = [];
|
|
3904
|
+
if (existsSync(TEAM_EVENTS_FILE)) {
|
|
3905
|
+
const lines = readFileSync(TEAM_EVENTS_FILE, "utf-8")
|
|
3906
|
+
.trim()
|
|
3907
|
+
.split("\n")
|
|
3908
|
+
.filter(Boolean);
|
|
3909
|
+
const recent = lines.slice(-100);
|
|
3910
|
+
events = recent
|
|
3911
|
+
.map((line) => {
|
|
3912
|
+
try {
|
|
3913
|
+
return JSON.parse(line);
|
|
3914
|
+
} catch {
|
|
3915
|
+
return null;
|
|
3916
|
+
}
|
|
3917
|
+
})
|
|
3918
|
+
.filter(Boolean);
|
|
3919
|
+
}
|
|
3920
|
+
return new Response(JSON.stringify(events), {
|
|
3921
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3922
|
+
});
|
|
3923
|
+
} catch (err) {
|
|
3924
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
3925
|
+
status: 500,
|
|
3926
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3927
|
+
});
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
// POST /api/claim — Label-based claim coordination
|
|
3932
|
+
if (pathname === "/api/claim" && req.method === "POST") {
|
|
3933
|
+
try {
|
|
3934
|
+
const body = (await req.json()) as any;
|
|
3935
|
+
const issue = body.issue as number;
|
|
3936
|
+
const machine = (body.machine || body.machine_name) as string;
|
|
3937
|
+
const repo = (body.repo as string) || "";
|
|
3938
|
+
|
|
3939
|
+
// Check for existing claimed:* label
|
|
3940
|
+
const repoFlag = repo ? ` -R ${repo}` : "";
|
|
3941
|
+
let labels = "";
|
|
3942
|
+
try {
|
|
3943
|
+
labels = execSync(
|
|
3944
|
+
`gh issue view ${issue}${repoFlag} --json labels -q '.labels[].name'`,
|
|
3945
|
+
{
|
|
3946
|
+
encoding: "utf-8",
|
|
3947
|
+
timeout: 10000,
|
|
3948
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3949
|
+
},
|
|
3950
|
+
).trim();
|
|
3951
|
+
} catch {
|
|
3952
|
+
labels = "";
|
|
3953
|
+
}
|
|
3954
|
+
|
|
3955
|
+
const claimedLabel = labels
|
|
3956
|
+
.split("\n")
|
|
3957
|
+
.find((l: string) => l.startsWith("claimed:"));
|
|
3958
|
+
if (claimedLabel) {
|
|
3959
|
+
return new Response(
|
|
3960
|
+
JSON.stringify({
|
|
3961
|
+
approved: false,
|
|
3962
|
+
claimed_by: claimedLabel.replace("claimed:", ""),
|
|
3963
|
+
}),
|
|
3964
|
+
{
|
|
3965
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3966
|
+
},
|
|
3967
|
+
);
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
// Ensure the claimed label exists (no-op if already created)
|
|
3971
|
+
try {
|
|
3972
|
+
execSync(
|
|
3973
|
+
`gh label create "claimed:${machine}"${repoFlag} --color EDEDED --description "Claimed by ${machine}" --force`,
|
|
3974
|
+
{ timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
|
|
3975
|
+
);
|
|
3976
|
+
} catch {
|
|
3977
|
+
/* label may already exist or gh label create not supported — fallback below */
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
// Add claimed:<machine> label
|
|
3981
|
+
try {
|
|
3982
|
+
execSync(
|
|
3983
|
+
`gh issue edit ${issue}${repoFlag} --add-label "claimed:${machine}"`,
|
|
3984
|
+
{
|
|
3985
|
+
timeout: 10000,
|
|
3986
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3987
|
+
},
|
|
3988
|
+
);
|
|
3989
|
+
} catch {
|
|
3990
|
+
return new Response(
|
|
3991
|
+
JSON.stringify({ approved: false, error: "Failed to set label" }),
|
|
3992
|
+
{
|
|
3993
|
+
status: 500,
|
|
3994
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3995
|
+
},
|
|
3996
|
+
);
|
|
3997
|
+
}
|
|
3998
|
+
|
|
3999
|
+
return new Response(
|
|
4000
|
+
JSON.stringify({ approved: true, claimed_by: machine }),
|
|
4001
|
+
{
|
|
4002
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4003
|
+
},
|
|
4004
|
+
);
|
|
4005
|
+
} catch (err) {
|
|
4006
|
+
return new Response(
|
|
4007
|
+
JSON.stringify({ approved: false, error: String(err) }),
|
|
4008
|
+
{
|
|
4009
|
+
status: 500,
|
|
4010
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4011
|
+
},
|
|
4012
|
+
);
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
// POST /api/claim/release — Remove claimed:* label from issue
|
|
4017
|
+
if (pathname === "/api/claim/release" && req.method === "POST") {
|
|
4018
|
+
try {
|
|
4019
|
+
const body = (await req.json()) as any;
|
|
4020
|
+
const issue = body.issue as number;
|
|
4021
|
+
const machine = ((body.machine || body.machine_name) as string) || "";
|
|
4022
|
+
const repo = (body.repo as string) || "";
|
|
4023
|
+
|
|
4024
|
+
const repoFlag = repo ? ` -R ${repo}` : "";
|
|
4025
|
+
const label = machine ? `claimed:${machine}` : "";
|
|
4026
|
+
|
|
4027
|
+
// Find the actual claimed label if machine not specified
|
|
4028
|
+
let targetLabel = label;
|
|
4029
|
+
if (!targetLabel) {
|
|
4030
|
+
try {
|
|
4031
|
+
const labels = execSync(
|
|
4032
|
+
`gh issue view ${issue}${repoFlag} --json labels -q '.labels[].name'`,
|
|
4033
|
+
{
|
|
4034
|
+
encoding: "utf-8",
|
|
4035
|
+
timeout: 10000,
|
|
4036
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
4037
|
+
},
|
|
4038
|
+
).trim();
|
|
4039
|
+
const found = labels
|
|
4040
|
+
.split("\n")
|
|
4041
|
+
.find((l: string) => l.startsWith("claimed:"));
|
|
4042
|
+
targetLabel = found || "";
|
|
4043
|
+
} catch {
|
|
4044
|
+
/* ignore */
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
if (targetLabel) {
|
|
4049
|
+
try {
|
|
4050
|
+
execSync(
|
|
4051
|
+
`gh issue edit ${issue}${repoFlag} --remove-label "${targetLabel}"`,
|
|
4052
|
+
{
|
|
4053
|
+
timeout: 10000,
|
|
4054
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
4055
|
+
},
|
|
4056
|
+
);
|
|
4057
|
+
} catch {
|
|
4058
|
+
/* label may already be removed */
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
4063
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4064
|
+
});
|
|
4065
|
+
} catch (err) {
|
|
4066
|
+
return new Response(JSON.stringify({ ok: false, error: String(err) }), {
|
|
4067
|
+
status: 500,
|
|
4068
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4069
|
+
});
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
// POST /api/webhook/ci — Accept CI pipeline events
|
|
4074
|
+
if (pathname === "/api/webhook/ci" && req.method === "POST") {
|
|
4075
|
+
try {
|
|
4076
|
+
const body = (await req.json()) as any;
|
|
4077
|
+
const dir = join(HOME, ".shipwright");
|
|
4078
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
4079
|
+
|
|
4080
|
+
const event = {
|
|
4081
|
+
...body,
|
|
4082
|
+
from_developer: "github-actions",
|
|
4083
|
+
from_machine: "ci",
|
|
4084
|
+
received_at: new Date().toISOString(),
|
|
4085
|
+
};
|
|
4086
|
+
appendFileSync(TEAM_EVENTS_FILE, JSON.stringify(event) + "\n");
|
|
4087
|
+
|
|
4088
|
+
// Broadcast to dashboard clients
|
|
4089
|
+
if (wsClients.size > 0) {
|
|
4090
|
+
broadcastToClients(getFleetState());
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
4094
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4095
|
+
});
|
|
4096
|
+
} catch (err) {
|
|
4097
|
+
return new Response(JSON.stringify({ ok: false, error: String(err) }), {
|
|
4098
|
+
status: 500,
|
|
4099
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4100
|
+
});
|
|
4101
|
+
}
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
// POST /api/team/invite — Generate a team invite token
|
|
4105
|
+
if (pathname === "/api/team/invite" && req.method === "POST") {
|
|
4106
|
+
try {
|
|
4107
|
+
const token = crypto.randomUUID();
|
|
4108
|
+
const now = new Date();
|
|
4109
|
+
const expires = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24h
|
|
4110
|
+
inviteTokens.set(token, {
|
|
4111
|
+
token,
|
|
4112
|
+
created_at: now.toISOString(),
|
|
4113
|
+
expires_at: expires.toISOString(),
|
|
4114
|
+
});
|
|
4115
|
+
saveInviteTokens();
|
|
4116
|
+
|
|
4117
|
+
const dashboardUrl = `${url.protocol}//${url.host}`;
|
|
4118
|
+
const command = `shipwright connect join --url ${dashboardUrl} --token ${token}`;
|
|
4119
|
+
|
|
4120
|
+
return new Response(
|
|
4121
|
+
JSON.stringify({ token, command, expires_at: expires.toISOString() }),
|
|
4122
|
+
{
|
|
4123
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4124
|
+
},
|
|
4125
|
+
);
|
|
4126
|
+
} catch (err) {
|
|
4127
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
4128
|
+
status: 500,
|
|
4129
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4130
|
+
});
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
|
|
4134
|
+
// GET /api/team/invite/<token> — Verify an invite token
|
|
4135
|
+
if (pathname.startsWith("/api/team/invite/") && req.method === "GET") {
|
|
4136
|
+
const token = pathname.split("/")[4] || "";
|
|
4137
|
+
if (!token) {
|
|
4138
|
+
return new Response(
|
|
4139
|
+
JSON.stringify({ valid: false, error: "Missing token" }),
|
|
4140
|
+
{
|
|
4141
|
+
status: 400,
|
|
4142
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4143
|
+
},
|
|
4144
|
+
);
|
|
4145
|
+
}
|
|
4146
|
+
const entry = inviteTokens.get(token);
|
|
4147
|
+
if (!entry || new Date(entry.expires_at).getTime() < Date.now()) {
|
|
4148
|
+
if (entry) {
|
|
4149
|
+
inviteTokens.delete(token);
|
|
4150
|
+
saveInviteTokens();
|
|
4151
|
+
}
|
|
4152
|
+
return new Response(
|
|
4153
|
+
JSON.stringify({ valid: false, error: "Invalid or expired token" }),
|
|
4154
|
+
{
|
|
4155
|
+
status: 404,
|
|
4156
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4157
|
+
},
|
|
4158
|
+
);
|
|
4159
|
+
}
|
|
4160
|
+
const dashboardUrl = `${url.protocol}//${url.host}`;
|
|
4161
|
+
return new Response(
|
|
4162
|
+
JSON.stringify({
|
|
4163
|
+
valid: true,
|
|
4164
|
+
dashboard_url: dashboardUrl,
|
|
4165
|
+
team_name: "shipwright",
|
|
4166
|
+
expires_at: entry.expires_at,
|
|
4167
|
+
}),
|
|
4168
|
+
{
|
|
4169
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4170
|
+
},
|
|
4171
|
+
);
|
|
4172
|
+
}
|
|
4173
|
+
|
|
4174
|
+
// Static files from public/
|
|
4175
|
+
const staticResponse = serveStaticFile(pathname);
|
|
4176
|
+
if (staticResponse) return staticResponse;
|
|
4177
|
+
|
|
4178
|
+
return new Response("Not Found", { status: 404 });
|
|
4179
|
+
},
|
|
4180
|
+
|
|
4181
|
+
websocket: {
|
|
4182
|
+
open(ws) {
|
|
4183
|
+
wsClients.add(ws);
|
|
4184
|
+
// Send initial state immediately on connect
|
|
4185
|
+
try {
|
|
4186
|
+
ws.send(JSON.stringify(getFleetState()));
|
|
4187
|
+
} catch {
|
|
4188
|
+
wsClients.delete(ws);
|
|
4189
|
+
}
|
|
4190
|
+
},
|
|
4191
|
+
message(_ws, _message) {
|
|
4192
|
+
// Clients don't send meaningful messages; server is push-only
|
|
4193
|
+
},
|
|
4194
|
+
close(ws) {
|
|
4195
|
+
wsClients.delete(ws);
|
|
4196
|
+
},
|
|
4197
|
+
},
|
|
4198
|
+
});
|
|
4199
|
+
|
|
4200
|
+
// Start background tasks
|
|
4201
|
+
startEventsWatcher();
|
|
4202
|
+
loadSessions();
|
|
4203
|
+
loadDeveloperRegistry();
|
|
4204
|
+
loadInviteTokens();
|
|
4205
|
+
const pushInterval = setInterval(periodicPush, WS_PUSH_INTERVAL_MS);
|
|
4206
|
+
|
|
4207
|
+
// Stale claim reaper — runs every 5 minutes
|
|
4208
|
+
const staleClaimInterval = setInterval(
|
|
4209
|
+
() => {
|
|
4210
|
+
try {
|
|
4211
|
+
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
|
|
4212
|
+
for (const [key, dev] of developerRegistry) {
|
|
4213
|
+
if (dev.last_heartbeat < twoHoursAgo && dev.last_heartbeat > 0) {
|
|
4214
|
+
// Check if this developer has claimed issues via labels
|
|
4215
|
+
try {
|
|
4216
|
+
const result = execSync(
|
|
4217
|
+
`gh issue list --label "claimed:${dev.machine_name}" --state open --json number -q '.[].number'`,
|
|
4218
|
+
{
|
|
4219
|
+
encoding: "utf-8",
|
|
4220
|
+
timeout: 15000,
|
|
4221
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
4222
|
+
},
|
|
4223
|
+
).trim();
|
|
4224
|
+
if (result) {
|
|
4225
|
+
const issues = result.split("\n").filter(Boolean);
|
|
4226
|
+
for (const issueNum of issues) {
|
|
4227
|
+
try {
|
|
4228
|
+
execSync(
|
|
4229
|
+
`gh issue edit ${issueNum} --remove-label "claimed:${dev.machine_name}"`,
|
|
4230
|
+
{ timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
|
|
4231
|
+
);
|
|
4232
|
+
} catch {
|
|
4233
|
+
/* label may already be removed */
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
} catch {
|
|
4238
|
+
/* gh may not be available or no issues found */
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
} catch {
|
|
4243
|
+
/* reaper errors are non-fatal */
|
|
4244
|
+
}
|
|
4245
|
+
},
|
|
4246
|
+
5 * 60 * 1000,
|
|
4247
|
+
);
|
|
4248
|
+
|
|
4249
|
+
// Invite token cleanup — runs every 15 minutes
|
|
4250
|
+
const inviteCleanupInterval = setInterval(
|
|
4251
|
+
() => {
|
|
4252
|
+
try {
|
|
4253
|
+
const now = Date.now();
|
|
4254
|
+
let removed = 0;
|
|
4255
|
+
for (const [key, entry] of inviteTokens) {
|
|
4256
|
+
if (new Date(entry.expires_at).getTime() < now) {
|
|
4257
|
+
inviteTokens.delete(key);
|
|
4258
|
+
removed++;
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
if (removed > 0) saveInviteTokens();
|
|
4262
|
+
} catch {
|
|
4263
|
+
/* cleanup errors are non-fatal */
|
|
4264
|
+
}
|
|
4265
|
+
},
|
|
4266
|
+
15 * 60 * 1000,
|
|
4267
|
+
);
|
|
4268
|
+
|
|
4269
|
+
// Graceful shutdown
|
|
4270
|
+
process.on("SIGINT", () => {
|
|
4271
|
+
clearInterval(pushInterval);
|
|
4272
|
+
clearInterval(staleClaimInterval);
|
|
4273
|
+
clearInterval(inviteCleanupInterval);
|
|
4274
|
+
if (eventsWatcher) eventsWatcher.close();
|
|
4275
|
+
for (const ws of wsClients) {
|
|
4276
|
+
try {
|
|
4277
|
+
ws.close(1001, "Server shutting down");
|
|
4278
|
+
} catch {
|
|
4279
|
+
// ignore
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4282
|
+
wsClients.clear();
|
|
4283
|
+
server.stop();
|
|
4284
|
+
process.exit(0);
|
|
4285
|
+
});
|
|
4286
|
+
|
|
4287
|
+
// ─── Startup banner ──────────────────────────────────────────────────
|
|
4288
|
+
const authModeLabel = (() => {
|
|
4289
|
+
const m = getAuthMode();
|
|
4290
|
+
if (m === "oauth")
|
|
4291
|
+
return `${GREEN}\u25CF${RESET} Auth: GitHub OAuth (repo: ${DASHBOARD_REPO})`;
|
|
4292
|
+
if (m === "pat")
|
|
4293
|
+
return `${GREEN}\u25CF${RESET} Auth: PAT-verified (repo: ${DASHBOARD_REPO})`;
|
|
4294
|
+
return `${DIM}\u25CB Auth: disabled (set GITHUB_PAT + DASHBOARD_REPO, or GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET + DASHBOARD_REPO)${RESET}`;
|
|
4295
|
+
})();
|
|
4296
|
+
|
|
4297
|
+
console.log(
|
|
4298
|
+
`\n ${CYAN}\u2693${RESET} ${BOLD}Shipwright Fleet Command${RESET}`,
|
|
4299
|
+
);
|
|
4300
|
+
console.log(
|
|
4301
|
+
` ${GREEN}\u25CF${RESET} Dashboard: ${ULINE}http://localhost:${server.port}${RESET}`,
|
|
4302
|
+
);
|
|
4303
|
+
console.log(
|
|
4304
|
+
` ${GREEN}\u25CF${RESET} API: ${ULINE}http://localhost:${server.port}/api/status${RESET}`,
|
|
4305
|
+
);
|
|
4306
|
+
console.log(
|
|
4307
|
+
` ${GREEN}\u25CF${RESET} WebSocket: ${ULINE}ws://localhost:${server.port}/ws${RESET}`,
|
|
4308
|
+
);
|
|
4309
|
+
console.log(
|
|
4310
|
+
` ${GREEN}\u25CF${RESET} Health: ${ULINE}http://localhost:${server.port}/api/health${RESET}`,
|
|
4311
|
+
);
|
|
4312
|
+
console.log(` ${authModeLabel}`);
|
|
4313
|
+
console.log(
|
|
4314
|
+
` ${DIM}Push interval: ${WS_PUSH_INTERVAL_MS}ms | File watcher: ${eventsWatcher ? "active" : "fallback to interval"}${RESET}\n`,
|
|
4315
|
+
);
|