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.
- package/.env +2 -1
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +54 -49
- package/apps/dashboard/app/demo/gates/page.tsx +3 -5
- package/apps/dashboard/app/design-system/page.tsx +1 -1
- package/apps/dashboard/app/globals.css +74 -2
- package/apps/dashboard/app/install-claude/page.tsx +3 -5
- package/apps/dashboard/app/login/page.tsx +17 -20
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +60 -12
- package/apps/dashboard/app/signup/page.tsx +14 -17
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +12 -15
- package/apps/dashboard/app/work/[id]/page.tsx +90 -75
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +70 -61
- package/apps/dashboard/components/CardMenu.tsx +0 -1
- package/apps/dashboard/components/ClaudePanel.tsx +541 -283
- package/apps/dashboard/components/ClaudePanelInput.tsx +23 -4
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +1 -5
- package/apps/dashboard/components/CopyableId.tsx +1 -2
- package/apps/dashboard/components/DetailReviewActions.tsx +11 -20
- package/apps/dashboard/components/DragContext.tsx +132 -62
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +5 -6
- package/apps/dashboard/components/EditableDetailDescription.tsx +6 -12
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +0 -1
- package/apps/dashboard/components/ElapsedTimer.tsx +15 -3
- package/apps/dashboard/components/EpicGroup.tsx +100 -70
- package/apps/dashboard/components/GateCard.tsx +0 -1
- package/apps/dashboard/components/GateChoiceCard.tsx +1 -2
- package/apps/dashboard/components/InstallClaudeScreen.tsx +1 -5
- package/apps/dashboard/components/JettyLoader.tsx +0 -1
- package/apps/dashboard/components/KanbanBoard.tsx +319 -173
- package/apps/dashboard/components/KanbanCard.tsx +341 -107
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +0 -1
- package/apps/dashboard/components/MainNav.tsx +24 -25
- package/apps/dashboard/components/MessageBlock.tsx +93 -16
- package/apps/dashboard/components/ModeStartCard.tsx +0 -1
- package/apps/dashboard/components/OnboardingWelcome.tsx +0 -1
- package/apps/dashboard/components/PlaceholderCard.tsx +0 -1
- package/apps/dashboard/components/ProjectSwitcher.tsx +20 -20
- package/apps/dashboard/components/PrototypeTimeline.tsx +47 -26
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +308 -223
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +303 -160
- package/apps/dashboard/components/ReviewFooter.tsx +12 -14
- package/apps/dashboard/components/SessionList.tsx +0 -1
- package/apps/dashboard/components/SubscribeContent.tsx +40 -11
- package/apps/dashboard/components/TestTree.tsx +1 -2
- package/apps/dashboard/components/TipCard.tsx +2 -4
- package/apps/dashboard/components/Toast.tsx +0 -1
- package/apps/dashboard/components/TypeIcon.tsx +7 -8
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +5 -17
- package/apps/dashboard/components/WelcomeScreen.tsx +2 -6
- package/apps/dashboard/components/WorkItemHeader.tsx +0 -1
- package/apps/dashboard/components/WorkItemTree.tsx +2 -4
- package/apps/dashboard/components/settings/AccountSection.tsx +27 -13
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +20 -73
- package/apps/dashboard/components/settings/GeneralSection.tsx +137 -26
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +0 -1
- package/apps/dashboard/components/ui/Button.tsx +1 -1
- package/apps/dashboard/components/ui/Input.tsx +1 -1
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +611 -358
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +0 -1
- package/apps/dashboard/contexts/UsageContext.tsx +62 -31
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1302
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +226 -26
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +253 -122
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +3 -3
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -33
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +167 -30
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/jettypod.js +96 -4
- package/lib/bdd-preflight.js +96 -0
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/work-commands/index.js +58 -16
- package/lib/work-tracking/index.js +108 -8
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +43 -1
- package/skills-templates/chore-mode/SKILL.md +40 -1
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +14 -0
- package/skills-templates/feature-planning/SKILL.md +90 -1
- package/skills-templates/production-mode/SKILL.md +20 -0
- package/skills-templates/simple-improvement/SKILL.md +39 -2
- package/skills-templates/speed-mode/SKILL.md +10 -15
- package/skills-templates/stable-mode/SKILL.md +47 -0
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -446
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -280
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -525
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/route.ts +0 -35
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -63
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -55
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -30
- package/apps/dashboard/electron/ipc-handlers.js +0 -1026
- package/apps/dashboard/electron/main.js +0 -2306
- package/apps/dashboard/electron/preload.js +0 -125
- package/apps/dashboard/electron/session-manager.js +0 -163
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/backlog-parser.ts +0 -50
- package/apps/dashboard/lib/claude-process-manager.ts +0 -529
- package/apps/dashboard/lib/db-bridge.ts +0 -283
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -66
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/bug-icon.svg +0 -9
- package/apps/dashboard/public/buoy-icon.svg +0 -9
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/in-flight-seagull.svg +0 -9
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/pier-icon.svg +0 -14
- package/apps/dashboard/public/star-icon.svg +0 -9
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/public/wrench-icon.svg +0 -9
- package/apps/dashboard/scripts/download-node.js +0 -104
- 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';
|