@hasna/loops 0.3.47 → 0.3.49
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/README.md +35 -3
- package/dist/cli/index.js +357 -349
- package/dist/daemon/index.js +223 -40
- package/dist/index.js +223 -58
- package/dist/lib/store.d.ts +13 -2
- package/dist/lib/store.js +206 -32
- package/dist/sdk/index.js +222 -39
- package/docs/USAGE.md +44 -5
- package/package.json +1 -1
package/dist/sdk/index.js
CHANGED
|
@@ -327,6 +327,13 @@ function optionalPositiveInteger(value, label) {
|
|
|
327
327
|
throw new Error(`${label} must be a positive integer`);
|
|
328
328
|
return value;
|
|
329
329
|
}
|
|
330
|
+
function optionalBoolean(value, label) {
|
|
331
|
+
if (value === undefined)
|
|
332
|
+
return;
|
|
333
|
+
if (typeof value !== "boolean")
|
|
334
|
+
throw new Error(`${label} must be a boolean`);
|
|
335
|
+
return value;
|
|
336
|
+
}
|
|
330
337
|
function optionalStringArray(value, label) {
|
|
331
338
|
if (value === undefined)
|
|
332
339
|
return;
|
|
@@ -338,6 +345,18 @@ function optionalStringArray(value, label) {
|
|
|
338
345
|
}).filter(Boolean);
|
|
339
346
|
return values.length ? values : undefined;
|
|
340
347
|
}
|
|
348
|
+
function optionalAccountRef(value, label) {
|
|
349
|
+
if (value === undefined)
|
|
350
|
+
return;
|
|
351
|
+
assertObject(value, label);
|
|
352
|
+
assertString(value.profile, `${label}.profile`);
|
|
353
|
+
if (value.tool !== undefined)
|
|
354
|
+
assertString(value.tool, `${label}.tool`);
|
|
355
|
+
return {
|
|
356
|
+
profile: value.profile.trim(),
|
|
357
|
+
tool: typeof value.tool === "string" ? value.tool.trim() : undefined
|
|
358
|
+
};
|
|
359
|
+
}
|
|
341
360
|
function normalizeGoalSpec(value, label = "goal") {
|
|
342
361
|
if (value === undefined)
|
|
343
362
|
return;
|
|
@@ -384,8 +403,21 @@ function validateTarget(value, label) {
|
|
|
384
403
|
if (value.provider !== "codewith")
|
|
385
404
|
throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
|
|
386
405
|
}
|
|
406
|
+
if (value.configIsolation !== undefined) {
|
|
407
|
+
assertString(value.configIsolation, `${label}.configIsolation`);
|
|
408
|
+
if (!["safe", "none"].includes(value.configIsolation))
|
|
409
|
+
throw new Error(`${label}.configIsolation must be safe or none`);
|
|
410
|
+
}
|
|
411
|
+
if (value.model !== undefined)
|
|
412
|
+
assertString(value.model, `${label}.model`);
|
|
387
413
|
if (value.variant !== undefined)
|
|
388
414
|
assertString(value.variant, `${label}.variant`);
|
|
415
|
+
if (value.agent !== undefined)
|
|
416
|
+
assertString(value.agent, `${label}.agent`);
|
|
417
|
+
if (value.provider === "cursor" && value.variant !== undefined)
|
|
418
|
+
throw new Error(`${label}.variant is not supported for provider cursor`);
|
|
419
|
+
if (value.provider === "codex" && value.agent !== undefined)
|
|
420
|
+
throw new Error(`${label}.agent is not supported for provider codex`);
|
|
389
421
|
optionalStringArray(value.addDirs, `${label}.addDirs`);
|
|
390
422
|
if (Array.isArray(value.addDirs) && value.addDirs.length > 0 && !["codewith", "codex"].includes(value.provider)) {
|
|
391
423
|
throw new Error(`${label}.addDirs is currently supported only for provider codewith or codex`);
|
|
@@ -482,8 +514,10 @@ function normalizeCreateWorkflowInput(input) {
|
|
|
482
514
|
id: step.id,
|
|
483
515
|
goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
|
|
484
516
|
target: validateTarget(step.target, `workflow.steps[${index}].target`),
|
|
485
|
-
dependsOn: step.dependsOn ?? [],
|
|
486
|
-
continueOnFailure: step.continueOnFailure ?? false
|
|
517
|
+
dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
|
|
518
|
+
continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
|
|
519
|
+
timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
|
|
520
|
+
account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
|
|
487
521
|
};
|
|
488
522
|
});
|
|
489
523
|
for (const step of steps) {
|
|
@@ -584,6 +618,11 @@ function writeWorkflowRunManifest(args) {
|
|
|
584
618
|
}
|
|
585
619
|
|
|
586
620
|
// src/lib/store.ts
|
|
621
|
+
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
622
|
+
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
623
|
+
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
624
|
+
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
|
|
625
|
+
var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
|
|
587
626
|
function rowToLoop(row) {
|
|
588
627
|
return {
|
|
589
628
|
id: row.id,
|
|
@@ -850,6 +889,7 @@ class Store {
|
|
|
850
889
|
ensurePrivateStorePath(file);
|
|
851
890
|
this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
|
|
852
891
|
this.db = new Database(file);
|
|
892
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
853
893
|
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
854
894
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
855
895
|
if (file !== ":memory:")
|
|
@@ -913,6 +953,7 @@ class Store {
|
|
|
913
953
|
);
|
|
914
954
|
CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
|
|
915
955
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
|
|
956
|
+
CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
|
|
916
957
|
CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
|
|
917
958
|
|
|
918
959
|
CREATE TABLE IF NOT EXISTS daemon_lease (
|
|
@@ -1240,39 +1281,51 @@ class Store {
|
|
|
1240
1281
|
}
|
|
1241
1282
|
return rows.map(rowToLoop);
|
|
1242
1283
|
}
|
|
1243
|
-
dueLoops(now) {
|
|
1284
|
+
dueLoops(now, limit = 500) {
|
|
1244
1285
|
const rows = this.db.query(`SELECT * FROM loops
|
|
1245
1286
|
WHERE status = 'active'
|
|
1246
1287
|
AND archived_at IS NULL
|
|
1247
1288
|
AND next_run_at IS NOT NULL
|
|
1248
1289
|
AND next_run_at <= ?
|
|
1249
|
-
ORDER BY next_run_at ASC
|
|
1290
|
+
ORDER BY next_run_at ASC
|
|
1291
|
+
LIMIT ?`).all(now.toISOString(), limit);
|
|
1250
1292
|
return rows.map(rowToLoop);
|
|
1251
1293
|
}
|
|
1252
1294
|
updateLoop(id, patch, opts = {}) {
|
|
1253
|
-
const current = this.getLoop(id);
|
|
1254
|
-
if (!current)
|
|
1255
|
-
throw new Error(`loop not found: ${id}`);
|
|
1256
1295
|
const updated = (opts.now ?? new Date).toISOString();
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1296
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1297
|
+
try {
|
|
1298
|
+
const current = this.getLoop(id);
|
|
1299
|
+
if (!current)
|
|
1300
|
+
throw new Error(`loop not found: ${id}`);
|
|
1301
|
+
const merged = { ...current, ...patch, updatedAt: updated };
|
|
1302
|
+
const res = this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
|
|
1303
|
+
expires_at=$expiresAt, updated_at=$updated
|
|
1304
|
+
WHERE id=$id
|
|
1305
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1306
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1307
|
+
))`).run({
|
|
1308
|
+
$id: id,
|
|
1309
|
+
$status: merged.status,
|
|
1310
|
+
$nextRun: merged.nextRunAt ?? null,
|
|
1311
|
+
$retrySlot: merged.retryScheduledFor ?? null,
|
|
1312
|
+
$expiresAt: merged.expiresAt ?? null,
|
|
1313
|
+
$updated: merged.updatedAt,
|
|
1314
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1315
|
+
$now: updated
|
|
1316
|
+
});
|
|
1317
|
+
if (res.changes !== 1)
|
|
1318
|
+
throw new Error("daemon lease lost");
|
|
1319
|
+
if (patch.status && patch.status !== "active") {
|
|
1320
|
+
const status = patch.status === "paused" ? "deferred" : "cancelled";
|
|
1321
|
+
this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
|
|
1322
|
+
}
|
|
1323
|
+
this.db.exec("COMMIT");
|
|
1324
|
+
} catch (error) {
|
|
1325
|
+
try {
|
|
1326
|
+
this.db.exec("ROLLBACK");
|
|
1327
|
+
} catch {}
|
|
1328
|
+
throw error;
|
|
1276
1329
|
}
|
|
1277
1330
|
const after = this.getLoop(id);
|
|
1278
1331
|
if (!after)
|
|
@@ -1390,10 +1443,23 @@ class Store {
|
|
|
1390
1443
|
})();
|
|
1391
1444
|
}
|
|
1392
1445
|
listWorkflows(opts = {}) {
|
|
1393
|
-
const
|
|
1394
|
-
|
|
1446
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
1447
|
+
let rows;
|
|
1448
|
+
if (opts.status && opts.limit !== undefined) {
|
|
1449
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
|
|
1450
|
+
} else if (opts.status) {
|
|
1451
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
|
|
1452
|
+
} else if (opts.limit !== undefined) {
|
|
1453
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
|
|
1454
|
+
} else {
|
|
1455
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
|
|
1456
|
+
}
|
|
1395
1457
|
return rows.map(rowToWorkflow);
|
|
1396
1458
|
}
|
|
1459
|
+
countWorkflows(opts = {}) {
|
|
1460
|
+
const row = opts.status ? this.db.query("SELECT COUNT(*) AS count FROM workflow_specs WHERE status = ?").get(opts.status) : this.db.query("SELECT COUNT(*) AS count FROM workflow_specs").get();
|
|
1461
|
+
return row?.count ?? 0;
|
|
1462
|
+
}
|
|
1397
1463
|
archiveWorkflow(idOrName) {
|
|
1398
1464
|
const workflow = this.requireWorkflow(idOrName);
|
|
1399
1465
|
const updated = nowIso();
|
|
@@ -1403,6 +1469,64 @@ class Store {
|
|
|
1403
1469
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1404
1470
|
return archived;
|
|
1405
1471
|
}
|
|
1472
|
+
generatedRouteArchiveContext(args) {
|
|
1473
|
+
if (!args.loopId || !args.workItemId)
|
|
1474
|
+
return;
|
|
1475
|
+
const workItem = this.getWorkflowWorkItem(args.workItemId);
|
|
1476
|
+
if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
|
|
1477
|
+
return;
|
|
1478
|
+
const invocation = this.getWorkflowInvocation(workItem.invocationId);
|
|
1479
|
+
if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
|
|
1480
|
+
return;
|
|
1481
|
+
const loop = this.getLoop(args.loopId);
|
|
1482
|
+
if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
|
|
1483
|
+
return;
|
|
1484
|
+
const input = loop.target.input ?? {};
|
|
1485
|
+
if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
|
|
1486
|
+
return;
|
|
1487
|
+
if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
|
|
1488
|
+
return;
|
|
1489
|
+
if (workItem.workflowId && workItem.workflowId !== args.workflowId)
|
|
1490
|
+
return;
|
|
1491
|
+
const workflow = this.getWorkflow(args.workflowId);
|
|
1492
|
+
if (!workflow)
|
|
1493
|
+
return;
|
|
1494
|
+
return { workflow, loop, workItem, invocation };
|
|
1495
|
+
}
|
|
1496
|
+
maybeArchiveGeneratedRouteWorkflow(args) {
|
|
1497
|
+
const context = this.generatedRouteArchiveContext(args);
|
|
1498
|
+
if (!context)
|
|
1499
|
+
return;
|
|
1500
|
+
const { workflow, loop, workItem } = context;
|
|
1501
|
+
if (!workflow || workflow.status !== "active")
|
|
1502
|
+
return;
|
|
1503
|
+
const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
|
|
1504
|
+
WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
|
|
1505
|
+
if (nonTerminal > 0)
|
|
1506
|
+
return;
|
|
1507
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
|
|
1508
|
+
if (res.changes === 1 && args.workflowRunId) {
|
|
1509
|
+
this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
|
|
1510
|
+
workflowId: args.workflowId,
|
|
1511
|
+
loopId: loop.id,
|
|
1512
|
+
workItemId: workItem.id,
|
|
1513
|
+
routeKey: workItem.routeKey,
|
|
1514
|
+
reason: "terminal generated one-shot route workflow"
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
|
|
1519
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
1520
|
+
if (!run)
|
|
1521
|
+
return;
|
|
1522
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
1523
|
+
workflowId: run.workflowId,
|
|
1524
|
+
loopId: run.loopId,
|
|
1525
|
+
workItemId: run.workItemId,
|
|
1526
|
+
workflowRunId,
|
|
1527
|
+
updated
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1406
1530
|
createWorkflowInvocation(input) {
|
|
1407
1531
|
const now = nowIso();
|
|
1408
1532
|
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
@@ -2188,6 +2312,7 @@ class Store {
|
|
|
2188
2312
|
if (changed) {
|
|
2189
2313
|
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2190
2314
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2315
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
|
|
2191
2316
|
}
|
|
2192
2317
|
this.db.exec("COMMIT");
|
|
2193
2318
|
} catch (error) {
|
|
@@ -2215,6 +2340,7 @@ class Store {
|
|
|
2215
2340
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2216
2341
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
2217
2342
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
2343
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
|
|
2218
2344
|
}
|
|
2219
2345
|
this.db.exec("COMMIT");
|
|
2220
2346
|
return this.requireWorkflowRun(workflowRunId);
|
|
@@ -2467,8 +2593,20 @@ class Store {
|
|
|
2467
2593
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2468
2594
|
if (opts.claimedBy && res.changes !== 1)
|
|
2469
2595
|
return run;
|
|
2470
|
-
if (res.changes === 1)
|
|
2596
|
+
if (res.changes === 1) {
|
|
2471
2597
|
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2598
|
+
const loop = this.getLoop(run.loopId);
|
|
2599
|
+
const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
2600
|
+
if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
|
|
2601
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2602
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2603
|
+
workflowId: loop.target.workflowId,
|
|
2604
|
+
loopId: loop.id,
|
|
2605
|
+
workItemId,
|
|
2606
|
+
updated: finishedAt
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2472
2610
|
return run;
|
|
2473
2611
|
}
|
|
2474
2612
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2503,14 +2641,40 @@ class Store {
|
|
|
2503
2641
|
}
|
|
2504
2642
|
return rows.map(rowToRun);
|
|
2505
2643
|
}
|
|
2644
|
+
deferLiveExpiredRun(id, now, opts = {}) {
|
|
2645
|
+
const updated = now.toISOString();
|
|
2646
|
+
const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
|
|
2647
|
+
this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
|
|
2648
|
+
WHERE id=$id AND status='running' AND lease_expires_at <= $now
|
|
2649
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
2650
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
2651
|
+
))`).run({
|
|
2652
|
+
$id: id,
|
|
2653
|
+
$deferredUntil: deferredUntil,
|
|
2654
|
+
$updated: updated,
|
|
2655
|
+
$now: updated,
|
|
2656
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2506
2659
|
recoverExpiredRunLeases(now = new Date, opts = {}) {
|
|
2507
|
-
const
|
|
2660
|
+
const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
|
|
2661
|
+
const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
|
|
2662
|
+
const rows = this.db.query(`SELECT * FROM loop_runs
|
|
2663
|
+
WHERE status = 'running' AND lease_expires_at <= ?
|
|
2664
|
+
ORDER BY lease_expires_at ASC
|
|
2665
|
+
LIMIT ?`).all(now.toISOString(), scanLimit);
|
|
2508
2666
|
const recovered = [];
|
|
2509
2667
|
for (const row of rows) {
|
|
2510
|
-
if (
|
|
2668
|
+
if (recovered.length >= limit)
|
|
2669
|
+
break;
|
|
2670
|
+
if (row.pid && isProcessAlive(row.pid)) {
|
|
2671
|
+
this.deferLiveExpiredRun(row.id, now, opts);
|
|
2511
2672
|
continue;
|
|
2512
|
-
|
|
2673
|
+
}
|
|
2674
|
+
if (this.hasLiveWorkflowStepProcesses(row.id)) {
|
|
2675
|
+
this.deferLiveExpiredRun(row.id, now, opts);
|
|
2513
2676
|
continue;
|
|
2677
|
+
}
|
|
2514
2678
|
const finished = now.toISOString();
|
|
2515
2679
|
this.db.exec("BEGIN IMMEDIATE");
|
|
2516
2680
|
try {
|
|
@@ -2563,6 +2727,7 @@ class Store {
|
|
|
2563
2727
|
loopRunId: row.id
|
|
2564
2728
|
});
|
|
2565
2729
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2730
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
|
|
2566
2731
|
}
|
|
2567
2732
|
const loop = this.getLoop(row.loop_id);
|
|
2568
2733
|
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
@@ -2570,6 +2735,15 @@ class Store {
|
|
|
2570
2735
|
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2571
2736
|
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2572
2737
|
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2738
|
+
if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
|
|
2739
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2740
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2741
|
+
workflowId: loop.target.workflowId,
|
|
2742
|
+
loopId: loop.id,
|
|
2743
|
+
workItemId,
|
|
2744
|
+
updated: finished
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2573
2747
|
}
|
|
2574
2748
|
this.db.exec("COMMIT");
|
|
2575
2749
|
} catch (error) {
|
|
@@ -2899,6 +3073,7 @@ var AUTH_ENV_KEYS = [
|
|
|
2899
3073
|
"CODEWITH_HOME",
|
|
2900
3074
|
"CODEX_HOME",
|
|
2901
3075
|
"CURSOR_CONFIG_DIR",
|
|
3076
|
+
"CURSOR_API_KEY",
|
|
2902
3077
|
"OPENCODE_CONFIG_DIR",
|
|
2903
3078
|
"AICOPILOT_CONFIG_DIR",
|
|
2904
3079
|
"ANTHROPIC_API_KEY",
|
|
@@ -3028,6 +3203,10 @@ function assertSupportedAgentOptions(target) {
|
|
|
3028
3203
|
assertStringOption(target.model, `${target.provider}.model`);
|
|
3029
3204
|
assertStringOption(target.agent, `${target.provider}.agent`);
|
|
3030
3205
|
assertStringOption(target.authProfile, `${target.provider}.authProfile`);
|
|
3206
|
+
assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
|
|
3207
|
+
if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
|
|
3208
|
+
throw new Error(`${target.provider}.configIsolation must be safe or none`);
|
|
3209
|
+
}
|
|
3031
3210
|
if (target.authProfile !== undefined && target.provider !== "codewith") {
|
|
3032
3211
|
throw new Error(`${target.provider}.authProfile is supported only for codewith`);
|
|
3033
3212
|
}
|
|
@@ -3040,6 +3219,8 @@ function assertSupportedAgentOptions(target) {
|
|
|
3040
3219
|
if (target.sandbox && !["read-only", "workspace-write", "danger-full-access", "enabled", "disabled"].includes(target.sandbox)) {
|
|
3041
3220
|
throw new Error(`${target.provider}.sandbox is not supported: ${target.sandbox}`);
|
|
3042
3221
|
}
|
|
3222
|
+
if (target.provider === "codex" && target.agent !== undefined)
|
|
3223
|
+
throw new Error("codex.agent is not supported");
|
|
3043
3224
|
if (["codewith", "codex"].includes(target.provider)) {
|
|
3044
3225
|
if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
|
|
3045
3226
|
throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
|
|
@@ -3054,6 +3235,8 @@ function assertSupportedAgentOptions(target) {
|
|
|
3054
3235
|
return;
|
|
3055
3236
|
}
|
|
3056
3237
|
if (target.provider === "cursor") {
|
|
3238
|
+
if (target.variant !== undefined)
|
|
3239
|
+
throw new Error("cursor.variant is not supported");
|
|
3057
3240
|
if (target.permissionMode === "auto")
|
|
3058
3241
|
throw new Error("cursor.permissionMode auto is not supported; use provider-specific extraArgs for Cursor auto-review");
|
|
3059
3242
|
if (target.sandbox !== undefined && target.sandbox !== "enabled" && target.sandbox !== "disabled") {
|
|
@@ -3095,10 +3278,8 @@ function agentArgs(target) {
|
|
|
3095
3278
|
"set -eu",
|
|
3096
3279
|
"if command -v agent >/dev/null 2>&1; then",
|
|
3097
3280
|
' exec agent "$@"',
|
|
3098
|
-
"elif command -v cursor >/dev/null 2>&1; then",
|
|
3099
|
-
' exec cursor agent "$@"',
|
|
3100
3281
|
"else",
|
|
3101
|
-
" echo 'Executable not found in PATH:
|
|
3282
|
+
" echo 'Executable not found in PATH: agent' >&2",
|
|
3102
3283
|
" exit 127",
|
|
3103
3284
|
"fi"
|
|
3104
3285
|
].join(`
|
|
@@ -3139,7 +3320,7 @@ function agentArgs(target) {
|
|
|
3139
3320
|
case "codex":
|
|
3140
3321
|
if (target.variant)
|
|
3141
3322
|
args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
|
|
3142
|
-
args.push("exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
|
|
3323
|
+
args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
|
|
3143
3324
|
if (isolation === "safe")
|
|
3144
3325
|
args.push("--ignore-rules");
|
|
3145
3326
|
if (target.cwd)
|
|
@@ -3209,7 +3390,7 @@ function commandSpec(target) {
|
|
|
3209
3390
|
account: agentTarget.account,
|
|
3210
3391
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
3211
3392
|
nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
|
|
3212
|
-
preflightAnyOf: agentTarget.provider === "cursor" ? ["
|
|
3393
|
+
preflightAnyOf: agentTarget.provider === "cursor" ? ["agent"] : undefined,
|
|
3213
3394
|
stdin: agentTarget.prompt,
|
|
3214
3395
|
allowlist: agentTarget.allowlist
|
|
3215
3396
|
};
|
|
@@ -4592,12 +4773,14 @@ function claimDueRuns(deps) {
|
|
|
4592
4773
|
const claimed = [];
|
|
4593
4774
|
const skipped = [];
|
|
4594
4775
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
4776
|
+
if (maxClaims === 0)
|
|
4777
|
+
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
4595
4778
|
for (const loop of deps.store.dueLoops(now)) {
|
|
4596
|
-
if (claims.length >= maxClaims)
|
|
4779
|
+
if (claims.length + skipped.length >= maxClaims)
|
|
4597
4780
|
break;
|
|
4598
4781
|
const plan = dueSlots(loop, now);
|
|
4599
4782
|
for (const slot of plan.slots) {
|
|
4600
|
-
if (claims.length >= maxClaims)
|
|
4783
|
+
if (claims.length + skipped.length >= maxClaims)
|
|
4601
4784
|
break;
|
|
4602
4785
|
const run = claimSlot(deps, loop, slot);
|
|
4603
4786
|
if (!run)
|
package/docs/USAGE.md
CHANGED
|
@@ -5,7 +5,7 @@ OpenLoops is a local CLI and daemon for persistent loops and workflows: schedule
|
|
|
5
5
|
It supports deterministic command loops, JSON-defined workflows, and guarded CLI adapters for headless coding agents:
|
|
6
6
|
|
|
7
7
|
- `claude`
|
|
8
|
-
- `
|
|
8
|
+
- `agent` (Cursor Agent CLI)
|
|
9
9
|
- `codewith exec`
|
|
10
10
|
- `aicopilot run`
|
|
11
11
|
- `opencode run`
|
|
@@ -367,13 +367,13 @@ loops events drain todos-task \
|
|
|
367
367
|
--todos-project "$HOME/.hasna/loops" \
|
|
368
368
|
--task-list repoops-pr-queue \
|
|
369
369
|
--tags auto:route \
|
|
370
|
-
--project-path-prefix
|
|
370
|
+
--project-path-prefix /home/hasna/workspace/hasna/opensource \
|
|
371
371
|
--provider codewith \
|
|
372
372
|
--auth-profile-pool account004,account005,account006 \
|
|
373
373
|
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
|
|
374
374
|
--project-group oss \
|
|
375
375
|
--max-dispatch 2 \
|
|
376
|
-
--scan-limit
|
|
376
|
+
--scan-limit 5000 \
|
|
377
377
|
--max-active-per-project 1 \
|
|
378
378
|
--max-active-per-project-group 4 \
|
|
379
379
|
--max-active 12 \
|
|
@@ -394,6 +394,45 @@ when requested, and leaves excess ready tasks in todos for a later drain pass.
|
|
|
394
394
|
Use `--dry-run` to preview candidates and rendered workflows without mutating
|
|
395
395
|
OpenLoops state.
|
|
396
396
|
|
|
397
|
+
For the Hasna OSS task-created route, keep the drain deterministic and narrow:
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
loops routes schedule todos-task oss-task-route-drain \
|
|
401
|
+
--every 5m \
|
|
402
|
+
--todos-project "$HOME/.hasna/loops" \
|
|
403
|
+
--project-path-prefix /home/hasna/workspace/hasna/opensource \
|
|
404
|
+
--tags auto:route \
|
|
405
|
+
--provider codewith \
|
|
406
|
+
--auth-profile-pool account004,account005,account006 \
|
|
407
|
+
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
|
|
408
|
+
--project-group oss \
|
|
409
|
+
--max-dispatch 2 \
|
|
410
|
+
--scan-limit 5000 \
|
|
411
|
+
--max-active-per-project 1 \
|
|
412
|
+
--max-active-per-project-group 4 \
|
|
413
|
+
--max-active 12 \
|
|
414
|
+
--worktree-mode required \
|
|
415
|
+
--evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
|
|
416
|
+
--compact
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Only tasks under `/home/hasna/workspace/hasna/opensource` that explicitly opt
|
|
420
|
+
in with the `auto:route` tag, `route_enabled=true`, or
|
|
421
|
+
`automation.allowed=true` should be routed. Keep repo-mutating worker/verifier
|
|
422
|
+
runs on a Codewith account pool with `--worktree-mode required`. Do not dispatch
|
|
423
|
+
or paste task prompts into tmux panes. Use max-active throttles and
|
|
424
|
+
`--max-dispatch` to bound agent fan-out, and write compact evidence into a
|
|
425
|
+
bounded reports directory so operators can audit each drain without unbounded
|
|
426
|
+
stdout or loop history growth. Keep `--scan-limit` large enough for the current
|
|
427
|
+
ready-task backlog; if the scan is exhausted before matching tasks appear, the
|
|
428
|
+
drain will correctly do no work.
|
|
429
|
+
|
|
430
|
+
Generated task/event route workflow specs are lifecycle-managed. After a
|
|
431
|
+
generated one-shot route workflow run reaches `succeeded`, `failed`,
|
|
432
|
+
`timed_out`, or `cancelled`, OpenLoops archives the generated workflow spec while
|
|
433
|
+
preserving workflow run, step, event, manifest, loop, invocation, and work-item
|
|
434
|
+
history.
|
|
435
|
+
|
|
397
436
|
For other Hasna apps that expose `@hasna/events` webhooks, use the generic
|
|
398
437
|
handler:
|
|
399
438
|
|
|
@@ -578,8 +617,8 @@ The adapters intentionally use provider command surfaces instead of pretending e
|
|
|
578
617
|
- Claude uses `claude -p --output-format json` and safe-mode/local setting sources by default.
|
|
579
618
|
- Codewith uses `codewith --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`, with `--add-dir` for explicit extra writable directories.
|
|
580
619
|
- AI Copilot and OpenCode use `run --format json --pure`.
|
|
581
|
-
- Cursor is CLI-first for now via standalone `agent -p`
|
|
582
|
-
- Codex uses `codex exec --json --ephemeral --skip-git-repo-check`, with `--add-dir` for explicit extra writable directories where supported.
|
|
620
|
+
- Cursor is CLI-first for now via the standalone `agent -p` binary. OpenLoops no longer falls back to `cursor agent`; install the standalone Cursor Agent CLI so preflight and scheduled runs use the same executable.
|
|
621
|
+
- Codex uses `codex --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`, with `--add-dir` for explicit extra writable directories where supported.
|
|
583
622
|
- Agent prompts are sent through child stdin instead of argv so prompt bodies do not appear in process listings.
|
|
584
623
|
- When `--account` or a step `account` is set, OpenLoops resolves `accounts env <profile> --tool <tool>` before spawning the target, strips inherited tool home/API-key variables, and applies the selected profile only to that process. Missing account profiles fail before the provider binary receives the prompt.
|
|
585
624
|
- `--auth-profile` and step `authProfile` are provider-native auth selectors. They currently apply to Codewith and are passed to Codewith as `--auth-profile <name>` before `exec`; they do not call OpenAccounts.
|
package/package.json
CHANGED