shipwright-cli 2.4.0 → 3.1.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/README.md +16 -11
- package/completions/_shipwright +248 -94
- package/completions/shipwright.bash +68 -19
- package/completions/shipwright.fish +310 -42
- package/config/decision-tiers.json +55 -0
- package/config/defaults.json +111 -0
- package/config/event-schema.json +218 -0
- package/config/policy.json +21 -18
- package/dashboard/coverage/coverage-summary.json +14 -0
- package/dashboard/public/index.html +1 -1
- package/dashboard/server.ts +306 -17
- package/dashboard/src/components/charts/bar.test.ts +79 -0
- package/dashboard/src/components/charts/donut.test.ts +68 -0
- package/dashboard/src/components/charts/pipeline-rail.test.ts +117 -0
- package/dashboard/src/components/charts/sparkline.test.ts +125 -0
- package/dashboard/src/core/api.test.ts +309 -0
- package/dashboard/src/core/helpers.test.ts +301 -0
- package/dashboard/src/core/router.test.ts +307 -0
- package/dashboard/src/core/router.ts +7 -0
- package/dashboard/src/core/sse.test.ts +144 -0
- package/dashboard/src/views/metrics.test.ts +186 -0
- package/dashboard/src/views/overview.test.ts +173 -0
- package/dashboard/src/views/pipelines.test.ts +183 -0
- package/dashboard/src/views/team.test.ts +253 -0
- package/dashboard/vitest.config.ts +14 -5
- package/docs/TIPS.md +1 -1
- package/docs/patterns/README.md +1 -1
- package/package.json +7 -9
- package/scripts/adapters/docker-deploy.sh +1 -1
- package/scripts/adapters/tmux-adapter.sh +11 -1
- package/scripts/adapters/wezterm-adapter.sh +1 -1
- package/scripts/check-version-consistency.sh +1 -1
- package/scripts/lib/architecture.sh +127 -0
- package/scripts/lib/bootstrap.sh +75 -0
- package/scripts/lib/compat.sh +89 -6
- package/scripts/lib/config.sh +91 -0
- package/scripts/lib/daemon-adaptive.sh +3 -3
- package/scripts/lib/daemon-dispatch.sh +63 -17
- package/scripts/lib/daemon-failure.sh +0 -0
- package/scripts/lib/daemon-health.sh +1 -1
- package/scripts/lib/daemon-patrol.sh +64 -17
- package/scripts/lib/daemon-poll.sh +54 -25
- package/scripts/lib/daemon-state.sh +125 -23
- package/scripts/lib/daemon-triage.sh +31 -9
- package/scripts/lib/decide-autonomy.sh +295 -0
- package/scripts/lib/decide-scoring.sh +228 -0
- package/scripts/lib/decide-signals.sh +462 -0
- package/scripts/lib/fleet-failover.sh +63 -0
- package/scripts/lib/helpers.sh +29 -6
- package/scripts/lib/pipeline-detection.sh +2 -2
- package/scripts/lib/pipeline-github.sh +9 -9
- package/scripts/lib/pipeline-intelligence.sh +105 -38
- package/scripts/lib/pipeline-quality-checks.sh +17 -16
- package/scripts/lib/pipeline-quality.sh +1 -1
- package/scripts/lib/pipeline-stages.sh +440 -59
- package/scripts/lib/pipeline-state.sh +54 -4
- package/scripts/lib/policy.sh +0 -0
- package/scripts/lib/test-helpers.sh +247 -0
- package/scripts/postinstall.mjs +78 -12
- package/scripts/signals/example-collector.sh +36 -0
- package/scripts/sw +17 -7
- package/scripts/sw-activity.sh +1 -11
- package/scripts/sw-adaptive.sh +109 -85
- package/scripts/sw-adversarial.sh +4 -14
- package/scripts/sw-architecture-enforcer.sh +1 -11
- package/scripts/sw-auth.sh +8 -17
- package/scripts/sw-autonomous.sh +111 -49
- package/scripts/sw-changelog.sh +1 -11
- package/scripts/sw-checkpoint.sh +144 -20
- package/scripts/sw-ci.sh +2 -12
- package/scripts/sw-cleanup.sh +13 -17
- package/scripts/sw-code-review.sh +16 -36
- package/scripts/sw-connect.sh +5 -12
- package/scripts/sw-context.sh +9 -26
- package/scripts/sw-cost.sh +17 -18
- package/scripts/sw-daemon.sh +76 -71
- package/scripts/sw-dashboard.sh +57 -17
- package/scripts/sw-db.sh +524 -26
- package/scripts/sw-decide.sh +685 -0
- package/scripts/sw-decompose.sh +1 -11
- package/scripts/sw-deps.sh +15 -25
- package/scripts/sw-developer-simulation.sh +1 -11
- package/scripts/sw-discovery.sh +138 -30
- package/scripts/sw-doc-fleet.sh +7 -17
- package/scripts/sw-docs-agent.sh +6 -16
- package/scripts/sw-docs.sh +4 -12
- package/scripts/sw-doctor.sh +134 -43
- package/scripts/sw-dora.sh +11 -19
- package/scripts/sw-durable.sh +35 -52
- package/scripts/sw-e2e-orchestrator.sh +11 -27
- package/scripts/sw-eventbus.sh +115 -115
- package/scripts/sw-evidence.sh +114 -30
- package/scripts/sw-feedback.sh +3 -13
- package/scripts/sw-fix.sh +2 -20
- package/scripts/sw-fleet-discover.sh +1 -11
- package/scripts/sw-fleet-viz.sh +10 -18
- package/scripts/sw-fleet.sh +13 -17
- package/scripts/sw-github-app.sh +6 -16
- package/scripts/sw-github-checks.sh +1 -11
- package/scripts/sw-github-deploy.sh +1 -11
- package/scripts/sw-github-graphql.sh +2 -12
- package/scripts/sw-guild.sh +1 -11
- package/scripts/sw-heartbeat.sh +49 -12
- package/scripts/sw-hygiene.sh +45 -43
- package/scripts/sw-incident.sh +48 -74
- package/scripts/sw-init.sh +35 -37
- package/scripts/sw-instrument.sh +1 -11
- package/scripts/sw-intelligence.sh +368 -53
- package/scripts/sw-jira.sh +5 -14
- package/scripts/sw-launchd.sh +2 -12
- package/scripts/sw-linear.sh +8 -17
- package/scripts/sw-logs.sh +4 -12
- package/scripts/sw-loop.sh +905 -104
- package/scripts/sw-memory.sh +263 -20
- package/scripts/sw-mission-control.sh +2 -12
- package/scripts/sw-model-router.sh +73 -34
- package/scripts/sw-otel.sh +15 -23
- package/scripts/sw-oversight.sh +1 -11
- package/scripts/sw-patrol-meta.sh +5 -11
- package/scripts/sw-pipeline-composer.sh +7 -17
- package/scripts/sw-pipeline-vitals.sh +1 -11
- package/scripts/sw-pipeline.sh +550 -122
- package/scripts/sw-pm.sh +2 -12
- package/scripts/sw-pr-lifecycle.sh +33 -28
- package/scripts/sw-predictive.sh +16 -22
- package/scripts/sw-prep.sh +6 -16
- package/scripts/sw-ps.sh +1 -11
- package/scripts/sw-public-dashboard.sh +2 -12
- package/scripts/sw-quality.sh +85 -14
- package/scripts/sw-reaper.sh +1 -11
- package/scripts/sw-recruit.sh +15 -25
- package/scripts/sw-regression.sh +11 -21
- package/scripts/sw-release-manager.sh +19 -28
- package/scripts/sw-release.sh +8 -16
- package/scripts/sw-remote.sh +1 -11
- package/scripts/sw-replay.sh +48 -44
- package/scripts/sw-retro.sh +70 -92
- package/scripts/sw-review-rerun.sh +1 -1
- package/scripts/sw-scale.sh +174 -41
- package/scripts/sw-security-audit.sh +12 -22
- package/scripts/sw-self-optimize.sh +239 -23
- package/scripts/sw-session.sh +5 -15
- package/scripts/sw-setup.sh +8 -18
- package/scripts/sw-standup.sh +5 -15
- package/scripts/sw-status.sh +32 -23
- package/scripts/sw-strategic.sh +129 -13
- package/scripts/sw-stream.sh +1 -11
- package/scripts/sw-swarm.sh +76 -36
- package/scripts/sw-team-stages.sh +10 -20
- package/scripts/sw-templates.sh +4 -14
- package/scripts/sw-testgen.sh +3 -13
- package/scripts/sw-tmux-pipeline.sh +1 -19
- package/scripts/sw-tmux-role-color.sh +0 -10
- package/scripts/sw-tmux-status.sh +3 -11
- package/scripts/sw-tmux.sh +2 -20
- package/scripts/sw-trace.sh +1 -19
- package/scripts/sw-tracker-github.sh +0 -10
- package/scripts/sw-tracker-jira.sh +1 -11
- package/scripts/sw-tracker-linear.sh +1 -11
- package/scripts/sw-tracker.sh +7 -24
- package/scripts/sw-triage.sh +29 -39
- package/scripts/sw-upgrade.sh +5 -23
- package/scripts/sw-ux.sh +1 -19
- package/scripts/sw-webhook.sh +18 -32
- package/scripts/sw-widgets.sh +3 -21
- package/scripts/sw-worktree.sh +11 -27
- package/scripts/update-homebrew-sha.sh +73 -0
- package/templates/pipelines/tdd.json +72 -0
- package/scripts/sw-pipeline.sh.mock +0 -7
package/dashboard/server.ts
CHANGED
|
@@ -57,6 +57,21 @@ function getDb(): Database | null {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function dbQueryEventsByIdGreaterThan(
|
|
61
|
+
afterId: number,
|
|
62
|
+
limit = 100,
|
|
63
|
+
): Array<Record<string, unknown>> {
|
|
64
|
+
const conn = getDb();
|
|
65
|
+
if (!conn) return [];
|
|
66
|
+
try {
|
|
67
|
+
return conn
|
|
68
|
+
.query(`SELECT * FROM events WHERE id > ? ORDER BY id ASC LIMIT ?`)
|
|
69
|
+
.all(afterId, limit) as Array<Record<string, unknown>>;
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
function dbQueryEvents(since?: number, limit = 200): DaemonEvent[] {
|
|
61
76
|
const conn = getDb();
|
|
62
77
|
if (!conn) return [];
|
|
@@ -475,6 +490,7 @@ function isPublicRoute(pathname: string): boolean {
|
|
|
475
490
|
pathname === "/login" ||
|
|
476
491
|
pathname.startsWith("/auth/") ||
|
|
477
492
|
pathname === "/api/health" ||
|
|
493
|
+
pathname === "/api/ws-status" ||
|
|
478
494
|
pathname.startsWith("/api/join/") ||
|
|
479
495
|
pathname.startsWith("/api/connect/") ||
|
|
480
496
|
pathname === "/api/team" ||
|
|
@@ -876,6 +892,8 @@ function appendAuditLog(
|
|
|
876
892
|
|
|
877
893
|
// ─── WebSocket client tracking ───────────────────────────────────────
|
|
878
894
|
const wsClients = new Set<import("bun").ServerWebSocket<unknown>>();
|
|
895
|
+
const eventClients = new Set<import("bun").ServerWebSocket<unknown>>();
|
|
896
|
+
let lastBroadcastEventId = 0;
|
|
879
897
|
const startTime = Date.now();
|
|
880
898
|
|
|
881
899
|
function broadcastToClients(data: FleetState): void {
|
|
@@ -889,6 +907,30 @@ function broadcastToClients(data: FleetState): void {
|
|
|
889
907
|
}
|
|
890
908
|
}
|
|
891
909
|
|
|
910
|
+
function broadcastNewEvents(): void {
|
|
911
|
+
if (eventClients.size === 0) return;
|
|
912
|
+
const db = getDb();
|
|
913
|
+
if (!db) return;
|
|
914
|
+
try {
|
|
915
|
+
const newEvents = dbQueryEventsByIdGreaterThan(lastBroadcastEventId, 100);
|
|
916
|
+
if (newEvents.length > 0) {
|
|
917
|
+
const eventMsg = JSON.stringify({ type: "events", data: newEvents });
|
|
918
|
+
for (const ws of eventClients) {
|
|
919
|
+
try {
|
|
920
|
+
ws.send(eventMsg);
|
|
921
|
+
} catch {
|
|
922
|
+
eventClients.delete(ws);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
const lastRow = newEvents[newEvents.length - 1];
|
|
926
|
+
const lastId = (lastRow?.id as number) | 0;
|
|
927
|
+
if (lastId > 0) lastBroadcastEventId = lastId;
|
|
928
|
+
}
|
|
929
|
+
} catch {
|
|
930
|
+
/* non-fatal */
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
892
934
|
// ─── Data Collection ─────────────────────────────────────────────────
|
|
893
935
|
function readEvents(): DaemonEvent[] {
|
|
894
936
|
// Try SQLite first (faster for large event logs)
|
|
@@ -917,6 +959,47 @@ function readEvents(): DaemonEvent[] {
|
|
|
917
959
|
}
|
|
918
960
|
|
|
919
961
|
function readDaemonState(): Record<string, unknown> | null {
|
|
962
|
+
// Try DB first
|
|
963
|
+
try {
|
|
964
|
+
const conn = getDb();
|
|
965
|
+
if (conn) {
|
|
966
|
+
const active = dbQueryJobs("active") as Array<Record<string, unknown>>;
|
|
967
|
+
const completedRows = conn
|
|
968
|
+
.query(
|
|
969
|
+
"SELECT * FROM daemon_state WHERE status IN ('completed', 'failed') ORDER BY completed_at DESC LIMIT 20",
|
|
970
|
+
)
|
|
971
|
+
.all() as Array<Record<string, unknown>>;
|
|
972
|
+
if (active.length > 0 || completedRows.length > 0) {
|
|
973
|
+
const activeJobs = active.map((j) => ({
|
|
974
|
+
job_id: j.job_id,
|
|
975
|
+
issue: j.issue_number,
|
|
976
|
+
title: j.title || "",
|
|
977
|
+
stage: j.stage_name || "",
|
|
978
|
+
started_at: j.started_at,
|
|
979
|
+
started_epoch: j.started_at
|
|
980
|
+
? Math.floor(new Date(j.started_at as string).getTime() / 1000)
|
|
981
|
+
: 0,
|
|
982
|
+
worktree: j.worktree || "",
|
|
983
|
+
branch: j.branch || "",
|
|
984
|
+
pid: j.pid || 0,
|
|
985
|
+
}));
|
|
986
|
+
const completed = completedRows.map((j) => ({
|
|
987
|
+
issue: j.issue_number,
|
|
988
|
+
result: j.result || "",
|
|
989
|
+
duration: j.duration || "",
|
|
990
|
+
completed_at: j.completed_at || "",
|
|
991
|
+
}));
|
|
992
|
+
return {
|
|
993
|
+
active_jobs: activeJobs,
|
|
994
|
+
completed,
|
|
995
|
+
queued: [] as number[],
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
} catch {
|
|
1000
|
+
/* fall through to file */
|
|
1001
|
+
}
|
|
1002
|
+
// Fallback to file
|
|
920
1003
|
if (!existsSync(DAEMON_STATE)) return null;
|
|
921
1004
|
try {
|
|
922
1005
|
return JSON.parse(readFileSync(DAEMON_STATE, "utf-8"));
|
|
@@ -1546,7 +1629,63 @@ function getAgents(): AgentInfo[] {
|
|
|
1546
1629
|
}
|
|
1547
1630
|
}
|
|
1548
1631
|
|
|
1549
|
-
//
|
|
1632
|
+
// Try DB heartbeats first
|
|
1633
|
+
try {
|
|
1634
|
+
const conn = getDb();
|
|
1635
|
+
if (conn) {
|
|
1636
|
+
const rows = dbQueryHeartbeats();
|
|
1637
|
+
if (rows.length > 0) {
|
|
1638
|
+
for (const hb of rows) {
|
|
1639
|
+
const jobId = String(hb.job_id || "");
|
|
1640
|
+
const issue = (hb.issue as number) || 0;
|
|
1641
|
+
const updatedAt = (hb.updated_at as string) || "";
|
|
1642
|
+
let hbEpoch = 0;
|
|
1643
|
+
try {
|
|
1644
|
+
hbEpoch = Math.floor(new Date(updatedAt).getTime() / 1000);
|
|
1645
|
+
} catch {
|
|
1646
|
+
/* ignore */
|
|
1647
|
+
}
|
|
1648
|
+
const age = hbEpoch > 0 ? now - hbEpoch : 9999;
|
|
1649
|
+
|
|
1650
|
+
let status: AgentInfo["status"] = "active";
|
|
1651
|
+
if (age > 120) status = "stale";
|
|
1652
|
+
else if (age > 30) status = "idle";
|
|
1653
|
+
|
|
1654
|
+
const job = issue ? jobMap[issue] : undefined;
|
|
1655
|
+
const startedAt = job ? (job.started_at as string) || "" : updatedAt;
|
|
1656
|
+
let elapsed = 0;
|
|
1657
|
+
if (startedAt) {
|
|
1658
|
+
try {
|
|
1659
|
+
elapsed = now - Math.floor(new Date(startedAt).getTime() / 1000);
|
|
1660
|
+
} catch {
|
|
1661
|
+
/* ignore */
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
agents.push({
|
|
1666
|
+
id: jobId,
|
|
1667
|
+
issue,
|
|
1668
|
+
title: job ? (job.title as string) || "" : "",
|
|
1669
|
+
machine: (hb.machine as string) || "localhost",
|
|
1670
|
+
stage: (hb.stage as string) || "",
|
|
1671
|
+
iteration: (hb.iteration as number) || 0,
|
|
1672
|
+
activity: (hb.last_activity as string) || "",
|
|
1673
|
+
memory_mb: (hb.memory_mb as number) || 0,
|
|
1674
|
+
cpu_pct: (hb.cpu_pct as number) || 0,
|
|
1675
|
+
status,
|
|
1676
|
+
heartbeat_age_s: age,
|
|
1677
|
+
started_at: startedAt,
|
|
1678
|
+
elapsed_s: elapsed,
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
return agents;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
} catch {
|
|
1685
|
+
/* fall through to file */
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// Fallback: Read heartbeat files
|
|
1550
1689
|
if (existsSync(HEARTBEAT_DIR)) {
|
|
1551
1690
|
try {
|
|
1552
1691
|
const files = readdirSync(HEARTBEAT_DIR).filter((f) =>
|
|
@@ -1869,10 +2008,48 @@ interface CostInfo {
|
|
|
1869
2008
|
pct_used: number;
|
|
1870
2009
|
}
|
|
1871
2010
|
|
|
2011
|
+
function dbQueryBudget(): { dailyBudget: number } {
|
|
2012
|
+
const conn = getDb();
|
|
2013
|
+
if (!conn) return { dailyBudget: 0 };
|
|
2014
|
+
try {
|
|
2015
|
+
const row = conn
|
|
2016
|
+
.query("SELECT daily_budget_usd, enabled FROM budgets WHERE id = 1")
|
|
2017
|
+
.get() as { daily_budget_usd: number; enabled: number } | null;
|
|
2018
|
+
if (!row || row.enabled !== 1) return { dailyBudget: 0 };
|
|
2019
|
+
return { dailyBudget: row.daily_budget_usd || 0 };
|
|
2020
|
+
} catch {
|
|
2021
|
+
return { dailyBudget: 0 };
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
1872
2025
|
function getCostInfo(): CostInfo {
|
|
1873
2026
|
let todaySpent = 0;
|
|
1874
2027
|
let dailyBudget = 0;
|
|
1875
2028
|
|
|
2029
|
+
// Try DB first
|
|
2030
|
+
try {
|
|
2031
|
+
const conn = getDb();
|
|
2032
|
+
if (conn) {
|
|
2033
|
+
const costs = dbQueryCostsToday();
|
|
2034
|
+
if (costs.count > 0 || costs.total > 0) {
|
|
2035
|
+
todaySpent = Math.round(costs.total * 100) / 100;
|
|
2036
|
+
const budget = dbQueryBudget();
|
|
2037
|
+
dailyBudget = budget.dailyBudget;
|
|
2038
|
+
const pctUsed =
|
|
2039
|
+
dailyBudget > 0
|
|
2040
|
+
? Math.round((todaySpent / dailyBudget) * 10000) / 100
|
|
2041
|
+
: 0;
|
|
2042
|
+
return {
|
|
2043
|
+
today_spent: todaySpent,
|
|
2044
|
+
daily_budget: dailyBudget,
|
|
2045
|
+
pct_used: pctUsed,
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
} catch {
|
|
2050
|
+
/* fall through to file */
|
|
2051
|
+
}
|
|
2052
|
+
|
|
1876
2053
|
if (existsSync(COSTS_FILE)) {
|
|
1877
2054
|
try {
|
|
1878
2055
|
const data = JSON.parse(readFileSync(COSTS_FILE, "utf-8"));
|
|
@@ -2126,6 +2303,7 @@ function startEventsWatcher(): void {
|
|
|
2126
2303
|
// Check for new events and send notifications
|
|
2127
2304
|
if (filename === "events.jsonl") {
|
|
2128
2305
|
checkAndNotifyNewEvents();
|
|
2306
|
+
broadcastNewEvents();
|
|
2129
2307
|
}
|
|
2130
2308
|
}
|
|
2131
2309
|
});
|
|
@@ -2138,15 +2316,22 @@ function startEventsWatcher(): void {
|
|
|
2138
2316
|
let lastPushedJson = "";
|
|
2139
2317
|
|
|
2140
2318
|
function periodicPush(): void {
|
|
2141
|
-
|
|
2319
|
+
const hasStateClients = wsClients.size > 0;
|
|
2320
|
+
const hasEventClients = eventClients.size > 0;
|
|
2142
2321
|
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2322
|
+
if (hasStateClients) {
|
|
2323
|
+
const state = getFleetState();
|
|
2324
|
+
const json = JSON.stringify(state);
|
|
2325
|
+
// Skip push if nothing changed (file watcher already pushed)
|
|
2326
|
+
if (json !== lastPushedJson) {
|
|
2327
|
+
lastPushedJson = json;
|
|
2328
|
+
broadcastToClients(state);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2148
2331
|
|
|
2149
|
-
|
|
2332
|
+
if (hasEventClients) {
|
|
2333
|
+
broadcastNewEvents();
|
|
2334
|
+
}
|
|
2150
2335
|
}
|
|
2151
2336
|
|
|
2152
2337
|
// ─── GitHub OAuth helpers ───────────────────────────────────────────
|
|
@@ -2397,6 +2582,24 @@ const server = Bun.serve({
|
|
|
2397
2582
|
});
|
|
2398
2583
|
}
|
|
2399
2584
|
|
|
2585
|
+
// GET /api/ws-status — WebSocket connection status (for evidence collection)
|
|
2586
|
+
if (pathname === "/api/ws-status" && req.method === "GET") {
|
|
2587
|
+
const clients = wsClients?.size ?? 0;
|
|
2588
|
+
return new Response(
|
|
2589
|
+
JSON.stringify({
|
|
2590
|
+
status: "ok",
|
|
2591
|
+
websocket: {
|
|
2592
|
+
active_connections: clients,
|
|
2593
|
+
server_running: true,
|
|
2594
|
+
},
|
|
2595
|
+
timestamp: new Date().toISOString(),
|
|
2596
|
+
}),
|
|
2597
|
+
{
|
|
2598
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2599
|
+
},
|
|
2600
|
+
);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2400
2603
|
// GET /api/join/{token} — Serve join script (public, no auth required)
|
|
2401
2604
|
if (pathname.startsWith("/api/join/") && req.method === "GET") {
|
|
2402
2605
|
const token = pathname.split("/")[3] || "";
|
|
@@ -2492,7 +2695,7 @@ const server = Bun.serve({
|
|
|
2492
2695
|
const session = getSession(req);
|
|
2493
2696
|
if (!session) {
|
|
2494
2697
|
// WebSocket upgrade attempt without auth
|
|
2495
|
-
if (pathname === "/ws") {
|
|
2698
|
+
if (pathname === "/ws" || pathname === "/ws/events") {
|
|
2496
2699
|
return new Response("Unauthorized", { status: 401 });
|
|
2497
2700
|
}
|
|
2498
2701
|
return new Response(null, {
|
|
@@ -2504,7 +2707,14 @@ const server = Bun.serve({
|
|
|
2504
2707
|
|
|
2505
2708
|
// ── Protected routes ──────────────────────────────────────────
|
|
2506
2709
|
|
|
2507
|
-
// WebSocket upgrade
|
|
2710
|
+
// WebSocket upgrade — /ws/events streams raw events, /ws streams aggregated state
|
|
2711
|
+
if (pathname === "/ws/events") {
|
|
2712
|
+
const upgraded = server.upgrade(req, {
|
|
2713
|
+
data: { type: "events", lastEventId: 0 },
|
|
2714
|
+
});
|
|
2715
|
+
if (upgraded) return undefined as unknown as Response;
|
|
2716
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
2717
|
+
}
|
|
2508
2718
|
if (pathname === "/ws") {
|
|
2509
2719
|
const upgraded = server.upgrade(req);
|
|
2510
2720
|
if (upgraded) return undefined as unknown as Response;
|
|
@@ -5289,6 +5499,68 @@ const server = Bun.serve({
|
|
|
5289
5499
|
);
|
|
5290
5500
|
}
|
|
5291
5501
|
|
|
5502
|
+
// VERIFY: re-read labels — if competing claimed:* labels exist, we lost the race
|
|
5503
|
+
try {
|
|
5504
|
+
const verifyLabels = execSync(
|
|
5505
|
+
`gh issue view ${issue}${repoFlag} --json labels -q '.labels[].name'`,
|
|
5506
|
+
{
|
|
5507
|
+
encoding: "utf-8",
|
|
5508
|
+
timeout: 10000,
|
|
5509
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
5510
|
+
},
|
|
5511
|
+
)
|
|
5512
|
+
.trim()
|
|
5513
|
+
.split("\n")
|
|
5514
|
+
.filter((l: string) => l.startsWith("claimed:"));
|
|
5515
|
+
if (
|
|
5516
|
+
verifyLabels.length !== 1 ||
|
|
5517
|
+
verifyLabels[0] !== `claimed:${machine}`
|
|
5518
|
+
) {
|
|
5519
|
+
// Competing claim — remove ours and reject
|
|
5520
|
+
execSync(
|
|
5521
|
+
`gh issue edit ${issue}${repoFlag} --remove-label "claimed:${machine}"`,
|
|
5522
|
+
{ timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
|
|
5523
|
+
);
|
|
5524
|
+
return new Response(
|
|
5525
|
+
JSON.stringify({
|
|
5526
|
+
approved: false,
|
|
5527
|
+
claimed_by:
|
|
5528
|
+
verifyLabels[0]?.replace("claimed:", "") || "another machine",
|
|
5529
|
+
error: "Claim race lost",
|
|
5530
|
+
}),
|
|
5531
|
+
{
|
|
5532
|
+
headers: {
|
|
5533
|
+
"Content-Type": "application/json",
|
|
5534
|
+
...CORS_HEADERS,
|
|
5535
|
+
},
|
|
5536
|
+
},
|
|
5537
|
+
);
|
|
5538
|
+
}
|
|
5539
|
+
} catch {
|
|
5540
|
+
// Verification failed — conservative: remove our label and reject
|
|
5541
|
+
try {
|
|
5542
|
+
execSync(
|
|
5543
|
+
`gh issue edit ${issue}${repoFlag} --remove-label "claimed:${machine}"`,
|
|
5544
|
+
{ timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
|
|
5545
|
+
);
|
|
5546
|
+
} catch {
|
|
5547
|
+
/* best-effort cleanup */
|
|
5548
|
+
}
|
|
5549
|
+
return new Response(
|
|
5550
|
+
JSON.stringify({
|
|
5551
|
+
approved: false,
|
|
5552
|
+
error: "Claim verification failed",
|
|
5553
|
+
}),
|
|
5554
|
+
{
|
|
5555
|
+
status: 500,
|
|
5556
|
+
headers: {
|
|
5557
|
+
"Content-Type": "application/json",
|
|
5558
|
+
...CORS_HEADERS,
|
|
5559
|
+
},
|
|
5560
|
+
},
|
|
5561
|
+
);
|
|
5562
|
+
}
|
|
5563
|
+
|
|
5292
5564
|
return new Response(
|
|
5293
5565
|
JSON.stringify({ approved: true, claimed_by: machine }),
|
|
5294
5566
|
{
|
|
@@ -5473,12 +5745,20 @@ const server = Bun.serve({
|
|
|
5473
5745
|
|
|
5474
5746
|
websocket: {
|
|
5475
5747
|
open(ws) {
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5748
|
+
const data = ws.data as
|
|
5749
|
+
| { type?: string; lastEventId?: number }
|
|
5750
|
+
| undefined;
|
|
5751
|
+
if (data?.type === "events") {
|
|
5752
|
+
eventClients.add(ws);
|
|
5753
|
+
// Event clients get events via broadcastNewEvents; no initial state
|
|
5754
|
+
} else {
|
|
5755
|
+
wsClients.add(ws);
|
|
5756
|
+
// Send initial state immediately on connect
|
|
5757
|
+
try {
|
|
5758
|
+
ws.send(JSON.stringify(getFleetState()));
|
|
5759
|
+
} catch {
|
|
5760
|
+
wsClients.delete(ws);
|
|
5761
|
+
}
|
|
5482
5762
|
}
|
|
5483
5763
|
},
|
|
5484
5764
|
message(_ws, _message) {
|
|
@@ -5486,6 +5766,7 @@ const server = Bun.serve({
|
|
|
5486
5766
|
},
|
|
5487
5767
|
close(ws) {
|
|
5488
5768
|
wsClients.delete(ws);
|
|
5769
|
+
eventClients.delete(ws);
|
|
5489
5770
|
},
|
|
5490
5771
|
},
|
|
5491
5772
|
});
|
|
@@ -5572,7 +5853,15 @@ process.on("SIGINT", () => {
|
|
|
5572
5853
|
// ignore
|
|
5573
5854
|
}
|
|
5574
5855
|
}
|
|
5856
|
+
for (const ws of eventClients) {
|
|
5857
|
+
try {
|
|
5858
|
+
ws.close(1001, "Server shutting down");
|
|
5859
|
+
} catch {
|
|
5860
|
+
// ignore
|
|
5861
|
+
}
|
|
5862
|
+
}
|
|
5575
5863
|
wsClients.clear();
|
|
5864
|
+
eventClients.clear();
|
|
5576
5865
|
server.stop();
|
|
5577
5866
|
process.exit(0);
|
|
5578
5867
|
});
|
|
@@ -5597,7 +5886,7 @@ console.log(
|
|
|
5597
5886
|
` ${GREEN}\u25CF${RESET} API: ${ULINE}http://localhost:${server.port}/api/status${RESET}`,
|
|
5598
5887
|
);
|
|
5599
5888
|
console.log(
|
|
5600
|
-
` ${GREEN}\u25CF${RESET} WebSocket: ${ULINE}ws://localhost:${server.port}/ws${RESET}`,
|
|
5889
|
+
` ${GREEN}\u25CF${RESET} WebSocket: ${ULINE}ws://localhost:${server.port}/ws${RESET} | ${ULINE}/ws/events${RESET}`,
|
|
5601
5890
|
);
|
|
5602
5891
|
console.log(
|
|
5603
5892
|
` ${GREEN}\u25CF${RESET} Health: ${ULINE}http://localhost:${server.port}/api/health${RESET}`,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderSVGBarChart } from "./bar";
|
|
3
|
+
|
|
4
|
+
describe("bar", () => {
|
|
5
|
+
describe("renderSVGBarChart", () => {
|
|
6
|
+
it("returns empty string for empty data", () => {
|
|
7
|
+
expect(renderSVGBarChart([])).toBe("");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns empty string for null/undefined", () => {
|
|
11
|
+
expect(renderSVGBarChart(null as unknown as never[])).toBe("");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns SVG with rect for single bar", () => {
|
|
15
|
+
const data = [{ date: "2025-02-17", completed: 5, failed: 2 }];
|
|
16
|
+
const svg = renderSVGBarChart(data);
|
|
17
|
+
expect(svg).toContain('<svg class="svg-bar-chart"');
|
|
18
|
+
expect(svg).toContain("<rect");
|
|
19
|
+
expect(svg).toContain('fill="#4ade80"');
|
|
20
|
+
expect(svg).toContain('fill="#f43f5e"');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns SVG with multiple rects for multiple bars", () => {
|
|
24
|
+
const data = [
|
|
25
|
+
{ date: "2025-02-15", completed: 3, failed: 0 },
|
|
26
|
+
{ date: "2025-02-16", completed: 5, failed: 1 },
|
|
27
|
+
{ date: "2025-02-17", completed: 2, failed: 3 },
|
|
28
|
+
];
|
|
29
|
+
const svg = renderSVGBarChart(data);
|
|
30
|
+
expect(svg).toContain('<svg class="svg-bar-chart"');
|
|
31
|
+
const rectCount = (svg.match(/<rect/g) || []).length;
|
|
32
|
+
expect(rectCount).toBeGreaterThanOrEqual(3);
|
|
33
|
+
const textCount = (svg.match(/<text/g) || []).length;
|
|
34
|
+
expect(textCount).toBe(3);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders zero-value bars as thin rect (1px)", () => {
|
|
38
|
+
const data = [{ date: "2025-02-17", completed: 0, failed: 0 }];
|
|
39
|
+
const svg = renderSVGBarChart(data);
|
|
40
|
+
expect(svg).toContain("<rect");
|
|
41
|
+
expect(svg).toContain('height="1"');
|
|
42
|
+
expect(svg).toContain('fill="#0d1f3c"');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("renders labels from date (MM/DD format)", () => {
|
|
46
|
+
const data = [{ date: "2025-02-17", completed: 1, failed: 0 }];
|
|
47
|
+
const svg = renderSVGBarChart(data);
|
|
48
|
+
expect(svg).toContain("02/17");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("handles dates without enough parts (falls back to full date)", () => {
|
|
52
|
+
const data = [{ date: "2025", completed: 1, failed: 0 }];
|
|
53
|
+
const svg = renderSVGBarChart(data);
|
|
54
|
+
expect(svg).toContain("2025");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("escapes HTML in labels", () => {
|
|
58
|
+
const data = [{ date: "2025-02-17<script>", completed: 1, failed: 0 }];
|
|
59
|
+
const svg = renderSVGBarChart(data);
|
|
60
|
+
expect(svg).toContain("<script>");
|
|
61
|
+
expect(svg).not.toContain("<script>");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("handles missing completed/failed (treats as 0)", () => {
|
|
65
|
+
const data = [
|
|
66
|
+
{ date: "2025-02-17", completed: undefined, failed: undefined },
|
|
67
|
+
];
|
|
68
|
+
const svg = renderSVGBarChart(data as never[]);
|
|
69
|
+
expect(svg).toContain("<rect");
|
|
70
|
+
expect(svg).toContain('height="1"');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("has correct viewBox dimensions", () => {
|
|
74
|
+
const data = [{ date: "2025-02-17", completed: 5, failed: 2 }];
|
|
75
|
+
const svg = renderSVGBarChart(data);
|
|
76
|
+
expect(svg).toContain('viewBox="0 0 700 120"');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderSVGDonut } from "./donut";
|
|
3
|
+
|
|
4
|
+
describe("donut", () => {
|
|
5
|
+
describe("renderSVGDonut", () => {
|
|
6
|
+
it("renders SVG with zero rate", () => {
|
|
7
|
+
const svg = renderSVGDonut(0);
|
|
8
|
+
expect(svg).toContain('<svg class="svg-donut"');
|
|
9
|
+
expect(svg).toContain('viewBox="0 0 120 120"');
|
|
10
|
+
expect(svg).toContain("<circle");
|
|
11
|
+
expect(svg).toContain("0.0%");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("renders SVG with single segment (partial fill)", () => {
|
|
15
|
+
const svg = renderSVGDonut(50);
|
|
16
|
+
expect(svg).toContain('<svg class="svg-donut"');
|
|
17
|
+
expect(svg).toContain("<circle");
|
|
18
|
+
expect(svg).toContain("50.0%");
|
|
19
|
+
expect(svg).toContain("stroke-dasharray");
|
|
20
|
+
expect(svg).toContain("stroke-dashoffset");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders SVG with full segment (100%)", () => {
|
|
24
|
+
const svg = renderSVGDonut(100);
|
|
25
|
+
expect(svg).toContain('<svg class="svg-donut"');
|
|
26
|
+
expect(svg).toContain("100.0%");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("contains expected SVG elements: circle, defs, text", () => {
|
|
30
|
+
const svg = renderSVGDonut(75);
|
|
31
|
+
expect(svg).toContain("<defs>");
|
|
32
|
+
expect(svg).toContain("linearGradient");
|
|
33
|
+
expect(svg).toContain("donut-grad");
|
|
34
|
+
expect(svg).toContain("<circle");
|
|
35
|
+
expect(svg).toContain("<text");
|
|
36
|
+
expect(svg).toContain('text-anchor="middle"');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("clamps negative rate to 0", () => {
|
|
40
|
+
const svg = renderSVGDonut(-10);
|
|
41
|
+
expect(svg).toContain("0.0%");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("clamps rate above 100 to 100", () => {
|
|
45
|
+
const svg = renderSVGDonut(150);
|
|
46
|
+
expect(svg).toContain("100.0%");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders label with percentage", () => {
|
|
50
|
+
const svg = renderSVGDonut(33.5);
|
|
51
|
+
expect(svg).toContain("33.5%");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("has fixed size 120x120", () => {
|
|
55
|
+
const svg = renderSVGDonut(25);
|
|
56
|
+
expect(svg).toContain('width="120"');
|
|
57
|
+
expect(svg).toContain('height="120"');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("uses background circle and gradient stroke circle", () => {
|
|
61
|
+
const svg = renderSVGDonut(50);
|
|
62
|
+
const circleCount = (svg.match(/<circle/g) || []).length;
|
|
63
|
+
expect(circleCount).toBe(2);
|
|
64
|
+
expect(svg).toContain('stroke="#0d1f3c"');
|
|
65
|
+
expect(svg).toContain('stroke="url(#donut-grad)"');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|