@hasna/loops 0.4.1 → 0.4.2
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 +1 -1
- package/dist/api/index.d.ts +8 -2
- package/dist/api/index.js +961 -4
- package/dist/cli/index.js +458 -96
- package/dist/daemon/index.js +48 -14
- package/dist/index.d.ts +2 -0
- package/dist/index.js +740 -52
- package/dist/lib/format.d.ts +3 -1
- package/dist/lib/mode.js +18 -2
- package/dist/lib/route/todos-cli.d.ts +1 -0
- package/dist/lib/route/types.d.ts +10 -0
- package/dist/lib/storage/contract.d.ts +133 -0
- package/dist/lib/storage/contract.js +1 -0
- package/dist/lib/storage/index.d.ts +6 -0
- package/dist/lib/storage/index.js +4612 -0
- package/dist/lib/storage/postgres-schema.d.ts +4 -0
- package/dist/lib/storage/postgres-schema.js +328 -0
- package/dist/lib/storage/postgres.d.ts +22 -0
- package/dist/lib/storage/postgres.js +423 -0
- package/dist/lib/storage/sqlite.d.ts +84 -0
- package/dist/lib/storage/sqlite.js +4187 -0
- package/dist/lib/store.d.ts +3 -0
- package/dist/lib/store.js +27 -10
- package/dist/lib/template-kit.d.ts +10 -8
- package/dist/lib/templates.d.ts +1 -0
- package/dist/mcp/index.js +48 -14
- package/dist/runner/index.d.ts +23 -0
- package/dist/runner/index.js +1746 -4
- package/dist/sdk/index.js +30 -12
- package/docs/DEPLOYMENT_MODES.md +15 -7
- package/package.json +18 -2
package/dist/cli/index.js
CHANGED
|
@@ -1205,7 +1205,7 @@ function discardWorkflowRunManifest(staged) {
|
|
|
1205
1205
|
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
1206
1206
|
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
1207
1207
|
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
1208
|
-
var SCHEMA_USER_VERSION =
|
|
1208
|
+
var SCHEMA_USER_VERSION = 7;
|
|
1209
1209
|
var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
|
|
1210
1210
|
var PRUNE_BATCH_SIZE = 400;
|
|
1211
1211
|
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
|
|
@@ -1575,6 +1575,13 @@ class Store {
|
|
|
1575
1575
|
this.addColumnIfMissing("loop_runs", "pgid", "INTEGER");
|
|
1576
1576
|
this.addColumnIfMissing("loop_runs", "process_started_at", "TEXT");
|
|
1577
1577
|
}
|
|
1578
|
+
},
|
|
1579
|
+
{
|
|
1580
|
+
id: "0007_run_claim_tokens",
|
|
1581
|
+
apply: () => {
|
|
1582
|
+
this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
|
|
1583
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
|
|
1584
|
+
}
|
|
1578
1585
|
}
|
|
1579
1586
|
];
|
|
1580
1587
|
}
|
|
@@ -1616,6 +1623,7 @@ class Store {
|
|
|
1616
1623
|
started_at TEXT,
|
|
1617
1624
|
finished_at TEXT,
|
|
1618
1625
|
claimed_by TEXT,
|
|
1626
|
+
claim_token TEXT,
|
|
1619
1627
|
lease_expires_at TEXT,
|
|
1620
1628
|
pid INTEGER,
|
|
1621
1629
|
pgid INTEGER,
|
|
@@ -1634,6 +1642,7 @@ class Store {
|
|
|
1634
1642
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
|
|
1635
1643
|
CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
|
|
1636
1644
|
CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
|
|
1645
|
+
CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL;
|
|
1637
1646
|
|
|
1638
1647
|
CREATE TABLE IF NOT EXISTS daemon_lease (
|
|
1639
1648
|
id TEXT PRIMARY KEY,
|
|
@@ -3505,6 +3514,7 @@ class Store {
|
|
|
3505
3514
|
}
|
|
3506
3515
|
claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
|
|
3507
3516
|
const startedAt = now.toISOString();
|
|
3517
|
+
const claimToken = opts.claimToken ?? genId();
|
|
3508
3518
|
this.db.exec("BEGIN IMMEDIATE");
|
|
3509
3519
|
try {
|
|
3510
3520
|
this.assertDaemonLeaseFence(opts, startedAt);
|
|
@@ -3527,12 +3537,13 @@ class Store {
|
|
|
3527
3537
|
return;
|
|
3528
3538
|
}
|
|
3529
3539
|
const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
|
|
3530
|
-
claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
|
|
3540
|
+
claimed_by=$claimedBy, claim_token=$claimToken, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
|
|
3531
3541
|
duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
3532
3542
|
WHERE id=$id AND status='running' AND lease_expires_at <= $now`).run({
|
|
3533
3543
|
$id: existing.id,
|
|
3534
3544
|
$started: startedAt,
|
|
3535
3545
|
$claimedBy: runnerId,
|
|
3546
|
+
$claimToken: claimToken,
|
|
3536
3547
|
$lease: leaseExpiresAt,
|
|
3537
3548
|
$updated: startedAt,
|
|
3538
3549
|
$now: startedAt
|
|
@@ -3541,7 +3552,7 @@ class Store {
|
|
|
3541
3552
|
if (res3.changes !== 1)
|
|
3542
3553
|
return;
|
|
3543
3554
|
const run3 = this.getRun(existing.id);
|
|
3544
|
-
return run3 ? { run: run3, loop } : undefined;
|
|
3555
|
+
return run3 ? { run: run3, loop, claimToken } : undefined;
|
|
3545
3556
|
}
|
|
3546
3557
|
if (existing.status === "succeeded" || existing.status === "skipped") {
|
|
3547
3558
|
this.db.exec("COMMIT");
|
|
@@ -3549,7 +3560,7 @@ class Store {
|
|
|
3549
3560
|
}
|
|
3550
3561
|
const attempt = existing.attempt + 1;
|
|
3551
3562
|
const res2 = this.db.query(`UPDATE loop_runs SET attempt=$attempt, status='running', started_at=$started, finished_at=NULL,
|
|
3552
|
-
claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
|
|
3563
|
+
claimed_by=$claimedBy, claim_token=$claimToken, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
|
|
3553
3564
|
duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
3554
3565
|
WHERE id=$id
|
|
3555
3566
|
AND status IN ('failed', 'timed_out', 'abandoned')
|
|
@@ -3558,6 +3569,7 @@ class Store {
|
|
|
3558
3569
|
$attempt: attempt,
|
|
3559
3570
|
$started: startedAt,
|
|
3560
3571
|
$claimedBy: runnerId,
|
|
3572
|
+
$claimToken: claimToken,
|
|
3561
3573
|
$lease: leaseExpiresAt,
|
|
3562
3574
|
$updated: startedAt,
|
|
3563
3575
|
$maxAttempts: loop.maxAttempts
|
|
@@ -3566,12 +3578,12 @@ class Store {
|
|
|
3566
3578
|
if (res2.changes !== 1)
|
|
3567
3579
|
return;
|
|
3568
3580
|
const run2 = this.getRun(existing.id);
|
|
3569
|
-
return run2 ? { run: run2, loop } : undefined;
|
|
3581
|
+
return run2 ? { run: run2, loop, claimToken } : undefined;
|
|
3570
3582
|
}
|
|
3571
3583
|
const id = genId();
|
|
3572
3584
|
const res = this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
|
|
3573
|
-
claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
|
|
3574
|
-
VALUES ($id, $loopId, $loopName, $scheduledFor, 1, 'running', $started, NULL, $claimedBy, $lease,
|
|
3585
|
+
claimed_by, claim_token, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
|
|
3586
|
+
VALUES ($id, $loopId, $loopName, $scheduledFor, 1, 'running', $started, NULL, $claimedBy, $claimToken, $lease,
|
|
3575
3587
|
NULL, NULL, NULL, NULL, NULL, NULL, $created, $updated)`).run({
|
|
3576
3588
|
$id: id,
|
|
3577
3589
|
$loopId: loop.id,
|
|
@@ -3579,6 +3591,7 @@ class Store {
|
|
|
3579
3591
|
$scheduledFor: scheduledFor,
|
|
3580
3592
|
$started: startedAt,
|
|
3581
3593
|
$claimedBy: runnerId,
|
|
3594
|
+
$claimToken: claimToken,
|
|
3582
3595
|
$lease: leaseExpiresAt,
|
|
3583
3596
|
$created: startedAt,
|
|
3584
3597
|
$updated: startedAt
|
|
@@ -3587,7 +3600,7 @@ class Store {
|
|
|
3587
3600
|
if (res.changes !== 1)
|
|
3588
3601
|
return;
|
|
3589
3602
|
const run = this.getRun(id);
|
|
3590
|
-
return run ? { run, loop } : undefined;
|
|
3603
|
+
return run ? { run, loop, claimToken } : undefined;
|
|
3591
3604
|
} catch (error) {
|
|
3592
3605
|
try {
|
|
3593
3606
|
this.db.exec("ROLLBACK");
|
|
@@ -3610,16 +3623,18 @@ class Store {
|
|
|
3610
3623
|
$error: error ?? null,
|
|
3611
3624
|
$updated: finishedAt,
|
|
3612
3625
|
$claimedBy: opts.claimedBy ?? null,
|
|
3626
|
+
$claimToken: opts.claimToken ?? null,
|
|
3613
3627
|
$now: (opts.now ?? new Date).toISOString(),
|
|
3614
3628
|
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
3615
3629
|
};
|
|
3616
3630
|
return this.transact(() => {
|
|
3617
|
-
const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
3631
|
+
const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, claim_token=NULL, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
3618
3632
|
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
3619
3633
|
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
3634
|
+
AND ($claimToken IS NULL OR claim_token=$claimToken)
|
|
3620
3635
|
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
3621
3636
|
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
3622
|
-
))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
3637
|
+
))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, claim_token=NULL, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
3623
3638
|
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
|
|
3624
3639
|
const run = this.getRun(id);
|
|
3625
3640
|
if (!run)
|
|
@@ -3647,11 +3662,13 @@ class Store {
|
|
|
3647
3662
|
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
3648
3663
|
const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
|
|
3649
3664
|
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
3665
|
+
AND ($claimToken IS NULL OR claim_token=$claimToken)
|
|
3650
3666
|
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
3651
3667
|
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
3652
3668
|
))`).run({
|
|
3653
3669
|
$id: id,
|
|
3654
3670
|
$claimedBy: claimedBy,
|
|
3671
|
+
$claimToken: opts.claimToken ?? null,
|
|
3655
3672
|
$expires: expiresAt,
|
|
3656
3673
|
$updated: now.toISOString(),
|
|
3657
3674
|
$now: now.toISOString(),
|
|
@@ -3967,7 +3984,7 @@ class Store {
|
|
|
3967
3984
|
// package.json
|
|
3968
3985
|
var package_default = {
|
|
3969
3986
|
name: "@hasna/loops",
|
|
3970
|
-
version: "0.4.
|
|
3987
|
+
version: "0.4.2",
|
|
3971
3988
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
3972
3989
|
type: "module",
|
|
3973
3990
|
main: "dist/index.js",
|
|
@@ -4007,6 +4024,22 @@ var package_default = {
|
|
|
4007
4024
|
"./storage": {
|
|
4008
4025
|
types: "./dist/lib/store.d.ts",
|
|
4009
4026
|
import: "./dist/lib/store.js"
|
|
4027
|
+
},
|
|
4028
|
+
"./storage/contract": {
|
|
4029
|
+
types: "./dist/lib/storage/contract.d.ts",
|
|
4030
|
+
import: "./dist/lib/storage/contract.js"
|
|
4031
|
+
},
|
|
4032
|
+
"./storage/sqlite": {
|
|
4033
|
+
types: "./dist/lib/storage/sqlite.d.ts",
|
|
4034
|
+
import: "./dist/lib/storage/sqlite.js"
|
|
4035
|
+
},
|
|
4036
|
+
"./storage/postgres": {
|
|
4037
|
+
types: "./dist/lib/storage/postgres.d.ts",
|
|
4038
|
+
import: "./dist/lib/storage/postgres.js"
|
|
4039
|
+
},
|
|
4040
|
+
"./storage/postgres-schema": {
|
|
4041
|
+
types: "./dist/lib/storage/postgres-schema.d.ts",
|
|
4042
|
+
import: "./dist/lib/storage/postgres-schema.js"
|
|
4010
4043
|
}
|
|
4011
4044
|
},
|
|
4012
4045
|
files: [
|
|
@@ -4017,7 +4050,7 @@ var package_default = {
|
|
|
4017
4050
|
"LICENSE"
|
|
4018
4051
|
],
|
|
4019
4052
|
scripts: {
|
|
4020
|
-
build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
4053
|
+
build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
4021
4054
|
"build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
|
|
4022
4055
|
typecheck: "tsc --noEmit",
|
|
4023
4056
|
test: "bun test",
|
|
@@ -4280,11 +4313,12 @@ function publicLoop(loop) {
|
|
|
4280
4313
|
target
|
|
4281
4314
|
};
|
|
4282
4315
|
}
|
|
4283
|
-
function publicRun(run, showOutput = false) {
|
|
4316
|
+
function publicRun(run, showOutput = false, opts = {}) {
|
|
4284
4317
|
return {
|
|
4285
4318
|
...run,
|
|
4286
4319
|
stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
|
|
4287
|
-
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
|
|
4320
|
+
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
|
|
4321
|
+
error: opts.redactError ? redact(run.error) : run.error
|
|
4288
4322
|
};
|
|
4289
4323
|
}
|
|
4290
4324
|
function publicExecutorResult(result, showOutput = false) {
|
|
@@ -8287,6 +8321,7 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
|
|
|
8287
8321
|
{ name: "taskTitle", description: "Human-readable task title." },
|
|
8288
8322
|
projectPathVariable(),
|
|
8289
8323
|
{ name: "todosProjectPath", description: "Todos storage project path used in worker/verifier commands." },
|
|
8324
|
+
{ name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
|
|
8290
8325
|
...routeScopeVariables(),
|
|
8291
8326
|
...workerVerifierAgentVariables({ addDirs: true, branchNoun: "task/event" })
|
|
8292
8327
|
]
|
|
@@ -8327,6 +8362,8 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
|
|
|
8327
8362
|
variables: [
|
|
8328
8363
|
{ name: "taskId", required: true, description: "Todos task id." },
|
|
8329
8364
|
projectPathVariable(),
|
|
8365
|
+
{ name: "todosProjectPath", description: "Todos storage project path used in lifecycle commands." },
|
|
8366
|
+
{ name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
|
|
8330
8367
|
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
8331
8368
|
roleAuthProfileVariable("triage"),
|
|
8332
8369
|
roleAuthProfileVariable("planner"),
|
|
@@ -8457,28 +8494,29 @@ function verifierRuntimeGuidance(input) {
|
|
|
8457
8494
|
].join(`
|
|
8458
8495
|
`);
|
|
8459
8496
|
}
|
|
8460
|
-
function todosInspectLine(todosProjectPath, taskId) {
|
|
8461
|
-
return `- Inspect first:
|
|
8497
|
+
function todosInspectLine(todosProjectPath, taskId, todosDbPath) {
|
|
8498
|
+
return `- Inspect first: ${todosCommand(todosProjectPath, todosDbPath)} inspect ${taskId}`;
|
|
8462
8499
|
}
|
|
8463
|
-
function todosStartLine(todosProjectPath, taskId) {
|
|
8464
|
-
return `- Claim/start if appropriate:
|
|
8500
|
+
function todosStartLine(todosProjectPath, taskId, todosDbPath) {
|
|
8501
|
+
return `- Claim/start if appropriate: ${todosCommand(todosProjectPath, todosDbPath)} start ${taskId}`;
|
|
8465
8502
|
}
|
|
8466
|
-
function todosEvidenceLine(todosProjectPath, taskId, placeholder) {
|
|
8467
|
-
return `- Record evidence:
|
|
8503
|
+
function todosEvidenceLine(todosProjectPath, taskId, placeholder, todosDbPath) {
|
|
8504
|
+
return `- Record evidence: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<${placeholder}>"`;
|
|
8468
8505
|
}
|
|
8469
|
-
function todosVerificationLine(todosProjectPath, taskId) {
|
|
8470
|
-
return `- Record verification:
|
|
8506
|
+
function todosVerificationLine(todosProjectPath, taskId, todosDbPath) {
|
|
8507
|
+
return `- Record verification: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<verification evidence or blocker>"`;
|
|
8471
8508
|
}
|
|
8472
|
-
function todosDoneLine(todosProjectPath, taskId) {
|
|
8473
|
-
return `- If valid and complete:
|
|
8509
|
+
function todosDoneLine(todosProjectPath, taskId, todosDbPath) {
|
|
8510
|
+
return `- If valid and complete: ${todosCommand(todosProjectPath, todosDbPath)} done ${taskId}`;
|
|
8474
8511
|
}
|
|
8475
|
-
function todosExactCommandsFragment(todosProjectPath, taskId, commandLines) {
|
|
8512
|
+
function todosExactCommandsFragment(todosProjectPath, taskId, commandLines, todosDbPath) {
|
|
8476
8513
|
return [
|
|
8477
8514
|
`Todos project path: ${todosProjectPath}`,
|
|
8515
|
+
todosDbPath ? `Todos DB path: ${todosDbPath}` : undefined,
|
|
8478
8516
|
"Use these exact todos commands so worktree cwd inference cannot attach to the wrong project:",
|
|
8479
|
-
todosInspectLine(todosProjectPath, taskId),
|
|
8517
|
+
todosInspectLine(todosProjectPath, taskId, todosDbPath),
|
|
8480
8518
|
...commandLines
|
|
8481
|
-
];
|
|
8519
|
+
].filter((line) => Boolean(line));
|
|
8482
8520
|
}
|
|
8483
8521
|
function worktreePrompt(plan) {
|
|
8484
8522
|
if (plan.enabled) {
|
|
@@ -8515,10 +8553,19 @@ function worktreeContextFragment(plan) {
|
|
|
8515
8553
|
function shellQuote3(value) {
|
|
8516
8554
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
8517
8555
|
}
|
|
8518
|
-
function
|
|
8556
|
+
function todosEnvPrefix(todosDbPath) {
|
|
8557
|
+
return todosDbPath ? `TODOS_DB_PATH=${shellQuote3(todosDbPath)} HASNA_TODOS_DB_PATH=${shellQuote3(todosDbPath)} ` : "";
|
|
8558
|
+
}
|
|
8559
|
+
function todosCommand(todosProjectPath, todosDbPath) {
|
|
8560
|
+
return `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath}`;
|
|
8561
|
+
}
|
|
8562
|
+
function shellTodosCommand(todosProjectPath, todosDbPath) {
|
|
8563
|
+
return `${todosEnvPrefix(todosDbPath)}todos --project ${shellQuote3(todosProjectPath)}`;
|
|
8564
|
+
}
|
|
8565
|
+
function sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath) {
|
|
8519
8566
|
return [
|
|
8520
8567
|
"set -euo pipefail",
|
|
8521
|
-
|
|
8568
|
+
`${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote3(taskId)} >/dev/null`,
|
|
8522
8569
|
`printf "source task %s resolved in todos project %s\\n" ${shellQuote3(taskId)} ${shellQuote3(todosProjectPath)}`
|
|
8523
8570
|
].join(`
|
|
8524
8571
|
`);
|
|
@@ -8578,10 +8625,10 @@ var LIFECYCLE_GATE_SCRIPT_TAIL = [
|
|
|
8578
8625
|
"console.log(`task lifecycle ${stage} gate passed for ${task.id || task.taskId || 'task'} status=${status || 'unknown'}`);"
|
|
8579
8626
|
].join(`
|
|
8580
8627
|
`);
|
|
8581
|
-
function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker) {
|
|
8628
|
+
function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker, todosDbPath) {
|
|
8582
8629
|
return [
|
|
8583
8630
|
"set -euo pipefail",
|
|
8584
|
-
`task_json="$(
|
|
8631
|
+
`task_json="$(${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote3(taskId)})"`,
|
|
8585
8632
|
`TASK_JSON="$task_json" STAGE=${shellQuote3(stage)} bun - <<'BUN'`,
|
|
8586
8633
|
LIFECYCLE_GATE_SCRIPT_HEAD,
|
|
8587
8634
|
`const goMarker = ${JSON.stringify(goMarker)};`,
|
|
@@ -8597,6 +8644,7 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
8597
8644
|
"const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
|
|
8598
8645
|
"const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
|
|
8599
8646
|
"const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
|
|
8647
|
+
"const todosDbPath = process.env.OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH || '';",
|
|
8600
8648
|
"const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
|
|
8601
8649
|
"const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
|
|
8602
8650
|
"const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
|
|
@@ -8614,7 +8662,8 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
8614
8662
|
"};",
|
|
8615
8663
|
"const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
|
|
8616
8664
|
"const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
|
|
8617
|
-
"const
|
|
8665
|
+
"const todosEnv = todosDbPath ? { ...process.env, TODOS_DB_PATH: todosDbPath, HASNA_TODOS_DB_PATH: todosDbPath } : process.env;",
|
|
8666
|
+
"const todos = (...args) => run(todosBin, todosArgs(...args), { env: todosEnv });",
|
|
8618
8667
|
"const comment = (text) => {",
|
|
8619
8668
|
" const result = todos('comment', taskId, text);",
|
|
8620
8669
|
" if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
|
|
@@ -8743,6 +8792,7 @@ function prHandoffCommand(opts) {
|
|
|
8743
8792
|
`export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote3(opts.artifactPath)}`,
|
|
8744
8793
|
`export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote3(opts.taskId)}`,
|
|
8745
8794
|
`export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote3(opts.todosProjectPath)}`,
|
|
8795
|
+
opts.todosDbPath ? `export OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH=${shellQuote3(opts.todosDbPath)}` : undefined,
|
|
8746
8796
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote3(opts.worktreeCwd)}`,
|
|
8747
8797
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote3(opts.worktreeRoot)}`,
|
|
8748
8798
|
`export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote3(opts.expectedBranch)}`,
|
|
@@ -8753,7 +8803,7 @@ function prHandoffCommand(opts) {
|
|
|
8753
8803
|
"bun - <<'BUN'",
|
|
8754
8804
|
PR_HANDOFF_SCRIPT,
|
|
8755
8805
|
"BUN"
|
|
8756
|
-
].join(`
|
|
8806
|
+
].filter((line) => Boolean(line)).join(`
|
|
8757
8807
|
`);
|
|
8758
8808
|
}
|
|
8759
8809
|
// src/lib/templates-custom.ts
|
|
@@ -9343,12 +9393,12 @@ function commandStep(opts) {
|
|
|
9343
9393
|
...opts.blockedExitCodes ? { blockedExitCodes: opts.blockedExitCodes } : {}
|
|
9344
9394
|
};
|
|
9345
9395
|
}
|
|
9346
|
-
function sourceTaskGateStep(todosProjectPath, taskId, plan, description) {
|
|
9396
|
+
function sourceTaskGateStep(todosProjectPath, taskId, plan, description, todosDbPath) {
|
|
9347
9397
|
return commandStep({
|
|
9348
9398
|
id: "source-task-gate",
|
|
9349
9399
|
name: "Source Task Gate",
|
|
9350
9400
|
description,
|
|
9351
|
-
command: sourceTaskGateCommand(todosProjectPath, taskId),
|
|
9401
|
+
command: sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath),
|
|
9352
9402
|
cwd: plan.originalCwd,
|
|
9353
9403
|
timeoutMs: 60000,
|
|
9354
9404
|
blockedExitCodes: GATE_BLOCKED_EXIT_CODES
|
|
@@ -9360,7 +9410,7 @@ function lifecycleGateStep(opts) {
|
|
|
9360
9410
|
name: opts.stage === "triage" ? "Triage Gate" : "Planner Gate",
|
|
9361
9411
|
description: opts.description,
|
|
9362
9412
|
dependsOn: opts.dependsOn,
|
|
9363
|
-
command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker),
|
|
9413
|
+
command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker, opts.todosDbPath),
|
|
9364
9414
|
cwd: opts.plan.originalCwd,
|
|
9365
9415
|
timeoutMs: 2 * 60000,
|
|
9366
9416
|
blockedExitCodes: GATE_BLOCKED_EXIT_CODES
|
|
@@ -9369,7 +9419,7 @@ function lifecycleGateStep(opts) {
|
|
|
9369
9419
|
function prHandoffArtifactPath(plan, taskId) {
|
|
9370
9420
|
return join7(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
|
|
9371
9421
|
}
|
|
9372
|
-
function prHandoffStep(input, plan, todosProjectPath) {
|
|
9422
|
+
function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
|
|
9373
9423
|
return commandStep({
|
|
9374
9424
|
id: "pr-handoff",
|
|
9375
9425
|
name: "PR Handoff",
|
|
@@ -9379,6 +9429,7 @@ function prHandoffStep(input, plan, todosProjectPath) {
|
|
|
9379
9429
|
artifactPath: prHandoffArtifactPath(plan, input.taskId),
|
|
9380
9430
|
taskId: input.taskId,
|
|
9381
9431
|
todosProjectPath,
|
|
9432
|
+
todosDbPath,
|
|
9382
9433
|
worktreeCwd: plan.cwd,
|
|
9383
9434
|
worktreeRoot: plan.path ?? plan.cwd,
|
|
9384
9435
|
expectedBranch: plan.branch ?? ""
|
|
@@ -9465,6 +9516,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9465
9516
|
if (!input.projectPath?.trim())
|
|
9466
9517
|
throw new Error("projectPath is required");
|
|
9467
9518
|
const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
|
|
9519
|
+
const todosDbPath = input.todosDbPath;
|
|
9468
9520
|
const plan = worktreePlan(input, input.taskId);
|
|
9469
9521
|
const taskContext = {
|
|
9470
9522
|
taskId: input.taskId,
|
|
@@ -9475,15 +9527,17 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9475
9527
|
projectPath: input.projectPath,
|
|
9476
9528
|
routeProjectPath: input.routeProjectPath,
|
|
9477
9529
|
projectGroup: input.projectGroup,
|
|
9530
|
+
todosProjectPath: input.todosProjectPath ?? (input.todosDbPath ? todosProjectPath : undefined),
|
|
9531
|
+
todosDbPath,
|
|
9478
9532
|
worktree: worktreeContextFragment(plan)
|
|
9479
9533
|
};
|
|
9480
9534
|
const workerPrompt = [
|
|
9481
9535
|
...goalHeaderFragment(`Complete todos task ${input.taskId} in ${input.projectPath}.`, "worker", "task"),
|
|
9482
9536
|
worktreePrompt(plan),
|
|
9483
9537
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
9484
|
-
todosStartLine(todosProjectPath, input.taskId),
|
|
9485
|
-
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers")
|
|
9486
|
-
]),
|
|
9538
|
+
todosStartLine(todosProjectPath, input.taskId, todosDbPath),
|
|
9539
|
+
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers", todosDbPath)
|
|
9540
|
+
], todosDbPath),
|
|
9487
9541
|
"Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
|
|
9488
9542
|
"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.",
|
|
9489
9543
|
NO_TMUX_DISPATCH_FRAGMENT,
|
|
@@ -9496,9 +9550,9 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9496
9550
|
...goalHeaderFragment(`Verify todos task ${input.taskId} after the worker step.`, "verifier", "task"),
|
|
9497
9551
|
worktreePrompt(plan),
|
|
9498
9552
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
9499
|
-
todosVerificationLine(todosProjectPath, input.taskId),
|
|
9500
|
-
todosDoneLine(todosProjectPath, input.taskId)
|
|
9501
|
-
]),
|
|
9553
|
+
todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
|
|
9554
|
+
todosDoneLine(todosProjectPath, input.taskId, todosDbPath)
|
|
9555
|
+
], todosDbPath),
|
|
9502
9556
|
adversarialReviewFragment("the task, repository state, commits, tests, and worker evidence", TASK_REVIEW_FOCUS),
|
|
9503
9557
|
verifierRuntimeGuidance(input),
|
|
9504
9558
|
TASK_VERIFIER_DECISION_FRAGMENT,
|
|
@@ -9513,7 +9567,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9513
9567
|
description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
|
|
9514
9568
|
version: 1,
|
|
9515
9569
|
steps: [
|
|
9516
|
-
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable."),
|
|
9570
|
+
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable.", todosDbPath),
|
|
9517
9571
|
...workerVerifierSteps({
|
|
9518
9572
|
input,
|
|
9519
9573
|
seed: input.taskId,
|
|
@@ -9533,6 +9587,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9533
9587
|
if (!input.projectPath?.trim())
|
|
9534
9588
|
throw new Error("projectPath is required");
|
|
9535
9589
|
const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
|
|
9590
|
+
const todosDbPath = input.todosDbPath;
|
|
9536
9591
|
const plan = worktreePlan(input, input.taskId);
|
|
9537
9592
|
const taskContext = {
|
|
9538
9593
|
taskId: input.taskId,
|
|
@@ -9544,6 +9599,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9544
9599
|
routeProjectPath: input.routeProjectPath,
|
|
9545
9600
|
projectGroup: input.projectGroup,
|
|
9546
9601
|
todosProjectPath,
|
|
9602
|
+
todosDbPath,
|
|
9547
9603
|
worktree: worktreeContextFragment(plan)
|
|
9548
9604
|
};
|
|
9549
9605
|
const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
|
|
@@ -9557,8 +9613,8 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9557
9613
|
const shared = [
|
|
9558
9614
|
worktreePrompt(plan),
|
|
9559
9615
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
9560
|
-
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
|
|
9561
|
-
]),
|
|
9616
|
+
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker", todosDbPath)
|
|
9617
|
+
], todosDbPath),
|
|
9562
9618
|
NO_TMUX_DISPATCH_FRAGMENT,
|
|
9563
9619
|
"Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
|
|
9564
9620
|
"",
|
|
@@ -9567,7 +9623,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9567
9623
|
].join(`
|
|
9568
9624
|
`);
|
|
9569
9625
|
const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
|
|
9570
|
-
const blockTaskCommand =
|
|
9626
|
+
const blockTaskCommand = `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
|
|
9571
9627
|
const gateStopFragment = (stage, stops) => `The deterministic ${stage} gate will stop ${stops} unless the latest ${stage} marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state.`;
|
|
9572
9628
|
const triagePrompt = [
|
|
9573
9629
|
...goalHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
|
|
@@ -9595,7 +9651,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9595
9651
|
const workerPrompt = [
|
|
9596
9652
|
...goalHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
|
|
9597
9653
|
shared,
|
|
9598
|
-
todosStartLine(todosProjectPath, input.taskId),
|
|
9654
|
+
todosStartLine(todosProjectPath, input.taskId, todosDbPath),
|
|
9599
9655
|
"Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record changed files, commits, evidence, blockers, and residual risks.",
|
|
9600
9656
|
input.prHandoff ? `When only GitHub network access is blocked after a successful commit/validation, record the handoff artifact at ${handoffArtifactPath} instead of repeatedly retrying push/PR creation.` : undefined,
|
|
9601
9657
|
WORKER_LEAVES_COMPLETION_FRAGMENT
|
|
@@ -9604,8 +9660,8 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9604
9660
|
const verifierPrompt = [
|
|
9605
9661
|
...goalHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
|
|
9606
9662
|
shared,
|
|
9607
|
-
todosVerificationLine(todosProjectPath, input.taskId),
|
|
9608
|
-
todosDoneLine(todosProjectPath, input.taskId),
|
|
9663
|
+
todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
|
|
9664
|
+
todosDoneLine(todosProjectPath, input.taskId, todosDbPath),
|
|
9609
9665
|
adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
|
|
9610
9666
|
verifierRuntimeGuidance(input),
|
|
9611
9667
|
input.prHandoff ? `If ${handoffArtifactPath} exists and there is no PR URL evidence, verify that the PR handoff step queued or completed a bounded handoff; leave the original task open or blocked until PR evidence is recorded.` : undefined,
|
|
@@ -9614,7 +9670,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9614
9670
|
].filter(Boolean).join(`
|
|
9615
9671
|
`);
|
|
9616
9672
|
const steps = [
|
|
9617
|
-
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable."),
|
|
9673
|
+
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable.", todosDbPath),
|
|
9618
9674
|
{
|
|
9619
9675
|
id: "triage",
|
|
9620
9676
|
name: "Triage",
|
|
@@ -9628,6 +9684,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9628
9684
|
description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
|
|
9629
9685
|
dependsOn: ["triage"],
|
|
9630
9686
|
todosProjectPath,
|
|
9687
|
+
todosDbPath,
|
|
9631
9688
|
taskId: input.taskId,
|
|
9632
9689
|
goMarker: gateMarker("triage", "go"),
|
|
9633
9690
|
blockedMarker: gateMarker("triage", "blocked"),
|
|
@@ -9646,6 +9703,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9646
9703
|
description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
|
|
9647
9704
|
dependsOn: ["planner"],
|
|
9648
9705
|
todosProjectPath,
|
|
9706
|
+
todosDbPath,
|
|
9649
9707
|
taskId: input.taskId,
|
|
9650
9708
|
goMarker: gateMarker("planner", "go"),
|
|
9651
9709
|
blockedMarker: gateMarker("planner", "blocked"),
|
|
@@ -9661,7 +9719,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9661
9719
|
}
|
|
9662
9720
|
];
|
|
9663
9721
|
if (input.prHandoff) {
|
|
9664
|
-
steps.push(prHandoffStep(input, plan, todosProjectPath));
|
|
9722
|
+
steps.push(prHandoffStep(input, plan, todosProjectPath, todosDbPath));
|
|
9665
9723
|
}
|
|
9666
9724
|
steps.push({
|
|
9667
9725
|
id: "verifier",
|
|
@@ -9886,6 +9944,7 @@ function renderLifecycleBoundedTemplate(id, values) {
|
|
|
9886
9944
|
taskTitle: values.taskTitle,
|
|
9887
9945
|
taskDescription: values.taskDescription,
|
|
9888
9946
|
todosProjectPath: values.todosProjectPath ?? values.todosProject,
|
|
9947
|
+
todosDbPath: values.todosDbPath,
|
|
9889
9948
|
triageAuthProfile: values.triageAuthProfile,
|
|
9890
9949
|
plannerAuthProfile: values.plannerAuthProfile,
|
|
9891
9950
|
triageAccount: accountVar(values.triageAccount, values.accountTool),
|
|
@@ -9950,6 +10009,7 @@ function renderBuiltinLoopTemplate(id, values) {
|
|
|
9950
10009
|
taskTitle: values.taskTitle,
|
|
9951
10010
|
taskDescription: values.taskDescription,
|
|
9952
10011
|
todosProjectPath: values.todosProjectPath ?? values.todosProject,
|
|
10012
|
+
todosDbPath: values.todosDbPath,
|
|
9953
10013
|
eventId: values.eventId,
|
|
9954
10014
|
eventType: values.eventType
|
|
9955
10015
|
});
|
|
@@ -10397,7 +10457,8 @@ function writeRouteEvidence(kind, value, evidenceDir) {
|
|
|
10397
10457
|
mkdirSync9(evidenceDir, { recursive: true, mode: 448 });
|
|
10398
10458
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
10399
10459
|
const evidencePath = join9(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
10400
|
-
|
|
10460
|
+
const encoded = scrubSecrets(JSON.stringify(scrubSecretsDeep(value), null, 2));
|
|
10461
|
+
writeFileSync5(evidencePath, encoded, { mode: 384, flag: "wx" });
|
|
10401
10462
|
return evidencePath;
|
|
10402
10463
|
}
|
|
10403
10464
|
function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
@@ -10993,6 +11054,70 @@ function todosTaskRouteTemplateId(opts) {
|
|
|
10993
11054
|
}
|
|
10994
11055
|
return id;
|
|
10995
11056
|
}
|
|
11057
|
+
function sourceCandidateRecords(data, metadata) {
|
|
11058
|
+
const records = [data, metadata];
|
|
11059
|
+
for (const record of [data, metadata]) {
|
|
11060
|
+
const sourceTask = objectField2(record.source_task) ?? objectField2(record.sourceTask);
|
|
11061
|
+
if (sourceTask)
|
|
11062
|
+
records.push(sourceTask);
|
|
11063
|
+
}
|
|
11064
|
+
return records;
|
|
11065
|
+
}
|
|
11066
|
+
function sourceStringField(records, keys) {
|
|
11067
|
+
for (const record of records) {
|
|
11068
|
+
for (const key of keys) {
|
|
11069
|
+
const value = stringField2(record[key]);
|
|
11070
|
+
if (value)
|
|
11071
|
+
return value;
|
|
11072
|
+
}
|
|
11073
|
+
}
|
|
11074
|
+
return;
|
|
11075
|
+
}
|
|
11076
|
+
function sourceBooleanField(records, keys) {
|
|
11077
|
+
for (const record of records) {
|
|
11078
|
+
for (const key of keys) {
|
|
11079
|
+
const value = record[key];
|
|
11080
|
+
if (typeof value === "boolean")
|
|
11081
|
+
return value;
|
|
11082
|
+
if (value === "true" || value === "1" || value === 1)
|
|
11083
|
+
return true;
|
|
11084
|
+
if (value === "false" || value === "0" || value === 0)
|
|
11085
|
+
return false;
|
|
11086
|
+
}
|
|
11087
|
+
}
|
|
11088
|
+
return;
|
|
11089
|
+
}
|
|
11090
|
+
function todosTaskSourceRef(data, metadata, taskId) {
|
|
11091
|
+
const records = sourceCandidateRecords(data, metadata);
|
|
11092
|
+
const sourceTaskKey = sourceStringField(records, ["source_task_key", "sourceTaskKey"]);
|
|
11093
|
+
const sourceStoreId = sourceStringField(records, ["source_store_id", "sourceStoreId"]);
|
|
11094
|
+
const sourceRepoPath = sourceStringField(records, ["source_repo_path", "sourceRepoPath"]);
|
|
11095
|
+
const sourceDbPath = sourceStringField(records, ["source_db_path", "sourceDbPath"]);
|
|
11096
|
+
const sourceSelectedByInput = sourceBooleanField(records, ["source_selected_by_input", "sourceSelectedByInput"]);
|
|
11097
|
+
const idempotencySource = sourceTaskKey ? "source_task_key" : sourceStoreId ? "source_store_id" : "legacy_task_id";
|
|
11098
|
+
return {
|
|
11099
|
+
sourceTaskKey,
|
|
11100
|
+
sourceStoreId,
|
|
11101
|
+
sourceRepoPath,
|
|
11102
|
+
sourceDbPath,
|
|
11103
|
+
sourceSelectedByInput,
|
|
11104
|
+
idempotencySource,
|
|
11105
|
+
idempotencyKey: sourceTaskKey ? `todos-task:${sourceTaskKey}` : sourceStoreId ? `todos-task:${sourceStoreId}:${taskId}` : `todos-task:${taskId}`
|
|
11106
|
+
};
|
|
11107
|
+
}
|
|
11108
|
+
function publicSourceTask(sourceTask) {
|
|
11109
|
+
if (!sourceTask.sourceTaskKey && !sourceTask.sourceStoreId && !sourceTask.sourceRepoPath && !sourceTask.sourceDbPath && sourceTask.sourceSelectedByInput === undefined) {
|
|
11110
|
+
return;
|
|
11111
|
+
}
|
|
11112
|
+
return {
|
|
11113
|
+
source_task_key: sourceTask.sourceTaskKey,
|
|
11114
|
+
source_store_id: sourceTask.sourceStoreId,
|
|
11115
|
+
source_repo_path: sourceTask.sourceRepoPath,
|
|
11116
|
+
source_db_path: sourceTask.sourceDbPath,
|
|
11117
|
+
source_selected_by_input: sourceTask.sourceSelectedByInput,
|
|
11118
|
+
idempotency_source: sourceTask.idempotencySource
|
|
11119
|
+
};
|
|
11120
|
+
}
|
|
10996
11121
|
async function readEventEnvelopeInput(opts = {}) {
|
|
10997
11122
|
const raw = opts.eventJson ?? (opts.eventFile ? readFileSync8(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
|
|
10998
11123
|
const event = JSON.parse(raw);
|
|
@@ -11169,11 +11294,13 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11169
11294
|
const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
|
|
11170
11295
|
if (!taskId)
|
|
11171
11296
|
throw new ValidationError("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
|
|
11297
|
+
const sourceTask = todosTaskSourceRef(data, metadata, taskId);
|
|
11298
|
+
const sourceTaskPublic = publicSourceTask(sourceTask);
|
|
11172
11299
|
const eligibility = taskRouteEligibility(data, metadata);
|
|
11173
11300
|
if (!eligibility.eligible) {
|
|
11174
11301
|
return {
|
|
11175
11302
|
kind: "skipped",
|
|
11176
|
-
value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility },
|
|
11303
|
+
value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility, sourceTask: sourceTaskPublic },
|
|
11177
11304
|
human: `skipped task ${taskId}: ${eligibility.reason}`
|
|
11178
11305
|
};
|
|
11179
11306
|
}
|
|
@@ -11188,11 +11315,11 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11188
11315
|
"project_canonical_path",
|
|
11189
11316
|
"cwd"
|
|
11190
11317
|
]);
|
|
11191
|
-
const projectPath = dataProjectPath ?? metadataProjectPath ?? opts.projectPath ?? process.cwd();
|
|
11318
|
+
const projectPath = dataProjectPath ?? metadataProjectPath ?? sourceTask.sourceRepoPath ?? opts.projectPath ?? process.cwd();
|
|
11192
11319
|
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve7(projectPath);
|
|
11193
11320
|
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
11194
11321
|
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
11195
|
-
const idempotencyKey =
|
|
11322
|
+
const idempotencyKey = sourceTask.idempotencyKey;
|
|
11196
11323
|
const idempotencySuffix = stableSuffix(idempotencyKey);
|
|
11197
11324
|
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
11198
11325
|
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
@@ -11205,7 +11332,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11205
11332
|
const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
|
|
11206
11333
|
const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
11207
11334
|
const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
|
|
11208
|
-
return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
|
|
11335
|
+
return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: sourceTaskPublic ? { sourceTask: sourceTaskPublic } : {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
|
|
11209
11336
|
}
|
|
11210
11337
|
} finally {
|
|
11211
11338
|
store.close();
|
|
@@ -11268,12 +11395,14 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11268
11395
|
prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
|
|
11269
11396
|
eventId: event.id,
|
|
11270
11397
|
eventType: event.type,
|
|
11271
|
-
todosProjectPath: opts.todosProject
|
|
11398
|
+
todosProjectPath: sourceTask.sourceRepoPath ?? opts.todosProject,
|
|
11399
|
+
todosDbPath: sourceTask.sourceDbPath
|
|
11272
11400
|
};
|
|
11273
11401
|
const workflowContext = {
|
|
11274
11402
|
name: workflowName,
|
|
11275
11403
|
type: "todos-task-event-workflow",
|
|
11276
|
-
event: event.id
|
|
11404
|
+
event: event.id,
|
|
11405
|
+
sourceTask: sourceTaskPublic
|
|
11277
11406
|
};
|
|
11278
11407
|
let workflowBody = templateId === TASK_LIFECYCLE_TEMPLATE_ID ? renderTaskLifecycleWorkflow(workflowInput) : renderTodosTaskWorkerVerifierWorkflow(workflowInput);
|
|
11279
11408
|
workflowBody.name = workflowName;
|
|
@@ -11286,13 +11415,13 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11286
11415
|
kind: "event",
|
|
11287
11416
|
id: event.id,
|
|
11288
11417
|
dedupeKey: idempotencyKey,
|
|
11289
|
-
raw: { type: event.type, source: event.source, subject: event.subject }
|
|
11418
|
+
raw: { type: event.type, source: event.source, subject: event.subject, sourceTask: sourceTaskPublic }
|
|
11290
11419
|
},
|
|
11291
11420
|
subjectRef: {
|
|
11292
11421
|
kind: "task",
|
|
11293
11422
|
id: taskId,
|
|
11294
11423
|
path: routeProjectPath,
|
|
11295
|
-
raw: { title: taskTitle, description: taskDescription }
|
|
11424
|
+
raw: { title: taskTitle, description: taskDescription, sourceTask: sourceTaskPublic }
|
|
11296
11425
|
},
|
|
11297
11426
|
intent: "route",
|
|
11298
11427
|
scope: {
|
|
@@ -11305,7 +11434,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11305
11434
|
accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
|
|
11306
11435
|
providerRouting: providerRoutingPublic(providerRouting),
|
|
11307
11436
|
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
|
|
11308
|
-
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
11437
|
+
concurrencyGroup: projectGroup ?? routeProjectPath,
|
|
11438
|
+
sourceTask: sourceTaskPublic
|
|
11309
11439
|
},
|
|
11310
11440
|
outputPolicy: {
|
|
11311
11441
|
report: "always",
|
|
@@ -11330,9 +11460,10 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11330
11460
|
humanSubject: `task ${taskId}`,
|
|
11331
11461
|
valueExtras: {
|
|
11332
11462
|
providerRouting: providerRoutingPublic(providerRouting),
|
|
11333
|
-
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined
|
|
11463
|
+
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
|
|
11464
|
+
sourceTask: sourceTaskPublic
|
|
11334
11465
|
},
|
|
11335
|
-
dedupeValueExtras: {}
|
|
11466
|
+
dedupeValueExtras: sourceTaskPublic ? { sourceTask: sourceTaskPublic } : {}
|
|
11336
11467
|
});
|
|
11337
11468
|
}
|
|
11338
11469
|
function routeGenericEvent(event, opts) {
|
|
@@ -11469,7 +11600,7 @@ function runLocalCommand(command, args, opts = {}) {
|
|
|
11469
11600
|
encoding: "utf8",
|
|
11470
11601
|
timeout: opts.timeoutMs ?? 30000,
|
|
11471
11602
|
maxBuffer: opts.maxBuffer ?? 8 * 1024 * 1024,
|
|
11472
|
-
env: process.env
|
|
11603
|
+
env: { ...process.env, ...opts.env }
|
|
11473
11604
|
});
|
|
11474
11605
|
return {
|
|
11475
11606
|
ok: result.status === 0,
|
|
@@ -11536,8 +11667,66 @@ function taskField(task, keys) {
|
|
|
11536
11667
|
}
|
|
11537
11668
|
return;
|
|
11538
11669
|
}
|
|
11539
|
-
function
|
|
11540
|
-
|
|
11670
|
+
function booleanTaskField(task, keys) {
|
|
11671
|
+
for (const key of keys) {
|
|
11672
|
+
const value = task[key];
|
|
11673
|
+
if (typeof value === "boolean")
|
|
11674
|
+
return value;
|
|
11675
|
+
if (value === "true" || value === "1" || value === 1)
|
|
11676
|
+
return true;
|
|
11677
|
+
if (value === "false" || value === "0" || value === 0)
|
|
11678
|
+
return false;
|
|
11679
|
+
}
|
|
11680
|
+
return;
|
|
11681
|
+
}
|
|
11682
|
+
function normalizedSourceRoots(opts) {
|
|
11683
|
+
return listFromRepeatedOpts(opts.todosSourceRoot) ?? [];
|
|
11684
|
+
}
|
|
11685
|
+
function normalizedSourceStores(opts) {
|
|
11686
|
+
return listFromRepeatedOpts(opts.todosSourceStore) ?? [];
|
|
11687
|
+
}
|
|
11688
|
+
function normalizedSourceIncludes(opts) {
|
|
11689
|
+
return listFromRepeatedOpts(opts.todosSourceInclude) ?? [];
|
|
11690
|
+
}
|
|
11691
|
+
function normalizedSourceExcludes(opts) {
|
|
11692
|
+
return listFromRepeatedOpts(opts.todosSourceExclude) ?? [];
|
|
11693
|
+
}
|
|
11694
|
+
function hasTodosSourceOptions(opts) {
|
|
11695
|
+
return Boolean(normalizedSourceRoots(opts).length || normalizedSourceStores(opts).length || normalizedSourceIncludes(opts).length || normalizedSourceExcludes(opts).length);
|
|
11696
|
+
}
|
|
11697
|
+
function taskSourceIdentity(task, taskId) {
|
|
11698
|
+
const sourceTaskKey = taskField(task, ["source_task_key", "sourceTaskKey"]);
|
|
11699
|
+
const sourceStoreId = taskField(task, ["source_store_id", "sourceStoreId"]);
|
|
11700
|
+
const identity = sourceTaskKey ?? (sourceStoreId && taskId ? `${sourceStoreId}:${taskId}` : undefined);
|
|
11701
|
+
return {
|
|
11702
|
+
sourceTaskKey,
|
|
11703
|
+
sourceStoreId,
|
|
11704
|
+
sourceRepoPath: taskField(task, ["source_repo_path", "sourceRepoPath"]),
|
|
11705
|
+
sourceDbPath: taskField(task, ["source_db_path", "sourceDbPath"]),
|
|
11706
|
+
sourceSelectedByInput: booleanTaskField(task, ["source_selected_by_input", "sourceSelectedByInput"]),
|
|
11707
|
+
idempotencyIdentity: identity
|
|
11708
|
+
};
|
|
11709
|
+
}
|
|
11710
|
+
function publicSourceTaskIdentity(source) {
|
|
11711
|
+
if (!source.sourceTaskKey && !source.sourceStoreId && !source.sourceRepoPath && !source.sourceDbPath && source.sourceSelectedByInput === undefined) {
|
|
11712
|
+
return;
|
|
11713
|
+
}
|
|
11714
|
+
return {
|
|
11715
|
+
source_task_key: source.sourceTaskKey,
|
|
11716
|
+
source_store_id: source.sourceStoreId,
|
|
11717
|
+
source_repo_path: source.sourceRepoPath,
|
|
11718
|
+
source_db_path: source.sourceDbPath,
|
|
11719
|
+
source_selected_by_input: source.sourceSelectedByInput
|
|
11720
|
+
};
|
|
11721
|
+
}
|
|
11722
|
+
function taskListValues(task) {
|
|
11723
|
+
const values = [
|
|
11724
|
+
taskField(task, ["task_list_id", "taskListId", "task_list_slug", "taskListSlug", "task_list_name", "taskListName"]),
|
|
11725
|
+
stringField2(task.task_list?.id),
|
|
11726
|
+
stringField2(task.task_list?.slug),
|
|
11727
|
+
stringField2(task.task_list?.name)
|
|
11728
|
+
].filter((value) => Boolean(value));
|
|
11729
|
+
return [...new Set(values)];
|
|
11541
11730
|
}
|
|
11542
11731
|
function taskProjectId(task) {
|
|
11543
11732
|
return taskField(task, ["project_id", "projectId"]);
|
|
@@ -11551,12 +11740,14 @@ function taskProjectPath(task) {
|
|
|
11551
11740
|
const metadata = objectField2(task.metadata) ?? {};
|
|
11552
11741
|
return taskField(task, ["project_path", "projectPath"]) ?? taskEventField(metadata, ["project_path", "projectPath", "project_canonical_path"]) ?? taskDescriptionProjectPath(task) ?? taskField(task, ["working_dir", "workingDir", "cwd"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "cwd"]);
|
|
11553
11742
|
}
|
|
11554
|
-
function taskDrainEvent(task) {
|
|
11743
|
+
function taskDrainEvent(task, opts = {}) {
|
|
11555
11744
|
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
11556
11745
|
if (!taskId)
|
|
11557
11746
|
throw new Error("todos ready returned a task without an id");
|
|
11558
11747
|
const metadata = objectField2(task.metadata) ?? {};
|
|
11559
11748
|
const workingDir = taskProjectPath(task);
|
|
11749
|
+
const sourceIdentity = taskSourceIdentity(task, taskId);
|
|
11750
|
+
const sourceTask = publicSourceTaskIdentity(sourceIdentity);
|
|
11560
11751
|
const data = {
|
|
11561
11752
|
...task,
|
|
11562
11753
|
id: taskId,
|
|
@@ -11566,14 +11757,21 @@ function taskDrainEvent(task) {
|
|
|
11566
11757
|
tags: tagsFromValue2(task.tags),
|
|
11567
11758
|
metadata
|
|
11568
11759
|
};
|
|
11760
|
+
if (opts.sourceRouteEligible) {
|
|
11761
|
+
data.route_enabled = true;
|
|
11762
|
+
data.route_enabled_by = "source_route_state";
|
|
11763
|
+
}
|
|
11569
11764
|
if (workingDir) {
|
|
11570
11765
|
data.working_dir = workingDir;
|
|
11571
11766
|
data.project_path = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
|
|
11572
11767
|
data.cwd = taskField(task, ["cwd"]) ?? workingDir;
|
|
11573
11768
|
}
|
|
11769
|
+
if (sourceTask)
|
|
11770
|
+
data.source_task = sourceTask;
|
|
11771
|
+
const eventId = sourceIdentity.idempotencyIdentity ? `drain-todos-task-${slugSegment2(taskId, "task")}-${stableSuffix(sourceIdentity.idempotencyIdentity)}` : `drain-todos-task-${taskId}`;
|
|
11574
11772
|
const time = new Date().toISOString();
|
|
11575
11773
|
return {
|
|
11576
|
-
id:
|
|
11774
|
+
id: eventId,
|
|
11577
11775
|
type: "task.created",
|
|
11578
11776
|
source: "@hasna/todos",
|
|
11579
11777
|
subject: taskId,
|
|
@@ -11584,6 +11782,8 @@ function taskDrainEvent(task) {
|
|
|
11584
11782
|
metadata: {
|
|
11585
11783
|
...metadata,
|
|
11586
11784
|
...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
|
|
11785
|
+
...sourceTask,
|
|
11786
|
+
...sourceTask ? { source_task: sourceTask } : {},
|
|
11587
11787
|
drained_by: "@hasna/loops",
|
|
11588
11788
|
drained_from: "todos ready"
|
|
11589
11789
|
}
|
|
@@ -11592,6 +11792,7 @@ function taskDrainEvent(task) {
|
|
|
11592
11792
|
function compactDrainResult(result) {
|
|
11593
11793
|
const value = result.value;
|
|
11594
11794
|
const event = objectField2(value.event);
|
|
11795
|
+
const sourceTask = objectField2(value.sourceTask) ?? objectField2(objectField2(event?.metadata)?.source_task) ?? objectField2(objectField2(event?.data)?.source_task);
|
|
11595
11796
|
const loop = objectField2(value.loop);
|
|
11596
11797
|
const workflow = objectField2(value.workflow);
|
|
11597
11798
|
const throttle = objectField2(value.throttle);
|
|
@@ -11608,26 +11809,75 @@ function compactDrainResult(result) {
|
|
|
11608
11809
|
workflowId: stringField2(workflow?.id),
|
|
11609
11810
|
workflowName: stringField2(workflow?.name),
|
|
11610
11811
|
providerRouting,
|
|
11812
|
+
sourceTask,
|
|
11611
11813
|
requeue,
|
|
11612
11814
|
queuedAtSource: value.queuedAtSource
|
|
11613
11815
|
};
|
|
11614
11816
|
}
|
|
11615
|
-
function
|
|
11616
|
-
|
|
11617
|
-
|
|
11618
|
-
|
|
11619
|
-
if (!result.ok)
|
|
11620
|
-
throw new Error(result.stderr || result.error || "todos ready failed");
|
|
11621
|
-
let parsed;
|
|
11817
|
+
function numberField2(value) {
|
|
11818
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
11819
|
+
}
|
|
11820
|
+
function parseTodosReadyJson(stdout) {
|
|
11622
11821
|
try {
|
|
11623
|
-
|
|
11822
|
+
return JSON.parse(stdout || "[]");
|
|
11624
11823
|
} catch (error) {
|
|
11625
11824
|
const message = error instanceof Error ? error.message : String(error);
|
|
11626
|
-
throw new Error(`failed to parse todos ready --json output (${
|
|
11627
|
-
}
|
|
11628
|
-
|
|
11629
|
-
|
|
11630
|
-
|
|
11825
|
+
throw new Error(`failed to parse todos ready --json output (${stdout.length} bytes): ${message}`);
|
|
11826
|
+
}
|
|
11827
|
+
}
|
|
11828
|
+
function loadReadyTodosTasks(opts, scanLimit, sourceMode) {
|
|
11829
|
+
const sourceRoots = normalizedSourceRoots(opts);
|
|
11830
|
+
const sourceStores = normalizedSourceStores(opts);
|
|
11831
|
+
const sourceIncludes = normalizedSourceIncludes(opts);
|
|
11832
|
+
const sourceExcludes = normalizedSourceExcludes(opts);
|
|
11833
|
+
if (sourceMode && !sourceRoots.length && !sourceStores.length) {
|
|
11834
|
+
throw new ValidationError("source discovery requires at least one --todos-source-root or --todos-source-store");
|
|
11835
|
+
}
|
|
11836
|
+
const args = sourceMode ? ["--json", "ready"] : ["--project", opts.todosProject ?? defaultLoopsProject(), "--json", "ready"];
|
|
11837
|
+
if (sourceMode) {
|
|
11838
|
+
for (const root of sourceRoots)
|
|
11839
|
+
args.push("--source-root", root);
|
|
11840
|
+
for (const store of sourceStores)
|
|
11841
|
+
args.push("--source-store", store);
|
|
11842
|
+
for (const pattern of sourceIncludes)
|
|
11843
|
+
args.push("--include", pattern);
|
|
11844
|
+
for (const pattern of sourceExcludes)
|
|
11845
|
+
args.push("--exclude", pattern);
|
|
11846
|
+
}
|
|
11847
|
+
args.push("--limit", String(scanLimit));
|
|
11848
|
+
const result = runLocalCommandWithStdoutFile("todos", args, { timeoutMs: 60000, maxBuffer: 64 * 1024 * 1024 });
|
|
11849
|
+
if (!result.ok)
|
|
11850
|
+
throw new Error(result.stderr || result.error || "todos ready failed");
|
|
11851
|
+
const parsed = parseTodosReadyJson(result.stdout);
|
|
11852
|
+
if (!sourceMode) {
|
|
11853
|
+
if (!Array.isArray(parsed))
|
|
11854
|
+
throw new Error("todos ready --json returned a non-array value");
|
|
11855
|
+
return { tasks: parsed, sourceMode, sourceRoots, sourceStores, sourceIncludes, sourceExcludes };
|
|
11856
|
+
}
|
|
11857
|
+
const response = objectField2(parsed);
|
|
11858
|
+
if (!response)
|
|
11859
|
+
throw new Error("todos ready --json source discovery returned a non-object value");
|
|
11860
|
+
if (response.schema_version !== "todos.task_route_sources.v1") {
|
|
11861
|
+
throw new Error(`todos ready source discovery returned unsupported schema_version: ${String(response.schema_version)}`);
|
|
11862
|
+
}
|
|
11863
|
+
if (!Array.isArray(response.candidates))
|
|
11864
|
+
throw new Error("todos ready source discovery returned missing candidates array");
|
|
11865
|
+
return {
|
|
11866
|
+
tasks: response.candidates,
|
|
11867
|
+
sourceMode,
|
|
11868
|
+
sourceRoots,
|
|
11869
|
+
sourceStores,
|
|
11870
|
+
sourceIncludes,
|
|
11871
|
+
sourceExcludes,
|
|
11872
|
+
sourceDiscovery: {
|
|
11873
|
+
schemaVersion: "todos.task_route_sources.v1",
|
|
11874
|
+
stores: Array.isArray(response.stores) ? response.stores : undefined,
|
|
11875
|
+
errors: Array.isArray(response.errors) ? response.errors : undefined,
|
|
11876
|
+
totalCandidateCount: numberField2(response.total_candidate_count),
|
|
11877
|
+
returnedCandidateCount: numberField2(response.returned_candidate_count),
|
|
11878
|
+
truncated: typeof response.truncated === "boolean" ? response.truncated : undefined
|
|
11879
|
+
}
|
|
11880
|
+
};
|
|
11631
11881
|
}
|
|
11632
11882
|
function resolveTaskListFilter(todosProject, filter) {
|
|
11633
11883
|
const wanted = filter?.trim();
|
|
@@ -11643,7 +11893,7 @@ function resolveTaskListFilter(todosProject, filter) {
|
|
|
11643
11893
|
function taskMatchesDrainFilters(task, filters) {
|
|
11644
11894
|
if (filters.projectId && taskProjectId(task) !== filters.projectId)
|
|
11645
11895
|
return false;
|
|
11646
|
-
if (filters.
|
|
11896
|
+
if (filters.taskList && !taskListValues(task).includes(filters.taskList))
|
|
11647
11897
|
return false;
|
|
11648
11898
|
if (filters.projectPathPrefix) {
|
|
11649
11899
|
const path = taskProjectPath(task);
|
|
@@ -11681,19 +11931,77 @@ function skippedDrainTask(task, event, reason, extra = {}) {
|
|
|
11681
11931
|
function isSkippableDrainRouteError(message) {
|
|
11682
11932
|
return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
|
|
11683
11933
|
}
|
|
11684
|
-
function
|
|
11934
|
+
function sourceRouteState(task) {
|
|
11935
|
+
return objectField2(task.route_state) ?? objectField2(task.routeState);
|
|
11936
|
+
}
|
|
11937
|
+
function sourceRouteReason(routeState) {
|
|
11938
|
+
const reasons = routeState?.reasons;
|
|
11939
|
+
const firstReason = Array.isArray(reasons) ? stringField2(reasons[0]) : undefined;
|
|
11940
|
+
return stringField2(routeState?.reason) ?? stringField2(routeState?.eligibility_reason) ?? stringField2(routeState?.eligibilityReason) ?? firstReason;
|
|
11941
|
+
}
|
|
11942
|
+
function sourceRouteEligibility(task) {
|
|
11943
|
+
const routeState = sourceRouteState(task);
|
|
11944
|
+
if (routeState?.eligible === true)
|
|
11945
|
+
return { eligible: true, routeState };
|
|
11946
|
+
const reason = sourceRouteReason(routeState) ?? "source route_state.eligible is not true";
|
|
11947
|
+
return { eligible: false, reason, routeState };
|
|
11948
|
+
}
|
|
11949
|
+
function sourceTaskMutationTarget(todosProject, task, opts = {}) {
|
|
11950
|
+
const source = taskSourceIdentity(task, taskField(task, ["id", "task_id", "taskId"]));
|
|
11951
|
+
if (source.sourceDbPath) {
|
|
11952
|
+
return {
|
|
11953
|
+
argsPrefix: source.sourceRepoPath ? ["--project", source.sourceRepoPath] : [],
|
|
11954
|
+
env: {
|
|
11955
|
+
TODOS_DB_PATH: source.sourceDbPath,
|
|
11956
|
+
HASNA_TODOS_DB_PATH: source.sourceDbPath
|
|
11957
|
+
},
|
|
11958
|
+
project: source.sourceRepoPath,
|
|
11959
|
+
sourceDbPath: source.sourceDbPath,
|
|
11960
|
+
sourceRepoPath: source.sourceRepoPath,
|
|
11961
|
+
sourceStoreId: source.sourceStoreId
|
|
11962
|
+
};
|
|
11963
|
+
}
|
|
11964
|
+
if (source.sourceRepoPath) {
|
|
11965
|
+
return {
|
|
11966
|
+
argsPrefix: ["--project", source.sourceRepoPath],
|
|
11967
|
+
project: source.sourceRepoPath,
|
|
11968
|
+
sourceRepoPath: source.sourceRepoPath,
|
|
11969
|
+
sourceStoreId: source.sourceStoreId
|
|
11970
|
+
};
|
|
11971
|
+
}
|
|
11972
|
+
if (opts.sourceMode)
|
|
11973
|
+
return;
|
|
11974
|
+
const project = todosProject ?? defaultLoopsProject();
|
|
11975
|
+
return { argsPrefix: ["--project", project], project };
|
|
11976
|
+
}
|
|
11977
|
+
function markInvalidDrainTaskNonRouteable(todosProject, task, reason, opts = {}) {
|
|
11685
11978
|
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
11686
11979
|
if (!taskId)
|
|
11687
11980
|
return { attempted: false, reason: "task id missing" };
|
|
11688
11981
|
const comment = `OpenLoops route blocked for task ${taskId}: ${reason}. Added no-auto and removed auto:route so route drains do not repeatedly route this task until its project path is fixed.`;
|
|
11689
|
-
const
|
|
11690
|
-
|
|
11691
|
-
|
|
11982
|
+
const target = sourceTaskMutationTarget(todosProject, task, { sourceMode: opts.sourceMode });
|
|
11983
|
+
if (!target) {
|
|
11984
|
+
return {
|
|
11985
|
+
attempted: false,
|
|
11986
|
+
taskId,
|
|
11987
|
+
reason: "source task missing source_db_path or source_repo_path; refusing to update router/default Todos store"
|
|
11988
|
+
};
|
|
11989
|
+
}
|
|
11990
|
+
const commentResult = runLocalCommand("todos", [...target.argsPrefix, "comment", taskId, comment], { timeoutMs: 30000, env: target.env });
|
|
11991
|
+
const tagResult = runLocalCommand("todos", [...target.argsPrefix, "tag", taskId, "no-auto"], { timeoutMs: 30000, env: target.env });
|
|
11992
|
+
const untagResult = runLocalCommand("todos", [...target.argsPrefix, "untag", taskId, "auto:route"], { timeoutMs: 30000, env: target.env });
|
|
11692
11993
|
const ok = commentResult.ok && tagResult.ok && untagResult.ok;
|
|
11693
11994
|
return {
|
|
11694
11995
|
ok,
|
|
11695
11996
|
attempted: true,
|
|
11696
11997
|
taskId,
|
|
11998
|
+
target: {
|
|
11999
|
+
project: target.project,
|
|
12000
|
+
source_db_path: target.sourceDbPath,
|
|
12001
|
+
source_repo_path: target.sourceRepoPath,
|
|
12002
|
+
source_store_id: target.sourceStoreId,
|
|
12003
|
+
used_db_env: Boolean(target.sourceDbPath)
|
|
12004
|
+
},
|
|
11697
12005
|
error: ok ? undefined : "one or more source task updates failed; inspect per-command results",
|
|
11698
12006
|
comment: todosMutationSummary(commentResult),
|
|
11699
12007
|
tagNoAuto: todosMutationSummary(tagResult),
|
|
@@ -11702,17 +12010,19 @@ function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
|
|
|
11702
12010
|
}
|
|
11703
12011
|
function drainTodosTaskRoutes(opts) {
|
|
11704
12012
|
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
11705
|
-
const
|
|
12013
|
+
const sourceMode = hasTodosSourceOptions(opts);
|
|
12014
|
+
const todosProject = sourceMode ? opts.todosProject : opts.todosProject ?? defaultLoopsProject();
|
|
11706
12015
|
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
11707
|
-
const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
|
|
12016
|
+
const taskListFilter = sourceMode ? opts.taskList?.trim() : resolveTaskListFilter(todosProject ?? defaultLoopsProject(), opts.taskList);
|
|
11708
12017
|
const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
|
|
11709
12018
|
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
|
|
11710
12019
|
const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
|
|
11711
12020
|
const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
|
|
11712
|
-
const
|
|
12021
|
+
const loaded = loadReadyTodosTasks(opts, scanLimit, sourceMode);
|
|
12022
|
+
const ready = loaded.tasks;
|
|
11713
12023
|
const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
|
|
11714
12024
|
projectId: opts.todosProjectId,
|
|
11715
|
-
|
|
12025
|
+
taskList: taskListFilter,
|
|
11716
12026
|
projectPathPrefix: opts.projectPathPrefix,
|
|
11717
12027
|
tags: requiredTags
|
|
11718
12028
|
}));
|
|
@@ -11725,13 +12035,25 @@ function drainTodosTaskRoutes(opts) {
|
|
|
11725
12035
|
let event;
|
|
11726
12036
|
let result;
|
|
11727
12037
|
try {
|
|
11728
|
-
|
|
12038
|
+
let sourceEligibility;
|
|
12039
|
+
if (sourceMode) {
|
|
12040
|
+
sourceEligibility = sourceRouteEligibility(task);
|
|
12041
|
+
if (!sourceEligibility.eligible) {
|
|
12042
|
+
result = skippedDrainTask(task, undefined, sourceEligibility.reason ?? "source route_state.eligible is not true", {
|
|
12043
|
+
sourceRouteState: sourceEligibility.routeState,
|
|
12044
|
+
sourceTask: publicSourceTaskIdentity(taskSourceIdentity(task, taskField(task, ["id", "task_id", "taskId"])))
|
|
12045
|
+
});
|
|
12046
|
+
results.push(result);
|
|
12047
|
+
continue;
|
|
12048
|
+
}
|
|
12049
|
+
}
|
|
12050
|
+
event = taskDrainEvent(task, { sourceRouteEligible: sourceEligibility?.eligible === true });
|
|
11729
12051
|
result = routeTodosTaskEvent(event, opts);
|
|
11730
12052
|
} catch (error) {
|
|
11731
12053
|
const message = error instanceof Error ? error.message : String(error);
|
|
11732
12054
|
if (!isSkippableDrainRouteError(message))
|
|
11733
12055
|
throw error;
|
|
11734
|
-
const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message);
|
|
12056
|
+
const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message, { sourceMode });
|
|
11735
12057
|
result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
|
|
11736
12058
|
}
|
|
11737
12059
|
results.push(result);
|
|
@@ -11741,6 +12063,12 @@ function drainTodosTaskRoutes(opts) {
|
|
|
11741
12063
|
const report = {
|
|
11742
12064
|
drainedAt: new Date().toISOString(),
|
|
11743
12065
|
todosProject,
|
|
12066
|
+
sourceMode,
|
|
12067
|
+
sourceRoots: loaded.sourceRoots,
|
|
12068
|
+
sourceStores: loaded.sourceStores,
|
|
12069
|
+
sourceIncludes: loaded.sourceIncludes,
|
|
12070
|
+
sourceExcludes: loaded.sourceExcludes,
|
|
12071
|
+
sourceDiscovery: loaded.sourceDiscovery,
|
|
11744
12072
|
templateId: todosTaskRouteTemplateId(opts),
|
|
11745
12073
|
todosProjectId: opts.todosProjectId,
|
|
11746
12074
|
taskList: opts.taskList,
|
|
@@ -11753,14 +12081,14 @@ function drainTodosTaskRoutes(opts) {
|
|
|
11753
12081
|
scanned: ready.length,
|
|
11754
12082
|
candidates: candidates.length,
|
|
11755
12083
|
filteredCandidates: filteredCandidates.length,
|
|
11756
|
-
scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
|
|
12084
|
+
scanExhausted: sourceMode ? Boolean(loaded.sourceDiscovery?.truncated) || ready.length >= scanLimit && filteredCandidates.length < candidateLimit : ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
|
|
11757
12085
|
considered: results.length,
|
|
11758
12086
|
created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
|
|
11759
12087
|
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
11760
12088
|
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
11761
12089
|
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
11762
12090
|
maxDispatch,
|
|
11763
|
-
source: "todos ready",
|
|
12091
|
+
source: sourceMode ? "todos ready source discovery" : "todos ready",
|
|
11764
12092
|
dryRun: Boolean(opts.dryRun),
|
|
11765
12093
|
results: results.map((result) => ({ kind: result.kind, ...result.value }))
|
|
11766
12094
|
};
|
|
@@ -11768,6 +12096,12 @@ function drainTodosTaskRoutes(opts) {
|
|
|
11768
12096
|
const value = opts.compact ? {
|
|
11769
12097
|
drainedAt: report.drainedAt,
|
|
11770
12098
|
todosProject: report.todosProject,
|
|
12099
|
+
sourceMode: report.sourceMode,
|
|
12100
|
+
sourceRoots: report.sourceRoots,
|
|
12101
|
+
sourceStores: report.sourceStores,
|
|
12102
|
+
sourceIncludes: report.sourceIncludes,
|
|
12103
|
+
sourceExcludes: report.sourceExcludes,
|
|
12104
|
+
sourceDiscovery: report.sourceDiscovery,
|
|
11771
12105
|
templateId: report.templateId,
|
|
11772
12106
|
todosProjectId: report.todosProjectId,
|
|
11773
12107
|
taskList: report.taskList,
|
|
@@ -11793,7 +12127,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
11793
12127
|
results: results.map(compactDrainResult)
|
|
11794
12128
|
} : { ...report, evidencePath };
|
|
11795
12129
|
return {
|
|
11796
|
-
value,
|
|
12130
|
+
value: scrubSecretsDeep(value),
|
|
11797
12131
|
human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`
|
|
11798
12132
|
};
|
|
11799
12133
|
}
|
|
@@ -12071,6 +12405,34 @@ var EVENT_INPUT_OPTION_SPECS = [
|
|
|
12071
12405
|
{ flags: "--event-json <json>", key: "eventJson", kind: "value", description: "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON" }
|
|
12072
12406
|
];
|
|
12073
12407
|
var DRAIN_FILTER_OPTION_SPECS = [
|
|
12408
|
+
{
|
|
12409
|
+
flags: "--todos-source-root <path>",
|
|
12410
|
+
key: "todosSourceRoot",
|
|
12411
|
+
kind: "repeat",
|
|
12412
|
+
description: "scan todos route candidates discovered under this source root; may be repeated",
|
|
12413
|
+
serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceRoot)
|
|
12414
|
+
},
|
|
12415
|
+
{
|
|
12416
|
+
flags: "--todos-source-store <path>",
|
|
12417
|
+
key: "todosSourceStore",
|
|
12418
|
+
kind: "repeat",
|
|
12419
|
+
description: "scan this explicit todos source store path; may be repeated",
|
|
12420
|
+
serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceStore)
|
|
12421
|
+
},
|
|
12422
|
+
{
|
|
12423
|
+
flags: "--todos-source-include <pattern>",
|
|
12424
|
+
key: "todosSourceInclude",
|
|
12425
|
+
kind: "repeat",
|
|
12426
|
+
description: "include source discovery entries matching this pattern; may be repeated",
|
|
12427
|
+
serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceInclude)
|
|
12428
|
+
},
|
|
12429
|
+
{
|
|
12430
|
+
flags: "--todos-source-exclude <pattern>",
|
|
12431
|
+
key: "todosSourceExclude",
|
|
12432
|
+
kind: "repeat",
|
|
12433
|
+
description: "exclude source discovery entries matching this pattern; may be repeated",
|
|
12434
|
+
serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceExclude)
|
|
12435
|
+
},
|
|
12074
12436
|
{ flags: "--todos-project-id <id>", key: "todosProjectId", kind: "value", description: "filter todos ready output to one todos project id" },
|
|
12075
12437
|
{ flags: "--task-list <id-or-slug>", key: "taskList", kind: "value", description: "filter ready tasks to one task-list id, slug, or name" },
|
|
12076
12438
|
{ flags: "--project-path-prefix <path>", key: "projectPathPrefix", kind: "value", description: "filter ready tasks to a project/repo path prefix" },
|