assistme 0.1.3 → 0.1.5
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/dist/{chunk-H5BZPIOY.js → chunk-QXT7DH44.js} +114 -189
- package/dist/index.js +7 -14
- package/dist/{supabase-DTKGPEER.js → supabase-QU7MFNDI.js} +3 -5
- package/package.json +1 -1
- package/src/agent/processor.test.ts +2 -3
- package/src/agent/processor.ts +1 -7
- package/src/agent/session.test.ts +11 -11
- package/src/agent/session.ts +6 -5
- package/src/db/supabase.ts +139 -320
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/db/supabase.ts
|
|
2
2
|
import { createClient } from "@supabase/supabase-js";
|
|
3
|
+
import { createHash } from "crypto";
|
|
3
4
|
|
|
4
5
|
// src/utils/config.ts
|
|
5
6
|
import Conf from "conf";
|
|
@@ -137,22 +138,6 @@ function writeAuthStore(data) {
|
|
|
137
138
|
ensureAuthDir();
|
|
138
139
|
writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 384 });
|
|
139
140
|
}
|
|
140
|
-
var fileStorage = {
|
|
141
|
-
getItem(key) {
|
|
142
|
-
const store = readAuthStore();
|
|
143
|
-
return store[key] ?? null;
|
|
144
|
-
},
|
|
145
|
-
setItem(key, value) {
|
|
146
|
-
const store = readAuthStore();
|
|
147
|
-
store[key] = value;
|
|
148
|
-
writeAuthStore(store);
|
|
149
|
-
},
|
|
150
|
-
removeItem(key) {
|
|
151
|
-
const store = readAuthStore();
|
|
152
|
-
delete store[key];
|
|
153
|
-
writeAuthStore(store);
|
|
154
|
-
}
|
|
155
|
-
};
|
|
156
141
|
var supabase = null;
|
|
157
142
|
function getSupabase() {
|
|
158
143
|
if (!supabase) {
|
|
@@ -163,230 +148,171 @@ function getSupabase() {
|
|
|
163
148
|
);
|
|
164
149
|
}
|
|
165
150
|
supabase = createClient(config2.supabaseUrl, config2.supabaseAnonKey, {
|
|
166
|
-
auth: {
|
|
167
|
-
storage: fileStorage,
|
|
168
|
-
autoRefreshToken: true,
|
|
169
|
-
persistSession: true
|
|
170
|
-
}
|
|
151
|
+
auth: { persistSession: false }
|
|
171
152
|
});
|
|
172
153
|
}
|
|
173
154
|
return supabase;
|
|
174
155
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
},
|
|
184
|
-
body: JSON.stringify({ token: cliToken })
|
|
185
|
-
});
|
|
186
|
-
if (!res.ok) {
|
|
187
|
-
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
188
|
-
throw new Error(body.error || `Token exchange failed (${res.status})`);
|
|
189
|
-
}
|
|
190
|
-
const data = await res.json();
|
|
191
|
-
if (!data.access_token || !data.refresh_token) {
|
|
192
|
-
throw new Error("Token exchange returned incomplete session");
|
|
156
|
+
function hashToken(token) {
|
|
157
|
+
return createHash("sha256").update(token).digest("hex");
|
|
158
|
+
}
|
|
159
|
+
function getTokenHash() {
|
|
160
|
+
const store = readAuthStore();
|
|
161
|
+
const token = store["mcp_token"];
|
|
162
|
+
if (!token || !token.startsWith("am_")) {
|
|
163
|
+
throw new Error("Not authenticated. Run `assistme login`.");
|
|
193
164
|
}
|
|
194
|
-
return
|
|
165
|
+
return hashToken(token);
|
|
195
166
|
}
|
|
196
|
-
async function loginWithToken(
|
|
197
|
-
if (!
|
|
167
|
+
async function loginWithToken(mcpToken) {
|
|
168
|
+
if (!mcpToken.startsWith("am_")) {
|
|
198
169
|
throw new Error(
|
|
199
170
|
"Invalid token format. Use an am_ token from the web page."
|
|
200
171
|
);
|
|
201
172
|
}
|
|
202
|
-
const
|
|
203
|
-
const store = readAuthStore();
|
|
204
|
-
store["cli_token"] = cliToken;
|
|
205
|
-
writeAuthStore(store);
|
|
173
|
+
const hash = hashToken(mcpToken);
|
|
206
174
|
const sb = getSupabase();
|
|
207
|
-
const { data, error } = await sb.
|
|
208
|
-
|
|
209
|
-
refresh_token: sessionTokens.refresh_token
|
|
175
|
+
const { data, error } = await sb.rpc("validate_mcp_token", {
|
|
176
|
+
p_token_hash: hash
|
|
210
177
|
});
|
|
211
|
-
if (error) throw new Error(`
|
|
212
|
-
if (!data.
|
|
213
|
-
return data.user.id;
|
|
214
|
-
}
|
|
215
|
-
async function refreshWithCliToken() {
|
|
178
|
+
if (error) throw new Error(`Token validation failed: ${error.message}`);
|
|
179
|
+
if (!data || data.length === 0) throw new Error("Invalid or expired token");
|
|
216
180
|
const store = readAuthStore();
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const sessionTokens = await exchangeCliToken(cliToken);
|
|
221
|
-
const sb = getSupabase();
|
|
222
|
-
const { data, error } = await sb.auth.setSession({
|
|
223
|
-
access_token: sessionTokens.access_token,
|
|
224
|
-
refresh_token: sessionTokens.refresh_token
|
|
225
|
-
});
|
|
226
|
-
if (error || !data.user) return null;
|
|
227
|
-
return data.user.id;
|
|
228
|
-
} catch {
|
|
229
|
-
return null;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
async function getSession() {
|
|
233
|
-
const sb = getSupabase();
|
|
234
|
-
const { data } = await sb.auth.getSession();
|
|
235
|
-
return data.session;
|
|
181
|
+
store["mcp_token"] = mcpToken;
|
|
182
|
+
writeAuthStore(store);
|
|
183
|
+
return data[0].out_user_id;
|
|
236
184
|
}
|
|
237
185
|
async function getCurrentUserId() {
|
|
186
|
+
const hash = getTokenHash();
|
|
238
187
|
const sb = getSupabase();
|
|
239
|
-
const { data, error } = await sb.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
throw new Error("
|
|
188
|
+
const { data, error } = await sb.rpc("validate_mcp_token", {
|
|
189
|
+
p_token_hash: hash
|
|
190
|
+
});
|
|
191
|
+
if (error || !data || data.length === 0) {
|
|
192
|
+
throw new Error("Token expired or revoked. Run `assistme login`.");
|
|
244
193
|
}
|
|
245
|
-
return data.
|
|
194
|
+
return data[0].out_user_id;
|
|
246
195
|
}
|
|
247
196
|
async function logout() {
|
|
248
|
-
const sb = getSupabase();
|
|
249
|
-
await sb.auth.signOut();
|
|
250
197
|
try {
|
|
251
198
|
writeAuthStore({});
|
|
252
199
|
} catch {
|
|
253
200
|
}
|
|
254
201
|
}
|
|
255
|
-
async function createSession(
|
|
202
|
+
async function createSession(_userId, sessionName, workspacePath, version) {
|
|
256
203
|
const sb = getSupabase();
|
|
257
|
-
const { data, error } = await sb.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}).select().single();
|
|
204
|
+
const { data, error } = await sb.rpc("mcp_create_session", {
|
|
205
|
+
p_token_hash: getTokenHash(),
|
|
206
|
+
p_session_name: sessionName,
|
|
207
|
+
p_workspace_path: workspacePath,
|
|
208
|
+
p_version: version,
|
|
209
|
+
p_model: getConfig().model || null
|
|
210
|
+
});
|
|
265
211
|
if (error) throw new Error(`Failed to create session: ${error.message}`);
|
|
266
212
|
return data;
|
|
267
213
|
}
|
|
268
214
|
async function updateHeartbeat(sessionId) {
|
|
269
215
|
const sb = getSupabase();
|
|
270
|
-
const { error } = await sb.
|
|
216
|
+
const { error } = await sb.rpc("mcp_heartbeat", {
|
|
217
|
+
p_token_hash: getTokenHash(),
|
|
218
|
+
p_session_id: sessionId
|
|
219
|
+
});
|
|
271
220
|
if (error) log.warn(`Heartbeat update failed: ${error.message}`);
|
|
272
221
|
}
|
|
273
222
|
async function endSession(sessionId) {
|
|
274
223
|
const sb = getSupabase();
|
|
275
|
-
const { error } = await sb.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
})
|
|
224
|
+
const { error } = await sb.rpc("mcp_end_session", {
|
|
225
|
+
p_token_hash: getTokenHash(),
|
|
226
|
+
p_session_id: sessionId
|
|
227
|
+
});
|
|
279
228
|
if (error) log.error(`Failed to end session: ${error.message}`);
|
|
280
229
|
}
|
|
281
230
|
async function setSessionBusy(sessionId, busy) {
|
|
282
231
|
const sb = getSupabase();
|
|
283
|
-
await sb.
|
|
232
|
+
await sb.rpc("mcp_set_session_busy", {
|
|
233
|
+
p_token_hash: getTokenHash(),
|
|
234
|
+
p_session_id: sessionId,
|
|
235
|
+
p_busy: busy
|
|
236
|
+
});
|
|
284
237
|
}
|
|
285
|
-
async function getOrCreateCliConversation(
|
|
238
|
+
async function getOrCreateCliConversation(_userId, _sessionId) {
|
|
286
239
|
const sb = getSupabase();
|
|
287
|
-
const { data
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
conversation_type: "direct",
|
|
293
|
-
agent: "cli",
|
|
294
|
-
created_by: userId
|
|
295
|
-
}).select("id").single();
|
|
296
|
-
if (convErr || !conv) {
|
|
297
|
-
throw new Error(`Failed to create conversation: ${convErr?.message}`);
|
|
298
|
-
}
|
|
299
|
-
await sb.from("conversation_participants").insert([
|
|
300
|
-
{ conversation_id: conv.id, user_id: userId, role: "member" },
|
|
301
|
-
{ conversation_id: conv.id, user_id: CLI_AGENT_ID, role: "member" }
|
|
302
|
-
]);
|
|
303
|
-
return conv.id;
|
|
240
|
+
const { data, error } = await sb.rpc("mcp_get_or_create_conversation", {
|
|
241
|
+
p_token_hash: getTokenHash()
|
|
242
|
+
});
|
|
243
|
+
if (error) throw new Error(`Failed to get conversation: ${error.message}`);
|
|
244
|
+
return data;
|
|
304
245
|
}
|
|
305
|
-
async function createTask(conversationId,
|
|
246
|
+
async function createTask(conversationId, _userId, sessionId, prompt) {
|
|
306
247
|
const sb = getSupabase();
|
|
307
|
-
const { data
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
p_agent_id: CLI_AGENT_ID,
|
|
313
|
-
p_session_id: sessionId,
|
|
314
|
-
p_prompt: prompt
|
|
315
|
-
}
|
|
316
|
-
);
|
|
317
|
-
if (!rpcError && rpcResult) {
|
|
318
|
-
return { ...rpcResult, prompt };
|
|
319
|
-
}
|
|
320
|
-
if (rpcError) {
|
|
321
|
-
log.debug(`create_task_pair RPC unavailable, using fallback: ${rpcError.message}`);
|
|
322
|
-
}
|
|
323
|
-
const { error: userErr } = await sb.from("conversation_messages").insert({
|
|
324
|
-
conversation_id: conversationId,
|
|
325
|
-
sender_id: userId,
|
|
326
|
-
role: "user",
|
|
327
|
-
content: prompt
|
|
248
|
+
const { data, error } = await sb.rpc("mcp_create_task", {
|
|
249
|
+
p_token_hash: getTokenHash(),
|
|
250
|
+
p_conversation_id: conversationId,
|
|
251
|
+
p_session_id: sessionId,
|
|
252
|
+
p_prompt: prompt
|
|
328
253
|
});
|
|
329
|
-
if (userErr) throw new Error(`Failed to create user message: ${userErr.message}`);
|
|
330
|
-
const { data, error } = await sb.from("conversation_messages").insert({
|
|
331
|
-
conversation_id: conversationId,
|
|
332
|
-
sender_id: CLI_AGENT_ID,
|
|
333
|
-
role: "assistant",
|
|
334
|
-
content: "",
|
|
335
|
-
status: "pending",
|
|
336
|
-
session_id: sessionId,
|
|
337
|
-
metadata: { prompt }
|
|
338
|
-
}).select().single();
|
|
339
254
|
if (error) throw new Error(`Failed to create task: ${error.message}`);
|
|
340
255
|
return { ...data, prompt };
|
|
341
256
|
}
|
|
342
257
|
async function pollPendingTasks(sessionId) {
|
|
343
258
|
const sb = getSupabase();
|
|
344
|
-
const { data, error } = await sb.
|
|
259
|
+
const { data, error } = await sb.rpc("mcp_poll_tasks", {
|
|
260
|
+
p_token_hash: getTokenHash(),
|
|
261
|
+
p_session_id: sessionId
|
|
262
|
+
});
|
|
345
263
|
if (error) {
|
|
346
264
|
log.warn(`Task poll failed: ${error.message}`);
|
|
347
265
|
return [];
|
|
348
266
|
}
|
|
349
|
-
|
|
267
|
+
const rows = data || [];
|
|
268
|
+
return rows.map((row) => ({
|
|
350
269
|
...row,
|
|
351
270
|
prompt: row.metadata?.prompt || row.content || ""
|
|
352
271
|
}));
|
|
353
272
|
}
|
|
273
|
+
async function pollAndClaimTask(sessionId) {
|
|
274
|
+
const sb = getSupabase();
|
|
275
|
+
const { data, error } = await sb.rpc("mcp_poll_and_claim_task", {
|
|
276
|
+
p_token_hash: getTokenHash(),
|
|
277
|
+
p_session_id: sessionId
|
|
278
|
+
});
|
|
279
|
+
if (error) {
|
|
280
|
+
log.warn(`Poll-and-claim failed: ${error.message}`);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
if (!data) return null;
|
|
284
|
+
const row = data;
|
|
285
|
+
return {
|
|
286
|
+
...row,
|
|
287
|
+
prompt: row.metadata?.prompt || row.content || ""
|
|
288
|
+
};
|
|
289
|
+
}
|
|
354
290
|
async function claimTask(messageId) {
|
|
355
291
|
const sb = getSupabase();
|
|
356
|
-
const { error } = await sb.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
})
|
|
292
|
+
const { data, error } = await sb.rpc("mcp_claim_task", {
|
|
293
|
+
p_token_hash: getTokenHash(),
|
|
294
|
+
p_message_id: messageId
|
|
295
|
+
});
|
|
360
296
|
if (error) throw new Error(`Failed to claim task: ${error.message}`);
|
|
297
|
+
return data;
|
|
361
298
|
}
|
|
362
299
|
async function completeTask(messageId, resultSummary, tokenUsage) {
|
|
363
300
|
const sb = getSupabase();
|
|
364
|
-
const {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
...existingMeta,
|
|
371
|
-
completed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
372
|
-
token_usage: tokenUsage || null
|
|
373
|
-
}
|
|
374
|
-
}).eq("id", messageId);
|
|
301
|
+
const { error } = await sb.rpc("mcp_complete_task", {
|
|
302
|
+
p_token_hash: getTokenHash(),
|
|
303
|
+
p_message_id: messageId,
|
|
304
|
+
p_result: resultSummary,
|
|
305
|
+
p_token_usage: tokenUsage || null
|
|
306
|
+
});
|
|
375
307
|
if (error) throw new Error(`Failed to complete task: ${error.message}`);
|
|
376
308
|
}
|
|
377
309
|
async function failTask(messageId, errorMessage) {
|
|
378
310
|
const sb = getSupabase();
|
|
379
|
-
const {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
metadata: {
|
|
385
|
-
...existingMeta,
|
|
386
|
-
completed_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
387
|
-
error_message: errorMessage
|
|
388
|
-
}
|
|
389
|
-
}).eq("id", messageId);
|
|
311
|
+
const { error } = await sb.rpc("mcp_fail_task", {
|
|
312
|
+
p_token_hash: getTokenHash(),
|
|
313
|
+
p_message_id: messageId,
|
|
314
|
+
p_error: errorMessage
|
|
315
|
+
});
|
|
390
316
|
if (error) log.error(`Failed to update task status: ${error.message}`);
|
|
391
317
|
}
|
|
392
318
|
var eventSequence = 0;
|
|
@@ -396,26 +322,26 @@ function resetEventSequence() {
|
|
|
396
322
|
async function emitEvent(messageId, eventType, eventData) {
|
|
397
323
|
const sb = getSupabase();
|
|
398
324
|
eventSequence++;
|
|
399
|
-
const { error } = await sb.
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
325
|
+
const { error } = await sb.rpc("mcp_emit_event", {
|
|
326
|
+
p_token_hash: getTokenHash(),
|
|
327
|
+
p_message_id: messageId,
|
|
328
|
+
p_event_type: eventType,
|
|
329
|
+
p_event_data: eventData,
|
|
330
|
+
p_seq: eventSequence
|
|
404
331
|
});
|
|
405
332
|
if (error) log.warn(`Failed to emit event: ${error.message}`);
|
|
406
333
|
}
|
|
407
334
|
async function emitEvents(messageId, events) {
|
|
408
335
|
const sb = getSupabase();
|
|
409
|
-
const
|
|
336
|
+
const eventsJson = events.map((e) => {
|
|
410
337
|
eventSequence++;
|
|
411
|
-
return {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
338
|
+
return { type: e.type, data: e.data, seq: eventSequence };
|
|
339
|
+
});
|
|
340
|
+
const { error } = await sb.rpc("mcp_emit_events", {
|
|
341
|
+
p_token_hash: getTokenHash(),
|
|
342
|
+
p_message_id: messageId,
|
|
343
|
+
p_events: eventsJson
|
|
417
344
|
});
|
|
418
|
-
const { error } = await sb.from("message_events").insert(rows);
|
|
419
345
|
if (error) log.warn(`Failed to emit events batch: ${error.message}`);
|
|
420
346
|
}
|
|
421
347
|
|
|
@@ -432,8 +358,6 @@ export {
|
|
|
432
358
|
DAYBOX_AGENT_ID,
|
|
433
359
|
getSupabase,
|
|
434
360
|
loginWithToken,
|
|
435
|
-
refreshWithCliToken,
|
|
436
|
-
getSession,
|
|
437
361
|
getCurrentUserId,
|
|
438
362
|
logout,
|
|
439
363
|
createSession,
|
|
@@ -443,6 +367,7 @@ export {
|
|
|
443
367
|
getOrCreateCliConversation,
|
|
444
368
|
createTask,
|
|
445
369
|
pollPendingTasks,
|
|
370
|
+
pollAndClaimTask,
|
|
446
371
|
claimTask,
|
|
447
372
|
completeTask,
|
|
448
373
|
failTask,
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
claimTask,
|
|
4
3
|
clearConfig,
|
|
5
4
|
completeTask,
|
|
6
5
|
createSession,
|
|
@@ -17,14 +16,14 @@ import {
|
|
|
17
16
|
loginWithToken,
|
|
18
17
|
logout,
|
|
19
18
|
newCorrelationId,
|
|
20
|
-
|
|
19
|
+
pollAndClaimTask,
|
|
21
20
|
resetEventSequence,
|
|
22
21
|
setConfig,
|
|
23
22
|
setCorrelationId,
|
|
24
23
|
setLogLevel,
|
|
25
24
|
setSessionBusy,
|
|
26
25
|
updateHeartbeat
|
|
27
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-QXT7DH44.js";
|
|
28
27
|
|
|
29
28
|
// src/index.ts
|
|
30
29
|
import { Command } from "commander";
|
|
@@ -277,12 +276,11 @@ var SessionManager = class {
|
|
|
277
276
|
return;
|
|
278
277
|
}
|
|
279
278
|
try {
|
|
280
|
-
const
|
|
279
|
+
const task = await pollAndClaimTask(this.session.id);
|
|
281
280
|
this.consecutivePollFailures = 0;
|
|
282
|
-
if (
|
|
281
|
+
if (task) {
|
|
283
282
|
this.processing = true;
|
|
284
|
-
|
|
285
|
-
log.info(`New task received: ${task.id}`);
|
|
283
|
+
log.info(`New task claimed: ${task.id}`);
|
|
286
284
|
log.agent(`Task from conversation: ${task.conversation_id}`);
|
|
287
285
|
await setSessionBusy(this.session.id, true);
|
|
288
286
|
try {
|
|
@@ -2449,11 +2447,6 @@ var TaskProcessor = class {
|
|
|
2449
2447
|
const toolCallRecords = [];
|
|
2450
2448
|
const usedSkillNames = [];
|
|
2451
2449
|
try {
|
|
2452
|
-
await withRetry(() => claimTask(task.id), {
|
|
2453
|
-
maxRetries: 2,
|
|
2454
|
-
baseDelayMs: 300,
|
|
2455
|
-
label: "claimTask"
|
|
2456
|
-
});
|
|
2457
2450
|
await emitEvent(task.id, "status_change", { status: "running" });
|
|
2458
2451
|
let systemPrompt = BASE_SYSTEM_PROMPT.replace(
|
|
2459
2452
|
"{workspace_path}",
|
|
@@ -3087,7 +3080,7 @@ program.command("start", { isDefault: true }).description("Start the agent and l
|
|
|
3087
3080
|
}
|
|
3088
3081
|
log.agent(`Processing: "${input}"`);
|
|
3089
3082
|
try {
|
|
3090
|
-
const { createTask: createTask2 } = await import("./supabase-
|
|
3083
|
+
const { createTask: createTask2 } = await import("./supabase-QU7MFNDI.js");
|
|
3091
3084
|
const session = sessionManager.getSession();
|
|
3092
3085
|
const conversationId = sessionManager.getConversationId();
|
|
3093
3086
|
if (session && conversationId) {
|
|
@@ -3116,7 +3109,7 @@ program.command("start", { isDefault: true }).description("Start the agent and l
|
|
|
3116
3109
|
program.command("status").description("Check the status of the current agent session").action(async () => {
|
|
3117
3110
|
try {
|
|
3118
3111
|
const userId = await getCurrentUserId();
|
|
3119
|
-
const { getSupabase: getSupabase2 } = await import("./supabase-
|
|
3112
|
+
const { getSupabase: getSupabase2 } = await import("./supabase-QU7MFNDI.js");
|
|
3120
3113
|
const sb = getSupabase2();
|
|
3121
3114
|
const { data: sessions } = await sb.from("agent_sessions").select("*").eq("user_id", userId).in("status", ["online", "busy"]).order("started_at", { ascending: false }).limit(5);
|
|
3122
3115
|
if (!sessions || sessions.length === 0) {
|
|
@@ -11,16 +11,15 @@ import {
|
|
|
11
11
|
failTask,
|
|
12
12
|
getCurrentUserId,
|
|
13
13
|
getOrCreateCliConversation,
|
|
14
|
-
getSession,
|
|
15
14
|
getSupabase,
|
|
16
15
|
loginWithToken,
|
|
17
16
|
logout,
|
|
17
|
+
pollAndClaimTask,
|
|
18
18
|
pollPendingTasks,
|
|
19
|
-
refreshWithCliToken,
|
|
20
19
|
resetEventSequence,
|
|
21
20
|
setSessionBusy,
|
|
22
21
|
updateHeartbeat
|
|
23
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-QXT7DH44.js";
|
|
24
23
|
export {
|
|
25
24
|
CLI_AGENT_ID,
|
|
26
25
|
DAYBOX_AGENT_ID,
|
|
@@ -34,12 +33,11 @@ export {
|
|
|
34
33
|
failTask,
|
|
35
34
|
getCurrentUserId,
|
|
36
35
|
getOrCreateCliConversation,
|
|
37
|
-
getSession,
|
|
38
36
|
getSupabase,
|
|
39
37
|
loginWithToken,
|
|
40
38
|
logout,
|
|
39
|
+
pollAndClaimTask,
|
|
41
40
|
pollPendingTasks,
|
|
42
|
-
refreshWithCliToken,
|
|
43
41
|
resetEventSequence,
|
|
44
42
|
setSessionBusy,
|
|
45
43
|
updateHeartbeat
|
package/package.json
CHANGED
|
@@ -19,7 +19,6 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
|
|
19
19
|
|
|
20
20
|
// Mock Supabase DB functions
|
|
21
21
|
const mockDbFns = {
|
|
22
|
-
claimTask: vi.fn().mockResolvedValue(undefined),
|
|
23
22
|
completeTask: vi.fn().mockResolvedValue(undefined),
|
|
24
23
|
failTask: vi.fn().mockResolvedValue(undefined),
|
|
25
24
|
emitEvent: vi.fn().mockResolvedValue(undefined),
|
|
@@ -130,7 +129,7 @@ describe("TaskProcessor", () => {
|
|
|
130
129
|
|
|
131
130
|
await processor.processTask(makeTask("say hello"));
|
|
132
131
|
|
|
133
|
-
|
|
132
|
+
// Task is already claimed by pollAndClaimTask in session.ts, not processor
|
|
134
133
|
expect(mockDbFns.completeTask).toHaveBeenCalledWith(
|
|
135
134
|
"task-001",
|
|
136
135
|
expect.stringContaining("Hello!")
|
|
@@ -196,7 +195,7 @@ describe("TaskProcessor", () => {
|
|
|
196
195
|
});
|
|
197
196
|
|
|
198
197
|
it("handles task failure gracefully", async () => {
|
|
199
|
-
mockDbFns.
|
|
198
|
+
mockDbFns.emitEvent.mockRejectedValueOnce(new Error("DB error"));
|
|
200
199
|
|
|
201
200
|
await processor.processTask(makeTask("will fail"));
|
|
202
201
|
|
package/src/agent/processor.ts
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
6
6
|
import {
|
|
7
7
|
AgentTask,
|
|
8
|
-
claimTask,
|
|
9
8
|
completeTask,
|
|
10
9
|
failTask,
|
|
11
10
|
emitEvent,
|
|
@@ -108,12 +107,7 @@ export class TaskProcessor {
|
|
|
108
107
|
const usedSkillNames: string[] = [];
|
|
109
108
|
|
|
110
109
|
try {
|
|
111
|
-
//
|
|
112
|
-
await withRetry(() => claimTask(task.id), {
|
|
113
|
-
maxRetries: 2,
|
|
114
|
-
baseDelayMs: 300,
|
|
115
|
-
label: "claimTask",
|
|
116
|
-
});
|
|
110
|
+
// Task is already claimed atomically by pollAndClaimTask in session.ts
|
|
117
111
|
await emitEvent(task.id, "status_change", { status: "running" });
|
|
118
112
|
|
|
119
113
|
// Build system prompt with memories + skills
|
|
@@ -30,7 +30,7 @@ const mockCreateSession = vi.fn().mockResolvedValue(mockSession);
|
|
|
30
30
|
const mockUpdateHeartbeat = vi.fn().mockResolvedValue(undefined);
|
|
31
31
|
const mockEndSession = vi.fn().mockResolvedValue(undefined);
|
|
32
32
|
const mockSetSessionBusy = vi.fn().mockResolvedValue(undefined);
|
|
33
|
-
const
|
|
33
|
+
const mockPollAndClaimTask = vi.fn().mockResolvedValue(null);
|
|
34
34
|
const mockGetOrCreateCliConversation = vi.fn().mockResolvedValue("conv-001");
|
|
35
35
|
|
|
36
36
|
vi.mock("../db/supabase.js", () => ({
|
|
@@ -38,7 +38,7 @@ vi.mock("../db/supabase.js", () => ({
|
|
|
38
38
|
updateHeartbeat: (...args: any[]) => mockUpdateHeartbeat(...args),
|
|
39
39
|
endSession: (...args: any[]) => mockEndSession(...args),
|
|
40
40
|
setSessionBusy: (...args: any[]) => mockSetSessionBusy(...args),
|
|
41
|
-
|
|
41
|
+
pollAndClaimTask: (...args: any[]) => mockPollAndClaimTask(...args),
|
|
42
42
|
createTask: vi.fn().mockResolvedValue({ id: "task-001", prompt: "test" }),
|
|
43
43
|
getOrCreateCliConversation: (...args: any[]) => mockGetOrCreateCliConversation(...args),
|
|
44
44
|
getSupabase: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue(chain) }),
|
|
@@ -79,7 +79,7 @@ describe("SessionManager", () => {
|
|
|
79
79
|
mockUpdateHeartbeat.mockResolvedValue(undefined);
|
|
80
80
|
mockEndSession.mockResolvedValue(undefined);
|
|
81
81
|
mockSetSessionBusy.mockResolvedValue(undefined);
|
|
82
|
-
|
|
82
|
+
mockPollAndClaimTask.mockResolvedValue(null);
|
|
83
83
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
84
84
|
manager = new SessionManager();
|
|
85
85
|
});
|
|
@@ -117,12 +117,12 @@ describe("SessionManager", () => {
|
|
|
117
117
|
|
|
118
118
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
119
119
|
|
|
120
|
-
expect(
|
|
120
|
+
expect(mockPollAndClaimTask).toHaveBeenCalledWith("sess-001");
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
it("processes tasks when polled", async () => {
|
|
123
|
+
it("processes tasks when polled and claimed atomically", async () => {
|
|
124
124
|
const task = { id: "task-001", conversation_id: "conv-001", prompt: "hello" };
|
|
125
|
-
|
|
125
|
+
mockPollAndClaimTask.mockResolvedValueOnce(task);
|
|
126
126
|
|
|
127
127
|
await manager.start("user-123", onTask);
|
|
128
128
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
@@ -133,24 +133,24 @@ describe("SessionManager", () => {
|
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
it("applies exponential backoff on poll failures", async () => {
|
|
136
|
-
|
|
136
|
+
mockPollAndClaimTask
|
|
137
137
|
.mockRejectedValueOnce(new Error("Network error"))
|
|
138
138
|
.mockRejectedValueOnce(new Error("Network error"))
|
|
139
|
-
.mockResolvedValue(
|
|
139
|
+
.mockResolvedValue(null);
|
|
140
140
|
|
|
141
141
|
await manager.start("user-123", onTask);
|
|
142
142
|
|
|
143
143
|
// First poll at ~2s base interval
|
|
144
144
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
145
|
-
expect(
|
|
145
|
+
expect(mockPollAndClaimTask).toHaveBeenCalledTimes(1);
|
|
146
146
|
|
|
147
147
|
// After first failure: backoff = 2s * 2^1 = 4s → fires at ~6s total
|
|
148
148
|
await vi.advanceTimersByTimeAsync(4_000);
|
|
149
|
-
expect(
|
|
149
|
+
expect(mockPollAndClaimTask).toHaveBeenCalledTimes(2);
|
|
150
150
|
|
|
151
151
|
// After second failure: backoff = 2s * 2^2 = 8s → fires at ~14s total
|
|
152
152
|
await vi.advanceTimersByTimeAsync(8_500);
|
|
153
|
-
expect(
|
|
153
|
+
expect(mockPollAndClaimTask).toHaveBeenCalledTimes(3);
|
|
154
154
|
// Third call succeeds, so backoff resets. Next poll at 2s would be at ~16s
|
|
155
155
|
// We don't advance that far, so only 3 calls total.
|
|
156
156
|
});
|
package/src/agent/session.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
updateHeartbeat,
|
|
4
4
|
endSession,
|
|
5
5
|
setSessionBusy,
|
|
6
|
-
|
|
6
|
+
pollAndClaimTask,
|
|
7
7
|
createTask,
|
|
8
8
|
getOrCreateCliConversation,
|
|
9
9
|
getSupabase,
|
|
@@ -145,14 +145,15 @@ export class SessionManager {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
try {
|
|
148
|
-
|
|
148
|
+
// Atomic poll + claim: if a pending task exists, it's claimed in one DB call.
|
|
149
|
+
// FOR UPDATE SKIP LOCKED ensures no two CLIs grab the same task.
|
|
150
|
+
const task = await pollAndClaimTask(this.session.id);
|
|
149
151
|
// Reset backoff on success
|
|
150
152
|
this.consecutivePollFailures = 0;
|
|
151
153
|
|
|
152
|
-
if (
|
|
154
|
+
if (task) {
|
|
153
155
|
this.processing = true;
|
|
154
|
-
|
|
155
|
-
log.info(`New task received: ${task.id}`);
|
|
156
|
+
log.info(`New task claimed: ${task.id}`);
|
|
156
157
|
log.agent(`Task from conversation: ${task.conversation_id}`);
|
|
157
158
|
|
|
158
159
|
await setSessionBusy(this.session.id, true);
|
package/src/db/supabase.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
|
2
|
+
import { createHash } from "crypto";
|
|
2
3
|
import { getConfig } from "../utils/config.js";
|
|
3
4
|
import { log } from "../utils/logger.js";
|
|
4
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
@@ -10,8 +11,7 @@ import { homedir } from "os";
|
|
|
10
11
|
export const CLI_AGENT_ID = "00000000-0000-0000-0000-000000000001";
|
|
11
12
|
export const DAYBOX_AGENT_ID = "00000000-0000-0000-0000-000000000002";
|
|
12
13
|
|
|
13
|
-
// ──
|
|
14
|
-
// Node.js has no localStorage, so we persist Supabase auth tokens to disk.
|
|
14
|
+
// ── Auth Store (persists am_ token to disk) ──────────────────────────
|
|
15
15
|
|
|
16
16
|
const AUTH_DIR = join(homedir(), ".config", "assistme");
|
|
17
17
|
const AUTH_FILE = join(AUTH_DIR, "auth.json");
|
|
@@ -38,28 +38,7 @@ function writeAuthStore(data: Record<string, string>) {
|
|
|
38
38
|
writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
* Custom storage adapter for Supabase client.
|
|
43
|
-
* Persists auth sessions to ~/.config/assistme/auth.json
|
|
44
|
-
*/
|
|
45
|
-
const fileStorage = {
|
|
46
|
-
getItem(key: string): string | null {
|
|
47
|
-
const store = readAuthStore();
|
|
48
|
-
return store[key] ?? null;
|
|
49
|
-
},
|
|
50
|
-
setItem(key: string, value: string): void {
|
|
51
|
-
const store = readAuthStore();
|
|
52
|
-
store[key] = value;
|
|
53
|
-
writeAuthStore(store);
|
|
54
|
-
},
|
|
55
|
-
removeItem(key: string): void {
|
|
56
|
-
const store = readAuthStore();
|
|
57
|
-
delete store[key];
|
|
58
|
-
writeAuthStore(store);
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
// ── Supabase Client ────────────────────────────────────────────────
|
|
41
|
+
// ── Supabase Client (anon key only, no auth session) ────────────────
|
|
63
42
|
|
|
64
43
|
let supabase: SupabaseClient | null = null;
|
|
65
44
|
|
|
@@ -72,122 +51,72 @@ export function getSupabase(): SupabaseClient {
|
|
|
72
51
|
);
|
|
73
52
|
}
|
|
74
53
|
supabase = createClient(config.supabaseUrl, config.supabaseAnonKey, {
|
|
75
|
-
auth: {
|
|
76
|
-
storage: fileStorage,
|
|
77
|
-
autoRefreshToken: true,
|
|
78
|
-
persistSession: true,
|
|
79
|
-
},
|
|
54
|
+
auth: { persistSession: false },
|
|
80
55
|
});
|
|
81
56
|
}
|
|
82
57
|
return supabase;
|
|
83
58
|
}
|
|
84
59
|
|
|
85
|
-
// ──
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Exchange an am_ CLI token for a Supabase session via the Edge Function.
|
|
89
|
-
*/
|
|
90
|
-
async function exchangeCliToken(
|
|
91
|
-
cliToken: string
|
|
92
|
-
): Promise<{ access_token: string; refresh_token: string }> {
|
|
93
|
-
const config = getConfig();
|
|
94
|
-
const exchangeUrl = `${config.supabaseUrl}/functions/v1/cli-token-exchange`;
|
|
95
|
-
|
|
96
|
-
const res = await fetch(exchangeUrl, {
|
|
97
|
-
method: "POST",
|
|
98
|
-
headers: {
|
|
99
|
-
"Content-Type": "application/json",
|
|
100
|
-
apikey: config.supabaseAnonKey,
|
|
101
|
-
},
|
|
102
|
-
body: JSON.stringify({ token: cliToken }),
|
|
103
|
-
});
|
|
60
|
+
// ── Token Hash ──────────────────────────────────────────────────────
|
|
104
61
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
62
|
+
function hashToken(token: string): string {
|
|
63
|
+
return createHash("sha256").update(token).digest("hex");
|
|
64
|
+
}
|
|
109
65
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
66
|
+
/** Get stored token hash (computed from persisted am_ token). */
|
|
67
|
+
function getTokenHash(): string {
|
|
68
|
+
const store = readAuthStore();
|
|
69
|
+
const token = store["mcp_token"];
|
|
70
|
+
if (!token || !token.startsWith("am_")) {
|
|
71
|
+
throw new Error("Not authenticated. Run `assistme login`.");
|
|
113
72
|
}
|
|
114
|
-
return
|
|
73
|
+
return hashToken(token);
|
|
115
74
|
}
|
|
116
75
|
|
|
76
|
+
// ── Auth ─────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
117
78
|
/**
|
|
118
|
-
* Login using an am_
|
|
79
|
+
* Login using an am_ MCP token.
|
|
80
|
+
* Validates against DB via validate_mcp_token RPC, stores locally.
|
|
119
81
|
*/
|
|
120
|
-
export async function loginWithToken(
|
|
121
|
-
if (!
|
|
82
|
+
export async function loginWithToken(mcpToken: string): Promise<string> {
|
|
83
|
+
if (!mcpToken.startsWith("am_")) {
|
|
122
84
|
throw new Error(
|
|
123
85
|
"Invalid token format. Use an am_ token from the web page."
|
|
124
86
|
);
|
|
125
87
|
}
|
|
126
88
|
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
// Persist the CLI token so we can re-authenticate later
|
|
130
|
-
const store = readAuthStore();
|
|
131
|
-
store["cli_token"] = cliToken;
|
|
132
|
-
writeAuthStore(store);
|
|
133
|
-
|
|
89
|
+
const hash = hashToken(mcpToken);
|
|
134
90
|
const sb = getSupabase();
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
91
|
+
|
|
92
|
+
const { data, error } = await sb.rpc("validate_mcp_token", {
|
|
93
|
+
p_token_hash: hash,
|
|
138
94
|
});
|
|
139
95
|
|
|
140
|
-
if (error) throw new Error(`
|
|
141
|
-
if (!data.
|
|
96
|
+
if (error) throw new Error(`Token validation failed: ${error.message}`);
|
|
97
|
+
if (!data || data.length === 0) throw new Error("Invalid or expired token");
|
|
142
98
|
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Re-authenticate using the stored CLI token (am_).
|
|
148
|
-
* Called automatically when the Supabase session has expired.
|
|
149
|
-
*/
|
|
150
|
-
export async function refreshWithCliToken(): Promise<string | null> {
|
|
99
|
+
// Persist token
|
|
151
100
|
const store = readAuthStore();
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
const sessionTokens = await exchangeCliToken(cliToken);
|
|
157
|
-
const sb = getSupabase();
|
|
158
|
-
const { data, error } = await sb.auth.setSession({
|
|
159
|
-
access_token: sessionTokens.access_token,
|
|
160
|
-
refresh_token: sessionTokens.refresh_token,
|
|
161
|
-
});
|
|
162
|
-
if (error || !data.user) return null;
|
|
163
|
-
return data.user.id;
|
|
164
|
-
} catch {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
101
|
+
store["mcp_token"] = mcpToken;
|
|
102
|
+
writeAuthStore(store);
|
|
168
103
|
|
|
169
|
-
|
|
170
|
-
const sb = getSupabase();
|
|
171
|
-
const { data } = await sb.auth.getSession();
|
|
172
|
-
return data.session;
|
|
104
|
+
return data[0].out_user_id;
|
|
173
105
|
}
|
|
174
106
|
|
|
175
107
|
export async function getCurrentUserId(): Promise<string> {
|
|
108
|
+
const hash = getTokenHash();
|
|
176
109
|
const sb = getSupabase();
|
|
177
|
-
const { data, error } = await sb.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
throw new Error("Not authenticated. Run `assistme login`.");
|
|
110
|
+
const { data, error } = await sb.rpc("validate_mcp_token", {
|
|
111
|
+
p_token_hash: hash,
|
|
112
|
+
});
|
|
113
|
+
if (error || !data || data.length === 0) {
|
|
114
|
+
throw new Error("Token expired or revoked. Run `assistme login`.");
|
|
183
115
|
}
|
|
184
|
-
return data.
|
|
116
|
+
return data[0].out_user_id;
|
|
185
117
|
}
|
|
186
118
|
|
|
187
119
|
export async function logout(): Promise<void> {
|
|
188
|
-
const sb = getSupabase();
|
|
189
|
-
await sb.auth.signOut();
|
|
190
|
-
// Also clear the file
|
|
191
120
|
try {
|
|
192
121
|
writeAuthStore({});
|
|
193
122
|
} catch {
|
|
@@ -211,24 +140,19 @@ export interface AgentSession {
|
|
|
211
140
|
}
|
|
212
141
|
|
|
213
142
|
export async function createSession(
|
|
214
|
-
|
|
143
|
+
_userId: string,
|
|
215
144
|
sessionName: string,
|
|
216
145
|
workspacePath: string,
|
|
217
146
|
version: string
|
|
218
147
|
): Promise<AgentSession> {
|
|
219
148
|
const sb = getSupabase();
|
|
220
|
-
const { data, error } = await sb
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
agent_version: version,
|
|
228
|
-
metadata: { model: getConfig().model },
|
|
229
|
-
})
|
|
230
|
-
.select()
|
|
231
|
-
.single();
|
|
149
|
+
const { data, error } = await sb.rpc("mcp_create_session", {
|
|
150
|
+
p_token_hash: getTokenHash(),
|
|
151
|
+
p_session_name: sessionName,
|
|
152
|
+
p_workspace_path: workspacePath,
|
|
153
|
+
p_version: version,
|
|
154
|
+
p_model: getConfig().model || null,
|
|
155
|
+
});
|
|
232
156
|
|
|
233
157
|
if (error) throw new Error(`Failed to create session: ${error.message}`);
|
|
234
158
|
return data as AgentSession;
|
|
@@ -236,24 +160,19 @@ export async function createSession(
|
|
|
236
160
|
|
|
237
161
|
export async function updateHeartbeat(sessionId: string): Promise<void> {
|
|
238
162
|
const sb = getSupabase();
|
|
239
|
-
const { error } = await sb
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
163
|
+
const { error } = await sb.rpc("mcp_heartbeat", {
|
|
164
|
+
p_token_hash: getTokenHash(),
|
|
165
|
+
p_session_id: sessionId,
|
|
166
|
+
});
|
|
244
167
|
if (error) log.warn(`Heartbeat update failed: ${error.message}`);
|
|
245
168
|
}
|
|
246
169
|
|
|
247
170
|
export async function endSession(sessionId: string): Promise<void> {
|
|
248
171
|
const sb = getSupabase();
|
|
249
|
-
const { error } = await sb
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
ended_at: new Date().toISOString(),
|
|
254
|
-
})
|
|
255
|
-
.eq("id", sessionId);
|
|
256
|
-
|
|
172
|
+
const { error } = await sb.rpc("mcp_end_session", {
|
|
173
|
+
p_token_hash: getTokenHash(),
|
|
174
|
+
p_session_id: sessionId,
|
|
175
|
+
});
|
|
257
176
|
if (error) log.error(`Failed to end session: ${error.message}`);
|
|
258
177
|
}
|
|
259
178
|
|
|
@@ -262,64 +181,28 @@ export async function setSessionBusy(
|
|
|
262
181
|
busy: boolean
|
|
263
182
|
): Promise<void> {
|
|
264
183
|
const sb = getSupabase();
|
|
265
|
-
await sb
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
184
|
+
await sb.rpc("mcp_set_session_busy", {
|
|
185
|
+
p_token_hash: getTokenHash(),
|
|
186
|
+
p_session_id: sessionId,
|
|
187
|
+
p_busy: busy,
|
|
188
|
+
});
|
|
269
189
|
}
|
|
270
190
|
|
|
271
191
|
// ── Conversation Management ─────────────────────────────────────────
|
|
272
192
|
|
|
273
|
-
/**
|
|
274
|
-
* Find or create a CLI conversation for a given session.
|
|
275
|
-
* Each agent session gets one conversation.
|
|
276
|
-
*/
|
|
277
193
|
export async function getOrCreateCliConversation(
|
|
278
|
-
|
|
279
|
-
|
|
194
|
+
_userId: string,
|
|
195
|
+
_sessionId: string
|
|
280
196
|
): Promise<string> {
|
|
281
197
|
const sb = getSupabase();
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
.select("id")
|
|
288
|
-
.eq("agent", "cli")
|
|
289
|
-
.eq("created_by", userId)
|
|
290
|
-
.order("created_at", { ascending: false })
|
|
291
|
-
.limit(1);
|
|
292
|
-
|
|
293
|
-
if (existing && existing.length > 0) {
|
|
294
|
-
return existing[0].id;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Create new conversation
|
|
298
|
-
const { data: conv, error: convErr } = await sb
|
|
299
|
-
.from("conversations")
|
|
300
|
-
.insert({
|
|
301
|
-
conversation_type: "direct",
|
|
302
|
-
agent: "cli",
|
|
303
|
-
created_by: userId,
|
|
304
|
-
})
|
|
305
|
-
.select("id")
|
|
306
|
-
.single();
|
|
307
|
-
|
|
308
|
-
if (convErr || !conv) {
|
|
309
|
-
throw new Error(`Failed to create conversation: ${convErr?.message}`);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Add participants: user + CLI agent
|
|
313
|
-
await sb.from("conversation_participants").insert([
|
|
314
|
-
{ conversation_id: conv.id, user_id: userId, role: "member" },
|
|
315
|
-
{ conversation_id: conv.id, user_id: CLI_AGENT_ID, role: "member" },
|
|
316
|
-
]);
|
|
317
|
-
|
|
318
|
-
return conv.id;
|
|
198
|
+
const { data, error } = await sb.rpc("mcp_get_or_create_conversation", {
|
|
199
|
+
p_token_hash: getTokenHash(),
|
|
200
|
+
});
|
|
201
|
+
if (error) throw new Error(`Failed to get conversation: ${error.message}`);
|
|
202
|
+
return data as string;
|
|
319
203
|
}
|
|
320
204
|
|
|
321
205
|
// ── Message / Task Management ────────────────────────────────────────
|
|
322
|
-
// A "task" is now an assistant message in conversation_messages with a status.
|
|
323
206
|
|
|
324
207
|
export interface ConversationMessage {
|
|
325
208
|
id: string;
|
|
@@ -334,115 +217,88 @@ export interface ConversationMessage {
|
|
|
334
217
|
metadata: Record<string, unknown>;
|
|
335
218
|
created_at: string;
|
|
336
219
|
updated_at: string;
|
|
337
|
-
/** The original user prompt (stored in metadata for assistant messages) */
|
|
338
220
|
prompt: string;
|
|
339
221
|
}
|
|
340
222
|
|
|
341
|
-
// Keep AgentTask as an alias for backward compatibility with processor.ts
|
|
342
223
|
export type AgentTask = ConversationMessage;
|
|
343
224
|
|
|
344
|
-
/**
|
|
345
|
-
* Create a user prompt + pending assistant message pair atomically.
|
|
346
|
-
* Uses a PG function for transactional safety. Falls back to sequential
|
|
347
|
-
* inserts if the function doesn't exist yet.
|
|
348
|
-
* Returns the assistant message (the "task" to process).
|
|
349
|
-
*/
|
|
350
225
|
export async function createTask(
|
|
351
226
|
conversationId: string,
|
|
352
|
-
|
|
227
|
+
_userId: string,
|
|
353
228
|
sessionId: string,
|
|
354
229
|
prompt: string
|
|
355
230
|
): Promise<ConversationMessage> {
|
|
356
231
|
const sb = getSupabase();
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
p_conversation_id: conversationId,
|
|
363
|
-
p_user_id: userId,
|
|
364
|
-
p_agent_id: CLI_AGENT_ID,
|
|
365
|
-
p_session_id: sessionId,
|
|
366
|
-
p_prompt: prompt,
|
|
367
|
-
}
|
|
368
|
-
);
|
|
369
|
-
|
|
370
|
-
if (!rpcError && rpcResult) {
|
|
371
|
-
return { ...rpcResult, prompt } as ConversationMessage;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// Fallback: sequential inserts (for environments without the PG function)
|
|
375
|
-
if (rpcError) {
|
|
376
|
-
log.debug(`create_task_pair RPC unavailable, using fallback: ${rpcError.message}`);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// Insert user message
|
|
380
|
-
const { error: userErr } = await sb.from("conversation_messages").insert({
|
|
381
|
-
conversation_id: conversationId,
|
|
382
|
-
sender_id: userId,
|
|
383
|
-
role: "user",
|
|
384
|
-
content: prompt,
|
|
232
|
+
const { data, error } = await sb.rpc("mcp_create_task", {
|
|
233
|
+
p_token_hash: getTokenHash(),
|
|
234
|
+
p_conversation_id: conversationId,
|
|
235
|
+
p_session_id: sessionId,
|
|
236
|
+
p_prompt: prompt,
|
|
385
237
|
});
|
|
386
238
|
|
|
387
|
-
if (userErr) throw new Error(`Failed to create user message: ${userErr.message}`);
|
|
388
|
-
|
|
389
|
-
// Insert assistant placeholder (the "task")
|
|
390
|
-
const { data, error } = await sb
|
|
391
|
-
.from("conversation_messages")
|
|
392
|
-
.insert({
|
|
393
|
-
conversation_id: conversationId,
|
|
394
|
-
sender_id: CLI_AGENT_ID,
|
|
395
|
-
role: "assistant",
|
|
396
|
-
content: "",
|
|
397
|
-
status: "pending",
|
|
398
|
-
session_id: sessionId,
|
|
399
|
-
metadata: { prompt },
|
|
400
|
-
})
|
|
401
|
-
.select()
|
|
402
|
-
.single();
|
|
403
|
-
|
|
404
239
|
if (error) throw new Error(`Failed to create task: ${error.message}`);
|
|
405
240
|
return { ...data, prompt } as ConversationMessage;
|
|
406
241
|
}
|
|
407
242
|
|
|
408
|
-
/**
|
|
409
|
-
* Poll for pending assistant messages assigned to this session.
|
|
410
|
-
*/
|
|
411
243
|
export async function pollPendingTasks(
|
|
412
244
|
sessionId: string
|
|
413
245
|
): Promise<ConversationMessage[]> {
|
|
414
246
|
const sb = getSupabase();
|
|
415
|
-
const { data, error } = await sb
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
.eq("status", "pending")
|
|
420
|
-
.order("created_at", { ascending: true })
|
|
421
|
-
.limit(1);
|
|
247
|
+
const { data, error } = await sb.rpc("mcp_poll_tasks", {
|
|
248
|
+
p_token_hash: getTokenHash(),
|
|
249
|
+
p_session_id: sessionId,
|
|
250
|
+
});
|
|
422
251
|
|
|
423
252
|
if (error) {
|
|
424
253
|
log.warn(`Task poll failed: ${error.message}`);
|
|
425
254
|
return [];
|
|
426
255
|
}
|
|
427
|
-
|
|
428
|
-
|
|
256
|
+
|
|
257
|
+
const rows = (data || []) as Record<string, unknown>[];
|
|
258
|
+
return rows.map((row) => ({
|
|
429
259
|
...row,
|
|
430
|
-
prompt:
|
|
260
|
+
prompt:
|
|
261
|
+
(row.metadata as Record<string, unknown>)?.prompt || row.content || "",
|
|
431
262
|
})) as ConversationMessage[];
|
|
432
263
|
}
|
|
433
264
|
|
|
434
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Atomically poll ONE pending task and claim it in a single DB call.
|
|
267
|
+
* Uses FOR UPDATE SKIP LOCKED — concurrent CLIs will never grab the same task.
|
|
268
|
+
* Returns null if no pending task exists.
|
|
269
|
+
*/
|
|
270
|
+
export async function pollAndClaimTask(
|
|
271
|
+
sessionId: string
|
|
272
|
+
): Promise<ConversationMessage | null> {
|
|
435
273
|
const sb = getSupabase();
|
|
436
|
-
const { error } = await sb
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
.
|
|
443
|
-
|
|
274
|
+
const { data, error } = await sb.rpc("mcp_poll_and_claim_task", {
|
|
275
|
+
p_token_hash: getTokenHash(),
|
|
276
|
+
p_session_id: sessionId,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (error) {
|
|
280
|
+
log.warn(`Poll-and-claim failed: ${error.message}`);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
444
283
|
|
|
284
|
+
if (!data) return null;
|
|
285
|
+
|
|
286
|
+
const row = data as Record<string, unknown>;
|
|
287
|
+
return {
|
|
288
|
+
...row,
|
|
289
|
+
prompt:
|
|
290
|
+
(row.metadata as Record<string, unknown>)?.prompt || row.content || "",
|
|
291
|
+
} as ConversationMessage;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function claimTask(messageId: string): Promise<boolean> {
|
|
295
|
+
const sb = getSupabase();
|
|
296
|
+
const { data, error } = await sb.rpc("mcp_claim_task", {
|
|
297
|
+
p_token_hash: getTokenHash(),
|
|
298
|
+
p_message_id: messageId,
|
|
299
|
+
});
|
|
445
300
|
if (error) throw new Error(`Failed to claim task: ${error.message}`);
|
|
301
|
+
return data as boolean;
|
|
446
302
|
}
|
|
447
303
|
|
|
448
304
|
export async function completeTask(
|
|
@@ -451,29 +307,12 @@ export async function completeTask(
|
|
|
451
307
|
tokenUsage?: Record<string, number>
|
|
452
308
|
): Promise<void> {
|
|
453
309
|
const sb = getSupabase();
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
.single();
|
|
461
|
-
|
|
462
|
-
const existingMeta = (current?.metadata as Record<string, unknown>) || {};
|
|
463
|
-
|
|
464
|
-
const { error } = await sb
|
|
465
|
-
.from("conversation_messages")
|
|
466
|
-
.update({
|
|
467
|
-
content: resultSummary,
|
|
468
|
-
status: "completed",
|
|
469
|
-
metadata: {
|
|
470
|
-
...existingMeta,
|
|
471
|
-
completed_at: new Date().toISOString(),
|
|
472
|
-
token_usage: tokenUsage || null,
|
|
473
|
-
},
|
|
474
|
-
})
|
|
475
|
-
.eq("id", messageId);
|
|
476
|
-
|
|
310
|
+
const { error } = await sb.rpc("mcp_complete_task", {
|
|
311
|
+
p_token_hash: getTokenHash(),
|
|
312
|
+
p_message_id: messageId,
|
|
313
|
+
p_result: resultSummary,
|
|
314
|
+
p_token_usage: tokenUsage || null,
|
|
315
|
+
});
|
|
477
316
|
if (error) throw new Error(`Failed to complete task: ${error.message}`);
|
|
478
317
|
}
|
|
479
318
|
|
|
@@ -482,28 +321,11 @@ export async function failTask(
|
|
|
482
321
|
errorMessage: string
|
|
483
322
|
): Promise<void> {
|
|
484
323
|
const sb = getSupabase();
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
.single();
|
|
491
|
-
|
|
492
|
-
const existingMeta = (current?.metadata as Record<string, unknown>) || {};
|
|
493
|
-
|
|
494
|
-
const { error } = await sb
|
|
495
|
-
.from("conversation_messages")
|
|
496
|
-
.update({
|
|
497
|
-
status: "failed",
|
|
498
|
-
content: errorMessage,
|
|
499
|
-
metadata: {
|
|
500
|
-
...existingMeta,
|
|
501
|
-
completed_at: new Date().toISOString(),
|
|
502
|
-
error_message: errorMessage,
|
|
503
|
-
},
|
|
504
|
-
})
|
|
505
|
-
.eq("id", messageId);
|
|
506
|
-
|
|
324
|
+
const { error } = await sb.rpc("mcp_fail_task", {
|
|
325
|
+
p_token_hash: getTokenHash(),
|
|
326
|
+
p_message_id: messageId,
|
|
327
|
+
p_error: errorMessage,
|
|
328
|
+
});
|
|
507
329
|
if (error) log.error(`Failed to update task status: ${error.message}`);
|
|
508
330
|
}
|
|
509
331
|
|
|
@@ -531,32 +353,29 @@ export async function emitEvent(
|
|
|
531
353
|
): Promise<void> {
|
|
532
354
|
const sb = getSupabase();
|
|
533
355
|
eventSequence++;
|
|
534
|
-
const { error } = await sb.
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
356
|
+
const { error } = await sb.rpc("mcp_emit_event", {
|
|
357
|
+
p_token_hash: getTokenHash(),
|
|
358
|
+
p_message_id: messageId,
|
|
359
|
+
p_event_type: eventType,
|
|
360
|
+
p_event_data: eventData,
|
|
361
|
+
p_seq: eventSequence,
|
|
539
362
|
});
|
|
540
|
-
|
|
541
363
|
if (error) log.warn(`Failed to emit event: ${error.message}`);
|
|
542
364
|
}
|
|
543
365
|
|
|
544
|
-
// Batch emit for efficiency
|
|
545
366
|
export async function emitEvents(
|
|
546
367
|
messageId: string,
|
|
547
368
|
events: Array<{ type: EventType; data: Record<string, unknown> }>
|
|
548
369
|
): Promise<void> {
|
|
549
370
|
const sb = getSupabase();
|
|
550
|
-
const
|
|
371
|
+
const eventsJson = events.map((e) => {
|
|
551
372
|
eventSequence++;
|
|
552
|
-
return {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
373
|
+
return { type: e.type, data: e.data, seq: eventSequence };
|
|
374
|
+
});
|
|
375
|
+
const { error } = await sb.rpc("mcp_emit_events", {
|
|
376
|
+
p_token_hash: getTokenHash(),
|
|
377
|
+
p_message_id: messageId,
|
|
378
|
+
p_events: eventsJson,
|
|
558
379
|
});
|
|
559
|
-
|
|
560
|
-
const { error } = await sb.from("message_events").insert(rows);
|
|
561
380
|
if (error) log.warn(`Failed to emit events batch: ${error.message}`);
|
|
562
381
|
}
|