jettypod 4.4.118 → 4.4.121
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/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proof Run Manager — orchestrates the QA environment.
|
|
3
|
+
*
|
|
4
|
+
* When given an EnvironmentConfig, actively spawns services via Tauri IPC,
|
|
5
|
+
* health-checks with retries, and tears down on close.
|
|
6
|
+
* Falls back to passive detection with hardcoded defaults when no config.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { invoke, listen, isTauri, runShellCommand } from './tauri';
|
|
10
|
+
import type { EnvironmentConfig, ServiceDefinition } from './environment-config';
|
|
11
|
+
import { ServiceRecovery } from './service-recovery';
|
|
12
|
+
import type { RecoveryStatus } from './service-recovery';
|
|
13
|
+
import { WS_PORT } from './utils';
|
|
14
|
+
|
|
15
|
+
// ─── Types ───────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface ServiceConfig {
|
|
18
|
+
name: string;
|
|
19
|
+
port: number | null;
|
|
20
|
+
order: number;
|
|
21
|
+
healthCheck?: () => Promise<boolean>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ServiceState = 'stopped' | 'starting' | 'running' | 'crashed';
|
|
25
|
+
|
|
26
|
+
export interface ServiceStatus {
|
|
27
|
+
name: string;
|
|
28
|
+
port: number | null;
|
|
29
|
+
status: ServiceState;
|
|
30
|
+
pid?: number;
|
|
31
|
+
startTime?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ProofRunState = 'idle' | 'launching' | 'healthy' | 'running' | 'complete' | 'failed';
|
|
35
|
+
|
|
36
|
+
export interface ProofRunStatus {
|
|
37
|
+
state: ProofRunState;
|
|
38
|
+
services: ServiceStatus[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Health Check Constants ──────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const HEALTH_CHECK_INTERVAL_MS = 500;
|
|
44
|
+
const HEALTH_CHECK_MAX_ATTEMPTS = 40; // 20 seconds
|
|
45
|
+
const HEALTH_CHECK_TIMEOUT_MS = 2000;
|
|
46
|
+
|
|
47
|
+
// ─── Port health checks ─────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
async function checkHttpPort(port: number): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS);
|
|
53
|
+
await fetch(`http://localhost:${port}/`, {
|
|
54
|
+
signal: controller.signal,
|
|
55
|
+
mode: 'no-cors',
|
|
56
|
+
});
|
|
57
|
+
clearTimeout(timeout);
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function checkWsPort(port: number): Promise<boolean> {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const timeout = setTimeout(() => {
|
|
67
|
+
ws.close();
|
|
68
|
+
resolve(false);
|
|
69
|
+
}, HEALTH_CHECK_TIMEOUT_MS);
|
|
70
|
+
const ws = new WebSocket(`ws://localhost:${port}`);
|
|
71
|
+
ws.onopen = () => {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
ws.close();
|
|
74
|
+
resolve(true);
|
|
75
|
+
};
|
|
76
|
+
ws.onerror = () => {
|
|
77
|
+
clearTimeout(timeout);
|
|
78
|
+
resolve(false);
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const HEALTH_CHECKERS: Record<string, (port: number) => Promise<boolean>> = {
|
|
84
|
+
http: checkHttpPort,
|
|
85
|
+
ws: checkWsPort,
|
|
86
|
+
tcp: checkHttpPort,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ─── Default service configs (fallback when no EnvironmentConfig) ──
|
|
90
|
+
|
|
91
|
+
const DEFAULT_SERVICE_CONFIGS: ServiceConfig[] = [
|
|
92
|
+
{ name: 'Vite', port: 1420, order: 1, healthCheck: () => checkHttpPort(1420) },
|
|
93
|
+
{ name: 'WS Server', port: WS_PORT, order: 2, healthCheck: () => checkWsPort(WS_PORT) },
|
|
94
|
+
{ name: 'Tauri', port: null, order: 3, healthCheck: async () => isTauri() },
|
|
95
|
+
{ name: 'Wrangler', port: 8787, order: 4, healthCheck: () => checkHttpPort(8787) },
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// ─── Proof Run Manager ──────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
export class ProofRunManager {
|
|
101
|
+
private services: ServiceStatus[];
|
|
102
|
+
private configs: ServiceConfig[];
|
|
103
|
+
private envConfig: EnvironmentConfig | null;
|
|
104
|
+
private projectCwd: string | null;
|
|
105
|
+
private onUpdate: (status: ProofRunStatus) => void;
|
|
106
|
+
private state: ProofRunState = 'idle';
|
|
107
|
+
private spawnedPids: Set<number> = new Set();
|
|
108
|
+
private unlistenFns: Array<() => void> = [];
|
|
109
|
+
private healthCheckTimers: Map<string, ReturnType<typeof setInterval>> = new Map();
|
|
110
|
+
private recovery: ServiceRecovery;
|
|
111
|
+
private stderrBuffers: Map<string, string[]> = new Map();
|
|
112
|
+
private retryCount: Map<string, number> = new Map();
|
|
113
|
+
|
|
114
|
+
constructor(
|
|
115
|
+
configs: ServiceConfig[] | undefined,
|
|
116
|
+
onUpdate: (status: ProofRunStatus) => void,
|
|
117
|
+
envConfig?: EnvironmentConfig | null,
|
|
118
|
+
projectCwd?: string | null,
|
|
119
|
+
) {
|
|
120
|
+
this.envConfig = envConfig ?? null;
|
|
121
|
+
this.projectCwd = projectCwd ?? null;
|
|
122
|
+
|
|
123
|
+
// If full environment config provided, derive ServiceConfigs from it
|
|
124
|
+
if (this.envConfig) {
|
|
125
|
+
this.configs = this.envConfig.services.map(svc => ({
|
|
126
|
+
name: svc.name,
|
|
127
|
+
port: svc.port,
|
|
128
|
+
order: svc.order,
|
|
129
|
+
healthCheck: () => {
|
|
130
|
+
if (!svc.port) return Promise.resolve(true);
|
|
131
|
+
const checker = HEALTH_CHECKERS[svc.healthCheck] || checkHttpPort;
|
|
132
|
+
return checker(svc.port);
|
|
133
|
+
},
|
|
134
|
+
}));
|
|
135
|
+
} else {
|
|
136
|
+
this.configs = [...(configs || DEFAULT_SERVICE_CONFIGS)].sort((a, b) => a.order - b.order);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.services = this.configs.map(c => ({
|
|
140
|
+
name: c.name,
|
|
141
|
+
port: c.port,
|
|
142
|
+
status: 'stopped' as ServiceState,
|
|
143
|
+
}));
|
|
144
|
+
this.onUpdate = onUpdate;
|
|
145
|
+
|
|
146
|
+
this.recovery = new ServiceRecovery((status: RecoveryStatus) => {
|
|
147
|
+
console.log(`[ProofRun] Recovery [${status.phase}] ${status.service}: ${status.action}`);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getStatus(): ProofRunStatus {
|
|
152
|
+
return { state: this.state, services: [...this.services] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async startServices(): Promise<void> {
|
|
156
|
+
// Active orchestration: spawn services via Tauri
|
|
157
|
+
if (this.envConfig && isTauri()) {
|
|
158
|
+
await this.orchestratedLaunch();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Passive detection (no config or not in Tauri)
|
|
163
|
+
this.state = 'launching';
|
|
164
|
+
this.notify();
|
|
165
|
+
|
|
166
|
+
const checks = this.configs.map(async (config) => {
|
|
167
|
+
this.updateService(config.name, 'starting');
|
|
168
|
+
const check = config.healthCheck || (config.port ? () => checkHttpPort(config.port!) : async () => false);
|
|
169
|
+
const isUp = await check();
|
|
170
|
+
this.updateService(config.name, isUp ? 'running' : 'stopped');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await Promise.all(checks);
|
|
174
|
+
this.checkAllHealthy();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async stopServices(): Promise<void> {
|
|
178
|
+
// Clear health check polling
|
|
179
|
+
for (const [, timer] of this.healthCheckTimers) {
|
|
180
|
+
clearInterval(timer);
|
|
181
|
+
}
|
|
182
|
+
this.healthCheckTimers.clear();
|
|
183
|
+
|
|
184
|
+
// Unlisten all events
|
|
185
|
+
for (const unlisten of this.unlistenFns) {
|
|
186
|
+
unlisten();
|
|
187
|
+
}
|
|
188
|
+
this.unlistenFns = [];
|
|
189
|
+
|
|
190
|
+
// Kill spawned processes (only ones we own)
|
|
191
|
+
if (isTauri() && this.spawnedPids.size > 0) {
|
|
192
|
+
const killPromises = [...this.spawnedPids].map(async (pid) => {
|
|
193
|
+
try {
|
|
194
|
+
await invoke('kill_process', { pid });
|
|
195
|
+
} catch {
|
|
196
|
+
// Process may have already exited
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
await Promise.all(killPromises);
|
|
200
|
+
this.spawnedPids.clear();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.stderrBuffers.clear();
|
|
204
|
+
this.retryCount.clear();
|
|
205
|
+
this.services = this.services.map(s => ({ ...s, status: 'stopped' as ServiceState, pid: undefined }));
|
|
206
|
+
this.state = 'idle';
|
|
207
|
+
this.notify();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
destroy(): void {
|
|
211
|
+
// Synchronous cleanup — stopServices handles async cleanup
|
|
212
|
+
for (const [, timer] of this.healthCheckTimers) {
|
|
213
|
+
clearInterval(timer);
|
|
214
|
+
}
|
|
215
|
+
this.healthCheckTimers.clear();
|
|
216
|
+
for (const unlisten of this.unlistenFns) {
|
|
217
|
+
unlisten();
|
|
218
|
+
}
|
|
219
|
+
this.unlistenFns = [];
|
|
220
|
+
this.stderrBuffers.clear();
|
|
221
|
+
this.retryCount.clear();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Orchestrated Launch (Tauri + EnvironmentConfig) ─────
|
|
225
|
+
|
|
226
|
+
private async orchestratedLaunch(): Promise<void> {
|
|
227
|
+
this.state = 'launching';
|
|
228
|
+
this.notify();
|
|
229
|
+
|
|
230
|
+
const groups = this.getOrderGroups();
|
|
231
|
+
|
|
232
|
+
for (const group of groups) {
|
|
233
|
+
await Promise.all(group.map(def => this.launchService(def)));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.evaluateOverallState();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async launchService(def: ServiceDefinition): Promise<void> {
|
|
240
|
+
// Step 1: Check if already running (skip for services with no port)
|
|
241
|
+
this.updateService(def.name, 'starting');
|
|
242
|
+
if (!def.port) {
|
|
243
|
+
this.updateService(def.name, 'running');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const checker = HEALTH_CHECKERS[def.healthCheck] || checkHttpPort;
|
|
247
|
+
const alreadyUp = await checker(def.port);
|
|
248
|
+
|
|
249
|
+
if (alreadyUp) {
|
|
250
|
+
// Track the existing process's PID so stopServices() can kill it on teardown
|
|
251
|
+
try {
|
|
252
|
+
const lsof = await runShellCommand('lsof', ['-ti', `:${def.port}`], { timeoutMs: 5000 });
|
|
253
|
+
const pid = parseInt(lsof.stdout.trim().split('\n')[0], 10);
|
|
254
|
+
if (pid) {
|
|
255
|
+
this.spawnedPids.add(pid);
|
|
256
|
+
this.updateServiceExtended(def.name, { pid });
|
|
257
|
+
console.log(`[ProofRun] "${def.name}" already running on port ${def.port} (pid=${pid}) — tracking for teardown`);
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// lsof failed — service is up but we can't track it; log and continue
|
|
261
|
+
console.warn(`[ProofRun] "${def.name}" already on port ${def.port} but could not identify PID`);
|
|
262
|
+
}
|
|
263
|
+
this.updateService(def.name, 'running');
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Step 2: Pre-flight checks (detect and fix issues before spawning)
|
|
268
|
+
const preflightResults = await this.recovery.runPreflightChecks(def, this.projectCwd);
|
|
269
|
+
// Only command-not-found is a hard blocker — other failures are best-effort
|
|
270
|
+
const blocker = preflightResults.find(r => !r.fixed && r.check === 'command-not-found');
|
|
271
|
+
if (blocker) {
|
|
272
|
+
console.error(`[ProofRun] Pre-flight blocked "${def.name}": ${blocker.reason}`);
|
|
273
|
+
this.updateService(def.name, 'crashed');
|
|
274
|
+
this.evaluateOverallState();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
for (const r of preflightResults) {
|
|
278
|
+
if (!r.fixed) {
|
|
279
|
+
console.warn(`[ProofRun] Pre-flight check "${r.check}" failed for "${def.name}": ${r.reason} — proceeding anyway`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Step 3: Spawn via Tauri
|
|
284
|
+
await this.spawnAndMonitor(def, checker);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async spawnAndMonitor(def: ServiceDefinition, checker: (port: number) => Promise<boolean>): Promise<void> {
|
|
288
|
+
const parts = def.command.split(/\s+/);
|
|
289
|
+
const command = parts[0];
|
|
290
|
+
const args = parts.slice(1);
|
|
291
|
+
|
|
292
|
+
// Initialize stderr buffer for this service
|
|
293
|
+
this.stderrBuffers.set(def.name, []);
|
|
294
|
+
|
|
295
|
+
let pid: number;
|
|
296
|
+
try {
|
|
297
|
+
pid = await invoke<number>('spawn_process', {
|
|
298
|
+
command,
|
|
299
|
+
args,
|
|
300
|
+
label: `qa-env-${def.name.toLowerCase().replace(/\s+/g, '-')}`,
|
|
301
|
+
kind: 'DevServer',
|
|
302
|
+
cwd: this.projectCwd,
|
|
303
|
+
env: {},
|
|
304
|
+
});
|
|
305
|
+
} catch (err) {
|
|
306
|
+
console.error(`[ProofRun] Failed to spawn "${def.name}" (${def.command}):`, err);
|
|
307
|
+
this.updateService(def.name, 'crashed');
|
|
308
|
+
this.evaluateOverallState();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
console.log(`[ProofRun] Spawned "${def.name}" (pid=${pid}, cwd=${this.projectCwd})`);
|
|
313
|
+
this.spawnedPids.add(pid);
|
|
314
|
+
this.updateServiceExtended(def.name, { pid });
|
|
315
|
+
|
|
316
|
+
// Listen for output (readyPattern detection + stderr buffering)
|
|
317
|
+
const outputUnlisten = await listen<{ pid: number; lines: string[]; stream: string }>(
|
|
318
|
+
'process-output-batch',
|
|
319
|
+
(event) => {
|
|
320
|
+
if (event.payload.pid !== pid) return;
|
|
321
|
+
|
|
322
|
+
// Buffer stderr for recovery analysis
|
|
323
|
+
if (event.payload.stream === 'stderr') {
|
|
324
|
+
const buffer = this.stderrBuffers.get(def.name) || [];
|
|
325
|
+
buffer.push(...event.payload.lines);
|
|
326
|
+
this.stderrBuffers.set(def.name, buffer);
|
|
327
|
+
for (const line of event.payload.lines) {
|
|
328
|
+
console.warn(`[ProofRun] ${def.name} stderr: ${line}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Check readyPattern in any stream
|
|
333
|
+
if (def.readyPattern) {
|
|
334
|
+
const svc = this.services.find(s => s.name === def.name);
|
|
335
|
+
if (!svc || svc.status === 'running') return;
|
|
336
|
+
|
|
337
|
+
for (const line of event.payload.lines) {
|
|
338
|
+
if (line.includes(def.readyPattern)) {
|
|
339
|
+
this.updateService(def.name, 'running');
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
this.unlistenFns.push(outputUnlisten);
|
|
347
|
+
|
|
348
|
+
// Listen for unexpected exit — attempt recovery if this is the first failure
|
|
349
|
+
const exitUnlisten = await listen<{ pid: number; code: number }>(
|
|
350
|
+
'process-exit',
|
|
351
|
+
async (event) => {
|
|
352
|
+
if (event.payload.pid !== pid) return;
|
|
353
|
+
console.error(`[ProofRun] "${def.name}" exited with code ${event.payload.code}`);
|
|
354
|
+
this.spawnedPids.delete(pid);
|
|
355
|
+
|
|
356
|
+
const svc = this.services.find(s => s.name === def.name);
|
|
357
|
+
if (!svc || svc.status === 'stopped') return;
|
|
358
|
+
|
|
359
|
+
// Brief delay to let final stderr batch arrive before analyzing
|
|
360
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
361
|
+
|
|
362
|
+
// Attempt stderr-based recovery (max 1 retry per service)
|
|
363
|
+
const retries = this.retryCount.get(def.name) || 0;
|
|
364
|
+
if (retries < 1) {
|
|
365
|
+
const stderrLines = this.stderrBuffers.get(def.name) || [];
|
|
366
|
+
const stderr = stderrLines.join('\n');
|
|
367
|
+
const analysis = await this.recovery.analyzeFailure(def, stderr, this.projectCwd);
|
|
368
|
+
|
|
369
|
+
if (analysis.shouldRetry) {
|
|
370
|
+
console.log(`[ProofRun] Retrying "${def.name}" after fix: ${analysis.fixApplied}`);
|
|
371
|
+
this.retryCount.set(def.name, retries + 1);
|
|
372
|
+
this.stderrBuffers.set(def.name, []); // Clear buffer for retry
|
|
373
|
+
this.updateService(def.name, 'starting');
|
|
374
|
+
await this.spawnAndMonitor(def, checker);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
console.warn(`[ProofRun] "${def.name}" retry exhausted (${retries}/${1})`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
this.updateService(def.name, 'crashed');
|
|
382
|
+
this.evaluateOverallState();
|
|
383
|
+
}
|
|
384
|
+
);
|
|
385
|
+
this.unlistenFns.push(exitUnlisten);
|
|
386
|
+
|
|
387
|
+
// Poll health check with retries
|
|
388
|
+
await this.waitForHealthy(def, checker);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private waitForHealthy(
|
|
392
|
+
def: ServiceDefinition,
|
|
393
|
+
checker: (port: number) => Promise<boolean>,
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
let attempts = 0;
|
|
396
|
+
|
|
397
|
+
return new Promise<void>((resolve) => {
|
|
398
|
+
const interval = setInterval(async () => {
|
|
399
|
+
const svc = this.services.find(s => s.name === def.name);
|
|
400
|
+
|
|
401
|
+
// Already marked running by readyPattern or crashed by exit
|
|
402
|
+
if (svc?.status === 'running' || svc?.status === 'crashed') {
|
|
403
|
+
clearInterval(interval);
|
|
404
|
+
this.healthCheckTimers.delete(def.name);
|
|
405
|
+
resolve();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
attempts++;
|
|
410
|
+
const isUp = await checker(def.port);
|
|
411
|
+
if (isUp) {
|
|
412
|
+
clearInterval(interval);
|
|
413
|
+
this.healthCheckTimers.delete(def.name);
|
|
414
|
+
this.updateService(def.name, 'running');
|
|
415
|
+
resolve();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (attempts >= HEALTH_CHECK_MAX_ATTEMPTS) {
|
|
420
|
+
clearInterval(interval);
|
|
421
|
+
this.healthCheckTimers.delete(def.name);
|
|
422
|
+
this.updateService(def.name, 'crashed');
|
|
423
|
+
resolve();
|
|
424
|
+
}
|
|
425
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
426
|
+
|
|
427
|
+
this.healthCheckTimers.set(def.name, interval);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ─── Helpers ────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
private getOrderGroups(): ServiceDefinition[][] {
|
|
434
|
+
if (!this.envConfig) return [];
|
|
435
|
+
const groups = new Map<number, ServiceDefinition[]>();
|
|
436
|
+
for (const def of this.envConfig.services) {
|
|
437
|
+
const existing = groups.get(def.order) || [];
|
|
438
|
+
existing.push(def);
|
|
439
|
+
groups.set(def.order, existing);
|
|
440
|
+
}
|
|
441
|
+
return [...groups.entries()]
|
|
442
|
+
.sort(([a], [b]) => a - b)
|
|
443
|
+
.map(([, defs]) => defs);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private updateService(name: string, status: ServiceState, startTime?: number): void {
|
|
447
|
+
this.services = this.services.map(s =>
|
|
448
|
+
s.name === name
|
|
449
|
+
? { ...s, status, ...(startTime ? { startTime } : {}) }
|
|
450
|
+
: s
|
|
451
|
+
);
|
|
452
|
+
this.notify();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private updateServiceExtended(name: string, updates: Partial<ServiceStatus>): void {
|
|
456
|
+
this.services = this.services.map(s =>
|
|
457
|
+
s.name === name ? { ...s, ...updates } : s
|
|
458
|
+
);
|
|
459
|
+
this.notify();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private checkAllHealthy(): void {
|
|
463
|
+
const allRunning = this.services.every(s => s.status === 'running');
|
|
464
|
+
if (allRunning) {
|
|
465
|
+
this.state = 'healthy';
|
|
466
|
+
} else if (this.services.some(s => s.status === 'stopped')) {
|
|
467
|
+
this.state = 'healthy';
|
|
468
|
+
}
|
|
469
|
+
this.notify();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private evaluateOverallState(): void {
|
|
473
|
+
if (!this.envConfig) return;
|
|
474
|
+
|
|
475
|
+
const serviceMap = new Map(this.envConfig.services.map(s => [s.name, s]));
|
|
476
|
+
const required = this.services.filter(s => !serviceMap.get(s.name)?.optional);
|
|
477
|
+
const optional = this.services.filter(s => serviceMap.get(s.name)?.optional);
|
|
478
|
+
|
|
479
|
+
const requiredAllUp = required.every(s => s.status === 'running');
|
|
480
|
+
const requiredAnyFailed = required.some(s => s.status === 'crashed');
|
|
481
|
+
const optionalAnyDown = optional.some(s => s.status !== 'running');
|
|
482
|
+
|
|
483
|
+
if (requiredAnyFailed) {
|
|
484
|
+
this.state = 'failed';
|
|
485
|
+
} else if (requiredAllUp) {
|
|
486
|
+
// Use 'healthy' to maintain compatibility with proof page (triggers scenario runner)
|
|
487
|
+
this.state = 'healthy';
|
|
488
|
+
}
|
|
489
|
+
this.notify();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private notify(): void {
|
|
493
|
+
this.onUpdate(this.getStatus());
|
|
494
|
+
}
|
|
495
|
+
}
|