shipwright-cli 2.2.1 → 2.3.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 +19 -19
- package/dashboard/public/index.html +224 -8
- package/dashboard/public/styles.css +1078 -4
- package/dashboard/server.ts +1100 -15
- package/dashboard/src/canvas/interactions.ts +74 -0
- package/dashboard/src/canvas/layout.ts +85 -0
- package/dashboard/src/canvas/overlays.ts +117 -0
- package/dashboard/src/canvas/particles.ts +105 -0
- package/dashboard/src/canvas/renderer.ts +191 -0
- package/dashboard/src/components/charts/bar.ts +54 -0
- package/dashboard/src/components/charts/donut.ts +25 -0
- package/dashboard/src/components/charts/pipeline-rail.ts +105 -0
- package/dashboard/src/components/charts/sparkline.ts +82 -0
- package/dashboard/src/components/header.ts +616 -0
- package/dashboard/src/components/modal.ts +413 -0
- package/dashboard/src/components/terminal.ts +144 -0
- package/dashboard/src/core/api.ts +381 -0
- package/dashboard/src/core/helpers.ts +118 -0
- package/dashboard/src/core/router.ts +190 -0
- package/dashboard/src/core/sse.ts +38 -0
- package/dashboard/src/core/state.ts +150 -0
- package/dashboard/src/core/ws.ts +143 -0
- package/dashboard/src/design/icons.ts +131 -0
- package/dashboard/src/design/tokens.ts +160 -0
- package/dashboard/src/main.ts +68 -0
- package/dashboard/src/types/api.ts +337 -0
- package/dashboard/src/views/activity.ts +185 -0
- package/dashboard/src/views/agent-cockpit.ts +236 -0
- package/dashboard/src/views/agents.ts +72 -0
- package/dashboard/src/views/fleet-map.ts +299 -0
- package/dashboard/src/views/insights.ts +298 -0
- package/dashboard/src/views/machines.ts +162 -0
- package/dashboard/src/views/metrics.ts +420 -0
- package/dashboard/src/views/overview.ts +409 -0
- package/dashboard/src/views/pipeline-theater.ts +219 -0
- package/dashboard/src/views/pipelines.ts +595 -0
- package/dashboard/src/views/team.ts +362 -0
- package/dashboard/src/views/timeline.ts +389 -0
- package/dashboard/tsconfig.json +21 -0
- package/docs/AGI-PLATFORM-PLAN.md +5 -5
- package/docs/AGI-WHATS-NEXT.md +19 -16
- package/docs/README.md +2 -0
- package/package.json +8 -1
- package/scripts/check-version-consistency.sh +72 -0
- package/scripts/lib/daemon-adaptive.sh +610 -0
- package/scripts/lib/daemon-dispatch.sh +489 -0
- package/scripts/lib/daemon-failure.sh +387 -0
- package/scripts/lib/daemon-patrol.sh +1113 -0
- package/scripts/lib/daemon-poll.sh +1202 -0
- package/scripts/lib/daemon-state.sh +550 -0
- package/scripts/lib/daemon-triage.sh +490 -0
- package/scripts/lib/helpers.sh +81 -0
- package/scripts/lib/pipeline-intelligence.sh +0 -6
- package/scripts/lib/pipeline-quality-checks.sh +3 -1
- package/scripts/lib/pipeline-stages.sh +20 -0
- package/scripts/sw +109 -168
- package/scripts/sw-activity.sh +1 -1
- package/scripts/sw-adaptive.sh +2 -2
- package/scripts/sw-adversarial.sh +1 -1
- package/scripts/sw-architecture-enforcer.sh +1 -1
- package/scripts/sw-auth.sh +14 -6
- package/scripts/sw-autonomous.sh +1 -1
- package/scripts/sw-changelog.sh +2 -2
- package/scripts/sw-checkpoint.sh +1 -1
- package/scripts/sw-ci.sh +1 -1
- package/scripts/sw-cleanup.sh +1 -1
- package/scripts/sw-code-review.sh +1 -1
- package/scripts/sw-connect.sh +1 -1
- package/scripts/sw-context.sh +1 -1
- package/scripts/sw-cost.sh +1 -1
- package/scripts/sw-daemon.sh +53 -4817
- package/scripts/sw-dashboard.sh +1 -1
- package/scripts/sw-db.sh +1 -1
- package/scripts/sw-decompose.sh +1 -1
- package/scripts/sw-deps.sh +1 -1
- package/scripts/sw-developer-simulation.sh +1 -1
- package/scripts/sw-discovery.sh +1 -1
- package/scripts/sw-doc-fleet.sh +1 -1
- package/scripts/sw-docs-agent.sh +1 -1
- package/scripts/sw-docs.sh +1 -1
- package/scripts/sw-doctor.sh +49 -1
- package/scripts/sw-dora.sh +1 -1
- package/scripts/sw-durable.sh +1 -1
- package/scripts/sw-e2e-orchestrator.sh +1 -1
- package/scripts/sw-eventbus.sh +1 -1
- package/scripts/sw-feedback.sh +1 -1
- package/scripts/sw-fix.sh +6 -5
- package/scripts/sw-fleet-discover.sh +1 -1
- package/scripts/sw-fleet-viz.sh +3 -3
- package/scripts/sw-fleet.sh +1 -1
- package/scripts/sw-github-app.sh +5 -2
- package/scripts/sw-github-checks.sh +1 -1
- package/scripts/sw-github-deploy.sh +1 -1
- package/scripts/sw-github-graphql.sh +1 -1
- package/scripts/sw-guild.sh +1 -1
- package/scripts/sw-heartbeat.sh +1 -1
- package/scripts/sw-hygiene.sh +1 -1
- package/scripts/sw-incident.sh +1 -1
- package/scripts/sw-init.sh +112 -9
- package/scripts/sw-instrument.sh +6 -1
- package/scripts/sw-intelligence.sh +5 -1
- package/scripts/sw-jira.sh +1 -1
- package/scripts/sw-launchd.sh +1 -1
- package/scripts/sw-linear.sh +20 -9
- package/scripts/sw-logs.sh +1 -1
- package/scripts/sw-loop.sh +2 -1
- package/scripts/sw-memory.sh +10 -1
- package/scripts/sw-mission-control.sh +1 -1
- package/scripts/sw-model-router.sh +4 -1
- package/scripts/sw-otel.sh +4 -4
- package/scripts/sw-oversight.sh +1 -1
- package/scripts/sw-pipeline-composer.sh +3 -1
- package/scripts/sw-pipeline-vitals.sh +4 -6
- package/scripts/sw-pipeline.sh +19 -56
- package/scripts/sw-pipeline.sh.mock +7 -0
- package/scripts/sw-pm.sh +5 -2
- package/scripts/sw-pr-lifecycle.sh +1 -1
- package/scripts/sw-predictive.sh +4 -1
- package/scripts/sw-prep.sh +3 -2
- package/scripts/sw-ps.sh +1 -1
- package/scripts/sw-public-dashboard.sh +10 -4
- package/scripts/sw-quality.sh +1 -1
- package/scripts/sw-reaper.sh +1 -1
- package/scripts/sw-recruit.sh +25 -1
- package/scripts/sw-regression.sh +2 -1
- package/scripts/sw-release-manager.sh +1 -1
- package/scripts/sw-release.sh +7 -5
- package/scripts/sw-remote.sh +1 -1
- package/scripts/sw-replay.sh +1 -1
- package/scripts/sw-retro.sh +1 -1
- package/scripts/sw-scale.sh +11 -5
- package/scripts/sw-security-audit.sh +1 -1
- package/scripts/sw-self-optimize.sh +172 -7
- package/scripts/sw-session.sh +1 -1
- package/scripts/sw-setup.sh +1 -1
- package/scripts/sw-standup.sh +4 -3
- package/scripts/sw-status.sh +1 -1
- package/scripts/sw-strategic.sh +2 -1
- package/scripts/sw-stream.sh +8 -2
- package/scripts/sw-swarm.sh +12 -10
- package/scripts/sw-team-stages.sh +1 -1
- package/scripts/sw-templates.sh +1 -1
- package/scripts/sw-testgen.sh +3 -2
- package/scripts/sw-tmux-pipeline.sh +2 -1
- package/scripts/sw-tmux.sh +1 -1
- package/scripts/sw-trace.sh +1 -1
- package/scripts/sw-tracker-jira.sh +1 -0
- package/scripts/sw-tracker-linear.sh +1 -0
- package/scripts/sw-tracker.sh +24 -6
- package/scripts/sw-triage.sh +1 -1
- package/scripts/sw-upgrade.sh +1 -1
- package/scripts/sw-ux.sh +1 -1
- package/scripts/sw-webhook.sh +1 -1
- package/scripts/sw-widgets.sh +2 -2
- package/scripts/sw-worktree.sh +1 -1
- package/dashboard/public/app.js +0 -4422
package/dashboard/server.ts
CHANGED
|
@@ -268,6 +268,7 @@ interface Session {
|
|
|
268
268
|
accessToken: string;
|
|
269
269
|
avatarUrl: string;
|
|
270
270
|
isAdmin: boolean;
|
|
271
|
+
role: "viewer" | "operator" | "admin";
|
|
271
272
|
expiresAt: number;
|
|
272
273
|
}
|
|
273
274
|
|
|
@@ -687,6 +688,192 @@ function accessDeniedHTML(repo: string): string {
|
|
|
687
688
|
</html>`;
|
|
688
689
|
}
|
|
689
690
|
|
|
691
|
+
// ─── Notification / Webhook System ──────────────────────────────────
|
|
692
|
+
const NOTIFICATIONS_CONFIG_FILE = join(
|
|
693
|
+
HOME,
|
|
694
|
+
".shipwright",
|
|
695
|
+
"notifications.json",
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
interface NotificationConfig {
|
|
699
|
+
enabled: boolean;
|
|
700
|
+
webhooks: Array<{
|
|
701
|
+
url: string;
|
|
702
|
+
label: string;
|
|
703
|
+
events: string[]; // "pipeline.completed", "pipeline.failed", "alert", "all"
|
|
704
|
+
created_at: string;
|
|
705
|
+
}>;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function loadNotificationConfig(): NotificationConfig {
|
|
709
|
+
try {
|
|
710
|
+
if (existsSync(NOTIFICATIONS_CONFIG_FILE)) {
|
|
711
|
+
return JSON.parse(
|
|
712
|
+
readFileOr(
|
|
713
|
+
NOTIFICATIONS_CONFIG_FILE,
|
|
714
|
+
'{"enabled":false,"webhooks":[]}',
|
|
715
|
+
),
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
} catch {}
|
|
719
|
+
return { enabled: false, webhooks: [] };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function saveNotificationConfig(config: NotificationConfig): void {
|
|
723
|
+
try {
|
|
724
|
+
const dir = join(HOME, ".shipwright");
|
|
725
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
726
|
+
const tmp = NOTIFICATIONS_CONFIG_FILE + ".tmp";
|
|
727
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2));
|
|
728
|
+
renameSync(tmp, NOTIFICATIONS_CONFIG_FILE);
|
|
729
|
+
} catch {}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let lastNotifiedEvents = new Set<string>();
|
|
733
|
+
|
|
734
|
+
async function sendNotifications(
|
|
735
|
+
eventType: string,
|
|
736
|
+
payload: Record<string, unknown>,
|
|
737
|
+
): Promise<void> {
|
|
738
|
+
const config = loadNotificationConfig();
|
|
739
|
+
if (!config.enabled || config.webhooks.length === 0) return;
|
|
740
|
+
|
|
741
|
+
// Deduplicate: create a key from event type + issue + timestamp
|
|
742
|
+
const eventKey = `${eventType}:${payload.issue || ""}:${payload.ts || ""}`;
|
|
743
|
+
if (lastNotifiedEvents.has(eventKey)) return;
|
|
744
|
+
lastNotifiedEvents.add(eventKey);
|
|
745
|
+
// Trim to prevent memory leak
|
|
746
|
+
if (lastNotifiedEvents.size > 500) {
|
|
747
|
+
const arr = Array.from(lastNotifiedEvents);
|
|
748
|
+
lastNotifiedEvents = new Set(arr.slice(-250));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
for (const webhook of config.webhooks) {
|
|
752
|
+
if (webhook.events.includes("all") || webhook.events.includes(eventType)) {
|
|
753
|
+
try {
|
|
754
|
+
await fetch(webhook.url, {
|
|
755
|
+
method: "POST",
|
|
756
|
+
headers: { "Content-Type": "application/json" },
|
|
757
|
+
body: JSON.stringify({
|
|
758
|
+
event: eventType,
|
|
759
|
+
timestamp: new Date().toISOString(),
|
|
760
|
+
...payload,
|
|
761
|
+
}),
|
|
762
|
+
});
|
|
763
|
+
} catch {}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Hook into event processing to detect new completions/failures
|
|
769
|
+
let lastEventCount = 0;
|
|
770
|
+
function checkAndNotifyNewEvents(): void {
|
|
771
|
+
const events = readEvents();
|
|
772
|
+
if (events.length <= lastEventCount) {
|
|
773
|
+
lastEventCount = events.length;
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const newEvents = events.slice(lastEventCount);
|
|
777
|
+
lastEventCount = events.length;
|
|
778
|
+
|
|
779
|
+
for (const evt of newEvents) {
|
|
780
|
+
const e = evt as Record<string, unknown>;
|
|
781
|
+
const type = String(e.type || "");
|
|
782
|
+
if (type === "pipeline.completed") {
|
|
783
|
+
const result =
|
|
784
|
+
e.result === "failure" ? "pipeline.failed" : "pipeline.completed";
|
|
785
|
+
sendNotifications(result, {
|
|
786
|
+
issue: e.issue,
|
|
787
|
+
result: e.result,
|
|
788
|
+
duration_s: e.duration_s,
|
|
789
|
+
ts: e.ts,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
if (type.includes("failed") && !type.includes("pipeline")) {
|
|
793
|
+
sendNotifications("stage.failed", {
|
|
794
|
+
issue: e.issue,
|
|
795
|
+
stage: e.stage,
|
|
796
|
+
ts: e.ts,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ─── RBAC (Role-Based Access Control) ───────────────────────────────
|
|
803
|
+
const RBAC_CONFIG_FILE = join(HOME, ".shipwright", "rbac.json");
|
|
804
|
+
|
|
805
|
+
interface RBACConfig {
|
|
806
|
+
default_role: "viewer" | "operator" | "admin";
|
|
807
|
+
users: Record<string, "viewer" | "operator" | "admin">;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function loadRBACConfig(): RBACConfig {
|
|
811
|
+
try {
|
|
812
|
+
if (existsSync(RBAC_CONFIG_FILE)) {
|
|
813
|
+
return JSON.parse(
|
|
814
|
+
readFileOr(RBAC_CONFIG_FILE, '{"default_role":"admin","users":{}}'),
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
} catch {}
|
|
818
|
+
return { default_role: "admin", users: {} };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function saveRBACConfig(config: RBACConfig): void {
|
|
822
|
+
try {
|
|
823
|
+
const dir = join(HOME, ".shipwright");
|
|
824
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
825
|
+
const tmp = RBAC_CONFIG_FILE + ".tmp";
|
|
826
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2));
|
|
827
|
+
renameSync(tmp, RBAC_CONFIG_FILE);
|
|
828
|
+
} catch {}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function resolveUserRole(username: string): "viewer" | "operator" | "admin" {
|
|
832
|
+
const config = loadRBACConfig();
|
|
833
|
+
return config.users[username] || config.default_role;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Role permissions: what each role can do
|
|
837
|
+
const ROLE_PERMISSIONS = {
|
|
838
|
+
viewer: new Set(["read"]),
|
|
839
|
+
operator: new Set(["read", "intervene", "approve", "control-daemon"]),
|
|
840
|
+
admin: new Set([
|
|
841
|
+
"read",
|
|
842
|
+
"intervene",
|
|
843
|
+
"approve",
|
|
844
|
+
"control-daemon",
|
|
845
|
+
"configure",
|
|
846
|
+
"manage-users",
|
|
847
|
+
]),
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
function hasPermission(
|
|
851
|
+
role: "viewer" | "operator" | "admin",
|
|
852
|
+
permission: string,
|
|
853
|
+
): boolean {
|
|
854
|
+
return ROLE_PERMISSIONS[role]?.has(permission) ?? false;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ─── Audit Log ──────────────────────────────────────────────────────
|
|
858
|
+
const AUDIT_LOG_FILE = join(HOME, ".shipwright", "audit-log.jsonl");
|
|
859
|
+
|
|
860
|
+
function appendAuditLog(
|
|
861
|
+
action: string,
|
|
862
|
+
details: Record<string, unknown>,
|
|
863
|
+
): void {
|
|
864
|
+
try {
|
|
865
|
+
const dir = join(HOME, ".shipwright");
|
|
866
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
867
|
+
const entry = JSON.stringify({
|
|
868
|
+
ts: new Date().toISOString(),
|
|
869
|
+
ts_epoch: Math.floor(Date.now() / 1000),
|
|
870
|
+
action,
|
|
871
|
+
...details,
|
|
872
|
+
});
|
|
873
|
+
appendFileSync(AUDIT_LOG_FILE, entry + "\n");
|
|
874
|
+
} catch {}
|
|
875
|
+
}
|
|
876
|
+
|
|
690
877
|
// ─── WebSocket client tracking ───────────────────────────────────────
|
|
691
878
|
const wsClients = new Set<import("bun").ServerWebSocket<unknown>>();
|
|
692
879
|
const startTime = Date.now();
|
|
@@ -945,9 +1132,10 @@ function resolveWorktreePath(relative: string): string | null {
|
|
|
945
1132
|
// Try well-known repo locations
|
|
946
1133
|
const candidates: string[] = [];
|
|
947
1134
|
|
|
948
|
-
// Check env
|
|
949
|
-
|
|
950
|
-
|
|
1135
|
+
// Check env vars for repo location
|
|
1136
|
+
const repoEnv = process.env.SHIPWRIGHT_REPO || process.env.VOICEAI_REPO;
|
|
1137
|
+
if (repoEnv) {
|
|
1138
|
+
candidates.push(join(repoEnv, relative));
|
|
951
1139
|
}
|
|
952
1140
|
|
|
953
1141
|
// Scan daemon state for any repo paths from completed or active jobs
|
|
@@ -961,15 +1149,15 @@ function resolveWorktreePath(relative: string): string | null {
|
|
|
961
1149
|
}
|
|
962
1150
|
}
|
|
963
1151
|
|
|
964
|
-
// Try
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1152
|
+
// Try discovering repo root via git
|
|
1153
|
+
try {
|
|
1154
|
+
const gitRoot = execSync("git rev-parse --show-toplevel 2>/dev/null", {
|
|
1155
|
+
encoding: "utf-8",
|
|
1156
|
+
timeout: 3000,
|
|
1157
|
+
}).trim();
|
|
1158
|
+
if (gitRoot) candidates.push(join(gitRoot, relative));
|
|
1159
|
+
} catch {
|
|
1160
|
+
// not in a git repo or git not available
|
|
973
1161
|
}
|
|
974
1162
|
|
|
975
1163
|
for (const candidate of candidates) {
|
|
@@ -1935,6 +2123,10 @@ function startEventsWatcher(): void {
|
|
|
1935
2123
|
if (wsClients.size > 0) {
|
|
1936
2124
|
broadcastToClients(getFleetState());
|
|
1937
2125
|
}
|
|
2126
|
+
// Check for new events and send notifications
|
|
2127
|
+
if (filename === "events.jsonl") {
|
|
2128
|
+
checkAndNotifyNewEvents();
|
|
2129
|
+
}
|
|
1938
2130
|
}
|
|
1939
2131
|
});
|
|
1940
2132
|
} catch {
|
|
@@ -2078,6 +2270,7 @@ async function handleAuthCallback(url: URL): Promise<Response> {
|
|
|
2078
2270
|
accessToken,
|
|
2079
2271
|
avatarUrl: user.avatar_url,
|
|
2080
2272
|
isAdmin,
|
|
2273
|
+
role: resolveUserRole(user.login),
|
|
2081
2274
|
});
|
|
2082
2275
|
|
|
2083
2276
|
return new Response(null, {
|
|
@@ -2159,6 +2352,7 @@ async function handlePatLogin(req: Request): Promise<Response> {
|
|
|
2159
2352
|
accessToken: "", // PAT mode doesn't give per-user tokens
|
|
2160
2353
|
avatarUrl,
|
|
2161
2354
|
isAdmin,
|
|
2355
|
+
role: resolveUserRole(username),
|
|
2162
2356
|
});
|
|
2163
2357
|
|
|
2164
2358
|
return new Response(null, {
|
|
@@ -2324,8 +2518,8 @@ const server = Bun.serve({
|
|
|
2324
2518
|
});
|
|
2325
2519
|
}
|
|
2326
2520
|
|
|
2327
|
-
// REST: pipeline detail for a specific issue
|
|
2328
|
-
if (
|
|
2521
|
+
// REST: pipeline detail for a specific issue (only exact /api/pipeline/:num)
|
|
2522
|
+
if (/^\/api\/pipeline\/\d+$/.test(pathname)) {
|
|
2329
2523
|
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
2330
2524
|
if (!issueNum || isNaN(issueNum)) {
|
|
2331
2525
|
return new Response(JSON.stringify({ error: "Invalid issue number" }), {
|
|
@@ -2552,6 +2746,7 @@ const server = Bun.serve({
|
|
|
2552
2746
|
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2553
2747
|
});
|
|
2554
2748
|
}
|
|
2749
|
+
appendAuditLog("intervention", { action, issue: issueNum, pid });
|
|
2555
2750
|
return new Response(
|
|
2556
2751
|
JSON.stringify({ ok: true, action, issue: issueNum }),
|
|
2557
2752
|
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
@@ -2575,7 +2770,12 @@ const server = Bun.serve({
|
|
|
2575
2770
|
if (pathname === "/api/me") {
|
|
2576
2771
|
if (!isAuthEnabled()) {
|
|
2577
2772
|
return new Response(
|
|
2578
|
-
JSON.stringify({
|
|
2773
|
+
JSON.stringify({
|
|
2774
|
+
username: "local",
|
|
2775
|
+
avatarUrl: "",
|
|
2776
|
+
isAdmin: true,
|
|
2777
|
+
role: "admin",
|
|
2778
|
+
}),
|
|
2579
2779
|
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
2580
2780
|
);
|
|
2581
2781
|
}
|
|
@@ -2591,6 +2791,7 @@ const server = Bun.serve({
|
|
|
2591
2791
|
username: session.githubUser,
|
|
2592
2792
|
avatarUrl: session.avatarUrl,
|
|
2593
2793
|
isAdmin: session.isAdmin,
|
|
2794
|
+
role: session.role || "admin",
|
|
2594
2795
|
}),
|
|
2595
2796
|
{ headers: { "Content-Type": "application/json", ...CORS_HEADERS } },
|
|
2596
2797
|
);
|
|
@@ -2598,6 +2799,100 @@ const server = Bun.serve({
|
|
|
2598
2799
|
|
|
2599
2800
|
// ── Phase 1: Pipeline Deep-Dive endpoints ─────────────────────
|
|
2600
2801
|
|
|
2802
|
+
// SSE: Live log streaming for a pipeline
|
|
2803
|
+
if (pathname.match(/^\/api\/logs\/\d+\/stream$/)) {
|
|
2804
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
2805
|
+
if (!issueNum || isNaN(issueNum)) {
|
|
2806
|
+
return new Response(JSON.stringify({ error: "Invalid issue number" }), {
|
|
2807
|
+
status: 400,
|
|
2808
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
const logFile = join(LOGS_DIR, `issue-${issueNum}.log`);
|
|
2812
|
+
if (!existsSync(logFile)) {
|
|
2813
|
+
return new Response(JSON.stringify({ error: "Log file not found" }), {
|
|
2814
|
+
status: 404,
|
|
2815
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2816
|
+
});
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// Stream the log file via SSE
|
|
2820
|
+
let lastSize = 0;
|
|
2821
|
+
const encoder = new TextEncoder();
|
|
2822
|
+
let sseWatcher: ReturnType<typeof watch> | null = null;
|
|
2823
|
+
let sseHeartbeat: ReturnType<typeof setInterval> | null = null;
|
|
2824
|
+
|
|
2825
|
+
const sseCleanup = () => {
|
|
2826
|
+
if (sseWatcher) {
|
|
2827
|
+
try {
|
|
2828
|
+
sseWatcher.close();
|
|
2829
|
+
} catch {}
|
|
2830
|
+
sseWatcher = null;
|
|
2831
|
+
}
|
|
2832
|
+
if (sseHeartbeat) {
|
|
2833
|
+
clearInterval(sseHeartbeat);
|
|
2834
|
+
sseHeartbeat = null;
|
|
2835
|
+
}
|
|
2836
|
+
};
|
|
2837
|
+
|
|
2838
|
+
const stream = new ReadableStream({
|
|
2839
|
+
start(controller) {
|
|
2840
|
+
// Send existing content first
|
|
2841
|
+
try {
|
|
2842
|
+
const existing = readFileSync(logFile, "utf-8");
|
|
2843
|
+
lastSize = existing.length;
|
|
2844
|
+
const lines = stripAnsi(existing).split("\n");
|
|
2845
|
+
for (const line of lines) {
|
|
2846
|
+
controller.enqueue(encoder.encode(`data: ${line}\n\n`));
|
|
2847
|
+
}
|
|
2848
|
+
} catch {
|
|
2849
|
+
/* ignore */
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// Watch for new content
|
|
2853
|
+
sseWatcher = watch(logFile, (_event) => {
|
|
2854
|
+
try {
|
|
2855
|
+
const content = readFileSync(logFile, "utf-8");
|
|
2856
|
+
if (content.length > lastSize) {
|
|
2857
|
+
const newContent = content.slice(lastSize);
|
|
2858
|
+
lastSize = content.length;
|
|
2859
|
+
const lines = stripAnsi(newContent).split("\n");
|
|
2860
|
+
for (const line of lines) {
|
|
2861
|
+
if (line)
|
|
2862
|
+
controller.enqueue(encoder.encode(`data: ${line}\n\n`));
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
} catch {
|
|
2866
|
+
/* file may have been removed */
|
|
2867
|
+
}
|
|
2868
|
+
});
|
|
2869
|
+
|
|
2870
|
+
controller.enqueue(encoder.encode(":ok\n\n"));
|
|
2871
|
+
|
|
2872
|
+
// Keep connection alive with periodic heartbeat
|
|
2873
|
+
sseHeartbeat = setInterval(() => {
|
|
2874
|
+
try {
|
|
2875
|
+
controller.enqueue(encoder.encode(":\n\n"));
|
|
2876
|
+
} catch {
|
|
2877
|
+
sseCleanup();
|
|
2878
|
+
}
|
|
2879
|
+
}, 15000);
|
|
2880
|
+
},
|
|
2881
|
+
cancel() {
|
|
2882
|
+
sseCleanup();
|
|
2883
|
+
},
|
|
2884
|
+
});
|
|
2885
|
+
|
|
2886
|
+
return new Response(stream, {
|
|
2887
|
+
headers: {
|
|
2888
|
+
"Content-Type": "text/event-stream",
|
|
2889
|
+
"Cache-Control": "no-cache",
|
|
2890
|
+
Connection: "keep-alive",
|
|
2891
|
+
...CORS_HEADERS,
|
|
2892
|
+
},
|
|
2893
|
+
});
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2601
2896
|
// REST: Pipeline build logs
|
|
2602
2897
|
if (pathname.startsWith("/api/logs/")) {
|
|
2603
2898
|
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
@@ -2614,6 +2909,208 @@ const server = Bun.serve({
|
|
|
2614
2909
|
});
|
|
2615
2910
|
}
|
|
2616
2911
|
|
|
2912
|
+
// REST: Pipeline live diff (git diff from worktree)
|
|
2913
|
+
if (/^\/api\/pipeline\/\d+\/diff$/.test(pathname)) {
|
|
2914
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
2915
|
+
if (!issueNum || isNaN(issueNum)) {
|
|
2916
|
+
return new Response(JSON.stringify({ error: "Invalid issue" }), {
|
|
2917
|
+
status: 400,
|
|
2918
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
const worktreeBase = findWorktreeBase(issueNum);
|
|
2922
|
+
let diff = "";
|
|
2923
|
+
let stats = { files_changed: 0, insertions: 0, deletions: 0 };
|
|
2924
|
+
if (worktreeBase && existsSync(worktreeBase)) {
|
|
2925
|
+
try {
|
|
2926
|
+
diff = execSync(
|
|
2927
|
+
"git diff HEAD --no-color 2>/dev/null || git diff --no-color 2>/dev/null || echo ''",
|
|
2928
|
+
{
|
|
2929
|
+
encoding: "utf-8",
|
|
2930
|
+
timeout: 10000,
|
|
2931
|
+
cwd: worktreeBase,
|
|
2932
|
+
},
|
|
2933
|
+
).trim();
|
|
2934
|
+
const statRaw = execSync(
|
|
2935
|
+
"git diff HEAD --stat --no-color 2>/dev/null || echo ''",
|
|
2936
|
+
{
|
|
2937
|
+
encoding: "utf-8",
|
|
2938
|
+
timeout: 10000,
|
|
2939
|
+
cwd: worktreeBase,
|
|
2940
|
+
},
|
|
2941
|
+
).trim();
|
|
2942
|
+
const statMatch = statRaw.match(
|
|
2943
|
+
/(\d+) files? changed(?:, (\d+) insertions?[^,]*)?(?:, (\d+) deletions?)?/,
|
|
2944
|
+
);
|
|
2945
|
+
if (statMatch) {
|
|
2946
|
+
stats.files_changed = parseInt(statMatch[1]) || 0;
|
|
2947
|
+
stats.insertions = parseInt(statMatch[2]) || 0;
|
|
2948
|
+
stats.deletions = parseInt(statMatch[3]) || 0;
|
|
2949
|
+
}
|
|
2950
|
+
} catch {}
|
|
2951
|
+
}
|
|
2952
|
+
return new Response(
|
|
2953
|
+
JSON.stringify({ diff, stats, worktree: worktreeBase || "" }),
|
|
2954
|
+
{
|
|
2955
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2956
|
+
},
|
|
2957
|
+
);
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
// REST: Pipeline changed files list
|
|
2961
|
+
if (/^\/api\/pipeline\/\d+\/files$/.test(pathname)) {
|
|
2962
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
2963
|
+
if (!issueNum || isNaN(issueNum)) {
|
|
2964
|
+
return new Response(JSON.stringify({ error: "Invalid issue" }), {
|
|
2965
|
+
status: 400,
|
|
2966
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
const worktreeBase = findWorktreeBase(issueNum);
|
|
2970
|
+
let files: Array<{ path: string; status: string }> = [];
|
|
2971
|
+
if (worktreeBase && existsSync(worktreeBase)) {
|
|
2972
|
+
try {
|
|
2973
|
+
const raw = execSync(
|
|
2974
|
+
"git diff HEAD --name-status --no-color 2>/dev/null || echo ''",
|
|
2975
|
+
{
|
|
2976
|
+
encoding: "utf-8",
|
|
2977
|
+
timeout: 10000,
|
|
2978
|
+
cwd: worktreeBase,
|
|
2979
|
+
},
|
|
2980
|
+
).trim();
|
|
2981
|
+
if (raw) {
|
|
2982
|
+
for (const line of raw.split("\n")) {
|
|
2983
|
+
const parts = line.split("\t");
|
|
2984
|
+
if (parts.length >= 2) {
|
|
2985
|
+
const statusCode = parts[0].trim();
|
|
2986
|
+
const filePath = parts.slice(1).join("\t");
|
|
2987
|
+
const status =
|
|
2988
|
+
statusCode === "A"
|
|
2989
|
+
? "added"
|
|
2990
|
+
: statusCode === "D"
|
|
2991
|
+
? "deleted"
|
|
2992
|
+
: statusCode === "M"
|
|
2993
|
+
? "modified"
|
|
2994
|
+
: statusCode;
|
|
2995
|
+
files.push({ path: filePath, status });
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
} catch {}
|
|
3000
|
+
}
|
|
3001
|
+
return new Response(JSON.stringify({ files }), {
|
|
3002
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
// REST: Pipeline test results
|
|
3007
|
+
if (/^\/api\/pipeline\/\d+\/test-results$/.test(pathname)) {
|
|
3008
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
3009
|
+
const worktreeBase = findWorktreeBase(issueNum);
|
|
3010
|
+
let testResults: Record<string, unknown> = { available: false };
|
|
3011
|
+
if (worktreeBase) {
|
|
3012
|
+
const resultsPath = join(
|
|
3013
|
+
worktreeBase,
|
|
3014
|
+
".claude",
|
|
3015
|
+
"pipeline-artifacts",
|
|
3016
|
+
"test-results.json",
|
|
3017
|
+
);
|
|
3018
|
+
if (existsSync(resultsPath)) {
|
|
3019
|
+
try {
|
|
3020
|
+
testResults = JSON.parse(readFileOr(resultsPath, "{}"));
|
|
3021
|
+
testResults.available = true;
|
|
3022
|
+
} catch {}
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
return new Response(JSON.stringify(testResults), {
|
|
3026
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3027
|
+
});
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
// REST: Pipeline reasoning (agent thinking/summary per stage)
|
|
3031
|
+
if (/^\/api\/pipeline\/\d+\/reasoning$/.test(pathname)) {
|
|
3032
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
3033
|
+
const worktreeBase = findWorktreeBase(issueNum);
|
|
3034
|
+
let reasoning: Array<Record<string, unknown>> = [];
|
|
3035
|
+
if (worktreeBase) {
|
|
3036
|
+
const artifactsDir = join(
|
|
3037
|
+
worktreeBase,
|
|
3038
|
+
".claude",
|
|
3039
|
+
"pipeline-artifacts",
|
|
3040
|
+
);
|
|
3041
|
+
// Look for reasoning.json or individual stage reasoning files
|
|
3042
|
+
const reasoningPath = join(artifactsDir, "reasoning.json");
|
|
3043
|
+
if (existsSync(reasoningPath)) {
|
|
3044
|
+
try {
|
|
3045
|
+
const raw = JSON.parse(readFileOr(reasoningPath, "[]"));
|
|
3046
|
+
reasoning = Array.isArray(raw) ? raw : [raw];
|
|
3047
|
+
} catch {}
|
|
3048
|
+
}
|
|
3049
|
+
// Also look for stage-specific reasoning files
|
|
3050
|
+
for (const stage of [
|
|
3051
|
+
"intake",
|
|
3052
|
+
"plan",
|
|
3053
|
+
"design",
|
|
3054
|
+
"build",
|
|
3055
|
+
"test",
|
|
3056
|
+
"review",
|
|
3057
|
+
"merge",
|
|
3058
|
+
]) {
|
|
3059
|
+
const stagePath = join(artifactsDir, `${stage}-reasoning.md`);
|
|
3060
|
+
if (existsSync(stagePath)) {
|
|
3061
|
+
reasoning.push({
|
|
3062
|
+
stage,
|
|
3063
|
+
content: readFileOr(stagePath, ""),
|
|
3064
|
+
type: "markdown",
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
return new Response(JSON.stringify({ reasoning }), {
|
|
3070
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
// REST: Pipeline failure analysis
|
|
3075
|
+
if (/^\/api\/pipeline\/\d+\/failures$/.test(pathname)) {
|
|
3076
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
3077
|
+
let failures: Array<Record<string, unknown>> = [];
|
|
3078
|
+
// Check memory directory for this issue
|
|
3079
|
+
const memDir = join(HOME, ".shipwright", "memory");
|
|
3080
|
+
if (existsSync(memDir)) {
|
|
3081
|
+
try {
|
|
3082
|
+
const dirs = readdirSync(memDir);
|
|
3083
|
+
for (const d of dirs) {
|
|
3084
|
+
const failPath = join(memDir, d, "failures.json");
|
|
3085
|
+
if (existsSync(failPath)) {
|
|
3086
|
+
try {
|
|
3087
|
+
const data = JSON.parse(readFileOr(failPath, "[]"));
|
|
3088
|
+
const items = Array.isArray(data) ? data : [data];
|
|
3089
|
+
for (const item of items) {
|
|
3090
|
+
if (item.issue === issueNum || !item.issue) {
|
|
3091
|
+
failures.push(item);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
} catch {}
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
} catch {}
|
|
3098
|
+
}
|
|
3099
|
+
// Also check events for stage failures
|
|
3100
|
+
const events = readEvents();
|
|
3101
|
+
for (const evt of events) {
|
|
3102
|
+
if (
|
|
3103
|
+
(evt as Record<string, unknown>).issue === issueNum &&
|
|
3104
|
+
String((evt as Record<string, unknown>).type || "").includes("failed")
|
|
3105
|
+
) {
|
|
3106
|
+
failures.push(evt as Record<string, unknown>);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
return new Response(JSON.stringify({ failures }), {
|
|
3110
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3111
|
+
});
|
|
3112
|
+
}
|
|
3113
|
+
|
|
2617
3114
|
// REST: Pipeline artifacts (plan, design, dod, test-results, review, coverage)
|
|
2618
3115
|
if (pathname.startsWith("/api/artifacts/")) {
|
|
2619
3116
|
const parts = pathname.split("/");
|
|
@@ -3429,6 +3926,121 @@ const server = Bun.serve({
|
|
|
3429
3926
|
});
|
|
3430
3927
|
}
|
|
3431
3928
|
|
|
3929
|
+
// REST: Predictions for a pipeline (ETA, success probability, cost estimate)
|
|
3930
|
+
if (pathname.match(/^\/api\/predictions\/\d+$/) && req.method === "GET") {
|
|
3931
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
3932
|
+
if (!issueNum || isNaN(issueNum)) {
|
|
3933
|
+
return new Response(JSON.stringify({ error: "Invalid issue" }), {
|
|
3934
|
+
status: 400,
|
|
3935
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3936
|
+
});
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
try {
|
|
3940
|
+
const fleetState = getFleetState();
|
|
3941
|
+
const pipeline = fleetState.pipelines?.find(
|
|
3942
|
+
(p: any) => p.issue === issueNum,
|
|
3943
|
+
);
|
|
3944
|
+
|
|
3945
|
+
if (!pipeline) {
|
|
3946
|
+
return new Response(JSON.stringify({}), {
|
|
3947
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3948
|
+
});
|
|
3949
|
+
}
|
|
3950
|
+
|
|
3951
|
+
// Compute predictions from real historical data
|
|
3952
|
+
const metricsHistory = getMetricsHistory(30);
|
|
3953
|
+
const successRate =
|
|
3954
|
+
metricsHistory.success_rate != null
|
|
3955
|
+
? metricsHistory.success_rate / 100
|
|
3956
|
+
: 0.8;
|
|
3957
|
+
const stagesDone = pipeline.stagesDone?.length || 0;
|
|
3958
|
+
|
|
3959
|
+
// Use real per-stage durations for ETA calculation
|
|
3960
|
+
const stageDurations = metricsHistory.stage_durations || {};
|
|
3961
|
+
const allStages = [
|
|
3962
|
+
"intake",
|
|
3963
|
+
"plan",
|
|
3964
|
+
"design",
|
|
3965
|
+
"build",
|
|
3966
|
+
"test",
|
|
3967
|
+
"review",
|
|
3968
|
+
"merge",
|
|
3969
|
+
];
|
|
3970
|
+
const currentStageIdx = allStages.indexOf(pipeline.stage || "intake");
|
|
3971
|
+
let remainingTime = 0;
|
|
3972
|
+
|
|
3973
|
+
// Sum average durations for stages not yet completed
|
|
3974
|
+
for (let i = currentStageIdx; i < allStages.length; i++) {
|
|
3975
|
+
const stageName = allStages[i];
|
|
3976
|
+
const stageDur = stageDurations[stageName];
|
|
3977
|
+
if (typeof stageDur === "number" && stageDur > 0) {
|
|
3978
|
+
remainingTime += stageDur;
|
|
3979
|
+
} else {
|
|
3980
|
+
// Fallback: use overall average divided by stage count
|
|
3981
|
+
const avgDuration = metricsHistory.avg_duration_s || 600;
|
|
3982
|
+
remainingTime += avgDuration / allStages.length;
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
// For the current stage, subtract time already spent
|
|
3987
|
+
if (pipeline.elapsed_s && stagesDone > 0) {
|
|
3988
|
+
const completedDurs = pipeline.stagesDone || [];
|
|
3989
|
+
let completedTime = 0;
|
|
3990
|
+
for (const sd of completedDurs) {
|
|
3991
|
+
completedTime += (sd as any).duration_s || 0;
|
|
3992
|
+
}
|
|
3993
|
+
const currentStageElapsed = Math.max(
|
|
3994
|
+
0,
|
|
3995
|
+
(pipeline.elapsed_s || 0) - completedTime,
|
|
3996
|
+
);
|
|
3997
|
+
remainingTime = Math.max(0, remainingTime - currentStageElapsed);
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
const eta_s = Math.max(0, Math.round(remainingTime));
|
|
4001
|
+
const progressPct =
|
|
4002
|
+
currentStageIdx >= 0 ? currentStageIdx / allStages.length : 0;
|
|
4003
|
+
|
|
4004
|
+
// Success probability: starts at historical rate, decreases with iterations
|
|
4005
|
+
const iterRatio =
|
|
4006
|
+
(pipeline.iteration || 0) / (pipeline.maxIterations || 20);
|
|
4007
|
+
const success_probability = Math.max(
|
|
4008
|
+
0.05,
|
|
4009
|
+
successRate * (1 - iterRatio * 0.5),
|
|
4010
|
+
);
|
|
4011
|
+
|
|
4012
|
+
// Cost estimate: use real historical average cost per pipeline
|
|
4013
|
+
const costInfo = getCostInfo();
|
|
4014
|
+
const totalCompleted =
|
|
4015
|
+
(metricsHistory.total_completed || 0) +
|
|
4016
|
+
(metricsHistory.total_failed || 0);
|
|
4017
|
+
const avgCostPerPipeline =
|
|
4018
|
+
totalCompleted > 0
|
|
4019
|
+
? (costInfo.total_spent || 0) / totalCompleted
|
|
4020
|
+
: 2.5; // only fall back to 2.5 if no history at all
|
|
4021
|
+
const estimated_cost =
|
|
4022
|
+
avgCostPerPipeline * (1 - progressPct) + (pipeline.cost || 0);
|
|
4023
|
+
|
|
4024
|
+
return new Response(
|
|
4025
|
+
JSON.stringify({
|
|
4026
|
+
eta_s: Math.round(eta_s),
|
|
4027
|
+
success_probability: Math.round(success_probability * 100) / 100,
|
|
4028
|
+
estimated_cost: Math.round(estimated_cost * 100) / 100,
|
|
4029
|
+
}),
|
|
4030
|
+
{
|
|
4031
|
+
headers: {
|
|
4032
|
+
"Content-Type": "application/json",
|
|
4033
|
+
...CORS_HEADERS,
|
|
4034
|
+
},
|
|
4035
|
+
},
|
|
4036
|
+
);
|
|
4037
|
+
} catch (err) {
|
|
4038
|
+
return new Response(JSON.stringify({}), {
|
|
4039
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4040
|
+
});
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
|
|
3432
4044
|
// REST: Emergency brake — pause all active pipelines + clear queue
|
|
3433
4045
|
if (pathname === "/api/emergency-brake" && req.method === "POST") {
|
|
3434
4046
|
try {
|
|
@@ -3453,6 +4065,7 @@ const server = Bun.serve({
|
|
|
3453
4065
|
queued = ((daemonState.queued as Array<unknown>) || []).length;
|
|
3454
4066
|
}
|
|
3455
4067
|
|
|
4068
|
+
appendAuditLog("emergency-brake", { paused, queued });
|
|
3456
4069
|
return new Response(JSON.stringify({ paused, queued }), {
|
|
3457
4070
|
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3458
4071
|
});
|
|
@@ -3815,6 +4428,7 @@ const server = Bun.serve({
|
|
|
3815
4428
|
timeout: 10000,
|
|
3816
4429
|
stdio: "pipe",
|
|
3817
4430
|
});
|
|
4431
|
+
appendAuditLog("daemon-control", { action: "start" });
|
|
3818
4432
|
return new Response(JSON.stringify({ ok: true, action: "started" }), {
|
|
3819
4433
|
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
3820
4434
|
});
|
|
@@ -3904,6 +4518,477 @@ const server = Bun.serve({
|
|
|
3904
4518
|
}
|
|
3905
4519
|
}
|
|
3906
4520
|
|
|
4521
|
+
// GET /api/notifications/config — Return notification configuration
|
|
4522
|
+
if (pathname === "/api/notifications/config" && req.method === "GET") {
|
|
4523
|
+
return new Response(JSON.stringify(loadNotificationConfig()), {
|
|
4524
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4525
|
+
});
|
|
4526
|
+
}
|
|
4527
|
+
|
|
4528
|
+
// POST /api/notifications/config — Update notification configuration
|
|
4529
|
+
if (pathname === "/api/notifications/config" && req.method === "POST") {
|
|
4530
|
+
try {
|
|
4531
|
+
const body = (await req.json()) as Partial<NotificationConfig>;
|
|
4532
|
+
const current = loadNotificationConfig();
|
|
4533
|
+
if (body.enabled !== undefined) current.enabled = body.enabled;
|
|
4534
|
+
if (body.webhooks) current.webhooks = body.webhooks;
|
|
4535
|
+
saveNotificationConfig(current);
|
|
4536
|
+
return new Response(JSON.stringify({ ok: true, config: current }), {
|
|
4537
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4538
|
+
});
|
|
4539
|
+
} catch (err) {
|
|
4540
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
4541
|
+
status: 400,
|
|
4542
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4543
|
+
});
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
// POST /api/notifications/webhook — Add a single webhook
|
|
4548
|
+
if (pathname === "/api/notifications/webhook" && req.method === "POST") {
|
|
4549
|
+
try {
|
|
4550
|
+
const body = (await req.json()) as {
|
|
4551
|
+
url: string;
|
|
4552
|
+
label?: string;
|
|
4553
|
+
events?: string[];
|
|
4554
|
+
};
|
|
4555
|
+
if (!body.url) throw new Error("url is required");
|
|
4556
|
+
const config = loadNotificationConfig();
|
|
4557
|
+
config.webhooks.push({
|
|
4558
|
+
url: body.url,
|
|
4559
|
+
label: body.label || new URL(body.url).hostname,
|
|
4560
|
+
events: body.events || ["all"],
|
|
4561
|
+
created_at: new Date().toISOString(),
|
|
4562
|
+
});
|
|
4563
|
+
config.enabled = true;
|
|
4564
|
+
saveNotificationConfig(config);
|
|
4565
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
4566
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4567
|
+
});
|
|
4568
|
+
} catch (err) {
|
|
4569
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
4570
|
+
status: 400,
|
|
4571
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4572
|
+
});
|
|
4573
|
+
}
|
|
4574
|
+
}
|
|
4575
|
+
|
|
4576
|
+
// DELETE /api/notifications/webhook — Remove a webhook by URL
|
|
4577
|
+
if (pathname === "/api/notifications/webhook" && req.method === "DELETE") {
|
|
4578
|
+
try {
|
|
4579
|
+
const body = (await req.json()) as { url: string };
|
|
4580
|
+
const config = loadNotificationConfig();
|
|
4581
|
+
config.webhooks = config.webhooks.filter((w) => w.url !== body.url);
|
|
4582
|
+
if (config.webhooks.length === 0) config.enabled = false;
|
|
4583
|
+
saveNotificationConfig(config);
|
|
4584
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
4585
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4586
|
+
});
|
|
4587
|
+
} catch (err) {
|
|
4588
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
4589
|
+
status: 400,
|
|
4590
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4591
|
+
});
|
|
4592
|
+
}
|
|
4593
|
+
}
|
|
4594
|
+
|
|
4595
|
+
// POST /api/notifications/test — Send a test notification
|
|
4596
|
+
if (pathname === "/api/notifications/test" && req.method === "POST") {
|
|
4597
|
+
await sendNotifications("test", {
|
|
4598
|
+
message: "Test notification from Fleet Command",
|
|
4599
|
+
});
|
|
4600
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
4601
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4602
|
+
});
|
|
4603
|
+
}
|
|
4604
|
+
|
|
4605
|
+
// ─── RBAC Management ──────────────────────────────────────────
|
|
4606
|
+
// GET /api/rbac — Get RBAC configuration
|
|
4607
|
+
if (pathname === "/api/rbac" && req.method === "GET") {
|
|
4608
|
+
return new Response(JSON.stringify(loadRBACConfig()), {
|
|
4609
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4610
|
+
});
|
|
4611
|
+
}
|
|
4612
|
+
|
|
4613
|
+
// POST /api/rbac — Update RBAC configuration
|
|
4614
|
+
if (pathname === "/api/rbac" && req.method === "POST") {
|
|
4615
|
+
try {
|
|
4616
|
+
const body = (await req.json()) as Partial<RBACConfig>;
|
|
4617
|
+
const current = loadRBACConfig();
|
|
4618
|
+
if (body.default_role) current.default_role = body.default_role;
|
|
4619
|
+
if (body.users) current.users = { ...current.users, ...body.users };
|
|
4620
|
+
saveRBACConfig(current);
|
|
4621
|
+
appendAuditLog("rbac-update", { changes: body });
|
|
4622
|
+
return new Response(JSON.stringify({ ok: true, config: current }), {
|
|
4623
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4624
|
+
});
|
|
4625
|
+
} catch (err) {
|
|
4626
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
4627
|
+
status: 400,
|
|
4628
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4629
|
+
});
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
|
|
4633
|
+
// ─── Audit Log ────────────────────────────────────────────────
|
|
4634
|
+
// GET /api/audit-log — Read audit log entries
|
|
4635
|
+
if (pathname === "/api/audit-log" && req.method === "GET") {
|
|
4636
|
+
const entries: Array<Record<string, unknown>> = [];
|
|
4637
|
+
if (existsSync(AUDIT_LOG_FILE)) {
|
|
4638
|
+
try {
|
|
4639
|
+
const content = readFileOr(AUDIT_LOG_FILE, "").trim();
|
|
4640
|
+
if (content) {
|
|
4641
|
+
for (const line of content.split("\n")) {
|
|
4642
|
+
try {
|
|
4643
|
+
entries.push(JSON.parse(line));
|
|
4644
|
+
} catch {}
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4647
|
+
} catch {}
|
|
4648
|
+
}
|
|
4649
|
+
// Return newest first, limit to 100
|
|
4650
|
+
entries.reverse();
|
|
4651
|
+
return new Response(JSON.stringify({ entries: entries.slice(0, 100) }), {
|
|
4652
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4653
|
+
});
|
|
4654
|
+
}
|
|
4655
|
+
|
|
4656
|
+
// ─── Approval Gates ──────────────────────────────────────────────
|
|
4657
|
+
// GET /api/approval-gates — Get approval gate configuration
|
|
4658
|
+
if (pathname === "/api/approval-gates" && req.method === "GET") {
|
|
4659
|
+
const configPath = join(HOME, ".shipwright", "approval-gates.json");
|
|
4660
|
+
const config = existsSync(configPath)
|
|
4661
|
+
? JSON.parse(
|
|
4662
|
+
readFileOr(
|
|
4663
|
+
configPath,
|
|
4664
|
+
'{"enabled":false,"stages":[],"pending":[]}',
|
|
4665
|
+
),
|
|
4666
|
+
)
|
|
4667
|
+
: { enabled: false, stages: [], pending: [] };
|
|
4668
|
+
return new Response(JSON.stringify(config), {
|
|
4669
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4670
|
+
});
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4673
|
+
// POST /api/approval-gates — Update gate configuration
|
|
4674
|
+
if (pathname === "/api/approval-gates" && req.method === "POST") {
|
|
4675
|
+
try {
|
|
4676
|
+
const body = (await req.json()) as Record<string, unknown>;
|
|
4677
|
+
const configPath = join(HOME, ".shipwright", "approval-gates.json");
|
|
4678
|
+
const current = existsSync(configPath)
|
|
4679
|
+
? JSON.parse(
|
|
4680
|
+
readFileOr(
|
|
4681
|
+
configPath,
|
|
4682
|
+
'{"enabled":false,"stages":[],"pending":[]}',
|
|
4683
|
+
),
|
|
4684
|
+
)
|
|
4685
|
+
: { enabled: false, stages: [], pending: [] };
|
|
4686
|
+
if (body.enabled !== undefined) current.enabled = body.enabled;
|
|
4687
|
+
if (body.stages) current.stages = body.stages;
|
|
4688
|
+
const dir = join(HOME, ".shipwright");
|
|
4689
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
4690
|
+
const tmp = configPath + ".tmp";
|
|
4691
|
+
writeFileSync(tmp, JSON.stringify(current, null, 2));
|
|
4692
|
+
renameSync(tmp, configPath);
|
|
4693
|
+
return new Response(JSON.stringify({ ok: true, config: current }), {
|
|
4694
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4695
|
+
});
|
|
4696
|
+
} catch (err) {
|
|
4697
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
4698
|
+
status: 400,
|
|
4699
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4700
|
+
});
|
|
4701
|
+
}
|
|
4702
|
+
}
|
|
4703
|
+
|
|
4704
|
+
// POST /api/approval-gates/:issue/approve — Approve a stage transition
|
|
4705
|
+
if (
|
|
4706
|
+
/^\/api\/approval-gates\/\d+\/approve$/.test(pathname) &&
|
|
4707
|
+
req.method === "POST"
|
|
4708
|
+
) {
|
|
4709
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
4710
|
+
try {
|
|
4711
|
+
const body = (await req.json()) as {
|
|
4712
|
+
stage?: string;
|
|
4713
|
+
approver?: string;
|
|
4714
|
+
};
|
|
4715
|
+
const configPath = join(HOME, ".shipwright", "approval-gates.json");
|
|
4716
|
+
const current = existsSync(configPath)
|
|
4717
|
+
? JSON.parse(
|
|
4718
|
+
readFileOr(
|
|
4719
|
+
configPath,
|
|
4720
|
+
'{"enabled":false,"stages":[],"pending":[]}',
|
|
4721
|
+
),
|
|
4722
|
+
)
|
|
4723
|
+
: { enabled: false, stages: [], pending: [] };
|
|
4724
|
+
// Remove from pending
|
|
4725
|
+
current.pending = (current.pending || []).filter(
|
|
4726
|
+
(p: any) =>
|
|
4727
|
+
!(p.issue === issueNum && (!body.stage || p.stage === body.stage)),
|
|
4728
|
+
);
|
|
4729
|
+
// Write approval signal to worktree
|
|
4730
|
+
const worktreeBase = findWorktreeBase(issueNum);
|
|
4731
|
+
if (worktreeBase) {
|
|
4732
|
+
const artifactsDir = join(
|
|
4733
|
+
worktreeBase,
|
|
4734
|
+
".claude",
|
|
4735
|
+
"pipeline-artifacts",
|
|
4736
|
+
);
|
|
4737
|
+
if (!existsSync(artifactsDir))
|
|
4738
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
4739
|
+
writeFileSync(
|
|
4740
|
+
join(artifactsDir, "human-approval.txt"),
|
|
4741
|
+
JSON.stringify({
|
|
4742
|
+
approved: true,
|
|
4743
|
+
stage: body.stage,
|
|
4744
|
+
approver: body.approver || "dashboard-user",
|
|
4745
|
+
timestamp: new Date().toISOString(),
|
|
4746
|
+
}),
|
|
4747
|
+
);
|
|
4748
|
+
}
|
|
4749
|
+
const dir = join(HOME, ".shipwright");
|
|
4750
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
4751
|
+
const tmp = configPath + ".tmp";
|
|
4752
|
+
writeFileSync(tmp, JSON.stringify(current, null, 2));
|
|
4753
|
+
renameSync(tmp, configPath);
|
|
4754
|
+
// Log the approval for audit
|
|
4755
|
+
appendAuditLog("approval", {
|
|
4756
|
+
issue: issueNum,
|
|
4757
|
+
stage: body.stage,
|
|
4758
|
+
approver: body.approver || "dashboard-user",
|
|
4759
|
+
});
|
|
4760
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
4761
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4762
|
+
});
|
|
4763
|
+
} catch (err) {
|
|
4764
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
4765
|
+
status: 400,
|
|
4766
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4767
|
+
});
|
|
4768
|
+
}
|
|
4769
|
+
}
|
|
4770
|
+
|
|
4771
|
+
// POST /api/approval-gates/:issue/reject — Reject a stage transition
|
|
4772
|
+
if (
|
|
4773
|
+
/^\/api\/approval-gates\/\d+\/reject$/.test(pathname) &&
|
|
4774
|
+
req.method === "POST"
|
|
4775
|
+
) {
|
|
4776
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
4777
|
+
try {
|
|
4778
|
+
const body = (await req.json()) as {
|
|
4779
|
+
stage?: string;
|
|
4780
|
+
reason?: string;
|
|
4781
|
+
approver?: string;
|
|
4782
|
+
};
|
|
4783
|
+
const configPath = join(HOME, ".shipwright", "approval-gates.json");
|
|
4784
|
+
const current = existsSync(configPath)
|
|
4785
|
+
? JSON.parse(
|
|
4786
|
+
readFileOr(
|
|
4787
|
+
configPath,
|
|
4788
|
+
'{"enabled":false,"stages":[],"pending":[]}',
|
|
4789
|
+
),
|
|
4790
|
+
)
|
|
4791
|
+
: { enabled: false, stages: [], pending: [] };
|
|
4792
|
+
current.pending = (current.pending || []).filter(
|
|
4793
|
+
(p: any) =>
|
|
4794
|
+
!(p.issue === issueNum && (!body.stage || p.stage === body.stage)),
|
|
4795
|
+
);
|
|
4796
|
+
// Write rejection signal
|
|
4797
|
+
const worktreeBase = findWorktreeBase(issueNum);
|
|
4798
|
+
if (worktreeBase) {
|
|
4799
|
+
const artifactsDir = join(
|
|
4800
|
+
worktreeBase,
|
|
4801
|
+
".claude",
|
|
4802
|
+
"pipeline-artifacts",
|
|
4803
|
+
);
|
|
4804
|
+
if (!existsSync(artifactsDir))
|
|
4805
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
4806
|
+
writeFileSync(
|
|
4807
|
+
join(artifactsDir, "human-approval.txt"),
|
|
4808
|
+
JSON.stringify({
|
|
4809
|
+
approved: false,
|
|
4810
|
+
stage: body.stage,
|
|
4811
|
+
reason: body.reason,
|
|
4812
|
+
approver: body.approver || "dashboard-user",
|
|
4813
|
+
timestamp: new Date().toISOString(),
|
|
4814
|
+
}),
|
|
4815
|
+
);
|
|
4816
|
+
}
|
|
4817
|
+
const dir = join(HOME, ".shipwright");
|
|
4818
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
4819
|
+
const tmp = configPath + ".tmp";
|
|
4820
|
+
writeFileSync(tmp, JSON.stringify(current, null, 2));
|
|
4821
|
+
renameSync(tmp, configPath);
|
|
4822
|
+
appendAuditLog("rejection", {
|
|
4823
|
+
issue: issueNum,
|
|
4824
|
+
stage: body.stage,
|
|
4825
|
+
reason: body.reason,
|
|
4826
|
+
});
|
|
4827
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
4828
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4829
|
+
});
|
|
4830
|
+
} catch (err) {
|
|
4831
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
4832
|
+
status: 400,
|
|
4833
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4834
|
+
});
|
|
4835
|
+
}
|
|
4836
|
+
}
|
|
4837
|
+
|
|
4838
|
+
// ─── Quality Gates ─────────────────────────────────────────────
|
|
4839
|
+
// GET /api/quality-gates — Get quality gate configuration
|
|
4840
|
+
if (pathname === "/api/quality-gates" && req.method === "GET") {
|
|
4841
|
+
const configPath = join(HOME, ".shipwright", "quality-gates.json");
|
|
4842
|
+
const config = existsSync(configPath)
|
|
4843
|
+
? JSON.parse(readFileOr(configPath, '{"enabled":false,"rules":[]}'))
|
|
4844
|
+
: {
|
|
4845
|
+
enabled: false,
|
|
4846
|
+
rules: [
|
|
4847
|
+
{
|
|
4848
|
+
name: "test_coverage",
|
|
4849
|
+
operator: ">=",
|
|
4850
|
+
threshold: 80,
|
|
4851
|
+
unit: "%",
|
|
4852
|
+
},
|
|
4853
|
+
{
|
|
4854
|
+
name: "lint_errors",
|
|
4855
|
+
operator: "<=",
|
|
4856
|
+
threshold: 0,
|
|
4857
|
+
unit: "errors",
|
|
4858
|
+
},
|
|
4859
|
+
{
|
|
4860
|
+
name: "type_errors",
|
|
4861
|
+
operator: "<=",
|
|
4862
|
+
threshold: 0,
|
|
4863
|
+
unit: "errors",
|
|
4864
|
+
},
|
|
4865
|
+
],
|
|
4866
|
+
};
|
|
4867
|
+
return new Response(JSON.stringify(config), {
|
|
4868
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4869
|
+
});
|
|
4870
|
+
}
|
|
4871
|
+
|
|
4872
|
+
// POST /api/quality-gates — Update quality gate configuration
|
|
4873
|
+
if (pathname === "/api/quality-gates" && req.method === "POST") {
|
|
4874
|
+
try {
|
|
4875
|
+
const body = (await req.json()) as Record<string, unknown>;
|
|
4876
|
+
const configPath = join(HOME, ".shipwright", "quality-gates.json");
|
|
4877
|
+
const dir = join(HOME, ".shipwright");
|
|
4878
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
4879
|
+
const tmp = configPath + ".tmp";
|
|
4880
|
+
writeFileSync(tmp, JSON.stringify(body, null, 2));
|
|
4881
|
+
renameSync(tmp, configPath);
|
|
4882
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
4883
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4884
|
+
});
|
|
4885
|
+
} catch (err) {
|
|
4886
|
+
return new Response(JSON.stringify({ error: String(err) }), {
|
|
4887
|
+
status: 400,
|
|
4888
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4889
|
+
});
|
|
4890
|
+
}
|
|
4891
|
+
}
|
|
4892
|
+
|
|
4893
|
+
// GET /api/pipeline/:issue/quality — Get quality metrics for a pipeline
|
|
4894
|
+
if (
|
|
4895
|
+
/^\/api\/pipeline\/\d+\/quality$/.test(pathname) &&
|
|
4896
|
+
req.method === "GET"
|
|
4897
|
+
) {
|
|
4898
|
+
const issueNum = parseInt(pathname.split("/")[3] || "0");
|
|
4899
|
+
const worktreeBase = findWorktreeBase(issueNum);
|
|
4900
|
+
const quality: Record<string, unknown> = {
|
|
4901
|
+
test_coverage: null,
|
|
4902
|
+
lint_errors: null,
|
|
4903
|
+
type_errors: null,
|
|
4904
|
+
tests_passing: null,
|
|
4905
|
+
tests_total: null,
|
|
4906
|
+
};
|
|
4907
|
+
|
|
4908
|
+
if (worktreeBase) {
|
|
4909
|
+
// Check for quality metrics in artifacts
|
|
4910
|
+
const artifactsDir = join(
|
|
4911
|
+
worktreeBase,
|
|
4912
|
+
".claude",
|
|
4913
|
+
"pipeline-artifacts",
|
|
4914
|
+
);
|
|
4915
|
+
|
|
4916
|
+
// Test results
|
|
4917
|
+
const testResultsPath = join(artifactsDir, "test-results.json");
|
|
4918
|
+
if (existsSync(testResultsPath)) {
|
|
4919
|
+
try {
|
|
4920
|
+
const tr = JSON.parse(readFileOr(testResultsPath, "{}"));
|
|
4921
|
+
quality.tests_passing = tr.passing ?? tr.numPassed ?? null;
|
|
4922
|
+
quality.tests_total = tr.total ?? tr.numTests ?? null;
|
|
4923
|
+
quality.test_coverage = tr.coverage ?? null;
|
|
4924
|
+
} catch {}
|
|
4925
|
+
}
|
|
4926
|
+
|
|
4927
|
+
// Coverage file
|
|
4928
|
+
const coveragePath = join(artifactsDir, "coverage.json");
|
|
4929
|
+
if (existsSync(coveragePath) && quality.test_coverage === null) {
|
|
4930
|
+
try {
|
|
4931
|
+
const cov = JSON.parse(readFileOr(coveragePath, "{}"));
|
|
4932
|
+
quality.test_coverage =
|
|
4933
|
+
cov.total?.lines?.pct ?? cov.percentage ?? null;
|
|
4934
|
+
} catch {}
|
|
4935
|
+
}
|
|
4936
|
+
|
|
4937
|
+
// Lint results
|
|
4938
|
+
const lintPath = join(artifactsDir, "lint-results.json");
|
|
4939
|
+
if (existsSync(lintPath)) {
|
|
4940
|
+
try {
|
|
4941
|
+
const lint = JSON.parse(readFileOr(lintPath, "{}"));
|
|
4942
|
+
quality.lint_errors = lint.errorCount ?? lint.errors ?? null;
|
|
4943
|
+
} catch {}
|
|
4944
|
+
}
|
|
4945
|
+
|
|
4946
|
+
// Type check results
|
|
4947
|
+
const typePath = join(artifactsDir, "type-check.json");
|
|
4948
|
+
if (existsSync(typePath)) {
|
|
4949
|
+
try {
|
|
4950
|
+
const types = JSON.parse(readFileOr(typePath, "{}"));
|
|
4951
|
+
quality.type_errors = types.errorCount ?? types.errors ?? null;
|
|
4952
|
+
} catch {}
|
|
4953
|
+
}
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
// Check quality rules
|
|
4957
|
+
const configPath = join(HOME, ".shipwright", "quality-gates.json");
|
|
4958
|
+
const gateConfig = existsSync(configPath)
|
|
4959
|
+
? JSON.parse(readFileOr(configPath, '{"enabled":false,"rules":[]}'))
|
|
4960
|
+
: { enabled: false, rules: [] };
|
|
4961
|
+
|
|
4962
|
+
const results: Array<Record<string, unknown>> = [];
|
|
4963
|
+
if (gateConfig.enabled && gateConfig.rules) {
|
|
4964
|
+
for (const rule of gateConfig.rules) {
|
|
4965
|
+
const value = quality[rule.name as string];
|
|
4966
|
+
let passed = true;
|
|
4967
|
+
if (value !== null && value !== undefined) {
|
|
4968
|
+
const numVal = Number(value);
|
|
4969
|
+
if (rule.operator === ">=" && numVal < rule.threshold)
|
|
4970
|
+
passed = false;
|
|
4971
|
+
if (rule.operator === "<=" && numVal > rule.threshold)
|
|
4972
|
+
passed = false;
|
|
4973
|
+
if (rule.operator === ">" && numVal <= rule.threshold)
|
|
4974
|
+
passed = false;
|
|
4975
|
+
if (rule.operator === "<" && numVal >= rule.threshold)
|
|
4976
|
+
passed = false;
|
|
4977
|
+
} else {
|
|
4978
|
+
passed = true; // Can't evaluate = pass by default
|
|
4979
|
+
}
|
|
4980
|
+
results.push({ ...rule, value, passed });
|
|
4981
|
+
}
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4984
|
+
return new Response(
|
|
4985
|
+
JSON.stringify({ quality, gates: gateConfig, results }),
|
|
4986
|
+
{
|
|
4987
|
+
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
|
4988
|
+
},
|
|
4989
|
+
);
|
|
4990
|
+
}
|
|
4991
|
+
|
|
3907
4992
|
// GET /api/daemon/config — Return daemon configuration
|
|
3908
4993
|
if (pathname === "/api/daemon/config" && req.method === "GET") {
|
|
3909
4994
|
try {
|