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.
- package/apps/dashboard/app/api/kanban/route.ts +15 -0
- package/apps/dashboard/app/page.tsx +9 -6
- package/apps/dashboard/components/KanbanBoard.tsx +4 -3
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +85 -0
- package/apps/dashboard/hooks/useWebSocket.ts +118 -0
- package/claude-hooks/global-guardrails.js +3 -2
- package/jettypod.js +13 -1
- package/lib/db-watcher.js +116 -0
- package/lib/merge-lock.js +102 -19
- package/lib/migrations/016-heartbeat-column.js +103 -0
- package/lib/work-commands/index.js +60 -1
- package/lib/ws-server.js +126 -0
- package/package.json +3 -2
- package/skills-templates/chore-mode/SKILL.md +9 -6
- package/skills-templates/speed-mode/SKILL.md +19 -13
- package/skills-templates/stable-mode/SKILL.md +17 -10
|
@@ -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 {
|
|
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
|
-
<
|
|
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
|
-
|
|
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 ||
|
|
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(
|
|
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
|
|
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:
|
|
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 =
|
|
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 ||
|
|
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(
|
|
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
|
-
* @
|
|
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
|
|
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 =
|
|
1949
|
+
const currentWork = await getCurrentWork();
|
|
1891
1950
|
if (currentWork && currentWork.worktreePath === worktreePath) {
|
|
1892
1951
|
clearCurrentWork();
|
|
1893
1952
|
}
|
package/lib/ws-server.js
ADDED
|
@@ -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.
|
|
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.
|
|
378
|
+
The merge will delete the worktree. You must be in the main repo BEFORE merging.
|
|
379
379
|
|
|
380
380
|
```bash
|
|
381
|
-
#
|
|
382
|
-
cd
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
627
|
-
cd
|
|
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
|
-
#
|
|
632
|
-
|
|
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.
|
|
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
|
-
#
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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.
|
|
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
|
-
#
|
|
586
|
-
cd
|
|
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
|
-
#
|
|
591
|
-
|
|
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
|
-
#
|
|
618
|
-
cd
|
|
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
|
-
|
|
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:**
|