jettypod 4.4.64 → 4.4.66

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.
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getKanbanData } from '@/lib/db';
3
+
4
+ export const dynamic = 'force-dynamic';
5
+
6
+ export async function GET() {
7
+ const data = getKanbanData();
8
+
9
+ // Convert Maps to arrays for JSON serialization
10
+ return NextResponse.json({
11
+ inFlight: data.inFlight,
12
+ backlog: Array.from(data.backlog.entries()),
13
+ done: Array.from(data.done.entries()),
14
+ });
15
+ }
@@ -1,5 +1,5 @@
1
1
  import { getKanbanData } from '@/lib/db';
2
- import { KanbanBoard } from '@/components/KanbanBoard';
2
+ import { RealTimeKanbanWrapper } from '@/components/RealTimeKanbanWrapper';
3
3
 
4
4
  // Force dynamic rendering - database is only available at runtime
5
5
  export const dynamic = 'force-dynamic';
@@ -7,6 +7,13 @@ export const dynamic = 'force-dynamic';
7
7
  export default function Home() {
8
8
  const data = getKanbanData();
9
9
 
10
+ // Serialize Map data for client component
11
+ const serializedData = {
12
+ inFlight: data.inFlight,
13
+ backlog: Array.from(data.backlog.entries()),
14
+ done: Array.from(data.done.entries()),
15
+ };
16
+
10
17
  return (
11
18
  <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
12
19
  <header className="border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
@@ -17,11 +24,7 @@ export default function Home() {
17
24
  </div>
18
25
  </header>
19
26
  <main className="max-w-6xl mx-auto px-4 py-8">
20
- <KanbanBoard
21
- inFlight={data.inFlight}
22
- backlog={data.backlog}
23
- done={data.done}
24
- />
27
+ <RealTimeKanbanWrapper initialData={serializedData} />
25
28
  </main>
26
29
  </div>
27
30
  );
@@ -152,8 +152,9 @@ interface KanbanColumnProps {
152
152
  }
153
153
 
154
154
  function KanbanColumn({ title, children, count }: KanbanColumnProps) {
155
+ const testId = title.toLowerCase().replace(/\s+/g, '-') + '-column';
155
156
  return (
156
- <div className="flex-1 min-w-[300px] max-w-[400px]">
157
+ <div className="flex-1 min-w-[300px] max-w-[400px]" data-testid={testId}>
157
158
  <div className="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-3 h-full">
158
159
  <div className="flex items-center justify-between mb-3">
159
160
  <h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
@@ -189,12 +190,12 @@ export function KanbanBoard({ inFlight, backlog, done }: KanbanBoardProps) {
189
190
  }
190
191
 
191
192
  return (
192
- <div className="flex gap-4 overflow-x-auto pb-4">
193
+ <div className="flex gap-4 overflow-x-auto pb-4" data-testid="kanban-board">
193
194
  {/* Backlog Column */}
194
195
  <KanbanColumn title="Backlog" count={backlogCount}>
195
196
  {/* In Flight Section */}
196
197
  {inFlight.length > 0 && (
197
- <div className="mb-4">
198
+ <div className="mb-4" data-testid="in-flight-section">
198
199
  <div className="flex items-center gap-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 mb-2">
199
200
  <span>🔥</span>
200
201
  <span>In Flight</span>
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback } from 'react';
4
+ import { KanbanBoard } from './KanbanBoard';
5
+ import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
6
+ import type { InFlightItem, KanbanGroup } from '@/lib/db';
7
+
8
+ interface KanbanData {
9
+ inFlight: InFlightItem[];
10
+ backlog: Map<string, KanbanGroup>;
11
+ done: Map<string, KanbanGroup>;
12
+ }
13
+
14
+ interface RealTimeKanbanWrapperProps {
15
+ initialData: {
16
+ inFlight: InFlightItem[];
17
+ backlog: [string, KanbanGroup][];
18
+ done: [string, KanbanGroup][];
19
+ };
20
+ }
21
+
22
+ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProps) {
23
+ const [data, setData] = useState<KanbanData>(() => ({
24
+ inFlight: initialData.inFlight,
25
+ backlog: new Map(initialData.backlog),
26
+ done: new Map(initialData.done),
27
+ }));
28
+
29
+ const refreshData = useCallback(async () => {
30
+ const response = await fetch('/api/kanban');
31
+ const newData = await response.json();
32
+ setData({
33
+ inFlight: newData.inFlight,
34
+ backlog: new Map(newData.backlog),
35
+ done: new Map(newData.done),
36
+ });
37
+ }, []);
38
+
39
+ const handleMessage = useCallback((message: WebSocketMessage) => {
40
+ if (message.type === 'db_change') {
41
+ refreshData();
42
+ }
43
+ }, [refreshData]);
44
+
45
+ const wsUrl = typeof window !== 'undefined'
46
+ ? `ws://${window.location.hostname}:8080`
47
+ : 'ws://localhost:8080';
48
+
49
+ const { isConnected, isReconnecting } = useWebSocket({
50
+ url: wsUrl,
51
+ onMessage: handleMessage,
52
+ });
53
+
54
+ // Derive connection status for display
55
+ const connectionStatus = isConnected ? 'connected' : isReconnecting ? 'reconnecting' : 'disconnected';
56
+
57
+ return (
58
+ <div>
59
+ {/* Connection Status Indicator */}
60
+ <div className="mb-4 flex items-center gap-2" data-testid="connection-status">
61
+ <span
62
+ className={`w-2 h-2 rounded-full ${
63
+ connectionStatus === 'connected'
64
+ ? 'bg-green-500'
65
+ : connectionStatus === 'reconnecting'
66
+ ? 'bg-yellow-500 animate-pulse'
67
+ : 'bg-red-500'
68
+ }`}
69
+ />
70
+ <span className="text-xs text-zinc-500 dark:text-zinc-400">
71
+ {connectionStatus === 'connected'
72
+ ? 'Live updates active'
73
+ : connectionStatus === 'reconnecting'
74
+ ? 'Reconnecting...'
75
+ : 'Disconnected'}
76
+ </span>
77
+ </div>
78
+ <KanbanBoard
79
+ inFlight={data.inFlight}
80
+ backlog={data.backlog}
81
+ done={data.done}
82
+ />
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useCallback, useState } from 'react';
4
+
5
+ export interface WebSocketMessage {
6
+ type: string;
7
+ timestamp: number;
8
+ event?: string;
9
+ }
10
+
11
+ interface UseWebSocketOptions {
12
+ url: string;
13
+ onMessage?: (message: WebSocketMessage) => void;
14
+ reconnectInterval?: number;
15
+ maxReconnectAttempts?: number;
16
+ }
17
+
18
+ interface UseWebSocketReturn {
19
+ isConnected: boolean;
20
+ isReconnecting: boolean;
21
+ reconnectionFailed: boolean;
22
+ manualReconnect: () => void;
23
+ }
24
+
25
+ export function useWebSocket({
26
+ url,
27
+ onMessage,
28
+ reconnectInterval = 3000,
29
+ maxReconnectAttempts = 5,
30
+ }: UseWebSocketOptions): UseWebSocketReturn {
31
+ const [isConnected, setIsConnected] = useState(false);
32
+ const [isReconnecting, setIsReconnecting] = useState(false);
33
+ const [reconnectionFailed, setReconnectionFailed] = useState(false);
34
+ const wsRef = useRef<WebSocket | null>(null);
35
+ const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
36
+ const reconnectAttemptsRef = useRef(0);
37
+
38
+ const connect = useCallback(() => {
39
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
40
+ return;
41
+ }
42
+
43
+ const ws = new WebSocket(url);
44
+
45
+ ws.onopen = () => {
46
+ setIsConnected(true);
47
+ setIsReconnecting(false);
48
+ setReconnectionFailed(false);
49
+ reconnectAttemptsRef.current = 0;
50
+ // Expose to window for test assertions
51
+ if (typeof window !== 'undefined') {
52
+ (window as unknown as { __wsConnection: WebSocket }).__wsConnection = ws;
53
+ (window as unknown as { __wsReconnecting: boolean }).__wsReconnecting = false;
54
+ }
55
+ };
56
+
57
+ ws.onmessage = (event) => {
58
+ try {
59
+ const message = JSON.parse(event.data) as WebSocketMessage;
60
+ onMessage?.(message);
61
+ } catch (e) {
62
+ // Gracefully handle malformed messages - log but don't crash
63
+ console.warn('Received malformed WebSocket message:', e);
64
+ }
65
+ };
66
+
67
+ ws.onclose = () => {
68
+ setIsConnected(false);
69
+ wsRef.current = null;
70
+
71
+ // Check if max reconnection attempts reached
72
+ if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
73
+ setIsReconnecting(false);
74
+ setReconnectionFailed(true);
75
+ return;
76
+ }
77
+
78
+ // Initiate reconnection
79
+ reconnectAttemptsRef.current += 1;
80
+ setIsReconnecting(true);
81
+ if (typeof window !== 'undefined') {
82
+ (window as unknown as { __wsReconnecting: boolean }).__wsReconnecting = true;
83
+ }
84
+
85
+ reconnectTimeoutRef.current = setTimeout(() => {
86
+ connect();
87
+ }, reconnectInterval);
88
+ };
89
+
90
+ ws.onerror = () => {
91
+ ws.close();
92
+ };
93
+
94
+ wsRef.current = ws;
95
+ }, [url, onMessage, reconnectInterval, maxReconnectAttempts]);
96
+
97
+ const manualReconnect = useCallback(() => {
98
+ // Reset reconnection state and attempt to connect
99
+ reconnectAttemptsRef.current = 0;
100
+ setReconnectionFailed(false);
101
+ connect();
102
+ }, [connect]);
103
+
104
+ useEffect(() => {
105
+ connect();
106
+
107
+ return () => {
108
+ if (reconnectTimeoutRef.current) {
109
+ clearTimeout(reconnectTimeoutRef.current);
110
+ }
111
+ if (wsRef.current) {
112
+ wsRef.current.close();
113
+ }
114
+ };
115
+ }, [connect]);
116
+
117
+ return { isConnected, isReconnecting, reconnectionFailed, manualReconnect };
118
+ }
@@ -110,7 +110,8 @@ function evaluateRequest(hookInput) {
110
110
  */
111
111
  function stripHeredocContent(command) {
112
112
  // Match heredoc: <<'DELIM' or <<DELIM or <<"DELIM" ... DELIM
113
- return command.replace(/<<-?['"]?(\w+)['"]?[\s\S]*?\n\1\b/g, '<<HEREDOC_STRIPPED');
113
+ // Also handles $(cat <<'EOF'...) pattern used in git commits
114
+ return command.replace(/<<-?['"]?(\w+)['"]?[\s\S]*?\n\s*\1\b/g, '<<HEREDOC_STRIPPED');
114
115
  }
115
116
 
116
117
  /**
@@ -193,7 +194,7 @@ function evaluateBashCommand(command, inputRef, cwd) {
193
194
  return {
194
195
  allowed: false,
195
196
  message: 'Cannot merge from inside a worktree',
196
- hint: 'Merging deletes the worktree. Run from main repo: cd <main-repo-path> && jettypod work merge'
197
+ hint: 'Merging deletes the worktree. Run from main repo: cd <main-repo-path> && jettypod work merge\n\nNote: "cd path && jettypod work merge" won\'t work - hooks check CWD before cd runs. Run cd separately first.'
197
198
  };
198
199
  }
199
200
  }
package/jettypod.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const config = require('./lib/config');
6
+ const wsServer = require('./lib/ws-server');
6
7
  // getModeBehaviorContent removed - skills now provide all mode guidance
7
8
 
8
9
  // CRITICAL: Calculate and cache the REAL git root BEFORE any worktree operations
@@ -2125,6 +2126,16 @@ switch (command) {
2125
2126
  }
2126
2127
  }
2127
2128
 
2129
+ // Start WebSocket server for real-time updates
2130
+ const WS_PORT = 8080;
2131
+ const { getDbPath } = require('./lib/database');
2132
+ try {
2133
+ await wsServer.start(WS_PORT, { dbPath: getDbPath() });
2134
+ } catch (err) {
2135
+ // WebSocket server failed to start (port in use?) - continue without it
2136
+ console.log('⚠️ WebSocket server unavailable (real-time updates disabled)');
2137
+ }
2138
+
2128
2139
  // Start dashboard in background with project path
2129
2140
  console.log('🚀 Starting dashboard...');
2130
2141
  const dashboardProcess = spawn('npm', ['run', 'start', '--', '-p', String(availablePort)], {
@@ -2133,7 +2144,8 @@ switch (command) {
2133
2144
  stdio: 'ignore',
2134
2145
  env: {
2135
2146
  ...process.env,
2136
- JETTYPOD_PROJECT_PATH: process.cwd()
2147
+ JETTYPOD_PROJECT_PATH: process.cwd(),
2148
+ JETTYPOD_WS_PORT: String(WS_PORT)
2137
2149
  }
2138
2150
  });
2139
2151
  dashboardProcess.unref();
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Database change watcher for triggering WebSocket broadcasts.
3
+ *
4
+ * Watches the .jettypod/work.db file and its WAL file for modifications
5
+ * and calls a callback when changes are detected.
6
+ *
7
+ * Usage:
8
+ * const dbWatcher = require('./lib/db-watcher');
9
+ * dbWatcher.start((changeType) => {
10
+ * wsServer.broadcast({ type: 'db_change' });
11
+ * });
12
+ * dbWatcher.stop();
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ let lastMtimes = { db: null, wal: null };
19
+ let pollInterval = null;
20
+ let onChange = null;
21
+ let watchedPath = null;
22
+
23
+ // Polling interval in milliseconds
24
+ const POLL_MS = 50;
25
+
26
+ /**
27
+ * Start watching the database file for changes
28
+ * @param {Function} callback - Called when database changes detected
29
+ * @param {string} dbPath - Path to database file (default: .jettypod/work.db)
30
+ */
31
+ function start(callback, dbPath = null) {
32
+ if (pollInterval) {
33
+ return; // Already watching
34
+ }
35
+
36
+ onChange = callback;
37
+ watchedPath = dbPath || path.join(process.cwd(), '.jettypod', 'work.db');
38
+
39
+ // Check if file exists
40
+ if (!fs.existsSync(watchedPath)) {
41
+ return;
42
+ }
43
+
44
+ // Get initial mtimes for both db and WAL file
45
+ try {
46
+ const dbStats = fs.statSync(watchedPath);
47
+ lastMtimes.db = dbStats.mtimeMs;
48
+
49
+ // Also check WAL file (SQLite in WAL mode writes here first)
50
+ const walPath = watchedPath + '-wal';
51
+ if (fs.existsSync(walPath)) {
52
+ const walStats = fs.statSync(walPath);
53
+ lastMtimes.wal = walStats.mtimeMs;
54
+ }
55
+ } catch {
56
+ return;
57
+ }
58
+
59
+ // Use polling - most reliable for SQLite files across platforms
60
+ pollInterval = setInterval(() => {
61
+ try {
62
+ let changed = false;
63
+
64
+ // Check main db file
65
+ const dbStats = fs.statSync(watchedPath);
66
+ if (dbStats.mtimeMs !== lastMtimes.db) {
67
+ lastMtimes.db = dbStats.mtimeMs;
68
+ changed = true;
69
+ }
70
+
71
+ // Check WAL file (where SQLite writes first in WAL mode)
72
+ const walPath = watchedPath + '-wal';
73
+ if (fs.existsSync(walPath)) {
74
+ const walStats = fs.statSync(walPath);
75
+ if (walStats.mtimeMs !== lastMtimes.wal) {
76
+ lastMtimes.wal = walStats.mtimeMs;
77
+ changed = true;
78
+ }
79
+ }
80
+
81
+ if (changed && onChange) {
82
+ onChange('change');
83
+ }
84
+ } catch {
85
+ // File might be temporarily locked during writes
86
+ }
87
+ }, POLL_MS);
88
+ }
89
+
90
+ /**
91
+ * Stop watching the database file
92
+ */
93
+ function stop() {
94
+ if (pollInterval) {
95
+ clearInterval(pollInterval);
96
+ pollInterval = null;
97
+ }
98
+
99
+ onChange = null;
100
+ lastMtimes = { db: null, wal: null };
101
+ watchedPath = null;
102
+ }
103
+
104
+ /**
105
+ * Check if currently watching
106
+ * @returns {boolean}
107
+ */
108
+ function isWatching() {
109
+ return pollInterval !== null;
110
+ }
111
+
112
+ module.exports = {
113
+ start,
114
+ stop,
115
+ isWatching,
116
+ };
package/lib/merge-lock.js CHANGED
@@ -36,7 +36,8 @@ async function acquireMergeLock(db, workItemId, instanceId = null, options = {})
36
36
 
37
37
  const maxWait = options.maxWait || 60000; // 1 minute default
38
38
  const pollInterval = options.pollInterval || 1500; // 1.5 seconds
39
- const staleThreshold = options.staleThreshold || 90000; // 90 seconds - locks auto-expire
39
+ const staleThreshold = options.staleThreshold || 15000; // 15 seconds - heartbeat-based stale detection
40
+ const heartbeatInterval = options.heartbeatInterval || 5000; // 5 seconds default
40
41
  const startTime = Date.now();
41
42
 
42
43
  // Generate instance identifier
@@ -70,8 +71,8 @@ async function acquireMergeLock(db, workItemId, instanceId = null, options = {})
70
71
  try {
71
72
  const lockId = await insertLock(db, workItemId, lockedBy);
72
73
 
73
- // Return lock handle with release function
74
- return createLockHandle(db, lockId, lockedBy, workItemId);
74
+ // Return lock handle with release function and heartbeat
75
+ return createLockHandle(db, lockId, lockedBy, workItemId, { heartbeatInterval });
75
76
  } catch (err) {
76
77
  // Race condition - someone else got it first
77
78
  // Continue polling
@@ -116,16 +117,16 @@ function checkExistingLock(db) {
116
117
  }
117
118
 
118
119
  /**
119
- * Get the age of a lock in milliseconds
120
+ * Get the age of a lock based on last heartbeat in milliseconds
120
121
  *
121
122
  * @param {Object} db - SQLite database connection
122
123
  * @param {number} lockId - Lock ID
123
- * @returns {Promise<number>} Age in milliseconds
124
+ * @returns {Promise<number>} Age since last heartbeat in milliseconds
124
125
  */
125
126
  function getLockAge(db, lockId) {
126
127
  return new Promise((resolve, reject) => {
127
128
  db.get(
128
- `SELECT (julianday('now') - julianday(locked_at)) * 86400000 as age_ms
129
+ `SELECT (julianday('now') - julianday(heartbeat_at)) * 86400000 as age_ms
129
130
  FROM merge_locks WHERE id = ?`,
130
131
  [lockId],
131
132
  (err, row) => {
@@ -159,13 +160,13 @@ function insertLock(db, workItemId, lockedBy) {
159
160
  }
160
161
 
161
162
  /**
162
- * Clean up stale locks that are older than the threshold
163
+ * Clean up stale locks based on heartbeat timestamp
163
164
  *
164
165
  * @param {Object} db - SQLite database connection
165
- * @param {number|Object} staleThresholdOrOptions - Age threshold in milliseconds (default: 120000), or options object with staleThreshold
166
+ * @param {number|Object} staleThresholdOrOptions - Age threshold in milliseconds (default: 15000), or options object with staleThreshold
166
167
  * @returns {Promise<number>} Count of locks removed
167
168
  */
168
- function cleanupStaleLocks(db, staleThresholdOrOptions = 90000) {
169
+ function cleanupStaleLocks(db, staleThresholdOrOptions = 15000) {
169
170
  if (!db) {
170
171
  return Promise.reject(new Error('Database connection required'));
171
172
  }
@@ -173,7 +174,7 @@ function cleanupStaleLocks(db, staleThresholdOrOptions = 90000) {
173
174
  // Support both number (old API) and options object (new API)
174
175
  let staleThresholdMs;
175
176
  if (typeof staleThresholdOrOptions === 'object') {
176
- staleThresholdMs = staleThresholdOrOptions.staleThreshold || 90000;
177
+ staleThresholdMs = staleThresholdOrOptions.staleThreshold || 15000;
177
178
  } else {
178
179
  staleThresholdMs = staleThresholdOrOptions;
179
180
  }
@@ -181,7 +182,7 @@ function cleanupStaleLocks(db, staleThresholdOrOptions = 90000) {
181
182
  return new Promise((resolve, reject) => {
182
183
  db.run(
183
184
  `DELETE FROM merge_locks
184
- WHERE (julianday('now') - julianday(locked_at)) * 86400000 > ?`,
185
+ WHERE (julianday('now') - julianday(heartbeat_at)) * 86400000 > ?`,
185
186
  [staleThresholdMs],
186
187
  function(err) {
187
188
  if (err) return reject(err);
@@ -192,25 +193,39 @@ function cleanupStaleLocks(db, staleThresholdOrOptions = 90000) {
192
193
  }
193
194
 
194
195
  /**
195
- * Create lock handle object with release function
196
+ * Create lock handle object with release function and heartbeat
196
197
  *
197
198
  * @param {Object} db - SQLite database connection
198
199
  * @param {number} lockId - Lock ID
199
200
  * @param {string} lockedBy - Instance identifier
200
201
  * @param {number} workItemId - Work item ID
201
- * @returns {Object} Lock handle
202
+ * @param {Object} options - Optional configuration
203
+ * @param {number} options.heartbeatInterval - Heartbeat interval in ms (default: 5000)
204
+ * @returns {Object} Lock handle with release() and stopHeartbeat()
202
205
  */
203
- function createLockHandle(db, lockId, lockedBy, workItemId) {
206
+ function createLockHandle(db, lockId, lockedBy, workItemId, options = {}) {
207
+ const heartbeatInterval = options.heartbeatInterval || 5000;
208
+
209
+ // Start heartbeat to keep lock alive
210
+ const heartbeat = startHeartbeat(db, lockId, heartbeatInterval);
211
+
204
212
  return {
205
213
  id: lockId,
206
214
  locked_by: lockedBy,
207
215
  work_item_id: workItemId,
216
+ stopHeartbeat: () => {
217
+ heartbeat.stop();
218
+ },
208
219
  release: async () => {
220
+ // Always stop heartbeat first
221
+ heartbeat.stop();
222
+
209
223
  try {
210
224
  if (!db || typeof db.run !== 'function') {
211
225
  throw new Error('Database connection unavailable during release');
212
226
  }
213
227
 
228
+ // Attempt to delete the lock
214
229
  await new Promise((resolve, reject) => {
215
230
  db.run('DELETE FROM merge_locks WHERE id = ?', [lockId], (err) => {
216
231
  if (err) {
@@ -225,12 +240,32 @@ function createLockHandle(db, lockId, lockedBy, workItemId) {
225
240
  }
226
241
  });
227
242
  });
243
+
244
+ // Verify lock was actually deleted (orphan detection)
245
+ const lockStillExists = await new Promise((resolve, reject) => {
246
+ db.get('SELECT id FROM merge_locks WHERE id = ?', [lockId], (err, row) => {
247
+ if (err) {
248
+ // If we can't verify, assume success (soft failure)
249
+ console.warn(`Warning: Could not verify lock ${lockId} was released: ${err.message}`);
250
+ resolve(false);
251
+ } else {
252
+ resolve(!!row);
253
+ }
254
+ });
255
+ });
256
+
257
+ if (lockStillExists) {
258
+ // Hard failure: lock persists after DELETE (orphan detected)
259
+ throw new Error(
260
+ `Failed to release merge lock ${lockId} - orphan lock detected.\n` +
261
+ `The lock still exists in the database after attempted deletion.\n\n` +
262
+ `Manual cleanup required:\n` +
263
+ ` jettypod work merge --release-lock`
264
+ );
265
+ }
228
266
  } catch (err) {
229
267
  // Log error but don't crash - lock may have already been cleaned up
230
- // or database may be unavailable. In production, consider:
231
- // - Logging to error tracking system
232
- // - Alerting on repeated failures
233
- // - Background cleanup task to handle orphaned locks
268
+ // or database may be unavailable
234
269
  console.error(`Failed to release lock ${lockId}:`, err.message);
235
270
  throw err; // Re-throw so caller knows release failed
236
271
  }
@@ -249,7 +284,55 @@ function sleep(ms) {
249
284
  return new Promise(resolve => setTimeout(resolve, ms));
250
285
  }
251
286
 
287
+ /**
288
+ * Update heartbeat timestamp for a lock
289
+ *
290
+ * @param {Object} db - SQLite database connection
291
+ * @param {number} lockId - Lock ID to update
292
+ * @returns {Promise<void>}
293
+ */
294
+ function updateHeartbeat(db, lockId) {
295
+ return new Promise((resolve, reject) => {
296
+ db.run(
297
+ `UPDATE merge_locks SET heartbeat_at = datetime('now') WHERE id = ?`,
298
+ [lockId],
299
+ (err) => {
300
+ if (err) reject(err);
301
+ else resolve();
302
+ }
303
+ );
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Start heartbeat interval for a lock
309
+ *
310
+ * @param {Object} db - SQLite database connection
311
+ * @param {number} lockId - Lock ID to heartbeat
312
+ * @param {number} intervalMs - Heartbeat interval in milliseconds (default: 5000)
313
+ * @returns {Object} Object with stop() function to clear the interval
314
+ */
315
+ function startHeartbeat(db, lockId, intervalMs = 5000) {
316
+ const intervalId = setInterval(async () => {
317
+ try {
318
+ await updateHeartbeat(db, lockId);
319
+ } catch (err) {
320
+ // Log but don't crash - heartbeat failure is not fatal
321
+ // The lock will eventually be cleaned up by stale detection
322
+ console.warn(`Warning: Heartbeat update failed for lock ${lockId}: ${err.message}`);
323
+ }
324
+ }, intervalMs);
325
+
326
+ return {
327
+ stop: () => {
328
+ clearInterval(intervalId);
329
+ }
330
+ };
331
+ }
332
+
252
333
  module.exports = {
253
334
  acquireMergeLock,
254
- cleanupStaleLocks
335
+ cleanupStaleLocks,
336
+ startHeartbeat,
337
+ updateHeartbeat
255
338
  };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Migration: Add heartbeat_at column to merge_locks table
3
+ *
4
+ * Purpose: Enable heartbeat-based stale detection for faster orphan lock cleanup.
5
+ * Instead of relying on locked_at timestamp (90s stale threshold), we use
6
+ * heartbeat_at which is updated every 5 seconds by active lock holders.
7
+ * This allows stale detection in ~15 seconds instead of 90 seconds.
8
+ */
9
+
10
+ module.exports = {
11
+ id: '016-heartbeat-column',
12
+ description: 'Add heartbeat_at column to merge_locks for heartbeat-based stale detection',
13
+
14
+ async up(db) {
15
+ return new Promise((resolve, reject) => {
16
+ // Add heartbeat_at column with default of current timestamp
17
+ db.run(`
18
+ ALTER TABLE merge_locks
19
+ ADD COLUMN heartbeat_at TEXT NOT NULL DEFAULT (datetime('now'))
20
+ `, (err) => {
21
+ if (err) {
22
+ // Column might already exist from a partial migration
23
+ if (err.message && err.message.includes('duplicate column')) {
24
+ return resolve();
25
+ }
26
+ return reject(err);
27
+ }
28
+ resolve();
29
+ });
30
+ });
31
+ },
32
+
33
+ async down(db) {
34
+ // SQLite doesn't support DROP COLUMN directly
35
+ // We need to recreate the table without the column
36
+ return new Promise((resolve, reject) => {
37
+ db.serialize(() => {
38
+ db.run('BEGIN TRANSACTION', (err) => {
39
+ if (err) return reject(err);
40
+ });
41
+
42
+ // Create new table without heartbeat_at
43
+ db.run(`
44
+ CREATE TABLE merge_locks_new (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ locked_by TEXT NOT NULL,
47
+ locked_at TEXT NOT NULL DEFAULT (datetime('now')),
48
+ operation TEXT NOT NULL DEFAULT 'merging',
49
+ work_item_id INTEGER NOT NULL
50
+ )
51
+ `, (err) => {
52
+ if (err) {
53
+ db.run('ROLLBACK');
54
+ return reject(err);
55
+ }
56
+ });
57
+
58
+ // Copy data
59
+ db.run(`
60
+ INSERT INTO merge_locks_new (id, locked_by, locked_at, operation, work_item_id)
61
+ SELECT id, locked_by, locked_at, operation, work_item_id FROM merge_locks
62
+ `, (err) => {
63
+ if (err) {
64
+ db.run('ROLLBACK');
65
+ return reject(err);
66
+ }
67
+ });
68
+
69
+ // Drop old table
70
+ db.run('DROP TABLE merge_locks', (err) => {
71
+ if (err) {
72
+ db.run('ROLLBACK');
73
+ return reject(err);
74
+ }
75
+ });
76
+
77
+ // Rename new table
78
+ db.run('ALTER TABLE merge_locks_new RENAME TO merge_locks', (err) => {
79
+ if (err) {
80
+ db.run('ROLLBACK');
81
+ return reject(err);
82
+ }
83
+ });
84
+
85
+ // Recreate index
86
+ db.run(`
87
+ CREATE INDEX IF NOT EXISTS idx_merge_locks_locked_at
88
+ ON merge_locks(locked_at)
89
+ `, (err) => {
90
+ if (err) {
91
+ db.run('ROLLBACK');
92
+ return reject(err);
93
+ }
94
+ });
95
+
96
+ db.run('COMMIT', (err) => {
97
+ if (err) return reject(err);
98
+ resolve();
99
+ });
100
+ });
101
+ });
102
+ }
103
+ };
@@ -1592,6 +1592,11 @@ async function mergeWork(options = {}) {
1592
1592
 
1593
1593
  // Clean up worktree if it exists
1594
1594
  if (worktree && worktree.worktree_path && fs.existsSync(worktree.worktree_path)) {
1595
+ // Check if shell CWD is inside the worktree being deleted
1596
+ const shellCwd = process.cwd();
1597
+ const worktreePath = path.resolve(worktree.worktree_path);
1598
+ const cwdWillBeInvalid = shellCwd.startsWith(worktreePath);
1599
+
1595
1600
  console.log('Cleaning up worktree...');
1596
1601
  try {
1597
1602
  // Remove the git worktree
@@ -1613,6 +1618,13 @@ async function mergeWork(options = {}) {
1613
1618
  });
1614
1619
 
1615
1620
  console.log('✅ Worktree cleaned up');
1621
+
1622
+ // Warn if shell CWD was inside deleted worktree
1623
+ if (cwdWillBeInvalid) {
1624
+ console.log('');
1625
+ console.log('⚠️ Your shell was inside the deleted worktree.');
1626
+ console.log(` Run this to fix: cd ${gitRoot}`);
1627
+ }
1616
1628
  } catch (worktreeErr) {
1617
1629
  console.warn(`Warning: Failed to clean up worktree: ${worktreeErr.message}`);
1618
1630
  // Non-fatal - continue with merge success
@@ -1838,6 +1850,41 @@ async function testsMerge(featureId) {
1838
1850
  ));
1839
1851
  }
1840
1852
 
1853
+ // Find and set scenario_file on the feature
1854
+ // Look for .feature files that were added in the merged commits
1855
+ try {
1856
+ const addedFiles = execSync(`git diff --name-only --diff-filter=A HEAD~1 HEAD`, {
1857
+ cwd: gitRoot,
1858
+ encoding: 'utf8',
1859
+ stdio: 'pipe'
1860
+ }).trim();
1861
+
1862
+ const featureFiles = addedFiles
1863
+ .split('\n')
1864
+ .filter(f => f.endsWith('.feature') && f.startsWith('features/'));
1865
+
1866
+ if (featureFiles.length > 0) {
1867
+ // Use the first feature file found (typically there's only one per feature)
1868
+ const scenarioFile = featureFiles[0];
1869
+
1870
+ await new Promise((resolve, reject) => {
1871
+ db.run(
1872
+ `UPDATE work_items SET scenario_file = ? WHERE id = ?`,
1873
+ [scenarioFile, featureId],
1874
+ (err) => {
1875
+ if (err) return reject(err);
1876
+ resolve();
1877
+ }
1878
+ );
1879
+ });
1880
+
1881
+ console.log(`✅ Set scenario_file: ${scenarioFile}`);
1882
+ }
1883
+ } catch (err) {
1884
+ // Non-fatal - scenario file can be set manually
1885
+ console.log('⚠️ Could not auto-detect scenario file:', err.message);
1886
+ }
1887
+
1841
1888
  // Push to remote
1842
1889
  try {
1843
1890
  execSync('git push', {
@@ -1851,6 +1898,11 @@ async function testsMerge(featureId) {
1851
1898
  }
1852
1899
 
1853
1900
  // Clean up the worktree
1901
+ // Check if shell CWD is inside the worktree being deleted
1902
+ const shellCwd = process.cwd();
1903
+ const resolvedWorktreePath = path.resolve(worktreePath);
1904
+ const cwdWillBeInvalid = shellCwd.startsWith(resolvedWorktreePath);
1905
+
1854
1906
  try {
1855
1907
  execSync(`git worktree remove "${worktreePath}" --force`, {
1856
1908
  cwd: gitRoot,
@@ -1858,6 +1910,13 @@ async function testsMerge(featureId) {
1858
1910
  stdio: 'pipe'
1859
1911
  });
1860
1912
  console.log('✅ Removed worktree directory');
1913
+
1914
+ // Warn if shell CWD was inside deleted worktree
1915
+ if (cwdWillBeInvalid) {
1916
+ console.log('');
1917
+ console.log('⚠️ Your shell was inside the deleted worktree.');
1918
+ console.log(` Run this to fix: cd ${gitRoot}`);
1919
+ }
1861
1920
  } catch (err) {
1862
1921
  console.log('⚠️ Failed to remove worktree (non-fatal):', err.message);
1863
1922
  }
@@ -1887,7 +1946,7 @@ async function testsMerge(featureId) {
1887
1946
  });
1888
1947
 
1889
1948
  // Clear current work if it was pointing to this worktree
1890
- const currentWork = getCurrentWorkSync();
1949
+ const currentWork = await getCurrentWork();
1891
1950
  if (currentWork && currentWork.worktreePath === worktreePath) {
1892
1951
  clearCurrentWork();
1893
1952
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * WebSocket server for broadcasting database changes to connected dashboard clients.
3
+ *
4
+ * Usage:
5
+ * const wsServer = require('./lib/ws-server');
6
+ * await wsServer.start(); // Start on port 8080 and watch for db changes
7
+ * wsServer.broadcast({ type: 'db_change' }); // Manual broadcast to all clients
8
+ * await wsServer.stop(); // Graceful shutdown
9
+ */
10
+
11
+ const { WebSocketServer } = require('ws');
12
+ const dbWatcher = require('./db-watcher');
13
+
14
+ const DEFAULT_PORT = 8080;
15
+
16
+ let wss = null;
17
+ const clients = new Set();
18
+
19
+ /**
20
+ * Start the WebSocket server
21
+ * @param {number} port - Port to listen on (default: 8080)
22
+ * @param {object} options - Options { dbPath: string }
23
+ * @returns {Promise<void>}
24
+ */
25
+ async function start(port = DEFAULT_PORT, options = {}) {
26
+ if (wss) {
27
+ return; // Already running
28
+ }
29
+
30
+ return new Promise((resolve, reject) => {
31
+ wss = new WebSocketServer({ port });
32
+
33
+ wss.on('listening', () => {
34
+ // Start watching database for changes
35
+ dbWatcher.start(() => {
36
+ broadcast({ type: 'db_change', timestamp: Date.now() });
37
+ }, options.dbPath);
38
+ resolve();
39
+ });
40
+
41
+ wss.on('error', (error) => {
42
+ wss = null;
43
+ reject(error);
44
+ });
45
+
46
+ wss.on('connection', (ws) => {
47
+ clients.add(ws);
48
+
49
+ // Send connected confirmation
50
+ ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
51
+
52
+ ws.on('close', () => {
53
+ clients.delete(ws);
54
+ });
55
+
56
+ ws.on('error', () => {
57
+ clients.delete(ws);
58
+ });
59
+ });
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Stop the WebSocket server
65
+ * @returns {Promise<void>}
66
+ */
67
+ async function stop() {
68
+ // Stop database watcher
69
+ dbWatcher.stop();
70
+
71
+ if (!wss) {
72
+ return;
73
+ }
74
+
75
+ return new Promise((resolve) => {
76
+ // Close all client connections
77
+ for (const client of clients) {
78
+ client.close();
79
+ }
80
+ clients.clear();
81
+
82
+ // Close the server
83
+ wss.close(() => {
84
+ wss = null;
85
+ resolve();
86
+ });
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Broadcast a message to all connected clients
92
+ * @param {object} message - Message to broadcast (will be JSON stringified)
93
+ */
94
+ function broadcast(message) {
95
+ const data = JSON.stringify(message);
96
+ for (const client of clients) {
97
+ if (client.readyState === 1) { // WebSocket.OPEN
98
+ client.send(data);
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Get the number of connected clients
105
+ * @returns {number}
106
+ */
107
+ function getClientCount() {
108
+ return clients.size;
109
+ }
110
+
111
+ /**
112
+ * Check if the server is running
113
+ * @returns {boolean}
114
+ */
115
+ function isRunning() {
116
+ return wss !== null;
117
+ }
118
+
119
+ module.exports = {
120
+ start,
121
+ stop,
122
+ broadcast,
123
+ getClientCount,
124
+ isRunning,
125
+ DEFAULT_PORT,
126
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jettypod",
3
- "version": "4.4.64",
3
+ "version": "4.4.66",
4
4
  "description": "AI-powered development workflow manager with TDD, BDD, and automatic test generation",
5
5
  "main": "jettypod.js",
6
6
  "bin": {
@@ -43,7 +43,8 @@
43
43
  "@cucumber/cucumber": "^10.0.1",
44
44
  "@types/better-sqlite3": "^7.6.13",
45
45
  "chai": "^6.0.1",
46
- "jest": "^30.1.3"
46
+ "jest": "^30.1.3",
47
+ "ws": "^8.18.3"
47
48
  },
48
49
  "jest": {
49
50
  "testEnvironment": "node",
@@ -375,18 +375,21 @@ git push
375
375
 
376
376
  **🚨 CRITICAL: Shell CWD Corruption Prevention**
377
377
 
378
- The merge will delete the worktree. Chain commands to ensure shell is in main repo BEFORE deletion.
378
+ The merge will delete the worktree. You must be in the main repo BEFORE merging.
379
379
 
380
380
  ```bash
381
- # CRITICAL: cd to main repo AND merge in SAME command
382
- cd $(git rev-parse --show-toplevel)/.. && jettypod work merge [chore-id]
383
- ```
381
+ # First, cd to the main repo (worktrees are in .jettypod-work/ inside main repo)
382
+ cd /path/to/main/repo
384
383
 
385
- ```bash
386
- # MANDATORY: Verify shell is in main repo
384
+ # Verify you're in the main repo
387
385
  pwd && ls .jettypod
386
+
387
+ # Then merge (pass the work item ID explicitly)
388
+ jettypod work merge [chore-id]
388
389
  ```
389
390
 
391
+ **Why not `cd $(git rev-parse --show-toplevel)/..`?** Inside a worktree, `--show-toplevel` returns the worktree path, not the main repo. Going `..` from there doesn't reach the main repo.
392
+
390
393
  **Display:**
391
394
 
392
395
  ```
@@ -615,7 +615,7 @@ More speed mode chores remain. Starting next chore:
615
615
 
616
616
  **🚨 CRITICAL: Shell CWD Corruption Prevention**
617
617
 
618
- The merge will delete the worktree. Chain commands to ensure shell is in main repo BEFORE deletion.
618
+ The merge will delete the worktree. You must be in the main repo BEFORE merging.
619
619
 
620
620
  ```bash
621
621
  # Commit changes in the worktree
@@ -623,13 +623,19 @@ git add . && git commit -m "feat: [brief description of what was implemented]"
623
623
  ```
624
624
 
625
625
  ```bash
626
- # CRITICAL: cd to main repo AND merge in SAME command
627
- cd $(git rev-parse --show-toplevel)/.. && jettypod work merge [current-chore-id]
626
+ # cd to the main repo first (worktrees are in .jettypod-work/ inside main repo)
627
+ cd /path/to/main/repo
628
+
629
+ # Verify you're in the main repo
630
+ pwd && ls .jettypod
631
+
632
+ # Then merge
633
+ jettypod work merge [current-chore-id]
628
634
  ```
629
635
 
630
636
  ```bash
631
- # Verify shell is valid, then start next chore
632
- pwd && jettypod work start [next-chore-id]
637
+ # Start next chore
638
+ jettypod work start [next-chore-id]
633
639
  ```
634
640
 
635
641
  The speed-mode skill will automatically re-invoke for the next chore.
@@ -670,7 +676,7 @@ npx cucumber-js <scenario-file-path> --name "User can reach" --format progress
670
676
 
671
677
  **🚨 CRITICAL: Shell CWD Corruption Prevention**
672
678
 
673
- The merge will delete the worktree. Chain commands to ensure shell is in main repo BEFORE deletion.
679
+ The merge will delete the worktree. You must be in the main repo BEFORE merging.
674
680
 
675
681
  ```bash
676
682
  # Commit changes in the worktree
@@ -678,17 +684,17 @@ git add . && git commit -m "feat: [brief description of what was implemented]"
678
684
  ```
679
685
 
680
686
  ```bash
681
- # CRITICAL: cd to main repo AND merge in SAME command
682
- # Using $(git rev-parse --show-toplevel)/.. exits worktree to main repo
683
- cd $(git rev-parse --show-toplevel)/.. && jettypod work merge [current-chore-id] --with-transition
684
- ```
687
+ # cd to the main repo first (worktrees are in .jettypod-work/ inside main repo)
688
+ cd /path/to/main/repo
685
689
 
686
- ```bash
687
- # MANDATORY: Verify shell is in main repo (run immediately after merge)
690
+ # Verify you're in the main repo
688
691
  pwd && ls .jettypod
692
+
693
+ # Then merge with transition flag
694
+ jettypod work merge [current-chore-id] --with-transition
689
695
  ```
690
696
 
691
- **If you see "No such file or directory" errors:** Your shell CWD was corrupted. Get the main repo path from your session context and run `cd <main-repo-path>`.
697
+ **Why not `cd $(git rev-parse --show-toplevel)/..`?** Inside a worktree, `--show-toplevel` returns the worktree path, not the main repo. Going `..` from there doesn't reach the main repo.
692
698
 
693
699
  After merge, you are on main branch. Ready to generate stable mode scenarios.
694
700
 
@@ -574,7 +574,7 @@ More stable mode chores remain. Starting next chore:
574
574
 
575
575
  **🚨 CRITICAL: Shell CWD Corruption Prevention**
576
576
 
577
- The merge will delete the worktree. Chain commands to ensure shell is in main repo BEFORE deletion.
577
+ The merge will delete the worktree. You must be in the main repo BEFORE merging.
578
578
 
579
579
  ```bash
580
580
  # Commit changes in the worktree
@@ -582,13 +582,19 @@ git add . && git commit -m "feat: [brief description of error handling added]"
582
582
  ```
583
583
 
584
584
  ```bash
585
- # CRITICAL: cd to main repo AND merge in SAME command
586
- cd $(git rev-parse --show-toplevel)/.. && jettypod work merge [current-chore-id]
585
+ # cd to the main repo first (worktrees are in .jettypod-work/ inside main repo)
586
+ cd /path/to/main/repo
587
+
588
+ # Verify you're in the main repo
589
+ pwd && ls .jettypod
590
+
591
+ # Then merge
592
+ jettypod work merge [current-chore-id]
587
593
  ```
588
594
 
589
595
  ```bash
590
- # Verify shell is valid, then start next chore
591
- pwd && jettypod work start [next-chore-id]
596
+ # Start next chore
597
+ jettypod work start [next-chore-id]
592
598
  ```
593
599
 
594
600
  The stable-mode skill will automatically re-invoke for the next chore.
@@ -614,13 +620,14 @@ git add . && git commit -m "feat: [brief description of error handling added]"
614
620
  ```
615
621
 
616
622
  ```bash
617
- # CRITICAL: cd to main repo AND merge in SAME command
618
- cd $(git rev-parse --show-toplevel)/.. && jettypod work merge [current-chore-id]
619
- ```
623
+ # cd to the main repo first (worktrees are in .jettypod-work/ inside main repo)
624
+ cd /path/to/main/repo
620
625
 
621
- ```bash
622
- # MANDATORY: Verify shell is in main repo
626
+ # Verify you're in the main repo
623
627
  pwd && ls .jettypod
628
+
629
+ # Then merge
630
+ jettypod work merge [current-chore-id]
624
631
  ```
625
632
 
626
633
  **Then check project state:**