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.
- package/.github/workflows/ci.yml +26 -0
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +113 -0
- package/README.md +130 -16
- package/examples/README.md +9 -2
- package/package.json +6 -1
- package/src/cli/index.js +1413 -73
- package/src/core/ci.js +1 -1
- package/src/core/db.js +143 -21
- package/src/core/enforcement.js +122 -10
- package/src/core/ignore.js +1 -0
- package/src/core/licence.js +365 -0
- package/src/core/mcp.js +41 -2
- package/src/core/merge-gate.js +5 -3
- package/src/core/outcome.js +43 -44
- package/src/core/pipeline.js +66 -35
- package/src/core/planner.js +10 -6
- package/src/core/policy.js +1 -1
- package/src/core/queue.js +11 -2
- package/src/core/sync.js +216 -0
- package/src/mcp/server.js +18 -6
- package/tests.zip +0 -0
package/src/core/pipeline.js
CHANGED
|
@@ -1164,49 +1164,80 @@ function runPipelineIteration(
|
|
|
1164
1164
|
retryBackoffMs,
|
|
1165
1165
|
timeoutMs,
|
|
1166
1166
|
});
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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 = !
|
|
1206
|
-
?
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
|
1270
|
+
reasonCode,
|
|
1240
1271
|
worktree: assignment.worktree,
|
|
1241
1272
|
taskId: assignment.task_id,
|
|
1242
1273
|
leaseId: assignment.lease_id,
|
package/src/core/planner.js
CHANGED
|
@@ -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(
|
|
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|
|
|
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 (
|
|
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
|
}
|
package/src/core/policy.js
CHANGED
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
|
package/src/core/sync.js
ADDED
|
@@ -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
|
-
|
|
635
|
-
|
|
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:
|
|
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
|
-
|
|
690
|
-
|
|
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:
|
|
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
|