@hasna/loops 0.4.0 → 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/CHANGELOG.md +18 -0
- package/README.md +75 -33
- package/dist/api/index.d.ts +17 -0
- package/dist/api/index.js +1290 -0
- package/dist/cli/index.js +738 -201
- package/dist/daemon/index.js +64 -15
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1005 -155
- package/dist/lib/format.d.ts +3 -1
- package/dist/lib/mode.d.ts +48 -0
- package/dist/lib/mode.js +276 -0
- 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 +64 -15
- package/dist/runner/index.d.ts +32 -0
- package/dist/runner/index.js +2037 -0
- package/dist/sdk/index.js +33 -15
- package/dist/types.d.ts +1 -1
- package/docs/DEPLOYMENT_MODES.md +148 -0
- package/docs/USAGE.md +66 -32
- package/package.json +34 -3
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(),
|
|
@@ -3964,6 +3981,273 @@ class Store {
|
|
|
3964
3981
|
this.db.close();
|
|
3965
3982
|
}
|
|
3966
3983
|
}
|
|
3984
|
+
// package.json
|
|
3985
|
+
var package_default = {
|
|
3986
|
+
name: "@hasna/loops",
|
|
3987
|
+
version: "0.4.2",
|
|
3988
|
+
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
3989
|
+
type: "module",
|
|
3990
|
+
main: "dist/index.js",
|
|
3991
|
+
types: "dist/index.d.ts",
|
|
3992
|
+
bin: {
|
|
3993
|
+
loops: "dist/cli/index.js",
|
|
3994
|
+
"loops-daemon": "dist/daemon/index.js",
|
|
3995
|
+
"loops-api": "dist/api/index.js",
|
|
3996
|
+
"loops-runner": "dist/runner/index.js",
|
|
3997
|
+
"loops-mcp": "dist/mcp/index.js"
|
|
3998
|
+
},
|
|
3999
|
+
exports: {
|
|
4000
|
+
".": {
|
|
4001
|
+
types: "./dist/index.d.ts",
|
|
4002
|
+
import: "./dist/index.js"
|
|
4003
|
+
},
|
|
4004
|
+
"./sdk": {
|
|
4005
|
+
types: "./dist/sdk/index.d.ts",
|
|
4006
|
+
import: "./dist/sdk/index.js"
|
|
4007
|
+
},
|
|
4008
|
+
"./mcp": {
|
|
4009
|
+
types: "./dist/mcp/index.d.ts",
|
|
4010
|
+
import: "./dist/mcp/index.js"
|
|
4011
|
+
},
|
|
4012
|
+
"./api": {
|
|
4013
|
+
types: "./dist/api/index.d.ts",
|
|
4014
|
+
import: "./dist/api/index.js"
|
|
4015
|
+
},
|
|
4016
|
+
"./runner": {
|
|
4017
|
+
types: "./dist/runner/index.d.ts",
|
|
4018
|
+
import: "./dist/runner/index.js"
|
|
4019
|
+
},
|
|
4020
|
+
"./mode": {
|
|
4021
|
+
types: "./dist/lib/mode.d.ts",
|
|
4022
|
+
import: "./dist/lib/mode.js"
|
|
4023
|
+
},
|
|
4024
|
+
"./storage": {
|
|
4025
|
+
types: "./dist/lib/store.d.ts",
|
|
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"
|
|
4043
|
+
}
|
|
4044
|
+
},
|
|
4045
|
+
files: [
|
|
4046
|
+
"dist",
|
|
4047
|
+
"README.md",
|
|
4048
|
+
"CHANGELOG.md",
|
|
4049
|
+
"docs",
|
|
4050
|
+
"LICENSE"
|
|
4051
|
+
],
|
|
4052
|
+
scripts: {
|
|
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",
|
|
4054
|
+
"build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
|
|
4055
|
+
typecheck: "tsc --noEmit",
|
|
4056
|
+
test: "bun test",
|
|
4057
|
+
"test:boundary": "bun run scripts/no-private-cloud-boundary.mjs",
|
|
4058
|
+
prepare: "test -d dist || bun run build",
|
|
4059
|
+
"dev:cli": "bun run src/cli/index.ts",
|
|
4060
|
+
"dev:daemon": "bun run src/daemon/index.ts",
|
|
4061
|
+
prepublishOnly: "bun run build && bun run test:boundary"
|
|
4062
|
+
},
|
|
4063
|
+
keywords: [
|
|
4064
|
+
"loops",
|
|
4065
|
+
"scheduler",
|
|
4066
|
+
"daemon",
|
|
4067
|
+
"agents",
|
|
4068
|
+
"claude",
|
|
4069
|
+
"cursor",
|
|
4070
|
+
"codewith",
|
|
4071
|
+
"opencode",
|
|
4072
|
+
"cli"
|
|
4073
|
+
],
|
|
4074
|
+
repository: {
|
|
4075
|
+
type: "git",
|
|
4076
|
+
url: "git+https://github.com/hasna/loops.git"
|
|
4077
|
+
},
|
|
4078
|
+
homepage: "https://github.com/hasna/loops#readme",
|
|
4079
|
+
bugs: {
|
|
4080
|
+
url: "https://github.com/hasna/loops/issues"
|
|
4081
|
+
},
|
|
4082
|
+
author: "Hasna <andrei@hasna.com>",
|
|
4083
|
+
license: "Apache-2.0",
|
|
4084
|
+
engines: {
|
|
4085
|
+
bun: ">=1.0.0"
|
|
4086
|
+
},
|
|
4087
|
+
dependencies: {
|
|
4088
|
+
"@hasna/events": "^0.1.9",
|
|
4089
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
4090
|
+
"@openrouter/ai-sdk-provider": "2.9.1",
|
|
4091
|
+
ai: "6.0.204",
|
|
4092
|
+
commander: "^13.1.0",
|
|
4093
|
+
zod: "4.4.3"
|
|
4094
|
+
},
|
|
4095
|
+
optionalDependencies: {
|
|
4096
|
+
"@hasna/machines": "0.0.49"
|
|
4097
|
+
},
|
|
4098
|
+
devDependencies: {
|
|
4099
|
+
"@types/bun": "latest",
|
|
4100
|
+
typescript: "^5.7.3"
|
|
4101
|
+
},
|
|
4102
|
+
publishConfig: {
|
|
4103
|
+
registry: "https://registry.npmjs.org",
|
|
4104
|
+
access: "public"
|
|
4105
|
+
}
|
|
4106
|
+
};
|
|
4107
|
+
|
|
4108
|
+
// src/lib/version.ts
|
|
4109
|
+
function packageVersion() {
|
|
4110
|
+
if (typeof package_default.version !== "string" || package_default.version.trim() === "")
|
|
4111
|
+
throw new Error("package.json version is missing");
|
|
4112
|
+
return package_default.version;
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
// src/lib/mode.ts
|
|
4116
|
+
var LOOP_DEPLOYMENT_MODES = ["local", "self_hosted", "cloud"];
|
|
4117
|
+
var MODE_ENV_KEYS = ["LOOPS_MODE", "HASNA_LOOPS_MODE"];
|
|
4118
|
+
var API_URL_ENV_KEYS = ["LOOPS_API_URL", "HASNA_LOOPS_API_URL"];
|
|
4119
|
+
var CLOUD_API_URL_ENV_KEYS = ["LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"];
|
|
4120
|
+
var DATABASE_URL_ENV_KEYS = ["LOOPS_DATABASE_URL", "HASNA_LOOPS_DATABASE_URL"];
|
|
4121
|
+
var API_TOKEN_ENV_KEYS = ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN"];
|
|
4122
|
+
var CLOUD_TOKEN_ENV_KEYS = ["LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"];
|
|
4123
|
+
var TOKEN_ENV_KEYS = [...API_TOKEN_ENV_KEYS, ...CLOUD_TOKEN_ENV_KEYS];
|
|
4124
|
+
function envValue(env, keys) {
|
|
4125
|
+
for (const key of keys) {
|
|
4126
|
+
const value = env[key]?.trim();
|
|
4127
|
+
if (value)
|
|
4128
|
+
return { key, value };
|
|
4129
|
+
}
|
|
4130
|
+
return;
|
|
4131
|
+
}
|
|
4132
|
+
function normalizeLoopDeploymentMode(value) {
|
|
4133
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
4134
|
+
if (normalized === "local")
|
|
4135
|
+
return "local";
|
|
4136
|
+
if (normalized === "self_hosted" || normalized === "selfhosted")
|
|
4137
|
+
return "self_hosted";
|
|
4138
|
+
if (normalized === "cloud" || normalized === "saas")
|
|
4139
|
+
return "cloud";
|
|
4140
|
+
throw new Error(`unsupported OpenLoops deployment mode "${value}"; expected local, self_hosted, or cloud`);
|
|
4141
|
+
}
|
|
4142
|
+
function resolveLoopDeploymentMode(env = process.env) {
|
|
4143
|
+
const explicitMode = envValue(env, MODE_ENV_KEYS);
|
|
4144
|
+
if (explicitMode) {
|
|
4145
|
+
return {
|
|
4146
|
+
deploymentMode: normalizeLoopDeploymentMode(explicitMode.value),
|
|
4147
|
+
source: explicitMode.key
|
|
4148
|
+
};
|
|
4149
|
+
}
|
|
4150
|
+
const cloudApiUrl = envValue(env, CLOUD_API_URL_ENV_KEYS);
|
|
4151
|
+
if (cloudApiUrl)
|
|
4152
|
+
return { deploymentMode: "cloud", source: cloudApiUrl.key };
|
|
4153
|
+
const apiUrl = envValue(env, API_URL_ENV_KEYS);
|
|
4154
|
+
if (apiUrl)
|
|
4155
|
+
return { deploymentMode: "self_hosted", source: apiUrl.key };
|
|
4156
|
+
const databaseUrl = envValue(env, DATABASE_URL_ENV_KEYS);
|
|
4157
|
+
if (databaseUrl)
|
|
4158
|
+
return { deploymentMode: "self_hosted", source: databaseUrl.key };
|
|
4159
|
+
return { deploymentMode: "local", source: "default" };
|
|
4160
|
+
}
|
|
4161
|
+
function loopControlPlaneConfig(env = process.env) {
|
|
4162
|
+
return {
|
|
4163
|
+
apiUrl: envValue(env, API_URL_ENV_KEYS)?.value,
|
|
4164
|
+
cloudApiUrl: envValue(env, CLOUD_API_URL_ENV_KEYS)?.value,
|
|
4165
|
+
databaseUrlPresent: Boolean(envValue(env, DATABASE_URL_ENV_KEYS)),
|
|
4166
|
+
apiAuthTokenPresent: Boolean(envValue(env, API_TOKEN_ENV_KEYS)),
|
|
4167
|
+
cloudAuthTokenPresent: Boolean(envValue(env, CLOUD_TOKEN_ENV_KEYS)),
|
|
4168
|
+
authTokenPresent: Boolean(envValue(env, TOKEN_ENV_KEYS))
|
|
4169
|
+
};
|
|
4170
|
+
}
|
|
4171
|
+
function sourceOfTruthForMode(mode) {
|
|
4172
|
+
if (mode === "local")
|
|
4173
|
+
return "local_sqlite";
|
|
4174
|
+
if (mode === "self_hosted")
|
|
4175
|
+
return "self_hosted_control_plane";
|
|
4176
|
+
return "cloud_control_plane";
|
|
4177
|
+
}
|
|
4178
|
+
function displayControlPlaneUrl(value) {
|
|
4179
|
+
if (!value)
|
|
4180
|
+
return;
|
|
4181
|
+
try {
|
|
4182
|
+
const url = new URL(value);
|
|
4183
|
+
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/g, "");
|
|
4184
|
+
return `${url.origin}${path}`;
|
|
4185
|
+
} catch {
|
|
4186
|
+
return "[invalid-url]";
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
function buildDeploymentStatus(opts = {}) {
|
|
4190
|
+
const env = opts.env ?? process.env;
|
|
4191
|
+
const active = resolveLoopDeploymentMode(env);
|
|
4192
|
+
const deploymentMode = opts.perspective ?? active.deploymentMode;
|
|
4193
|
+
const config = loopControlPlaneConfig(env);
|
|
4194
|
+
const rawApiUrl = deploymentMode === "cloud" ? config.cloudApiUrl : config.apiUrl;
|
|
4195
|
+
const apiUrl = displayControlPlaneUrl(rawApiUrl);
|
|
4196
|
+
const controlPlaneKind = deploymentMode === "local" ? "none" : deploymentMode;
|
|
4197
|
+
const deploymentAuthTokenPresent = deploymentMode === "cloud" ? config.cloudAuthTokenPresent : deploymentMode === "self_hosted" ? config.apiAuthTokenPresent : false;
|
|
4198
|
+
const controlPlaneConfigured = deploymentMode === "local" ? false : deploymentMode === "self_hosted" ? Boolean(config.apiUrl || config.databaseUrlPresent) : Boolean(config.cloudApiUrl && deploymentAuthTokenPresent);
|
|
4199
|
+
const warnings = [];
|
|
4200
|
+
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
4201
|
+
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
4202
|
+
}
|
|
4203
|
+
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
4204
|
+
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
4205
|
+
}
|
|
4206
|
+
if (deploymentMode === "cloud" && !config.cloudApiUrl) {
|
|
4207
|
+
warnings.push("cloud mode is a public contract until LOOPS_CLOUD_API_URL is configured");
|
|
4208
|
+
}
|
|
4209
|
+
if (deploymentMode === "cloud" && config.cloudApiUrl && !deploymentAuthTokenPresent) {
|
|
4210
|
+
warnings.push("cloud mode needs LOOPS_CLOUD_TOKEN or HASNA_LOOPS_CLOUD_TOKEN before it can become ready");
|
|
4211
|
+
}
|
|
4212
|
+
if (opts.perspective && opts.perspective !== active.deploymentMode) {
|
|
4213
|
+
warnings.push(`active deployment mode is ${active.deploymentMode}; this is the ${opts.perspective} perspective`);
|
|
4214
|
+
}
|
|
4215
|
+
return {
|
|
4216
|
+
packageVersion: packageVersion(),
|
|
4217
|
+
deploymentMode,
|
|
4218
|
+
activeDeploymentMode: active.deploymentMode,
|
|
4219
|
+
active: deploymentMode === active.deploymentMode,
|
|
4220
|
+
deploymentModeSource: active.source,
|
|
4221
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
4222
|
+
localStore: {
|
|
4223
|
+
role: deploymentMode === "local" ? "authoritative" : "cache_and_spool"
|
|
4224
|
+
},
|
|
4225
|
+
controlPlane: {
|
|
4226
|
+
kind: controlPlaneKind,
|
|
4227
|
+
configured: controlPlaneConfigured,
|
|
4228
|
+
apiUrl,
|
|
4229
|
+
databaseUrlPresent: config.databaseUrlPresent,
|
|
4230
|
+
authTokenPresent: deploymentAuthTokenPresent
|
|
4231
|
+
},
|
|
4232
|
+
runner: {
|
|
4233
|
+
required: deploymentMode !== "local",
|
|
4234
|
+
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
4235
|
+
},
|
|
4236
|
+
warnings
|
|
4237
|
+
};
|
|
4238
|
+
}
|
|
4239
|
+
function deploymentStatusLine(status) {
|
|
4240
|
+
const configured = status.controlPlane.kind === "none" ? "none" : String(status.controlPlane.configured);
|
|
4241
|
+
const active = status.active ? "active" : `active=${status.activeDeploymentMode}`;
|
|
4242
|
+
return [
|
|
4243
|
+
`deploymentMode=${status.deploymentMode}`,
|
|
4244
|
+
active,
|
|
4245
|
+
`source=${status.deploymentModeSource}`,
|
|
4246
|
+
`truth=${status.sourceOfTruth}`,
|
|
4247
|
+
`local=${status.localStore.role}`,
|
|
4248
|
+
`control_plane=${configured}`
|
|
4249
|
+
].join(" ");
|
|
4250
|
+
}
|
|
3967
4251
|
|
|
3968
4252
|
// src/cli/index.ts
|
|
3969
4253
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
@@ -4029,11 +4313,12 @@ function publicLoop(loop) {
|
|
|
4029
4313
|
target
|
|
4030
4314
|
};
|
|
4031
4315
|
}
|
|
4032
|
-
function publicRun(run, showOutput = false) {
|
|
4316
|
+
function publicRun(run, showOutput = false, opts = {}) {
|
|
4033
4317
|
return {
|
|
4034
4318
|
...run,
|
|
4035
4319
|
stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
|
|
4036
|
-
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
|
|
4037
4322
|
};
|
|
4038
4323
|
}
|
|
4039
4324
|
function publicExecutorResult(result, showOutput = false) {
|
|
@@ -7701,6 +7986,7 @@ function runDoctor(store) {
|
|
|
7701
7986
|
|
|
7702
7987
|
// src/lib/hygiene.ts
|
|
7703
7988
|
import { basename as basename3 } from "path";
|
|
7989
|
+
import { homedir as homedir3 } from "os";
|
|
7704
7990
|
var PROVIDER_TOKENS = new Set([
|
|
7705
7991
|
"codewith",
|
|
7706
7992
|
"claude",
|
|
@@ -7715,11 +8001,14 @@ var PROVIDER_TOKENS = new Set([
|
|
|
7715
8001
|
var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
|
|
7716
8002
|
var CADENCE_SUFFIX_TOKENS = new Set(["hourly", "daily", "weekly", "monthly"]);
|
|
7717
8003
|
var CADENCE_SUFFIX_PATTERN = /^(?:every-?)?\d+(?:s|m|h|d|w)$/;
|
|
8004
|
+
function userHome() {
|
|
8005
|
+
return process.env.HOME || homedir3();
|
|
8006
|
+
}
|
|
7718
8007
|
function slugify(value) {
|
|
7719
8008
|
return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
7720
8009
|
}
|
|
7721
8010
|
function repoSlugFromCwd(cwd) {
|
|
7722
|
-
if (!cwd || cwd ===
|
|
8011
|
+
if (!cwd || cwd === userHome())
|
|
7723
8012
|
return "";
|
|
7724
8013
|
if (cwd.includes("/.hasna/loops/"))
|
|
7725
8014
|
return "";
|
|
@@ -7902,7 +8191,7 @@ function commandText(loop) {
|
|
|
7902
8191
|
return [loop.target.command, ...loop.target.args ?? []].join(" ");
|
|
7903
8192
|
}
|
|
7904
8193
|
function scriptNeedles(scriptsDir) {
|
|
7905
|
-
const home =
|
|
8194
|
+
const home = userHome();
|
|
7906
8195
|
const normalized = scriptsDir.replace(/\/+$/g, "");
|
|
7907
8196
|
const values = [
|
|
7908
8197
|
normalized,
|
|
@@ -7920,7 +8209,7 @@ function scriptNeedles(scriptsDir) {
|
|
|
7920
8209
|
return [...new Set(values)];
|
|
7921
8210
|
}
|
|
7922
8211
|
function buildScriptInventoryReport(store, opts = {}) {
|
|
7923
|
-
const scriptsDir = opts.scriptsDir ?? `${
|
|
8212
|
+
const scriptsDir = opts.scriptsDir ?? `${userHome()}/.hasna/loops/scripts`;
|
|
7924
8213
|
const needles = scriptNeedles(scriptsDir);
|
|
7925
8214
|
const loops = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
|
|
7926
8215
|
const scriptBacked = loops.map((loop) => {
|
|
@@ -7947,110 +8236,11 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
7947
8236
|
loops: scriptBacked
|
|
7948
8237
|
};
|
|
7949
8238
|
}
|
|
7950
|
-
// package.json
|
|
7951
|
-
var package_default = {
|
|
7952
|
-
name: "@hasna/loops",
|
|
7953
|
-
version: "0.4.0",
|
|
7954
|
-
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7955
|
-
type: "module",
|
|
7956
|
-
main: "dist/index.js",
|
|
7957
|
-
types: "dist/index.d.ts",
|
|
7958
|
-
bin: {
|
|
7959
|
-
loops: "dist/cli/index.js",
|
|
7960
|
-
"loops-daemon": "dist/daemon/index.js",
|
|
7961
|
-
"loops-mcp": "dist/mcp/index.js"
|
|
7962
|
-
},
|
|
7963
|
-
exports: {
|
|
7964
|
-
".": {
|
|
7965
|
-
types: "./dist/index.d.ts",
|
|
7966
|
-
import: "./dist/index.js"
|
|
7967
|
-
},
|
|
7968
|
-
"./sdk": {
|
|
7969
|
-
types: "./dist/sdk/index.d.ts",
|
|
7970
|
-
import: "./dist/sdk/index.js"
|
|
7971
|
-
},
|
|
7972
|
-
"./mcp": {
|
|
7973
|
-
types: "./dist/mcp/index.d.ts",
|
|
7974
|
-
import: "./dist/mcp/index.js"
|
|
7975
|
-
},
|
|
7976
|
-
"./storage": {
|
|
7977
|
-
types: "./dist/lib/store.d.ts",
|
|
7978
|
-
import: "./dist/lib/store.js"
|
|
7979
|
-
}
|
|
7980
|
-
},
|
|
7981
|
-
files: [
|
|
7982
|
-
"dist",
|
|
7983
|
-
"README.md",
|
|
7984
|
-
"CHANGELOG.md",
|
|
7985
|
-
"docs",
|
|
7986
|
-
"LICENSE"
|
|
7987
|
-
],
|
|
7988
|
-
scripts: {
|
|
7989
|
-
build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
7990
|
-
"build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
|
|
7991
|
-
typecheck: "tsc --noEmit",
|
|
7992
|
-
test: "bun test",
|
|
7993
|
-
prepare: "test -d dist || bun run build",
|
|
7994
|
-
"dev:cli": "bun run src/cli/index.ts",
|
|
7995
|
-
"dev:daemon": "bun run src/daemon/index.ts",
|
|
7996
|
-
prepublishOnly: "bun run build"
|
|
7997
|
-
},
|
|
7998
|
-
keywords: [
|
|
7999
|
-
"loops",
|
|
8000
|
-
"scheduler",
|
|
8001
|
-
"daemon",
|
|
8002
|
-
"agents",
|
|
8003
|
-
"claude",
|
|
8004
|
-
"cursor",
|
|
8005
|
-
"codewith",
|
|
8006
|
-
"opencode",
|
|
8007
|
-
"cli"
|
|
8008
|
-
],
|
|
8009
|
-
repository: {
|
|
8010
|
-
type: "git",
|
|
8011
|
-
url: "git+https://github.com/hasna/loops.git"
|
|
8012
|
-
},
|
|
8013
|
-
homepage: "https://github.com/hasna/loops#readme",
|
|
8014
|
-
bugs: {
|
|
8015
|
-
url: "https://github.com/hasna/loops/issues"
|
|
8016
|
-
},
|
|
8017
|
-
author: "Hasna <andrei@hasna.com>",
|
|
8018
|
-
license: "Apache-2.0",
|
|
8019
|
-
engines: {
|
|
8020
|
-
bun: ">=1.0.0"
|
|
8021
|
-
},
|
|
8022
|
-
dependencies: {
|
|
8023
|
-
"@hasna/events": "^0.1.9",
|
|
8024
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
8025
|
-
"@openrouter/ai-sdk-provider": "2.9.1",
|
|
8026
|
-
ai: "6.0.204",
|
|
8027
|
-
commander: "^13.1.0",
|
|
8028
|
-
zod: "4.4.3"
|
|
8029
|
-
},
|
|
8030
|
-
optionalDependencies: {
|
|
8031
|
-
"@hasna/machines": "0.0.49"
|
|
8032
|
-
},
|
|
8033
|
-
devDependencies: {
|
|
8034
|
-
"@types/bun": "latest",
|
|
8035
|
-
typescript: "^5.7.3"
|
|
8036
|
-
},
|
|
8037
|
-
publishConfig: {
|
|
8038
|
-
registry: "https://registry.npmjs.org",
|
|
8039
|
-
access: "public"
|
|
8040
|
-
}
|
|
8041
|
-
};
|
|
8042
|
-
|
|
8043
|
-
// src/lib/version.ts
|
|
8044
|
-
function packageVersion() {
|
|
8045
|
-
if (typeof package_default.version !== "string" || package_default.version.trim() === "")
|
|
8046
|
-
throw new Error("package.json version is missing");
|
|
8047
|
-
return package_default.version;
|
|
8048
|
-
}
|
|
8049
8239
|
|
|
8050
8240
|
// src/lib/templates.ts
|
|
8051
8241
|
import { execFileSync } from "child_process";
|
|
8052
8242
|
import { existsSync as existsSync7 } from "fs";
|
|
8053
|
-
import { homedir as
|
|
8243
|
+
import { homedir as homedir4 } from "os";
|
|
8054
8244
|
import { basename as basename4, isAbsolute as isAbsolute2, join as join7, relative, resolve as resolve4 } from "path";
|
|
8055
8245
|
|
|
8056
8246
|
// src/lib/template-kit.ts
|
|
@@ -8131,6 +8321,7 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
|
|
|
8131
8321
|
{ name: "taskTitle", description: "Human-readable task title." },
|
|
8132
8322
|
projectPathVariable(),
|
|
8133
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." },
|
|
8134
8325
|
...routeScopeVariables(),
|
|
8135
8326
|
...workerVerifierAgentVariables({ addDirs: true, branchNoun: "task/event" })
|
|
8136
8327
|
]
|
|
@@ -8171,6 +8362,8 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
|
|
|
8171
8362
|
variables: [
|
|
8172
8363
|
{ name: "taskId", required: true, description: "Todos task id." },
|
|
8173
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." },
|
|
8174
8367
|
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
8175
8368
|
roleAuthProfileVariable("triage"),
|
|
8176
8369
|
roleAuthProfileVariable("planner"),
|
|
@@ -8301,28 +8494,29 @@ function verifierRuntimeGuidance(input) {
|
|
|
8301
8494
|
].join(`
|
|
8302
8495
|
`);
|
|
8303
8496
|
}
|
|
8304
|
-
function todosInspectLine(todosProjectPath, taskId) {
|
|
8305
|
-
return `- Inspect first:
|
|
8497
|
+
function todosInspectLine(todosProjectPath, taskId, todosDbPath) {
|
|
8498
|
+
return `- Inspect first: ${todosCommand(todosProjectPath, todosDbPath)} inspect ${taskId}`;
|
|
8306
8499
|
}
|
|
8307
|
-
function todosStartLine(todosProjectPath, taskId) {
|
|
8308
|
-
return `- Claim/start if appropriate:
|
|
8500
|
+
function todosStartLine(todosProjectPath, taskId, todosDbPath) {
|
|
8501
|
+
return `- Claim/start if appropriate: ${todosCommand(todosProjectPath, todosDbPath)} start ${taskId}`;
|
|
8309
8502
|
}
|
|
8310
|
-
function todosEvidenceLine(todosProjectPath, taskId, placeholder) {
|
|
8311
|
-
return `- Record evidence:
|
|
8503
|
+
function todosEvidenceLine(todosProjectPath, taskId, placeholder, todosDbPath) {
|
|
8504
|
+
return `- Record evidence: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<${placeholder}>"`;
|
|
8312
8505
|
}
|
|
8313
|
-
function todosVerificationLine(todosProjectPath, taskId) {
|
|
8314
|
-
return `- Record verification:
|
|
8506
|
+
function todosVerificationLine(todosProjectPath, taskId, todosDbPath) {
|
|
8507
|
+
return `- Record verification: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<verification evidence or blocker>"`;
|
|
8315
8508
|
}
|
|
8316
|
-
function todosDoneLine(todosProjectPath, taskId) {
|
|
8317
|
-
return `- If valid and complete:
|
|
8509
|
+
function todosDoneLine(todosProjectPath, taskId, todosDbPath) {
|
|
8510
|
+
return `- If valid and complete: ${todosCommand(todosProjectPath, todosDbPath)} done ${taskId}`;
|
|
8318
8511
|
}
|
|
8319
|
-
function todosExactCommandsFragment(todosProjectPath, taskId, commandLines) {
|
|
8512
|
+
function todosExactCommandsFragment(todosProjectPath, taskId, commandLines, todosDbPath) {
|
|
8320
8513
|
return [
|
|
8321
8514
|
`Todos project path: ${todosProjectPath}`,
|
|
8515
|
+
todosDbPath ? `Todos DB path: ${todosDbPath}` : undefined,
|
|
8322
8516
|
"Use these exact todos commands so worktree cwd inference cannot attach to the wrong project:",
|
|
8323
|
-
todosInspectLine(todosProjectPath, taskId),
|
|
8517
|
+
todosInspectLine(todosProjectPath, taskId, todosDbPath),
|
|
8324
8518
|
...commandLines
|
|
8325
|
-
];
|
|
8519
|
+
].filter((line) => Boolean(line));
|
|
8326
8520
|
}
|
|
8327
8521
|
function worktreePrompt(plan) {
|
|
8328
8522
|
if (plan.enabled) {
|
|
@@ -8359,10 +8553,19 @@ function worktreeContextFragment(plan) {
|
|
|
8359
8553
|
function shellQuote3(value) {
|
|
8360
8554
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
8361
8555
|
}
|
|
8362
|
-
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) {
|
|
8363
8566
|
return [
|
|
8364
8567
|
"set -euo pipefail",
|
|
8365
|
-
|
|
8568
|
+
`${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote3(taskId)} >/dev/null`,
|
|
8366
8569
|
`printf "source task %s resolved in todos project %s\\n" ${shellQuote3(taskId)} ${shellQuote3(todosProjectPath)}`
|
|
8367
8570
|
].join(`
|
|
8368
8571
|
`);
|
|
@@ -8422,10 +8625,10 @@ var LIFECYCLE_GATE_SCRIPT_TAIL = [
|
|
|
8422
8625
|
"console.log(`task lifecycle ${stage} gate passed for ${task.id || task.taskId || 'task'} status=${status || 'unknown'}`);"
|
|
8423
8626
|
].join(`
|
|
8424
8627
|
`);
|
|
8425
|
-
function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker) {
|
|
8628
|
+
function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker, todosDbPath) {
|
|
8426
8629
|
return [
|
|
8427
8630
|
"set -euo pipefail",
|
|
8428
|
-
`task_json="$(
|
|
8631
|
+
`task_json="$(${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote3(taskId)})"`,
|
|
8429
8632
|
`TASK_JSON="$task_json" STAGE=${shellQuote3(stage)} bun - <<'BUN'`,
|
|
8430
8633
|
LIFECYCLE_GATE_SCRIPT_HEAD,
|
|
8431
8634
|
`const goMarker = ${JSON.stringify(goMarker)};`,
|
|
@@ -8441,6 +8644,7 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
8441
8644
|
"const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
|
|
8442
8645
|
"const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
|
|
8443
8646
|
"const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
|
|
8647
|
+
"const todosDbPath = process.env.OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH || '';",
|
|
8444
8648
|
"const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
|
|
8445
8649
|
"const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
|
|
8446
8650
|
"const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
|
|
@@ -8458,7 +8662,8 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
8458
8662
|
"};",
|
|
8459
8663
|
"const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
|
|
8460
8664
|
"const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
|
|
8461
|
-
"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 });",
|
|
8462
8667
|
"const comment = (text) => {",
|
|
8463
8668
|
" const result = todos('comment', taskId, text);",
|
|
8464
8669
|
" if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
|
|
@@ -8587,6 +8792,7 @@ function prHandoffCommand(opts) {
|
|
|
8587
8792
|
`export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote3(opts.artifactPath)}`,
|
|
8588
8793
|
`export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote3(opts.taskId)}`,
|
|
8589
8794
|
`export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote3(opts.todosProjectPath)}`,
|
|
8795
|
+
opts.todosDbPath ? `export OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH=${shellQuote3(opts.todosDbPath)}` : undefined,
|
|
8590
8796
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote3(opts.worktreeCwd)}`,
|
|
8591
8797
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote3(opts.worktreeRoot)}`,
|
|
8592
8798
|
`export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote3(opts.expectedBranch)}`,
|
|
@@ -8597,7 +8803,7 @@ function prHandoffCommand(opts) {
|
|
|
8597
8803
|
"bun - <<'BUN'",
|
|
8598
8804
|
PR_HANDOFF_SCRIPT,
|
|
8599
8805
|
"BUN"
|
|
8600
|
-
].join(`
|
|
8806
|
+
].filter((line) => Boolean(line)).join(`
|
|
8601
8807
|
`);
|
|
8602
8808
|
}
|
|
8603
8809
|
// src/lib/templates-custom.ts
|
|
@@ -9031,10 +9237,10 @@ function normalizeWorktreeMode(mode) {
|
|
|
9031
9237
|
}
|
|
9032
9238
|
function defaultWorktreeRoot(root) {
|
|
9033
9239
|
if (root?.trim()) {
|
|
9034
|
-
const expanded = root.trim().replace(/^~(?=$|\/)/,
|
|
9240
|
+
const expanded = root.trim().replace(/^~(?=$|\/)/, homedir4());
|
|
9035
9241
|
return isAbsolute2(expanded) ? expanded : resolve4(expanded);
|
|
9036
9242
|
}
|
|
9037
|
-
return join7(
|
|
9243
|
+
return join7(homedir4(), ".hasna", "loops", "worktrees");
|
|
9038
9244
|
}
|
|
9039
9245
|
function gitRootFor(path) {
|
|
9040
9246
|
if (!existsSync7(path))
|
|
@@ -9187,12 +9393,12 @@ function commandStep(opts) {
|
|
|
9187
9393
|
...opts.blockedExitCodes ? { blockedExitCodes: opts.blockedExitCodes } : {}
|
|
9188
9394
|
};
|
|
9189
9395
|
}
|
|
9190
|
-
function sourceTaskGateStep(todosProjectPath, taskId, plan, description) {
|
|
9396
|
+
function sourceTaskGateStep(todosProjectPath, taskId, plan, description, todosDbPath) {
|
|
9191
9397
|
return commandStep({
|
|
9192
9398
|
id: "source-task-gate",
|
|
9193
9399
|
name: "Source Task Gate",
|
|
9194
9400
|
description,
|
|
9195
|
-
command: sourceTaskGateCommand(todosProjectPath, taskId),
|
|
9401
|
+
command: sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath),
|
|
9196
9402
|
cwd: plan.originalCwd,
|
|
9197
9403
|
timeoutMs: 60000,
|
|
9198
9404
|
blockedExitCodes: GATE_BLOCKED_EXIT_CODES
|
|
@@ -9204,7 +9410,7 @@ function lifecycleGateStep(opts) {
|
|
|
9204
9410
|
name: opts.stage === "triage" ? "Triage Gate" : "Planner Gate",
|
|
9205
9411
|
description: opts.description,
|
|
9206
9412
|
dependsOn: opts.dependsOn,
|
|
9207
|
-
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),
|
|
9208
9414
|
cwd: opts.plan.originalCwd,
|
|
9209
9415
|
timeoutMs: 2 * 60000,
|
|
9210
9416
|
blockedExitCodes: GATE_BLOCKED_EXIT_CODES
|
|
@@ -9213,7 +9419,7 @@ function lifecycleGateStep(opts) {
|
|
|
9213
9419
|
function prHandoffArtifactPath(plan, taskId) {
|
|
9214
9420
|
return join7(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
|
|
9215
9421
|
}
|
|
9216
|
-
function prHandoffStep(input, plan, todosProjectPath) {
|
|
9422
|
+
function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
|
|
9217
9423
|
return commandStep({
|
|
9218
9424
|
id: "pr-handoff",
|
|
9219
9425
|
name: "PR Handoff",
|
|
@@ -9223,6 +9429,7 @@ function prHandoffStep(input, plan, todosProjectPath) {
|
|
|
9223
9429
|
artifactPath: prHandoffArtifactPath(plan, input.taskId),
|
|
9224
9430
|
taskId: input.taskId,
|
|
9225
9431
|
todosProjectPath,
|
|
9432
|
+
todosDbPath,
|
|
9226
9433
|
worktreeCwd: plan.cwd,
|
|
9227
9434
|
worktreeRoot: plan.path ?? plan.cwd,
|
|
9228
9435
|
expectedBranch: plan.branch ?? ""
|
|
@@ -9309,6 +9516,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9309
9516
|
if (!input.projectPath?.trim())
|
|
9310
9517
|
throw new Error("projectPath is required");
|
|
9311
9518
|
const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
|
|
9519
|
+
const todosDbPath = input.todosDbPath;
|
|
9312
9520
|
const plan = worktreePlan(input, input.taskId);
|
|
9313
9521
|
const taskContext = {
|
|
9314
9522
|
taskId: input.taskId,
|
|
@@ -9319,15 +9527,17 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9319
9527
|
projectPath: input.projectPath,
|
|
9320
9528
|
routeProjectPath: input.routeProjectPath,
|
|
9321
9529
|
projectGroup: input.projectGroup,
|
|
9530
|
+
todosProjectPath: input.todosProjectPath ?? (input.todosDbPath ? todosProjectPath : undefined),
|
|
9531
|
+
todosDbPath,
|
|
9322
9532
|
worktree: worktreeContextFragment(plan)
|
|
9323
9533
|
};
|
|
9324
9534
|
const workerPrompt = [
|
|
9325
9535
|
...goalHeaderFragment(`Complete todos task ${input.taskId} in ${input.projectPath}.`, "worker", "task"),
|
|
9326
9536
|
worktreePrompt(plan),
|
|
9327
9537
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
9328
|
-
todosStartLine(todosProjectPath, input.taskId),
|
|
9329
|
-
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers")
|
|
9330
|
-
]),
|
|
9538
|
+
todosStartLine(todosProjectPath, input.taskId, todosDbPath),
|
|
9539
|
+
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers", todosDbPath)
|
|
9540
|
+
], todosDbPath),
|
|
9331
9541
|
"Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
|
|
9332
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.",
|
|
9333
9543
|
NO_TMUX_DISPATCH_FRAGMENT,
|
|
@@ -9340,9 +9550,9 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9340
9550
|
...goalHeaderFragment(`Verify todos task ${input.taskId} after the worker step.`, "verifier", "task"),
|
|
9341
9551
|
worktreePrompt(plan),
|
|
9342
9552
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
9343
|
-
todosVerificationLine(todosProjectPath, input.taskId),
|
|
9344
|
-
todosDoneLine(todosProjectPath, input.taskId)
|
|
9345
|
-
]),
|
|
9553
|
+
todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
|
|
9554
|
+
todosDoneLine(todosProjectPath, input.taskId, todosDbPath)
|
|
9555
|
+
], todosDbPath),
|
|
9346
9556
|
adversarialReviewFragment("the task, repository state, commits, tests, and worker evidence", TASK_REVIEW_FOCUS),
|
|
9347
9557
|
verifierRuntimeGuidance(input),
|
|
9348
9558
|
TASK_VERIFIER_DECISION_FRAGMENT,
|
|
@@ -9357,7 +9567,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9357
9567
|
description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
|
|
9358
9568
|
version: 1,
|
|
9359
9569
|
steps: [
|
|
9360
|
-
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),
|
|
9361
9571
|
...workerVerifierSteps({
|
|
9362
9572
|
input,
|
|
9363
9573
|
seed: input.taskId,
|
|
@@ -9377,6 +9587,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9377
9587
|
if (!input.projectPath?.trim())
|
|
9378
9588
|
throw new Error("projectPath is required");
|
|
9379
9589
|
const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
|
|
9590
|
+
const todosDbPath = input.todosDbPath;
|
|
9380
9591
|
const plan = worktreePlan(input, input.taskId);
|
|
9381
9592
|
const taskContext = {
|
|
9382
9593
|
taskId: input.taskId,
|
|
@@ -9388,6 +9599,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9388
9599
|
routeProjectPath: input.routeProjectPath,
|
|
9389
9600
|
projectGroup: input.projectGroup,
|
|
9390
9601
|
todosProjectPath,
|
|
9602
|
+
todosDbPath,
|
|
9391
9603
|
worktree: worktreeContextFragment(plan)
|
|
9392
9604
|
};
|
|
9393
9605
|
const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
|
|
@@ -9401,8 +9613,8 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9401
9613
|
const shared = [
|
|
9402
9614
|
worktreePrompt(plan),
|
|
9403
9615
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
9404
|
-
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
|
|
9405
|
-
]),
|
|
9616
|
+
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker", todosDbPath)
|
|
9617
|
+
], todosDbPath),
|
|
9406
9618
|
NO_TMUX_DISPATCH_FRAGMENT,
|
|
9407
9619
|
"Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
|
|
9408
9620
|
"",
|
|
@@ -9411,7 +9623,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9411
9623
|
].join(`
|
|
9412
9624
|
`);
|
|
9413
9625
|
const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
|
|
9414
|
-
const blockTaskCommand =
|
|
9626
|
+
const blockTaskCommand = `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
|
|
9415
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.`;
|
|
9416
9628
|
const triagePrompt = [
|
|
9417
9629
|
...goalHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
|
|
@@ -9439,7 +9651,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9439
9651
|
const workerPrompt = [
|
|
9440
9652
|
...goalHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
|
|
9441
9653
|
shared,
|
|
9442
|
-
todosStartLine(todosProjectPath, input.taskId),
|
|
9654
|
+
todosStartLine(todosProjectPath, input.taskId, todosDbPath),
|
|
9443
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.",
|
|
9444
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,
|
|
9445
9657
|
WORKER_LEAVES_COMPLETION_FRAGMENT
|
|
@@ -9448,8 +9660,8 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9448
9660
|
const verifierPrompt = [
|
|
9449
9661
|
...goalHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
|
|
9450
9662
|
shared,
|
|
9451
|
-
todosVerificationLine(todosProjectPath, input.taskId),
|
|
9452
|
-
todosDoneLine(todosProjectPath, input.taskId),
|
|
9663
|
+
todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
|
|
9664
|
+
todosDoneLine(todosProjectPath, input.taskId, todosDbPath),
|
|
9453
9665
|
adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
|
|
9454
9666
|
verifierRuntimeGuidance(input),
|
|
9455
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,
|
|
@@ -9458,7 +9670,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9458
9670
|
].filter(Boolean).join(`
|
|
9459
9671
|
`);
|
|
9460
9672
|
const steps = [
|
|
9461
|
-
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),
|
|
9462
9674
|
{
|
|
9463
9675
|
id: "triage",
|
|
9464
9676
|
name: "Triage",
|
|
@@ -9472,6 +9684,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9472
9684
|
description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
|
|
9473
9685
|
dependsOn: ["triage"],
|
|
9474
9686
|
todosProjectPath,
|
|
9687
|
+
todosDbPath,
|
|
9475
9688
|
taskId: input.taskId,
|
|
9476
9689
|
goMarker: gateMarker("triage", "go"),
|
|
9477
9690
|
blockedMarker: gateMarker("triage", "blocked"),
|
|
@@ -9490,6 +9703,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9490
9703
|
description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
|
|
9491
9704
|
dependsOn: ["planner"],
|
|
9492
9705
|
todosProjectPath,
|
|
9706
|
+
todosDbPath,
|
|
9493
9707
|
taskId: input.taskId,
|
|
9494
9708
|
goMarker: gateMarker("planner", "go"),
|
|
9495
9709
|
blockedMarker: gateMarker("planner", "blocked"),
|
|
@@ -9505,7 +9719,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9505
9719
|
}
|
|
9506
9720
|
];
|
|
9507
9721
|
if (input.prHandoff) {
|
|
9508
|
-
steps.push(prHandoffStep(input, plan, todosProjectPath));
|
|
9722
|
+
steps.push(prHandoffStep(input, plan, todosProjectPath, todosDbPath));
|
|
9509
9723
|
}
|
|
9510
9724
|
steps.push({
|
|
9511
9725
|
id: "verifier",
|
|
@@ -9730,6 +9944,7 @@ function renderLifecycleBoundedTemplate(id, values) {
|
|
|
9730
9944
|
taskTitle: values.taskTitle,
|
|
9731
9945
|
taskDescription: values.taskDescription,
|
|
9732
9946
|
todosProjectPath: values.todosProjectPath ?? values.todosProject,
|
|
9947
|
+
todosDbPath: values.todosDbPath,
|
|
9733
9948
|
triageAuthProfile: values.triageAuthProfile,
|
|
9734
9949
|
plannerAuthProfile: values.plannerAuthProfile,
|
|
9735
9950
|
triageAccount: accountVar(values.triageAccount, values.accountTool),
|
|
@@ -9794,6 +10009,7 @@ function renderBuiltinLoopTemplate(id, values) {
|
|
|
9794
10009
|
taskTitle: values.taskTitle,
|
|
9795
10010
|
taskDescription: values.taskDescription,
|
|
9796
10011
|
todosProjectPath: values.todosProjectPath ?? values.todosProject,
|
|
10012
|
+
todosDbPath: values.todosDbPath,
|
|
9797
10013
|
eventId: values.eventId,
|
|
9798
10014
|
eventType: values.eventType
|
|
9799
10015
|
});
|
|
@@ -10241,7 +10457,8 @@ function writeRouteEvidence(kind, value, evidenceDir) {
|
|
|
10241
10457
|
mkdirSync9(evidenceDir, { recursive: true, mode: 448 });
|
|
10242
10458
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
10243
10459
|
const evidencePath = join9(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
10244
|
-
|
|
10460
|
+
const encoded = scrubSecrets(JSON.stringify(scrubSecretsDeep(value), null, 2));
|
|
10461
|
+
writeFileSync5(evidencePath, encoded, { mode: 384, flag: "wx" });
|
|
10245
10462
|
return evidencePath;
|
|
10246
10463
|
}
|
|
10247
10464
|
function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
@@ -10837,6 +11054,70 @@ function todosTaskRouteTemplateId(opts) {
|
|
|
10837
11054
|
}
|
|
10838
11055
|
return id;
|
|
10839
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
|
+
}
|
|
10840
11121
|
async function readEventEnvelopeInput(opts = {}) {
|
|
10841
11122
|
const raw = opts.eventJson ?? (opts.eventFile ? readFileSync8(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
|
|
10842
11123
|
const event = JSON.parse(raw);
|
|
@@ -11013,11 +11294,13 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11013
11294
|
const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
|
|
11014
11295
|
if (!taskId)
|
|
11015
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);
|
|
11016
11299
|
const eligibility = taskRouteEligibility(data, metadata);
|
|
11017
11300
|
if (!eligibility.eligible) {
|
|
11018
11301
|
return {
|
|
11019
11302
|
kind: "skipped",
|
|
11020
|
-
value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility },
|
|
11303
|
+
value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility, sourceTask: sourceTaskPublic },
|
|
11021
11304
|
human: `skipped task ${taskId}: ${eligibility.reason}`
|
|
11022
11305
|
};
|
|
11023
11306
|
}
|
|
@@ -11032,11 +11315,11 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11032
11315
|
"project_canonical_path",
|
|
11033
11316
|
"cwd"
|
|
11034
11317
|
]);
|
|
11035
|
-
const projectPath = dataProjectPath ?? metadataProjectPath ?? opts.projectPath ?? process.cwd();
|
|
11318
|
+
const projectPath = dataProjectPath ?? metadataProjectPath ?? sourceTask.sourceRepoPath ?? opts.projectPath ?? process.cwd();
|
|
11036
11319
|
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve7(projectPath);
|
|
11037
11320
|
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
11038
11321
|
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
11039
|
-
const idempotencyKey =
|
|
11322
|
+
const idempotencyKey = sourceTask.idempotencyKey;
|
|
11040
11323
|
const idempotencySuffix = stableSuffix(idempotencyKey);
|
|
11041
11324
|
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
11042
11325
|
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
@@ -11049,7 +11332,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11049
11332
|
const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
|
|
11050
11333
|
const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
11051
11334
|
const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
|
|
11052
|
-
return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
|
|
11335
|
+
return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: sourceTaskPublic ? { sourceTask: sourceTaskPublic } : {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
|
|
11053
11336
|
}
|
|
11054
11337
|
} finally {
|
|
11055
11338
|
store.close();
|
|
@@ -11112,12 +11395,14 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11112
11395
|
prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
|
|
11113
11396
|
eventId: event.id,
|
|
11114
11397
|
eventType: event.type,
|
|
11115
|
-
todosProjectPath: opts.todosProject
|
|
11398
|
+
todosProjectPath: sourceTask.sourceRepoPath ?? opts.todosProject,
|
|
11399
|
+
todosDbPath: sourceTask.sourceDbPath
|
|
11116
11400
|
};
|
|
11117
11401
|
const workflowContext = {
|
|
11118
11402
|
name: workflowName,
|
|
11119
11403
|
type: "todos-task-event-workflow",
|
|
11120
|
-
event: event.id
|
|
11404
|
+
event: event.id,
|
|
11405
|
+
sourceTask: sourceTaskPublic
|
|
11121
11406
|
};
|
|
11122
11407
|
let workflowBody = templateId === TASK_LIFECYCLE_TEMPLATE_ID ? renderTaskLifecycleWorkflow(workflowInput) : renderTodosTaskWorkerVerifierWorkflow(workflowInput);
|
|
11123
11408
|
workflowBody.name = workflowName;
|
|
@@ -11130,13 +11415,13 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11130
11415
|
kind: "event",
|
|
11131
11416
|
id: event.id,
|
|
11132
11417
|
dedupeKey: idempotencyKey,
|
|
11133
|
-
raw: { type: event.type, source: event.source, subject: event.subject }
|
|
11418
|
+
raw: { type: event.type, source: event.source, subject: event.subject, sourceTask: sourceTaskPublic }
|
|
11134
11419
|
},
|
|
11135
11420
|
subjectRef: {
|
|
11136
11421
|
kind: "task",
|
|
11137
11422
|
id: taskId,
|
|
11138
11423
|
path: routeProjectPath,
|
|
11139
|
-
raw: { title: taskTitle, description: taskDescription }
|
|
11424
|
+
raw: { title: taskTitle, description: taskDescription, sourceTask: sourceTaskPublic }
|
|
11140
11425
|
},
|
|
11141
11426
|
intent: "route",
|
|
11142
11427
|
scope: {
|
|
@@ -11149,7 +11434,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11149
11434
|
accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
|
|
11150
11435
|
providerRouting: providerRoutingPublic(providerRouting),
|
|
11151
11436
|
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
|
|
11152
|
-
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
11437
|
+
concurrencyGroup: projectGroup ?? routeProjectPath,
|
|
11438
|
+
sourceTask: sourceTaskPublic
|
|
11153
11439
|
},
|
|
11154
11440
|
outputPolicy: {
|
|
11155
11441
|
report: "always",
|
|
@@ -11174,9 +11460,10 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11174
11460
|
humanSubject: `task ${taskId}`,
|
|
11175
11461
|
valueExtras: {
|
|
11176
11462
|
providerRouting: providerRoutingPublic(providerRouting),
|
|
11177
|
-
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined
|
|
11463
|
+
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
|
|
11464
|
+
sourceTask: sourceTaskPublic
|
|
11178
11465
|
},
|
|
11179
|
-
dedupeValueExtras: {}
|
|
11466
|
+
dedupeValueExtras: sourceTaskPublic ? { sourceTask: sourceTaskPublic } : {}
|
|
11180
11467
|
});
|
|
11181
11468
|
}
|
|
11182
11469
|
function routeGenericEvent(event, opts) {
|
|
@@ -11303,9 +11590,9 @@ import { resolve as resolve8 } from "path";
|
|
|
11303
11590
|
import { closeSync, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync9, rmSync as rmSync6 } from "fs";
|
|
11304
11591
|
import { spawnSync as spawnSync8 } from "child_process";
|
|
11305
11592
|
import { join as join10 } from "path";
|
|
11306
|
-
import { tmpdir as tmpdir2 } from "os";
|
|
11593
|
+
import { homedir as homedir5, tmpdir as tmpdir2 } from "os";
|
|
11307
11594
|
function defaultLoopsProject() {
|
|
11308
|
-
return process.env.LOOPS_TASK_PROJECT || process.env.LOOPS_DATA_DIR || `${process.env.HOME
|
|
11595
|
+
return process.env.LOOPS_TASK_PROJECT || process.env.LOOPS_DATA_DIR || `${process.env.HOME || homedir5()}/.hasna/loops`;
|
|
11309
11596
|
}
|
|
11310
11597
|
function runLocalCommand(command, args, opts = {}) {
|
|
11311
11598
|
const result = spawnSync8(command, args, {
|
|
@@ -11313,7 +11600,7 @@ function runLocalCommand(command, args, opts = {}) {
|
|
|
11313
11600
|
encoding: "utf8",
|
|
11314
11601
|
timeout: opts.timeoutMs ?? 30000,
|
|
11315
11602
|
maxBuffer: opts.maxBuffer ?? 8 * 1024 * 1024,
|
|
11316
|
-
env: process.env
|
|
11603
|
+
env: { ...process.env, ...opts.env }
|
|
11317
11604
|
});
|
|
11318
11605
|
return {
|
|
11319
11606
|
ok: result.status === 0,
|
|
@@ -11380,8 +11667,66 @@ function taskField(task, keys) {
|
|
|
11380
11667
|
}
|
|
11381
11668
|
return;
|
|
11382
11669
|
}
|
|
11383
|
-
function
|
|
11384
|
-
|
|
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)];
|
|
11385
11730
|
}
|
|
11386
11731
|
function taskProjectId(task) {
|
|
11387
11732
|
return taskField(task, ["project_id", "projectId"]);
|
|
@@ -11395,12 +11740,14 @@ function taskProjectPath(task) {
|
|
|
11395
11740
|
const metadata = objectField2(task.metadata) ?? {};
|
|
11396
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"]);
|
|
11397
11742
|
}
|
|
11398
|
-
function taskDrainEvent(task) {
|
|
11743
|
+
function taskDrainEvent(task, opts = {}) {
|
|
11399
11744
|
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
11400
11745
|
if (!taskId)
|
|
11401
11746
|
throw new Error("todos ready returned a task without an id");
|
|
11402
11747
|
const metadata = objectField2(task.metadata) ?? {};
|
|
11403
11748
|
const workingDir = taskProjectPath(task);
|
|
11749
|
+
const sourceIdentity = taskSourceIdentity(task, taskId);
|
|
11750
|
+
const sourceTask = publicSourceTaskIdentity(sourceIdentity);
|
|
11404
11751
|
const data = {
|
|
11405
11752
|
...task,
|
|
11406
11753
|
id: taskId,
|
|
@@ -11410,14 +11757,21 @@ function taskDrainEvent(task) {
|
|
|
11410
11757
|
tags: tagsFromValue2(task.tags),
|
|
11411
11758
|
metadata
|
|
11412
11759
|
};
|
|
11760
|
+
if (opts.sourceRouteEligible) {
|
|
11761
|
+
data.route_enabled = true;
|
|
11762
|
+
data.route_enabled_by = "source_route_state";
|
|
11763
|
+
}
|
|
11413
11764
|
if (workingDir) {
|
|
11414
11765
|
data.working_dir = workingDir;
|
|
11415
11766
|
data.project_path = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
|
|
11416
11767
|
data.cwd = taskField(task, ["cwd"]) ?? workingDir;
|
|
11417
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}`;
|
|
11418
11772
|
const time = new Date().toISOString();
|
|
11419
11773
|
return {
|
|
11420
|
-
id:
|
|
11774
|
+
id: eventId,
|
|
11421
11775
|
type: "task.created",
|
|
11422
11776
|
source: "@hasna/todos",
|
|
11423
11777
|
subject: taskId,
|
|
@@ -11428,6 +11782,8 @@ function taskDrainEvent(task) {
|
|
|
11428
11782
|
metadata: {
|
|
11429
11783
|
...metadata,
|
|
11430
11784
|
...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
|
|
11785
|
+
...sourceTask,
|
|
11786
|
+
...sourceTask ? { source_task: sourceTask } : {},
|
|
11431
11787
|
drained_by: "@hasna/loops",
|
|
11432
11788
|
drained_from: "todos ready"
|
|
11433
11789
|
}
|
|
@@ -11436,6 +11792,7 @@ function taskDrainEvent(task) {
|
|
|
11436
11792
|
function compactDrainResult(result) {
|
|
11437
11793
|
const value = result.value;
|
|
11438
11794
|
const event = objectField2(value.event);
|
|
11795
|
+
const sourceTask = objectField2(value.sourceTask) ?? objectField2(objectField2(event?.metadata)?.source_task) ?? objectField2(objectField2(event?.data)?.source_task);
|
|
11439
11796
|
const loop = objectField2(value.loop);
|
|
11440
11797
|
const workflow = objectField2(value.workflow);
|
|
11441
11798
|
const throttle = objectField2(value.throttle);
|
|
@@ -11452,26 +11809,75 @@ function compactDrainResult(result) {
|
|
|
11452
11809
|
workflowId: stringField2(workflow?.id),
|
|
11453
11810
|
workflowName: stringField2(workflow?.name),
|
|
11454
11811
|
providerRouting,
|
|
11812
|
+
sourceTask,
|
|
11455
11813
|
requeue,
|
|
11456
11814
|
queuedAtSource: value.queuedAtSource
|
|
11457
11815
|
};
|
|
11458
11816
|
}
|
|
11459
|
-
function
|
|
11460
|
-
|
|
11461
|
-
|
|
11462
|
-
|
|
11463
|
-
if (!result.ok)
|
|
11464
|
-
throw new Error(result.stderr || result.error || "todos ready failed");
|
|
11465
|
-
let parsed;
|
|
11817
|
+
function numberField2(value) {
|
|
11818
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
11819
|
+
}
|
|
11820
|
+
function parseTodosReadyJson(stdout) {
|
|
11466
11821
|
try {
|
|
11467
|
-
|
|
11822
|
+
return JSON.parse(stdout || "[]");
|
|
11468
11823
|
} catch (error) {
|
|
11469
11824
|
const message = error instanceof Error ? error.message : String(error);
|
|
11470
|
-
throw new Error(`failed to parse todos ready --json output (${
|
|
11471
|
-
}
|
|
11472
|
-
|
|
11473
|
-
|
|
11474
|
-
|
|
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
|
+
};
|
|
11475
11881
|
}
|
|
11476
11882
|
function resolveTaskListFilter(todosProject, filter) {
|
|
11477
11883
|
const wanted = filter?.trim();
|
|
@@ -11487,7 +11893,7 @@ function resolveTaskListFilter(todosProject, filter) {
|
|
|
11487
11893
|
function taskMatchesDrainFilters(task, filters) {
|
|
11488
11894
|
if (filters.projectId && taskProjectId(task) !== filters.projectId)
|
|
11489
11895
|
return false;
|
|
11490
|
-
if (filters.
|
|
11896
|
+
if (filters.taskList && !taskListValues(task).includes(filters.taskList))
|
|
11491
11897
|
return false;
|
|
11492
11898
|
if (filters.projectPathPrefix) {
|
|
11493
11899
|
const path = taskProjectPath(task);
|
|
@@ -11525,19 +11931,77 @@ function skippedDrainTask(task, event, reason, extra = {}) {
|
|
|
11525
11931
|
function isSkippableDrainRouteError(message) {
|
|
11526
11932
|
return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
|
|
11527
11933
|
}
|
|
11528
|
-
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 = {}) {
|
|
11529
11978
|
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
11530
11979
|
if (!taskId)
|
|
11531
11980
|
return { attempted: false, reason: "task id missing" };
|
|
11532
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.`;
|
|
11533
|
-
const
|
|
11534
|
-
|
|
11535
|
-
|
|
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 });
|
|
11536
11993
|
const ok = commentResult.ok && tagResult.ok && untagResult.ok;
|
|
11537
11994
|
return {
|
|
11538
11995
|
ok,
|
|
11539
11996
|
attempted: true,
|
|
11540
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
|
+
},
|
|
11541
12005
|
error: ok ? undefined : "one or more source task updates failed; inspect per-command results",
|
|
11542
12006
|
comment: todosMutationSummary(commentResult),
|
|
11543
12007
|
tagNoAuto: todosMutationSummary(tagResult),
|
|
@@ -11546,17 +12010,19 @@ function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
|
|
|
11546
12010
|
}
|
|
11547
12011
|
function drainTodosTaskRoutes(opts) {
|
|
11548
12012
|
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
11549
|
-
const
|
|
12013
|
+
const sourceMode = hasTodosSourceOptions(opts);
|
|
12014
|
+
const todosProject = sourceMode ? opts.todosProject : opts.todosProject ?? defaultLoopsProject();
|
|
11550
12015
|
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
11551
|
-
const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
|
|
12016
|
+
const taskListFilter = sourceMode ? opts.taskList?.trim() : resolveTaskListFilter(todosProject ?? defaultLoopsProject(), opts.taskList);
|
|
11552
12017
|
const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
|
|
11553
12018
|
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
|
|
11554
12019
|
const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
|
|
11555
12020
|
const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
|
|
11556
|
-
const
|
|
12021
|
+
const loaded = loadReadyTodosTasks(opts, scanLimit, sourceMode);
|
|
12022
|
+
const ready = loaded.tasks;
|
|
11557
12023
|
const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
|
|
11558
12024
|
projectId: opts.todosProjectId,
|
|
11559
|
-
|
|
12025
|
+
taskList: taskListFilter,
|
|
11560
12026
|
projectPathPrefix: opts.projectPathPrefix,
|
|
11561
12027
|
tags: requiredTags
|
|
11562
12028
|
}));
|
|
@@ -11569,13 +12035,25 @@ function drainTodosTaskRoutes(opts) {
|
|
|
11569
12035
|
let event;
|
|
11570
12036
|
let result;
|
|
11571
12037
|
try {
|
|
11572
|
-
|
|
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 });
|
|
11573
12051
|
result = routeTodosTaskEvent(event, opts);
|
|
11574
12052
|
} catch (error) {
|
|
11575
12053
|
const message = error instanceof Error ? error.message : String(error);
|
|
11576
12054
|
if (!isSkippableDrainRouteError(message))
|
|
11577
12055
|
throw error;
|
|
11578
|
-
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 });
|
|
11579
12057
|
result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
|
|
11580
12058
|
}
|
|
11581
12059
|
results.push(result);
|
|
@@ -11585,6 +12063,12 @@ function drainTodosTaskRoutes(opts) {
|
|
|
11585
12063
|
const report = {
|
|
11586
12064
|
drainedAt: new Date().toISOString(),
|
|
11587
12065
|
todosProject,
|
|
12066
|
+
sourceMode,
|
|
12067
|
+
sourceRoots: loaded.sourceRoots,
|
|
12068
|
+
sourceStores: loaded.sourceStores,
|
|
12069
|
+
sourceIncludes: loaded.sourceIncludes,
|
|
12070
|
+
sourceExcludes: loaded.sourceExcludes,
|
|
12071
|
+
sourceDiscovery: loaded.sourceDiscovery,
|
|
11588
12072
|
templateId: todosTaskRouteTemplateId(opts),
|
|
11589
12073
|
todosProjectId: opts.todosProjectId,
|
|
11590
12074
|
taskList: opts.taskList,
|
|
@@ -11597,14 +12081,14 @@ function drainTodosTaskRoutes(opts) {
|
|
|
11597
12081
|
scanned: ready.length,
|
|
11598
12082
|
candidates: candidates.length,
|
|
11599
12083
|
filteredCandidates: filteredCandidates.length,
|
|
11600
|
-
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,
|
|
11601
12085
|
considered: results.length,
|
|
11602
12086
|
created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
|
|
11603
12087
|
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
11604
12088
|
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
11605
12089
|
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
11606
12090
|
maxDispatch,
|
|
11607
|
-
source: "todos ready",
|
|
12091
|
+
source: sourceMode ? "todos ready source discovery" : "todos ready",
|
|
11608
12092
|
dryRun: Boolean(opts.dryRun),
|
|
11609
12093
|
results: results.map((result) => ({ kind: result.kind, ...result.value }))
|
|
11610
12094
|
};
|
|
@@ -11612,6 +12096,12 @@ function drainTodosTaskRoutes(opts) {
|
|
|
11612
12096
|
const value = opts.compact ? {
|
|
11613
12097
|
drainedAt: report.drainedAt,
|
|
11614
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,
|
|
11615
12105
|
templateId: report.templateId,
|
|
11616
12106
|
todosProjectId: report.todosProjectId,
|
|
11617
12107
|
taskList: report.taskList,
|
|
@@ -11637,7 +12127,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
11637
12127
|
results: results.map(compactDrainResult)
|
|
11638
12128
|
} : { ...report, evidencePath };
|
|
11639
12129
|
return {
|
|
11640
|
-
value,
|
|
12130
|
+
value: scrubSecretsDeep(value),
|
|
11641
12131
|
human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`
|
|
11642
12132
|
};
|
|
11643
12133
|
}
|
|
@@ -11915,6 +12405,34 @@ var EVENT_INPUT_OPTION_SPECS = [
|
|
|
11915
12405
|
{ flags: "--event-json <json>", key: "eventJson", kind: "value", description: "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON" }
|
|
11916
12406
|
];
|
|
11917
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
|
+
},
|
|
11918
12436
|
{ flags: "--todos-project-id <id>", key: "todosProjectId", kind: "value", description: "filter todos ready output to one todos project id" },
|
|
11919
12437
|
{ flags: "--task-list <id-or-slug>", key: "taskList", kind: "value", description: "filter ready tasks to one task-list id, slug, or name" },
|
|
11920
12438
|
{ flags: "--project-path-prefix <path>", key: "projectPathPrefix", kind: "value", description: "filter ready tasks to a project/repo path prefix" },
|
|
@@ -12156,6 +12674,20 @@ function runAction(fn) {
|
|
|
12156
12674
|
}
|
|
12157
12675
|
};
|
|
12158
12676
|
}
|
|
12677
|
+
function printDeploymentStatus(status, opts = {}) {
|
|
12678
|
+
if (isJson() || opts.json)
|
|
12679
|
+
console.log(JSON.stringify(status, null, 2));
|
|
12680
|
+
else {
|
|
12681
|
+
console.log(deploymentStatusLine(status));
|
|
12682
|
+
for (const warning of status.warnings)
|
|
12683
|
+
console.log(`warn ${warning}`);
|
|
12684
|
+
}
|
|
12685
|
+
}
|
|
12686
|
+
function deploymentStatusCommand(mode) {
|
|
12687
|
+
return (opts = {}) => {
|
|
12688
|
+
printDeploymentStatus(buildDeploymentStatus({ perspective: mode }), opts);
|
|
12689
|
+
};
|
|
12690
|
+
}
|
|
12159
12691
|
function printCreatedLoop(loop, human, preflight) {
|
|
12160
12692
|
if (preflight !== undefined)
|
|
12161
12693
|
print({ loop: publicLoop(loop), preflight }, human);
|
|
@@ -12484,6 +13016,11 @@ var routes = program.command("routes").alias("route").description("create, inspe
|
|
|
12484
13016
|
var events = program.command("events").description("(deprecated) Hasna event envelope aliases for 'routes create' and 'routes drain'");
|
|
12485
13017
|
var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
|
|
12486
13018
|
var goal = program.command("goal").description("inspect goal runs");
|
|
13019
|
+
program.command("mode").description("show the active OpenLoops deployment mode").option("--json", "print JSON").action(runAction(deploymentStatusCommand()));
|
|
13020
|
+
var selfHosted = program.command("self-hosted").alias("selfhosted").description("inspect the self-hosted OpenLoops contract");
|
|
13021
|
+
selfHosted.command("status").option("--json", "print JSON").action(runAction(deploymentStatusCommand("self_hosted")));
|
|
13022
|
+
var cloud = program.command("cloud").description("inspect the hosted OpenLoops contract");
|
|
13023
|
+
cloud.command("status").option("--json", "print JSON").action(runAction(deploymentStatusCommand("cloud")));
|
|
12487
13024
|
function formatTemplateVariable(template, name) {
|
|
12488
13025
|
const variable = template.variables.find((entry) => entry.name === name);
|
|
12489
13026
|
const placeholder = variable?.default ? variable.default : `<${name}>`;
|