@shipers-dev/multi 0.9.6 → 0.10.0
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/index.js +64 -4
- package/package.json +1 -1
- package/src/index.ts +69 -3
package/dist/index.js
CHANGED
|
@@ -5703,7 +5703,7 @@ import { join as join3, dirname as dirname2 } from "path";
|
|
|
5703
5703
|
// package.json
|
|
5704
5704
|
var package_default = {
|
|
5705
5705
|
name: "@shipers-dev/multi",
|
|
5706
|
-
version: "0.
|
|
5706
|
+
version: "0.10.0",
|
|
5707
5707
|
type: "module",
|
|
5708
5708
|
bin: {
|
|
5709
5709
|
"multi-agent": "./dist/index.js"
|
|
@@ -6052,6 +6052,9 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6052
6052
|
if (t.issue_id) {
|
|
6053
6053
|
postStream(apiUrl, t.issue_id, "queued", { queue_position: pos });
|
|
6054
6054
|
}
|
|
6055
|
+
if (t.dispatch_id) {
|
|
6056
|
+
ackDispatch(apiUrl, t.dispatch_id, config.dispatchSecret);
|
|
6057
|
+
}
|
|
6055
6058
|
queueMicrotask(() => schedule());
|
|
6056
6059
|
return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
|
|
6057
6060
|
} catch (e) {
|
|
@@ -6153,7 +6156,16 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6153
6156
|
process.exit(1);
|
|
6154
6157
|
}
|
|
6155
6158
|
log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
|
|
6156
|
-
|
|
6159
|
+
const heartbeat = async () => {
|
|
6160
|
+
const res = await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel?.url });
|
|
6161
|
+
return res.success && res.data?.pending_dispatches || 0;
|
|
6162
|
+
};
|
|
6163
|
+
{
|
|
6164
|
+
const pending = await heartbeat();
|
|
6165
|
+
if (pending > 0)
|
|
6166
|
+
drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
|
|
6167
|
+
}
|
|
6168
|
+
drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
|
|
6157
6169
|
let alive = true;
|
|
6158
6170
|
const shutdown = async (reason) => {
|
|
6159
6171
|
if (!alive)
|
|
@@ -6195,7 +6207,9 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6195
6207
|
tunnel = next;
|
|
6196
6208
|
log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
|
|
6197
6209
|
try {
|
|
6198
|
-
|
|
6210
|
+
const pending = await heartbeat();
|
|
6211
|
+
if (pending > 0)
|
|
6212
|
+
drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
|
|
6199
6213
|
} catch (e) {
|
|
6200
6214
|
log(`heartbeat error after tunnel restart: ${String(e)}`);
|
|
6201
6215
|
}
|
|
@@ -6246,7 +6260,9 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6246
6260
|
}
|
|
6247
6261
|
}
|
|
6248
6262
|
try {
|
|
6249
|
-
|
|
6263
|
+
const pending = await heartbeat();
|
|
6264
|
+
if (pending > 0)
|
|
6265
|
+
drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
|
|
6250
6266
|
} catch (e) {
|
|
6251
6267
|
log(`heartbeat error: ${String(e)}`);
|
|
6252
6268
|
}
|
|
@@ -7060,6 +7076,50 @@ async function resolveAcpAdapter(agentType, detectedPath) {
|
|
|
7060
7076
|
return [c];
|
|
7061
7077
|
return null;
|
|
7062
7078
|
}
|
|
7079
|
+
async function ackDispatch(apiUrl, dispatchId, secret) {
|
|
7080
|
+
try {
|
|
7081
|
+
await fetch(`${apiUrl}/api/issues/dispatches/${dispatchId}/ack`, {
|
|
7082
|
+
method: "POST",
|
|
7083
|
+
headers: { authorization: `Bearer ${secret}` }
|
|
7084
|
+
});
|
|
7085
|
+
} catch (e) {
|
|
7086
|
+
log(`ack dispatch ${dispatchId} failed: ${String(e)}`);
|
|
7087
|
+
}
|
|
7088
|
+
}
|
|
7089
|
+
async function drainOfflineDispatches(apiUrl, deviceId, secret, db, onEnqueued) {
|
|
7090
|
+
let list;
|
|
7091
|
+
try {
|
|
7092
|
+
list = await apiClient.get(`${apiUrl}/api/devices/${deviceId}/dispatches/pending`);
|
|
7093
|
+
} catch (e) {
|
|
7094
|
+
log(`drain list failed: ${String(e)}`);
|
|
7095
|
+
return;
|
|
7096
|
+
}
|
|
7097
|
+
const rows = list?.data?.results || list?.data || list?.results || list || [];
|
|
7098
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
7099
|
+
return;
|
|
7100
|
+
log(`\uD83E\uDEA3 draining ${rows.length} offline dispatch(es)`);
|
|
7101
|
+
for (const r of rows) {
|
|
7102
|
+
try {
|
|
7103
|
+
const res = await fetch(`${apiUrl}/api/issues/dispatches/${r.id}/claim`, {
|
|
7104
|
+
method: "POST",
|
|
7105
|
+
headers: { authorization: `Bearer ${secret}` }
|
|
7106
|
+
});
|
|
7107
|
+
if (!res.ok) {
|
|
7108
|
+
log(`claim ${r.id} skipped: ${res.status}`);
|
|
7109
|
+
continue;
|
|
7110
|
+
}
|
|
7111
|
+
const { task } = await res.json();
|
|
7112
|
+
const taskId = task.issue_id ? `${task.issue_id}-${Date.now()}` : crypto.randomUUID();
|
|
7113
|
+
db.run("INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)", [taskId, JSON.stringify(task), task.agent_id ?? null, task.issue_id ?? null]);
|
|
7114
|
+
if (task.issue_id)
|
|
7115
|
+
postStream(apiUrl, task.issue_id, "queued", { drained: true });
|
|
7116
|
+
ackDispatch(apiUrl, r.id, secret);
|
|
7117
|
+
} catch (e) {
|
|
7118
|
+
log(`drain ${r.id} failed: ${String(e)}`);
|
|
7119
|
+
}
|
|
7120
|
+
}
|
|
7121
|
+
onEnqueued();
|
|
7122
|
+
}
|
|
7063
7123
|
async function postStream(apiUrl, issueId, event_type, payload) {
|
|
7064
7124
|
try {
|
|
7065
7125
|
ensureDirs();
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -355,6 +355,11 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
355
355
|
if (t.issue_id) {
|
|
356
356
|
void postStream(apiUrl, t.issue_id, 'queued', { queue_position: pos });
|
|
357
357
|
}
|
|
358
|
+
// Ack dispatch back to worker so UI flips from queued → acked. Fire-and-forget:
|
|
359
|
+
// missing/failed ack is fine, stall-sweep covers it.
|
|
360
|
+
if (t.dispatch_id) {
|
|
361
|
+
void ackDispatch(apiUrl, t.dispatch_id, config.dispatchSecret!);
|
|
362
|
+
}
|
|
358
363
|
queueMicrotask(() => schedule());
|
|
359
364
|
return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
|
|
360
365
|
} catch (e) {
|
|
@@ -441,7 +446,17 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
441
446
|
}
|
|
442
447
|
log(`☁️ Tunnel up: ${tunnel.url}`);
|
|
443
448
|
|
|
444
|
-
|
|
449
|
+
const heartbeat = async (): Promise<number> => {
|
|
450
|
+
const res = await apiClient.post<{ pending_dispatches?: number }>(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel?.url });
|
|
451
|
+
return (res.success && res.data?.pending_dispatches) || 0;
|
|
452
|
+
};
|
|
453
|
+
{
|
|
454
|
+
const pending = await heartbeat();
|
|
455
|
+
if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
456
|
+
}
|
|
457
|
+
// Always attempt one drain on fresh startup, covering the case where worker
|
|
458
|
+
// couldn't reach the previous daemon instance right before restart.
|
|
459
|
+
void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
445
460
|
|
|
446
461
|
let alive = true;
|
|
447
462
|
|
|
@@ -477,7 +492,8 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
477
492
|
tunnel = next;
|
|
478
493
|
log(`☁️ Tunnel up: ${tunnel.url}`);
|
|
479
494
|
try {
|
|
480
|
-
|
|
495
|
+
const pending = await heartbeat();
|
|
496
|
+
if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
481
497
|
} catch (e) {
|
|
482
498
|
log(`heartbeat error after tunnel restart: ${String(e)}`);
|
|
483
499
|
}
|
|
@@ -524,7 +540,8 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
524
540
|
}
|
|
525
541
|
}
|
|
526
542
|
try {
|
|
527
|
-
|
|
543
|
+
const pending = await heartbeat();
|
|
544
|
+
if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
528
545
|
} catch (e) {
|
|
529
546
|
log(`heartbeat error: ${String(e)}`);
|
|
530
547
|
}
|
|
@@ -1247,6 +1264,55 @@ async function resolveAcpAdapter(agentType: string, detectedPath?: string): Prom
|
|
|
1247
1264
|
return null;
|
|
1248
1265
|
}
|
|
1249
1266
|
|
|
1267
|
+
// Ack a dispatch so the worker flips its status from dispatched → acked.
|
|
1268
|
+
// Uses dispatch_secret (Bearer) so the daemon can call an unauthenticated-by-user endpoint.
|
|
1269
|
+
async function ackDispatch(apiUrl: string, dispatchId: string, secret: string) {
|
|
1270
|
+
try {
|
|
1271
|
+
await fetch(`${apiUrl}/api/issues/dispatches/${dispatchId}/ack`, {
|
|
1272
|
+
method: 'POST',
|
|
1273
|
+
headers: { 'authorization': `Bearer ${secret}` },
|
|
1274
|
+
});
|
|
1275
|
+
} catch (e) {
|
|
1276
|
+
log(`ack dispatch ${dispatchId} failed: ${String(e)}`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// On reconnect, pull dispatches the worker couldn't deliver and enqueue them.
|
|
1281
|
+
// Each call claims the row (offline → dispatched) atomically so parallel daemons
|
|
1282
|
+
// don't double-run the same task.
|
|
1283
|
+
async function drainOfflineDispatches(apiUrl: string, deviceId: string, secret: string, db: Database, onEnqueued: () => void) {
|
|
1284
|
+
let list: any;
|
|
1285
|
+
try {
|
|
1286
|
+
list = await apiClient.get<any>(`${apiUrl}/api/devices/${deviceId}/dispatches/pending`);
|
|
1287
|
+
} catch (e) {
|
|
1288
|
+
log(`drain list failed: ${String(e)}`);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
const rows = (list?.data?.results || list?.data || list?.results || list || []) as any[];
|
|
1292
|
+
if (!Array.isArray(rows) || rows.length === 0) return;
|
|
1293
|
+
log(`🪣 draining ${rows.length} offline dispatch(es)`);
|
|
1294
|
+
for (const r of rows) {
|
|
1295
|
+
try {
|
|
1296
|
+
const res = await fetch(`${apiUrl}/api/issues/dispatches/${r.id}/claim`, {
|
|
1297
|
+
method: 'POST',
|
|
1298
|
+
headers: { 'authorization': `Bearer ${secret}` },
|
|
1299
|
+
});
|
|
1300
|
+
if (!res.ok) { log(`claim ${r.id} skipped: ${res.status}`); continue; }
|
|
1301
|
+
const { task } = await res.json() as { task: any };
|
|
1302
|
+
const taskId = task.issue_id ? `${task.issue_id}-${Date.now()}` : crypto.randomUUID();
|
|
1303
|
+
db.run(
|
|
1304
|
+
"INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)",
|
|
1305
|
+
[taskId, JSON.stringify(task), task.agent_id ?? null, task.issue_id ?? null],
|
|
1306
|
+
);
|
|
1307
|
+
if (task.issue_id) void postStream(apiUrl, task.issue_id, 'queued', { drained: true });
|
|
1308
|
+
void ackDispatch(apiUrl, r.id, secret);
|
|
1309
|
+
} catch (e) {
|
|
1310
|
+
log(`drain ${r.id} failed: ${String(e)}`);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
onEnqueued();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1250
1316
|
async function postStream(apiUrl: string, issueId: string, event_type: string, payload: any) {
|
|
1251
1317
|
// Local ndjson sink for tail -f debugging.
|
|
1252
1318
|
try {
|