jettypod 4.4.120 → 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.
Files changed (208) hide show
  1. package/.env +2 -1
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
  8. package/apps/dashboard/app/demo/gates/page.tsx +3 -5
  9. package/apps/dashboard/app/design-system/page.tsx +1 -1
  10. package/apps/dashboard/app/globals.css +74 -2
  11. package/apps/dashboard/app/install-claude/page.tsx +3 -5
  12. package/apps/dashboard/app/login/page.tsx +17 -20
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +60 -12
  15. package/apps/dashboard/app/signup/page.tsx +14 -17
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +12 -15
  19. package/apps/dashboard/app/work/[id]/page.tsx +90 -75
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +70 -61
  22. package/apps/dashboard/components/CardMenu.tsx +0 -1
  23. package/apps/dashboard/components/ClaudePanel.tsx +541 -283
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
  26. package/apps/dashboard/components/CopyableId.tsx +1 -2
  27. package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
  28. package/apps/dashboard/components/DragContext.tsx +132 -62
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +5 -6
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +0 -1
  34. package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
  35. package/apps/dashboard/components/EpicGroup.tsx +100 -70
  36. package/apps/dashboard/components/GateCard.tsx +0 -1
  37. package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
  39. package/apps/dashboard/components/JettyLoader.tsx +0 -1
  40. package/apps/dashboard/components/KanbanBoard.tsx +319 -173
  41. package/apps/dashboard/components/KanbanCard.tsx +341 -107
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
  44. package/apps/dashboard/components/MainNav.tsx +24 -25
  45. package/apps/dashboard/components/MessageBlock.tsx +93 -16
  46. package/apps/dashboard/components/ModeStartCard.tsx +0 -1
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
  48. package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
  53. package/apps/dashboard/components/ReviewFooter.tsx +12 -14
  54. package/apps/dashboard/components/SessionList.tsx +0 -1
  55. package/apps/dashboard/components/SubscribeContent.tsx +40 -11
  56. package/apps/dashboard/components/TestTree.tsx +1 -2
  57. package/apps/dashboard/components/TipCard.tsx +2 -4
  58. package/apps/dashboard/components/Toast.tsx +0 -1
  59. package/apps/dashboard/components/TypeIcon.tsx +7 -8
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
  62. package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
  63. package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
  64. package/apps/dashboard/components/WorkItemTree.tsx +2 -4
  65. package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
  72. package/apps/dashboard/components/ui/Button.tsx +1 -1
  73. package/apps/dashboard/components/ui/Input.tsx +1 -1
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
  77. package/apps/dashboard/contexts/UsageContext.tsx +62 -31
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  81. package/apps/dashboard/index.html +73 -0
  82. package/apps/dashboard/lib/data-bridge.ts +722 -0
  83. package/apps/dashboard/lib/db.ts +69 -1302
  84. package/apps/dashboard/lib/environment-config.ts +173 -0
  85. package/apps/dashboard/lib/environment-verification.ts +119 -0
  86. package/apps/dashboard/lib/kanban-utils.ts +226 -26
  87. package/apps/dashboard/lib/proof-run.ts +495 -0
  88. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  89. package/apps/dashboard/lib/service-recovery.ts +326 -0
  90. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  91. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  92. package/apps/dashboard/lib/session-stream-manager.ts +253 -122
  93. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  94. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  95. package/apps/dashboard/lib/tauri.ts +106 -0
  96. package/apps/dashboard/lib/utils.ts +3 -3
  97. package/apps/dashboard/next-env.d.ts +1 -1
  98. package/apps/dashboard/package.json +21 -33
  99. package/apps/dashboard/public/bug-icon.png +0 -0
  100. package/apps/dashboard/public/buoy-icon.png +0 -0
  101. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  102. package/apps/dashboard/public/pier-icon.png +0 -0
  103. package/apps/dashboard/public/star-icon.png +0 -0
  104. package/apps/dashboard/public/wrench-icon.png +0 -0
  105. package/apps/dashboard/scripts/tauri-build.js +228 -0
  106. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  107. package/apps/dashboard/src/main.tsx +12 -0
  108. package/apps/dashboard/src/router.tsx +107 -0
  109. package/apps/dashboard/src/vite-env.d.ts +1 -0
  110. package/apps/dashboard/tsconfig.json +7 -12
  111. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  112. package/apps/dashboard/vite.config.ts +33 -0
  113. package/apps/update-server/src/index.ts +167 -30
  114. package/claude-hooks/global-guardrails.js +14 -13
  115. package/crates/jettypod-cli/Cargo.toml +19 -0
  116. package/crates/jettypod-cli/src/commands.rs +1249 -0
  117. package/crates/jettypod-cli/src/main.rs +595 -0
  118. package/crates/jettypod-core/Cargo.toml +26 -0
  119. package/crates/jettypod-core/build.rs +98 -0
  120. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  121. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  122. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  123. package/crates/jettypod-core/src/auth.rs +294 -0
  124. package/crates/jettypod-core/src/config.rs +397 -0
  125. package/crates/jettypod-core/src/db/mod.rs +507 -0
  126. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  127. package/crates/jettypod-core/src/db/startup.rs +101 -0
  128. package/crates/jettypod-core/src/db/validate.rs +149 -0
  129. package/crates/jettypod-core/src/error.rs +76 -0
  130. package/crates/jettypod-core/src/git.rs +458 -0
  131. package/crates/jettypod-core/src/lib.rs +20 -0
  132. package/crates/jettypod-core/src/sessions.rs +625 -0
  133. package/crates/jettypod-core/src/skills.rs +556 -0
  134. package/crates/jettypod-core/src/work.rs +1086 -0
  135. package/crates/jettypod-core/src/worktree.rs +628 -0
  136. package/crates/jettypod-core/src/ws.rs +767 -0
  137. package/cucumber-test.cjs +6 -0
  138. package/jettypod.js +96 -4
  139. package/lib/bdd-preflight.js +96 -0
  140. package/lib/merge-lock.js +111 -253
  141. package/lib/migrations/030-rejection-round-columns.js +54 -0
  142. package/lib/migrations/031-session-isolation-index.js +17 -0
  143. package/lib/work-commands/index.js +58 -16
  144. package/lib/work-tracking/index.js +108 -8
  145. package/package.json +1 -1
  146. package/skills-templates/bug-mode/SKILL.md +43 -1
  147. package/skills-templates/chore-mode/SKILL.md +40 -1
  148. package/skills-templates/design-system-selection/SKILL.md +273 -0
  149. package/skills-templates/epic-planning/SKILL.md +14 -0
  150. package/skills-templates/feature-planning/SKILL.md +90 -1
  151. package/skills-templates/production-mode/SKILL.md +20 -0
  152. package/skills-templates/simple-improvement/SKILL.md +39 -2
  153. package/skills-templates/speed-mode/SKILL.md +10 -15
  154. package/skills-templates/stable-mode/SKILL.md +47 -0
  155. package/apps/dashboard/README.md +0 -36
  156. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
  157. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  158. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
  159. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  160. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
  161. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  162. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  163. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  164. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  165. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  166. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  167. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  168. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  169. package/apps/dashboard/app/api/tests/route.ts +0 -9
  170. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  171. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  172. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  173. package/apps/dashboard/app/api/usage/route.ts +0 -17
  174. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  175. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  176. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  177. package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
  178. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
  179. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  180. package/apps/dashboard/app/layout.tsx +0 -55
  181. package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
  182. package/apps/dashboard/electron/ipc-handlers.js +0 -1026
  183. package/apps/dashboard/electron/main.js +0 -2306
  184. package/apps/dashboard/electron/preload.js +0 -125
  185. package/apps/dashboard/electron/session-manager.js +0 -163
  186. package/apps/dashboard/electron-builder.config.js +0 -357
  187. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  188. package/apps/dashboard/lib/backlog-parser.ts +0 -50
  189. package/apps/dashboard/lib/claude-process-manager.ts +0 -529
  190. package/apps/dashboard/lib/db-bridge.ts +0 -283
  191. package/apps/dashboard/lib/prototypes.ts +0 -202
  192. package/apps/dashboard/lib/test-results-db.ts +0 -307
  193. package/apps/dashboard/lib/tests.ts +0 -282
  194. package/apps/dashboard/next.config.js +0 -66
  195. package/apps/dashboard/postcss.config.mjs +0 -7
  196. package/apps/dashboard/public/bug-icon.svg +0 -9
  197. package/apps/dashboard/public/buoy-icon.svg +0 -9
  198. package/apps/dashboard/public/file.svg +0 -1
  199. package/apps/dashboard/public/globe.svg +0 -1
  200. package/apps/dashboard/public/in-flight-seagull.svg +0 -9
  201. package/apps/dashboard/public/next.svg +0 -1
  202. package/apps/dashboard/public/pier-icon.svg +0 -14
  203. package/apps/dashboard/public/star-icon.svg +0 -9
  204. package/apps/dashboard/public/vercel.svg +0 -1
  205. package/apps/dashboard/public/window.svg +0 -1
  206. package/apps/dashboard/public/wrench-icon.svg +0 -9
  207. package/apps/dashboard/scripts/download-node.js +0 -104
  208. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
@@ -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
+ }