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,346 @@
1
+ /**
2
+ * Proof Scenario Runner — spawns cucumber-js and streams BDD results
3
+ * in real time to the proof dashboard.
4
+ *
5
+ * Uses Tauri IPC (spawn_process) when available, simulated behavior otherwise.
6
+ * Parses cucumber-js message format output for step-by-step progress.
7
+ */
8
+
9
+ import { invoke, listen, isTauri } from './tauri';
10
+
11
+ // ─── Types ───────────────────────────────────────────────────
12
+
13
+ export interface ScenarioStep {
14
+ keyword: string;
15
+ text: string;
16
+ status: 'passed' | 'running' | 'pending' | 'failed' | 'skipped';
17
+ duration?: number;
18
+ }
19
+
20
+ export interface Scenario {
21
+ title: string;
22
+ status: 'passed' | 'running' | 'pending' | 'failed';
23
+ steps: ScenarioStep[];
24
+ duration?: number;
25
+ }
26
+
27
+ export interface ScenarioRunnerStatus {
28
+ scenarios: Scenario[];
29
+ logs: string[];
30
+ state: 'idle' | 'running' | 'complete' | 'failed';
31
+ }
32
+
33
+ export type ScenarioRunnerCallback = (status: ScenarioRunnerStatus) => void;
34
+
35
+ // ─── Simulated scenario data ─────────────────────────────────
36
+
37
+ const SIMULATED_SCENARIOS: { title: string; steps: { keyword: string; text: string }[] }[] = [
38
+ {
39
+ title: 'User can reach proof dashboard from kanban card',
40
+ steps: [
41
+ { keyword: 'Given', text: 'I am viewing the kanban board' },
42
+ { keyword: 'And', text: 'there is a work item that is ready for review' },
43
+ { keyword: 'When', text: 'I click the QA button on that card' },
44
+ { keyword: 'Then', text: 'I am on the proof dashboard for that work item' },
45
+ { keyword: 'And', text: 'I see the service health strip' },
46
+ { keyword: 'And', text: 'I see the scenario list' },
47
+ ],
48
+ },
49
+ {
50
+ title: 'Services auto-launch when proof starts',
51
+ steps: [
52
+ { keyword: 'Given', text: 'I am on the proof dashboard for a work item' },
53
+ { keyword: 'When', text: 'I click the Run Proof button' },
54
+ { keyword: 'Then', text: 'required services start in dependency order' },
55
+ { keyword: 'And', text: 'each service shows a health indicator' },
56
+ { keyword: 'And', text: 'the proof waits until all services are healthy' },
57
+ ],
58
+ },
59
+ {
60
+ title: 'BDD scenarios stream results in real time',
61
+ steps: [
62
+ { keyword: 'Given', text: 'I am on the proof dashboard for a work item' },
63
+ { keyword: 'And', text: 'all required services are healthy' },
64
+ { keyword: 'When', text: 'the proof run begins' },
65
+ { keyword: 'Then', text: 'each scenario appears in the scenario list' },
66
+ { keyword: 'And', text: 'each step streams its status as it executes' },
67
+ { keyword: 'And', text: 'the progress bar updates with each completed step' },
68
+ ],
69
+ },
70
+ {
71
+ title: 'Inspector tabs show proof details',
72
+ steps: [
73
+ { keyword: 'Given', text: 'a proof run is in progress' },
74
+ { keyword: 'When', text: 'I switch between inspector tabs' },
75
+ { keyword: 'Then', text: 'the Flow tab shows chronological event traces' },
76
+ { keyword: 'And', text: 'the Scenarios tab shows step-by-step BDD progress' },
77
+ { keyword: 'And', text: 'the API tab shows HTTP and IPC calls with latency' },
78
+ { keyword: 'And', text: 'the DB tab shows table mutations with before and after values' },
79
+ { keyword: 'And', text: 'the Logs tab shows raw service output' },
80
+ ],
81
+ },
82
+ ];
83
+
84
+ // ─── Scenario Runner ─────────────────────────────────────────
85
+
86
+ export class ScenarioRunner {
87
+ private scenarios: Scenario[] = [];
88
+ private logs: string[] = [];
89
+ private state: ScenarioRunnerStatus['state'] = 'idle';
90
+ private onUpdate: ScenarioRunnerCallback;
91
+ private unlisteners: (() => void)[] = [];
92
+ private pid: number | null = null;
93
+ private outputBuffer = '';
94
+
95
+ constructor(onUpdate: ScenarioRunnerCallback) {
96
+ this.onUpdate = onUpdate;
97
+ }
98
+
99
+ getStatus(): ScenarioRunnerStatus {
100
+ return {
101
+ scenarios: this.scenarios.map(s => ({ ...s, steps: [...s.steps] })),
102
+ logs: [...this.logs],
103
+ state: this.state,
104
+ };
105
+ }
106
+
107
+ async run(featureFile: string | null): Promise<void> {
108
+ this.state = 'running';
109
+ this.scenarios = [];
110
+ this.logs = [];
111
+ this.notify();
112
+
113
+ if (featureFile && isTauri()) {
114
+ await this.runReal(featureFile);
115
+ } else if (featureFile && !isTauri()) {
116
+ // Outside Tauri with a real feature file — can't spawn processes,
117
+ // fall back to simulated run
118
+ this.addLog(`Feature file: ${featureFile} (simulated — no Tauri IPC)`);
119
+ await this.runSimulated();
120
+ } else {
121
+ // No feature file — simulated run
122
+ this.addLog('No feature file linked to this work item — running simulated scenarios');
123
+ await this.runSimulated();
124
+ }
125
+ }
126
+
127
+ async stop(): Promise<void> {
128
+ if (this.pid && isTauri()) {
129
+ try {
130
+ await invoke('kill_process', { pid: this.pid });
131
+ } catch { /* best effort */ }
132
+ }
133
+ this.state = 'idle';
134
+ this.pid = null;
135
+ this.notify();
136
+ }
137
+
138
+ destroy(): void {
139
+ for (const unlisten of this.unlisteners) {
140
+ unlisten();
141
+ }
142
+ this.unlisteners = [];
143
+ }
144
+
145
+ // ─── Real Tauri IPC implementation ──────────────────────
146
+
147
+ private async runReal(featureFile: string): Promise<void> {
148
+ // Listen for process output to parse cucumber messages
149
+ const outputUnlisten = await listen<{ pid: number; lines: string[] }>(
150
+ 'process-output-batch',
151
+ (event) => {
152
+ if (event.payload.pid !== this.pid) return;
153
+
154
+ for (const line of event.payload.lines) {
155
+ this.addLog(line);
156
+ this.outputBuffer += line;
157
+ this.parseCucumberOutput();
158
+ }
159
+ },
160
+ );
161
+ this.unlisteners.push(outputUnlisten);
162
+
163
+ const exitUnlisten = await listen<{ pid: number; code: number }>(
164
+ 'process-exit',
165
+ (event) => {
166
+ if (event.payload.pid !== this.pid) return;
167
+ this.state = event.payload.code === 0 ? 'complete' : 'failed';
168
+ this.pid = null;
169
+ this.notify();
170
+ },
171
+ );
172
+ this.unlisteners.push(exitUnlisten);
173
+
174
+ // Spawn cucumber-js with message format for streaming
175
+ this.pid = await invoke<number>('spawn_process', {
176
+ command: 'npx',
177
+ args: ['cucumber-js', featureFile, '--format', 'message'],
178
+ label: 'cucumber-bdd',
179
+ kind: 'DevServer',
180
+ cwd: null,
181
+ envVars: {},
182
+ });
183
+ }
184
+
185
+ private parseCucumberOutput(): void {
186
+ // Parse newline-delimited JSON messages from cucumber
187
+ const lines = this.outputBuffer.split('\n');
188
+ this.outputBuffer = lines.pop() || ''; // Keep incomplete last line
189
+
190
+ for (const line of lines) {
191
+ const trimmed = line.trim();
192
+ if (!trimmed || !trimmed.startsWith('{')) continue;
193
+
194
+ try {
195
+ const msg = JSON.parse(trimmed);
196
+ this.handleCucumberMessage(msg);
197
+ } catch {
198
+ // Not valid JSON, skip
199
+ }
200
+ }
201
+ }
202
+
203
+ private handleCucumberMessage(msg: Record<string, unknown>): void {
204
+ // Cucumber message format events
205
+ if (msg.pickle) {
206
+ const pickle = msg.pickle as { name: string; steps: { text: string; keyword?: string }[] };
207
+ this.scenarios.push({
208
+ title: pickle.name,
209
+ status: 'pending',
210
+ steps: pickle.steps.map(s => ({
211
+ keyword: (s.keyword || 'Step').trim(),
212
+ text: s.text,
213
+ status: 'pending' as const,
214
+ })),
215
+ });
216
+ this.notify();
217
+ }
218
+
219
+ if (msg.testCaseStarted) {
220
+ // Mark the next pending scenario as running
221
+ const pendingScenario = this.scenarios.find(s => s.status === 'pending');
222
+ if (pendingScenario) {
223
+ pendingScenario.status = 'running';
224
+ if (pendingScenario.steps.length > 0) {
225
+ pendingScenario.steps[0].status = 'running';
226
+ }
227
+ this.notify();
228
+ }
229
+ }
230
+
231
+ if (msg.testStepFinished) {
232
+ const result = msg.testStepFinished as { testStepResult: { status: string; duration?: { nanos: number } } };
233
+ const runningScenario = this.scenarios.find(s => s.status === 'running');
234
+ if (runningScenario) {
235
+ const runningStep = runningScenario.steps.find(s => s.status === 'running');
236
+ if (runningStep) {
237
+ const status = result.testStepResult.status.toLowerCase();
238
+ runningStep.status = (status === 'passed' ? 'passed' : 'failed') as ScenarioStep['status'];
239
+ if (result.testStepResult.duration) {
240
+ runningStep.duration = Math.round(result.testStepResult.duration.nanos / 1_000_000);
241
+ }
242
+
243
+ if (runningStep.status === 'failed') {
244
+ // Skip all remaining pending steps in this scenario
245
+ for (const step of runningScenario.steps) {
246
+ if (step.status === 'pending') {
247
+ step.status = 'skipped';
248
+ }
249
+ }
250
+ } else {
251
+ // Mark next pending step as running
252
+ const nextPending = runningScenario.steps.find(s => s.status === 'pending');
253
+ if (nextPending) {
254
+ nextPending.status = 'running';
255
+ }
256
+ }
257
+ }
258
+ this.notify();
259
+ }
260
+ }
261
+
262
+ if (msg.testCaseFinished) {
263
+ const runningScenario = this.scenarios.find(s => s.status === 'running');
264
+ if (runningScenario) {
265
+ const allPassed = runningScenario.steps.every(s => s.status === 'passed');
266
+ runningScenario.status = allPassed ? 'passed' : 'failed';
267
+
268
+ // Calculate total duration
269
+ const totalDuration = runningScenario.steps.reduce(
270
+ (acc, s) => acc + (s.duration || 0), 0
271
+ );
272
+ runningScenario.duration = totalDuration;
273
+ this.notify();
274
+ }
275
+ }
276
+ }
277
+
278
+ // ─── Simulated implementation (dev mode) ────────────────
279
+
280
+ private async runSimulated(): Promise<void> {
281
+ // Create all scenarios in pending state first
282
+ this.scenarios = SIMULATED_SCENARIOS.map(s => ({
283
+ title: s.title,
284
+ status: 'pending' as const,
285
+ steps: s.steps.map(step => ({
286
+ ...step,
287
+ status: 'pending' as const,
288
+ })),
289
+ }));
290
+ this.notify();
291
+
292
+ // Run through each scenario
293
+ for (const scenario of this.scenarios) {
294
+ scenario.status = 'running';
295
+ this.notify();
296
+
297
+ const scenarioStart = Date.now();
298
+
299
+ let scenarioFailed = false;
300
+
301
+ for (const step of scenario.steps) {
302
+ if (scenarioFailed) {
303
+ step.status = 'skipped';
304
+ this.addLog(` - ${step.keyword} ${step.text} (skipped)`);
305
+ this.notify();
306
+ continue;
307
+ }
308
+
309
+ step.status = 'running';
310
+ this.notify();
311
+
312
+ // Simulate step execution (100-400ms per step)
313
+ const stepStart = Date.now();
314
+ const delay = 100 + Math.random() * 300;
315
+ await new Promise(resolve => setTimeout(resolve, delay));
316
+
317
+ step.status = 'passed';
318
+ step.duration = Date.now() - stepStart;
319
+ this.addLog(` ✓ ${step.keyword} ${step.text} (${step.duration}ms)`);
320
+ this.notify();
321
+ }
322
+
323
+ scenario.status = scenarioFailed ? 'failed' : 'passed';
324
+ scenario.duration = Date.now() - scenarioStart;
325
+ this.addLog(`${scenarioFailed ? '✗' : '✓'} Scenario: ${scenario.title} (${scenario.duration}ms)`);
326
+ this.notify();
327
+ }
328
+
329
+ this.state = 'complete';
330
+ this.notify();
331
+ }
332
+
333
+ // ─── Helpers ────────────────────────────────────────────
334
+
335
+ private addLog(line: string): void {
336
+ this.logs.push(line);
337
+ // Keep last 500 lines
338
+ if (this.logs.length > 500) {
339
+ this.logs = this.logs.slice(-500);
340
+ }
341
+ }
342
+
343
+ private notify(): void {
344
+ this.onUpdate(this.getStatus());
345
+ }
346
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Service Recovery — pre-flight checks and stderr-based recovery
3
+ * for the QA environment launch flow.
4
+ *
5
+ * Phase 1 (pre-flight): Detects and fixes common issues BEFORE spawning.
6
+ * Phase 2 (stderr recovery): Matches crash output to known patterns and retries.
7
+ *
8
+ * Uses Tauri IPC (run_shell_command) for all shell operations since this
9
+ * runs in browser context where child_process is unavailable.
10
+ */
11
+
12
+ import { runShellCommand, isTauri } from './tauri';
13
+ import type { ServiceDefinition } from './environment-config';
14
+
15
+ // ─── Types ──────────────────────────────────────────────────
16
+
17
+ export interface RecoveryStatus {
18
+ phase: 'preflight' | 'recovery';
19
+ service: string;
20
+ action: string;
21
+ severity?: 'info' | 'warning';
22
+ }
23
+
24
+ export interface PreflightResult {
25
+ check: string;
26
+ label: string;
27
+ fixed: boolean;
28
+ reason?: string;
29
+ }
30
+
31
+ export interface RecoveryResult {
32
+ shouldRetry: boolean;
33
+ fixApplied: string | null;
34
+ reason: string;
35
+ }
36
+
37
+ interface PreflightCheck {
38
+ name: string;
39
+ label: (ctx: Record<string, unknown>) => string;
40
+ detect: (service: ServiceDefinition, cwd: string | null) => Promise<Record<string, unknown> | null>;
41
+ fix: (ctx: Record<string, unknown>) => Promise<{ fixed: boolean; reason?: string }>;
42
+ }
43
+
44
+ interface StderrPattern {
45
+ name: string;
46
+ pattern: RegExp;
47
+ label: () => string;
48
+ fix: (ctx: { stderr: string; service: ServiceDefinition; cwd: string | null }) => Promise<{ fixed: boolean; reason?: string }>;
49
+ }
50
+
51
+ // ─── Dependency Install Strategies ──────────────────────────
52
+
53
+ interface DependencyStrategy {
54
+ /** File that indicates this ecosystem */
55
+ marker: string;
56
+ /** Command to run */
57
+ command: string;
58
+ /** Arguments */
59
+ args: string[];
60
+ /** Timeout in ms */
61
+ timeoutMs: number;
62
+ }
63
+
64
+ const DEPENDENCY_STRATEGIES: DependencyStrategy[] = [
65
+ { marker: 'package.json', command: 'npm', args: ['install'], timeoutMs: 120_000 },
66
+ { marker: 'requirements.txt', command: 'pip', args: ['install', '-r', 'requirements.txt'], timeoutMs: 120_000 },
67
+ { marker: 'pyproject.toml', command: 'pip', args: ['install', '.'], timeoutMs: 120_000 },
68
+ { marker: 'Gemfile', command: 'bundle', args: ['install'], timeoutMs: 120_000 },
69
+ { marker: 'go.mod', command: 'go', args: ['mod', 'download'], timeoutMs: 120_000 },
70
+ { marker: 'Cargo.toml', command: 'cargo', args: ['build'], timeoutMs: 300_000 },
71
+ ];
72
+
73
+ /** Detect which dependency strategy applies by checking for marker files. */
74
+ async function detectDependencyStrategy(cwd: string): Promise<DependencyStrategy | null> {
75
+ for (const strategy of DEPENDENCY_STRATEGIES) {
76
+ const result = await runShellCommand('test', ['-f', strategy.marker], { cwd });
77
+ if (result.exit_code === 0) return strategy;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** Check if a dependency directory exists (node_modules, venv, vendor, etc.) */
83
+ async function hasDependencyDir(cwd: string, strategy: DependencyStrategy): Promise<boolean> {
84
+ const dirMap: Record<string, string> = {
85
+ 'package.json': 'node_modules',
86
+ 'Gemfile': 'vendor/bundle',
87
+ 'go.mod': 'vendor',
88
+ };
89
+ const dir = dirMap[strategy.marker];
90
+ if (!dir) return true; // No dependency dir concept for this ecosystem
91
+ const result = await runShellCommand('test', ['-d', dir], { cwd });
92
+ return result.exit_code === 0;
93
+ }
94
+
95
+ // ─── Pre-flight Checks ─────────────────────────────────────
96
+
97
+ export const PREFLIGHT_CHECKS: PreflightCheck[] = [
98
+ {
99
+ name: 'port-in-use',
100
+ label: (ctx) => `Freeing port ${ctx.port}...`,
101
+ detect: async (service) => {
102
+ if (!service.port) return null;
103
+ // Use lsof to check if port is in use
104
+ const result = await runShellCommand('lsof', ['-ti', `:${service.port}`], { timeoutMs: 5000 });
105
+ if (result.exit_code === 0 && result.stdout.trim()) {
106
+ return { port: service.port, pid: result.stdout.trim().split('\n')[0] };
107
+ }
108
+ return null;
109
+ },
110
+ fix: async (ctx) => {
111
+ const pid = ctx.pid as string;
112
+ if (!pid) return { fixed: false, reason: 'Could not identify process holding port' };
113
+
114
+ // Kill the process holding the port
115
+ await runShellCommand('kill', ['-TERM', pid], { timeoutMs: 5000 });
116
+
117
+ // Wait briefly then verify it's free
118
+ await new Promise(resolve => setTimeout(resolve, 500));
119
+
120
+ const check = await runShellCommand('lsof', ['-ti', `:${ctx.port}`], { timeoutMs: 5000 });
121
+ if (check.exit_code === 0 && check.stdout.trim()) {
122
+ // Still in use — force kill
123
+ await runShellCommand('kill', ['-KILL', pid], { timeoutMs: 5000 });
124
+ await new Promise(resolve => setTimeout(resolve, 300));
125
+ }
126
+ return { fixed: true };
127
+ },
128
+ },
129
+ {
130
+ name: 'missing-dependencies',
131
+ label: () => 'Installing dependencies...',
132
+ detect: async (service, cwd) => {
133
+ if (!cwd) return null;
134
+ const strategy = await detectDependencyStrategy(cwd);
135
+ if (!strategy) return null;
136
+ const hasDeps = await hasDependencyDir(cwd, strategy);
137
+ return hasDeps ? null : { cwd, strategy };
138
+ },
139
+ fix: async (ctx) => {
140
+ const strategy = ctx.strategy as DependencyStrategy;
141
+ const cwd = ctx.cwd as string;
142
+ const result = await runShellCommand(strategy.command, strategy.args, {
143
+ cwd,
144
+ timeoutMs: strategy.timeoutMs,
145
+ });
146
+ if (result.exit_code === 0) return { fixed: true };
147
+ return { fixed: false, reason: `${strategy.command} ${strategy.args.join(' ')} failed: ${result.stderr.slice(0, 200)}` };
148
+ },
149
+ },
150
+ {
151
+ name: 'command-not-found',
152
+ label: (ctx) => `Command "${ctx.command}" not found`,
153
+ detect: async (service) => {
154
+ const command = service.command.split(/\s+/)[0];
155
+ const result = await runShellCommand('which', [command], { timeoutMs: 5000 });
156
+ if (result.exit_code !== 0) return { command };
157
+ return null;
158
+ },
159
+ fix: async (ctx) => {
160
+ // Can't auto-fix missing commands — but we fail fast with a clear message
161
+ return { fixed: false, reason: `"${ctx.command}" is not installed` };
162
+ },
163
+ },
164
+ ];
165
+
166
+ // ─── Stderr Patterns ────────────────────────────────────────
167
+
168
+ export const STDERR_PATTERNS: StderrPattern[] = [
169
+ {
170
+ name: 'eaddrinuse',
171
+ pattern: /EADDRINUSE|address already in use|port.*already.*in.*use/i,
172
+ label: () => 'Port conflict detected, freeing port...',
173
+ fix: async (ctx) => {
174
+ // Extract port from stderr if possible, otherwise use service port
175
+ const portMatch = ctx.stderr.match(/port\s+(\d+)|:(\d+)/);
176
+ const port = portMatch ? parseInt(portMatch[1] || portMatch[2]) : ctx.service.port;
177
+ if (!port) return { fixed: false, reason: 'Could not determine port' };
178
+
179
+ const lsof = await runShellCommand('lsof', ['-ti', `:${port}`], { timeoutMs: 5000 });
180
+ const pid = lsof.stdout.trim().split('\n')[0];
181
+ if (pid) {
182
+ await runShellCommand('kill', ['-TERM', pid], { timeoutMs: 5000 });
183
+ await new Promise(resolve => setTimeout(resolve, 500));
184
+ return { fixed: true };
185
+ }
186
+ // Port may have been freed by the crashing process itself
187
+ const recheck = await runShellCommand('lsof', ['-ti', `:${port}`], { timeoutMs: 5000 });
188
+ return { fixed: recheck.exit_code !== 0 || !recheck.stdout.trim() };
189
+ },
190
+ },
191
+ {
192
+ name: 'module-not-found',
193
+ pattern: /Cannot find module|MODULE_NOT_FOUND|Error: Cannot find package|ModuleNotFoundError|No module named|cannot load such file|cannot find package/i,
194
+ label: () => 'Missing dependency detected, installing...',
195
+ fix: async (ctx) => {
196
+ const cwd = ctx.cwd;
197
+ if (!cwd) return { fixed: false, reason: 'No project directory' };
198
+ const strategy = await detectDependencyStrategy(cwd);
199
+ if (!strategy) return { fixed: false, reason: 'Unknown project type' };
200
+ const result = await runShellCommand(strategy.command, strategy.args, {
201
+ cwd,
202
+ timeoutMs: strategy.timeoutMs,
203
+ });
204
+ return { fixed: result.exit_code === 0, reason: result.exit_code !== 0 ? result.stderr.slice(0, 200) : undefined };
205
+ },
206
+ },
207
+ {
208
+ name: 'eacces-permission',
209
+ pattern: /EACCES|permission denied/i,
210
+ label: () => 'Permission error detected',
211
+ fix: async () => {
212
+ return { fixed: false, reason: 'Permission denied — check file permissions' };
213
+ },
214
+ },
215
+ {
216
+ name: 'env-var-missing',
217
+ pattern: /env.*not.*defined|missing.*environment|undefined.*env|process\.env\.\w+.*undefined/i,
218
+ label: () => 'Missing environment variable detected',
219
+ fix: async () => {
220
+ return { fixed: false, reason: 'Missing environment variable — check .env file' };
221
+ },
222
+ },
223
+ ];
224
+
225
+ // ─── Service Recovery Orchestrator ──────────────────────────
226
+
227
+ export class ServiceRecovery {
228
+ private onStatus: (status: RecoveryStatus) => void;
229
+
230
+ constructor(onStatus?: (status: RecoveryStatus) => void) {
231
+ this.onStatus = onStatus || (() => {});
232
+ }
233
+
234
+ /**
235
+ * Phase 1: Run pre-flight checks before spawning a service.
236
+ * Returns list of issues found and whether they were fixed.
237
+ */
238
+ async runPreflightChecks(
239
+ service: ServiceDefinition,
240
+ cwd: string | null,
241
+ ): Promise<PreflightResult[]> {
242
+ if (!isTauri()) return [];
243
+
244
+ const results: PreflightResult[] = [];
245
+
246
+ for (const check of PREFLIGHT_CHECKS) {
247
+ try {
248
+ const issue = await check.detect(service, cwd);
249
+ if (issue) {
250
+ const label = check.label(issue);
251
+ this.onStatus({ phase: 'preflight', service: service.name, action: label });
252
+
253
+ try {
254
+ const result = await check.fix(issue);
255
+ results.push({ check: check.name, label, ...result });
256
+
257
+ if (!result.fixed) {
258
+ this.onStatus({
259
+ phase: 'preflight',
260
+ service: service.name,
261
+ action: `Could not fix: ${result.reason}`,
262
+ severity: 'warning',
263
+ });
264
+ }
265
+ } catch (fixErr) {
266
+ const reason = fixErr instanceof Error ? fixErr.message : String(fixErr);
267
+ console.error(`[ServiceRecovery] Fix "${check.name}" threw for "${service.name}":`, reason);
268
+ results.push({ check: check.name, label, fixed: false, reason });
269
+ this.onStatus({
270
+ phase: 'preflight',
271
+ service: service.name,
272
+ action: `Could not fix: ${reason}`,
273
+ severity: 'warning',
274
+ });
275
+ }
276
+ }
277
+ } catch (detectErr) {
278
+ const reason = detectErr instanceof Error ? detectErr.message : String(detectErr);
279
+ console.error(`[ServiceRecovery] Check "${check.name}" threw for "${service.name}":`, reason);
280
+ results.push({ check: check.name, label: check.name, fixed: false, reason });
281
+ this.onStatus({
282
+ phase: 'preflight',
283
+ service: service.name,
284
+ action: `Check failed: ${reason}`,
285
+ severity: 'warning',
286
+ });
287
+ }
288
+ }
289
+
290
+ return results;
291
+ }
292
+
293
+ /**
294
+ * Phase 2: Analyze stderr from a crashed service and attempt recovery.
295
+ * Returns whether the service should be retried.
296
+ */
297
+ async analyzeFailure(
298
+ service: ServiceDefinition,
299
+ stderr: string,
300
+ cwd: string | null,
301
+ ): Promise<RecoveryResult> {
302
+ if (!isTauri()) return { shouldRetry: false, fixApplied: null, reason: 'Not in Tauri' };
303
+
304
+ for (const pattern of STDERR_PATTERNS) {
305
+ if (pattern.pattern.test(stderr)) {
306
+ const label = pattern.label();
307
+ this.onStatus({ phase: 'recovery', service: service.name, action: label });
308
+
309
+ try {
310
+ const result = await pattern.fix({ stderr, service, cwd });
311
+ if (result.fixed) {
312
+ return { shouldRetry: true, fixApplied: pattern.name, reason: label };
313
+ } else {
314
+ return { shouldRetry: false, fixApplied: null, reason: result.reason || 'Fix failed' };
315
+ }
316
+ } catch (fixErr) {
317
+ const reason = fixErr instanceof Error ? fixErr.message : String(fixErr);
318
+ console.error(`[ServiceRecovery] Recovery fix "${pattern.name}" threw for "${service.name}":`, reason);
319
+ return { shouldRetry: false, fixApplied: null, reason };
320
+ }
321
+ }
322
+ }
323
+
324
+ return { shouldRetry: false, fixApplied: null, reason: 'Unrecognized failure' };
325
+ }
326
+ }
@@ -280,6 +280,7 @@ export function streamStatusToSessionState(status: string): SessionState {
280
280
  case 'idle':
281
281
  return 'idle';
282
282
  case 'connecting':
283
+ case 'creating': // Stream manager uses 'creating' for first-message delay
283
284
  return 'connecting';
284
285
  case 'streaming':
285
286
  return 'streaming';