@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/daemon/index.js
CHANGED
|
@@ -329,6 +329,13 @@ function optionalPositiveInteger(value, label) {
|
|
|
329
329
|
throw new Error(`${label} must be a positive integer`);
|
|
330
330
|
return value;
|
|
331
331
|
}
|
|
332
|
+
function optionalBoolean(value, label) {
|
|
333
|
+
if (value === undefined)
|
|
334
|
+
return;
|
|
335
|
+
if (typeof value !== "boolean")
|
|
336
|
+
throw new Error(`${label} must be a boolean`);
|
|
337
|
+
return value;
|
|
338
|
+
}
|
|
332
339
|
function optionalStringArray(value, label) {
|
|
333
340
|
if (value === undefined)
|
|
334
341
|
return;
|
|
@@ -340,6 +347,18 @@ function optionalStringArray(value, label) {
|
|
|
340
347
|
}).filter(Boolean);
|
|
341
348
|
return values.length ? values : undefined;
|
|
342
349
|
}
|
|
350
|
+
function optionalAccountRef(value, label) {
|
|
351
|
+
if (value === undefined)
|
|
352
|
+
return;
|
|
353
|
+
assertObject(value, label);
|
|
354
|
+
assertString(value.profile, `${label}.profile`);
|
|
355
|
+
if (value.tool !== undefined)
|
|
356
|
+
assertString(value.tool, `${label}.tool`);
|
|
357
|
+
return {
|
|
358
|
+
profile: value.profile.trim(),
|
|
359
|
+
tool: typeof value.tool === "string" ? value.tool.trim() : undefined
|
|
360
|
+
};
|
|
361
|
+
}
|
|
343
362
|
function normalizeGoalSpec(value, label = "goal") {
|
|
344
363
|
if (value === undefined)
|
|
345
364
|
return;
|
|
@@ -386,8 +405,21 @@ function validateTarget(value, label) {
|
|
|
386
405
|
if (value.provider !== "codewith")
|
|
387
406
|
throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
|
|
388
407
|
}
|
|
408
|
+
if (value.configIsolation !== undefined) {
|
|
409
|
+
assertString(value.configIsolation, `${label}.configIsolation`);
|
|
410
|
+
if (!["safe", "none"].includes(value.configIsolation))
|
|
411
|
+
throw new Error(`${label}.configIsolation must be safe or none`);
|
|
412
|
+
}
|
|
413
|
+
if (value.model !== undefined)
|
|
414
|
+
assertString(value.model, `${label}.model`);
|
|
389
415
|
if (value.variant !== undefined)
|
|
390
416
|
assertString(value.variant, `${label}.variant`);
|
|
417
|
+
if (value.agent !== undefined)
|
|
418
|
+
assertString(value.agent, `${label}.agent`);
|
|
419
|
+
if (value.provider === "cursor" && value.variant !== undefined)
|
|
420
|
+
throw new Error(`${label}.variant is not supported for provider cursor`);
|
|
421
|
+
if (value.provider === "codex" && value.agent !== undefined)
|
|
422
|
+
throw new Error(`${label}.agent is not supported for provider codex`);
|
|
391
423
|
optionalStringArray(value.addDirs, `${label}.addDirs`);
|
|
392
424
|
if (Array.isArray(value.addDirs) && value.addDirs.length > 0 && !["codewith", "codex"].includes(value.provider)) {
|
|
393
425
|
throw new Error(`${label}.addDirs is currently supported only for provider codewith or codex`);
|
|
@@ -484,8 +516,10 @@ function normalizeCreateWorkflowInput(input) {
|
|
|
484
516
|
id: step.id,
|
|
485
517
|
goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
|
|
486
518
|
target: validateTarget(step.target, `workflow.steps[${index}].target`),
|
|
487
|
-
dependsOn: step.dependsOn ?? [],
|
|
488
|
-
continueOnFailure: step.continueOnFailure ?? false
|
|
519
|
+
dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
|
|
520
|
+
continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
|
|
521
|
+
timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
|
|
522
|
+
account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
|
|
489
523
|
};
|
|
490
524
|
});
|
|
491
525
|
for (const step of steps) {
|
|
@@ -586,6 +620,9 @@ function writeWorkflowRunManifest(args) {
|
|
|
586
620
|
}
|
|
587
621
|
|
|
588
622
|
// src/lib/store.ts
|
|
623
|
+
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
624
|
+
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
625
|
+
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
589
626
|
function rowToLoop(row) {
|
|
590
627
|
return {
|
|
591
628
|
id: row.id,
|
|
@@ -852,6 +889,7 @@ class Store {
|
|
|
852
889
|
ensurePrivateStorePath(file);
|
|
853
890
|
this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
|
|
854
891
|
this.db = new Database(file);
|
|
892
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
855
893
|
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
856
894
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
857
895
|
if (file !== ":memory:")
|
|
@@ -915,6 +953,7 @@ class Store {
|
|
|
915
953
|
);
|
|
916
954
|
CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
|
|
917
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);
|
|
918
957
|
CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
|
|
919
958
|
|
|
920
959
|
CREATE TABLE IF NOT EXISTS daemon_lease (
|
|
@@ -1242,39 +1281,51 @@ class Store {
|
|
|
1242
1281
|
}
|
|
1243
1282
|
return rows.map(rowToLoop);
|
|
1244
1283
|
}
|
|
1245
|
-
dueLoops(now) {
|
|
1284
|
+
dueLoops(now, limit = 500) {
|
|
1246
1285
|
const rows = this.db.query(`SELECT * FROM loops
|
|
1247
1286
|
WHERE status = 'active'
|
|
1248
1287
|
AND archived_at IS NULL
|
|
1249
1288
|
AND next_run_at IS NOT NULL
|
|
1250
1289
|
AND next_run_at <= ?
|
|
1251
|
-
ORDER BY next_run_at ASC
|
|
1290
|
+
ORDER BY next_run_at ASC
|
|
1291
|
+
LIMIT ?`).all(now.toISOString(), limit);
|
|
1252
1292
|
return rows.map(rowToLoop);
|
|
1253
1293
|
}
|
|
1254
1294
|
updateLoop(id, patch, opts = {}) {
|
|
1255
|
-
const current = this.getLoop(id);
|
|
1256
|
-
if (!current)
|
|
1257
|
-
throw new Error(`loop not found: ${id}`);
|
|
1258
1295
|
const updated = (opts.now ?? new Date).toISOString();
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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;
|
|
1278
1329
|
}
|
|
1279
1330
|
const after = this.getLoop(id);
|
|
1280
1331
|
if (!after)
|
|
@@ -2505,14 +2556,40 @@ class Store {
|
|
|
2505
2556
|
}
|
|
2506
2557
|
return rows.map(rowToRun);
|
|
2507
2558
|
}
|
|
2559
|
+
deferLiveExpiredRun(id, now, opts = {}) {
|
|
2560
|
+
const updated = now.toISOString();
|
|
2561
|
+
const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
|
|
2562
|
+
this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
|
|
2563
|
+
WHERE id=$id AND status='running' AND lease_expires_at <= $now
|
|
2564
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
2565
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
2566
|
+
))`).run({
|
|
2567
|
+
$id: id,
|
|
2568
|
+
$deferredUntil: deferredUntil,
|
|
2569
|
+
$updated: updated,
|
|
2570
|
+
$now: updated,
|
|
2571
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2508
2574
|
recoverExpiredRunLeases(now = new Date, opts = {}) {
|
|
2509
|
-
const
|
|
2575
|
+
const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
|
|
2576
|
+
const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
|
|
2577
|
+
const rows = this.db.query(`SELECT * FROM loop_runs
|
|
2578
|
+
WHERE status = 'running' AND lease_expires_at <= ?
|
|
2579
|
+
ORDER BY lease_expires_at ASC
|
|
2580
|
+
LIMIT ?`).all(now.toISOString(), scanLimit);
|
|
2510
2581
|
const recovered = [];
|
|
2511
2582
|
for (const row of rows) {
|
|
2512
|
-
if (
|
|
2583
|
+
if (recovered.length >= limit)
|
|
2584
|
+
break;
|
|
2585
|
+
if (row.pid && isProcessAlive(row.pid)) {
|
|
2586
|
+
this.deferLiveExpiredRun(row.id, now, opts);
|
|
2513
2587
|
continue;
|
|
2514
|
-
|
|
2588
|
+
}
|
|
2589
|
+
if (this.hasLiveWorkflowStepProcesses(row.id)) {
|
|
2590
|
+
this.deferLiveExpiredRun(row.id, now, opts);
|
|
2515
2591
|
continue;
|
|
2592
|
+
}
|
|
2516
2593
|
const finished = now.toISOString();
|
|
2517
2594
|
this.db.exec("BEGIN IMMEDIATE");
|
|
2518
2595
|
try {
|
|
@@ -2909,6 +2986,7 @@ var AUTH_ENV_KEYS = [
|
|
|
2909
2986
|
"CODEWITH_HOME",
|
|
2910
2987
|
"CODEX_HOME",
|
|
2911
2988
|
"CURSOR_CONFIG_DIR",
|
|
2989
|
+
"CURSOR_API_KEY",
|
|
2912
2990
|
"OPENCODE_CONFIG_DIR",
|
|
2913
2991
|
"AICOPILOT_CONFIG_DIR",
|
|
2914
2992
|
"ANTHROPIC_API_KEY",
|
|
@@ -3038,6 +3116,10 @@ function assertSupportedAgentOptions(target) {
|
|
|
3038
3116
|
assertStringOption(target.model, `${target.provider}.model`);
|
|
3039
3117
|
assertStringOption(target.agent, `${target.provider}.agent`);
|
|
3040
3118
|
assertStringOption(target.authProfile, `${target.provider}.authProfile`);
|
|
3119
|
+
assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
|
|
3120
|
+
if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
|
|
3121
|
+
throw new Error(`${target.provider}.configIsolation must be safe or none`);
|
|
3122
|
+
}
|
|
3041
3123
|
if (target.authProfile !== undefined && target.provider !== "codewith") {
|
|
3042
3124
|
throw new Error(`${target.provider}.authProfile is supported only for codewith`);
|
|
3043
3125
|
}
|
|
@@ -3050,6 +3132,8 @@ function assertSupportedAgentOptions(target) {
|
|
|
3050
3132
|
if (target.sandbox && !["read-only", "workspace-write", "danger-full-access", "enabled", "disabled"].includes(target.sandbox)) {
|
|
3051
3133
|
throw new Error(`${target.provider}.sandbox is not supported: ${target.sandbox}`);
|
|
3052
3134
|
}
|
|
3135
|
+
if (target.provider === "codex" && target.agent !== undefined)
|
|
3136
|
+
throw new Error("codex.agent is not supported");
|
|
3053
3137
|
if (["codewith", "codex"].includes(target.provider)) {
|
|
3054
3138
|
if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
|
|
3055
3139
|
throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
|
|
@@ -3064,6 +3148,8 @@ function assertSupportedAgentOptions(target) {
|
|
|
3064
3148
|
return;
|
|
3065
3149
|
}
|
|
3066
3150
|
if (target.provider === "cursor") {
|
|
3151
|
+
if (target.variant !== undefined)
|
|
3152
|
+
throw new Error("cursor.variant is not supported");
|
|
3067
3153
|
if (target.permissionMode === "auto")
|
|
3068
3154
|
throw new Error("cursor.permissionMode auto is not supported; use provider-specific extraArgs for Cursor auto-review");
|
|
3069
3155
|
if (target.sandbox !== undefined && target.sandbox !== "enabled" && target.sandbox !== "disabled") {
|
|
@@ -3105,10 +3191,8 @@ function agentArgs(target) {
|
|
|
3105
3191
|
"set -eu",
|
|
3106
3192
|
"if command -v agent >/dev/null 2>&1; then",
|
|
3107
3193
|
' exec agent "$@"',
|
|
3108
|
-
"elif command -v cursor >/dev/null 2>&1; then",
|
|
3109
|
-
' exec cursor agent "$@"',
|
|
3110
3194
|
"else",
|
|
3111
|
-
" echo 'Executable not found in PATH:
|
|
3195
|
+
" echo 'Executable not found in PATH: agent' >&2",
|
|
3112
3196
|
" exit 127",
|
|
3113
3197
|
"fi"
|
|
3114
3198
|
].join(`
|
|
@@ -3149,7 +3233,7 @@ function agentArgs(target) {
|
|
|
3149
3233
|
case "codex":
|
|
3150
3234
|
if (target.variant)
|
|
3151
3235
|
args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
|
|
3152
|
-
args.push("exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
|
|
3236
|
+
args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
|
|
3153
3237
|
if (isolation === "safe")
|
|
3154
3238
|
args.push("--ignore-rules");
|
|
3155
3239
|
if (target.cwd)
|
|
@@ -3219,7 +3303,7 @@ function commandSpec(target) {
|
|
|
3219
3303
|
account: agentTarget.account,
|
|
3220
3304
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
3221
3305
|
nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
|
|
3222
|
-
preflightAnyOf: agentTarget.provider === "cursor" ? ["
|
|
3306
|
+
preflightAnyOf: agentTarget.provider === "cursor" ? ["agent"] : undefined,
|
|
3223
3307
|
stdin: agentTarget.prompt,
|
|
3224
3308
|
allowlist: agentTarget.allowlist
|
|
3225
3309
|
};
|
|
@@ -4602,12 +4686,14 @@ function claimDueRuns(deps) {
|
|
|
4602
4686
|
const claimed = [];
|
|
4603
4687
|
const skipped = [];
|
|
4604
4688
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
4689
|
+
if (maxClaims === 0)
|
|
4690
|
+
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
4605
4691
|
for (const loop of deps.store.dueLoops(now)) {
|
|
4606
|
-
if (claims.length >= maxClaims)
|
|
4692
|
+
if (claims.length + skipped.length >= maxClaims)
|
|
4607
4693
|
break;
|
|
4608
4694
|
const plan = dueSlots(loop, now);
|
|
4609
4695
|
for (const slot of plan.slots) {
|
|
4610
|
-
if (claims.length >= maxClaims)
|
|
4696
|
+
if (claims.length + skipped.length >= maxClaims)
|
|
4611
4697
|
break;
|
|
4612
4698
|
const run = claimSlot(deps, loop, slot);
|
|
4613
4699
|
if (!run)
|
|
@@ -5057,7 +5143,7 @@ function enableStartup(result) {
|
|
|
5057
5143
|
// package.json
|
|
5058
5144
|
var package_default = {
|
|
5059
5145
|
name: "@hasna/loops",
|
|
5060
|
-
version: "0.3.
|
|
5146
|
+
version: "0.3.48",
|
|
5061
5147
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5062
5148
|
type: "module",
|
|
5063
5149
|
main: "dist/index.js",
|
package/dist/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)
|
|
@@ -5868,35 +5954,17 @@ async function stopDaemon(opts = {}) {
|
|
|
5868
5954
|
// src/lib/doctor.ts
|
|
5869
5955
|
var PROVIDER_COMMANDS = [
|
|
5870
5956
|
"claude",
|
|
5871
|
-
"
|
|
5957
|
+
"agent",
|
|
5872
5958
|
"codewith",
|
|
5873
5959
|
"aicopilot",
|
|
5874
5960
|
"opencode",
|
|
5875
5961
|
"codex"
|
|
5876
5962
|
];
|
|
5877
5963
|
function hasCommand(command) {
|
|
5878
|
-
if (command === "cursor agent") {
|
|
5879
|
-
return hasCommand("cursor") || hasCommand("agent");
|
|
5880
|
-
}
|
|
5881
5964
|
const result = spawnSync3("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
|
|
5882
5965
|
return (result.status ?? 1) === 0;
|
|
5883
5966
|
}
|
|
5884
5967
|
function commandVersion(command) {
|
|
5885
|
-
if (command === "cursor agent") {
|
|
5886
|
-
const cursorResult = spawnSync3("cursor", ["agent", "--version"], {
|
|
5887
|
-
encoding: "utf8",
|
|
5888
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
5889
|
-
});
|
|
5890
|
-
if ((cursorResult.status ?? 1) === 0)
|
|
5891
|
-
return (cursorResult.stdout || cursorResult.stderr).trim().split(/\r?\n/)[0];
|
|
5892
|
-
const agentResult = spawnSync3("agent", ["--version"], {
|
|
5893
|
-
encoding: "utf8",
|
|
5894
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
5895
|
-
});
|
|
5896
|
-
if ((agentResult.status ?? 1) === 0)
|
|
5897
|
-
return (agentResult.stdout || agentResult.stderr).trim().split(/\r?\n/)[0];
|
|
5898
|
-
return;
|
|
5899
|
-
}
|
|
5900
5968
|
const result = spawnSync3(command, ["--version"], {
|
|
5901
5969
|
encoding: "utf8",
|
|
5902
5970
|
stdio: ["ignore", "pipe", "pipe"]
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -81,7 +81,7 @@ export declare class Store {
|
|
|
81
81
|
archived?: boolean;
|
|
82
82
|
includeArchived?: boolean;
|
|
83
83
|
}): Loop[];
|
|
84
|
-
dueLoops(now: Date): Loop[];
|
|
84
|
+
dueLoops(now: Date, limit?: number): Loop[];
|
|
85
85
|
updateLoop(id: string, patch: Partial<Pick<Loop, "status" | "nextRunAt" | "retryScheduledFor" | "expiresAt">>, opts?: DaemonLeaseFence): Loop;
|
|
86
86
|
renameLoop(id: string, name: string, opts?: DaemonLeaseFence): Loop;
|
|
87
87
|
archiveLoop(idOrName: string): Loop;
|
|
@@ -195,7 +195,11 @@ export declare class Store {
|
|
|
195
195
|
status?: RunStatus;
|
|
196
196
|
limit?: number;
|
|
197
197
|
}): LoopRun[];
|
|
198
|
-
|
|
198
|
+
private deferLiveExpiredRun;
|
|
199
|
+
recoverExpiredRunLeases(now?: Date, opts?: DaemonLeaseFence & {
|
|
200
|
+
limit?: number;
|
|
201
|
+
scanLimit?: number;
|
|
202
|
+
}): LoopRun[];
|
|
199
203
|
expireLoops(now?: Date, opts?: DaemonLeaseFence): Loop[];
|
|
200
204
|
countLoops(status?: LoopStatus, opts?: {
|
|
201
205
|
archived?: boolean;
|