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,173 @@
1
+ /**
2
+ * Environment config loader — reads user-defined service configuration
3
+ * from .jettypod/config.json via Tauri IPC.
4
+ *
5
+ * The config.json "environment" key is captured by the Rust config reader's
6
+ * `extra` field (#[serde(flatten)]), so it's returned by db_get_config.
7
+ */
8
+
9
+ import { invoke, isTauri } from './tauri';
10
+ import type { ServiceConfig } from './proof-run';
11
+
12
+ // ─── Config Types ────────────────────────────────────────────
13
+
14
+ export type ServiceCategory = 'server' | 'infrastructure' | 'managed' | 'test-runner' | 'build-tool';
15
+
16
+ const VALID_CATEGORIES = new Set<ServiceCategory>(['server', 'infrastructure', 'managed', 'test-runner', 'build-tool']);
17
+
18
+ /** Coerce unknown category to valid ServiceCategory, defaulting to 'server' */
19
+ export function validateCategory(value: unknown): ServiceCategory {
20
+ if (typeof value === 'string' && VALID_CATEGORIES.has(value as ServiceCategory)) {
21
+ return value as ServiceCategory;
22
+ }
23
+ return 'server';
24
+ }
25
+
26
+ /** Validate faIcon string — must start with 'fa-' */
27
+ export function validateFaIcon(value: unknown): string | undefined {
28
+ if (typeof value === 'string' && value.startsWith('fa-')) return value;
29
+ return undefined;
30
+ }
31
+
32
+ export interface ServiceDefinition {
33
+ /** Display name shown in Environment Bar */
34
+ name: string;
35
+ /** Shell command to start this service */
36
+ command: string;
37
+ /** Port to health-check against */
38
+ port: number;
39
+ /** Health check strategy: http, ws, or tcp */
40
+ healthCheck: 'http' | 'ws' | 'tcp';
41
+ /** Optional: stdout pattern that means "ready" (faster than polling port) */
42
+ readyPattern?: string;
43
+ /** Optional services don't block "Ready for QA" */
44
+ optional?: boolean;
45
+ /** Launch order (lower = first). Same order = parallel. */
46
+ order: number;
47
+ /** Category for two-zone layout grouping */
48
+ category: ServiceCategory;
49
+ /** FontAwesome icon class (e.g. 'fa-database', 'fa-server') */
50
+ faIcon?: string;
51
+ }
52
+
53
+ /** Non-launchable stack items (managed services, test runners, build tools) */
54
+ export interface ContextItem {
55
+ /** Display name */
56
+ name: string;
57
+ /** Category determines zone placement and UX controls */
58
+ category: Extract<ServiceCategory, 'managed' | 'test-runner' | 'build-tool'>;
59
+ /** FontAwesome icon class */
60
+ faIcon?: string;
61
+ /** Shell command (e.g. test runner run command, connection test) */
62
+ command?: string;
63
+ /** Connection string or URL for managed services */
64
+ connectionUrl?: string;
65
+ }
66
+
67
+ export interface EnvironmentConfig {
68
+ services: ServiceDefinition[];
69
+ /** Non-launchable stack items displayed in the context zone */
70
+ contextItems: ContextItem[];
71
+ /** 'required' = all non-optional services must be up. 'all' = everything. */
72
+ readyWhen: 'required' | 'all';
73
+ /** Kill spawned processes when QA page unmounts. Default true. */
74
+ teardownOnClose: boolean;
75
+ }
76
+
77
+ // ─── Loader ──────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Load environment config from .jettypod/config.json via Tauri IPC.
81
+ * Returns null if no environment section is configured or not running in Tauri.
82
+ */
83
+ export async function loadEnvironmentConfig(): Promise<EnvironmentConfig | null> {
84
+ if (!isTauri()) return null;
85
+
86
+ try {
87
+ const fullConfig = await invoke<Record<string, unknown>>('db_get_config');
88
+ const env = fullConfig?.environment as Record<string, unknown> | undefined;
89
+ if (!env || !Array.isArray(env.services) || env.services.length === 0) {
90
+ return null;
91
+ }
92
+
93
+ const contextItems = Array.isArray(env.contextItems)
94
+ ? (env.contextItems as ContextItem[]).map(item => ({
95
+ name: item.name,
96
+ category: item.category,
97
+ faIcon: item.faIcon,
98
+ command: item.command,
99
+ connectionUrl: item.connectionUrl,
100
+ }))
101
+ : [];
102
+
103
+ return {
104
+ services: (env.services as ServiceDefinition[]).map((svc, i) => ({
105
+ name: svc.name,
106
+ command: svc.command,
107
+ port: svc.port,
108
+ healthCheck: svc.healthCheck || 'http',
109
+ readyPattern: svc.readyPattern,
110
+ optional: svc.optional ?? false,
111
+ order: svc.order ?? i + 1,
112
+ category: validateCategory(svc.category),
113
+ faIcon: validateFaIcon(svc.faIcon),
114
+ })),
115
+ contextItems,
116
+ readyWhen: (env.readyWhen as EnvironmentConfig['readyWhen']) || 'required',
117
+ teardownOnClose: env.teardownOnClose !== false,
118
+ };
119
+ } catch {
120
+ // IPC failure (db not initialized, config read error) — fall back to passive detection
121
+ return null;
122
+ }
123
+ }
124
+
125
+ // ─── Health Check Helpers ────────────────────────────────────
126
+
127
+ function checkHttp(port: number): Promise<boolean> {
128
+ return new Promise((resolve) => {
129
+ const controller = new AbortController();
130
+ const timeout = setTimeout(() => controller.abort(), 2000);
131
+ fetch(`http://localhost:${port}/`, { signal: controller.signal, mode: 'no-cors' })
132
+ .then(() => { clearTimeout(timeout); resolve(true); })
133
+ .catch(() => { clearTimeout(timeout); resolve(false); });
134
+ });
135
+ }
136
+
137
+ function checkWs(port: number): Promise<boolean> {
138
+ return new Promise((resolve) => {
139
+ const timeout = setTimeout(() => { ws.close(); resolve(false); }, 2000);
140
+ const ws = new WebSocket(`ws://localhost:${port}`);
141
+ ws.onopen = () => { clearTimeout(timeout); ws.close(); resolve(true); };
142
+ ws.onerror = () => { clearTimeout(timeout); resolve(false); };
143
+ });
144
+ }
145
+
146
+ // ─── Converter ───────────────────────────────────────────────
147
+
148
+ /**
149
+ * Convert EnvironmentConfig into ServiceConfig[] that ProofRunManager accepts.
150
+ * Maps the string-based healthCheck type to actual check functions.
151
+ */
152
+ /** Categories that are part of the QA launch sequence */
153
+ const LAUNCHABLE_CATEGORIES: ServiceCategory[] = ['server', 'infrastructure'];
154
+
155
+ export function toServiceConfigs(config: EnvironmentConfig): ServiceConfig[] {
156
+ const checkers: Record<string, (port: number) => Promise<boolean>> = {
157
+ http: checkHttp,
158
+ ws: checkWs,
159
+ tcp: checkHttp, // TCP check uses same HTTP probe (any response = port open)
160
+ };
161
+
162
+ return config.services
163
+ .filter(svc => LAUNCHABLE_CATEGORIES.includes(svc.category))
164
+ .map(svc => ({
165
+ name: svc.name,
166
+ port: svc.port,
167
+ order: svc.order,
168
+ healthCheck: () => {
169
+ const checker = checkers[svc.healthCheck] || checkHttp;
170
+ return checker(svc.port);
171
+ },
172
+ }));
173
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Environment Verification — fire-and-teardown wrapper around ProofRunManager.
3
+ *
4
+ * Used by ProjectStackSection after saving a new environment config to verify
5
+ * that all services actually start and pass health checks before transitioning
6
+ * to the "configured" state. Services are torn down immediately after verification.
7
+ */
8
+
9
+ import { ProofRunManager } from './proof-run';
10
+ import type { ProofRunStatus, ServiceStatus } from './proof-run';
11
+ import type { EnvironmentConfig } from './environment-config';
12
+
13
+ // ─── Types ───────────────────────────────────────────────────
14
+
15
+ export interface VerificationServiceResult {
16
+ name: string;
17
+ port: number | null;
18
+ passed: boolean;
19
+ error?: string;
20
+ }
21
+
22
+ export interface VerificationResult {
23
+ allPassed: boolean;
24
+ services: VerificationServiceResult[];
25
+ }
26
+
27
+ export type VerificationState = 'idle' | 'verifying' | 'done';
28
+
29
+ export interface VerificationUpdate {
30
+ state: VerificationState;
31
+ services: ServiceStatus[];
32
+ result?: VerificationResult;
33
+ }
34
+
35
+ // ─── Verification Runner ─────────────────────────────────────
36
+
37
+ /** Default timeout for verification (ms) */
38
+ const DEFAULT_VERIFICATION_TIMEOUT_MS = 60_000;
39
+
40
+ /**
41
+ * Launch all services from an EnvironmentConfig, health-check them,
42
+ * report per-service results, then tear everything down.
43
+ *
44
+ * @param config - The environment config to verify
45
+ * @param projectCwd - The project working directory for spawning processes
46
+ * @param onUpdate - Callback for live status updates during verification
47
+ * @param timeoutMs - Maximum time to wait for all services (default 60s)
48
+ * @returns VerificationResult with per-service pass/fail
49
+ */
50
+ export async function runVerification(
51
+ config: EnvironmentConfig,
52
+ projectCwd: string | null,
53
+ onUpdate: (update: VerificationUpdate) => void,
54
+ timeoutMs: number = DEFAULT_VERIFICATION_TIMEOUT_MS,
55
+ ): Promise<VerificationResult> {
56
+ let lastStatus: ProofRunStatus | null = null;
57
+
58
+ // Create a ProofRunManager to handle the actual launch + health checks
59
+ const manager = new ProofRunManager(
60
+ undefined,
61
+ (status: ProofRunStatus) => {
62
+ lastStatus = status;
63
+ onUpdate({
64
+ state: 'verifying',
65
+ services: status.services,
66
+ });
67
+ },
68
+ config,
69
+ projectCwd,
70
+ );
71
+
72
+ // Notify: starting verification
73
+ onUpdate({ state: 'verifying', services: manager.getStatus().services });
74
+
75
+ try {
76
+ // Launch services with a timeout to prevent hanging forever
77
+ const timeoutPromise = new Promise<'timeout'>((resolve) =>
78
+ setTimeout(() => resolve('timeout'), timeoutMs),
79
+ );
80
+
81
+ const raceResult = await Promise.race([
82
+ manager.startServices().then(() => 'done' as const),
83
+ timeoutPromise,
84
+ ]);
85
+
86
+ // Build result from final service statuses
87
+ const finalStatus = lastStatus ?? manager.getStatus();
88
+
89
+ const result: VerificationResult = {
90
+ allPassed: raceResult !== 'timeout' && finalStatus.services.every(s => s.status === 'running'),
91
+ services: finalStatus.services.map(s => {
92
+ const isTimedOut = raceResult === 'timeout' && s.status !== 'running';
93
+ return {
94
+ name: s.name,
95
+ port: s.port,
96
+ passed: s.status === 'running',
97
+ error: isTimedOut
98
+ ? `Verification timed out waiting for ${s.name} on port ${s.port}`
99
+ : s.status === 'crashed'
100
+ ? `Service failed to start on port ${s.port}`
101
+ : undefined,
102
+ };
103
+ }),
104
+ };
105
+
106
+ // Notify with result before teardown
107
+ onUpdate({
108
+ state: 'done',
109
+ services: finalStatus.services,
110
+ result,
111
+ });
112
+
113
+ return result;
114
+ } finally {
115
+ // Always tear down — verification is fire-and-forget
116
+ await manager.stopServices();
117
+ manager.destroy();
118
+ }
119
+ }
@@ -4,24 +4,18 @@ export interface KanbanData {
4
4
  inFlight: InFlightItem[];
5
5
  backlog: Map<string, KanbanGroup>;
6
6
  done: Map<string, KanbanGroup>;
7
+ /** O(1) item lookup by ID — built once during transform in data-bridge */
8
+ itemMap: Map<number, InFlightItem>;
9
+ /** O(1) status lookup by ID — built once during transform in data-bridge */
10
+ statusMap: Map<number, string>;
7
11
  }
8
12
 
9
- // Helper to find item by ID in the kanban data
13
+ // O(1) item lookup using the pre-built itemMap from data-bridge
10
14
  export function findItemById(data: KanbanData, id: number): { item: InFlightItem; status: string } | null {
11
- const inFlightItem = data.inFlight.find(item => item.id === id);
12
- if (inFlightItem) return { item: inFlightItem, status: 'in_progress' };
13
-
14
- for (const group of data.backlog.values()) {
15
- const backlogItem = group.items.find(item => item.id === id);
16
- if (backlogItem) return { item: backlogItem as InFlightItem, status: 'backlog' };
17
- }
18
-
19
- for (const group of data.done.values()) {
20
- const doneItem = group.items.find(item => item.id === id);
21
- if (doneItem) return { item: doneItem as InFlightItem, status: 'done' };
22
- }
23
-
24
- return null;
15
+ const item = data.itemMap.get(id);
16
+ if (!item) return null;
17
+ const status = data.statusMap.get(id) ?? item.status;
18
+ return { item, status };
25
19
  }
26
20
 
27
21
  // Extract onboarding chore items from kanban data for the OnboardingWelcome component
@@ -34,23 +28,229 @@ export function getOnboardingItems(data: KanbanData): WorkItem[] {
34
28
  return [];
35
29
  }
36
30
 
37
- // Build a status map from kanban data (item id -> status string)
31
+ // Returns the pre-built statusMap from data-bridge (O(1) no iteration needed)
38
32
  export function buildStatusMap(kanbanData: KanbanData): Map<number, string> {
39
- const map = new Map<number, string>();
40
- for (const item of kanbanData.inFlight) {
41
- map.set(item.id, item.status);
33
+ return kanbanData.statusMap;
34
+ }
35
+
36
+ // ---- Optimistic update helpers ----
37
+ // Pure functions that return new KanbanData with a mutation applied locally.
38
+ // Used by RealTimeKanbanWrapper to update the UI instantly before IPC completes.
39
+
40
+ /** Determine which column a status belongs to */
41
+ function statusColumn(status: string): 'inFlight' | 'backlog' | 'done' {
42
+ if (status === 'in_progress' || status === 'blocked') return 'inFlight';
43
+ if (status === 'done' || status === 'cancelled') return 'done';
44
+ return 'backlog';
45
+ }
46
+
47
+ /** Remove an item from grouped columns (backlog or done Maps) */
48
+ function removeFromGroups(groups: Map<string, KanbanGroup>, id: number): Map<string, KanbanGroup> {
49
+ const result = new Map<string, KanbanGroup>();
50
+ for (const [key, group] of groups) {
51
+ const filtered = group.items.filter(i => i.id !== id);
52
+ if (filtered.length > 0) {
53
+ result.set(key, { ...group, items: filtered });
54
+ }
55
+ }
56
+ return result;
57
+ }
58
+
59
+ /** Add an item to the appropriate epic group in a grouped column */
60
+ function addToGroups(groups: Map<string, KanbanGroup>, item: InFlightItem, sort: 'display_order' | 'completed_at_desc' = 'display_order'): Map<string, KanbanGroup> {
61
+ const result = new Map(groups);
62
+ const key = item.parent_id ? String(item.parent_id) : 'ungrouped';
63
+ const compareFn = sort === 'completed_at_desc'
64
+ ? (a: InFlightItem, b: InFlightItem) => (b.completed_at ?? '').localeCompare(a.completed_at ?? '')
65
+ : (a: InFlightItem, b: InFlightItem) => (a.display_order ?? a.id * 10) - (b.display_order ?? b.id * 10);
66
+ const existing = result.get(key);
67
+ if (existing) {
68
+ const items = [...existing.items, item].sort(compareFn);
69
+ result.set(key, { ...existing, items });
70
+ } else {
71
+ result.set(key, {
72
+ epicId: item.parent_id,
73
+ epicTitle: item.parent_title,
74
+ items: [item],
75
+ });
42
76
  }
43
- for (const group of kanbanData.backlog.values()) {
44
- for (const item of group.items) {
45
- map.set(item.id, item.status);
77
+ return result;
78
+ }
79
+
80
+ /** Replace an item in a flat array */
81
+ function replaceInArray(arr: InFlightItem[], id: number, updated: InFlightItem): InFlightItem[] {
82
+ return arr.map(i => i.id === id ? updated : i);
83
+ }
84
+
85
+ /** Replace an item in grouped columns */
86
+ function replaceInGroups(groups: Map<string, KanbanGroup>, id: number, updated: InFlightItem): Map<string, KanbanGroup> {
87
+ const result = new Map<string, KanbanGroup>();
88
+ for (const [key, group] of groups) {
89
+ if (group.items.some(i => i.id === id)) {
90
+ result.set(key, { ...group, items: group.items.map(i => i.id === id ? updated : i) });
91
+ } else {
92
+ result.set(key, group);
46
93
  }
47
94
  }
48
- for (const group of kanbanData.done.values()) {
49
- for (const item of group.items) {
50
- map.set(item.id, item.status);
95
+ return result;
96
+ }
97
+
98
+ /** Apply a status change optimistically. Moves item to the correct column. */
99
+ export function applyStatusChange(
100
+ data: KanbanData,
101
+ id: number,
102
+ newStatus: string,
103
+ rejection?: { reason: string },
104
+ ): KanbanData {
105
+ const existing = data.itemMap.get(id);
106
+ if (!existing) return data;
107
+
108
+ const updatedItem: InFlightItem = {
109
+ ...existing,
110
+ status: newStatus,
111
+ ...(rejection && {
112
+ rejection_reason: rejection.reason,
113
+ rejection_count: existing.rejection_count + 1,
114
+ ready_for_review: 0,
115
+ }),
116
+ // Set completed_at when moving to done/cancelled so sort-by-recent works
117
+ ...(statusColumn(newStatus) === 'done' && !existing.completed_at && {
118
+ completed_at: new Date().toISOString(),
119
+ }),
120
+ };
121
+
122
+ // Remove from all columns
123
+ let newInFlight = data.inFlight.filter(i => i.id !== id);
124
+ const newBacklog = removeFromGroups(data.backlog, id);
125
+ const newDone = removeFromGroups(data.done, id);
126
+
127
+ // Add to correct column
128
+ const col = statusColumn(newStatus);
129
+ let finalBacklog = newBacklog;
130
+ let finalDone = newDone;
131
+ if (col === 'inFlight') {
132
+ newInFlight = [...newInFlight, updatedItem].sort((a, b) => (a.display_order ?? a.id * 10) - (b.display_order ?? b.id * 10));
133
+ } else if (col === 'done') {
134
+ finalDone = addToGroups(newDone, updatedItem, 'completed_at_desc');
135
+ } else {
136
+ finalBacklog = addToGroups(newBacklog, updatedItem);
137
+ }
138
+
139
+ const newItemMap = new Map(data.itemMap);
140
+ newItemMap.set(id, updatedItem);
141
+ const newStatusMap = new Map(data.statusMap);
142
+ newStatusMap.set(id, newStatus);
143
+
144
+ return { inFlight: newInFlight, backlog: finalBacklog, done: finalDone, itemMap: newItemMap, statusMap: newStatusMap };
145
+ }
146
+
147
+ /** Apply a title change optimistically. */
148
+ export function applyTitleChange(data: KanbanData, id: number, newTitle: string): KanbanData {
149
+ const existing = data.itemMap.get(id);
150
+ if (!existing) return data;
151
+
152
+ const updatedItem: InFlightItem = { ...existing, title: newTitle };
153
+ const col = statusColumn(existing.status);
154
+
155
+ const newItemMap = new Map(data.itemMap);
156
+ newItemMap.set(id, updatedItem);
157
+
158
+ return {
159
+ inFlight: col === 'inFlight' ? replaceInArray(data.inFlight, id, updatedItem) : data.inFlight,
160
+ backlog: col === 'backlog' ? replaceInGroups(data.backlog, id, updatedItem) : data.backlog,
161
+ done: col === 'done' ? replaceInGroups(data.done, id, updatedItem) : data.done,
162
+ itemMap: newItemMap,
163
+ statusMap: data.statusMap,
164
+ };
165
+ }
166
+
167
+ /** Apply a display order change optimistically. */
168
+ export function applyOrderChange(data: KanbanData, id: number, newOrder: number): KanbanData {
169
+ const existing = data.itemMap.get(id);
170
+ if (!existing) return data;
171
+
172
+ const updatedItem: InFlightItem = { ...existing, display_order: newOrder };
173
+ const col = statusColumn(existing.status);
174
+
175
+ const newItemMap = new Map(data.itemMap);
176
+ newItemMap.set(id, updatedItem);
177
+
178
+ if (col === 'inFlight') {
179
+ return {
180
+ ...data,
181
+ inFlight: replaceInArray(data.inFlight, id, updatedItem).sort((a, b) => (a.display_order ?? a.id * 10) - (b.display_order ?? b.id * 10)),
182
+ itemMap: newItemMap,
183
+ };
184
+ }
185
+
186
+ const groups = col === 'backlog' ? data.backlog : data.done;
187
+ const newGroups = new Map<string, KanbanGroup>();
188
+ for (const [key, group] of groups) {
189
+ if (group.items.some(i => i.id === id)) {
190
+ const items = group.items.map(i => i.id === id ? updatedItem : i).sort((a, b) => (a.display_order ?? a.id * 10) - (b.display_order ?? b.id * 10));
191
+ newGroups.set(key, { ...group, items });
192
+ } else {
193
+ newGroups.set(key, group);
51
194
  }
52
195
  }
53
- return map;
196
+
197
+ return {
198
+ ...data,
199
+ backlog: col === 'backlog' ? newGroups : data.backlog,
200
+ done: col === 'done' ? newGroups : data.done,
201
+ itemMap: newItemMap,
202
+ };
203
+ }
204
+
205
+ /** Apply an epic assignment optimistically. Moves item between epic groups. */
206
+ export function applyEpicAssign(data: KanbanData, id: number, epicId: number | null): KanbanData {
207
+ const existing = data.itemMap.get(id);
208
+ if (!existing) return data;
209
+
210
+ // Look up epic title from existing groups
211
+ let epicTitle: string | null = null;
212
+ if (epicId !== null) {
213
+ const key = String(epicId);
214
+ const group = data.backlog.get(key) || data.done.get(key);
215
+ if (group) {
216
+ epicTitle = group.epicTitle;
217
+ } else {
218
+ const sibling = data.inFlight.find(i => i.parent_id === epicId);
219
+ epicTitle = sibling?.parent_title ?? null;
220
+ }
221
+ }
222
+
223
+ const updatedItem: InFlightItem = {
224
+ ...existing,
225
+ parent_id: epicId,
226
+ parent_title: epicTitle,
227
+ epic_id: epicId,
228
+ epicTitle: epicTitle,
229
+ };
230
+
231
+ const col = statusColumn(existing.status);
232
+ const newItemMap = new Map(data.itemMap);
233
+ newItemMap.set(id, updatedItem);
234
+
235
+ if (col === 'inFlight') {
236
+ return {
237
+ ...data,
238
+ inFlight: replaceInArray(data.inFlight, id, updatedItem),
239
+ itemMap: newItemMap,
240
+ };
241
+ }
242
+
243
+ // Remove from current group, add to new group
244
+ const groups = col === 'backlog' ? data.backlog : data.done;
245
+ const sortMode = col === 'done' ? 'completed_at_desc' as const : 'display_order' as const;
246
+ const newGroups = addToGroups(removeFromGroups(groups, id), updatedItem, sortMode);
247
+
248
+ return {
249
+ ...data,
250
+ backlog: col === 'backlog' ? newGroups : data.backlog,
251
+ done: col === 'done' ? newGroups : data.done,
252
+ itemMap: newItemMap,
253
+ };
54
254
  }
55
255
 
56
256
  // Build a mode map from kanban data (feature id -> mode) for detecting mode transitions