switchman-dev 0.1.7 → 0.1.8

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.
@@ -1164,49 +1164,80 @@ function runPipelineIteration(
1164
1164
  retryBackoffMs,
1165
1165
  timeoutMs,
1166
1166
  });
1167
- const beforeHead = getHeadRevision(assignment.worktree_path);
1168
- const result = spawnSync(command, args, {
1169
- cwd: assignment.worktree_path,
1170
- env: buildLaunchEnv(
1171
- repoRoot,
1172
- { id: assignment.task_id, title: assignment.title, task_spec: assignment.task_spec },
1173
- { id: assignment.lease_id },
1174
- { name: assignment.worktree, path: assignment.worktree_path },
1175
- ),
1176
- encoding: 'utf8',
1177
- timeout: executionPolicy.timeout_ms > 0 ? executionPolicy.timeout_ms : undefined,
1178
- });
1179
- const afterHead = getHeadRevision(assignment.worktree_path);
1180
-
1181
- const timedOut = result.error?.code === 'ETIMEDOUT';
1182
- const commandOk = !result.error && result.status === 0;
1183
- let evaluation = commandOk
1184
- ? evaluateTaskOutcome(db, repoRoot, { leaseId: assignment.lease_id })
1185
- : null;
1186
- if (commandOk && evaluation?.reason_code === 'no_changes_detected' && beforeHead && afterHead && beforeHead !== afterHead) {
1187
- evaluation = {
1188
- status: 'accepted',
1189
- reason_code: null,
1190
- changed_files: [],
1191
- claimed_files: [],
1192
- findings: ['task created a new commit with no remaining uncommitted diff'],
1193
- };
1194
- }
1195
- const ok = commandOk && evaluation?.status === 'accepted';
1167
+ let result = { status: null, stdout: '', stderr: '', error: null };
1168
+ let timedOut = false;
1169
+ let evaluation = null;
1170
+ let ok = false;
1171
+ let reasonCode = 'agent_command_failed';
1196
1172
  let retry = {
1197
1173
  retried: false,
1198
1174
  retry_attempt: getTaskRetryCount(db, assignment.task_id),
1199
1175
  retries_remaining: Math.max(0, executionPolicy.max_retries - getTaskRetryCount(db, assignment.task_id)),
1200
1176
  retry_delay_ms: 0,
1201
1177
  };
