switchman-dev 0.1.13 → 0.1.15
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/.switchman/audit.key +1 -0
- package/.switchman/switchman.db +0 -0
- package/README.md +11 -0
- package/bin/switchman.js +3 -0
- package/examples/taskapi/.switchman/audit.key +1 -0
- package/examples/taskapi/.switchman/switchman.db +0 -0
- package/examples/taskapi/package-lock.json +4736 -0
- package/examples/worktrees/agent-rate-limiting/.cursor/mcp.json +8 -0
- package/examples/worktrees/agent-rate-limiting/.mcp.json +8 -0
- package/examples/worktrees/agent-rate-limiting/package-lock.json +4736 -0
- package/examples/worktrees/agent-rate-limiting/package.json +18 -0
- package/examples/worktrees/agent-rate-limiting/src/db.js +179 -0
- package/examples/worktrees/agent-rate-limiting/src/middleware/auth.js +100 -0
- package/examples/worktrees/agent-rate-limiting/src/middleware/validate.js +135 -0
- package/examples/worktrees/agent-rate-limiting/src/routes/tasks.js +67 -0
- package/examples/worktrees/agent-rate-limiting/src/routes/users.js +38 -0
- package/examples/worktrees/agent-rate-limiting/src/server.js +11 -0
- package/examples/worktrees/agent-tests/.cursor/mcp.json +8 -0
- package/examples/worktrees/agent-tests/.mcp.json +8 -0
- package/examples/worktrees/agent-tests/package-lock.json +4736 -0
- package/examples/worktrees/agent-tests/package.json +18 -0
- package/examples/worktrees/agent-tests/src/db.js +179 -0
- package/examples/worktrees/agent-tests/src/middleware/auth.js +98 -0
- package/examples/worktrees/agent-tests/src/middleware/validate.js +135 -0
- package/examples/worktrees/agent-tests/src/routes/tasks.js +67 -0
- package/examples/worktrees/agent-tests/src/routes/users.js +38 -0
- package/examples/worktrees/agent-tests/src/server.js +9 -0
- package/examples/worktrees/agent-validation/.cursor/mcp.json +8 -0
- package/examples/worktrees/agent-validation/.mcp.json +8 -0
- package/examples/worktrees/agent-validation/package-lock.json +4736 -0
- package/examples/worktrees/agent-validation/package.json +18 -0
- package/examples/worktrees/agent-validation/src/db.js +179 -0
- package/examples/worktrees/agent-validation/src/middleware/auth.js +100 -0
- package/examples/worktrees/agent-validation/src/middleware/validate.js +137 -0
- package/examples/worktrees/agent-validation/src/routes/tasks.js +69 -0
- package/examples/worktrees/agent-validation/src/routes/users.js +38 -0
- package/examples/worktrees/agent-validation/src/server.js +11 -0
- package/package.json +2 -2
- package/src/core/sync.js +177 -49
package/src/core/sync.js
CHANGED
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
* 2. The user is a member of a team
|
|
8
8
|
* 3. Network is available
|
|
9
9
|
*
|
|
10
|
+
* Improvements over v1:
|
|
11
|
+
* - Offline event queue — events buffered to disk when offline, flushed on next success
|
|
12
|
+
* - Retry logic — failed pushes retried up to 3 times with exponential backoff
|
|
13
|
+
* - Push result returned — callers can optionally log or act on failures
|
|
14
|
+
*
|
|
10
15
|
* Never throws — all sync operations are best-effort.
|
|
11
16
|
* Local SQLite remains the source of truth.
|
|
12
17
|
*/
|
|
13
18
|
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
20
|
+
import { homedir } from 'os';
|
|
21
|
+
import { join } from 'path';
|
|
14
22
|
import { readCredentials } from './licence.js';
|
|
15
23
|
|
|
16
24
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
@@ -21,7 +29,52 @@ const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
|
|
|
21
29
|
const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
|
|
22
30
|
?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
|
|
23
31
|
|
|
24
|
-
const SYNC_TIMEOUT_MS
|
|
32
|
+
const SYNC_TIMEOUT_MS = 3000;
|
|
33
|
+
const MAX_RETRIES = 3;
|
|
34
|
+
const MAX_QUEUED = 50;
|
|
35
|
+
const QUEUE_FILE_NAME = 'sync-queue.json';
|
|
36
|
+
|
|
37
|
+
// ─── Offline queue ────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function getQueuePath() {
|
|
40
|
+
return join(homedir(), '.switchman', QUEUE_FILE_NAME);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ensureConfigDir() {
|
|
44
|
+
const dir = join(homedir(), '.switchman');
|
|
45
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readQueue() {
|
|
49
|
+
try {
|
|
50
|
+
const path = getQueuePath();
|
|
51
|
+
if (!existsSync(path)) return [];
|
|
52
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
53
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeQueue(events) {
|
|
60
|
+
try {
|
|
61
|
+
ensureConfigDir();
|
|
62
|
+
const trimmed = events.slice(-MAX_QUEUED);
|
|
63
|
+
writeFileSync(getQueuePath(), JSON.stringify(trimmed, null, 2), { mode: 0o600 });
|
|
64
|
+
} catch {
|
|
65
|
+
// Best effort
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function enqueueEvent(event) {
|
|
70
|
+
try {
|
|
71
|
+
const queue = readQueue();
|
|
72
|
+
queue.push({ ...event, queued_at: new Date().toISOString(), attempts: 0 });
|
|
73
|
+
writeQueue(queue);
|
|
74
|
+
} catch {
|
|
75
|
+
// Best effort
|
|
76
|
+
}
|
|
77
|
+
}
|
|
25
78
|
|
|
26
79
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
27
80
|
|
|
@@ -29,10 +82,15 @@ function getHeaders(accessToken) {
|
|
|
29
82
|
return {
|
|
30
83
|
'Content-Type': 'application/json',
|
|
31
84
|
'apikey': SUPABASE_ANON,
|
|
32
|
-
'Authorization': `Bearer ${
|
|
85
|
+
'Authorization': `Bearer ${SUPABASE_ANON}`,
|
|
86
|
+
'x-user-token': accessToken,
|
|
33
87
|
};
|
|
34
88
|
}
|
|
35
89
|
|
|
90
|
+
async function sleep(ms) {
|
|
91
|
+
return new Promise(r => setTimeout(r, ms));
|
|
92
|
+
}
|
|
93
|
+
|
|
36
94
|
async function fetchWithTimeout(url, options, timeoutMs = SYNC_TIMEOUT_MS) {
|
|
37
95
|
const controller = new AbortController();
|
|
38
96
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -46,10 +104,6 @@ async function fetchWithTimeout(url, options, timeoutMs = SYNC_TIMEOUT_MS) {
|
|
|
46
104
|
|
|
47
105
|
// ─── Team resolution ──────────────────────────────────────────────────────────
|
|
48
106
|
|
|
49
|
-
/**
|
|
50
|
-
* Get the team ID for the current user.
|
|
51
|
-
* Returns null if not in a team or on error.
|
|
52
|
-
*/
|
|
53
107
|
async function getTeamId(accessToken, userId) {
|
|
54
108
|
try {
|
|
55
109
|
const res = await fetchWithTimeout(
|
|
@@ -64,60 +118,122 @@ async function getTeamId(accessToken, userId) {
|
|
|
64
118
|
}
|
|
65
119
|
}
|
|
66
120
|
|
|
121
|
+
// ─── Core push with retry ─────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async function pushEventWithRetry(row, accessToken) {
|
|
124
|
+
let lastError = null;
|
|
125
|
+
|
|
126
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
127
|
+
try {
|
|
128
|
+
const res = await fetchWithTimeout(
|
|
129
|
+
`${SUPABASE_URL}/rest/v1/sync_state`,
|
|
130
|
+
{
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: {
|
|
133
|
+
...getHeaders(accessToken),
|
|
134
|
+
'Prefer': 'return=minimal',
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify(row),
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (res.ok) {
|
|
141
|
+
return { ok: true, attempts: attempt };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 4xx errors won't improve with retries — bail early
|
|
145
|
+
if (res.status >= 400 && res.status < 500) {
|
|
146
|
+
return { ok: false, attempts: attempt, reason: `http_${res.status}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
lastError = `http_${res.status}`;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
lastError = err?.name === 'AbortError' ? 'timeout' : 'network_error';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Exponential backoff: 200ms, 400ms, 800ms
|
|
155
|
+
if (attempt < MAX_RETRIES) {
|
|
156
|
+
await sleep(200 * Math.pow(2, attempt - 1));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { ok: false, attempts: MAX_RETRIES, reason: lastError };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Flush offline queue ──────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
async function flushQueue(accessToken) {
|
|
166
|
+
const queue = readQueue();
|
|
167
|
+
if (queue.length === 0) return;
|
|
168
|
+
|
|
169
|
+
const remaining = [];
|
|
170
|
+
|
|
171
|
+
for (const event of queue) {
|
|
172
|
+
const { queued_at, attempts, ...row } = event;
|
|
173
|
+
const result = await pushEventWithRetry(row, accessToken);
|
|
174
|
+
if (!result.ok) {
|
|
175
|
+
remaining.push({ ...event, attempts: (attempts ?? 0) + result.attempts });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
writeQueue(remaining);
|
|
180
|
+
}
|
|
181
|
+
|
|
67
182
|
// ─── Push ─────────────────────────────────────────────────────────────────────
|
|
68
183
|
|
|
69
184
|
/**
|
|
70
185
|
* Push a state change event to Supabase.
|
|
71
|
-
*
|
|
186
|
+
* Returns { ok, queued, attempts } — never throws.
|
|
72
187
|
*
|
|
73
188
|
* eventType: 'task_added' | 'task_done' | 'task_failed' | 'lease_acquired' |
|
|
74
189
|
* 'claim_added' | 'claim_released' | 'status_ping'
|
|
75
|
-
* payload: object with relevant fields
|
|
76
190
|
*/
|
|
77
191
|
export async function pushSyncEvent(eventType, payload, { worktree = null } = {}) {
|
|
78
192
|
try {
|
|
79
193
|
const creds = readCredentials();
|
|
80
|
-
if (!creds?.access_token || !creds?.user_id) return;
|
|
194
|
+
if (!creds?.access_token || !creds?.user_id) return { ok: false, reason: 'not_logged_in' };
|
|
81
195
|
|
|
82
196
|
const teamId = await getTeamId(creds.access_token, creds.user_id);
|
|
83
|
-
if (!teamId) return
|
|
197
|
+
if (!teamId) return { ok: false, reason: 'no_team' };
|
|
84
198
|
|
|
85
199
|
const resolvedWorktree = worktree
|
|
86
200
|
?? process.cwd().split('/').pop()
|
|
87
201
|
?? 'unknown';
|
|
88
202
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
203
|
+
const row = {
|
|
204
|
+
team_id: teamId,
|
|
205
|
+
user_id: creds.user_id,
|
|
206
|
+
worktree: resolvedWorktree,
|
|
207
|
+
event_type: eventType,
|
|
208
|
+
payload: {
|
|
209
|
+
...payload,
|
|
210
|
+
email: creds.email ?? null,
|
|
211
|
+
synced_at: new Date().toISOString(),
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const result = await pushEventWithRetry(row, creds.access_token);
|
|
216
|
+
|
|
217
|
+
if (result.ok) {
|
|
218
|
+
// Flush any previously queued offline events now that we're back online
|
|
219
|
+
flushQueue(creds.access_token).catch(() => {});
|
|
220
|
+
return { ok: true, queued: false, attempts: result.attempts };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Push failed after retries — save to offline queue
|
|
224
|
+
enqueueEvent(row);
|
|
225
|
+
return { ok: false, queued: true, attempts: result.attempts, reason: result.reason };
|
|
226
|
+
|
|
110
227
|
} catch {
|
|
111
|
-
|
|
228
|
+
return { ok: false, reason: 'unexpected_error' };
|
|
112
229
|
}
|
|
113
230
|
}
|
|
114
231
|
|
|
115
232
|
// ─── Pull ─────────────────────────────────────────────────────────────────────
|
|
116
233
|
|
|
117
234
|
/**
|
|
118
|
-
* Pull recent team sync events
|
|
119
|
-
* Returns
|
|
120
|
-
* Used by `switchman status` to show team-wide activity.
|
|
235
|
+
* Pull recent team sync events (last 5 minutes).
|
|
236
|
+
* Returns array of events or empty array on error.
|
|
121
237
|
*/
|
|
122
238
|
export async function pullTeamState() {
|
|
123
239
|
try {
|
|
@@ -127,7 +243,6 @@ export async function pullTeamState() {
|
|
|
127
243
|
const teamId = await getTeamId(creds.access_token, creds.user_id);
|
|
128
244
|
if (!teamId) return [];
|
|
129
245
|
|
|
130
|
-
// Pull last 5 minutes of events from all team members
|
|
131
246
|
const since = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
|
132
247
|
|
|
133
248
|
const res = await fetchWithTimeout(
|
|
@@ -147,8 +262,9 @@ export async function pullTeamState() {
|
|
|
147
262
|
}
|
|
148
263
|
|
|
149
264
|
/**
|
|
150
|
-
* Pull active team members (
|
|
265
|
+
* Pull active team members (events in the last 15 minutes).
|
|
151
266
|
* Returns array of { email, worktree, event_type, payload, created_at }
|
|
267
|
+
* Excludes the current user's own events.
|
|
152
268
|
*/
|
|
153
269
|
export async function pullActiveTeamMembers() {
|
|
154
270
|
try {
|
|
@@ -172,13 +288,12 @@ export async function pullActiveTeamMembers() {
|
|
|
172
288
|
if (!res.ok) return [];
|
|
173
289
|
const events = await res.json();
|
|
174
290
|
|
|
175
|
-
// Deduplicate — keep most recent event per user+worktree
|
|
291
|
+
// Deduplicate — keep most recent event per user+worktree, exclude self
|
|
176
292
|
const seen = new Map();
|
|
177
293
|
for (const event of events) {
|
|
294
|
+
if (event.user_id === creds.user_id) continue;
|
|
178
295
|
const key = `${event.user_id}:${event.worktree}`;
|
|
179
|
-
if (!seen.has(key))
|
|
180
|
-
seen.set(key, event);
|
|
181
|
-
}
|
|
296
|
+
if (!seen.has(key)) seen.set(key, event);
|
|
182
297
|
}
|
|
183
298
|
|
|
184
299
|
return [...seen.values()];
|
|
@@ -190,27 +305,40 @@ export async function pullActiveTeamMembers() {
|
|
|
190
305
|
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
|
191
306
|
|
|
192
307
|
/**
|
|
193
|
-
* Delete sync events older than
|
|
194
|
-
*
|
|
195
|
-
* Best effort — never fails.
|
|
308
|
+
* Delete sync events older than retentionDays for this user.
|
|
309
|
+
* Also purges stale offline queue entries older than 24 hours.
|
|
196
310
|
*/
|
|
197
311
|
export async function cleanupOldSyncEvents({ retentionDays = 7 } = {}) {
|
|
198
312
|
try {
|
|
199
313
|
const creds = readCredentials();
|
|
200
314
|
if (!creds?.access_token || !creds?.user_id) return;
|
|
201
315
|
|
|
202
|
-
const cutoff = new Date(
|
|
316
|
+
const cutoff = new Date(
|
|
317
|
+
Date.now() - Math.max(1, Number.parseInt(retentionDays, 10) || 7) * 24 * 60 * 60 * 1000
|
|
318
|
+
).toISOString();
|
|
203
319
|
|
|
204
320
|
await fetchWithTimeout(
|
|
205
321
|
`${SUPABASE_URL}/rest/v1/sync_state` +
|
|
206
322
|
`?user_id=eq.${creds.user_id}` +
|
|
207
323
|
`&created_at=lt.${cutoff}`,
|
|
208
|
-
{
|
|
209
|
-
method: 'DELETE',
|
|
210
|
-
headers: getHeaders(creds.access_token),
|
|
211
|
-
}
|
|
324
|
+
{ method: 'DELETE', headers: getHeaders(creds.access_token) }
|
|
212
325
|
);
|
|
326
|
+
|
|
327
|
+
// Purge queue entries older than 24 hours — too stale to be useful
|
|
328
|
+
const queue = readQueue();
|
|
329
|
+
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
330
|
+
const fresh = queue.filter(e => (e.queued_at ?? '') > dayAgo);
|
|
331
|
+
if (fresh.length !== queue.length) writeQueue(fresh);
|
|
332
|
+
|
|
213
333
|
} catch {
|
|
214
334
|
// Best effort
|
|
215
335
|
}
|
|
216
336
|
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Return the number of events currently queued offline.
|
|
340
|
+
* Useful for status display.
|
|
341
|
+
*/
|
|
342
|
+
export function getPendingQueueCount() {
|
|
343
|
+
return readQueue().length;
|
|
344
|
+
}
|