jettypod 4.4.118 → 4.4.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +4 -3
- package/Cargo.lock +6450 -0
- package/Cargo.toml +35 -0
- package/README.md +5 -1
- package/TAURI-MIGRATION-PLAN.md +840 -0
- package/apps/dashboard/app/connect-claude/page.tsx +5 -6
- package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
- package/apps/dashboard/app/demo/gates/page.tsx +43 -45
- package/apps/dashboard/app/design-system/page.tsx +868 -0
- package/apps/dashboard/app/globals.css +80 -4
- package/apps/dashboard/app/install-claude/page.tsx +4 -6
- package/apps/dashboard/app/login/page.tsx +72 -54
- package/apps/dashboard/app/page.tsx +101 -48
- package/apps/dashboard/app/settings/page.tsx +61 -13
- package/apps/dashboard/app/signup/page.tsx +242 -0
- package/apps/dashboard/app/subscribe/page.tsx +0 -2
- package/apps/dashboard/app/tests/page.tsx +37 -4
- package/apps/dashboard/app/welcome/page.tsx +13 -16
- package/apps/dashboard/app/work/[id]/page.tsx +117 -118
- package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
- package/apps/dashboard/components/AppShell.tsx +92 -85
- package/apps/dashboard/components/CardMenu.tsx +45 -12
- package/apps/dashboard/components/ClaudePanel.tsx +771 -850
- package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
- package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
- package/apps/dashboard/components/CopyableId.tsx +3 -4
- package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
- package/apps/dashboard/components/DragContext.tsx +134 -63
- package/apps/dashboard/components/DraggableCard.tsx +3 -5
- package/apps/dashboard/components/DropZone.tsx +6 -7
- package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
- package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
- package/apps/dashboard/components/EditableTitle.tsx +26 -7
- package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
- package/apps/dashboard/components/EpicGroup.tsx +359 -0
- package/apps/dashboard/components/GateCard.tsx +79 -17
- package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
- package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
- package/apps/dashboard/components/JettyLoader.tsx +37 -0
- package/apps/dashboard/components/KanbanBoard.tsx +368 -958
- package/apps/dashboard/components/KanbanCard.tsx +740 -0
- package/apps/dashboard/components/LazyCard.tsx +62 -0
- package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
- package/apps/dashboard/components/MainNav.tsx +38 -73
- package/apps/dashboard/components/MessageBlock.tsx +468 -0
- package/apps/dashboard/components/ModeStartCard.tsx +15 -16
- package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
- package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
- package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
- package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
- package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
- package/apps/dashboard/components/ReviewFooter.tsx +139 -0
- package/apps/dashboard/components/SessionList.tsx +19 -19
- package/apps/dashboard/components/SubscribeContent.tsx +91 -47
- package/apps/dashboard/components/TestTree.tsx +16 -16
- package/apps/dashboard/components/TipCard.tsx +16 -17
- package/apps/dashboard/components/Toast.tsx +5 -6
- package/apps/dashboard/components/TypeIcon.tsx +55 -0
- package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
- package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
- package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
- package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
- package/apps/dashboard/components/WorkItemTree.tsx +11 -32
- package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
- package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
- package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
- package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
- package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
- package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
- package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
- package/apps/dashboard/components/ui/Button.tsx +104 -0
- package/apps/dashboard/components/ui/Input.tsx +78 -0
- package/apps/dashboard/components.json +1 -1
- package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
- package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
- package/apps/dashboard/contexts/UsageContext.tsx +87 -32
- package/apps/dashboard/dev.sh +35 -0
- package/apps/dashboard/eslint.config.mjs +9 -9
- package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
- package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
- package/apps/dashboard/hooks/useWebSocket.ts +138 -83
- package/apps/dashboard/index.html +73 -0
- package/apps/dashboard/lib/constants.ts +43 -0
- package/apps/dashboard/lib/data-bridge.ts +722 -0
- package/apps/dashboard/lib/db.ts +69 -1265
- package/apps/dashboard/lib/environment-config.ts +173 -0
- package/apps/dashboard/lib/environment-verification.ts +119 -0
- package/apps/dashboard/lib/kanban-utils.ts +270 -0
- package/apps/dashboard/lib/proof-run.ts +495 -0
- package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
- package/apps/dashboard/lib/run-migrations.js +27 -2
- package/apps/dashboard/lib/service-recovery.ts +326 -0
- package/apps/dashboard/lib/session-state-machine.ts +1 -0
- package/apps/dashboard/lib/session-state-utils.ts +0 -164
- package/apps/dashboard/lib/session-stream-manager.ts +308 -134
- package/apps/dashboard/lib/shadows.ts +7 -0
- package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
- package/apps/dashboard/lib/tauri-bridge.ts +102 -0
- package/apps/dashboard/lib/tauri.ts +106 -0
- package/apps/dashboard/lib/utils.ts +6 -0
- package/apps/dashboard/next-env.d.ts +1 -1
- package/apps/dashboard/package.json +21 -32
- package/apps/dashboard/public/bug-icon.png +0 -0
- package/apps/dashboard/public/buoy-icon.png +0 -0
- package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
- package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
- package/apps/dashboard/public/in-flight-seagull.png +0 -0
- package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
- package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
- package/apps/dashboard/public/jettypod_logo.png +0 -0
- package/apps/dashboard/public/pier-icon.png +0 -0
- package/apps/dashboard/public/star-icon.png +0 -0
- package/apps/dashboard/public/wrench-icon.png +0 -0
- package/apps/dashboard/scripts/tauri-build.js +228 -0
- package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
- package/apps/dashboard/scripts/ws-server.js +191 -0
- package/apps/dashboard/src/main.tsx +12 -0
- package/apps/dashboard/src/router.tsx +107 -0
- package/apps/dashboard/src/vite-env.d.ts +1 -0
- package/apps/dashboard/tsconfig.json +7 -12
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
- package/apps/dashboard/vite.config.ts +33 -0
- package/apps/update-server/src/index.ts +228 -80
- package/claude-hooks/global-guardrails.js +14 -13
- package/crates/jettypod-cli/Cargo.toml +19 -0
- package/crates/jettypod-cli/src/commands.rs +1249 -0
- package/crates/jettypod-cli/src/main.rs +595 -0
- package/crates/jettypod-core/Cargo.toml +26 -0
- package/crates/jettypod-core/build.rs +98 -0
- package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
- package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
- package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
- package/crates/jettypod-core/src/auth.rs +294 -0
- package/crates/jettypod-core/src/config.rs +397 -0
- package/crates/jettypod-core/src/db/mod.rs +507 -0
- package/crates/jettypod-core/src/db/recovery.rs +114 -0
- package/crates/jettypod-core/src/db/startup.rs +101 -0
- package/crates/jettypod-core/src/db/validate.rs +149 -0
- package/crates/jettypod-core/src/error.rs +76 -0
- package/crates/jettypod-core/src/git.rs +458 -0
- package/crates/jettypod-core/src/lib.rs +20 -0
- package/crates/jettypod-core/src/sessions.rs +625 -0
- package/crates/jettypod-core/src/skills.rs +556 -0
- package/crates/jettypod-core/src/work.rs +1086 -0
- package/crates/jettypod-core/src/worktree.rs +628 -0
- package/crates/jettypod-core/src/ws.rs +767 -0
- package/cucumber-test.cjs +6 -0
- package/cucumber.js +9 -3
- package/docs/COMMAND_REFERENCE.md +34 -0
- package/hooks/post-checkout +32 -75
- package/hooks/post-merge +111 -10
- package/jest.setup.js +1 -0
- package/jettypod.js +145 -116
- package/lib/bdd-preflight.js +96 -0
- package/lib/chore-taxonomy.js +33 -10
- package/lib/database.js +36 -16
- package/lib/db-watcher.js +1 -1
- package/lib/git-hooks/pre-commit +1 -1
- package/lib/jettypod-backup.js +27 -4
- package/lib/merge-lock.js +111 -253
- package/lib/migrations/027-plan-at-creation-column.js +3 -1
- package/lib/migrations/029-remove-autoincrement.js +307 -0
- package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
- package/lib/migrations/030-rejection-round-columns.js +54 -0
- package/lib/migrations/031-session-isolation-index.js +17 -0
- package/lib/migrations/index.js +47 -4
- package/lib/schema.js +10 -5
- package/lib/seed-onboarding.js +1 -1
- package/lib/update-command/index.js +9 -175
- package/lib/work-commands/index.js +144 -19
- package/lib/work-tracking/index.js +148 -27
- package/lib/worktree-diagnostics.js +16 -16
- package/lib/worktree-facade.js +1 -1
- package/lib/worktree-manager.js +8 -8
- package/lib/worktree-reconciler.js +5 -5
- package/package.json +9 -2
- package/scripts/ndjson-to-cucumber-json.js +152 -0
- package/scripts/postinstall.js +25 -0
- package/skills-templates/bug-mode/SKILL.md +79 -20
- package/skills-templates/bug-planning/SKILL.md +25 -29
- package/skills-templates/chore-mode/SKILL.md +171 -69
- package/skills-templates/chore-mode/verification.js +51 -10
- package/skills-templates/chore-planning/SKILL.md +47 -18
- package/skills-templates/design-system-selection/SKILL.md +273 -0
- package/skills-templates/epic-planning/SKILL.md +82 -48
- package/skills-templates/external-transition/SKILL.md +47 -47
- package/skills-templates/feature-planning/SKILL.md +173 -74
- package/skills-templates/production-mode/SKILL.md +69 -49
- package/skills-templates/request-routing/SKILL.md +4 -4
- package/skills-templates/simple-improvement/SKILL.md +74 -29
- package/skills-templates/speed-mode/SKILL.md +217 -141
- package/skills-templates/stable-mode/SKILL.md +148 -89
- package/apps/dashboard/README.md +0 -36
- package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
- package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
- package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
- package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
- package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
- package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
- package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
- package/apps/dashboard/app/api/kanban/route.ts +0 -15
- package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
- package/apps/dashboard/app/api/settings/general/route.ts +0 -21
- package/apps/dashboard/app/api/tests/route.ts +0 -9
- package/apps/dashboard/app/api/tests/run/route.ts +0 -82
- package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
- package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
- package/apps/dashboard/app/api/usage/route.ts +0 -17
- package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
- package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
- package/apps/dashboard/app/layout.tsx +0 -43
- package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
- package/apps/dashboard/electron/ipc-handlers.js +0 -1028
- package/apps/dashboard/electron/main.js +0 -2124
- package/apps/dashboard/electron/preload.js +0 -123
- package/apps/dashboard/electron/session-manager.js +0 -141
- package/apps/dashboard/electron-builder.config.js +0 -357
- package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
- package/apps/dashboard/lib/claude-process-manager.ts +0 -492
- package/apps/dashboard/lib/db-bridge.ts +0 -282
- package/apps/dashboard/lib/prototypes.ts +0 -202
- package/apps/dashboard/lib/test-results-db.ts +0 -307
- package/apps/dashboard/lib/tests.ts +0 -282
- package/apps/dashboard/next.config.js +0 -50
- package/apps/dashboard/postcss.config.mjs +0 -7
- package/apps/dashboard/public/file.svg +0 -1
- package/apps/dashboard/public/globe.svg +0 -1
- package/apps/dashboard/public/next.svg +0 -1
- package/apps/dashboard/public/vercel.svg +0 -1
- package/apps/dashboard/public/window.svg +0 -1
- package/apps/dashboard/scripts/download-node.js +0 -104
- package/apps/dashboard/scripts/upload-to-r2.js +0 -89
- package/docs/bdd-guidance.md +0 -390
|
@@ -0,0 +1,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';
|
|
@@ -57,170 +57,6 @@ export function createSyncRef<T>(initialValue: T): SyncRef<T> {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// ============================================================================
|
|
61
|
-
// Atomic State Updates
|
|
62
|
-
// ============================================================================
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Result of an atomic update operation.
|
|
66
|
-
*/
|
|
67
|
-
export interface AtomicUpdateResult<T> {
|
|
68
|
-
/** Whether the update was applied */
|
|
69
|
-
applied: boolean;
|
|
70
|
-
/** The resulting state (whether updated or not) */
|
|
71
|
-
state: T;
|
|
72
|
-
/** If not applied, the reason */
|
|
73
|
-
reason?: 'version_mismatch' | 'condition_failed' | 'locked';
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Options for atomic update.
|
|
78
|
-
*/
|
|
79
|
-
export interface AtomicUpdateOptions<T> {
|
|
80
|
-
/** Only apply if this condition returns true */
|
|
81
|
-
condition?: (currentState: T) => boolean;
|
|
82
|
-
/** Timeout for acquiring lock (ms). Default: 5000 */
|
|
83
|
-
timeoutMs?: number;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Atomic state container that prevents concurrent update races.
|
|
88
|
-
* Uses optimistic locking with version numbers.
|
|
89
|
-
*/
|
|
90
|
-
export class AtomicState<T> {
|
|
91
|
-
private state: T;
|
|
92
|
-
private version: number = 0;
|
|
93
|
-
private locked: boolean = false;
|
|
94
|
-
private lockQueue: Array<() => void> = [];
|
|
95
|
-
|
|
96
|
-
constructor(initialState: T) {
|
|
97
|
-
this.state = initialState;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Get current state and version.
|
|
102
|
-
*/
|
|
103
|
-
get(): { state: T; version: number } {
|
|
104
|
-
return { state: this.state, version: this.version };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Attempt to update state atomically.
|
|
109
|
-
* Only succeeds if the version matches (no concurrent updates).
|
|
110
|
-
*
|
|
111
|
-
* @param expectedVersion - The version you read before making changes
|
|
112
|
-
* @param newState - The new state to apply
|
|
113
|
-
* @param options - Optional conditions and timeout
|
|
114
|
-
*/
|
|
115
|
-
compareAndSet(
|
|
116
|
-
expectedVersion: number,
|
|
117
|
-
newState: T,
|
|
118
|
-
options?: AtomicUpdateOptions<T>
|
|
119
|
-
): AtomicUpdateResult<T> {
|
|
120
|
-
// Check version
|
|
121
|
-
if (this.version !== expectedVersion) {
|
|
122
|
-
return {
|
|
123
|
-
applied: false,
|
|
124
|
-
state: this.state,
|
|
125
|
-
reason: 'version_mismatch',
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Check condition if provided
|
|
130
|
-
if (options?.condition && !options.condition(this.state)) {
|
|
131
|
-
return {
|
|
132
|
-
applied: false,
|
|
133
|
-
state: this.state,
|
|
134
|
-
reason: 'condition_failed',
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Apply update
|
|
139
|
-
this.state = newState;
|
|
140
|
-
this.version++;
|
|
141
|
-
|
|
142
|
-
return {
|
|
143
|
-
applied: true,
|
|
144
|
-
state: this.state,
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Update state with a transform function, handling retries automatically.
|
|
150
|
-
* This is the recommended way to update state when you need read-modify-write.
|
|
151
|
-
*
|
|
152
|
-
* @param transform - Function that takes current state and returns new state
|
|
153
|
-
* @param maxRetries - Maximum retries on version conflict. Default: 3
|
|
154
|
-
*/
|
|
155
|
-
update(transform: (current: T) => T, maxRetries: number = 3): AtomicUpdateResult<T> {
|
|
156
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
157
|
-
const { state: current, version } = this.get();
|
|
158
|
-
const newState = transform(current);
|
|
159
|
-
const result = this.compareAndSet(version, newState);
|
|
160
|
-
|
|
161
|
-
if (result.applied) {
|
|
162
|
-
return result;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Version mismatch - retry with fresh state
|
|
166
|
-
if (result.reason !== 'version_mismatch') {
|
|
167
|
-
return result;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// All retries exhausted
|
|
172
|
-
return {
|
|
173
|
-
applied: false,
|
|
174
|
-
state: this.state,
|
|
175
|
-
reason: 'version_mismatch',
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Acquire exclusive lock for complex multi-step operations.
|
|
181
|
-
* Use sparingly - prefer compareAndSet or update for simple cases.
|
|
182
|
-
*/
|
|
183
|
-
async lock(timeoutMs: number = 5000): Promise<() => void> {
|
|
184
|
-
if (!this.locked) {
|
|
185
|
-
this.locked = true;
|
|
186
|
-
return () => this.unlock();
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Wait for lock
|
|
190
|
-
return new Promise((resolve, reject) => {
|
|
191
|
-
const timeout = setTimeout(() => {
|
|
192
|
-
const index = this.lockQueue.indexOf(tryAcquire);
|
|
193
|
-
if (index >= 0) this.lockQueue.splice(index, 1);
|
|
194
|
-
reject(new Error('Lock acquisition timeout'));
|
|
195
|
-
}, timeoutMs);
|
|
196
|
-
|
|
197
|
-
const tryAcquire = () => {
|
|
198
|
-
clearTimeout(timeout);
|
|
199
|
-
this.locked = true;
|
|
200
|
-
resolve(() => this.unlock());
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
this.lockQueue.push(tryAcquire);
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
private unlock(): void {
|
|
208
|
-
if (this.lockQueue.length > 0) {
|
|
209
|
-
const next = this.lockQueue.shift();
|
|
210
|
-
next?.();
|
|
211
|
-
} else {
|
|
212
|
-
this.locked = false;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Get current version (for debugging/monitoring).
|
|
218
|
-
*/
|
|
219
|
-
getVersion(): number {
|
|
220
|
-
return this.version;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
60
|
// ============================================================================
|
|
225
61
|
// Session-Specific Helpers
|
|
226
62
|
// ============================================================================
|