@hasna/loops 0.3.25 → 0.3.27
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 +10 -0
- package/dist/cli/index.js +728 -95
- 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 +62 -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,16 +2225,29 @@ 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
|
}
|
|
2195
2244
|
}
|
|
2196
2245
|
|
|
2197
2246
|
// src/cli/index.ts
|
|
2198
|
-
import { createHash as createHash2 } from "crypto";
|
|
2199
|
-
import { existsSync as
|
|
2247
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
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.27",
|
|
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"));
|
|
@@ -5958,6 +6271,15 @@ function writeRouteCursor(key, lastFingerprint) {
|
|
|
5958
6271
|
cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
|
|
5959
6272
|
writeFileSync3(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
|
|
5960
6273
|
}
|
|
6274
|
+
function writeRouteEvidence(kind, value, evidenceDir) {
|
|
6275
|
+
if (!evidenceDir)
|
|
6276
|
+
return;
|
|
6277
|
+
mkdirSync5(evidenceDir, { recursive: true, mode: 448 });
|
|
6278
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
6279
|
+
const evidencePath = join4(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
6280
|
+
writeFileSync3(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
|
|
6281
|
+
return evidencePath;
|
|
6282
|
+
}
|
|
5961
6283
|
function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
5962
6284
|
const total = items.length;
|
|
5963
6285
|
const boundedMax = Math.max(0, Math.floor(Number.isFinite(maxActions) ? maxActions : 0));
|
|
@@ -5984,6 +6306,77 @@ function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
|
5984
6306
|
}
|
|
5985
6307
|
};
|
|
5986
6308
|
}
|
|
6309
|
+
function taskAutoRoute(tags, base, opts) {
|
|
6310
|
+
if (!opts.autoRoute) {
|
|
6311
|
+
return {
|
|
6312
|
+
tags,
|
|
6313
|
+
metadata: {
|
|
6314
|
+
...base,
|
|
6315
|
+
route_enabled: false,
|
|
6316
|
+
project_path: null,
|
|
6317
|
+
working_dir: null,
|
|
6318
|
+
auto_route_requested: false,
|
|
6319
|
+
auto_route_enabled: false,
|
|
6320
|
+
automation: {
|
|
6321
|
+
allowed: false,
|
|
6322
|
+
source: opts.source,
|
|
6323
|
+
kind: "task-created-worker-verifier"
|
|
6324
|
+
}
|
|
6325
|
+
},
|
|
6326
|
+
autoRoute: { requested: false, enabled: false }
|
|
6327
|
+
};
|
|
6328
|
+
}
|
|
6329
|
+
const projectPath = typeof base.cwd === "string" && base.cwd.trim() || typeof opts.routeProjectPath === "string" && opts.routeProjectPath.trim() || undefined;
|
|
6330
|
+
if (!projectPath) {
|
|
6331
|
+
return {
|
|
6332
|
+
tags,
|
|
6333
|
+
metadata: {
|
|
6334
|
+
...base,
|
|
6335
|
+
route_enabled: false,
|
|
6336
|
+
project_path: null,
|
|
6337
|
+
working_dir: null,
|
|
6338
|
+
auto_route_requested: true,
|
|
6339
|
+
auto_route_enabled: false,
|
|
6340
|
+
auto_route_skipped_reason: "missing cwd or --route-project-path",
|
|
6341
|
+
automation: {
|
|
6342
|
+
allowed: false,
|
|
6343
|
+
source: opts.source,
|
|
6344
|
+
kind: "task-created-worker-verifier"
|
|
6345
|
+
}
|
|
6346
|
+
},
|
|
6347
|
+
autoRoute: {
|
|
6348
|
+
requested: true,
|
|
6349
|
+
enabled: false,
|
|
6350
|
+
skippedReason: "missing cwd or --route-project-path"
|
|
6351
|
+
}
|
|
6352
|
+
};
|
|
6353
|
+
}
|
|
6354
|
+
return {
|
|
6355
|
+
tags: [...new Set([...tags, "auto:route"])],
|
|
6356
|
+
metadata: {
|
|
6357
|
+
...base,
|
|
6358
|
+
route_enabled: true,
|
|
6359
|
+
project_path: projectPath,
|
|
6360
|
+
working_dir: projectPath,
|
|
6361
|
+
auto_route_requested: true,
|
|
6362
|
+
auto_route_enabled: true,
|
|
6363
|
+
automation: {
|
|
6364
|
+
allowed: true,
|
|
6365
|
+
source: opts.source,
|
|
6366
|
+
kind: "task-created-worker-verifier"
|
|
6367
|
+
}
|
|
6368
|
+
},
|
|
6369
|
+
autoRoute: { requested: true, enabled: true }
|
|
6370
|
+
};
|
|
6371
|
+
}
|
|
6372
|
+
function routeTaskWorkingDirArgs(routeTask) {
|
|
6373
|
+
const workingDir = routeTask.autoRoute.enabled && typeof routeTask.metadata.working_dir === "string" ? routeTask.metadata.working_dir : undefined;
|
|
6374
|
+
return workingDir ? ["--working-dir", workingDir] : [];
|
|
6375
|
+
}
|
|
6376
|
+
function routeCursorKey(kind, parts, opts) {
|
|
6377
|
+
const routeMode = opts.autoRoute ? ["auto-route", opts.routeProjectPath ?? "cwd"] : [];
|
|
6378
|
+
return `${kind}:${stableHash([...parts, ...routeMode])}`;
|
|
6379
|
+
}
|
|
5987
6380
|
function eventData(event) {
|
|
5988
6381
|
const data = event.data;
|
|
5989
6382
|
if (data && typeof data === "object" && !Array.isArray(data))
|
|
@@ -5999,7 +6392,7 @@ function eventMetadata(event) {
|
|
|
5999
6392
|
function stringField(value) {
|
|
6000
6393
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
6001
6394
|
}
|
|
6002
|
-
function
|
|
6395
|
+
function slugSegment2(value, fallback = "event") {
|
|
6003
6396
|
return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
|
|
6004
6397
|
}
|
|
6005
6398
|
function stableSuffix(value) {
|
|
@@ -6138,6 +6531,111 @@ function taskRouteEligibility(data, metadata) {
|
|
|
6138
6531
|
}
|
|
6139
6532
|
return { eligible: true, tags };
|
|
6140
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
|
+
}
|
|
6141
6639
|
async function readEventEnvelopeFromStdin() {
|
|
6142
6640
|
const raw = process.env.HASNA_EVENT_JSON || await Bun.stdin.text();
|
|
6143
6641
|
const event = JSON.parse(raw);
|
|
@@ -6301,7 +6799,7 @@ templates.command("create-workflow <id>").description("render and store a templa
|
|
|
6301
6799
|
}
|
|
6302
6800
|
});
|
|
6303
6801
|
var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
|
|
6304
|
-
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) => {
|
|
6802
|
+
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) => {
|
|
6305
6803
|
const event = await readEventEnvelopeFromStdin();
|
|
6306
6804
|
const data = eventData(event);
|
|
6307
6805
|
const metadata = eventMetadata(event);
|
|
@@ -6325,7 +6823,34 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
|
|
|
6325
6823
|
"cwd"
|
|
6326
6824
|
]);
|
|
6327
6825
|
const projectPath = opts.projectPath ?? dataProjectPath ?? metadataProjectPath ?? process.cwd();
|
|
6826
|
+
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
|
|
6827
|
+
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
6828
|
+
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
6328
6829
|
const idempotencyKey = `todos-task:${taskId}:${event.type}`;
|
|
6830
|
+
const idempotencySuffix = stableSuffix(idempotencyKey);
|
|
6831
|
+
const workflowName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
6832
|
+
const loopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
|
|
6833
|
+
const legacyLoopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
|
|
6834
|
+
if (!opts.dryRun) {
|
|
6835
|
+
const store2 = new Store;
|
|
6836
|
+
try {
|
|
6837
|
+
const existingLoop = store2.findLoopByName(loopName) ?? store2.findLoopByName(legacyLoopName);
|
|
6838
|
+
if (existingLoop) {
|
|
6839
|
+
const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6840
|
+
print({
|
|
6841
|
+
deduped: true,
|
|
6842
|
+
idempotencyKey,
|
|
6843
|
+
dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
|
|
6844
|
+
event,
|
|
6845
|
+
workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined,
|
|
6846
|
+
loop: publicLoop(existingLoop)
|
|
6847
|
+
}, `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`);
|
|
6848
|
+
return;
|
|
6849
|
+
}
|
|
6850
|
+
} finally {
|
|
6851
|
+
store2.close();
|
|
6852
|
+
}
|
|
6853
|
+
}
|
|
6329
6854
|
const provider = opts.provider;
|
|
6330
6855
|
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
|
|
6331
6856
|
throw new Error("unsupported provider");
|
|
@@ -6337,6 +6862,8 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
|
|
|
6337
6862
|
taskTitle,
|
|
6338
6863
|
taskDescription,
|
|
6339
6864
|
projectPath,
|
|
6865
|
+
routeProjectPath,
|
|
6866
|
+
projectGroup,
|
|
6340
6867
|
provider,
|
|
6341
6868
|
authProfile,
|
|
6342
6869
|
authProfilePool: splitList(opts.authProfilePool),
|
|
@@ -6351,14 +6878,14 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
|
|
|
6351
6878
|
agent: opts.agent,
|
|
6352
6879
|
permissionMode,
|
|
6353
6880
|
sandbox,
|
|
6881
|
+
worktreeMode: opts.worktreeMode,
|
|
6882
|
+
worktreeRoot: opts.worktreeRoot,
|
|
6883
|
+
worktreeBranchPrefix: opts.worktreeBranchPrefix,
|
|
6354
6884
|
eventId: event.id,
|
|
6355
6885
|
eventType: event.type
|
|
6356
6886
|
});
|
|
6357
|
-
|
|
6358
|
-
workflowBody.
|
|
6359
|
-
workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}`;
|
|
6360
|
-
const loopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
|
|
6361
|
-
const legacyLoopName = `${opts.namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
|
|
6887
|
+
workflowBody.name = workflowName;
|
|
6888
|
+
workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
6362
6889
|
const loopInput = {
|
|
6363
6890
|
name: loopName,
|
|
6364
6891
|
description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
|
|
@@ -6370,50 +6897,96 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
|
|
|
6370
6897
|
leaseMs: 90 * 60000
|
|
6371
6898
|
};
|
|
6372
6899
|
if (opts.dryRun) {
|
|
6900
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
6373
6901
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
|
|
6374
6902
|
name: workflowBody.name,
|
|
6375
6903
|
type: "todos-task-event-workflow",
|
|
6376
6904
|
event: event.id
|
|
6377
6905
|
}, {}) : undefined;
|
|
6378
|
-
print({ deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, preflight }, `dry-run ${loopName}`);
|
|
6906
|
+
print({ deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, throttle, preflight }, `dry-run ${loopName}`);
|
|
6379
6907
|
return;
|
|
6380
6908
|
}
|
|
6381
6909
|
const store = new Store;
|
|
6382
6910
|
try {
|
|
6383
|
-
const
|
|
6384
|
-
|
|
6385
|
-
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6386
|
-
print({
|
|
6387
|
-
deduped: true,
|
|
6388
|
-
idempotencyKey,
|
|
6389
|
-
dedupedBy: existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
|
|
6390
|
-
event,
|
|
6391
|
-
workflow: existingWorkflow2 ? publicWorkflow(existingWorkflow2) : undefined,
|
|
6392
|
-
loop: publicLoop(existingLoop)
|
|
6393
|
-
}, `deduped existing loop ${existingLoop.id} (${existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`);
|
|
6394
|
-
return;
|
|
6395
|
-
}
|
|
6396
|
-
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
6397
|
-
const workflowPreflightSpec = existingWorkflow ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
6911
|
+
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
6912
|
+
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
6398
6913
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
6399
6914
|
name: workflowBody.name,
|
|
6400
6915
|
type: "todos-task-event-workflow",
|
|
6401
6916
|
event: event.id
|
|
6402
6917
|
}, {}) : undefined;
|
|
6403
|
-
const
|
|
6404
|
-
|
|
6405
|
-
|
|
6406
|
-
|
|
6918
|
+
const outcome = store.writeTransaction(() => {
|
|
6919
|
+
const existingLoop = store.findLoopByName(loopName) ?? store.findLoopByName(legacyLoopName);
|
|
6920
|
+
if (existingLoop) {
|
|
6921
|
+
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6922
|
+
return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
|
|
6923
|
+
}
|
|
6924
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
6925
|
+
if (throttle && !throttle.allowed)
|
|
6926
|
+
return { kind: "throttled", throttle };
|
|
6927
|
+
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
6928
|
+
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
6929
|
+
const loop = store.createLoop({
|
|
6930
|
+
...loopInput,
|
|
6931
|
+
target: { type: "workflow", workflowId: workflow.id }
|
|
6932
|
+
});
|
|
6933
|
+
return { kind: "created", workflow, loop, throttle };
|
|
6407
6934
|
});
|
|
6408
|
-
|
|
6935
|
+
if (outcome.kind === "deduped") {
|
|
6936
|
+
print({
|
|
6937
|
+
deduped: true,
|
|
6938
|
+
idempotencyKey,
|
|
6939
|
+
dedupedBy: outcome.existingLoop.name === loopName ? "idempotency" : "legacy-event-name",
|
|
6940
|
+
event,
|
|
6941
|
+
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
6942
|
+
loop: publicLoop(outcome.existingLoop)
|
|
6943
|
+
}, `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name}) for event=${event.id} idempotency=${idempotencyKey}`);
|
|
6944
|
+
return;
|
|
6945
|
+
}
|
|
6946
|
+
if (outcome.kind === "throttled") {
|
|
6947
|
+
print({
|
|
6948
|
+
skipped: true,
|
|
6949
|
+
queuedAtSource: true,
|
|
6950
|
+
reason: outcome.throttle.reason,
|
|
6951
|
+
idempotencyKey,
|
|
6952
|
+
event,
|
|
6953
|
+
throttle: outcome.throttle,
|
|
6954
|
+
workflow: workflowBody,
|
|
6955
|
+
loop: loopInput
|
|
6956
|
+
}, `skipped task ${taskId}: ${outcome.throttle.reason}`);
|
|
6957
|
+
return;
|
|
6958
|
+
}
|
|
6959
|
+
print({ deduped: false, idempotencyKey, event, workflow: publicWorkflow(outcome.workflow), loop: publicLoop(outcome.loop), throttle: outcome.throttle, preflight }, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
|
|
6409
6960
|
} finally {
|
|
6410
6961
|
store.close();
|
|
6411
6962
|
}
|
|
6412
6963
|
});
|
|
6413
|
-
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) => {
|
|
6964
|
+
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) => {
|
|
6414
6965
|
const event = await readEventEnvelopeFromStdin();
|
|
6415
6966
|
const data = eventData(event);
|
|
6416
|
-
const
|
|
6967
|
+
const metadata = eventMetadata(event);
|
|
6968
|
+
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();
|
|
6969
|
+
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
|
|
6970
|
+
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
6971
|
+
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
6972
|
+
const eventSuffix = event.id.slice(0, 8);
|
|
6973
|
+
const source = slugSegment2(event.source, "source");
|
|
6974
|
+
const type = slugSegment2(event.type, "type");
|
|
6975
|
+
const workflowName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
|
|
6976
|
+
const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
|
|
6977
|
+
if (!opts.dryRun) {
|
|
6978
|
+
const store2 = new Store;
|
|
6979
|
+
try {
|
|
6980
|
+
const existingLoop = store2.findLoopByName(loopName);
|
|
6981
|
+
if (existingLoop) {
|
|
6982
|
+
const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6983
|
+
print({ deduped: true, event, workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined, loop: publicLoop(existingLoop) }, `deduped existing loop ${existingLoop.id} (${existingLoop.name})`);
|
|
6984
|
+
return;
|
|
6985
|
+
}
|
|
6986
|
+
} finally {
|
|
6987
|
+
store2.close();
|
|
6988
|
+
}
|
|
6989
|
+
}
|
|
6417
6990
|
const provider = opts.provider;
|
|
6418
6991
|
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
|
|
6419
6992
|
throw new Error("unsupported provider");
|
|
@@ -6428,6 +7001,8 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
6428
7001
|
eventMessage: stringField(event.message),
|
|
6429
7002
|
eventJson: JSON.stringify(event),
|
|
6430
7003
|
projectPath,
|
|
7004
|
+
routeProjectPath,
|
|
7005
|
+
projectGroup,
|
|
6431
7006
|
provider,
|
|
6432
7007
|
authProfile,
|
|
6433
7008
|
authProfilePool: splitList(opts.authProfilePool),
|
|
@@ -6441,14 +7016,13 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
6441
7016
|
variant: opts.variant,
|
|
6442
7017
|
agent: opts.agent,
|
|
6443
7018
|
permissionMode,
|
|
6444
|
-
sandbox
|
|
7019
|
+
sandbox,
|
|
7020
|
+
worktreeMode: opts.worktreeMode,
|
|
7021
|
+
worktreeRoot: opts.worktreeRoot,
|
|
7022
|
+
worktreeBranchPrefix: opts.worktreeBranchPrefix
|
|
6445
7023
|
});
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
const type = slugSegment(event.type, "type");
|
|
6449
|
-
workflowBody.name = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
|
|
6450
|
-
workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}`;
|
|
6451
|
-
const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
|
|
7024
|
+
workflowBody.name = workflowName;
|
|
7025
|
+
workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
6452
7026
|
const loopInput = {
|
|
6453
7027
|
name: loopName,
|
|
6454
7028
|
description: `Run ${workflowBody.name} once for event ${event.id}`,
|
|
@@ -6460,35 +7034,50 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
6460
7034
|
leaseMs: 90 * 60000
|
|
6461
7035
|
};
|
|
6462
7036
|
if (opts.dryRun) {
|
|
7037
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
6463
7038
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
|
|
6464
7039
|
name: workflowBody.name,
|
|
6465
7040
|
type: "generic-event-workflow",
|
|
6466
7041
|
event: event.id
|
|
6467
7042
|
}, {}) : undefined;
|
|
6468
|
-
print({ event, workflow: workflowBody, loop: loopInput, preflight }, `dry-run ${loopName}`);
|
|
7043
|
+
print({ event, workflow: workflowBody, loop: loopInput, throttle, preflight }, `dry-run ${loopName}`);
|
|
6469
7044
|
return;
|
|
6470
7045
|
}
|
|
6471
7046
|
const store = new Store;
|
|
6472
7047
|
try {
|
|
6473
|
-
const
|
|
6474
|
-
|
|
6475
|
-
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
6476
|
-
print({ deduped: true, event, workflow: existingWorkflow2 ? publicWorkflow(existingWorkflow2) : undefined, loop: publicLoop(existingLoop) }, `deduped existing loop ${existingLoop.id} (${existingLoop.name})`);
|
|
6477
|
-
return;
|
|
6478
|
-
}
|
|
6479
|
-
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
6480
|
-
const workflowPreflightSpec = existingWorkflow ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
7048
|
+
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
7049
|
+
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
6481
7050
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
6482
7051
|
name: workflowBody.name,
|
|
6483
7052
|
type: "generic-event-workflow",
|
|
6484
7053
|
event: event.id
|
|
6485
7054
|
}, {}) : undefined;
|
|
6486
|
-
const
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
7055
|
+
const outcome = store.writeTransaction(() => {
|
|
7056
|
+
const existingLoop = store.findLoopByName(loopName);
|
|
7057
|
+
if (existingLoop) {
|
|
7058
|
+
const existingWorkflow2 = existingLoop.target.type === "workflow" ? store.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
7059
|
+
return { kind: "deduped", existingLoop, existingWorkflow: existingWorkflow2 };
|
|
7060
|
+
}
|
|
7061
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
7062
|
+
if (throttle && !throttle.allowed)
|
|
7063
|
+
return { kind: "throttled", throttle };
|
|
7064
|
+
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
7065
|
+
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
7066
|
+
const loop = store.createLoop({
|
|
7067
|
+
...loopInput,
|
|
7068
|
+
target: { type: "workflow", workflowId: workflow.id }
|
|
7069
|
+
});
|
|
7070
|
+
return { kind: "created", workflow, loop, throttle };
|
|
6490
7071
|
});
|
|
6491
|
-
|
|
7072
|
+
if (outcome.kind === "deduped") {
|
|
7073
|
+
print({ deduped: true, event, workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined, loop: publicLoop(outcome.existingLoop) }, `deduped existing loop ${outcome.existingLoop.id} (${outcome.existingLoop.name})`);
|
|
7074
|
+
return;
|
|
7075
|
+
}
|
|
7076
|
+
if (outcome.kind === "throttled") {
|
|
7077
|
+
print({ skipped: true, queuedAtSource: true, reason: outcome.throttle.reason, event, throttle: outcome.throttle, workflow: workflowBody, loop: loopInput }, `skipped event ${event.id}: ${outcome.throttle.reason}`);
|
|
7078
|
+
return;
|
|
7079
|
+
}
|
|
7080
|
+
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}`);
|
|
6492
7081
|
} finally {
|
|
6493
7082
|
store.close();
|
|
6494
7083
|
}
|
|
@@ -6790,26 +7379,43 @@ var health = program.command("health").description("summarize loop health and la
|
|
|
6790
7379
|
store.close();
|
|
6791
7380
|
}
|
|
6792
7381
|
});
|
|
6793
|
-
health.command("route-tasks").description("upsert deduped todos tasks for failed loop health expectations").option("--project <path>", "todos project path", defaultLoopsProject()).option("--task-list <slug>", "todos task-list slug", "loop-error-self-heal").option("--limit <n>", "maximum loops to inspect", "200").option("--max-actions <n>", "maximum todos tasks to upsert", "5").option("--include-inactive", "also route stopped or expired loops").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
|
|
7382
|
+
health.command("route-tasks").description("upsert deduped todos tasks for failed loop health expectations").option("--project <path>", "todos project path", defaultLoopsProject()).option("--task-list <slug>", "todos task-list slug", "loop-error-self-heal").option("--limit <n>", "maximum loops to inspect", "200").option("--max-actions <n>", "maximum todos tasks to upsert", "5").option("--include-inactive", "also route stopped or expired loops").option("--auto-route", "opt routed tasks into task-created headless worker/verifier automation").option("--route-project-path <path>", "fallback project path for --auto-route when the failed loop has no cwd").option("--evidence-dir <path>", "write the route result JSON to this directory").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
|
|
6794
7383
|
const store = new Store;
|
|
6795
7384
|
try {
|
|
6796
7385
|
const report = buildHealthReport(store, { limit: Number(opts.limit), includeInactive: Boolean(opts.includeInactive) });
|
|
6797
7386
|
const failures = report.expectations.filter((entry) => !entry.ok && entry.recommendedTask);
|
|
6798
|
-
const selection = selectRouteItems(failures, Number(opts.maxActions),
|
|
7387
|
+
const selection = selectRouteItems(failures, Number(opts.maxActions), routeCursorKey("health", [opts.project, opts.taskList, opts.limit, Boolean(opts.includeInactive)], {
|
|
7388
|
+
autoRoute: Boolean(opts.autoRoute),
|
|
7389
|
+
routeProjectPath: opts.routeProjectPath
|
|
7390
|
+
}), (expectation) => expectation.recommendedTask.dedupeKey);
|
|
6799
7391
|
const listId = opts.dryRun ? undefined : ensureTodosTaskList(opts.project, opts.taskList, "Loop Error Self Heal", "Deduped OpenLoops health expectation failures routed by loops health route-tasks.");
|
|
6800
7392
|
const actions = selection.selected.map((expectation) => {
|
|
6801
7393
|
const task = expectation.recommendedTask;
|
|
6802
|
-
const
|
|
7394
|
+
const routeTask = taskAutoRoute(task.tags, {
|
|
6803
7395
|
source: "openloops.health.route-tasks",
|
|
6804
7396
|
loop_id: expectation.loop.id,
|
|
6805
7397
|
loop_name: expectation.loop.name,
|
|
6806
7398
|
run_id: expectation.latestRun?.id,
|
|
6807
7399
|
classification: expectation.failure?.classification,
|
|
6808
7400
|
fingerprint: task.dedupeKey,
|
|
7401
|
+
cwd: expectation.route.cwd,
|
|
7402
|
+
provider: expectation.route.provider,
|
|
6809
7403
|
no_tmux_dispatch: true
|
|
6810
|
-
}
|
|
7404
|
+
}, {
|
|
7405
|
+
autoRoute: Boolean(opts.autoRoute),
|
|
7406
|
+
routeProjectPath: opts.routeProjectPath,
|
|
7407
|
+
source: "openloops.health.route-tasks"
|
|
7408
|
+
});
|
|
6811
7409
|
if (opts.dryRun) {
|
|
6812
|
-
return {
|
|
7410
|
+
return {
|
|
7411
|
+
action: "would-upsert",
|
|
7412
|
+
title: task.title,
|
|
7413
|
+
fingerprint: task.dedupeKey,
|
|
7414
|
+
priority: task.priority,
|
|
7415
|
+
tags: routeTask.tags,
|
|
7416
|
+
metadata: routeTask.metadata,
|
|
7417
|
+
autoRoute: routeTask.autoRoute
|
|
7418
|
+
};
|
|
6813
7419
|
}
|
|
6814
7420
|
const result = runLocalCommand("todos", [
|
|
6815
7421
|
"--project",
|
|
@@ -6830,9 +7436,10 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
|
|
|
6830
7436
|
"--list",
|
|
6831
7437
|
listId,
|
|
6832
7438
|
"--tags",
|
|
6833
|
-
|
|
7439
|
+
routeTask.tags.join(","),
|
|
7440
|
+
...routeTaskWorkingDirArgs(routeTask),
|
|
6834
7441
|
"--metadata-json",
|
|
6835
|
-
JSON.stringify(metadata)
|
|
7442
|
+
JSON.stringify(routeTask.metadata)
|
|
6836
7443
|
]);
|
|
6837
7444
|
if (!result.ok) {
|
|
6838
7445
|
return { action: "upsert-failed", fingerprint: task.dedupeKey, error: result.stderr || result.error || result.stdout };
|
|
@@ -6846,12 +7453,16 @@ health.command("route-tasks").description("upsert deduped todos tasks for failed
|
|
|
6846
7453
|
routing: selection.cursor,
|
|
6847
7454
|
actions
|
|
6848
7455
|
};
|
|
7456
|
+
const evidencePath = writeRouteEvidence("health-route-tasks", routed, opts.evidenceDir);
|
|
7457
|
+
const output = evidencePath ? { ...routed, evidencePath } : routed;
|
|
6849
7458
|
if (!opts.dryRun && routed.ok)
|
|
6850
7459
|
writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
|
|
6851
7460
|
if (isJson() || opts.json)
|
|
6852
|
-
console.log(JSON.stringify(
|
|
7461
|
+
console.log(JSON.stringify(output, null, 2));
|
|
6853
7462
|
else {
|
|
6854
|
-
console.log(`health_route_tasks inspected=${
|
|
7463
|
+
console.log(`health_route_tasks inspected=${output.inspected} failures=${output.failures} actions=${actions.length}`);
|
|
7464
|
+
if (evidencePath)
|
|
7465
|
+
console.log(`evidence=${evidencePath}`);
|
|
6855
7466
|
for (const action of actions)
|
|
6856
7467
|
console.log(`${action.action} ${action.fingerprint}`);
|
|
6857
7468
|
}
|
|
@@ -7074,7 +7685,7 @@ hygiene.command("scripts").description("inventory loops still backed by local ~/
|
|
|
7074
7685
|
store.close();
|
|
7075
7686
|
}
|
|
7076
7687
|
});
|
|
7077
|
-
hygiene.command("route-tasks").description("upsert deduped todos tasks for hygiene findings").option("--checks <list>", "comma-separated hygiene checks: names,duplicates,scripts,all", "all").option("--project <path>", "todos project path", defaultLoopsProject()).option("--task-list <slug>", "todos task-list slug", "openloops-hygiene").option("--limit <n>", "maximum loops to inspect", "1000").option("--max-actions <n>", "maximum todos tasks to upsert", "10").option("--scripts-dir <path>", "script directory to detect for script inventory").option("--include-inactive", "also route stopped, expired, or archived loops").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
|
|
7688
|
+
hygiene.command("route-tasks").description("upsert deduped todos tasks for hygiene findings").option("--checks <list>", "comma-separated hygiene checks: names,duplicates,scripts,all", "all").option("--project <path>", "todos project path", defaultLoopsProject()).option("--task-list <slug>", "todos task-list slug", "openloops-hygiene").option("--limit <n>", "maximum loops to inspect", "1000").option("--max-actions <n>", "maximum todos tasks to upsert", "10").option("--scripts-dir <path>", "script directory to detect for script inventory").option("--include-inactive", "also route stopped, expired, or archived loops").option("--auto-route", "opt routed tasks into task-created headless worker/verifier automation").option("--route-project-path <path>", "fallback project path for --auto-route when the hygiene finding has no cwd").option("--evidence-dir <path>", "write the route result JSON to this directory").option("--dry-run", "print intended task upserts without mutating todos").option("--json", "print JSON").action((opts) => {
|
|
7078
7689
|
const store = new Store;
|
|
7079
7690
|
try {
|
|
7080
7691
|
const checks = parseHygieneChecks(opts.checks);
|
|
@@ -7084,11 +7695,28 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
7084
7695
|
limit: Number(opts.limit),
|
|
7085
7696
|
scriptsDir: opts.scriptsDir
|
|
7086
7697
|
});
|
|
7087
|
-
const selection = selectRouteItems(route.tasks, Number(opts.maxActions),
|
|
7698
|
+
const selection = selectRouteItems(route.tasks, Number(opts.maxActions), routeCursorKey("hygiene", [opts.project, opts.taskList, checks, opts.limit, Boolean(opts.includeInactive), opts.scriptsDir ?? ""], {
|
|
7699
|
+
autoRoute: Boolean(opts.autoRoute),
|
|
7700
|
+
routeProjectPath: opts.routeProjectPath
|
|
7701
|
+
}), (task) => task.fingerprint);
|
|
7088
7702
|
const listId = opts.dryRun ? undefined : ensureTodosTaskList(opts.project, opts.taskList, "OpenLoops Hygiene", "Deduped OpenLoops hygiene findings routed by loops hygiene route-tasks.");
|
|
7089
7703
|
const actions = selection.selected.map((task) => {
|
|
7704
|
+
const routeTask = taskAutoRoute(task.tags, task.metadata, {
|
|
7705
|
+
autoRoute: Boolean(opts.autoRoute),
|
|
7706
|
+
routeProjectPath: opts.routeProjectPath,
|
|
7707
|
+
source: "openloops.hygiene.route-tasks"
|
|
7708
|
+
});
|
|
7090
7709
|
if (opts.dryRun) {
|
|
7091
|
-
return {
|
|
7710
|
+
return {
|
|
7711
|
+
action: "would-upsert",
|
|
7712
|
+
check: task.check,
|
|
7713
|
+
title: task.title,
|
|
7714
|
+
fingerprint: task.fingerprint,
|
|
7715
|
+
priority: task.priority,
|
|
7716
|
+
tags: routeTask.tags,
|
|
7717
|
+
metadata: routeTask.metadata,
|
|
7718
|
+
autoRoute: routeTask.autoRoute
|
|
7719
|
+
};
|
|
7092
7720
|
}
|
|
7093
7721
|
const result = runLocalCommand("todos", [
|
|
7094
7722
|
"--project",
|
|
@@ -7109,9 +7737,10 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
7109
7737
|
"--list",
|
|
7110
7738
|
listId,
|
|
7111
7739
|
"--tags",
|
|
7112
|
-
|
|
7740
|
+
routeTask.tags.join(","),
|
|
7741
|
+
...routeTaskWorkingDirArgs(routeTask),
|
|
7113
7742
|
"--metadata-json",
|
|
7114
|
-
JSON.stringify(
|
|
7743
|
+
JSON.stringify(routeTask.metadata)
|
|
7115
7744
|
]);
|
|
7116
7745
|
if (!result.ok) {
|
|
7117
7746
|
return { action: "upsert-failed", check: task.check, fingerprint: task.fingerprint, error: result.stderr || result.error || result.stdout };
|
|
@@ -7126,12 +7755,16 @@ hygiene.command("route-tasks").description("upsert deduped todos tasks for hygie
|
|
|
7126
7755
|
routing: selection.cursor,
|
|
7127
7756
|
actions
|
|
7128
7757
|
};
|
|
7758
|
+
const evidencePath = writeRouteEvidence("hygiene-route-tasks", routed, opts.evidenceDir);
|
|
7759
|
+
const output = evidencePath ? { ...routed, evidencePath } : routed;
|
|
7129
7760
|
if (!opts.dryRun && routed.ok)
|
|
7130
7761
|
writeRouteCursor(selection.cursor.key, selection.cursor.lastFingerprint);
|
|
7131
7762
|
if (isJson() || opts.json)
|
|
7132
|
-
console.log(JSON.stringify(
|
|
7763
|
+
console.log(JSON.stringify(output, null, 2));
|
|
7133
7764
|
else {
|
|
7134
|
-
console.log(`hygiene_route_tasks checks=${checks.join(",")} findings=${
|
|
7765
|
+
console.log(`hygiene_route_tasks checks=${checks.join(",")} findings=${output.findings} actions=${actions.length}`);
|
|
7766
|
+
if (evidencePath)
|
|
7767
|
+
console.log(`evidence=${evidencePath}`);
|
|
7135
7768
|
for (const action of actions)
|
|
7136
7769
|
console.log(`${action.action} ${action.fingerprint}`);
|
|
7137
7770
|
}
|
|
@@ -7278,7 +7911,7 @@ ${result.instructions.join(`
|
|
|
7278
7911
|
});
|
|
7279
7912
|
daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
|
|
7280
7913
|
const path = daemonLogPath();
|
|
7281
|
-
if (!
|
|
7914
|
+
if (!existsSync4(path)) {
|
|
7282
7915
|
console.log("");
|
|
7283
7916
|
return;
|
|
7284
7917
|
}
|