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.
Files changed (240) hide show
  1. package/.env +4 -3
  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 +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  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 +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  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 +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. 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
+ }