@hasna/loops 0.3.46 → 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 +184 -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/cli/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 {
|
|
@@ -3025,6 +3102,7 @@ var AUTH_ENV_KEYS = [
|
|
|
3025
3102
|
"CODEWITH_HOME",
|
|
3026
3103
|
"CODEX_HOME",
|
|
3027
3104
|
"CURSOR_CONFIG_DIR",
|
|
3105
|
+
"CURSOR_API_KEY",
|
|
3028
3106
|
"OPENCODE_CONFIG_DIR",
|
|
3029
3107
|
"AICOPILOT_CONFIG_DIR",
|
|
3030
3108
|
"ANTHROPIC_API_KEY",
|
|
@@ -3154,6 +3232,10 @@ function assertSupportedAgentOptions(target) {
|
|
|
3154
3232
|
assertStringOption(target.model, `${target.provider}.model`);
|
|
3155
3233
|
assertStringOption(target.agent, `${target.provider}.agent`);
|
|
3156
3234
|
assertStringOption(target.authProfile, `${target.provider}.authProfile`);
|
|
3235
|
+
assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
|
|
3236
|
+
if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
|
|
3237
|
+
throw new Error(`${target.provider}.configIsolation must be safe or none`);
|
|
3238
|
+
}
|
|
3157
3239
|
if (target.authProfile !== undefined && target.provider !== "codewith") {
|
|
3158
3240
|
throw new Error(`${target.provider}.authProfile is supported only for codewith`);
|
|
3159
3241
|
}
|
|
@@ -3166,6 +3248,8 @@ function assertSupportedAgentOptions(target) {
|
|
|
3166
3248
|
if (target.sandbox && !["read-only", "workspace-write", "danger-full-access", "enabled", "disabled"].includes(target.sandbox)) {
|
|
3167
3249
|
throw new Error(`${target.provider}.sandbox is not supported: ${target.sandbox}`);
|
|
3168
3250
|
}
|
|
3251
|
+
if (target.provider === "codex" && target.agent !== undefined)
|
|
3252
|
+
throw new Error("codex.agent is not supported");
|
|
3169
3253
|
if (["codewith", "codex"].includes(target.provider)) {
|
|
3170
3254
|
if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
|
|
3171
3255
|
throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
|
|
@@ -3180,6 +3264,8 @@ function assertSupportedAgentOptions(target) {
|
|
|
3180
3264
|
return;
|
|
3181
3265
|
}
|
|
3182
3266
|
if (target.provider === "cursor") {
|
|
3267
|
+
if (target.variant !== undefined)
|
|
3268
|
+
throw new Error("cursor.variant is not supported");
|
|
3183
3269
|
if (target.permissionMode === "auto")
|
|
3184
3270
|
throw new Error("cursor.permissionMode auto is not supported; use provider-specific extraArgs for Cursor auto-review");
|
|
3185
3271
|
if (target.sandbox !== undefined && target.sandbox !== "enabled" && target.sandbox !== "disabled") {
|
|
@@ -3221,10 +3307,8 @@ function agentArgs(target) {
|
|
|
3221
3307
|
"set -eu",
|
|
3222
3308
|
"if command -v agent >/dev/null 2>&1; then",
|
|
3223
3309
|
' exec agent "$@"',
|
|
3224
|
-
"elif command -v cursor >/dev/null 2>&1; then",
|
|
3225
|
-
' exec cursor agent "$@"',
|
|
3226
3310
|
"else",
|
|
3227
|
-
" echo 'Executable not found in PATH:
|
|
3311
|
+
" echo 'Executable not found in PATH: agent' >&2",
|
|
3228
3312
|
" exit 127",
|
|
3229
3313
|
"fi"
|
|
3230
3314
|
].join(`
|
|
@@ -3265,7 +3349,7 @@ function agentArgs(target) {
|
|
|
3265
3349
|
case "codex":
|
|
3266
3350
|
if (target.variant)
|
|
3267
3351
|
args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
|
|
3268
|
-
args.push("exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
|
|
3352
|
+
args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
|
|
3269
3353
|
if (isolation === "safe")
|
|
3270
3354
|
args.push("--ignore-rules");
|
|
3271
3355
|
if (target.cwd)
|
|
@@ -3335,7 +3419,7 @@ function commandSpec(target) {
|
|
|
3335
3419
|
account: agentTarget.account,
|
|
3336
3420
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
3337
3421
|
nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
|
|
3338
|
-
preflightAnyOf: agentTarget.provider === "cursor" ? ["
|
|
3422
|
+
preflightAnyOf: agentTarget.provider === "cursor" ? ["agent"] : undefined,
|
|
3339
3423
|
stdin: agentTarget.prompt,
|
|
3340
3424
|
allowlist: agentTarget.allowlist
|
|
3341
3425
|
};
|
|
@@ -4718,12 +4802,14 @@ function claimDueRuns(deps) {
|
|
|
4718
4802
|
const claimed = [];
|
|
4719
4803
|
const skipped = [];
|
|
4720
4804
|
const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
|
|
4805
|
+
if (maxClaims === 0)
|
|
4806
|
+
return { claims, claimed, completed: [], skipped, recovered, expired };
|
|
4721
4807
|
for (const loop of deps.store.dueLoops(now)) {
|
|
4722
|
-
if (claims.length >= maxClaims)
|
|
4808
|
+
if (claims.length + skipped.length >= maxClaims)
|
|
4723
4809
|
break;
|
|
4724
4810
|
const plan = dueSlots(loop, now);
|
|
4725
4811
|
for (const slot of plan.slots) {
|
|
4726
|
-
if (claims.length >= maxClaims)
|
|
4812
|
+
if (claims.length + skipped.length >= maxClaims)
|
|
4727
4813
|
break;
|
|
4728
4814
|
const run = claimSlot(deps, loop, slot);
|
|
4729
4815
|
if (!run)
|
|
@@ -5179,35 +5265,17 @@ import { spawnSync as spawnSync4 } from "child_process";
|
|
|
5179
5265
|
import { accessSync as accessSync2, constants as constants2 } from "fs";
|
|
5180
5266
|
var PROVIDER_COMMANDS = [
|
|
5181
5267
|
"claude",
|
|
5182
|
-
"
|
|
5268
|
+
"agent",
|
|
5183
5269
|
"codewith",
|
|
5184
5270
|
"aicopilot",
|
|
5185
5271
|
"opencode",
|
|
5186
5272
|
"codex"
|
|
5187
5273
|
];
|
|
5188
5274
|
function hasCommand(command) {
|
|
5189
|
-
if (command === "cursor agent") {
|
|
5190
|
-
return hasCommand("cursor") || hasCommand("agent");
|
|
5191
|
-
}
|
|
5192
5275
|
const result = spawnSync4("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
|
|
5193
5276
|
return (result.status ?? 1) === 0;
|
|
5194
5277
|
}
|
|
5195
5278
|
function commandVersion(command) {
|
|
5196
|
-
if (command === "cursor agent") {
|
|
5197
|
-
const cursorResult = spawnSync4("cursor", ["agent", "--version"], {
|
|
5198
|
-
encoding: "utf8",
|
|
5199
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
5200
|
-
});
|
|
5201
|
-
if ((cursorResult.status ?? 1) === 0)
|
|
5202
|
-
return (cursorResult.stdout || cursorResult.stderr).trim().split(/\r?\n/)[0];
|
|
5203
|
-
const agentResult = spawnSync4("agent", ["--version"], {
|
|
5204
|
-
encoding: "utf8",
|
|
5205
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
5206
|
-
});
|
|
5207
|
-
if ((agentResult.status ?? 1) === 0)
|
|
5208
|
-
return (agentResult.stdout || agentResult.stderr).trim().split(/\r?\n/)[0];
|
|
5209
|
-
return;
|
|
5210
|
-
}
|
|
5211
5279
|
const result = spawnSync4(command, ["--version"], {
|
|
5212
5280
|
encoding: "utf8",
|
|
5213
5281
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -5743,7 +5811,7 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
5743
5811
|
// package.json
|
|
5744
5812
|
var package_default = {
|
|
5745
5813
|
name: "@hasna/loops",
|
|
5746
|
-
version: "0.3.
|
|
5814
|
+
version: "0.3.48",
|
|
5747
5815
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5748
5816
|
type: "module",
|
|
5749
5817
|
main: "dist/index.js",
|
|
@@ -6809,6 +6877,38 @@ function preflightFailed(error, context) {
|
|
|
6809
6877
|
});
|
|
6810
6878
|
process.exit(1);
|
|
6811
6879
|
}
|
|
6880
|
+
function validationFailed(error, context) {
|
|
6881
|
+
if (!isJson())
|
|
6882
|
+
throw error;
|
|
6883
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6884
|
+
print({
|
|
6885
|
+
ok: false,
|
|
6886
|
+
created: false,
|
|
6887
|
+
validation: {
|
|
6888
|
+
ok: false,
|
|
6889
|
+
error: redact(message, 320)
|
|
6890
|
+
},
|
|
6891
|
+
...context
|
|
6892
|
+
});
|
|
6893
|
+
process.exit(1);
|
|
6894
|
+
}
|
|
6895
|
+
function validateLoopTargetForStorage(target, context) {
|
|
6896
|
+
try {
|
|
6897
|
+
workflowBodyFromJson({
|
|
6898
|
+
name: "loop-target-validation",
|
|
6899
|
+
steps: [{ id: "target", target }]
|
|
6900
|
+
});
|
|
6901
|
+
} catch (error) {
|
|
6902
|
+
validationFailed(error, context);
|
|
6903
|
+
}
|
|
6904
|
+
}
|
|
6905
|
+
function normalizeWorkflowForStorage(body, context) {
|
|
6906
|
+
try {
|
|
6907
|
+
return workflowBodyFromJson(body);
|
|
6908
|
+
} catch (error) {
|
|
6909
|
+
validationFailed(error, context);
|
|
6910
|
+
}
|
|
6911
|
+
}
|
|
6812
6912
|
function preflightLoopTarget(target, context, metadata, opts) {
|
|
6813
6913
|
try {
|
|
6814
6914
|
return preflightTarget(target, metadata, opts);
|
|
@@ -7508,7 +7608,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
7508
7608
|
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
|
|
7509
7609
|
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
7510
7610
|
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
7511
|
-
const idempotencyKey = `todos-task:${taskId}
|
|
7611
|
+
const idempotencyKey = `todos-task:${taskId}`;
|
|
7512
7612
|
const idempotencySuffix = stableSuffix(idempotencyKey);
|
|
7513
7613
|
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
7514
7614
|
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
@@ -7546,7 +7646,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
7546
7646
|
const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
|
|
7547
7647
|
const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
|
|
7548
7648
|
const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
|
|
7549
|
-
|
|
7649
|
+
let workflowBody = renderTodosTaskWorkerVerifierWorkflow({
|
|
7550
7650
|
taskId,
|
|
7551
7651
|
taskTitle,
|
|
7552
7652
|
taskDescription,
|
|
@@ -7578,6 +7678,11 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
7578
7678
|
});
|
|
7579
7679
|
workflowBody.name = workflowName;
|
|
7580
7680
|
workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
7681
|
+
workflowBody = normalizeWorkflowForStorage(workflowBody, {
|
|
7682
|
+
name: workflowName,
|
|
7683
|
+
type: "todos-task-event-workflow",
|
|
7684
|
+
event: event.id
|
|
7685
|
+
});
|
|
7581
7686
|
const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
|
|
7582
7687
|
const invocationInput = {
|
|
7583
7688
|
templateId: "todos-task-worker-verifier",
|
|
@@ -7757,7 +7862,7 @@ function routeGenericEvent(event, opts) {
|
|
|
7757
7862
|
const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
|
|
7758
7863
|
const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
|
|
7759
7864
|
const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
|
|
7760
|
-
|
|
7865
|
+
let workflowBody = renderEventWorkerVerifierWorkflow({
|
|
7761
7866
|
eventId: event.id,
|
|
7762
7867
|
eventType: event.type,
|
|
7763
7868
|
eventSource: event.source,
|
|
@@ -7789,6 +7894,11 @@ function routeGenericEvent(event, opts) {
|
|
|
7789
7894
|
});
|
|
7790
7895
|
workflowBody.name = workflowName;
|
|
7791
7896
|
workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
7897
|
+
workflowBody = normalizeWorkflowForStorage(workflowBody, {
|
|
7898
|
+
name: workflowName,
|
|
7899
|
+
type: "generic-event-workflow",
|
|
7900
|
+
event: event.id
|
|
7901
|
+
});
|
|
7792
7902
|
const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
|
|
7793
7903
|
const invocationInput = {
|
|
7794
7904
|
templateId: "event-worker-verifier",
|
|
@@ -8171,6 +8281,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
|
|
|
8171
8281
|
preflight: runtimePreflightFromOpts(opts)
|
|
8172
8282
|
};
|
|
8173
8283
|
const input = baseCreateInput(name, opts, target);
|
|
8284
|
+
validateLoopTargetForStorage(input.target, { name, type: "agent", provider });
|
|
8174
8285
|
const preflight = opts.preflight ? preflightLoopTarget(input.target, { name, type: "agent", provider }, { loopName: name }, { machine: input.machine }) : undefined;
|
|
8175
8286
|
const loop = store.createLoop(input);
|
|
8176
8287
|
printCreatedLoop(loop, `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`, preflight);
|
|
@@ -8204,8 +8315,12 @@ var goal = program.command("goal").description("inspect goal runs");
|
|
|
8204
8315
|
function addRouteEventOptions(command) {
|
|
8205
8316
|
return command.option("--event-file <file>", "read event envelope JSON from a file instead of stdin/HASNA_EVENT_JSON").option("--event-json <json>", "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix").option("--preflight", "check generated workflow steps before storing the workflow loop");
|
|
8206
8317
|
}
|
|
8207
|
-
function addTodosDrainOptions(command) {
|
|
8208
|
-
|
|
8318
|
+
function addTodosDrainOptions(command, opts = {}) {
|
|
8319
|
+
let configured = command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", opts.preflightDescription ?? "check generated workflow steps before storing workflow loops");
|
|
8320
|
+
if (opts.includeDryRun ?? true) {
|
|
8321
|
+
configured = configured.option("--dry-run", "preview selected tasks and generated workflow loops without storing anything");
|
|
8322
|
+
}
|
|
8323
|
+
return configured;
|
|
8209
8324
|
}
|
|
8210
8325
|
function routeEventByKind(kind, event, opts) {
|
|
8211
8326
|
if (kind === "todos-task")
|
|
@@ -8446,7 +8561,10 @@ addTodosDrainOptions(routes.command("drain <kind>").description("drain a durable
|
|
|
8446
8561
|
throw new Error("route drain currently supports kind todos-task");
|
|
8447
8562
|
drainTodosTaskRoutes(opts);
|
|
8448
8563
|
});
|
|
8449
|
-
addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>").description("schedule a deterministic route drain loop")
|
|
8564
|
+
addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>").description("schedule a deterministic route drain loop"), {
|
|
8565
|
+
includeDryRun: false,
|
|
8566
|
+
preflightDescription: "forward generated workflow preflight checks into each future drain run"
|
|
8567
|
+
})).action((kind, name, opts) => {
|
|
8450
8568
|
if (kind !== "todos-task")
|
|
8451
8569
|
throw new Error("route schedule currently supports kind todos-task");
|
|
8452
8570
|
const store = new Store;
|
|
@@ -8473,288 +8591,13 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
|
|
|
8473
8591
|
print(result.value, result.human);
|
|
8474
8592
|
});
|
|
8475
8593
|
var eventsDrain = events.command("drain").description("drain durable source queues into bounded OpenLoops workflows");
|
|
8476
|
-
eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops")
|
|
8477
|
-
|
|
8478
|
-
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
8479
|
-
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
8480
|
-
const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
|
|
8481
|
-
const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
|
|
8482
|
-
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
|
|
8483
|
-
const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
|
|
8484
|
-
const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
|
|
8485
|
-
const ready = loadReadyTodosTasks(opts, scanLimit);
|
|
8486
|
-
const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
|
|
8487
|
-
projectId: opts.todosProjectId,
|
|
8488
|
-
taskListId: taskListFilter,
|
|
8489
|
-
projectPathPrefix: opts.projectPathPrefix,
|
|
8490
|
-
tags: requiredTags
|
|
8491
|
-
}));
|
|
8492
|
-
const candidates = filteredCandidates.slice(0, candidateLimit);
|
|
8493
|
-
const results = [];
|
|
8494
|
-
let created = 0;
|
|
8495
|
-
for (const task of candidates) {
|
|
8496
|
-
if (created >= maxDispatch)
|
|
8497
|
-
break;
|
|
8498
|
-
const event = taskDrainEvent(task);
|
|
8499
|
-
const result = routeTodosTaskEvent(event, opts);
|
|
8500
|
-
results.push(result);
|
|
8501
|
-
if (result.kind === "created" && !opts.dryRun)
|
|
8502
|
-
created += 1;
|
|
8503
|
-
if (result.kind === "created" && opts.dryRun)
|
|
8504
|
-
created += 1;
|
|
8505
|
-
}
|
|
8506
|
-
const report = {
|
|
8507
|
-
drainedAt: new Date().toISOString(),
|
|
8508
|
-
todosProject,
|
|
8509
|
-
todosProjectId: opts.todosProjectId,
|
|
8510
|
-
taskList: opts.taskList,
|
|
8511
|
-
taskListId: taskListFilter,
|
|
8512
|
-
projectPathPrefix: opts.projectPathPrefix,
|
|
8513
|
-
tags: requiredTags,
|
|
8514
|
-
limit: candidateLimit,
|
|
8515
|
-
scanLimit,
|
|
8516
|
-
filtersApplied: hasPostFilters,
|
|
8517
|
-
scanned: ready.length,
|
|
8518
|
-
candidates: candidates.length,
|
|
8519
|
-
filteredCandidates: filteredCandidates.length,
|
|
8520
|
-
scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
|
|
8521
|
-
considered: results.length,
|
|
8522
|
-
created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
|
|
8523
|
-
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
8524
|
-
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
8525
|
-
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
8526
|
-
maxDispatch,
|
|
8527
|
-
source: "todos ready",
|
|
8528
|
-
dryRun: Boolean(opts.dryRun),
|
|
8529
|
-
results: results.map((result) => ({ kind: result.kind, ...result.value }))
|
|
8530
|
-
};
|
|
8531
|
-
const evidencePath = writeRouteEvidence("todos-task-drain", report, opts.evidenceDir);
|
|
8532
|
-
const output = opts.compact ? {
|
|
8533
|
-
drainedAt: report.drainedAt,
|
|
8534
|
-
todosProject: report.todosProject,
|
|
8535
|
-
todosProjectId: report.todosProjectId,
|
|
8536
|
-
taskList: report.taskList,
|
|
8537
|
-
taskListId: report.taskListId,
|
|
8538
|
-
projectPathPrefix: report.projectPathPrefix,
|
|
8539
|
-
tags: report.tags,
|
|
8540
|
-
limit: report.limit,
|
|
8541
|
-
scanLimit: report.scanLimit,
|
|
8542
|
-
filtersApplied: report.filtersApplied,
|
|
8543
|
-
scanned: report.scanned,
|
|
8544
|
-
candidates: report.candidates,
|
|
8545
|
-
filteredCandidates: report.filteredCandidates,
|
|
8546
|
-
scanExhausted: report.scanExhausted,
|
|
8547
|
-
considered: report.considered,
|
|
8548
|
-
created: report.created,
|
|
8549
|
-
deduped: report.deduped,
|
|
8550
|
-
throttled: report.throttled,
|
|
8551
|
-
skipped: report.skipped,
|
|
8552
|
-
maxDispatch: report.maxDispatch,
|
|
8553
|
-
source: report.source,
|
|
8554
|
-
dryRun: report.dryRun,
|
|
8555
|
-
evidencePath,
|
|
8556
|
-
results: results.map(compactDrainResult)
|
|
8557
|
-
} : { ...report, evidencePath };
|
|
8558
|
-
print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
|
|
8594
|
+
addTodosDrainOptions(eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops")).action((opts) => {
|
|
8595
|
+
drainTodosTaskRoutes(opts);
|
|
8559
8596
|
});
|
|
8560
8597
|
eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
8561
8598
|
const event = await readEventEnvelopeFromStdin();
|
|
8562
|
-
const
|
|
8563
|
-
|
|
8564
|
-
const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd", "repo_path", "repoPath"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "project_path", "projectPath", "project_canonical_path", "cwd", "repo_path", "repoPath"]) ?? process.cwd();
|
|
8565
|
-
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
|
|
8566
|
-
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
8567
|
-
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
8568
|
-
const eventSuffix = event.id.slice(0, 8);
|
|
8569
|
-
const source = slugSegment2(event.source, "source");
|
|
8570
|
-
const type = slugSegment2(event.type, "type");
|
|
8571
|
-
const workflowName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
|
|
8572
|
-
const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
|
|
8573
|
-
const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
|
|
8574
|
-
const provider = opts.provider;
|
|
8575
|
-
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
|
|
8576
|
-
throw new Error("unsupported provider");
|
|
8577
|
-
const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode }, provider);
|
|
8578
|
-
const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
|
|
8579
|
-
const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
|
|
8580
|
-
const workflowBody = renderEventWorkerVerifierWorkflow({
|
|
8581
|
-
eventId: event.id,
|
|
8582
|
-
eventType: event.type,
|
|
8583
|
-
eventSource: event.source,
|
|
8584
|
-
eventSubject: stringField(event.subject),
|
|
8585
|
-
eventMessage: stringField(event.message),
|
|
8586
|
-
eventJson: JSON.stringify(event),
|
|
8587
|
-
projectPath,
|
|
8588
|
-
routeProjectPath,
|
|
8589
|
-
projectGroup,
|
|
8590
|
-
provider,
|
|
8591
|
-
authProfile,
|
|
8592
|
-
authProfilePool: splitList(opts.authProfilePool),
|
|
8593
|
-
workerAuthProfile: opts.workerAuthProfile,
|
|
8594
|
-
verifierAuthProfile: opts.verifierAuthProfile,
|
|
8595
|
-
account: accountFromOpts(opts),
|
|
8596
|
-
accountPool: accountPoolFromOpts(opts),
|
|
8597
|
-
workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
|
|
8598
|
-
verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
|
|
8599
|
-
model: opts.model,
|
|
8600
|
-
variant: opts.variant,
|
|
8601
|
-
agent: opts.agent,
|
|
8602
|
-
addDirs: listFromRepeatedOpts(opts.addDir),
|
|
8603
|
-
permissionMode,
|
|
8604
|
-
sandbox,
|
|
8605
|
-
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
8606
|
-
worktreeMode: opts.worktreeMode,
|
|
8607
|
-
worktreeRoot: opts.worktreeRoot,
|
|
8608
|
-
worktreeBranchPrefix: opts.worktreeBranchPrefix
|
|
8609
|
-
});
|
|
8610
|
-
workflowBody.name = workflowName;
|
|
8611
|
-
workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
8612
|
-
const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
|
|
8613
|
-
const invocationInput = {
|
|
8614
|
-
templateId: "event-worker-verifier",
|
|
8615
|
-
sourceRef: {
|
|
8616
|
-
kind: "event",
|
|
8617
|
-
id: event.id,
|
|
8618
|
-
dedupeKey: idempotencyKey,
|
|
8619
|
-
raw: { source: event.source, type: event.type }
|
|
8620
|
-
},
|
|
8621
|
-
subjectRef: {
|
|
8622
|
-
kind: "event",
|
|
8623
|
-
id: stringField(event.subject) ?? event.id,
|
|
8624
|
-
path: routeProjectPath,
|
|
8625
|
-
raw: { message: stringField(event.message) }
|
|
8626
|
-
},
|
|
8627
|
-
intent: "route",
|
|
8628
|
-
scope: {
|
|
8629
|
-
projectPath: routeProjectPath,
|
|
8630
|
-
projectGroup,
|
|
8631
|
-
worktreePolicy: opts.worktreeMode ?? "auto",
|
|
8632
|
-
permissions: permissionMode,
|
|
8633
|
-
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
8634
|
-
accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
|
|
8635
|
-
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
8636
|
-
},
|
|
8637
|
-
outputPolicy: {
|
|
8638
|
-
report: "always",
|
|
8639
|
-
createTask: "on_failure"
|
|
8640
|
-
}
|
|
8641
|
-
};
|
|
8642
|
-
const workItemInput = {
|
|
8643
|
-
routeKey: "generic-event",
|
|
8644
|
-
idempotencyKey,
|
|
8645
|
-
invocationId: "<created-invocation-id>",
|
|
8646
|
-
sourceType: event.type,
|
|
8647
|
-
sourceRef: event.id,
|
|
8648
|
-
subjectRef: stringField(event.subject) ?? event.id,
|
|
8649
|
-
projectKey: routeProjectPath,
|
|
8650
|
-
projectGroup,
|
|
8651
|
-
priority: 0,
|
|
8652
|
-
status: "queued"
|
|
8653
|
-
};
|
|
8654
|
-
const loopInput = {
|
|
8655
|
-
name: loopName,
|
|
8656
|
-
description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
|
|
8657
|
-
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
8658
|
-
target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
|
|
8659
|
-
overlap: "skip",
|
|
8660
|
-
maxAttempts: 1,
|
|
8661
|
-
retryDelayMs: 60000,
|
|
8662
|
-
leaseMs: 90 * 60000
|
|
8663
|
-
};
|
|
8664
|
-
if (opts.dryRun) {
|
|
8665
|
-
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
8666
|
-
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
|
|
8667
|
-
name: workflowBody.name,
|
|
8668
|
-
type: "generic-event-workflow",
|
|
8669
|
-
event: event.id
|
|
8670
|
-
}, {}) : undefined;
|
|
8671
|
-
print({ event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight }, `dry-run ${loopName}`);
|
|
8672
|
-
return;
|
|
8673
|
-
}
|
|
8674
|
-
const store = new Store;
|
|
8675
|
-
try {
|
|
8676
|
-
const workflowPreflightSpec = workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
8677
|
-
generatedRouteSandboxPreflight(workflowPreflightSpec);
|
|
8678
|
-
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
8679
|
-
name: workflowBody.name,
|
|
8680
|
-
type: "generic-event-workflow",
|
|
8681
|
-
event: event.id
|
|
8682
|
-
}, {}) : undefined;
|
|
8683
|
-
const outcome = store.writeTransaction(() => {
|
|
8684
|
-
const invocation = store.createWorkflowInvocation(invocationInput);
|
|
8685
|
-
const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
|
|
8686
|
-
if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
|
|
8687
|
-
const existingLoop = store.getLoop(existingItem.loopId);
|
|
8688
|
-
const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
8689
|
-
return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
|
|
8690
|
-
}
|
|
8691
|
-
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
8692
|
-
const workItem = store.upsertWorkflowWorkItem({
|
|
8693
|
-
...workItemInput,
|
|
8694
|
-
invocationId: invocation.id,
|
|
8695
|
-
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
8696
|
-
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
8697
|
-
});
|
|
8698
|
-
if (throttle && !throttle.allowed)
|
|
8699
|
-
return { kind: "throttled", invocation, workItem, throttle };
|
|
8700
|
-
const workflow = routeWorkflowForStorage(store, workflowBody);
|
|
8701
|
-
const loop = store.createLoop({
|
|
8702
|
-
...loopInput,
|
|
8703
|
-
target: {
|
|
8704
|
-
type: "workflow",
|
|
8705
|
-
workflowId: workflow.id,
|
|
8706
|
-
input: {
|
|
8707
|
-
workflowInvocationId: invocation.id,
|
|
8708
|
-
workflowWorkItemId: workItem.id
|
|
8709
|
-
}
|
|
8710
|
-
}
|
|
8711
|
-
});
|
|
8712
|
-
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
|
|
8713
|
-
return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
|
|
8714
|
-
});
|
|
8715
|
-
if (outcome.kind === "deduped") {
|
|
8716
|
-
print({
|
|
8717
|
-
deduped: true,
|
|
8718
|
-
idempotencyKey,
|
|
8719
|
-
dedupedBy: "work-item",
|
|
8720
|
-
event,
|
|
8721
|
-
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8722
|
-
workItem: publicWorkflowWorkItem(outcome.existingItem),
|
|
8723
|
-
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
8724
|
-
loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
|
|
8725
|
-
}, `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`);
|
|
8726
|
-
return;
|
|
8727
|
-
}
|
|
8728
|
-
if (outcome.kind === "throttled") {
|
|
8729
|
-
print({
|
|
8730
|
-
skipped: true,
|
|
8731
|
-
queuedAtSource: true,
|
|
8732
|
-
reason: outcome.throttle.reason,
|
|
8733
|
-
idempotencyKey,
|
|
8734
|
-
event,
|
|
8735
|
-
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8736
|
-
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
8737
|
-
throttle: outcome.throttle,
|
|
8738
|
-
workflow: workflowBody,
|
|
8739
|
-
loop: loopInput
|
|
8740
|
-
}, `skipped event ${event.id}: ${outcome.throttle.reason}`);
|
|
8741
|
-
return;
|
|
8742
|
-
}
|
|
8743
|
-
print({
|
|
8744
|
-
deduped: false,
|
|
8745
|
-
idempotencyKey,
|
|
8746
|
-
event,
|
|
8747
|
-
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8748
|
-
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
8749
|
-
workflow: publicWorkflow(outcome.workflow),
|
|
8750
|
-
loop: publicLoop(outcome.loop),
|
|
8751
|
-
throttle: outcome.throttle,
|
|
8752
|
-
sandboxPreflight,
|
|
8753
|
-
preflight
|
|
8754
|
-
}, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
|
|
8755
|
-
} finally {
|
|
8756
|
-
store.close();
|
|
8757
|
-
}
|
|
8599
|
+
const result = routeGenericEvent(event, opts);
|
|
8600
|
+
print(result.value, result.human);
|
|
8758
8601
|
});
|
|
8759
8602
|
goal.command("show <idOrName>").description("show a goal or configured loop/workflow goal").action((idOrName) => {
|
|
8760
8603
|
const store = new Store;
|