@hasna/loops 0.3.26 → 0.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +844 -158
- package/dist/daemon/index.js +50 -1
- package/dist/index.js +337 -24
- package/dist/lib/store.d.ts +1 -0
- package/dist/lib/store.js +49 -0
- package/dist/lib/templates.d.ts +16 -1
- package/dist/sdk/index.js +49 -0
- package/dist/types.d.ts +22 -0
- package/docs/USAGE.md +87 -3
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -418,6 +418,42 @@ function validateTarget(value, label) {
|
|
|
418
418
|
throw new Error(`${label}.allowlist.enforcement must be metadata_only`);
|
|
419
419
|
}
|
|
420
420
|
}
|
|
421
|
+
if (value.worktree !== undefined) {
|
|
422
|
+
assertObject(value.worktree, `${label}.worktree`);
|
|
423
|
+
assertString(value.worktree.mode, `${label}.worktree.mode`);
|
|
424
|
+
const modes = ["auto", "required", "off", "main"];
|
|
425
|
+
if (!modes.includes(value.worktree.mode))
|
|
426
|
+
throw new Error(`${label}.worktree.mode must be one of ${modes.join(", ")}`);
|
|
427
|
+
if (typeof value.worktree.enabled !== "boolean")
|
|
428
|
+
throw new Error(`${label}.worktree.enabled must be a boolean`);
|
|
429
|
+
assertString(value.worktree.originalCwd, `${label}.worktree.originalCwd`);
|
|
430
|
+
assertString(value.worktree.cwd, `${label}.worktree.cwd`);
|
|
431
|
+
if (value.worktree.repoRoot !== undefined)
|
|
432
|
+
assertString(value.worktree.repoRoot, `${label}.worktree.repoRoot`);
|
|
433
|
+
if (value.worktree.root !== undefined)
|
|
434
|
+
assertString(value.worktree.root, `${label}.worktree.root`);
|
|
435
|
+
if (value.worktree.path !== undefined)
|
|
436
|
+
assertString(value.worktree.path, `${label}.worktree.path`);
|
|
437
|
+
if (value.worktree.branch !== undefined)
|
|
438
|
+
assertString(value.worktree.branch, `${label}.worktree.branch`);
|
|
439
|
+
if (value.worktree.reason !== undefined)
|
|
440
|
+
assertString(value.worktree.reason, `${label}.worktree.reason`);
|
|
441
|
+
}
|
|
442
|
+
if (value.routing !== undefined) {
|
|
443
|
+
assertObject(value.routing, `${label}.routing`);
|
|
444
|
+
if (value.routing.projectPath !== undefined)
|
|
445
|
+
assertString(value.routing.projectPath, `${label}.routing.projectPath`);
|
|
446
|
+
if (value.routing.projectGroup !== undefined)
|
|
447
|
+
assertString(value.routing.projectGroup, `${label}.routing.projectGroup`);
|
|
448
|
+
if (value.routing.taskId !== undefined)
|
|
449
|
+
assertString(value.routing.taskId, `${label}.routing.taskId`);
|
|
450
|
+
if (value.routing.eventId !== undefined)
|
|
451
|
+
assertString(value.routing.eventId, `${label}.routing.eventId`);
|
|
452
|
+
if (value.routing.eventType !== undefined)
|
|
453
|
+
assertString(value.routing.eventType, `${label}.routing.eventType`);
|
|
454
|
+
if (value.routing.eventSource !== undefined)
|
|
455
|
+
assertString(value.routing.eventSource, `${label}.routing.eventSource`);
|
|
456
|
+
}
|
|
421
457
|
return value;
|
|
422
458
|
}
|
|
423
459
|
throw new Error(`${label}.type must be command or agent`);
|
|
@@ -2189,6 +2225,19 @@ class Store {
|
|
|
2189
2225
|
const row = this.db.query("SELECT * FROM daemon_lease LIMIT 1").get();
|
|
2190
2226
|
return row ? rowToLease(row) : undefined;
|
|
2191
2227
|
}
|
|
2228
|
+
writeTransaction(fn) {
|
|
2229
|
+
this.db.exec("BEGIN IMMEDIATE;");
|
|
2230
|
+
try {
|
|
2231
|
+
const result = fn();
|
|
2232
|
+
this.db.exec("COMMIT;");
|
|
2233
|
+
return result;
|
|
2234
|
+
} catch (error) {
|
|
2235
|
+
try {
|
|
2236
|
+
this.db.exec("ROLLBACK;");
|
|
2237
|
+
} catch {}
|
|
2238
|
+
throw error;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2192
2241
|
close() {
|
|
2193
2242
|
this.db.close();
|
|
2194
2243
|
}
|
|
@@ -2196,9 +2245,9 @@ class Store {
|
|
|
2196
2245
|
|
|
2197
2246
|
// src/cli/index.ts
|
|
2198
2247
|
import { createHash as createHash2, randomUUID } from "crypto";
|
|
2199
|
-
import { existsSync as
|
|
2248
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync2, realpathSync, writeFileSync as writeFileSync3 } from "fs";
|
|
2200
2249
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
2201
|
-
import { join as
|
|
2250
|
+
import { join as join4, resolve as resolve2 } from "path";
|
|
2202
2251
|
import { Database as Database2 } from "bun:sqlite";
|
|
2203
2252
|
import { Command } from "commander";
|
|
2204
2253
|
|
|
@@ -5204,7 +5253,7 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
5204
5253
|
// package.json
|
|
5205
5254
|
var package_default = {
|
|
5206
5255
|
name: "@hasna/loops",
|
|
5207
|
-
version: "0.3.
|
|
5256
|
+
version: "0.3.28",
|
|
5208
5257
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5209
5258
|
type: "module",
|
|
5210
5259
|
main: "dist/index.js",
|
|
@@ -5293,6 +5342,10 @@ function packageVersion() {
|
|
|
5293
5342
|
}
|
|
5294
5343
|
|
|
5295
5344
|
// src/lib/templates.ts
|
|
5345
|
+
import { execFileSync } from "child_process";
|
|
5346
|
+
import { existsSync as existsSync3 } from "fs";
|
|
5347
|
+
import { homedir as homedir3 } from "os";
|
|
5348
|
+
import { basename as basename2, isAbsolute, join as join3, relative, resolve } from "path";
|
|
5296
5349
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
5297
5350
|
var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
5298
5351
|
var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
@@ -5306,6 +5359,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5306
5359
|
{ name: "taskId", required: true, description: "Todos task id to execute." },
|
|
5307
5360
|
{ name: "taskTitle", description: "Human-readable task title." },
|
|
5308
5361
|
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5362
|
+
{ name: "routeProjectPath", description: "Canonical project path used for scheduler concurrency limits." },
|
|
5363
|
+
{ name: "projectGroup", description: "Optional project group used for scheduler concurrency limits." },
|
|
5309
5364
|
{ name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
|
|
5310
5365
|
{ name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
|
|
5311
5366
|
{ name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
|
|
@@ -5315,7 +5370,10 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5315
5370
|
{ name: "model", description: "Provider model." },
|
|
5316
5371
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
5317
5372
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
5318
|
-
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
|
|
5373
|
+
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
|
|
5374
|
+
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
5375
|
+
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
5376
|
+
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." }
|
|
5319
5377
|
]
|
|
5320
5378
|
},
|
|
5321
5379
|
{
|
|
@@ -5329,6 +5387,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5329
5387
|
{ name: "eventSource", required: true, description: "Hasna event source." },
|
|
5330
5388
|
{ name: "eventJson", required: true, description: "Full event envelope JSON." },
|
|
5331
5389
|
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5390
|
+
{ name: "routeProjectPath", description: "Canonical project path used for scheduler concurrency limits." },
|
|
5391
|
+
{ name: "projectGroup", description: "Optional project group used for scheduler concurrency limits." },
|
|
5332
5392
|
{ name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
|
|
5333
5393
|
{ name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
|
|
5334
5394
|
{ name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
|
|
@@ -5338,7 +5398,10 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5338
5398
|
{ name: "model", description: "Provider model." },
|
|
5339
5399
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
5340
5400
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
5341
|
-
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." }
|
|
5401
|
+
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
|
|
5402
|
+
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
5403
|
+
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
5404
|
+
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." }
|
|
5342
5405
|
]
|
|
5343
5406
|
},
|
|
5344
5407
|
{
|
|
@@ -5350,6 +5413,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5350
5413
|
{ name: "objective", required: true, description: "Narrow goal-mode objective for the worker." },
|
|
5351
5414
|
{ name: "prompt", description: "Optional extra worker prompt details." },
|
|
5352
5415
|
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5416
|
+
{ name: "routeProjectPath", description: "Canonical project path used for scheduler concurrency limits." },
|
|
5417
|
+
{ name: "projectGroup", description: "Optional project group used for scheduler concurrency limits." },
|
|
5353
5418
|
{ name: "provider", default: "codewith", description: "Agent provider: codewith, claude, cursor, opencode, aicopilot, or codex." },
|
|
5354
5419
|
{ name: "authProfile", description: "Provider-native auth profile, currently Codewith." },
|
|
5355
5420
|
{ name: "authProfilePool", description: "Comma-separated provider-native auth profiles; worker/verifier are selected deterministically." },
|
|
@@ -5360,6 +5425,9 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5360
5425
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
5361
5426
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
5362
5427
|
{ name: "sandbox", default: "danger-full-access", description: "Provider sandbox mode." },
|
|
5428
|
+
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
5429
|
+
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
5430
|
+
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
|
|
5363
5431
|
{ name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
|
|
5364
5432
|
]
|
|
5365
5433
|
}
|
|
@@ -5401,6 +5469,183 @@ function accountForRole(input, role, seed) {
|
|
|
5401
5469
|
return input.verifierAccount;
|
|
5402
5470
|
return rolePoolValue(input.accountPool, seed, role) ?? input.account;
|
|
5403
5471
|
}
|
|
5472
|
+
function slugSegment(value, fallback = "item") {
|
|
5473
|
+
const slug = (value ?? "").toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72);
|
|
5474
|
+
return slug || fallback;
|
|
5475
|
+
}
|
|
5476
|
+
function stableHex(seed) {
|
|
5477
|
+
return stableIndex(seed, 4294967295).toString(16).padStart(8, "0");
|
|
5478
|
+
}
|
|
5479
|
+
function shellQuote2(value) {
|
|
5480
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
5481
|
+
}
|
|
5482
|
+
function normalizeWorktreeMode(mode) {
|
|
5483
|
+
const value = mode ?? "auto";
|
|
5484
|
+
if (!["auto", "required", "off", "main"].includes(value)) {
|
|
5485
|
+
throw new Error(`worktreeMode must be one of auto, required, off, or main`);
|
|
5486
|
+
}
|
|
5487
|
+
return value;
|
|
5488
|
+
}
|
|
5489
|
+
function defaultWorktreeRoot(root) {
|
|
5490
|
+
if (root?.trim()) {
|
|
5491
|
+
const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
|
|
5492
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
5493
|
+
}
|
|
5494
|
+
return join3(homedir3(), ".hasna", "loops", "worktrees");
|
|
5495
|
+
}
|
|
5496
|
+
function gitRootFor(path) {
|
|
5497
|
+
if (!existsSync3(path))
|
|
5498
|
+
return;
|
|
5499
|
+
try {
|
|
5500
|
+
return execFileSync("git", ["-C", path, "rev-parse", "--show-toplevel"], {
|
|
5501
|
+
encoding: "utf8",
|
|
5502
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
5503
|
+
}).trim();
|
|
5504
|
+
} catch {
|
|
5505
|
+
return;
|
|
5506
|
+
}
|
|
5507
|
+
}
|
|
5508
|
+
function prepareWorktreeCommand(plan) {
|
|
5509
|
+
const repo = shellQuote2(plan.repoRoot);
|
|
5510
|
+
const path = shellQuote2(plan.path);
|
|
5511
|
+
const branch = shellQuote2(plan.branch);
|
|
5512
|
+
return [
|
|
5513
|
+
"set -euo pipefail",
|
|
5514
|
+
`repo=${repo}`,
|
|
5515
|
+
`path=${path}`,
|
|
5516
|
+
`branch=${branch}`,
|
|
5517
|
+
'resolve_path() { cd "$1" && pwd -P; }',
|
|
5518
|
+
"git_common_dir() {",
|
|
5519
|
+
' local base="$1"',
|
|
5520
|
+
" local common",
|
|
5521
|
+
' common="$(git -C "$base" rev-parse --git-common-dir)"',
|
|
5522
|
+
' case "$common" in',
|
|
5523
|
+
' /*) printf "%s\\n" "$common" ;;',
|
|
5524
|
+
' *) (cd "$base" && cd "$common" && pwd -P) ;;',
|
|
5525
|
+
" esac",
|
|
5526
|
+
"}",
|
|
5527
|
+
'mkdir -p "$(dirname "$path")"',
|
|
5528
|
+
'if [ -e "$path" ]; then',
|
|
5529
|
+
' if [ -L "$path" ]; then',
|
|
5530
|
+
' printf "refusing symlinked worktree path %s\\n" "$path" >&2',
|
|
5531
|
+
" exit 1",
|
|
5532
|
+
" fi",
|
|
5533
|
+
' if git -C "$path" rev-parse --is-inside-work-tree >/dev/null 2>&1; then',
|
|
5534
|
+
' expected_common="$(git_common_dir "$repo")"',
|
|
5535
|
+
' actual_common="$(git_common_dir "$path")"',
|
|
5536
|
+
' if [ "$actual_common" != "$expected_common" ]; then',
|
|
5537
|
+
' printf "existing worktree %s belongs to different git common dir\\n" "$path" >&2',
|
|
5538
|
+
' printf "expected %s got %s\\n" "$expected_common" "$actual_common" >&2',
|
|
5539
|
+
" exit 1",
|
|
5540
|
+
" fi",
|
|
5541
|
+
' actual_top="$(git -C "$path" rev-parse --show-toplevel)"',
|
|
5542
|
+
' actual_top="$(resolve_path "$actual_top")"',
|
|
5543
|
+
' expected_top="$(resolve_path "$path")"',
|
|
5544
|
+
' if [ "$actual_top" != "$expected_top" ]; then',
|
|
5545
|
+
' printf "existing worktree top-level mismatch for %s: %s\\n" "$path" "$actual_top" >&2',
|
|
5546
|
+
" exit 1",
|
|
5547
|
+
" fi",
|
|
5548
|
+
' actual_branch="$(git -C "$path" branch --show-current)"',
|
|
5549
|
+
' if [ "$actual_branch" != "$branch" ]; then',
|
|
5550
|
+
' printf "existing worktree %s is on branch %s, expected %s\\n" "$path" "$actual_branch" "$branch" >&2',
|
|
5551
|
+
" exit 1",
|
|
5552
|
+
" fi",
|
|
5553
|
+
' printf "existing worktree %s branch %s\\n" "$path" "$branch"',
|
|
5554
|
+
" exit 0",
|
|
5555
|
+
" fi",
|
|
5556
|
+
' printf "refusing to overwrite non-git path: %s\\n" "$path" >&2',
|
|
5557
|
+
" exit 1",
|
|
5558
|
+
"fi",
|
|
5559
|
+
'git -C "$repo" rev-parse --is-inside-work-tree >/dev/null',
|
|
5560
|
+
'if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
|
|
5561
|
+
' git -C "$repo" worktree add "$path" "$branch"',
|
|
5562
|
+
"else",
|
|
5563
|
+
' git -C "$repo" worktree add -b "$branch" "$path" HEAD',
|
|
5564
|
+
"fi",
|
|
5565
|
+
'printf "prepared worktree %s branch %s\\n" "$path" "$branch"'
|
|
5566
|
+
].join(`
|
|
5567
|
+
`);
|
|
5568
|
+
}
|
|
5569
|
+
function worktreePlan(input, seed) {
|
|
5570
|
+
const mode = normalizeWorktreeMode(input.worktreeMode);
|
|
5571
|
+
const originalCwd = input.projectPath;
|
|
5572
|
+
if (mode === "off" || mode === "main") {
|
|
5573
|
+
return {
|
|
5574
|
+
mode,
|
|
5575
|
+
enabled: false,
|
|
5576
|
+
originalCwd,
|
|
5577
|
+
cwd: originalCwd,
|
|
5578
|
+
reason: mode === "main" ? "explicit main/default checkout mode" : "worktree mode disabled"
|
|
5579
|
+
};
|
|
5580
|
+
}
|
|
5581
|
+
const repoRoot = gitRootFor(originalCwd);
|
|
5582
|
+
if (!repoRoot) {
|
|
5583
|
+
if (mode === "required") {
|
|
5584
|
+
throw new Error(`worktreeMode=required but projectPath is not an existing git repository: ${originalCwd}`);
|
|
5585
|
+
}
|
|
5586
|
+
return {
|
|
5587
|
+
mode,
|
|
5588
|
+
enabled: false,
|
|
5589
|
+
originalCwd,
|
|
5590
|
+
cwd: originalCwd,
|
|
5591
|
+
reason: "projectPath is not an existing git repository"
|
|
5592
|
+
};
|
|
5593
|
+
}
|
|
5594
|
+
const root = defaultWorktreeRoot(input.worktreeRoot);
|
|
5595
|
+
const repoSlug = slugSegment(basename2(repoRoot), "repo");
|
|
5596
|
+
const seedSlug = `${slugSegment(seed, "run").slice(0, 48)}-${stableHex(`${repoRoot}:${seed}`)}`;
|
|
5597
|
+
const worktreePath = join3(root, repoSlug, seedSlug);
|
|
5598
|
+
const relativeCwd = relative(repoRoot, originalCwd);
|
|
5599
|
+
const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join3(worktreePath, relativeCwd) : worktreePath;
|
|
5600
|
+
const branchPrefix = (input.worktreeBranchPrefix?.trim() || "openloops").replace(/^\/+|\/+$/g, "") || "openloops";
|
|
5601
|
+
const branch = `${branchPrefix}/${repoSlug}/${seedSlug}`;
|
|
5602
|
+
const prepareStep = {
|
|
5603
|
+
id: "prepare-worktree",
|
|
5604
|
+
name: "Prepare Worktree",
|
|
5605
|
+
description: "Create or reuse the isolated OpenLoops git worktree for this workflow run.",
|
|
5606
|
+
target: {
|
|
5607
|
+
type: "command",
|
|
5608
|
+
command: "bash",
|
|
5609
|
+
args: ["-lc", prepareWorktreeCommand({ repoRoot, path: worktreePath, branch })],
|
|
5610
|
+
cwd: repoRoot,
|
|
5611
|
+
timeoutMs: 5 * 60000
|
|
5612
|
+
},
|
|
5613
|
+
timeoutMs: 5 * 60000
|
|
5614
|
+
};
|
|
5615
|
+
return {
|
|
5616
|
+
mode,
|
|
5617
|
+
enabled: true,
|
|
5618
|
+
originalCwd,
|
|
5619
|
+
cwd,
|
|
5620
|
+
repoRoot,
|
|
5621
|
+
root,
|
|
5622
|
+
path: worktreePath,
|
|
5623
|
+
branch,
|
|
5624
|
+
prepareStep
|
|
5625
|
+
};
|
|
5626
|
+
}
|
|
5627
|
+
function worktreePrompt(plan) {
|
|
5628
|
+
if (plan.enabled) {
|
|
5629
|
+
return [
|
|
5630
|
+
"OpenLoops worktree policy:",
|
|
5631
|
+
"- Use the isolated git worktree as the only writeable repository checkout for this task/event.",
|
|
5632
|
+
`- Worktree cwd: ${plan.cwd}`,
|
|
5633
|
+
`- Worktree root: ${plan.path}`,
|
|
5634
|
+
`- Branch: ${plan.branch}`,
|
|
5635
|
+
`- Original checkout: ${plan.originalCwd}`,
|
|
5636
|
+
"- Do not mutate the original checkout/main branch except for read-only inspection.",
|
|
5637
|
+
"- Preserve unrelated changes in both the original checkout and this worktree."
|
|
5638
|
+
].join(`
|
|
5639
|
+
`);
|
|
5640
|
+
}
|
|
5641
|
+
return [
|
|
5642
|
+
"OpenLoops worktree policy:",
|
|
5643
|
+
`- Worktree mode ${plan.mode} did not select an isolated worktree: ${plan.reason ?? "not enabled"}.`,
|
|
5644
|
+
`- Cwd: ${plan.cwd}`,
|
|
5645
|
+
"- Do not create ad hoc worktrees unless the task itself explicitly requires one."
|
|
5646
|
+
].join(`
|
|
5647
|
+
`);
|
|
5648
|
+
}
|
|
5404
5649
|
function assertNativeAuthProfileSupport(input, provider) {
|
|
5405
5650
|
if (provider === "codewith")
|
|
5406
5651
|
return;
|
|
@@ -5409,7 +5654,7 @@ function assertNativeAuthProfileSupport(input, provider) {
|
|
|
5409
5654
|
return;
|
|
5410
5655
|
throw new Error(`authProfile, authProfilePool, workerAuthProfile, and verifierAuthProfile are supported only for provider codewith; use account/accountPool for ${provider} profile isolation`);
|
|
5411
5656
|
}
|
|
5412
|
-
function agentTarget(input, prompt, role, seed) {
|
|
5657
|
+
function agentTarget(input, prompt, role, seed, plan) {
|
|
5413
5658
|
const provider = input.provider ?? "codewith";
|
|
5414
5659
|
assertNativeAuthProfileSupport(input, provider);
|
|
5415
5660
|
const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "danger-full-access" : provider === "cursor" ? "disabled" : undefined);
|
|
@@ -5417,7 +5662,7 @@ function agentTarget(input, prompt, role, seed) {
|
|
|
5417
5662
|
type: "agent",
|
|
5418
5663
|
provider,
|
|
5419
5664
|
prompt,
|
|
5420
|
-
cwd:
|
|
5665
|
+
cwd: plan.cwd,
|
|
5421
5666
|
model: input.model,
|
|
5422
5667
|
variant: input.variant,
|
|
5423
5668
|
agent: input.agent,
|
|
@@ -5425,10 +5670,33 @@ function agentTarget(input, prompt, role, seed) {
|
|
|
5425
5670
|
configIsolation: "safe",
|
|
5426
5671
|
permissionMode: input.permissionMode ?? "bypass",
|
|
5427
5672
|
sandbox,
|
|
5673
|
+
worktree: {
|
|
5674
|
+
mode: plan.mode,
|
|
5675
|
+
enabled: plan.enabled,
|
|
5676
|
+
originalCwd: plan.originalCwd,
|
|
5677
|
+
cwd: plan.cwd,
|
|
5678
|
+
repoRoot: plan.repoRoot,
|
|
5679
|
+
root: plan.root,
|
|
5680
|
+
path: plan.path,
|
|
5681
|
+
branch: plan.branch,
|
|
5682
|
+
reason: plan.reason
|
|
5683
|
+
},
|
|
5684
|
+
routing: {
|
|
5685
|
+
projectPath: input.routeProjectPath ?? input.projectPath,
|
|
5686
|
+
...input.projectGroup ? { projectGroup: input.projectGroup } : {}
|
|
5687
|
+
},
|
|
5428
5688
|
account: accountForRole(input, role, seed),
|
|
5429
5689
|
timeoutMs: 45 * 60000
|
|
5430
5690
|
};
|
|
5431
5691
|
}
|
|
5692
|
+
function workflowStepsWithWorktree(plan, steps) {
|
|
5693
|
+
if (!plan.prepareStep)
|
|
5694
|
+
return steps;
|
|
5695
|
+
return [
|
|
5696
|
+
plan.prepareStep,
|
|
5697
|
+
...steps.map((step) => step.id === "worker" ? { ...step, dependsOn: [...new Set([...step.dependsOn ?? [], plan.prepareStep.id])] } : step)
|
|
5698
|
+
];
|
|
5699
|
+
}
|
|
5432
5700
|
function listLoopTemplates() {
|
|
5433
5701
|
return TEMPLATE_SUMMARIES.map((template) => structuredClone(template));
|
|
5434
5702
|
}
|
|
@@ -5440,18 +5708,30 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
5440
5708
|
throw new Error("taskId is required");
|
|
5441
5709
|
if (!input.projectPath?.trim())
|
|
5442
5710
|
throw new Error("projectPath is required");
|
|
5711
|
+
const plan = worktreePlan(input, input.taskId);
|
|
5443
5712
|
const taskContext = {
|
|
5444
5713
|
taskId: input.taskId,
|
|
5445
5714
|
taskTitle: input.taskTitle,
|
|
5446
5715
|
taskDescription: input.taskDescription,
|
|
5447
5716
|
eventId: input.eventId,
|
|
5448
5717
|
eventType: input.eventType,
|
|
5449
|
-
projectPath: input.projectPath
|
|
5718
|
+
projectPath: input.projectPath,
|
|
5719
|
+
routeProjectPath: input.routeProjectPath,
|
|
5720
|
+
projectGroup: input.projectGroup,
|
|
5721
|
+
worktree: {
|
|
5722
|
+
mode: plan.mode,
|
|
5723
|
+
enabled: plan.enabled,
|
|
5724
|
+
cwd: plan.cwd,
|
|
5725
|
+
path: plan.path,
|
|
5726
|
+
branch: plan.branch,
|
|
5727
|
+
reason: plan.reason
|
|
5728
|
+
}
|
|
5450
5729
|
};
|
|
5451
5730
|
const workerPrompt = [
|
|
5452
5731
|
`/goal Complete todos task ${input.taskId} in ${input.projectPath}.`,
|
|
5453
5732
|
"",
|
|
5454
5733
|
"You are the worker agent for a task-triggered OpenLoops workflow.",
|
|
5734
|
+
worktreePrompt(plan),
|
|
5455
5735
|
"Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
|
|
5456
5736
|
"Claim/start the task if appropriate, inspect the repository/project state, implement only the task scope, run focused validation, preserve unrelated user changes, and update the task with comments, evidence, changed files, commits, and blockers.",
|
|
5457
5737
|
"Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
|
|
@@ -5464,6 +5744,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
5464
5744
|
`/goal Verify todos task ${input.taskId} after the worker step.`,
|
|
5465
5745
|
"",
|
|
5466
5746
|
"You are the verifier agent for a task-triggered OpenLoops workflow.",
|
|
5747
|
+
worktreePrompt(plan),
|
|
5467
5748
|
"Use fresh context. Inspect the task, repository state, commits, tests, and worker evidence. Act as an adversarial reviewer focused on correctness, regressions, missing tests, security, and incomplete requirements.",
|
|
5468
5749
|
"If the work is valid, record verification evidence in todos and mark/leave the task in the correct completed state according to the todos CLI. If it is not valid, add precise follow-up tasks or comments and leave the original task open or blocked with clear evidence.",
|
|
5469
5750
|
"Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
|
|
@@ -5476,12 +5757,12 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
5476
5757
|
name: `todos-task-${input.taskId.slice(0, 8)}-worker-verifier`,
|
|
5477
5758
|
description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
|
|
5478
5759
|
version: 1,
|
|
5479
|
-
steps: [
|
|
5760
|
+
steps: workflowStepsWithWorktree(plan, [
|
|
5480
5761
|
{
|
|
5481
5762
|
id: "worker",
|
|
5482
5763
|
name: "Worker",
|
|
5483
5764
|
description: "Implement the todos task and record evidence.",
|
|
5484
|
-
target: agentTarget(input, workerPrompt, "worker", input.taskId),
|
|
5765
|
+
target: agentTarget(input, workerPrompt, "worker", input.taskId, plan),
|
|
5485
5766
|
timeoutMs: 45 * 60000
|
|
5486
5767
|
},
|
|
5487
5768
|
{
|
|
@@ -5489,10 +5770,10 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
5489
5770
|
name: "Verifier",
|
|
5490
5771
|
description: "Adversarially verify worker output and update todos.",
|
|
5491
5772
|
dependsOn: ["worker"],
|
|
5492
|
-
target: agentTarget(input, verifierPrompt, "verifier", input.taskId),
|
|
5773
|
+
target: agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
|
|
5493
5774
|
timeoutMs: 30 * 60000
|
|
5494
5775
|
}
|
|
5495
|
-
]
|
|
5776
|
+
])
|
|
5496
5777
|
};
|
|
5497
5778
|
}
|
|
5498
5779
|
function renderEventWorkerVerifierWorkflow(input) {
|
|
@@ -5506,18 +5787,31 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
5506
5787
|
throw new Error("eventJson is required");
|
|
5507
5788
|
if (!input.projectPath?.trim())
|
|
5508
5789
|
throw new Error("projectPath is required");
|
|
5790
|
+
const seed = `${input.eventSource}:${input.eventType}:${input.eventId}`;
|
|
5791
|
+
const plan = worktreePlan(input, seed);
|
|
5509
5792
|
const eventContext = {
|
|
5510
5793
|
eventId: input.eventId,
|
|
5511
5794
|
eventType: input.eventType,
|
|
5512
5795
|
eventSource: input.eventSource,
|
|
5513
5796
|
eventSubject: input.eventSubject,
|
|
5514
5797
|
eventMessage: input.eventMessage,
|
|
5515
|
-
projectPath: input.projectPath
|
|
5798
|
+
projectPath: input.projectPath,
|
|
5799
|
+
routeProjectPath: input.routeProjectPath,
|
|
5800
|
+
projectGroup: input.projectGroup,
|
|
5801
|
+
worktree: {
|
|
5802
|
+
mode: plan.mode,
|
|
5803
|
+
enabled: plan.enabled,
|
|
5804
|
+
cwd: plan.cwd,
|
|
5805
|
+
path: plan.path,
|
|
5806
|
+
branch: plan.branch,
|
|
5807
|
+
reason: plan.reason
|
|
5808
|
+
}
|
|
5516
5809
|
};
|
|
5517
5810
|
const workerPrompt = [
|
|
5518
5811
|
`/goal Handle Hasna event ${input.eventSource}/${input.eventType} (${input.eventId}) in ${input.projectPath}.`,
|
|
5519
5812
|
"",
|
|
5520
5813
|
"You are the worker agent for an event-triggered OpenLoops workflow.",
|
|
5814
|
+
worktreePrompt(plan),
|
|
5521
5815
|
"Investigate first before changing files. Read the full event envelope and decide the narrow action required by that event. Preserve unrelated user changes and update the relevant local CLI/task/knowledge system with evidence, changed files, commits, and blockers.",
|
|
5522
5816
|
"If the event is informational or does not require action, record that finding and stop without making changes.",
|
|
5523
5817
|
"",
|
|
@@ -5529,6 +5823,7 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
5529
5823
|
`/goal Verify handling of Hasna event ${input.eventSource}/${input.eventType} (${input.eventId}).`,
|
|
5530
5824
|
"",
|
|
5531
5825
|
"You are the verifier agent for an event-triggered OpenLoops workflow.",
|
|
5826
|
+
worktreePrompt(plan),
|
|
5532
5827
|
"Use fresh context. Inspect the event, repository/project state, worker evidence, tests, and any created tasks or notes. Act as an adversarial reviewer focused on correctness, regressions, security, missing evidence, and incomplete requirements.",
|
|
5533
5828
|
"If the work is valid, record verification evidence in the relevant local system. If it is not valid, add precise follow-up tasks/comments and leave the event handling state open or blocked with clear evidence.",
|
|
5534
5829
|
"",
|
|
@@ -5540,12 +5835,12 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
5540
5835
|
name: `event-${input.eventSource}-${input.eventType}-${input.eventId.slice(0, 8)}-worker-verifier`.replace(/[^a-zA-Z0-9._:-]+/g, "-"),
|
|
5541
5836
|
description: `Event-triggered worker/verifier workflow for ${input.eventSource}/${input.eventType}`,
|
|
5542
5837
|
version: 1,
|
|
5543
|
-
steps: [
|
|
5838
|
+
steps: workflowStepsWithWorktree(plan, [
|
|
5544
5839
|
{
|
|
5545
5840
|
id: "worker",
|
|
5546
5841
|
name: "Worker",
|
|
5547
5842
|
description: "Handle the Hasna event and record evidence.",
|
|
5548
|
-
target: agentTarget(input, workerPrompt, "worker",
|
|
5843
|
+
target: agentTarget(input, workerPrompt, "worker", seed, plan),
|
|
5549
5844
|
timeoutMs: 45 * 60000
|
|
5550
5845
|
},
|
|
5551
5846
|
{
|
|
@@ -5553,10 +5848,10 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
5553
5848
|
name: "Verifier",
|
|
5554
5849
|
description: "Adversarially verify event handling.",
|
|
5555
5850
|
dependsOn: ["worker"],
|
|
5556
|
-
target: agentTarget(input, verifierPrompt, "verifier",
|
|
5851
|
+
target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
|
|
5557
5852
|
timeoutMs: 30 * 60000
|
|
5558
5853
|
}
|
|
5559
|
-
]
|
|
5854
|
+
])
|
|
5560
5855
|
};
|
|
5561
5856
|
}
|
|
5562
5857
|
function renderBoundedAgentWorkerVerifierWorkflow(input) {
|
|
@@ -5565,11 +5860,13 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
|
|
|
5565
5860
|
if (!input.projectPath?.trim())
|
|
5566
5861
|
throw new Error("projectPath is required");
|
|
5567
5862
|
const seed = `${input.projectPath}:${input.objective}`;
|
|
5863
|
+
const plan = worktreePlan(input, seed);
|
|
5568
5864
|
const timeoutMs = input.timeoutMs && Number.isFinite(input.timeoutMs) ? input.timeoutMs : 45 * 60000;
|
|
5569
5865
|
const workerPrompt = [
|
|
5570
5866
|
`/goal ${input.objective}`,
|
|
5571
5867
|
"",
|
|
5572
5868
|
"You are the worker step for a bounded OpenLoops agent workflow.",
|
|
5869
|
+
worktreePrompt(plan),
|
|
5573
5870
|
"Investigate first. Keep scope narrow, use local project/task systems as the source of truth when relevant, preserve unrelated changes, run focused validation, and record concise evidence.",
|
|
5574
5871
|
"Do not dispatch or paste prompts into tmux panes. If additional work is required, create or update deduped todos tasks so task-created routing can start a fresh headless workflow.",
|
|
5575
5872
|
input.prompt ? "" : undefined,
|
|
@@ -5580,6 +5877,7 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
|
|
|
5580
5877
|
`/goal Adversarially verify: ${input.objective}`,
|
|
5581
5878
|
"",
|
|
5582
5879
|
"You are the verifier step for a bounded OpenLoops agent workflow.",
|
|
5880
|
+
worktreePrompt(plan),
|
|
5583
5881
|
"Use fresh context. Review the worker result for correctness, regressions, missing tests, safety, runaway-agent risk, output bounds, and incomplete evidence.",
|
|
5584
5882
|
"If valid, record verification evidence. If invalid, create precise follow-up tasks or comments and leave the original work open. Do not make broad unrelated changes."
|
|
5585
5883
|
].join(`
|
|
@@ -5588,12 +5886,12 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
|
|
|
5588
5886
|
name: input.name ?? `bounded-agent-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
|
|
5589
5887
|
description: `Bounded worker/verifier workflow for ${input.objective.slice(0, 180)}`,
|
|
5590
5888
|
version: 1,
|
|
5591
|
-
steps: [
|
|
5889
|
+
steps: workflowStepsWithWorktree(plan, [
|
|
5592
5890
|
{
|
|
5593
5891
|
id: "worker",
|
|
5594
5892
|
name: "Worker",
|
|
5595
5893
|
description: "Execute the bounded objective and record evidence.",
|
|
5596
|
-
target: agentTarget(input, workerPrompt, "worker", seed),
|
|
5894
|
+
target: agentTarget(input, workerPrompt, "worker", seed, plan),
|
|
5597
5895
|
timeoutMs
|
|
5598
5896
|
},
|
|
5599
5897
|
{
|
|
@@ -5601,10 +5899,10 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
|
|
|
5601
5899
|
name: "Verifier",
|
|
5602
5900
|
description: "Adversarially verify the bounded objective result.",
|
|
5603
5901
|
dependsOn: ["worker"],
|
|
5604
|
-
target: agentTarget(input, verifierPrompt, "verifier", seed),
|
|
5902
|
+
target: agentTarget(input, verifierPrompt, "verifier", seed, plan),
|
|
5605
5903
|
timeoutMs: Math.min(timeoutMs, 30 * 60000)
|
|
5606
5904
|
}
|
|
5607
|
-
]
|
|
5905
|
+
])
|
|
5608
5906
|
};
|
|
5609
5907
|
}
|
|
5610
5908
|
function renderLoopTemplate(id, values) {
|
|
@@ -5614,6 +5912,8 @@ function renderLoopTemplate(id, values) {
|
|
|
5614
5912
|
taskTitle: values.taskTitle,
|
|
5615
5913
|
taskDescription: values.taskDescription,
|
|
5616
5914
|
projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
|
|
5915
|
+
routeProjectPath: values.routeProjectPath,
|
|
5916
|
+
projectGroup: values.projectGroup,
|
|
5617
5917
|
provider: values.provider,
|
|
5618
5918
|
authProfile: values.authProfile,
|
|
5619
5919
|
authProfilePool: listVar(values.authProfilePool),
|
|
@@ -5626,6 +5926,9 @@ function renderLoopTemplate(id, values) {
|
|
|
5626
5926
|
agent: values.agent,
|
|
5627
5927
|
permissionMode: values.permissionMode,
|
|
5628
5928
|
sandbox: values.sandbox,
|
|
5929
|
+
worktreeMode: values.worktreeMode,
|
|
5930
|
+
worktreeRoot: values.worktreeRoot,
|
|
5931
|
+
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
5629
5932
|
eventId: values.eventId,
|
|
5630
5933
|
eventType: values.eventType
|
|
5631
5934
|
});
|
|
@@ -5639,6 +5942,8 @@ function renderLoopTemplate(id, values) {
|
|
|
5639
5942
|
eventMessage: values.eventMessage,
|
|
5640
5943
|
eventJson: values.eventJson ?? "",
|
|
5641
5944
|
projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
|
|
5945
|
+
routeProjectPath: values.routeProjectPath,
|
|
5946
|
+
projectGroup: values.projectGroup,
|
|
5642
5947
|
provider: values.provider,
|
|
5643
5948
|
authProfile: values.authProfile,
|
|
5644
5949
|
authProfilePool: listVar(values.authProfilePool),
|
|
@@ -5650,7 +5955,10 @@ function renderLoopTemplate(id, values) {
|
|
|
5650
5955
|
variant: values.variant,
|
|
5651
5956
|
agent: values.agent,
|
|
5652
5957
|
permissionMode: values.permissionMode,
|
|
5653
|
-
sandbox: values.sandbox
|
|
5958
|
+
sandbox: values.sandbox,
|
|
5959
|
+
worktreeMode: values.worktreeMode,
|
|
5960
|
+
worktreeRoot: values.worktreeRoot,
|
|
5961
|
+
worktreeBranchPrefix: values.worktreeBranchPrefix
|
|
5654
5962
|
});
|
|
5655
5963
|
}
|
|
5656
5964
|
if (id === BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID) {
|
|
@@ -5659,6 +5967,8 @@ function renderLoopTemplate(id, values) {
|
|
|
5659
5967
|
objective: values.objective ?? "",
|
|
5660
5968
|
prompt: values.prompt,
|
|
5661
5969
|
projectPath: values.projectPath ?? values.cwd ?? process.cwd(),
|
|
5970
|
+
routeProjectPath: values.routeProjectPath,
|
|
5971
|
+
projectGroup: values.projectGroup,
|
|
5662
5972
|
provider: values.provider,
|
|
5663
5973
|
authProfile: values.authProfile,
|
|
5664
5974
|
authProfilePool: listVar(values.authProfilePool),
|
|
@@ -5671,6 +5981,9 @@ function renderLoopTemplate(id, values) {
|
|
|
5671
5981
|
agent: values.agent,
|
|
5672
5982
|
permissionMode: values.permissionMode,
|
|
5673
5983
|
sandbox: values.sandbox,
|
|
5984
|
+
worktreeMode: values.worktreeMode,
|
|
5985
|
+
worktreeRoot: values.worktreeRoot,
|
|
5986
|
+
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
5674
5987
|
timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
|
|
5675
5988
|
});
|
|
5676
5989
|
}
|
|
@@ -5922,9 +6235,9 @@ function ensureTodosTaskList(project, slug, name, description) {
|
|
|
5922
6235
|
}
|
|
5923
6236
|
function backupLoopsDatabase(reason) {
|
|
5924
6237
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
|
|
5925
|
-
const backupDir =
|
|
6238
|
+
const backupDir = join4(dataDir(), "backups");
|
|
5926
6239
|
mkdirSync5(backupDir, { recursive: true, mode: 448 });
|
|
5927
|
-
const backupPath =
|
|
6240
|
+
const backupPath = join4(backupDir, `loops.db.bak-${reason}-${stamp}`);
|
|
5928
6241
|
const db = new Database2(dbPath(), { readonly: true });
|
|
5929
6242
|
try {
|
|
5930
6243
|
writeFileSync3(backupPath, db.serialize(), { mode: 384 });
|
|
@@ -5938,11 +6251,11 @@ function stableHash(parts) {
|
|
|
5938
6251
|
`)).digest("hex").slice(0, 16);
|
|
5939
6252
|
}
|
|
5940
6253
|
function routeCursorsPath() {
|
|
5941
|
-
return
|
|
6254
|
+
return join4(dataDir(), "route-cursors.json");
|
|
5942
6255
|
}
|
|
5943
6256
|
function readRouteCursors() {
|
|
5944
6257
|
const path = routeCursorsPath();
|
|
5945
|
-
if (!
|
|
6258
|
+
if (!existsSync4(path))
|
|
5946
6259
|
return {};
|
|
5947
6260
|
try {
|
|
5948
6261
|
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
@@ -5963,7 +6276,7 @@ function writeRouteEvidence(kind, value, evidenceDir) {
|
|
|
5963
6276
|
return;
|
|
5964
6277
|
mkdirSync5(evidenceDir, { recursive: true, mode: 448 });
|
|
5965
6278
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
5966
|
-
const evidencePath =
|
|
6279
|
+
const evidencePath = join4(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
5967
6280
|
writeFileSync3(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
|
|
5968
6281
|
return evidencePath;
|
|
5969
6282
|
}
|
|
@@ -6079,7 +6392,7 @@ function eventMetadata(event) {
|
|
|
6079
6392
|
function stringField(value) {
|
|
6080
6393
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
6081
6394
|
}
|
|
6082
|
-
function
|
|
6395
|
+
function slugSegment2(value, fallback = "event") {
|
|
6083
6396
|
return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
|
|
6084
6397
|
}
|
|
6085
6398
|
function stableSuffix(value) {
|
|
@@ -6218,6 +6531,111 @@ function taskRouteEligibility(data, metadata) {
|
|
|
6218
6531
|
}
|
|
6219
6532
|
return { eligible: true, tags };
|
|
6220
6533
|
}
|
|
6534
|
+
function routeThrottleLimitsFromOpts(opts) {
|
|
6535
|
+
return {
|
|
6536
|
+
maxActive: positiveInteger(opts.maxActive, "--max-active"),
|
|
6537
|
+
maxActivePerProject: positiveInteger(opts.maxActivePerProject, "--max-active-per-project"),
|
|
6538
|
+
maxActivePerProjectGroup: positiveInteger(opts.maxActivePerProjectGroup, "--max-active-per-project-group")
|
|
6539
|
+
};
|
|
6540
|
+
}
|
|
6541
|
+
function hasThrottleLimits(limits) {
|
|
6542
|
+
return limits.maxActive !== undefined || limits.maxActivePerProject !== undefined || limits.maxActivePerProjectGroup !== undefined;
|
|
6543
|
+
}
|
|
6544
|
+
function normalizeRoutePath(value) {
|
|
6545
|
+
if (!value?.trim())
|
|
6546
|
+
return;
|
|
6547
|
+
const resolved = resolve2(value.trim());
|
|
6548
|
+
let canonical = resolved;
|
|
6549
|
+
try {
|
|
6550
|
+
canonical = realpathSync(resolved);
|
|
6551
|
+
} catch {
|
|
6552
|
+
return canonical;
|
|
6553
|
+
}
|
|
6554
|
+
const gitRoot = spawnSync5("git", ["-C", canonical, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
6555
|
+
if (gitRoot.status === 0 && gitRoot.stdout.trim()) {
|
|
6556
|
+
try {
|
|
6557
|
+
return realpathSync(gitRoot.stdout.trim());
|
|
6558
|
+
} catch {
|
|
6559
|
+
return resolve2(gitRoot.stdout.trim());
|
|
6560
|
+
}
|
|
6561
|
+
}
|
|
6562
|
+
return canonical;
|
|
6563
|
+
}
|
|
6564
|
+
function routeProjectGroup(optsGroup, data, metadata) {
|
|
6565
|
+
return optsGroup?.trim() || taskEventField(data, ["project_group", "projectGroup", "repo_group", "repoGroup", "workspace_group", "workspaceGroup"]) || taskEventField(metadata, ["project_group", "projectGroup", "repo_group", "repoGroup", "workspace_group", "workspaceGroup"]);
|
|
6566
|
+
}
|
|
6567
|
+
function firstWorkflowRouting(workflow) {
|
|
6568
|
+
for (const step of workflow.steps) {
|
|
6569
|
+
if (step.target.type !== "agent")
|
|
6570
|
+
continue;
|
|
6571
|
+
const target = step.target;
|
|
6572
|
+
const projectPath = normalizeRoutePath(target.routing?.projectPath ?? target.worktree?.originalCwd ?? target.cwd);
|
|
6573
|
+
const projectGroup = target.routing?.projectGroup?.trim();
|
|
6574
|
+
if (projectPath || projectGroup) {
|
|
6575
|
+
return {
|
|
6576
|
+
...projectPath ? { projectPath } : {},
|
|
6577
|
+
...projectGroup ? { projectGroup } : {}
|
|
6578
|
+
};
|
|
6579
|
+
}
|
|
6580
|
+
}
|
|
6581
|
+
return;
|
|
6582
|
+
}
|
|
6583
|
+
function activeWorkflowLoopRoutes(store) {
|
|
6584
|
+
const values = [];
|
|
6585
|
+
for (const loop of store.listLoops({ status: "active", limit: 1e4 })) {
|
|
6586
|
+
if (loop.target.type !== "workflow")
|
|
6587
|
+
continue;
|
|
6588
|
+
const workflow = store.getWorkflow(loop.target.workflowId);
|
|
6589
|
+
if (!workflow || workflow.status !== "active")
|
|
6590
|
+
continue;
|
|
6591
|
+
const routing = firstWorkflowRouting(workflow);
|
|
6592
|
+
if (routing)
|
|
6593
|
+
values.push({ loop, routing });
|
|
6594
|
+
}
|
|
6595
|
+
return values;
|
|
6596
|
+
}
|
|
6597
|
+
function routeThrottleDecision(store, args) {
|
|
6598
|
+
const projectPath = normalizeRoutePath(args.projectPath) ?? resolve2(args.projectPath);
|
|
6599
|
+
const projectGroup = args.projectGroup?.trim() || undefined;
|
|
6600
|
+
const active = activeWorkflowLoopRoutes(store);
|
|
6601
|
+
const counts = {
|
|
6602
|
+
global: active.length,
|
|
6603
|
+
project: active.filter((entry) => normalizeRoutePath(entry.routing.projectPath) === projectPath).length
|
|
6604
|
+
};
|
|
6605
|
+
if (projectGroup)
|
|
6606
|
+
counts.projectGroup = active.filter((entry) => entry.routing.projectGroup?.trim() === projectGroup).length;
|
|
6607
|
+
const base = {
|
|
6608
|
+
projectPath,
|
|
6609
|
+
...projectGroup ? { projectGroup } : {},
|
|
6610
|
+
limits: args.limits,
|
|
6611
|
+
counts
|
|
6612
|
+
};
|
|
6613
|
+
if (args.limits.maxActive !== undefined && counts.global >= args.limits.maxActive) {
|
|
6614
|
+
return { ...base, allowed: false, reason: `global active workflow limit reached (${counts.global}/${args.limits.maxActive})` };
|
|
6615
|
+
}
|
|
6616
|
+
if (args.limits.maxActivePerProject !== undefined && counts.project >= args.limits.maxActivePerProject) {
|
|
6617
|
+
return { ...base, allowed: false, reason: `project active workflow limit reached (${counts.project}/${args.limits.maxActivePerProject})` };
|
|
6618
|
+
}
|
|
6619
|
+
if (projectGroup && args.limits.maxActivePerProjectGroup !== undefined && counts.projectGroup !== undefined && counts.projectGroup >= args.limits.maxActivePerProjectGroup) {
|
|
6620
|
+
return {
|
|
6621
|
+
...base,
|
|
6622
|
+
allowed: false,
|
|
6623
|
+
reason: `project-group active workflow limit reached (${counts.projectGroup}/${args.limits.maxActivePerProjectGroup})`
|
|
6624
|
+
};
|
|
6625
|
+
}
|
|
6626
|
+
return { ...base, allowed: true };
|
|
6627
|
+
}
|
|
6628
|
+
function routeThrottleDryRunPreview(args) {
|
|
6629
|
+
const projectPath = normalizeRoutePath(args.projectPath) ?? resolve2(args.projectPath);
|
|
6630
|
+
const projectGroup = args.projectGroup?.trim() || undefined;
|
|
6631
|
+
return {
|
|
6632
|
+
evaluated: false,
|
|
6633
|
+
reason: "not evaluated in dry-run because opening the live loop store may create or migrate the database",
|
|
6634
|
+
projectPath,
|
|
6635
|
+
...projectGroup ? { projectGroup } : {},
|
|
6636
|
+
limits: args.limits
|
|
6637
|
+
};
|
|
6638
|
+
}
|
|
6221
6639
|
async function readEventEnvelopeFromStdin() {
|
|
6222
6640
|
const raw = process.env.HASNA_EVENT_JSON || await Bun.stdin.text();
|
|
6223
6641
|
const event = JSON.parse(raw);
|
|
@@ -6231,6 +6649,283 @@ async function readEventEnvelopeFromStdin() {
|
|
|
6231
6649
|
throw new Error("event.source is required");
|
|
6232
6650
|
return event;
|
|
6233
6651
|
}
|
|
6652
|
+
function routeTodosTaskEvent(event, opts) {
|
|
6653
|
+
const data = eventData(event);
|
|
6654
|
+
const metadata = eventMetadata(event);
|
|
6655
|
+
const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
|
|
6656
|
+
if (!taskId)
|
|
6657
|
+
throw new Error("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
|
|
6658
|
+
const eligibility = taskRouteEligibility(data, metadata);
|
|
6659
|
+
if (!eligibility.eligible) {
|
|
6660
|
+
return {
|
|
6661
|
+
kind: "skipped",
|
|
6662
|
+
value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility },
|
|
6663
|
+
human: `skipped task ${taskId}: ${eligibility.reason}`
|
|
6664
|
+
};
|
|
6665
|
+
}
|
|
6666
|
+
const taskTitle = taskEventField(data, ["title", "task_title", "taskTitle"]);
|
|
6667
|
+
const taskDescription = taskEventField(data, ["description", "body"]);
|
|
6668
|
+
const dataProjectPath = taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
|
|
6669
|
+
const metadataProjectPath = taskEventField(metadata, [
|
|
6670
|
+
"working_dir",
|
|
6671
|
+
"workingDir",
|
|
6672
|
+
"project_path",
|
|
6673
|
+
"projectPath",
|
|
6674
|
+
"project_canonical_path",
|
|
6675
|
+
"cwd"
|
|
6676
|
+
]);
|
|
6677
|
+
const projectPath = opts.projectPath ?? dataProjectPath ?? metadataProjectPath ?? process.cwd();
|
|
6678
|
+
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
|
|
6679
|
+
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
6680
|
+
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
6681
|
+
const idempotencyKey = `todos-task:${taskId}:${event.type}`;
|
|
6682
|
+
const idempotencySuffix = stableSuffix(idempotencyKey);
|
|
6683
|
+
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
6684
|
+
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
6685
|
+
const loopName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
|
|
6686
|
+
const legacyLoopName = `${namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
|
|
6687
|
+
if (!opts.dryRun) {
|
|
6688
|
+
const store2 = new Store;
|
|
6689
|
+
try {
|
|
6690
|
+
const existingLoop = store2.findLoopByName(loopName) ?? store2.findLoopByName(legacyLoopName);
|
|
6691
|
+
if (existingLoop) {
|
|
6692
|
+
const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6693
|
+
return {
|
|
6694
|
+
kind: "deduped",
|
|
6695
|
+
value: {
|
|
6696
|
+
deduped: true,
|
|
6697
|
+
idempotencyKey,
|
|
6698
|
+
dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
|
|
6699
|
+
event,
|
|
6700
|
+
workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined,
|
|
6701
|
+
loop: publicLoop(existingLoop)
|
|
6702
|
+
},
|
|
6703
|
+
human: `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`
|
|
6704
|
+
};
|
|
6705
|
+
}
|
|
6706
|
+
} finally {
|
|
6707
|
+
store2.close();
|
|
6708
|
+
}
|
|
6709
|
+
}
|
|
6710
|
+
const provider = opts.provider ?? "codewith";
|
|
6711
|
+
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
|
|
6712
|
+
throw new Error("unsupported provider");
|
|
6713
|
+
const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
|
|
6714
|
+
const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
|
|
6715
|
+
const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
|
|
6716
|
+
const workflowBody = renderTodosTaskWorkerVerifierWorkflow({
|
|
6717
|
+
taskId,
|
|
6718
|
+
taskTitle,
|
|
6719
|
+
taskDescription,
|
|
6720
|
+
projectPath,
|
|
6721
|
+
routeProjectPath,
|
|
6722
|
+
projectGroup,
|
|
6723
|
+
provider,
|
|
6724
|
+
authProfile,
|
|
6725
|
+
authProfilePool: splitList(opts.authProfilePool),
|
|
6726
|
+
workerAuthProfile: opts.workerAuthProfile,
|
|
6727
|
+
verifierAuthProfile: opts.verifierAuthProfile,
|
|
6728
|
+
account: accountFromOpts(opts),
|
|
6729
|
+
accountPool: accountPoolFromOpts(opts),
|
|
6730
|
+
workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
|
|
6731
|
+
verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
|
|
6732
|
+
model: opts.model,
|
|
6733
|
+
variant: opts.variant,
|
|
6734
|
+
agent: opts.agent,
|
|
6735
|
+
permissionMode,
|
|
6736
|
+
sandbox,
|
|
6737
|
+
worktreeMode: opts.worktreeMode ?? "auto",
|
|
6738
|
+
worktreeRoot: opts.worktreeRoot,
|
|
6739
|
+
worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
|
|
6740
|
+
eventId: event.id,
|
|
6741
|
+
eventType: event.type
|
|
6742
|
+
});
|
|
6743
|
+
workflowBody.name = workflowName;
|
|
6744
|
+
workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
6745
|
+
const loopInput = {
|
|
6746
|
+
name: loopName,
|
|
6747
|
+
description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
|
|
6748
|
+
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
6749
|
+
target: { type: "workflow", workflowId: "<created-workflow-id>" },
|
|
6750
|
+
overlap: "skip",
|
|
6751
|
+
maxAttempts: 1,
|
|
6752
|
+
retryDelayMs: 60000,
|
|
6753
|
+
leaseMs: 90 * 60000
|
|
6754
|
+
};
|
|
6755
|
+
if (opts.dryRun) {
|
|
6756
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
6757
|
+
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
|
|
6758
|
+
name: workflowBody.name,
|
|
6759
|
+
type: "todos-task-event-workflow",
|
|
6760
|
+
event: event.id
|
|
6761
|
+
}, {}) : undefined;
|
|
6762
|
+
return {
|
|
6763
|
+
kind: "created",
|
|
6764
|
+
value: { deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, throttle, preflight },
|
|
6765
|
+
human: `dry-run ${loopName}`
|
|
6766
|
+
};
|
|
6767
|
+
}
|
|
6768
|
+
const store = new Store;
|
|
6769
|
+
try {
|
|
6770
|
+
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
6771
|
+
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
6772
|
+
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
6773
|
+
name: workflowBody.name,
|
|
6774
|
+
type: "todos-task-event-workflow",
|
|
6775
|
+
event: event.id
|
|
6776
|
+
}, {}) : undefined;
|
|
6777
|
+
const outcome = store.writeTransaction(() => {
|
|
6778
|
+
const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName);
|
|
6779
|
+
if (existingLoop) {
|
|
6780
|
+
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6781
|
+
return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
|
|
6782
|
+
}
|
|
6783
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
6784
|
+
if (throttle && !throttle.allowed)
|
|
6785
|
+
return { kind: "throttled", throttle };
|
|
6786
|
+
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
6787
|
+
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
6788
|
+
const loop = store.createLoop({
|
|
6789
|
+
...loopInput,
|
|
6790
|
+
target: { type: "workflow", workflowId: workflow.id }
|
|
6791
|
+
});
|
|
6792
|
+
return { kind: "created", workflow, loop, throttle };
|
|
6793
|
+
});
|
|
6794
|
+
if (outcome.kind === "deduped") {
|
|
6795
|
+
return {
|
|
6796
|
+
kind: "deduped",
|
|
6797
|
+
value: {
|
|
6798
|
+
deduped: true,
|
|
6799
|
+
idempotencyKey,
|
|
6800
|
+
dedupedBy: outcome.existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
|
|
6801
|
+
event,
|
|
6802
|
+
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
6803
|
+
loop: publicLoop(outcome.existingLoop)
|
|
6804
|
+
},
|
|
6805
|
+
human: `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`
|
|
6806
|
+
};
|
|
6807
|
+
}
|
|
6808
|
+
if (outcome.kind === "throttled") {
|
|
6809
|
+
return {
|
|
6810
|
+
kind: "throttled",
|
|
6811
|
+
value: {
|
|
6812
|
+
skipped: true,
|
|
6813
|
+
queuedAtSource: true,
|
|
6814
|
+
reason: outcome.throttle.reason,
|
|
6815
|
+
idempotencyKey,
|
|
6816
|
+
event,
|
|
6817
|
+
throttle: outcome.throttle,
|
|
6818
|
+
workflow: workflowBody,
|
|
6819
|
+
loop: loopInput
|
|
6820
|
+
},
|
|
6821
|
+
human: `skipped task ${taskId}: ${outcome.throttle.reason}`
|
|
6822
|
+
};
|
|
6823
|
+
}
|
|
6824
|
+
return {
|
|
6825
|
+
kind: "created",
|
|
6826
|
+
value: {
|
|
6827
|
+
deduped: false,
|
|
6828
|
+
idempotencyKey,
|
|
6829
|
+
event,
|
|
6830
|
+
workflow: publicWorkflow(outcome.workflow),
|
|
6831
|
+
loop: publicLoop(outcome.loop),
|
|
6832
|
+
throttle: outcome.throttle,
|
|
6833
|
+
preflight
|
|
6834
|
+
},
|
|
6835
|
+
human: `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`
|
|
6836
|
+
};
|
|
6837
|
+
} finally {
|
|
6838
|
+
store.close();
|
|
6839
|
+
}
|
|
6840
|
+
}
|
|
6841
|
+
function taskField(task, keys) {
|
|
6842
|
+
for (const key of keys) {
|
|
6843
|
+
const value = stringField(task[key]);
|
|
6844
|
+
if (value)
|
|
6845
|
+
return value;
|
|
6846
|
+
}
|
|
6847
|
+
return;
|
|
6848
|
+
}
|
|
6849
|
+
function taskListId(task) {
|
|
6850
|
+
return taskField(task, ["task_list_id", "taskListId"]) ?? stringField(task.task_list?.id);
|
|
6851
|
+
}
|
|
6852
|
+
function taskProjectId(task) {
|
|
6853
|
+
return taskField(task, ["project_id", "projectId"]);
|
|
6854
|
+
}
|
|
6855
|
+
function taskDrainEvent(task) {
|
|
6856
|
+
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
6857
|
+
if (!taskId)
|
|
6858
|
+
throw new Error("todos ready returned a task without an id");
|
|
6859
|
+
const metadata = objectField(task.metadata) ?? {};
|
|
6860
|
+
const workingDir = taskField(task, ["working_dir", "workingDir", "project_path", "projectPath", "cwd"]);
|
|
6861
|
+
const data = {
|
|
6862
|
+
...task,
|
|
6863
|
+
id: taskId,
|
|
6864
|
+
title: taskField(task, ["title"]),
|
|
6865
|
+
description: taskField(task, ["description", "body"]),
|
|
6866
|
+
status: taskField(task, ["status"]),
|
|
6867
|
+
tags: tagsFromValue(task.tags),
|
|
6868
|
+
metadata
|
|
6869
|
+
};
|
|
6870
|
+
if (workingDir) {
|
|
6871
|
+
data.working_dir = workingDir;
|
|
6872
|
+
data.project_path = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
|
|
6873
|
+
data.cwd = taskField(task, ["cwd"]) ?? workingDir;
|
|
6874
|
+
}
|
|
6875
|
+
const time = new Date().toISOString();
|
|
6876
|
+
return {
|
|
6877
|
+
id: `drain-todos-task-${taskId}`,
|
|
6878
|
+
type: "task.created",
|
|
6879
|
+
source: "@hasna/todos",
|
|
6880
|
+
subject: taskId,
|
|
6881
|
+
severity: "info",
|
|
6882
|
+
data,
|
|
6883
|
+
time,
|
|
6884
|
+
schemaVersion: "1.0",
|
|
6885
|
+
metadata: {
|
|
6886
|
+
...metadata,
|
|
6887
|
+
...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
|
|
6888
|
+
drained_by: "@hasna/loops",
|
|
6889
|
+
drained_from: "todos ready"
|
|
6890
|
+
}
|
|
6891
|
+
};
|
|
6892
|
+
}
|
|
6893
|
+
function loadReadyTodosTasks(opts, scanLimit) {
|
|
6894
|
+
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
6895
|
+
const args = ["--project", todosProject, "--json", "ready", "--limit", String(scanLimit)];
|
|
6896
|
+
const result = runLocalCommand("todos", args, { timeoutMs: 60000 });
|
|
6897
|
+
if (!result.ok)
|
|
6898
|
+
throw new Error(result.stderr || result.error || "todos ready failed");
|
|
6899
|
+
const parsed = JSON.parse(result.stdout || "[]");
|
|
6900
|
+
if (!Array.isArray(parsed))
|
|
6901
|
+
throw new Error("todos ready --json returned a non-array value");
|
|
6902
|
+
return parsed;
|
|
6903
|
+
}
|
|
6904
|
+
function resolveTaskListFilter(todosProject, filter) {
|
|
6905
|
+
const wanted = filter?.trim();
|
|
6906
|
+
if (!wanted)
|
|
6907
|
+
return;
|
|
6908
|
+
const result = runLocalCommand("todos", ["--project", todosProject, "--json", "task-lists"], { timeoutMs: 30000 });
|
|
6909
|
+
if (!result.ok)
|
|
6910
|
+
throw new Error(result.stderr || result.error || "failed to list todos task lists");
|
|
6911
|
+
const values = JSON.parse(result.stdout || "[]");
|
|
6912
|
+
const match = values.find((entry) => entry.id === wanted || entry.slug === wanted || entry.name === wanted);
|
|
6913
|
+
return match?.id ?? wanted;
|
|
6914
|
+
}
|
|
6915
|
+
function taskMatchesDrainFilters(task, filters) {
|
|
6916
|
+
if (filters.projectId && taskProjectId(task) !== filters.projectId)
|
|
6917
|
+
return false;
|
|
6918
|
+
if (filters.taskListId && taskListId(task) !== filters.taskListId)
|
|
6919
|
+
return false;
|
|
6920
|
+
if (filters.tags.length) {
|
|
6921
|
+
const taskTags = new Set(tagsFromValue(task.tags));
|
|
6922
|
+
for (const tag of filters.tags) {
|
|
6923
|
+
if (!taskTags.has(tag))
|
|
6924
|
+
return false;
|
|
6925
|
+
}
|
|
6926
|
+
}
|
|
6927
|
+
return true;
|
|
6928
|
+
}
|
|
6234
6929
|
function providerAuthProfileFromOpts(opts, provider) {
|
|
6235
6930
|
if (!opts.authProfile)
|
|
6236
6931
|
return;
|
|
@@ -6381,119 +7076,94 @@ templates.command("create-workflow <id>").description("render and store a templa
|
|
|
6381
7076
|
}
|
|
6382
7077
|
});
|
|
6383
7078
|
var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
|
|
6384
|
-
eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task 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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").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) => {
|
|
7079
|
+
eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task 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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").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", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
6385
7080
|
const event = await readEventEnvelopeFromStdin();
|
|
6386
|
-
const
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
const
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
const
|
|
6397
|
-
const
|
|
6398
|
-
const
|
|
6399
|
-
const
|
|
6400
|
-
|
|
6401
|
-
|
|
6402
|
-
|
|
6403
|
-
|
|
6404
|
-
|
|
6405
|
-
|
|
6406
|
-
]
|
|
6407
|
-
|
|
6408
|
-
const
|
|
6409
|
-
|
|
6410
|
-
|
|
6411
|
-
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
|
|
6415
|
-
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
const loopInput = {
|
|
6443
|
-
name: loopName,
|
|
6444
|
-
description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
|
|
6445
|
-
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
6446
|
-
target: { type: "workflow", workflowId: "<created-workflow-id>" },
|
|
6447
|
-
overlap: "skip",
|
|
6448
|
-
maxAttempts: 1,
|
|
6449
|
-
retryDelayMs: 60000,
|
|
6450
|
-
leaseMs: 90 * 60000
|
|
7081
|
+
const result = routeTodosTaskEvent(event, opts);
|
|
7082
|
+
print(result.value, result.human);
|
|
7083
|
+
});
|
|
7084
|
+
var eventsDrain = events.command("drain").description("drain durable source queues into bounded OpenLoops workflows");
|
|
7085
|
+
eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops").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("--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("--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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").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", "check generated workflow steps before storing workflow loops").option("--dry-run", "preview selected tasks and generated workflow loops without storing anything").action((opts) => {
|
|
7086
|
+
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
7087
|
+
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
7088
|
+
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
7089
|
+
const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
|
|
7090
|
+
const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
|
|
7091
|
+
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || requiredTags.length);
|
|
7092
|
+
const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
|
|
7093
|
+
const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
|
|
7094
|
+
const ready = loadReadyTodosTasks(opts, scanLimit);
|
|
7095
|
+
const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
|
|
7096
|
+
projectId: opts.todosProjectId,
|
|
7097
|
+
taskListId: taskListFilter,
|
|
7098
|
+
tags: requiredTags
|
|
7099
|
+
}));
|
|
7100
|
+
const candidates = filteredCandidates.slice(0, candidateLimit);
|
|
7101
|
+
const results = [];
|
|
7102
|
+
let created = 0;
|
|
7103
|
+
for (const task of candidates) {
|
|
7104
|
+
if (created >= maxDispatch)
|
|
7105
|
+
break;
|
|
7106
|
+
const event = taskDrainEvent(task);
|
|
7107
|
+
const result = routeTodosTaskEvent(event, opts);
|
|
7108
|
+
results.push(result);
|
|
7109
|
+
if (result.kind === "created" && !opts.dryRun)
|
|
7110
|
+
created += 1;
|
|
7111
|
+
if (result.kind === "created" && opts.dryRun)
|
|
7112
|
+
created += 1;
|
|
7113
|
+
}
|
|
7114
|
+
const report = {
|
|
7115
|
+
drainedAt: new Date().toISOString(),
|
|
7116
|
+
todosProject,
|
|
7117
|
+
todosProjectId: opts.todosProjectId,
|
|
7118
|
+
taskList: opts.taskList,
|
|
7119
|
+
taskListId: taskListFilter,
|
|
7120
|
+
tags: requiredTags,
|
|
7121
|
+
limit: candidateLimit,
|
|
7122
|
+
scanLimit,
|
|
7123
|
+
filtersApplied: hasPostFilters,
|
|
7124
|
+
scanned: ready.length,
|
|
7125
|
+
candidates: candidates.length,
|
|
7126
|
+
filteredCandidates: filteredCandidates.length,
|
|
7127
|
+
scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
|
|
7128
|
+
considered: results.length,
|
|
7129
|
+
created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
|
|
7130
|
+
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
7131
|
+
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
7132
|
+
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
7133
|
+
maxDispatch,
|
|
7134
|
+
source: "todos ready",
|
|
7135
|
+
dryRun: Boolean(opts.dryRun),
|
|
7136
|
+
results: results.map((result) => ({ kind: result.kind, ...result.value }))
|
|
6451
7137
|
};
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
name: workflowBody.name,
|
|
6455
|
-
type: "todos-task-event-workflow",
|
|
6456
|
-
event: event.id
|
|
6457
|
-
}, {}) : undefined;
|
|
6458
|
-
print({ deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, preflight }, `dry-run ${loopName}`);
|
|
6459
|
-
return;
|
|
6460
|
-
}
|
|
6461
|
-
const store = new Store;
|
|
6462
|
-
try {
|
|
6463
|
-
const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName);
|
|
6464
|
-
if (existingLoop) {
|
|
6465
|
-
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6466
|
-
print({
|
|
6467
|
-
deduped: true,
|
|
6468
|
-
idempotencyKey,
|
|
6469
|
-
dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
|
|
6470
|
-
event,
|
|
6471
|
-
workflow: existingWorkflow2 ? publicWorkflow(existingWorkflow2) : undefined,
|
|
6472
|
-
loop: publicLoop(existingLoop)
|
|
6473
|
-
}, `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`);
|
|
6474
|
-
return;
|
|
6475
|
-
}
|
|
6476
|
-
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
6477
|
-
const workflowPreflightSpec = existingWorkflow ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
6478
|
-
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
6479
|
-
name: workflowBody.name,
|
|
6480
|
-
type: "todos-task-event-workflow",
|
|
6481
|
-
event: event.id
|
|
6482
|
-
}, {}) : undefined;
|
|
6483
|
-
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
6484
|
-
const loop = store.createLoop({
|
|
6485
|
-
...loopInput,
|
|
6486
|
-
target: { type: "workflow", workflowId: workflow.id }
|
|
6487
|
-
});
|
|
6488
|
-
print({ deduped: false, idempotencyKey, event, workflow: publicWorkflow(workflow), loop: publicLoop(loop), preflight }, `created ${loop.id} (${loop.name}) workflow=${workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
|
|
6489
|
-
} finally {
|
|
6490
|
-
store.close();
|
|
6491
|
-
}
|
|
7138
|
+
const evidencePath = writeRouteEvidence("todos-task-drain", report, opts.evidenceDir);
|
|
7139
|
+
print({ ...report, evidencePath }, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
|
|
6492
7140
|
});
|
|
6493
|
-
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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").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) => {
|
|
7141
|
+
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("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").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) => {
|
|
6494
7142
|
const event = await readEventEnvelopeFromStdin();
|
|
6495
7143
|
const data = eventData(event);
|
|
6496
|
-
const
|
|
7144
|
+
const metadata = eventMetadata(event);
|
|
7145
|
+
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();
|
|
7146
|
+
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
|
|
7147
|
+
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
7148
|
+
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
7149
|
+
const eventSuffix = event.id.slice(0, 8);
|
|
7150
|
+
const source = slugSegment2(event.source, "source");
|
|
7151
|
+
const type = slugSegment2(event.type, "type");
|
|
7152
|
+
const workflowName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
|
|
7153
|
+
const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
|
|
7154
|
+
if (!opts.dryRun) {
|
|
7155
|
+
const store2 = new Store;
|
|
7156
|
+
try {
|
|
7157
|
+
const existingLoop = store2.findLoopByName(loopName);
|
|
7158
|
+
if (existingLoop) {
|
|
7159
|
+
const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
7160
|
+
print({ deduped: true, event, workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined, loop: publicLoop(existingLoop) }, `deduped existing loop ${existingLoop.id} (${existingLoop.name})`);
|
|
7161
|
+
return;
|
|
7162
|
+
}
|
|
7163
|
+
} finally {
|
|
7164
|
+
store2.close();
|
|
7165
|
+
}
|
|
7166
|
+
}
|
|
6497
7167
|
const provider = opts.provider;
|
|
6498
7168
|
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
|
|
6499
7169
|
throw new Error("unsupported provider");
|
|
@@ -6508,6 +7178,8 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
6508
7178
|
eventMessage: stringField(event.message),
|
|
6509
7179
|
eventJson: JSON.stringify(event),
|
|
6510
7180
|
projectPath,
|
|
7181
|
+
routeProjectPath,
|
|
7182
|
+
projectGroup,
|
|
6511
7183
|
provider,
|
|
6512
7184
|
authProfile,
|
|
6513
7185
|
authProfilePool: splitList(opts.authProfilePool),
|
|
@@ -6521,14 +7193,13 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
6521
7193
|
variant: opts.variant,
|
|
6522
7194
|
agent: opts.agent,
|
|
6523
7195
|
permissionMode,
|
|
6524
|
-
sandbox
|
|
7196
|
+
sandbox,
|
|
7197
|
+
worktreeMode: opts.worktreeMode,
|
|
7198
|
+
worktreeRoot: opts.worktreeRoot,
|
|
7199
|
+
worktreeBranchPrefix: opts.worktreeBranchPrefix
|
|
6525
7200
|
});
|
|
6526
|
-
|
|
6527
|
-
|
|
6528
|
-
const type = slugSegment(event.type, "type");
|
|
6529
|
-
workflowBody.name = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
|
|
6530
|
-
workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}`;
|
|
6531
|
-
const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
|
|
7201
|
+
workflowBody.name = workflowName;
|
|
7202
|
+
workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
6532
7203
|
const loopInput = {
|
|
6533
7204
|
name: loopName,
|
|
6534
7205
|
description: `Run ${workflowBody.name} once for event ${event.id}`,
|
|
@@ -6540,35 +7211,50 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
6540
7211
|
leaseMs: 90 * 60000
|
|
6541
7212
|
};
|
|
6542
7213
|
if (opts.dryRun) {
|
|
7214
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
6543
7215
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
|
|
6544
7216
|
name: workflowBody.name,
|
|
6545
7217
|
type: "generic-event-workflow",
|
|
6546
7218
|
event: event.id
|
|
6547
7219
|
}, {}) : undefined;
|
|
6548
|
-
print({ event, workflow: workflowBody, loop: loopInput, preflight }, `dry-run ${loopName}`);
|
|
7220
|
+
print({ event, workflow: workflowBody, loop: loopInput, throttle, preflight }, `dry-run ${loopName}`);
|
|
6549
7221
|
return;
|
|
6550
7222
|
}
|
|
6551
7223
|
const store = new Store;
|
|
6552
7224
|
try {
|
|
6553
|
-
const
|
|
6554
|
-
|
|
6555
|
-
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6556
|
-
print({ deduped: true, event, workflow: existingWorkflow2 ? publicWorkflow(existingWorkflow2) : undefined, loop: publicLoop(existingLoop) }, `deduped existing loop ${existingLoop.id} (${existingLoop.name})`);
|
|
6557
|
-
return;
|
|
6558
|
-
}
|
|
6559
|
-
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
6560
|
-
const workflowPreflightSpec = existingWorkflow ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
7225
|
+
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
7226
|
+
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
6561
7227
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
6562
7228
|
name: workflowBody.name,
|
|
6563
7229
|
type: "generic-event-workflow",
|
|
6564
7230
|
event: event.id
|
|
6565
7231
|
}, {}) : undefined;
|
|
6566
|
-
const
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
7232
|
+
const outcome = store.writeTransaction(() => {
|
|
7233
|
+
const existingLoop = store.findLoopByName(loopName);
|
|
7234
|
+
if (existingLoop) {
|
|
7235
|
+
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
7236
|
+
return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
|
|
7237
|
+
}
|
|
7238
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
7239
|
+
if (throttle && !throttle.allowed)
|
|
7240
|
+
return { kind: "throttled", throttle };
|
|
7241
|
+
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
7242
|
+
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
7243
|
+
const loop = store.createLoop({
|
|
7244
|
+
...loopInput,
|
|
7245
|
+
target: { type: "workflow", workflowId: workflow.id }
|
|
7246
|
+
});
|
|
7247
|
+
return { kind: "created", workflow, loop, throttle };
|
|
6570
7248
|
});
|
|
6571
|
-
|
|
7249
|
+
if (outcome.kind === "deduped") {
|
|
7250
|
+
print({ deduped: true, event, workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined, loop: publicLoop(outcome.existingLoop) }, `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name})`);
|
|
7251
|
+
return;
|
|
7252
|
+
}
|
|
7253
|
+
if (outcome.kind === "throttled") {
|
|
7254
|
+
print({ skipped: true, queuedAtSource: true, reason: outcome.throttle.reason, event, throttle: outcome.throttle, workflow: workflowBody, loop: loopInput }, `skipped event ${event.id}: ${outcome.throttle.reason}`);
|
|
7255
|
+
return;
|
|
7256
|
+
}
|
|
7257
|
+
print({ deduped: false, event, workflow: publicWorkflow(outcome.workflow), loop: publicLoop(outcome.loop), throttle: outcome.throttle, preflight }, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name}`);
|
|
6572
7258
|
} finally {
|
|
6573
7259
|
store.close();
|
|
6574
7260
|
}
|
|
@@ -7402,7 +8088,7 @@ ${result.instructions.join(`
|
|
|
7402
8088
|
});
|
|
7403
8089
|
daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
|
|
7404
8090
|
const path = daemonLogPath();
|
|
7405
|
-
if (!
|
|
8091
|
+
if (!existsSync4(path)) {
|
|
7406
8092
|
console.log("");
|
|
7407
8093
|
return;
|
|
7408
8094
|
}
|