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.
Files changed (39) hide show
  1. package/.switchman/audit.key +1 -0
  2. package/.switchman/switchman.db +0 -0
  3. package/README.md +11 -0
  4. package/bin/switchman.js +3 -0
  5. package/examples/taskapi/.switchman/audit.key +1 -0
  6. package/examples/taskapi/.switchman/switchman.db +0 -0
  7. package/examples/taskapi/package-lock.json +4736 -0
  8. package/examples/worktrees/agent-rate-limiting/.cursor/mcp.json +8 -0
  9. package/examples/worktrees/agent-rate-limiting/.mcp.json +8 -0
  10. package/examples/worktrees/agent-rate-limiting/package-lock.json +4736 -0
  11. package/examples/worktrees/agent-rate-limiting/package.json +18 -0
  12. package/examples/worktrees/agent-rate-limiting/src/db.js +179 -0
  13. package/examples/worktrees/agent-rate-limiting/src/middleware/auth.js +100 -0
  14. package/examples/worktrees/agent-rate-limiting/src/middleware/validate.js +135 -0
  15. package/examples/worktrees/agent-rate-limiting/src/routes/tasks.js +67 -0
  16. package/examples/worktrees/agent-rate-limiting/src/routes/users.js +38 -0
  17. package/examples/worktrees/agent-rate-limiting/src/server.js +11 -0
  18. package/examples/worktrees/agent-tests/.cursor/mcp.json +8 -0
  19. package/examples/worktrees/agent-tests/.mcp.json +8 -0
  20. package/examples/worktrees/agent-tests/package-lock.json +4736 -0
  21. package/examples/worktrees/agent-tests/package.json +18 -0
  22. package/examples/worktrees/agent-tests/src/db.js +179 -0
  23. package/examples/worktrees/agent-tests/src/middleware/auth.js +98 -0
  24. package/examples/worktrees/agent-tests/src/middleware/validate.js +135 -0
  25. package/examples/worktrees/agent-tests/src/routes/tasks.js +67 -0
  26. package/examples/worktrees/agent-tests/src/routes/users.js +38 -0
  27. package/examples/worktrees/agent-tests/src/server.js +9 -0
  28. package/examples/worktrees/agent-validation/.cursor/mcp.json +8 -0
  29. package/examples/worktrees/agent-validation/.mcp.json +8 -0
  30. package/examples/worktrees/agent-validation/package-lock.json +4736 -0
  31. package/examples/worktrees/agent-validation/package.json +18 -0
  32. package/examples/worktrees/agent-validation/src/db.js +179 -0
  33. package/examples/worktrees/agent-validation/src/middleware/auth.js +100 -0
  34. package/examples/worktrees/agent-validation/src/middleware/validate.js +137 -0
  35. package/examples/worktrees/agent-validation/src/routes/tasks.js +69 -0
  36. package/examples/worktrees/agent-validation/src/routes/users.js +38 -0
  37. package/examples/worktrees/agent-validation/src/server.js +11 -0
  38. package/package.json +2 -2
  39. 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 = 3000;
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 ${accessToken}`,
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
- * Called after any state-changing command.
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; // Not in a team — no sync needed
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
- await fetchWithTimeout(
90
- `${SUPABASE_URL}/rest/v1/sync_state`,
91
- {
92
- method: 'POST',
93
- headers: {
94
- ...getHeaders(creds.access_token),
95
- 'Prefer': 'return=minimal',
96
- },
97
- body: JSON.stringify({
98
- team_id: teamId,
99
- user_id: creds.user_id,
100
- worktree: resolvedWorktree,
101
- event_type: eventType,
102
- payload: {
103
- ...payload,
104
- email: creds.email ?? null,
105
- synced_at: new Date().toISOString(),
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
- // Best effort never fail the local operation
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 from Supabase.
119
- * Returns an array of events or empty array on error.
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 (those with events in the last 15 minutes).
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 the configured retention window for this user.
194
- * Called occasionally to keep the table tidy.
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(Date.now() - Math.max(1, Number.parseInt(retentionDays, 10) || 7) * 24 * 60 * 60 * 1000).toISOString();
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
+ }