jettypod 4.4.118 → 4.4.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -0,0 +1,767 @@
1
+ //! WebSocket server for live dashboard updates.
2
+ //!
3
+ //! Broadcasts `db_change` and `test_change` events when the work database
4
+ //! or test results are modified. Uses native file watching (kqueue/FSEvents
5
+ //! on macOS, inotify on Linux) for instant change detection, with mtime
6
+ //! polling as a fallback.
7
+ //!
8
+ //! ## Usage
9
+ //!
10
+ //! ```rust,no_run
11
+ //! use jettypod_core::ws::{WsServer, WsConfig};
12
+ //! use std::path::PathBuf;
13
+ //! use tokio::sync::broadcast;
14
+ //!
15
+ //! #[tokio::main]
16
+ //! async fn main() {
17
+ //! let config = WsConfig {
18
+ //! port: 47808,
19
+ //! project_root: PathBuf::from("/path/to/project"),
20
+ //! };
21
+ //! let (tx, _) = broadcast::channel::<String>(64);
22
+ //! let server = WsServer::new(config);
23
+ //! server.run(tx).await.unwrap();
24
+ //! }
25
+ //! ```
26
+
27
+ use std::path::{Path, PathBuf};
28
+ use std::sync::Arc;
29
+ use std::time::{Duration, Instant, SystemTime};
30
+
31
+ use futures_util::{SinkExt, StreamExt};
32
+ use serde::Serialize;
33
+ use tokio::net::{TcpListener, TcpStream};
34
+ use tokio::sync::{broadcast, RwLock};
35
+ use tokio_tungstenite::tungstenite::Message as WsMessage;
36
+
37
+ use notify::Watcher;
38
+
39
+ use crate::error::{CoreError, Result};
40
+
41
+ /// Default WebSocket port (matches Node.js `WS_PORT`).
42
+ pub const DEFAULT_PORT: u16 = 47808;
43
+
44
+ /// Debounce window for native file watch events (milliseconds).
45
+ const DEBOUNCE_MS: u64 = 100;
46
+
47
+ /// Polling interval for fallback mtime checks (milliseconds).
48
+ const POLL_MS: u64 = 500;
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Message types
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /// A broadcast message sent to all connected clients.
55
+ #[derive(Debug, Clone, Serialize)]
56
+ pub struct BroadcastMessage {
57
+ #[serde(rename = "type")]
58
+ pub msg_type: String,
59
+ pub timestamp: u64,
60
+ #[serde(skip_serializing_if = "Option::is_none")]
61
+ pub table: Option<String>,
62
+ #[serde(skip_serializing_if = "Option::is_none")]
63
+ pub rowid: Option<i64>,
64
+ #[serde(skip_serializing_if = "Option::is_none")]
65
+ pub action: Option<String>,
66
+ }
67
+
68
+ impl BroadcastMessage {
69
+ pub fn db_change() -> Self {
70
+ Self {
71
+ msg_type: "db_change".into(),
72
+ timestamp: now_millis(),
73
+ table: None,
74
+ rowid: None,
75
+ action: None,
76
+ }
77
+ }
78
+
79
+ pub fn db_delta(action: &str, table: &str, rowid: i64) -> Self {
80
+ Self {
81
+ msg_type: "db_delta".into(),
82
+ timestamp: now_millis(),
83
+ table: Some(table.to_string()),
84
+ rowid: Some(rowid),
85
+ action: Some(action.to_string()),
86
+ }
87
+ }
88
+
89
+ pub fn test_change() -> Self {
90
+ Self {
91
+ msg_type: "test_change".into(),
92
+ timestamp: now_millis(),
93
+ table: None,
94
+ rowid: None,
95
+ action: None,
96
+ }
97
+ }
98
+
99
+ pub fn connected() -> Self {
100
+ Self {
101
+ msg_type: "connected".into(),
102
+ timestamp: now_millis(),
103
+ table: None,
104
+ rowid: None,
105
+ action: None,
106
+ }
107
+ }
108
+
109
+ pub fn to_json(&self) -> String {
110
+ serde_json::to_string(self).unwrap_or_default()
111
+ }
112
+ }
113
+
114
+ fn now_millis() -> u64 {
115
+ SystemTime::now()
116
+ .duration_since(SystemTime::UNIX_EPOCH)
117
+ .unwrap_or_default()
118
+ .as_millis() as u64
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Native file watcher
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /// Set up native file watching for database and test result changes.
126
+ ///
127
+ /// Watches the parent directories of the target files and filters events
128
+ /// by filename. Spawns a dedicated thread for the blocking event loop.
129
+ fn setup_file_watcher(
130
+ db_path: &Path,
131
+ test_path: &Path,
132
+ tx: &broadcast::Sender<String>,
133
+ ) -> std::result::Result<(), Box<dyn std::error::Error>> {
134
+ let (notify_tx, notify_rx) = std::sync::mpsc::channel();
135
+ let mut watcher = notify::recommended_watcher(notify_tx)?;
136
+
137
+ let db_dir = db_path.parent().ok_or("db_path has no parent directory")?;
138
+ watcher.watch(db_dir, notify::RecursiveMode::NonRecursive)?;
139
+
140
+ let test_dir = test_path.parent().ok_or("test_path has no parent directory")?;
141
+ if test_dir != db_dir {
142
+ watcher.watch(test_dir, notify::RecursiveMode::NonRecursive)?;
143
+ }
144
+
145
+ let db_name = db_path
146
+ .file_name()
147
+ .ok_or("db_path has no filename")?
148
+ .to_owned();
149
+ let wal_path = db_path.with_extension("db-wal");
150
+ let wal_name = wal_path
151
+ .file_name()
152
+ .ok_or("wal path has no filename")?
153
+ .to_owned();
154
+ let test_name = test_path
155
+ .file_name()
156
+ .ok_or("test_path has no filename")?
157
+ .to_owned();
158
+ let tx = tx.clone();
159
+
160
+ std::thread::Builder::new()
161
+ .name("jettypod-fswatcher".into())
162
+ .spawn(move || {
163
+ let _watcher = watcher; // Must stay alive for watching to continue
164
+ let debounce = Duration::from_millis(DEBOUNCE_MS);
165
+ let mut last_db = Instant::now() - Duration::from_secs(60);
166
+ let mut last_test = Instant::now() - Duration::from_secs(60);
167
+
168
+ for result in notify_rx {
169
+ match result {
170
+ Ok(event) => {
171
+ let now = Instant::now();
172
+ for path in &event.paths {
173
+ let fname = match path.file_name() {
174
+ Some(f) => f,
175
+ None => continue,
176
+ };
177
+ if (fname == db_name || fname == wal_name)
178
+ && now.duration_since(last_db) > debounce
179
+ {
180
+ last_db = now;
181
+ let _ = tx.send(BroadcastMessage::db_change().to_json());
182
+ } else if fname == test_name
183
+ && now.duration_since(last_test) > debounce
184
+ {
185
+ last_test = now;
186
+ let _ = tx.send(BroadcastMessage::test_change().to_json());
187
+ }
188
+ }
189
+ }
190
+ Err(e) => log::debug!("File watch error: {e}"),
191
+ }
192
+ }
193
+ })
194
+ .map_err(|e| -> Box<dyn std::error::Error> { Box::new(e) })?;
195
+
196
+ Ok(())
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Mtime tracking (polling fallback)
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /// Tracks file modification times for change detection.
204
+ #[derive(Debug, Default)]
205
+ struct MtimeTracker {
206
+ db: Option<u64>,
207
+ wal: Option<u64>,
208
+ }
209
+
210
+ /// Get the mtime of a file in milliseconds, or `None` if it doesn't exist.
211
+ fn file_mtime_ms(path: &Path) -> Option<u64> {
212
+ std::fs::metadata(path)
213
+ .ok()
214
+ .and_then(|m| m.modified().ok())
215
+ .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
216
+ .map(|d| d.as_millis() as u64)
217
+ }
218
+
219
+ /// Check if the database files have changed since the last poll.
220
+ fn check_db_changed(db_path: &Path, last: &mut MtimeTracker) -> bool {
221
+ let current_db = file_mtime_ms(db_path);
222
+ let wal_path = db_path.with_extension("db-wal");
223
+ let current_wal = file_mtime_ms(&wal_path);
224
+
225
+ let changed = current_db != last.db || current_wal != last.wal;
226
+ last.db = current_db;
227
+ last.wal = current_wal;
228
+ changed
229
+ }
230
+
231
+ /// Check if a single file has changed since the last poll.
232
+ fn check_file_changed(path: &Path, last: &mut Option<u64>) -> bool {
233
+ let current = file_mtime_ms(path);
234
+ let changed = current != *last;
235
+ *last = current;
236
+ changed
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Server config
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /// Configuration for the WebSocket server.
244
+ #[derive(Debug, Clone)]
245
+ pub struct WsConfig {
246
+ /// Port to listen on (default: 47808).
247
+ pub port: u16,
248
+ /// Project root directory containing `.jettypod/`.
249
+ pub project_root: PathBuf,
250
+ }
251
+
252
+ impl Default for WsConfig {
253
+ fn default() -> Self {
254
+ Self {
255
+ port: DEFAULT_PORT,
256
+ project_root: PathBuf::from("."),
257
+ }
258
+ }
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Server
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /// State shared between the accept loop and watcher tasks.
266
+ struct ServerState {
267
+ /// Broadcast channel for sending messages to all clients.
268
+ broadcast_tx: broadcast::Sender<String>,
269
+ /// Count of connected clients (for stats).
270
+ client_count: RwLock<usize>,
271
+ }
272
+
273
+ /// The WebSocket server.
274
+ pub struct WsServer {
275
+ config: WsConfig,
276
+ }
277
+
278
+ impl WsServer {
279
+ pub fn new(config: WsConfig) -> Self {
280
+ Self { config }
281
+ }
282
+
283
+ /// Run the server with an externally-created broadcast sender.
284
+ ///
285
+ /// The sender is shared with callers (e.g., SQLite update_hook) so they
286
+ /// can inject messages directly without going through file watching.
287
+ ///
288
+ /// Spawns:
289
+ /// 1. TCP accept loop (handles new WS connections)
290
+ /// 2. File watcher thread (broadcasts db_change/test_change via native OS events)
291
+ /// Falls back to mtime polling if native watching is unavailable.
292
+ pub async fn run(&self, broadcast_tx: broadcast::Sender<String>) -> Result<()> {
293
+ let addr = format!("127.0.0.1:{}", self.config.port);
294
+ let listener = TcpListener::bind(&addr)
295
+ .await
296
+ .map_err(|e| CoreError::Other(format!("ws bind failed on {addr}: {e}")))?;
297
+
298
+ let state = Arc::new(ServerState {
299
+ broadcast_tx: broadcast_tx.clone(),
300
+ client_count: RwLock::new(0),
301
+ });
302
+
303
+ let db_path = self
304
+ .config
305
+ .project_root
306
+ .join(".jettypod")
307
+ .join("work.db");
308
+ let test_results_path = self.config.project_root.join("cucumber-results.json");
309
+
310
+ // Try native file watching, fall back to mtime polling if unavailable.
311
+ match setup_file_watcher(&db_path, &test_results_path, &broadcast_tx) {
312
+ Ok(()) => log::info!("Using native file watching for change detection"),
313
+ Err(e) => {
314
+ log::warn!("Native file watch unavailable ({e}), using mtime polling");
315
+ let tx_db = broadcast_tx.clone();
316
+ let db_path_clone = db_path.clone();
317
+ tokio::spawn(async move {
318
+ poll_db_changes(&db_path_clone, tx_db).await;
319
+ });
320
+ let tx_test = broadcast_tx.clone();
321
+ let test_path_clone = test_results_path.clone();
322
+ tokio::spawn(async move {
323
+ poll_test_changes(&test_path_clone, tx_test).await;
324
+ });
325
+ }
326
+ }
327
+
328
+ // Accept loop
329
+ loop {
330
+ let (stream, _addr) = listener
331
+ .accept()
332
+ .await
333
+ .map_err(|e| CoreError::Other(format!("ws accept error: {e}")))?;
334
+
335
+ let state = state.clone();
336
+ tokio::spawn(async move {
337
+ if let Err(e) = handle_connection(stream, state).await {
338
+ log::debug!("ws connection error: {e}");
339
+ }
340
+ });
341
+ }
342
+ }
343
+ }
344
+
345
+ /// Start a WS server on a background thread with its own tokio runtime.
346
+ ///
347
+ /// Returns the broadcast sender so callers (e.g., SQLite update_hook) can
348
+ /// inject messages directly into the WebSocket broadcast channel.
349
+ ///
350
+ /// This works regardless of whether a tokio runtime already exists on the
351
+ /// calling thread (CLI) or not (Tauri IPC handlers).
352
+ pub fn spawn_server(config: WsConfig) -> (std::thread::JoinHandle<()>, broadcast::Sender<String>) {
353
+ let (broadcast_tx, _) = broadcast::channel::<String>(64);
354
+ let tx_for_server = broadcast_tx.clone();
355
+
356
+ let handle = std::thread::Builder::new()
357
+ .name("jettypod-ws".into())
358
+ .spawn(move || {
359
+ let rt = tokio::runtime::Runtime::new()
360
+ .expect("Failed to create tokio runtime for WS server");
361
+ rt.block_on(async {
362
+ let server = WsServer::new(config);
363
+ if let Err(e) = server.run(tx_for_server).await {
364
+ log::error!("WebSocket server error: {e}");
365
+ }
366
+ });
367
+ })
368
+ .expect("Failed to spawn WS server thread");
369
+
370
+ (handle, broadcast_tx)
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Connection handler
375
+ // ---------------------------------------------------------------------------
376
+
377
+ async fn handle_connection(
378
+ stream: TcpStream,
379
+ state: Arc<ServerState>,
380
+ ) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
381
+ let ws_stream = tokio_tungstenite::accept_async(stream).await?;
382
+ let (mut ws_sender, mut ws_receiver) = ws_stream.split();
383
+
384
+ // Increment client count
385
+ {
386
+ let mut count = state.client_count.write().await;
387
+ *count += 1;
388
+ }
389
+
390
+ // Send connected message
391
+ let connected = BroadcastMessage::connected().to_json();
392
+ ws_sender.send(WsMessage::Text(connected.into())).await?;
393
+
394
+ // Subscribe to broadcast channel
395
+ let mut rx = state.broadcast_tx.subscribe();
396
+
397
+ // Two tasks: forward broadcasts to client, and drain incoming messages
398
+ let send_task = tokio::spawn(async move {
399
+ while let Ok(msg) = rx.recv().await {
400
+ if ws_sender.send(WsMessage::Text(msg.into())).await.is_err() {
401
+ break;
402
+ }
403
+ }
404
+ });
405
+
406
+ let recv_task = tokio::spawn(async move {
407
+ // We don't process incoming messages, just drain them
408
+ while let Some(Ok(_msg)) = ws_receiver.next().await {}
409
+ });
410
+
411
+ // Wait for either to finish (client disconnect)
412
+ tokio::select! {
413
+ _ = send_task => {},
414
+ _ = recv_task => {},
415
+ }
416
+
417
+ // Decrement client count
418
+ {
419
+ let mut count = state.client_count.write().await;
420
+ *count = count.saturating_sub(1);
421
+ }
422
+
423
+ Ok(())
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // Polling fallback
428
+ // ---------------------------------------------------------------------------
429
+
430
+ async fn poll_db_changes(db_path: &Path, tx: broadcast::Sender<String>) {
431
+ let mut tracker = MtimeTracker::default();
432
+ let interval = Duration::from_millis(POLL_MS);
433
+
434
+ // Initialize tracker with current state (skip first "change")
435
+ let _ = check_db_changed(db_path, &mut tracker);
436
+
437
+ loop {
438
+ tokio::time::sleep(interval).await;
439
+
440
+ if check_db_changed(db_path, &mut tracker) {
441
+ let msg = BroadcastMessage::db_change().to_json();
442
+ if tx.send(msg).is_err() {
443
+ log::trace!("No active WebSocket receivers for db change");
444
+ }
445
+ }
446
+ }
447
+ }
448
+
449
+ async fn poll_test_changes(test_path: &Path, tx: broadcast::Sender<String>) {
450
+ let mut last_mtime: Option<u64> = None;
451
+ let interval = Duration::from_millis(POLL_MS);
452
+
453
+ // Initialize
454
+ let _ = check_file_changed(test_path, &mut last_mtime);
455
+
456
+ loop {
457
+ tokio::time::sleep(interval).await;
458
+
459
+ if check_file_changed(test_path, &mut last_mtime) {
460
+ let msg = BroadcastMessage::test_change().to_json();
461
+ if tx.send(msg).is_err() {
462
+ log::trace!("No active WebSocket receivers for test change");
463
+ }
464
+ }
465
+ }
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Tests
470
+ // ---------------------------------------------------------------------------
471
+
472
+ #[cfg(test)]
473
+ mod tests {
474
+ use super::*;
475
+ use tempfile::TempDir;
476
+
477
+ #[test]
478
+ fn broadcast_message_serialization() {
479
+ let msg = BroadcastMessage::db_change();
480
+ let json = msg.to_json();
481
+ assert!(json.contains("\"type\":\"db_change\""));
482
+ assert!(json.contains("\"timestamp\""));
483
+ }
484
+
485
+ #[test]
486
+ fn connected_message() {
487
+ let msg = BroadcastMessage::connected();
488
+ let json = msg.to_json();
489
+ assert!(json.contains("\"type\":\"connected\""));
490
+ }
491
+
492
+ #[test]
493
+ fn test_change_message() {
494
+ let msg = BroadcastMessage::test_change();
495
+ let json = msg.to_json();
496
+ assert!(json.contains("\"type\":\"test_change\""));
497
+ }
498
+
499
+ #[test]
500
+ fn mtime_tracker_detects_changes() {
501
+ let dir = TempDir::new().unwrap();
502
+ let db_path = dir.path().join("work.db");
503
+ std::fs::write(&db_path, "initial").unwrap();
504
+
505
+ let mut tracker = MtimeTracker::default();
506
+
507
+ // First call initializes — always "changed"
508
+ assert!(check_db_changed(&db_path, &mut tracker));
509
+
510
+ // No change
511
+ assert!(!check_db_changed(&db_path, &mut tracker));
512
+
513
+ // Modify file
514
+ std::thread::sleep(Duration::from_millis(50));
515
+ std::fs::write(&db_path, "modified").unwrap();
516
+ assert!(check_db_changed(&db_path, &mut tracker));
517
+ }
518
+
519
+ #[test]
520
+ fn file_changed_tracker() {
521
+ let dir = TempDir::new().unwrap();
522
+ let path = dir.path().join("results.json");
523
+
524
+ let mut last: Option<u64> = None;
525
+
526
+ // File doesn't exist — no change from None to None
527
+ assert!(!check_file_changed(&path, &mut last));
528
+
529
+ // Create file
530
+ std::fs::write(&path, "{}").unwrap();
531
+ assert!(check_file_changed(&path, &mut last));
532
+
533
+ // No change
534
+ assert!(!check_file_changed(&path, &mut last));
535
+ }
536
+
537
+ #[test]
538
+ fn now_millis_returns_positive() {
539
+ assert!(now_millis() > 0);
540
+ }
541
+
542
+ #[test]
543
+ fn db_delta_message_serialization() {
544
+ let msg = BroadcastMessage::db_delta("update", "work_items", 42);
545
+ let json = msg.to_json();
546
+ assert!(json.contains("\"type\":\"db_delta\""));
547
+ assert!(json.contains("\"table\":\"work_items\""));
548
+ assert!(json.contains("\"rowid\":42"));
549
+ assert!(json.contains("\"action\":\"update\""));
550
+ assert!(json.contains("\"timestamp\""));
551
+ }
552
+
553
+ #[test]
554
+ fn db_delta_insert_action() {
555
+ let msg = BroadcastMessage::db_delta("insert", "work_items", 1);
556
+ let json = msg.to_json();
557
+ assert!(json.contains("\"action\":\"insert\""));
558
+ assert!(json.contains("\"rowid\":1"));
559
+ }
560
+
561
+ #[test]
562
+ fn db_delta_delete_action() {
563
+ let msg = BroadcastMessage::db_delta("delete", "work_items", 99);
564
+ let json = msg.to_json();
565
+ assert!(json.contains("\"action\":\"delete\""));
566
+ assert!(json.contains("\"rowid\":99"));
567
+ }
568
+
569
+ #[test]
570
+ fn db_change_omits_delta_fields() {
571
+ let msg = BroadcastMessage::db_change();
572
+ let json = msg.to_json();
573
+ assert!(!json.contains("\"table\""));
574
+ assert!(!json.contains("\"rowid\""));
575
+ assert!(!json.contains("\"action\""));
576
+ }
577
+
578
+ #[test]
579
+ fn connected_message_omits_delta_fields() {
580
+ let msg = BroadcastMessage::connected();
581
+ let json = msg.to_json();
582
+ assert!(!json.contains("\"table\""));
583
+ assert!(!json.contains("\"rowid\""));
584
+ assert!(!json.contains("\"action\""));
585
+ }
586
+
587
+ #[test]
588
+ fn test_change_omits_delta_fields() {
589
+ let msg = BroadcastMessage::test_change();
590
+ let json = msg.to_json();
591
+ assert!(!json.contains("\"table\""));
592
+ assert!(!json.contains("\"rowid\""));
593
+ assert!(!json.contains("\"action\""));
594
+ }
595
+
596
+ #[test]
597
+ fn db_delta_roundtrips_through_json() {
598
+ let msg = BroadcastMessage::db_delta("update", "work_items", 7);
599
+ let json = msg.to_json();
600
+ let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
601
+ assert_eq!(parsed["type"], "db_delta");
602
+ assert_eq!(parsed["table"], "work_items");
603
+ assert_eq!(parsed["rowid"], 7);
604
+ assert_eq!(parsed["action"], "update");
605
+ assert!(parsed["timestamp"].as_u64().unwrap() > 0);
606
+ }
607
+
608
+ #[tokio::test]
609
+ async fn server_binds_and_accepts() {
610
+ let addr = "127.0.0.1:0";
611
+ let listener = TcpListener::bind(addr).await.unwrap();
612
+ let local_addr = listener.local_addr().unwrap();
613
+ assert!(local_addr.port() > 0);
614
+ }
615
+
616
+ #[test]
617
+ fn file_watcher_initializes() {
618
+ let dir = TempDir::new().unwrap();
619
+ let db_path = dir.path().join("work.db");
620
+ let test_path = dir.path().join("cucumber-results.json");
621
+ std::fs::write(&db_path, "test").unwrap();
622
+
623
+ let (tx, _rx) = broadcast::channel::<String>(16);
624
+ let result = setup_file_watcher(&db_path, &test_path, &tx);
625
+ assert!(result.is_ok(), "File watcher should initialize: {result:?}");
626
+ }
627
+
628
+ #[test]
629
+ fn file_watcher_detects_db_change() {
630
+ let dir = TempDir::new().unwrap();
631
+ let db_path = dir.path().join("work.db");
632
+ let test_path = dir.path().join("cucumber-results.json");
633
+ std::fs::write(&db_path, "initial").unwrap();
634
+
635
+ let (tx, mut rx) = broadcast::channel::<String>(16);
636
+ setup_file_watcher(&db_path, &test_path, &tx).unwrap();
637
+
638
+ // Give watcher time to start
639
+ std::thread::sleep(Duration::from_millis(200));
640
+
641
+ // Modify the db file
642
+ std::fs::write(&db_path, "modified").unwrap();
643
+
644
+ // Wait for event with timeout
645
+ let deadline = Instant::now() + Duration::from_secs(2);
646
+ loop {
647
+ match rx.try_recv() {
648
+ Ok(msg) => {
649
+ let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
650
+ assert_eq!(parsed["type"], "db_change");
651
+ return;
652
+ }
653
+ Err(broadcast::error::TryRecvError::Empty) => {
654
+ if Instant::now() > deadline {
655
+ panic!("Timed out waiting for db_change event");
656
+ }
657
+ std::thread::sleep(Duration::from_millis(50));
658
+ }
659
+ Err(e) => panic!("Unexpected recv error: {e}"),
660
+ }
661
+ }
662
+ }
663
+
664
+ #[test]
665
+ fn file_watcher_detects_test_change() {
666
+ let dir = TempDir::new().unwrap();
667
+ let db_path = dir.path().join("work.db");
668
+ let test_path = dir.path().join("cucumber-results.json");
669
+ std::fs::write(&db_path, "db").unwrap();
670
+
671
+ let (tx, mut rx) = broadcast::channel::<String>(16);
672
+ setup_file_watcher(&db_path, &test_path, &tx).unwrap();
673
+
674
+ std::thread::sleep(Duration::from_millis(200));
675
+
676
+ // Create the test results file
677
+ std::fs::write(&test_path, "{\"results\":[]}").unwrap();
678
+
679
+ let deadline = Instant::now() + Duration::from_secs(2);
680
+ loop {
681
+ match rx.try_recv() {
682
+ Ok(msg) => {
683
+ let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
684
+ assert_eq!(parsed["type"], "test_change");
685
+ return;
686
+ }
687
+ Err(broadcast::error::TryRecvError::Empty) => {
688
+ if Instant::now() > deadline {
689
+ panic!("Timed out waiting for test_change event");
690
+ }
691
+ std::thread::sleep(Duration::from_millis(50));
692
+ }
693
+ Err(e) => panic!("Unexpected recv error: {e}"),
694
+ }
695
+ }
696
+ }
697
+
698
+ #[test]
699
+ fn file_watcher_debounces_rapid_changes() {
700
+ let dir = TempDir::new().unwrap();
701
+ let db_path = dir.path().join("work.db");
702
+ let test_path = dir.path().join("cucumber-results.json");
703
+ std::fs::write(&db_path, "initial").unwrap();
704
+
705
+ let (tx, mut rx) = broadcast::channel::<String>(64);
706
+ setup_file_watcher(&db_path, &test_path, &tx).unwrap();
707
+
708
+ std::thread::sleep(Duration::from_millis(200));
709
+
710
+ // Rapid-fire writes (simulating SQLite WAL checkpoint)
711
+ for i in 0..10 {
712
+ std::fs::write(&db_path, format!("write-{i}")).unwrap();
713
+ }
714
+
715
+ // Wait for events to settle
716
+ std::thread::sleep(Duration::from_millis(500));
717
+
718
+ // Count received events — should be much less than 10 due to debouncing
719
+ let mut count = 0;
720
+ while rx.try_recv().is_ok() {
721
+ count += 1;
722
+ }
723
+
724
+ assert!(
725
+ count < 10,
726
+ "Expected debouncing to reduce events to fewer than 10, got {count}"
727
+ );
728
+ assert!(count >= 1, "Expected at least 1 event, got {count}");
729
+ }
730
+
731
+ #[test]
732
+ fn file_watcher_separates_db_and_test_events() {
733
+ let dir = TempDir::new().unwrap();
734
+ let db_path = dir.path().join("work.db");
735
+ let test_path = dir.path().join("cucumber-results.json");
736
+ std::fs::write(&db_path, "db-initial").unwrap();
737
+ std::fs::write(&test_path, "test-initial").unwrap();
738
+
739
+ let (tx, mut rx) = broadcast::channel::<String>(64);
740
+ setup_file_watcher(&db_path, &test_path, &tx).unwrap();
741
+
742
+ std::thread::sleep(Duration::from_millis(200));
743
+
744
+ // Modify db file
745
+ std::fs::write(&db_path, "db-modified").unwrap();
746
+ std::thread::sleep(Duration::from_millis(200));
747
+
748
+ // Modify test file
749
+ std::fs::write(&test_path, "test-modified").unwrap();
750
+ std::thread::sleep(Duration::from_millis(200));
751
+
752
+ // Collect all events
753
+ let mut db_events = 0;
754
+ let mut test_events = 0;
755
+ while let Ok(msg) = rx.try_recv() {
756
+ let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
757
+ match parsed["type"].as_str().unwrap() {
758
+ "db_change" => db_events += 1,
759
+ "test_change" => test_events += 1,
760
+ other => panic!("Unexpected event type: {other}"),
761
+ }
762
+ }
763
+
764
+ assert!(db_events >= 1, "Expected at least 1 db_change, got {db_events}");
765
+ assert!(test_events >= 1, "Expected at least 1 test_change, got {test_events}");
766
+ }
767
+ }