@hasna/loops 0.3.47 → 0.3.48
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 +3 -3
- package/dist/cli/index.js +158 -341
- package/dist/daemon/index.js +123 -37
- package/dist/index.js +123 -55
- package/dist/lib/store.d.ts +6 -2
- package/dist/lib/store.js +106 -29
- package/dist/sdk/index.js +122 -36
- package/docs/USAGE.md +3 -3
- package/package.json +1 -1
package/dist/lib/store.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,9 @@ 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;
|
|
587
624
|
function rowToLoop(row) {
|
|
588
625
|
return {
|
|
589
626
|
id: row.id,
|
|
@@ -850,6 +887,7 @@ class Store {
|
|
|
850
887
|
ensurePrivateStorePath(file);
|
|
851
888
|
this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
|
|
852
889
|
this.db = new Database(file);
|
|
890
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
853
891
|
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
854
892
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
855
893
|
if (file !== ":memory:")
|
|
@@ -913,6 +951,7 @@ class Store {
|
|
|
913
951
|
);
|
|
914
952
|
CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
|
|
915
953
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
|
|
954
|
+
CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
|
|
916
955
|
CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
|
|
917
956
|
|
|
918
957
|
CREATE TABLE IF NOT EXISTS daemon_lease (
|
|
@@ -1240,39 +1279,51 @@ class Store {
|
|
|
1240
1279
|
}
|
|
1241
1280
|
return rows.map(rowToLoop);
|
|
1242
1281
|
}
|
|
1243
|
-
dueLoops(now) {
|
|
1282
|
+
dueLoops(now, limit = 500) {
|
|
1244
1283
|
const rows = this.db.query(`SELECT * FROM loops
|
|
1245
1284
|
WHERE status = 'active'
|
|
1246
1285
|
AND archived_at IS NULL
|
|
1247
1286
|
AND next_run_at IS NOT NULL
|
|
1248
1287
|
AND next_run_at <= ?
|
|
1249
|
-
ORDER BY next_run_at ASC
|
|
1288
|
+
ORDER BY next_run_at ASC
|
|
1289
|
+
LIMIT ?`).all(now.toISOString(), limit);
|
|
1250
1290
|
return rows.map(rowToLoop);
|
|
1251
1291
|
}
|
|
1252
1292
|
updateLoop(id, patch, opts = {}) {
|
|
1253
|
-
const current = this.getLoop(id);
|
|
1254
|
-
if (!current)
|
|
1255
|
-
throw new Error(`loop not found: ${id}`);
|
|
1256
1293
|
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
|
-
|
|
1294
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1295
|
+
try {
|
|
1296
|
+
const current = this.getLoop(id);
|
|
1297
|
+
if (!current)
|
|
1298
|
+
throw new Error(`loop not found: ${id}`);
|
|
1299
|
+
const merged = { ...current, ...patch, updatedAt: updated };
|
|
1300
|
+
const res = this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
|
|
1301
|
+
expires_at=$expiresAt, updated_at=$updated
|
|
1302
|
+
WHERE id=$id
|
|
1303
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1304
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1305
|
+
))`).run({
|
|
1306
|
+
$id: id,
|
|
1307
|
+
$status: merged.status,
|
|
1308
|
+
$nextRun: merged.nextRunAt ?? null,
|
|
1309
|
+
$retrySlot: merged.retryScheduledFor ?? null,
|
|
1310
|
+
$expiresAt: merged.expiresAt ?? null,
|
|
1311
|
+
$updated: merged.updatedAt,
|
|
1312
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1313
|
+
$now: updated
|
|
1314
|
+
});
|
|
1315
|
+
if (res.changes !== 1)
|
|
1316
|
+
throw new Error("daemon lease lost");
|
|
1317
|
+
if (patch.status && patch.status !== "active") {
|
|
1318
|
+
const status = patch.status === "paused" ? "deferred" : "cancelled";
|
|
1319
|
+
this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
|
|
1320
|
+
}
|
|
1321
|
+
this.db.exec("COMMIT");
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
try {
|
|
1324
|
+
this.db.exec("ROLLBACK");
|
|
1325
|
+
} catch {}
|
|
1326
|
+
throw error;
|
|
1276
1327
|
}
|
|
1277
1328
|
const after = this.getLoop(id);
|
|
1278
1329
|
if (!after)
|
|
@@ -2503,14 +2554,40 @@ class Store {
|
|
|
2503
2554
|
}
|
|
2504
2555
|
return rows.map(rowToRun);
|
|
2505
2556
|
}
|
|
2557
|
+
deferLiveExpiredRun(id, now, opts = {}) {
|
|
2558
|
+
const updated = now.toISOString();
|
|
2559
|
+
const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
|
|
2560
|
+
this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
|
|
2561
|
+
WHERE id=$id AND status='running' AND lease_expires_at <= $now
|
|
2562
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
2563
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
2564
|
+
))`).run({
|
|
2565
|
+
$id: id,
|
|
2566
|
+
$deferredUntil: deferredUntil,
|
|
2567
|
+
$updated: updated,
|
|
2568
|
+
$now: updated,
|
|
2569
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2506
2572
|
recoverExpiredRunLeases(now = new Date, opts = {}) {
|
|
2507
|
-
const
|
|
2573
|
+
const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
|
|
2574
|
+
const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
|
|
2575
|
+
const rows = this.db.query(`SELECT * FROM loop_runs
|
|
2576
|
+
WHERE status = 'running' AND lease_expires_at <= ?
|
|
2577
|
+
ORDER BY lease_expires_at ASC
|
|
2578
|
+
LIMIT ?`).all(now.toISOString(), scanLimit);
|
|
2508
2579
|
const recovered = [];
|
|
2509
2580
|
for (const row of rows) {
|
|
2510
|
-
if (
|
|
2581
|
+
if (recovered.length >= limit)
|
|
2582
|
+
break;
|
|
2583
|
+
if (row.pid && isProcessAlive(row.pid)) {
|
|
2584
|
+
this.deferLiveExpiredRun(row.id, now, opts);
|
|
2511
2585
|
continue;
|
|
2512
|
-
|
|
2586
|
+
}
|
|
2587
|
+
if (this.hasLiveWorkflowStepProcesses(row.id)) {
|
|
2588
|
+
this.deferLiveExpiredRun(row.id, now, opts);
|
|
2513
2589
|
continue;
|
|
2590
|
+
}
|
|
2514
2591
|
const finished = now.toISOString();
|
|
2515
2592
|
this.db.exec("BEGIN IMMEDIATE");
|
|
2516
2593
|
try {
|
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,9 @@ 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;
|
|
587
624
|
function rowToLoop(row) {
|
|
588
625
|
return {
|
|
589
626
|
id: row.id,
|
|
@@ -850,6 +887,7 @@ class Store {
|
|
|
850
887
|
ensurePrivateStorePath(file);
|
|
851
888
|
this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
|
|
852
889
|
this.db = new Database(file);
|
|
890
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
853
891
|
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
854
892
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
855
893
|
if (file !== ":memory:")
|
|
@@ -913,6 +951,7 @@ class Store {
|
|
|
913
951
|
);
|
|
914
952
|
CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
|
|
915
953
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
|
|
954
|
+
CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
|
|
916
955
|
CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
|
|
917
956
|
|
|
918
957
|
CREATE TABLE IF NOT EXISTS daemon_lease (
|
|
@@ -1240,39 +1279,51 @@ class Store {
|
|
|
1240
1279
|
}
|
|
1241
1280
|
return rows.map(rowToLoop);
|
|
1242
1281
|
}
|
|
1243
|
-
dueLoops(now) {
|
|
1282
|
+
dueLoops(now, limit = 500) {
|
|
1244
1283
|
const rows = this.db.query(`SELECT * FROM loops
|
|
1245
1284
|
WHERE status = 'active'
|
|
1246
1285
|
AND archived_at IS NULL
|
|
1247
1286
|
AND next_run_at IS NOT NULL
|
|
1248
1287
|
AND next_run_at <= ?
|
|
1249
|
-
ORDER BY next_run_at ASC
|
|
1288
|
+
ORDER BY next_run_at ASC
|
|
1289
|
+
LIMIT ?`).all(now.toISOString(), limit);
|
|
1250
1290
|
return rows.map(rowToLoop);
|
|
1251
1291
|
}
|
|
1252
1292
|
updateLoop(id, patch, opts = {}) {
|
|
1253
|
-
const current = this.getLoop(id);
|
|
1254
|
-
if (!current)
|
|
1255
|
-
throw new Error(`loop not found: ${id}`);
|
|
1256
1293
|
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
|
-
|
|
1294
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1295
|
+
try {
|
|
1296
|
+
const current = this.getLoop(id);
|
|
1297
|
+
if (!current)
|
|
1298
|
+
throw new Error(`loop not found: ${id}`);
|
|
1299
|
+
const merged = { ...current, ...patch, updatedAt: updated };
|
|
1300
|
+
const res = this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
|
|
1301
|
+
expires_at=$expiresAt, updated_at=$updated
|
|
1302
|
+
WHERE id=$id
|
|
1303
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1304
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1305
|
+
))`).run({
|
|
1306
|
+
$id: id,
|
|
1307
|
+
$status: merged.status,
|
|
1308
|
+
$nextRun: merged.nextRunAt ?? null,
|
|
1309
|
+
$retrySlot: merged.retryScheduledFor ?? null,
|
|
1310
|
+
$expiresAt: merged.expiresAt ?? null,
|
|
1311
|
+
$updated: merged.updatedAt,
|
|
1312
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1313
|
+
$now: updated
|
|
1314
|
+
});
|
|
1315
|
+
if (res.changes !== 1)
|
|
1316
|
+
throw new Error("daemon lease lost");
|
|
1317
|
+
if (patch.status && patch.status !== "active") {
|
|
1318
|
+
const status = patch.status === "paused" ? "deferred" : "cancelled";
|
|
1319
|
+
this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
|
|
1320
|
+
}
|
|
1321
|
+
this.db.exec("COMMIT");
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
try {
|
|
1324
|
+
this.db.exec("ROLLBACK");
|
|
1325
|
+
} catch {}
|
|
1326
|
+
throw error;
|
|
1276
1327
|
}
|
|
1277
1328
|
const after = this.getLoop(id);
|
|
1278
1329
|
if (!after)
|
|
@@ -2503,14 +2554,40 @@ class Store {
|
|
|
2503
2554
|
}
|
|
2504
2555
|
return rows.map(rowToRun);
|
|
2505
2556
|
}
|
|
2557
|
+
deferLiveExpiredRun(id, now, opts = {}) {
|
|
2558
|
+
const updated = now.toISOString();
|
|
2559
|
+
const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
|
|
2560
|
+
this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
|
|
2561
|
+
WHERE id=$id AND status='running' AND lease_expires_at <= $now
|
|
2562
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
2563
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
2564
|
+
))`).run({
|
|
2565
|
+
$id: id,
|
|
2566
|
+
$deferredUntil: deferredUntil,
|
|
2567
|
+
$updated: updated,
|
|
2568
|
+
$now: updated,
|
|
2569
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2506
2572
|
recoverExpiredRunLeases(now = new Date, opts = {}) {
|
|
2507
|
-
const
|
|
2573
|
+
const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
|
|
2574
|
+
const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
|
|
2575
|
+
const rows = this.db.query(`SELECT * FROM loop_runs
|
|
2576
|
+
WHERE status = 'running' AND lease_expires_at <= ?
|
|
2577
|
+
ORDER BY lease_expires_at ASC
|
|
2578
|
+
LIMIT ?`).all(now.toISOString(), scanLimit);
|
|
2508
2579
|
const recovered = [];
|
|
2509
2580
|
for (const row of rows) {
|
|
2510
|
-
if (
|
|
2581
|
+
if (recovered.length >= limit)
|
|
2582
|
+
break;
|
|
2583
|
+
if (row.pid && isProcessAlive(row.pid)) {
|
|
2584
|
+
this.deferLiveExpiredRun(row.id, now, opts);
|
|
2511
2585
|
continue;
|
|
2512
|
-
|
|
2586
|
+
}
|
|
2587
|
+
if (this.hasLiveWorkflowStepProcesses(row.id)) {
|
|
2588
|
+
this.deferLiveExpiredRun(row.id, now, opts);
|
|
2513
2589
|
continue;
|
|
2590
|
+
}
|
|
2514
2591
|
const finished = now.toISOString();
|
|
2515
2592
|
this.db.exec("BEGIN IMMEDIATE");
|
|
2516
2593
|
try {
|
|
@@ -2899,6 +2976,7 @@ var AUTH_ENV_KEYS = [
|
|
|
2899
2976
|
"CODEWITH_HOME",
|
|
2900
2977
|
"CODEX_HOME",
|
|
2901
2978
|
"CURSOR_CONFIG_DIR",
|
|
2979
|
+
"CURSOR_API_KEY",
|
|
2902
2980
|
"OPENCODE_CONFIG_DIR",
|
|
2903
2981
|
"AICOPILOT_CONFIG_DIR",
|
|
2904
2982
|
"ANTHROPIC_API_KEY",
|
|
@@ -3028,6 +3106,10 @@ function assertSupportedAgentOptions(target) {
|
|
|
3028
3106
|
assertStringOption(target.model, `${target.provider}.model`);
|
|
3029
3107
|
assertStringOption(target.agent, `${target.provider}.agent`);
|
|
3030
3108
|
assertStringOption(target.authProfile, `${target.provider}.authProfile`);
|
|
3109
|
+
assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
|
|
3110
|
+
if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
|
|
3111
|
+
throw new Error(`${target.provider}.configIsolation must be safe or none`);
|
|
3112
|
+
}
|
|
3031
3113
|
if (target.authProfile !== undefined && target.provider !== "codewith") {
|
|
3032
3114
|
throw new Error(`${target.provider}.authProfile is supported only for codewith`);
|
|
3033
3115
|
}
|
|
@@ -3040,6 +3122,8 @@ function assertSupportedAgentOptions(target) {
|
|
|
3040
3122
|
if (target.sandbox && !["read-only", "workspace-write", "danger-full-access", "enabled", "disabled"].includes(target.sandbox)) {
|
|
3041
3123
|
throw new Error(`${target.provider}.sandbox is not supported: ${target.sandbox}`);
|
|
3042
3124
|
}
|
|
3125
|
+
if (target.provider === "codex" && target.agent !== undefined)
|
|
3126
|
+
throw new Error("codex.agent is not supported");
|
|
3043
3127
|
if (["codewith", "codex"].includes(target.provider)) {
|
|
3044
3128
|
if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
|
|
3045
3129
|
throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
|
|
@@ -3054,6 +3138,8 @@ function assertSupportedAgentOptions(target) {
|
|
|
3054
3138
|
return;
|
|
3055
3139
|
}
|
|
3056
3140
|
if (target.provider === "cursor") {
|
|
3141
|
+
if (target.variant !== undefined)
|
|
3142
|
+
throw new Error("cursor.variant is not supported");
|
|
3057
3143
|
if (target.permissionMode === "auto")
|
|
3058
3144
|
throw new Error("cursor.permissionMode auto is not supported; use provider-specific extraArgs for Cursor auto-review");
|
|
3059
3145
|
if (target.sandbox !== undefined && target.sandbox !== "enabled" && target.sandbox !== "disabled") {
|
|
@@ -3095,10 +3181,8 @@ function agentArgs(target) {
|
|
|
3095
3181
|
"set -eu",
|
|
3096
3182
|
"if command -v agent >/dev/null 2>&1; then",
|
|
3097
3183
|
' exec agent "$@"',
|
|
3098
|
-
"elif command -v cursor >/dev/null 2>&1; then",
|
|
3099
|
-
' exec cursor agent "$@"',
|
|
3100
3184
|
"else",
|
|
3101
|
-
" echo 'Executable not found in PATH:
|
|
3185
|
+
" echo 'Executable not found in PATH: agent' >&2",
|
|
3102
3186
|
" exit 127",
|
|
3103
3187
|
"fi"
|
|
3104
3188
|
].join(`
|
|
@@ -3139,7 +3223,7 @@ function agentArgs(target) {
|
|
|
3139
3223
|
case "codex":
|
|
3140
3224
|
if (target.variant)
|
|
3141
3225
|
args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
|
|
3142
|
-
args.push("exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
|
|
3226
|
+
args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
|
|
3143
3227
|
if (isolation === "safe")
|
|
3144
3228
|
args.push("--ignore-rules");
|
|
3145
3229
|
if (target.cwd)
|
|
@@ -3209,7 +3293,7 @@ function commandSpec(target) {
|
|
|
3209
3293
|
account: agentTarget.account,
|
|
3210
3294
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
3211
3295
|
nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
|
|
3212
|
-
preflightAnyOf: agentTarget.provider === "cursor" ? ["
|
|
3296
|
+
preflightAnyOf: agentTarget.provider === "cursor" ? ["agent"] : undefined,
|
|
3213
3297
|
stdin: agentTarget.prompt,
|
|
3214
3298
|
allowlist: agentTarget.allowlist
|
|
3215
3299
|
};
|
|
@@ -4592,12 +4676,14 @@ function claimDueRuns(deps) {
|
|
|
4592
4676
|
const claimed = [];
|
|
4593
4677
|
const skipped = [];
|
|
4594
4678
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
4679
|
+
if (maxClaims === 0)
|
|
4680
|
+
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
4595
4681
|
for (const loop of deps.store.dueLoops(now)) {
|
|
4596
|
-
if (claims.length >= maxClaims)
|
|
4682
|
+
if (claims.length + skipped.length >= maxClaims)
|
|
4597
4683
|
break;
|
|
4598
4684
|
const plan = dueSlots(loop, now);
|
|
4599
4685
|
for (const slot of plan.slots) {
|
|
4600
|
-
if (claims.length >= maxClaims)
|
|
4686
|
+
if (claims.length + skipped.length >= maxClaims)
|
|
4601
4687
|
break;
|
|
4602
4688
|
const run = claimSlot(deps, loop, slot);
|
|
4603
4689
|
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`
|
|
@@ -578,8 +578,8 @@ The adapters intentionally use provider command surfaces instead of pretending e
|
|
|
578
578
|
- Claude uses `claude -p --output-format json` and safe-mode/local setting sources by default.
|
|
579
579
|
- Codewith uses `codewith --ask-for-approval never exec --json --ephemeral --skip-git-repo-check`, with `--add-dir` for explicit extra writable directories.
|
|
580
580
|
- 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.
|
|
581
|
+
- 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.
|
|
582
|
+
- 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
583
|
- Agent prompts are sent through child stdin instead of argv so prompt bodies do not appear in process listings.
|
|
584
584
|
- 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
585
|
- `--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