1178
+
1179
+ try {
1180
+ const beforeHead = getHeadRevision(assignment.worktree_path);
1181
+ result = spawnSync(command, args, {
1182
+ cwd: assignment.worktree_path,
1183
+ env: buildLaunchEnv(
1184
+ repoRoot,
1185
+ { id: assignment.task_id, title: assignment.title, task_spec: assignment.task_spec },
1186
+ { id: assignment.lease_id },
1187
+ { name: assignment.worktree, path: assignment.worktree_path },
1188
+ ),
1189
+ encoding: 'utf8',
1190
+ timeout: executionPolicy.timeout_ms > 0 ? executionPolicy.timeout_ms : undefined,
1191
+ });
1192
+ const afterHead = getHeadRevision(assignment.worktree_path);
1193
+
1194
+ timedOut = result.error?.code === 'ETIMEDOUT';
1195
+ const commandOk = !result.error && result.status === 0;
1196
+ evaluation = commandOk
1197
+ ? evaluateTaskOutcome(db, repoRoot, { leaseId: assignment.lease_id })
1198
+ : null;
1199
+ if (commandOk && evaluation?.reason_code === 'no_changes_detected' && beforeHead && afterHead && beforeHead !== afterHead) {
1200
+ evaluation = {
1201
+ status: 'accepted',
1202
+ reason_code: null,
1203
+ changed_files: [],
1204
+ claimed_files: [],
1205
+ findings: ['task created a new commit with no remaining uncommitted diff'],
1206
+ };
1207
+ }
1208
+ ok = commandOk && evaluation?.status === 'accepted';
1209
+ reasonCode = timedOut
1210
+ ? 'task_execution_timeout'
1211
+ : ok
1212
+ ? null
1213
+ : 'agent_command_failed';
1214
+ } catch (err) {
1215
+ result = {
1216
+ status: null,
1217
+ stdout: result.stdout || '',
1218
+ stderr: `${result.stderr || ''}${result.stderr ? '\n' : ''}${err.message}`,
1219
+ error: err,
1220
+ };
1221
+ timedOut = false;
1222
+ evaluation = {
1223
+ status: 'rejected',
1224
+ reason_code: 'pipeline_execution_internal_error',
1225
+ findings: [err.message],
1226
+ };
1227
+ ok = false;
1228
+ reasonCode = 'pipeline_execution_internal_error';
1229
+ }
1230
+
1202
1231
  if (ok) {
1203
1232
  completeLeaseTask(db, assignment.lease_id);
1204
1233
  } else {
1205
- const failureReason = !commandOk
1206
- ? (timedOut
1207
- ? `agent command timed out after ${executionPolicy.timeout_ms}ms`
1208
- : (result.error?.message || `agent command exited with status ${result.status}`))
1209
- : `${evaluation.reason_code}: ${evaluation.findings.join('; ')}`;
1234
+ const failureReason = result.error && !timedOut && result.status === null
1235
+ ? `${reasonCode}: ${result.error.message}`
1236
+ : result.error || result.status !== 0
1237
+ ? (timedOut
1238
+ ? `agent command timed out after ${executionPolicy.timeout_ms}ms`
1239
+ : (result.error?.message || `agent command exited with status ${result.status}`))
1240
+ : `${evaluation.reason_code}: ${evaluation.findings.join('; ')}`;
1210
1241
  failLeaseTask(db, assignment.lease_id, failureReason);
1211
1242
  retry = scheduleTaskRetry(db, {
1212
1243
  pipelineId,
@@ -1236,7 +1267,7 @@ function runPipelineIteration(
1236
1267
  logAuditEvent(db, {
1237
1268
  eventType: 'pipeline_task_executed',
1238
1269
  status: ok ? 'allowed' : 'denied',
1239
- reasonCode: ok ? null : (timedOut ? 'task_execution_timeout' : 'agent_command_failed'),
1270
+ reasonCode,
1240
1271
  worktree: assignment.worktree,
1241
1272
  taskId: assignment.task_id,
1242
1273
  leaseId: assignment.lease_id,
@@ -9,7 +9,7 @@ const DOMAIN_RULES = [
9
9
  { key: 'api', regex: /\b(api|endpoint|route|graphql|rest|handler)\b/i, source: ['src/api/**', 'app/api/**', 'server/api/**', 'routes/**'] },
10
10
  { key: 'schema', regex: /\b(schema|migration|database|db|sql|prisma)\b/i, source: ['db/**', 'database/**', 'migrations/**', 'prisma/**', 'schema/**', 'src/db/**'] },
11
11
  { key: 'config', regex: /\b(config|configuration|env|feature flag|settings?)\b/i, source: ['config/**', '.github/**', '.switchman/**', 'src/config/**'] },
12
- { key: 'payments', regex: /\b(payment|billing|invoice|checkout|subscription|stripe)\b/i, source: ['src/payments/**', 'app/payments/**', 'lib/payments/**', 'server/payments/**'] },
12
+ { key: 'payments', regex: /\b(payments?|billing|invoice|checkout|subscription|stripe)\b/i, source: ['src/payments/**', 'app/payments/**', 'lib/payments/**', 'server/payments/**'] },
13
13
  { key: 'ui', regex: /\b(ui|ux|frontend|component|screen|page|layout)\b/i, source: ['src/components/**', 'src/ui/**', 'app/**', 'client/**'] },
14
14
  { key: 'infra', regex: /\b(deploy|infra|infrastructure|build|pipeline|docker|kubernetes|terraform)\b/i, source: ['infra/**', '.github/**', 'docker/**', 'scripts/**'] },
15
15
  { key: 'docs', regex: /\b(docs?|readme|documentation|integration notes)\b/i, source: ['docs/**', 'README.md'] },
@@ -154,10 +154,8 @@ function deriveSubtaskTitles(title, description) {
154
154
  const text = `${title}\n${description || ''}`.toLowerCase();
155
155
  const subtasks = [];
156
156
  const domains = detectDomains(text);
157
- const highRisk = /\b(auth|payment|schema|migration|security|permission|billing)\b/.test(text);
158
-
159
- const docsOnly = /\b(docs?|readme|documentation)\b/.test(text)
160
- && !/\b(api|auth|bug|feature|fix|refactor|schema|migration|config|build|test)\b/.test(text);
157
+ const highRisk = /\b(auth|payments?|schema|migration|security|permission|billing)\b/.test(text);
158
+ const docsOnly = isDocsOnlyRequest(text);
161
159
 
162
160
  if (docsOnly) {
163
161
  return [`Update docs for: ${title}`];
@@ -180,8 +178,14 @@ function deriveSubtaskTitles(title, description) {
180
178
  return subtasks;
181
179
  }
182
180
 
181
+ function isDocsOnlyRequest(text) {
182
+ return /\b(docs?|readme|documentation)\b/.test(text)
183
+ && !/\b(auth|bug|feature|fix|refactor|schema|migration|config|build|test|implement|route|handler|endpoint|model)\b/.test(text);
184
+ }
185
+
183
186
  function inferRiskLevel(text) {
184
- if (/\b(auth|payment|schema|migration|security|permission|billing)\b/.test(text)) return 'high';
187
+ if (isDocsOnlyRequest(text)) return 'low';
188
+ if (/\b(auth|payments?|schema|migration|security|permission|billing)\b/.test(text)) return 'high';
185
189
  if (/\b(api|config|deploy|build|infra)\b/.test(text)) return 'medium';
186
190
  return 'low';
187
191
  }
@@ -4,7 +4,7 @@ import { dirname, join } from 'path';
4
4
  export const DEFAULT_LEASE_POLICY = {
5
5
  heartbeat_interval_seconds: 60,
6
6
  stale_after_minutes: 15,
7
- reap_on_status_check: false,
7
+ reap_on_status_check: true,
8
8
  requeue_task_on_reap: true,
9
9
  };
10
10
 
package/src/core/queue.js CHANGED
@@ -30,7 +30,7 @@ function computeQueueRetryBackoff(item) {
30
30
  };
31
31
  }
32
32
 
33
- function describeQueueError(err) {
33
+ export function describeQueueError(err) {
34
34
  const message = String(err?.stderr || err?.message || err || '').trim();
35
35
  if (/conflict/i.test(message)) {
36
36
  return {
@@ -50,6 +50,15 @@ function describeQueueError(err) {
50
50
  };
51
51
  }
52
52
 
53
+ if (/untracked working tree files would be overwritten by merge/i.test(message)) {
54
+ return {
55
+ code: 'untracked_worktree_files',
56
+ summary: message || 'Untracked local files would be overwritten by merge.',
57
+ nextAction: 'Remove or ignore the untracked files in the target worktree, then run `switchman queue retry <itemId>`. Project-local MCP files should be excluded via `.git/info/exclude` after `switchman setup`.',
58
+ retryable: true,
59
+ };
60
+ }
61
+
53
62
  return {
54
63
  code: 'merge_failed',
55
64
  summary: message || 'Merge queue item failed.',
@@ -87,7 +96,7 @@ function scheduleRetryOrBlock(db, item, failure) {
87
96
  };
88
97
  }
89
98
 
90
- async function evaluateQueueRepoGate(db, repoRoot) {
99
+ export async function evaluateQueueRepoGate(db, repoRoot) {
91
100
  const report = await scanAllWorktrees(db, repoRoot);
92
101
  const aiGate = await runAiMergeGate(db, repoRoot);
93
102
  const ok = report.conflicts.length === 0
@@ -0,0 +1,216 @@
1
+ /**
2
+ * switchman cloud sync module
3
+ * Syncs coordination state to Supabase for Pro team users.
4
+ *
5
+ * Only runs when:
6
+ * 1. The user has a valid Pro licence
7
+ * 2. The user is a member of a team
8
+ * 3. Network is available
9
+ *
10
+ * Never throws — all sync operations are best-effort.
11
+ * Local SQLite remains the source of truth.
12
+ */
13
+
14
+ import { readCredentials } from './licence.js';
15
+
16
+ // ─── Config ───────────────────────────────────────────────────────────────────
17
+
18
+ const SUPABASE_URL = process.env.SWITCHMAN_SUPABASE_URL
19
+ ?? 'https://afilbolhlkiingnsupgr.supabase.co';
20
+
21
+ const SUPABASE_ANON = process.env.SWITCHMAN_SUPABASE_ANON
22
+ ?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFmaWxib2xobGtpaW5nbnN1cGdyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzM1OTIzOTIsImV4cCI6MjA4OTE2ODM5Mn0.8TBfHfRB0vEyKPMWBd6i1DNwx1nS9UqprIAsJf35n88';
23
+
24
+ const SYNC_TIMEOUT_MS = 3000;
25
+
26
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
27
+
28
+ function getHeaders(accessToken) {
29
+ return {
30
+ 'Content-Type': 'application/json',
31
+ 'apikey': SUPABASE_ANON,
32
+ 'Authorization': `Bearer ${accessToken}`,
33
+ };
34
+ }
35
+
36
+ async function fetchWithTimeout(url, options, timeoutMs = SYNC_TIMEOUT_MS) {
37
+ const controller = new AbortController();
38
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
39
+ try {
40
+ const res = await fetch(url, { ...options, signal: controller.signal });
41
+ return res;
42
+ } finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+
47
+ // ─── Team resolution ──────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Get the team ID for the current user.
51
+ * Returns null if not in a team or on error.
52
+ */
53
+ async function getTeamId(accessToken, userId) {
54
+ try {
55
+ const res = await fetchWithTimeout(
56
+ `${SUPABASE_URL}/rest/v1/team_members?user_id=eq.${userId}&select=team_id&limit=1`,
57
+ { headers: getHeaders(accessToken) }
58
+ );
59
+ if (!res.ok) return null;
60
+ const rows = await res.json();
61
+ return rows?.[0]?.team_id ?? null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ // ─── Push ─────────────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Push a state change event to Supabase.
71
+ * Called after any state-changing command.
72
+ *
73
+ * eventType: 'task_added' | 'task_done' | 'task_failed' | 'lease_acquired' |
74
+ * 'claim_added' | 'claim_released' | 'status_ping'
75
+ * payload: object with relevant fields
76
+ */
77
+ export async function pushSyncEvent(eventType, payload, { worktree = null } = {}) {
78
+ try {
79
+ const creds = readCredentials();
80
+ if (!creds?.access_token || !creds?.user_id) return;
81
+
82
+ const teamId = await getTeamId(creds.access_token, creds.user_id);
83
+ if (!teamId) return; // Not in a team — no sync needed
84
+
85
+ const resolvedWorktree = worktree
86
+ ?? process.cwd().split('/').pop()
87
+ ?? 'unknown';
88
+
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
+ );
110
+ } catch {
111
+ // Best effort — never fail the local operation
112
+ }
113
+ }
114
+
115
+ // ─── Pull ─────────────────────────────────────────────────────────────────────
116
+
117
+ /**
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.
121
+ */
122
+ export async function pullTeamState() {
123
+ try {
124
+ const creds = readCredentials();
125
+ if (!creds?.access_token || !creds?.user_id) return [];
126
+
127
+ const teamId = await getTeamId(creds.access_token, creds.user_id);
128
+ if (!teamId) return [];
129
+
130
+ // Pull last 5 minutes of events from all team members
131
+ const since = new Date(Date.now() - 5 * 60 * 1000).toISOString();
132
+
133
+ const res = await fetchWithTimeout(
134
+ `${SUPABASE_URL}/rest/v1/sync_state` +
135
+ `?team_id=eq.${teamId}` +
136
+ `&created_at=gte.${since}` +
137
+ `&order=created_at.desc` +
138
+ `&limit=50`,
139
+ { headers: getHeaders(creds.access_token) }
140
+ );
141
+
142
+ if (!res.ok) return [];
143
+ return await res.json();
144
+ } catch {
145
+ return [];
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Pull active team members (those with events in the last 15 minutes).
151
+ * Returns array of { email, worktree, event_type, payload, created_at }
152
+ */
153
+ export async function pullActiveTeamMembers() {
154
+ try {
155
+ const creds = readCredentials();
156
+ if (!creds?.access_token || !creds?.user_id) return [];
157
+
158
+ const teamId = await getTeamId(creds.access_token, creds.user_id);
159
+ if (!teamId) return [];
160
+
161
+ const since = new Date(Date.now() - 15 * 60 * 1000).toISOString();
162
+
163
+ const res = await fetchWithTimeout(
164
+ `${SUPABASE_URL}/rest/v1/sync_state` +
165
+ `?team_id=eq.${teamId}` +
166
+ `&created_at=gte.${since}` +
167
+ `&order=created_at.desc` +
168
+ `&limit=100`,
169
+ { headers: getHeaders(creds.access_token) }
170
+ );
171
+
172
+ if (!res.ok) return [];
173
+ const events = await res.json();
174
+
175
+ // Deduplicate — keep most recent event per user+worktree
176
+ const seen = new Map();
177
+ for (const event of events) {
178
+ const key = `${event.user_id}:${event.worktree}`;
179
+ if (!seen.has(key)) {
180
+ seen.set(key, event);
181
+ }
182
+ }
183
+
184
+ return [...seen.values()];
185
+ } catch {
186
+ return [];
187
+ }
188
+ }
189
+
190
+ // ─── Cleanup ──────────────────────────────────────────────────────────────────
191
+
192
+ /**
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.
196
+ */
197
+ export async function cleanupOldSyncEvents({ retentionDays = 7 } = {}) {
198
+ try {
199
+ const creds = readCredentials();
200
+ if (!creds?.access_token || !creds?.user_id) return;
201
+
202
+ const cutoff = new Date(Date.now() - Math.max(1, Number.parseInt(retentionDays, 10) || 7) * 24 * 60 * 60 * 1000).toISOString();
203
+
204
+ await fetchWithTimeout(
205
+ `${SUPABASE_URL}/rest/v1/sync_state` +
206
+ `?user_id=eq.${creds.user_id}` +
207
+ `&created_at=lt.${cutoff}`,
208
+ {
209
+ method: 'DELETE',
210
+ headers: getHeaders(creds.access_token),
211
+ }
212
+ );
213
+ } catch {
214
+ // Best effort
215
+ }
216
+ }
package/src/mcp/server.js CHANGED
@@ -26,7 +26,9 @@ import {
26
26
  createTask,
27
27
  startTaskLease,
28
28
  completeTask,
29
+ completeLeaseTask,
29
30
  failTask,
31
+ failLeaseTask,
30
32
  listTasks,
31
33
  getNextPendingTask,
32
34
  listLeases,
@@ -631,11 +633,16 @@ Returns JSON:
631
633
  db.close();
632
634
  return toolError(`Task ${task_id} is active under lease ${activeLease.id}, not ${lease_id}.`);
633
635
  }
634
- completeTask(db, task_id);
635
- releaseFileClaims(db, task_id);
636
+ const effectiveLeaseId = activeLease?.id ?? lease_id ?? null;
637
+ if (effectiveLeaseId) {
638
+ completeLeaseTask(db, effectiveLeaseId);
639
+ } else {
640
+ completeTask(db, task_id);
641
+ releaseFileClaims(db, task_id);
642
+ }
636
643
  db.close();
637
644
 
638
- const result = { task_id, lease_id: activeLease?.id ?? lease_id ?? null, status: 'done', files_released: true };
645
+ const result = { task_id, lease_id: effectiveLeaseId, status: 'done', files_released: true };
639
646
  return toolOk(JSON.stringify(result, null, 2), result);
640
647
  } catch (err) {
641
648
  return toolError(err.message);
@@ -686,11 +693,16 @@ Returns JSON:
686
693
  db.close();
687
694
  return toolError(`Task ${task_id} is active under lease ${activeLease.id}, not ${lease_id}.`);
688
695
  }
689
- failTask(db, task_id, reason);
690
- releaseFileClaims(db, task_id);
696
+ const effectiveLeaseId = activeLease?.id ?? lease_id ?? null;
697
+ if (effectiveLeaseId) {
698
+ failLeaseTask(db, effectiveLeaseId, reason);
699
+ } else {
700
+ failTask(db, task_id, reason);
701
+ releaseFileClaims(db, task_id);
702
+ }
691
703
  db.close();
692
704
 
693
- const result = { task_id, lease_id: activeLease?.id ?? lease_id ?? null, status: 'failed', reason, files_released: true };
705
+ const result = { task_id, lease_id: effectiveLeaseId, status: 'failed', reason, files_released: true };
694
706
  return toolOk(JSON.stringify(result, null, 2), result);
695
707
  } catch (err) {
696
708
  return toolError(err.message);
package/tests.zip ADDED
Binary file