@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/dist/index.js CHANGED
@@ -1203,7 +1203,7 @@ function discardWorkflowRunManifest(staged) {
1203
1203
  var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
1204
1204
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
1205
1205
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
1206
- var SCHEMA_USER_VERSION = 6;
1206
+ var SCHEMA_USER_VERSION = 7;
1207
1207
  var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
1208
1208
  var PRUNE_BATCH_SIZE = 400;
1209
1209
  var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
@@ -1573,6 +1573,13 @@ class Store {
1573
1573
  this.addColumnIfMissing("loop_runs", "pgid", "INTEGER");
1574
1574
  this.addColumnIfMissing("loop_runs", "process_started_at", "TEXT");
1575
1575
  }
1576
+ },
1577
+ {
1578
+ id: "0007_run_claim_tokens",
1579
+ apply: () => {
1580
+ this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
1581
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
1582
+ }
1576
1583
  }
1577
1584
  ];
1578
1585
  }
@@ -1614,6 +1621,7 @@ class Store {
1614
1621
  started_at TEXT,
1615
1622
  finished_at TEXT,
1616
1623
  claimed_by TEXT,
1624
+ claim_token TEXT,
1617
1625
  lease_expires_at TEXT,
1618
1626
  pid INTEGER,
1619
1627
  pgid INTEGER,
@@ -1632,6 +1640,7 @@ class Store {
1632
1640
  CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
1633
1641
  CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
1634
1642
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
1643
+ CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL;
1635
1644
 
1636
1645
  CREATE TABLE IF NOT EXISTS daemon_lease (
1637
1646
  id TEXT PRIMARY KEY,
@@ -3503,6 +3512,7 @@ class Store {
3503
3512
  }
3504
3513
  claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
3505
3514
  const startedAt = now.toISOString();
3515
+ const claimToken = opts.claimToken ?? genId();
3506
3516
  this.db.exec("BEGIN IMMEDIATE");
3507
3517
  try {
3508
3518
  this.assertDaemonLeaseFence(opts, startedAt);
@@ -3525,12 +3535,13 @@ class Store {
3525
3535
  return;
3526
3536
  }
3527
3537
  const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
3528
- claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
3538
+ claimed_by=$claimedBy, claim_token=$claimToken, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
3529
3539
  duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
3530
3540
  WHERE id=$id AND status='running' AND lease_expires_at <= $now`).run({
3531
3541
  $id: existing.id,
3532
3542
  $started: startedAt,
3533
3543
  $claimedBy: runnerId,
3544
+ $claimToken: claimToken,
3534
3545
  $lease: leaseExpiresAt,
3535
3546
  $updated: startedAt,
3536
3547
  $now: startedAt
@@ -3539,7 +3550,7 @@ class Store {
3539
3550
  if (res3.changes !== 1)
3540
3551
  return;
3541
3552
  const run3 = this.getRun(existing.id);
3542
- return run3 ? { run: run3, loop } : undefined;
3553
+ return run3 ? { run: run3, loop, claimToken } : undefined;
3543
3554
  }
3544
3555
  if (existing.status === "succeeded" || existing.status === "skipped") {
3545
3556
  this.db.exec("COMMIT");
@@ -3547,7 +3558,7 @@ class Store {
3547
3558
  }
3548
3559
  const attempt = existing.attempt + 1;
3549
3560
  const res2 = this.db.query(`UPDATE loop_runs SET attempt=$attempt, status='running', started_at=$started, finished_at=NULL,
3550
- claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
3561
+ claimed_by=$claimedBy, claim_token=$claimToken, lease_expires_at=$lease, pid=NULL, pgid=NULL, process_started_at=NULL, exit_code=NULL,
3551
3562
  duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
3552
3563
  WHERE id=$id
3553
3564
  AND status IN ('failed', 'timed_out', 'abandoned')
@@ -3556,6 +3567,7 @@ class Store {
3556
3567
  $attempt: attempt,
3557
3568
  $started: startedAt,
3558
3569
  $claimedBy: runnerId,
3570
+ $claimToken: claimToken,
3559
3571
  $lease: leaseExpiresAt,
3560
3572
  $updated: startedAt,
3561
3573
  $maxAttempts: loop.maxAttempts
@@ -3564,12 +3576,12 @@ class Store {
3564
3576
  if (res2.changes !== 1)
3565
3577
  return;
3566
3578
  const run2 = this.getRun(existing.id);
3567
- return run2 ? { run: run2, loop } : undefined;
3579
+ return run2 ? { run: run2, loop, claimToken } : undefined;
3568
3580
  }
3569
3581
  const id = genId();
3570
3582
  const res = this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
3571
- claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
3572
- VALUES ($id, $loopId, $loopName, $scheduledFor, 1, 'running', $started, NULL, $claimedBy, $lease,
3583
+ claimed_by, claim_token, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
3584
+ VALUES ($id, $loopId, $loopName, $scheduledFor, 1, 'running', $started, NULL, $claimedBy, $claimToken, $lease,
3573
3585
  NULL, NULL, NULL, NULL, NULL, NULL, $created, $updated)`).run({
3574
3586
  $id: id,
3575
3587
  $loopId: loop.id,
@@ -3577,6 +3589,7 @@ class Store {
3577
3589
  $scheduledFor: scheduledFor,
3578
3590
  $started: startedAt,
3579
3591
  $claimedBy: runnerId,
3592
+ $claimToken: claimToken,
3580
3593
  $lease: leaseExpiresAt,
3581
3594
  $created: startedAt,
3582
3595
  $updated: startedAt
@@ -3585,7 +3598,7 @@ class Store {
3585
3598
  if (res.changes !== 1)
3586
3599
  return;
3587
3600
  const run = this.getRun(id);
3588
- return run ? { run, loop } : undefined;
3601
+ return run ? { run, loop, claimToken } : undefined;
3589
3602
  } catch (error) {
3590
3603
  try {
3591
3604
  this.db.exec("ROLLBACK");
@@ -3608,16 +3621,18 @@ class Store {
3608
3621
  $error: error ?? null,
3609
3622
  $updated: finishedAt,
3610
3623
  $claimedBy: opts.claimedBy ?? null,
3624
+ $claimToken: opts.claimToken ?? null,
3611
3625
  $now: (opts.now ?? new Date).toISOString(),
3612
3626
  $daemonLeaseId: opts.daemonLeaseId ?? null
3613
3627
  };
3614
3628
  return this.transact(() => {
3615
- 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,
3629
+ 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,
3616
3630
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
3617
3631
  WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
3632
+ AND ($claimToken IS NULL OR claim_token=$claimToken)
3618
3633
  AND ($daemonLeaseId IS NULL OR EXISTS (
3619
3634
  SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3620
- ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
3635
+ ))`).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,
3621
3636
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
3622
3637
  const run = this.getRun(id);
3623
3638
  if (!run)
@@ -3645,11 +3660,13 @@ class Store {
3645
3660
  const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
3646
3661
  const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
3647
3662
  WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
3663
+ AND ($claimToken IS NULL OR claim_token=$claimToken)
3648
3664
  AND ($daemonLeaseId IS NULL OR EXISTS (
3649
3665
  SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
3650
3666
  ))`).run({
3651
3667
  $id: id,
3652
3668
  $claimedBy: claimedBy,
3669
+ $claimToken: opts.claimToken ?? null,
3653
3670
  $expires: expiresAt,
3654
3671
  $updated: now.toISOString(),
3655
3672
  $now: now.toISOString(),
@@ -3962,6 +3979,273 @@ class Store {
3962
3979
  this.db.close();
3963
3980
  }
3964
3981
  }
3982
+ // package.json
3983
+ var package_default = {
3984
+ name: "@hasna/loops",
3985
+ version: "0.4.2",
3986
+ description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
3987
+ type: "module",
3988
+ main: "dist/index.js",
3989
+ types: "dist/index.d.ts",
3990
+ bin: {
3991
+ loops: "dist/cli/index.js",
3992
+ "loops-daemon": "dist/daemon/index.js",
3993
+ "loops-api": "dist/api/index.js",
3994
+ "loops-runner": "dist/runner/index.js",
3995
+ "loops-mcp": "dist/mcp/index.js"
3996
+ },
3997
+ exports: {
3998
+ ".": {
3999
+ types: "./dist/index.d.ts",
4000
+ import: "./dist/index.js"
4001
+ },
4002
+ "./sdk": {
4003
+ types: "./dist/sdk/index.d.ts",
4004
+ import: "./dist/sdk/index.js"
4005
+ },
4006
+ "./mcp": {
4007
+ types: "./dist/mcp/index.d.ts",
4008
+ import: "./dist/mcp/index.js"
4009
+ },
4010
+ "./api": {
4011
+ types: "./dist/api/index.d.ts",
4012
+ import: "./dist/api/index.js"
4013
+ },
4014
+ "./runner": {
4015
+ types: "./dist/runner/index.d.ts",
4016
+ import: "./dist/runner/index.js"
4017
+ },
4018
+ "./mode": {
4019
+ types: "./dist/lib/mode.d.ts",
4020
+ import: "./dist/lib/mode.js"
4021
+ },
4022
+ "./storage": {
4023
+ types: "./dist/lib/store.d.ts",
4024
+ import: "./dist/lib/store.js"
4025
+ },
4026
+ "./storage/contract": {
4027
+ types: "./dist/lib/storage/contract.d.ts",
4028
+ import: "./dist/lib/storage/contract.js"
4029
+ },
4030
+ "./storage/sqlite": {
4031
+ types: "./dist/lib/storage/sqlite.d.ts",
4032
+ import: "./dist/lib/storage/sqlite.js"
4033
+ },
4034
+ "./storage/postgres": {
4035
+ types: "./dist/lib/storage/postgres.d.ts",
4036
+ import: "./dist/lib/storage/postgres.js"
4037
+ },
4038
+ "./storage/postgres-schema": {
4039
+ types: "./dist/lib/storage/postgres-schema.d.ts",
4040
+ import: "./dist/lib/storage/postgres-schema.js"
4041
+ }
4042
+ },
4043
+ files: [
4044
+ "dist",
4045
+ "README.md",
4046
+ "CHANGELOG.md",
4047
+ "docs",
4048
+ "LICENSE"
4049
+ ],
4050
+ scripts: {
4051
+ 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",
4052
+ "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
4053
+ typecheck: "tsc --noEmit",
4054
+ test: "bun test",
4055
+ "test:boundary": "bun run scripts/no-private-cloud-boundary.mjs",
4056
+ prepare: "test -d dist || bun run build",
4057
+ "dev:cli": "bun run src/cli/index.ts",
4058
+ "dev:daemon": "bun run src/daemon/index.ts",
4059
+ prepublishOnly: "bun run build && bun run test:boundary"
4060
+ },
4061
+ keywords: [
4062
+ "loops",
4063
+ "scheduler",
4064
+ "daemon",
4065
+ "agents",
4066
+ "claude",
4067
+ "cursor",
4068
+ "codewith",
4069
+ "opencode",
4070
+ "cli"
4071
+ ],
4072
+ repository: {
4073
+ type: "git",
4074
+ url: "git+https://github.com/hasna/loops.git"
4075
+ },
4076
+ homepage: "https://github.com/hasna/loops#readme",
4077
+ bugs: {
4078
+ url: "https://github.com/hasna/loops/issues"
4079
+ },
4080
+ author: "Hasna <andrei@hasna.com>",
4081
+ license: "Apache-2.0",
4082
+ engines: {
4083
+ bun: ">=1.0.0"
4084
+ },
4085
+ dependencies: {
4086
+ "@hasna/events": "^0.1.9",
4087
+ "@modelcontextprotocol/sdk": "^1.29.0",
4088
+ "@openrouter/ai-sdk-provider": "2.9.1",
4089
+ ai: "6.0.204",
4090
+ commander: "^13.1.0",
4091
+ zod: "4.4.3"
4092
+ },
4093
+ optionalDependencies: {
4094
+ "@hasna/machines": "0.0.49"
4095
+ },
4096
+ devDependencies: {
4097
+ "@types/bun": "latest",
4098
+ typescript: "^5.7.3"
4099
+ },
4100
+ publishConfig: {
4101
+ registry: "https://registry.npmjs.org",
4102
+ access: "public"
4103
+ }
4104
+ };
4105
+
4106
+ // src/lib/version.ts
4107
+ function packageVersion() {
4108
+ if (typeof package_default.version !== "string" || package_default.version.trim() === "")
4109
+ throw new Error("package.json version is missing");
4110
+ return package_default.version;
4111
+ }
4112
+
4113
+ // src/lib/mode.ts
4114
+ var LOOP_DEPLOYMENT_MODES = ["local", "self_hosted", "cloud"];
4115
+ var MODE_ENV_KEYS = ["LOOPS_MODE", "HASNA_LOOPS_MODE"];
4116
+ var API_URL_ENV_KEYS = ["LOOPS_API_URL", "HASNA_LOOPS_API_URL"];
4117
+ var CLOUD_API_URL_ENV_KEYS = ["LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"];
4118
+ var DATABASE_URL_ENV_KEYS = ["LOOPS_DATABASE_URL", "HASNA_LOOPS_DATABASE_URL"];
4119
+ var API_TOKEN_ENV_KEYS = ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN"];
4120
+ var CLOUD_TOKEN_ENV_KEYS = ["LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"];
4121
+ var TOKEN_ENV_KEYS = [...API_TOKEN_ENV_KEYS, ...CLOUD_TOKEN_ENV_KEYS];
4122
+ function envValue(env, keys) {
4123
+ for (const key of keys) {
4124
+ const value = env[key]?.trim();
4125
+ if (value)
4126
+ return { key, value };
4127
+ }
4128
+ return;
4129
+ }
4130
+ function normalizeLoopDeploymentMode(value) {
4131
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
4132
+ if (normalized === "local")
4133
+ return "local";
4134
+ if (normalized === "self_hosted" || normalized === "selfhosted")
4135
+ return "self_hosted";
4136
+ if (normalized === "cloud" || normalized === "saas")
4137
+ return "cloud";
4138
+ throw new Error(`unsupported OpenLoops deployment mode "${value}"; expected local, self_hosted, or cloud`);
4139
+ }
4140
+ function resolveLoopDeploymentMode(env = process.env) {
4141
+ const explicitMode = envValue(env, MODE_ENV_KEYS);
4142
+ if (explicitMode) {
4143
+ return {
4144
+ deploymentMode: normalizeLoopDeploymentMode(explicitMode.value),
4145
+ source: explicitMode.key
4146
+ };
4147
+ }
4148
+ const cloudApiUrl = envValue(env, CLOUD_API_URL_ENV_KEYS);
4149
+ if (cloudApiUrl)
4150
+ return { deploymentMode: "cloud", source: cloudApiUrl.key };
4151
+ const apiUrl = envValue(env, API_URL_ENV_KEYS);
4152
+ if (apiUrl)
4153
+ return { deploymentMode: "self_hosted", source: apiUrl.key };
4154
+ const databaseUrl = envValue(env, DATABASE_URL_ENV_KEYS);
4155
+ if (databaseUrl)
4156
+ return { deploymentMode: "self_hosted", source: databaseUrl.key };
4157
+ return { deploymentMode: "local", source: "default" };
4158
+ }
4159
+ function loopControlPlaneConfig(env = process.env) {
4160
+ return {
4161
+ apiUrl: envValue(env, API_URL_ENV_KEYS)?.value,
4162
+ cloudApiUrl: envValue(env, CLOUD_API_URL_ENV_KEYS)?.value,
4163
+ databaseUrlPresent: Boolean(envValue(env, DATABASE_URL_ENV_KEYS)),
4164
+ apiAuthTokenPresent: Boolean(envValue(env, API_TOKEN_ENV_KEYS)),
4165
+ cloudAuthTokenPresent: Boolean(envValue(env, CLOUD_TOKEN_ENV_KEYS)),
4166
+ authTokenPresent: Boolean(envValue(env, TOKEN_ENV_KEYS))
4167
+ };
4168
+ }
4169
+ function sourceOfTruthForMode(mode) {
4170
+ if (mode === "local")
4171
+ return "local_sqlite";
4172
+ if (mode === "self_hosted")
4173
+ return "self_hosted_control_plane";
4174
+ return "cloud_control_plane";
4175
+ }
4176
+ function displayControlPlaneUrl(value) {
4177
+ if (!value)
4178
+ return;
4179
+ try {
4180
+ const url = new URL(value);
4181
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/g, "");
4182
+ return `${url.origin}${path}`;
4183
+ } catch {
4184
+ return "[invalid-url]";
4185
+ }
4186
+ }
4187
+ function buildDeploymentStatus(opts = {}) {
4188
+ const env = opts.env ?? process.env;
4189
+ const active = resolveLoopDeploymentMode(env);
4190
+ const deploymentMode = opts.perspective ?? active.deploymentMode;
4191
+ const config = loopControlPlaneConfig(env);
4192
+ const rawApiUrl = deploymentMode === "cloud" ? config.cloudApiUrl : config.apiUrl;
4193
+ const apiUrl = displayControlPlaneUrl(rawApiUrl);
4194
+ const controlPlaneKind = deploymentMode === "local" ? "none" : deploymentMode;
4195
+ const deploymentAuthTokenPresent = deploymentMode === "cloud" ? config.cloudAuthTokenPresent : deploymentMode === "self_hosted" ? config.apiAuthTokenPresent : false;
4196
+ const controlPlaneConfigured = deploymentMode === "local" ? false : deploymentMode === "self_hosted" ? Boolean(config.apiUrl || config.databaseUrlPresent) : Boolean(config.cloudApiUrl && deploymentAuthTokenPresent);
4197
+ const warnings = [];
4198
+ if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
4199
+ warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
4200
+ }
4201
+ if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
4202
+ warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
4203
+ }
4204
+ if (deploymentMode === "cloud" && !config.cloudApiUrl) {
4205
+ warnings.push("cloud mode is a public contract until LOOPS_CLOUD_API_URL is configured");
4206
+ }
4207
+ if (deploymentMode === "cloud" && config.cloudApiUrl && !deploymentAuthTokenPresent) {
4208
+ warnings.push("cloud mode needs LOOPS_CLOUD_TOKEN or HASNA_LOOPS_CLOUD_TOKEN before it can become ready");
4209
+ }
4210
+ if (opts.perspective && opts.perspective !== active.deploymentMode) {
4211
+ warnings.push(`active deployment mode is ${active.deploymentMode}; this is the ${opts.perspective} perspective`);
4212
+ }
4213
+ return {
4214
+ packageVersion: packageVersion(),
4215
+ deploymentMode,
4216
+ activeDeploymentMode: active.deploymentMode,
4217
+ active: deploymentMode === active.deploymentMode,
4218
+ deploymentModeSource: active.source,
4219
+ sourceOfTruth: sourceOfTruthForMode(deploymentMode),
4220
+ localStore: {
4221
+ role: deploymentMode === "local" ? "authoritative" : "cache_and_spool"
4222
+ },
4223
+ controlPlane: {
4224
+ kind: controlPlaneKind,
4225
+ configured: controlPlaneConfigured,
4226
+ apiUrl,
4227
+ databaseUrlPresent: config.databaseUrlPresent,
4228
+ authTokenPresent: deploymentAuthTokenPresent
4229
+ },
4230
+ runner: {
4231
+ required: deploymentMode !== "local",
4232
+ role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
4233
+ },
4234
+ warnings
4235
+ };
4236
+ }
4237
+ function deploymentStatusLine(status) {
4238
+ const configured = status.controlPlane.kind === "none" ? "none" : String(status.controlPlane.configured);
4239
+ const active = status.active ? "active" : `active=${status.activeDeploymentMode}`;
4240
+ return [
4241
+ `deploymentMode=${status.deploymentMode}`,
4242
+ active,
4243
+ `source=${status.deploymentModeSource}`,
4244
+ `truth=${status.sourceOfTruth}`,
4245
+ `local=${status.localStore.role}`,
4246
+ `control_plane=${configured}`
4247
+ ].join(" ");
4248
+ }
3965
4249
 
3966
4250
  // src/mcp/index.ts
3967
4251
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -5545,11 +5829,12 @@ function publicLoop(loop) {
5545
5829
  target
5546
5830
  };
5547
5831
  }
5548
- function publicRun(run, showOutput = false) {
5832
+ function publicRun(run, showOutput = false, opts = {}) {
5549
5833
  return {
5550
5834
  ...run,
5551
5835
  stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
5552
- stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
5836
+ stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
5837
+ error: opts.redactError ? redact(run.error) : run.error
5553
5838
  };
5554
5839
  }
5555
5840
  function publicExecutorResult(result, showOutput = false) {
@@ -7323,105 +7608,6 @@ async function tick(deps) {
7323
7608
  }
7324
7609
  return { claimed, completed, skipped, recovered, expired };
7325
7610
  }
7326
- // package.json
7327
- var package_default = {
7328
- name: "@hasna/loops",
7329
- version: "0.4.0",
7330
- description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
7331
- type: "module",
7332
- main: "dist/index.js",
7333
- types: "dist/index.d.ts",
7334
- bin: {
7335
- loops: "dist/cli/index.js",
7336
- "loops-daemon": "dist/daemon/index.js",
7337
- "loops-mcp": "dist/mcp/index.js"
7338
- },
7339
- exports: {
7340
- ".": {
7341
- types: "./dist/index.d.ts",
7342
- import: "./dist/index.js"
7343
- },
7344
- "./sdk": {
7345
- types: "./dist/sdk/index.d.ts",
7346
- import: "./dist/sdk/index.js"
7347
- },
7348
- "./mcp": {
7349
- types: "./dist/mcp/index.d.ts",
7350
- import: "./dist/mcp/index.js"
7351
- },
7352
- "./storage": {
7353
- types: "./dist/lib/store.d.ts",
7354
- import: "./dist/lib/store.js"
7355
- }
7356
- },
7357
- files: [
7358
- "dist",
7359
- "README.md",
7360
- "CHANGELOG.md",
7361
- "docs",
7362
- "LICENSE"
7363
- ],
7364
- scripts: {
7365
- 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",
7366
- "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
7367
- typecheck: "tsc --noEmit",
7368
- test: "bun test",
7369
- prepare: "test -d dist || bun run build",
7370
- "dev:cli": "bun run src/cli/index.ts",
7371
- "dev:daemon": "bun run src/daemon/index.ts",
7372
- prepublishOnly: "bun run build"
7373
- },
7374
- keywords: [
7375
- "loops",
7376
- "scheduler",
7377
- "daemon",
7378
- "agents",
7379
- "claude",
7380
- "cursor",
7381
- "codewith",
7382
- "opencode",
7383
- "cli"
7384
- ],
7385
- repository: {
7386
- type: "git",
7387
- url: "git+https://github.com/hasna/loops.git"
7388
- },
7389
- homepage: "https://github.com/hasna/loops#readme",
7390
- bugs: {
7391
- url: "https://github.com/hasna/loops/issues"
7392
- },
7393
- author: "Hasna <andrei@hasna.com>",
7394
- license: "Apache-2.0",
7395
- engines: {
7396
- bun: ">=1.0.0"
7397
- },
7398
- dependencies: {
7399
- "@hasna/events": "^0.1.9",
7400
- "@modelcontextprotocol/sdk": "^1.29.0",
7401
- "@openrouter/ai-sdk-provider": "2.9.1",
7402
- ai: "6.0.204",
7403
- commander: "^13.1.0",
7404
- zod: "4.4.3"
7405
- },
7406
- optionalDependencies: {
7407
- "@hasna/machines": "0.0.49"
7408
- },
7409
- devDependencies: {
7410
- "@types/bun": "latest",
7411
- typescript: "^5.7.3"
7412
- },
7413
- publishConfig: {
7414
- registry: "https://registry.npmjs.org",
7415
- access: "public"
7416
- }
7417
- };
7418
-
7419
- // src/lib/version.ts
7420
- function packageVersion() {
7421
- if (typeof package_default.version !== "string" || package_default.version.trim() === "")
7422
- throw new Error("package.json version is missing");
7423
- return package_default.version;
7424
- }
7425
7611
 
7426
7612
  // src/mcp/index.ts
7427
7613
  var LOOP_STATUSES = ["active", "paused", "stopped", "expired"];
@@ -8141,15 +8327,15 @@ function openAutomationsRuntimeBinding(overrides = {}) {
8141
8327
  failCommand: "automations queue fail",
8142
8328
  eventHandoff: {
8143
8329
  envelopeCommand: "automations webhooks event",
8144
- handlerCommand: "loops events handle generic",
8145
- pipeExample: "automations --json webhooks event <route> --body-json '<json>' | loops --json events handle generic",
8330
+ handlerCommand: "loops routes create generic",
8331
+ pipeExample: "automations --json webhooks event <route> --body-json '<json>' | loops --json routes create generic",
8146
8332
  boundary: "Use only for explicit event-envelope workflow handoff. OpenAutomations still owns deterministic automation materialization and queue state; OpenLoops owns workflow invocation."
8147
8333
  },
8148
8334
  requiredEnvironment: ["HASNA_AUTOMATIONS_DIR"],
8149
8335
  guarantees: [
8150
8336
  "OpenAutomations owns automation specs, run materialization, queue state, DLQ, replay, idempotency, and approvals.",
8151
8337
  "OpenLoops may execute claimed actions through explicit command or SDK handoff only.",
8152
- "OpenLoops may consume exported event envelopes only through explicit events handle commands.",
8338
+ "OpenLoops may consume exported event envelopes only through explicit routes create commands.",
8153
8339
  "Workers must complete or fail actions by action id and runner id so OpenAutomations can enforce queue leases."
8154
8340
  ],
8155
8341
  nonGoals: [
@@ -8167,6 +8353,627 @@ function openAutomationsRuntimeBinding(overrides = {}) {
8167
8353
  nonGoals: overrides.nonGoals ?? defaults.nonGoals
8168
8354
  };
8169
8355
  }
8356
+
8357
+ // src/lib/storage/sqlite.ts
8358
+ class SqliteLoopStorage {
8359
+ store;
8360
+ backend = "sqlite";
8361
+ supportsRemoteRunners = false;
8362
+ constructor(store = new Store) {
8363
+ this.store = store;
8364
+ }
8365
+ async close() {
8366
+ this.store.close();
8367
+ }
8368
+ async call(method, ...args) {
8369
+ return this.store[method](...args);
8370
+ }
8371
+ createLoop(...args) {
8372
+ return this.call("createLoop", ...args);
8373
+ }
8374
+ getLoop(...args) {
8375
+ return this.call("getLoop", ...args);
8376
+ }
8377
+ findLoopByName(...args) {
8378
+ return this.call("findLoopByName", ...args);
8379
+ }
8380
+ requireLoop(...args) {
8381
+ return this.call("requireLoop", ...args);
8382
+ }
8383
+ listLoops(...args) {
8384
+ return this.call("listLoops", ...args);
8385
+ }
8386
+ dueLoops(...args) {
8387
+ return this.call("dueLoops", ...args);
8388
+ }
8389
+ updateLoop(...args) {
8390
+ return this.call("updateLoop", ...args);
8391
+ }
8392
+ renameLoop(...args) {
8393
+ return this.call("renameLoop", ...args);
8394
+ }
8395
+ archiveLoop(...args) {
8396
+ return this.call("archiveLoop", ...args);
8397
+ }
8398
+ unarchiveLoop(...args) {
8399
+ return this.call("unarchiveLoop", ...args);
8400
+ }
8401
+ deleteLoop(...args) {
8402
+ return this.call("deleteLoop", ...args);
8403
+ }
8404
+ createWorkflow(...args) {
8405
+ return this.call("createWorkflow", ...args);
8406
+ }
8407
+ getWorkflow(...args) {
8408
+ return this.call("getWorkflow", ...args);
8409
+ }
8410
+ listWorkflows(...args) {
8411
+ return this.call("listWorkflows", ...args);
8412
+ }
8413
+ countWorkflows(...args) {
8414
+ return this.call("countWorkflows", ...args);
8415
+ }
8416
+ archiveWorkflow(...args) {
8417
+ return this.call("archiveWorkflow", ...args);
8418
+ }
8419
+ createWorkflowInvocation(...args) {
8420
+ return this.call("createWorkflowInvocation", ...args);
8421
+ }
8422
+ getWorkflowInvocation(...args) {
8423
+ return this.call("getWorkflowInvocation", ...args);
8424
+ }
8425
+ listWorkflowInvocations(...args) {
8426
+ return this.call("listWorkflowInvocations", ...args);
8427
+ }
8428
+ upsertWorkflowWorkItem(...args) {
8429
+ return this.call("upsertWorkflowWorkItem", ...args);
8430
+ }
8431
+ getWorkflowWorkItem(...args) {
8432
+ return this.call("getWorkflowWorkItem", ...args);
8433
+ }
8434
+ listWorkflowWorkItems(...args) {
8435
+ return this.call("listWorkflowWorkItems", ...args);
8436
+ }
8437
+ countActiveWorkflowWorkItems(...args) {
8438
+ return this.call("countActiveWorkflowWorkItems", ...args);
8439
+ }
8440
+ admitWorkflowWorkItem(...args) {
8441
+ return this.call("admitWorkflowWorkItem", ...args);
8442
+ }
8443
+ createGoal(...args) {
8444
+ return this.call("createGoal", ...args);
8445
+ }
8446
+ getGoal(...args) {
8447
+ return this.call("getGoal", ...args);
8448
+ }
8449
+ listGoals(...args) {
8450
+ return this.call("listGoals", ...args);
8451
+ }
8452
+ createGoalPlanNodes(...args) {
8453
+ return this.call("createGoalPlanNodes", ...args);
8454
+ }
8455
+ listGoalPlanNodes(...args) {
8456
+ return this.call("listGoalPlanNodes", ...args);
8457
+ }
8458
+ updateGoalStatus(...args) {
8459
+ return this.call("updateGoalStatus", ...args);
8460
+ }
8461
+ updateGoalPlanNode(...args) {
8462
+ return this.call("updateGoalPlanNode", ...args);
8463
+ }
8464
+ recordGoalEvent(...args) {
8465
+ return this.call("recordGoalEvent", ...args);
8466
+ }
8467
+ listGoalRuns(...args) {
8468
+ return this.call("listGoalRuns", ...args);
8469
+ }
8470
+ createWorkflowRun(...args) {
8471
+ return this.call("createWorkflowRun", ...args);
8472
+ }
8473
+ getWorkflowRun(...args) {
8474
+ return this.call("getWorkflowRun", ...args);
8475
+ }
8476
+ listWorkflowRuns(...args) {
8477
+ return this.call("listWorkflowRuns", ...args);
8478
+ }
8479
+ listWorkflowStepRuns(...args) {
8480
+ return this.call("listWorkflowStepRuns", ...args);
8481
+ }
8482
+ getWorkflowStepRun(...args) {
8483
+ return this.call("getWorkflowStepRun", ...args);
8484
+ }
8485
+ startWorkflowStepRun(...args) {
8486
+ return this.call("startWorkflowStepRun", ...args);
8487
+ }
8488
+ recoverWorkflowRun(...args) {
8489
+ return this.call("recoverWorkflowRun", ...args);
8490
+ }
8491
+ finalizeWorkflowStepRun(...args) {
8492
+ return this.call("finalizeWorkflowStepRun", ...args);
8493
+ }
8494
+ finalizeWorkflowRun(...args) {
8495
+ return this.call("finalizeWorkflowRun", ...args);
8496
+ }
8497
+ appendWorkflowEvent(...args) {
8498
+ return this.call("appendWorkflowEvent", ...args);
8499
+ }
8500
+ listWorkflowEvents(...args) {
8501
+ return this.call("listWorkflowEvents", ...args);
8502
+ }
8503
+ recordRunProcess(...args) {
8504
+ return this.call("recordRunProcess", ...args);
8505
+ }
8506
+ createSkippedRun(...args) {
8507
+ return this.call("createSkippedRun", ...args);
8508
+ }
8509
+ getRun(...args) {
8510
+ return this.call("getRun", ...args);
8511
+ }
8512
+ getRunBySlot(...args) {
8513
+ return this.call("getRunBySlot", ...args);
8514
+ }
8515
+ claimRun(...args) {
8516
+ return this.call("claimRun", ...args);
8517
+ }
8518
+ finalizeRun(...args) {
8519
+ return this.call("finalizeRun", ...args);
8520
+ }
8521
+ heartbeatRunLease(...args) {
8522
+ return this.call("heartbeatRunLease", ...args);
8523
+ }
8524
+ listRuns(...args) {
8525
+ return this.call("listRuns", ...args);
8526
+ }
8527
+ recoverExpiredRunLeases(...args) {
8528
+ return this.call("recoverExpiredRunLeases", ...args);
8529
+ }
8530
+ recoverExpiredRunLeasesDetailed(...args) {
8531
+ return this.call("recoverExpiredRunLeasesDetailed", ...args);
8532
+ }
8533
+ countLoops(...args) {
8534
+ return this.call("countLoops", ...args);
8535
+ }
8536
+ countRuns(...args) {
8537
+ return this.call("countRuns", ...args);
8538
+ }
8539
+ pruneHistory(...args) {
8540
+ return this.call("pruneHistory", ...args);
8541
+ }
8542
+ acquireDaemonLease(...args) {
8543
+ return this.call("acquireDaemonLease", ...args);
8544
+ }
8545
+ heartbeatDaemonLease(...args) {
8546
+ return this.call("heartbeatDaemonLease", ...args);
8547
+ }
8548
+ releaseDaemonLease(...args) {
8549
+ return this.call("releaseDaemonLease", ...args);
8550
+ }
8551
+ getDaemonLease(...args) {
8552
+ return this.call("getDaemonLease", ...args);
8553
+ }
8554
+ }
8555
+ function createSqliteLoopStorage(path) {
8556
+ return new SqliteLoopStorage(new Store(path));
8557
+ }
8558
+
8559
+ // src/lib/storage/postgres-schema.ts
8560
+ import { createHash as createHash3 } from "crypto";
8561
+ var POSTGRES_MIGRATION_LEDGER_TABLE = "open_loops_schema_migrations";
8562
+ function checksumStorageSql(sql) {
8563
+ const normalized = sql.trim().replace(/\r\n/g, `
8564
+ `);
8565
+ return `sha256:${createHash3("sha256").update(normalized).digest("hex")}`;
8566
+ }
8567
+ function migration(id, sql) {
8568
+ return Object.freeze({ id, sql: sql.trim(), checksum: checksumStorageSql(sql) });
8569
+ }
8570
+ var POSTGRES_STORAGE_MIGRATIONS = Object.freeze([
8571
+ migration("0001_core_runtime", `
8572
+ CREATE TABLE IF NOT EXISTS loops (
8573
+ id TEXT PRIMARY KEY,
8574
+ name TEXT NOT NULL,
8575
+ description TEXT,
8576
+ status TEXT NOT NULL,
8577
+ archived_at TIMESTAMPTZ,
8578
+ archived_from_status TEXT,
8579
+ schedule_json JSONB NOT NULL,
8580
+ target_json JSONB NOT NULL,
8581
+ goal_json JSONB,
8582
+ machine_json JSONB,
8583
+ next_run_at TIMESTAMPTZ,
8584
+ retry_scheduled_for TIMESTAMPTZ,
8585
+ catch_up TEXT NOT NULL,
8586
+ catch_up_limit INTEGER NOT NULL,
8587
+ overlap TEXT NOT NULL,
8588
+ max_attempts INTEGER NOT NULL,
8589
+ retry_delay_ms INTEGER NOT NULL,
8590
+ lease_ms INTEGER NOT NULL,
8591
+ expires_at TIMESTAMPTZ,
8592
+ created_at TIMESTAMPTZ NOT NULL,
8593
+ updated_at TIMESTAMPTZ NOT NULL
8594
+ );
8595
+ CREATE INDEX IF NOT EXISTS idx_loops_status_next ON loops(status, next_run_at);
8596
+ CREATE INDEX IF NOT EXISTS idx_loops_name ON loops(name);
8597
+
8598
+ CREATE TABLE IF NOT EXISTS loop_runs (
8599
+ id TEXT PRIMARY KEY,
8600
+ loop_id TEXT NOT NULL REFERENCES loops(id) ON DELETE CASCADE,
8601
+ loop_name TEXT NOT NULL,
8602
+ scheduled_for TIMESTAMPTZ NOT NULL,
8603
+ attempt INTEGER NOT NULL,
8604
+ status TEXT NOT NULL,
8605
+ started_at TIMESTAMPTZ,
8606
+ finished_at TIMESTAMPTZ,
8607
+ claimed_by TEXT,
8608
+ claim_token TEXT,
8609
+ lease_expires_at TIMESTAMPTZ,
8610
+ pid INTEGER,
8611
+ pgid INTEGER,
8612
+ process_started_at TIMESTAMPTZ,
8613
+ exit_code INTEGER,
8614
+ duration_ms INTEGER,
8615
+ stdout TEXT,
8616
+ stderr TEXT,
8617
+ error TEXT,
8618
+ goal_run_id TEXT,
8619
+ created_at TIMESTAMPTZ NOT NULL,
8620
+ updated_at TIMESTAMPTZ NOT NULL,
8621
+ UNIQUE(loop_id, scheduled_for)
8622
+ );
8623
+ CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
8624
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
8625
+ CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
8626
+ CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
8627
+ CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL;
8628
+
8629
+ CREATE TABLE IF NOT EXISTS daemon_lease (
8630
+ id TEXT PRIMARY KEY,
8631
+ pid INTEGER NOT NULL,
8632
+ hostname TEXT NOT NULL,
8633
+ heartbeat_at TIMESTAMPTZ NOT NULL,
8634
+ expires_at TIMESTAMPTZ NOT NULL,
8635
+ created_at TIMESTAMPTZ NOT NULL,
8636
+ updated_at TIMESTAMPTZ NOT NULL
8637
+ );
8638
+ `),
8639
+ migration("0002_workflows_goals", `
8640
+ CREATE TABLE IF NOT EXISTS workflow_specs (
8641
+ id TEXT PRIMARY KEY,
8642
+ name TEXT NOT NULL,
8643
+ description TEXT,
8644
+ version INTEGER NOT NULL,
8645
+ status TEXT NOT NULL,
8646
+ goal_json JSONB,
8647
+ steps_json JSONB NOT NULL,
8648
+ created_at TIMESTAMPTZ NOT NULL,
8649
+ updated_at TIMESTAMPTZ NOT NULL
8650
+ );
8651
+ CREATE INDEX IF NOT EXISTS idx_workflows_status_name ON workflow_specs(status, name);
8652
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_name_active ON workflow_specs(name) WHERE status = 'active';
8653
+
8654
+ CREATE TABLE IF NOT EXISTS workflow_runs (
8655
+ id TEXT PRIMARY KEY,
8656
+ workflow_id TEXT NOT NULL REFERENCES workflow_specs(id) ON DELETE CASCADE,
8657
+ workflow_name TEXT NOT NULL,
8658
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
8659
+ loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
8660
+ invocation_id TEXT,
8661
+ work_item_id TEXT,
8662
+ scheduled_for TIMESTAMPTZ,
8663
+ idempotency_key TEXT,
8664
+ manifest_path TEXT,
8665
+ status TEXT NOT NULL,
8666
+ started_at TIMESTAMPTZ,
8667
+ finished_at TIMESTAMPTZ,
8668
+ duration_ms INTEGER,
8669
+ error TEXT,
8670
+ goal_run_id TEXT,
8671
+ created_at TIMESTAMPTZ NOT NULL,
8672
+ updated_at TIMESTAMPTZ NOT NULL
8673
+ );
8674
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_idempotency ON workflow_runs(workflow_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
8675
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
8676
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
8677
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
8678
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
8679
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
8680
+
8681
+ CREATE TABLE IF NOT EXISTS workflow_invocations (
8682
+ id TEXT PRIMARY KEY,
8683
+ workflow_id TEXT,
8684
+ template_id TEXT,
8685
+ source_kind TEXT NOT NULL,
8686
+ source_id TEXT,
8687
+ source_dedupe_key TEXT,
8688
+ source_json JSONB NOT NULL,
8689
+ subject_kind TEXT NOT NULL,
8690
+ subject_id TEXT,
8691
+ subject_path TEXT,
8692
+ subject_url TEXT,
8693
+ subject_json JSONB NOT NULL,
8694
+ intent TEXT NOT NULL,
8695
+ scope_json JSONB,
8696
+ output_policy_json JSONB,
8697
+ created_at TIMESTAMPTZ NOT NULL,
8698
+ updated_at TIMESTAMPTZ NOT NULL
8699
+ );
8700
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
8701
+ CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
8702
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe ON workflow_invocations(source_kind, source_dedupe_key) WHERE source_dedupe_key IS NOT NULL;
8703
+
8704
+ CREATE TABLE IF NOT EXISTS workflow_work_items (
8705
+ id TEXT PRIMARY KEY,
8706
+ route_key TEXT NOT NULL,
8707
+ idempotency_key TEXT NOT NULL,
8708
+ invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
8709
+ source_type TEXT NOT NULL,
8710
+ source_ref TEXT NOT NULL,
8711
+ subject_ref TEXT NOT NULL,
8712
+ project_key TEXT,
8713
+ project_group TEXT,
8714
+ priority INTEGER NOT NULL,
8715
+ status TEXT NOT NULL,
8716
+ attempts INTEGER NOT NULL,
8717
+ next_attempt_at TIMESTAMPTZ,
8718
+ lease_expires_at TIMESTAMPTZ,
8719
+ workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
8720
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
8721
+ workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
8722
+ last_reason TEXT,
8723
+ created_at TIMESTAMPTZ NOT NULL,
8724
+ updated_at TIMESTAMPTZ NOT NULL,
8725
+ UNIQUE(route_key, idempotency_key)
8726
+ );
8727
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
8728
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
8729
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
8730
+ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
8731
+
8732
+ CREATE TABLE IF NOT EXISTS workflow_step_runs (
8733
+ id TEXT PRIMARY KEY,
8734
+ workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
8735
+ step_id TEXT NOT NULL,
8736
+ sequence INTEGER NOT NULL,
8737
+ status TEXT NOT NULL,
8738
+ started_at TIMESTAMPTZ,
8739
+ finished_at TIMESTAMPTZ,
8740
+ exit_code INTEGER,
8741
+ pid INTEGER,
8742
+ duration_ms INTEGER,
8743
+ stdout TEXT,
8744
+ stderr TEXT,
8745
+ error TEXT,
8746
+ account_profile TEXT,
8747
+ account_tool TEXT,
8748
+ goal_run_id TEXT,
8749
+ created_at TIMESTAMPTZ NOT NULL,
8750
+ updated_at TIMESTAMPTZ NOT NULL,
8751
+ UNIQUE(workflow_run_id, step_id)
8752
+ );
8753
+ CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_run_sequence ON workflow_step_runs(workflow_run_id, sequence);
8754
+ CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_status ON workflow_step_runs(status);
8755
+
8756
+ CREATE TABLE IF NOT EXISTS workflow_events (
8757
+ id TEXT PRIMARY KEY,
8758
+ workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
8759
+ sequence INTEGER NOT NULL,
8760
+ event_type TEXT NOT NULL,
8761
+ step_id TEXT,
8762
+ payload_json JSONB,
8763
+ created_at TIMESTAMPTZ NOT NULL,
8764
+ UNIQUE(workflow_run_id, sequence)
8765
+ );
8766
+ CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
8767
+
8768
+ CREATE TABLE IF NOT EXISTS goals (
8769
+ id TEXT PRIMARY KEY,
8770
+ plan_id TEXT NOT NULL,
8771
+ objective TEXT NOT NULL,
8772
+ status TEXT NOT NULL,
8773
+ token_budget INTEGER,
8774
+ tokens_used INTEGER NOT NULL,
8775
+ time_used_seconds INTEGER NOT NULL,
8776
+ auto_execute TEXT NOT NULL,
8777
+ max_tokens INTEGER,
8778
+ source_type TEXT,
8779
+ source_id TEXT,
8780
+ loop_id TEXT,
8781
+ loop_run_id TEXT,
8782
+ workflow_id TEXT,
8783
+ workflow_run_id TEXT,
8784
+ workflow_step_id TEXT,
8785
+ created_at TIMESTAMPTZ NOT NULL,
8786
+ updated_at TIMESTAMPTZ NOT NULL
8787
+ );
8788
+ CREATE INDEX IF NOT EXISTS idx_goals_status_updated ON goals(status, updated_at DESC);
8789
+ CREATE INDEX IF NOT EXISTS idx_goals_loop_run ON goals(loop_run_id);
8790
+ CREATE INDEX IF NOT EXISTS idx_goals_workflow_run ON goals(workflow_run_id);
8791
+ CREATE INDEX IF NOT EXISTS idx_goals_source ON goals(source_type, source_id);
8792
+
8793
+ CREATE TABLE IF NOT EXISTS goal_plan_nodes (
8794
+ id TEXT PRIMARY KEY,
8795
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
8796
+ plan_id TEXT NOT NULL,
8797
+ key TEXT NOT NULL,
8798
+ sequence INTEGER NOT NULL,
8799
+ priority INTEGER NOT NULL,
8800
+ objective TEXT NOT NULL,
8801
+ status TEXT NOT NULL,
8802
+ ready BOOLEAN NOT NULL,
8803
+ token_budget INTEGER,
8804
+ tokens_used INTEGER NOT NULL,
8805
+ time_used_seconds INTEGER NOT NULL,
8806
+ depends_on_json JSONB NOT NULL,
8807
+ created_at TIMESTAMPTZ NOT NULL,
8808
+ updated_at TIMESTAMPTZ NOT NULL,
8809
+ UNIQUE(plan_id, key)
8810
+ );
8811
+ CREATE INDEX IF NOT EXISTS idx_goal_plan_nodes_goal_sequence ON goal_plan_nodes(goal_id, sequence);
8812
+ CREATE INDEX IF NOT EXISTS idx_goal_plan_nodes_status ON goal_plan_nodes(status);
8813
+
8814
+ CREATE TABLE IF NOT EXISTS goal_runs (
8815
+ id TEXT PRIMARY KEY,
8816
+ goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
8817
+ plan_id TEXT NOT NULL,
8818
+ loop_id TEXT,
8819
+ loop_run_id TEXT,
8820
+ workflow_id TEXT,
8821
+ workflow_run_id TEXT,
8822
+ workflow_step_id TEXT,
8823
+ turn INTEGER NOT NULL,
8824
+ phase TEXT NOT NULL,
8825
+ status TEXT NOT NULL,
8826
+ node_key TEXT,
8827
+ tokens_used INTEGER NOT NULL,
8828
+ evidence_json JSONB,
8829
+ raw_response_json JSONB,
8830
+ created_at TIMESTAMPTZ NOT NULL,
8831
+ updated_at TIMESTAMPTZ NOT NULL
8832
+ );
8833
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_goal_created ON goal_runs(goal_id, created_at);
8834
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_loop_run ON goal_runs(loop_run_id);
8835
+ CREATE INDEX IF NOT EXISTS idx_goal_runs_workflow_run ON goal_runs(workflow_run_id);
8836
+ `),
8837
+ migration("0003_remote_runners_and_audit", `
8838
+ CREATE TABLE IF NOT EXISTS runner_machines (
8839
+ id TEXT PRIMARY KEY,
8840
+ hostname TEXT NOT NULL,
8841
+ labels_json JSONB NOT NULL DEFAULT '{}'::jsonb,
8842
+ capabilities_json JSONB NOT NULL DEFAULT '{}'::jsonb,
8843
+ status TEXT NOT NULL,
8844
+ last_seen_at TIMESTAMPTZ NOT NULL,
8845
+ created_at TIMESTAMPTZ NOT NULL,
8846
+ updated_at TIMESTAMPTZ NOT NULL
8847
+ );
8848
+ CREATE INDEX IF NOT EXISTS idx_runner_machines_status_seen ON runner_machines(status, last_seen_at DESC);
8849
+
8850
+ CREATE TABLE IF NOT EXISTS runner_leases (
8851
+ id TEXT PRIMARY KEY,
8852
+ runner_id TEXT NOT NULL REFERENCES runner_machines(id) ON DELETE CASCADE,
8853
+ loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE CASCADE,
8854
+ workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE CASCADE,
8855
+ claim_token TEXT NOT NULL,
8856
+ status TEXT NOT NULL,
8857
+ heartbeat_at TIMESTAMPTZ NOT NULL,
8858
+ expires_at TIMESTAMPTZ NOT NULL,
8859
+ created_at TIMESTAMPTZ NOT NULL,
8860
+ updated_at TIMESTAMPTZ NOT NULL,
8861
+ CHECK (loop_run_id IS NOT NULL OR workflow_run_id IS NOT NULL)
8862
+ );
8863
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_runner_leases_active_loop_run ON runner_leases(loop_run_id) WHERE loop_run_id IS NOT NULL AND status = 'active';
8864
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_runner_leases_active_workflow_run ON runner_leases(workflow_run_id) WHERE workflow_run_id IS NOT NULL AND status = 'active';
8865
+ CREATE INDEX IF NOT EXISTS idx_runner_leases_runner_status ON runner_leases(runner_id, status, expires_at);
8866
+ CREATE INDEX IF NOT EXISTS idx_runner_leases_claim_token ON runner_leases(claim_token);
8867
+
8868
+ CREATE TABLE IF NOT EXISTS audit_events (
8869
+ id TEXT PRIMARY KEY,
8870
+ actor TEXT NOT NULL,
8871
+ action TEXT NOT NULL,
8872
+ subject_type TEXT NOT NULL,
8873
+ subject_id TEXT NOT NULL,
8874
+ metadata_json JSONB,
8875
+ created_at TIMESTAMPTZ NOT NULL
8876
+ );
8877
+ CREATE INDEX IF NOT EXISTS idx_audit_events_subject ON audit_events(subject_type, subject_id, created_at DESC);
8878
+ CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events(action, created_at DESC);
8879
+ `)
8880
+ ]);
8881
+
8882
+ // src/lib/storage/postgres.ts
8883
+ class PostgresStorage {
8884
+ executor;
8885
+ backend = "postgres";
8886
+ migrations;
8887
+ constructor(executor, migrations = POSTGRES_STORAGE_MIGRATIONS) {
8888
+ this.executor = executor;
8889
+ this.migrations = migrations;
8890
+ }
8891
+ async listAppliedMigrations() {
8892
+ await this.ensureLedger();
8893
+ return this.readAppliedMigrations();
8894
+ }
8895
+ async migrate(opts = {}) {
8896
+ const dryRun = opts.dryRun === true;
8897
+ if (!dryRun)
8898
+ await this.ensureLedger();
8899
+ const applied = dryRun ? await this.tryReadAppliedMigrations() : await this.readAppliedMigrations();
8900
+ const knownMigrationIds = new Set(this.migrations.map((migration2) => migration2.id));
8901
+ for (const row of applied) {
8902
+ if (!knownMigrationIds.has(row.id)) {
8903
+ throw new Error(`Postgres migration ${row.id} is not recognized by this binary`);
8904
+ }
8905
+ }
8906
+ const appliedById = new Map(applied.map((row) => [row.id, row]));
8907
+ const plan = this.migrations.map((migration2) => ({
8908
+ migration: migration2,
8909
+ state: appliedById.has(migration2.id) ? "already_applied" : "pending"
8910
+ }));
8911
+ for (const migration2 of this.migrations) {
8912
+ const existing = appliedById.get(migration2.id);
8913
+ if (existing && existing.checksum !== migration2.checksum) {
8914
+ throw new Error(`Postgres migration checksum mismatch for ${migration2.id}`);
8915
+ }
8916
+ }
8917
+ if (dryRun)
8918
+ return { backend: this.backend, dryRun, applied, plan };
8919
+ const run = async () => {
8920
+ for (const item of plan) {
8921
+ if (item.state === "already_applied")
8922
+ continue;
8923
+ await this.executor.execute(item.migration.sql);
8924
+ await this.executor.execute(`INSERT INTO ${POSTGRES_MIGRATION_LEDGER_TABLE} (id, checksum, applied_at) VALUES ($1, $2, NOW())`, [item.migration.id, item.migration.checksum]);
8925
+ }
8926
+ };
8927
+ if (this.executor.transaction)
8928
+ await this.executor.transaction(run);
8929
+ else
8930
+ await run();
8931
+ return {
8932
+ backend: this.backend,
8933
+ dryRun,
8934
+ applied: await this.readAppliedMigrations(),
8935
+ plan
8936
+ };
8937
+ }
8938
+ async close() {
8939
+ await this.executor.close?.();
8940
+ }
8941
+ async ensureLedger() {
8942
+ await this.executor.execute(`
8943
+ CREATE TABLE IF NOT EXISTS ${POSTGRES_MIGRATION_LEDGER_TABLE} (
8944
+ id TEXT PRIMARY KEY,
8945
+ checksum TEXT NOT NULL,
8946
+ applied_at TIMESTAMPTZ NOT NULL
8947
+ );
8948
+ `);
8949
+ }
8950
+ async readAppliedMigrations() {
8951
+ const rows = await this.executor.query(`SELECT id, checksum, applied_at FROM ${POSTGRES_MIGRATION_LEDGER_TABLE} ORDER BY id ASC`);
8952
+ return rows.map((row) => ({
8953
+ id: row.id,
8954
+ checksum: row.checksum,
8955
+ appliedAt: row.applied_at instanceof Date ? row.applied_at.toISOString() : row.applied_at
8956
+ }));
8957
+ }
8958
+ async tryReadAppliedMigrations() {
8959
+ try {
8960
+ return await this.readAppliedMigrations();
8961
+ } catch (error) {
8962
+ if (isMissingLedgerError(error))
8963
+ return [];
8964
+ throw error;
8965
+ }
8966
+ }
8967
+ }
8968
+ function createPostgresStorage(executor) {
8969
+ return new PostgresStorage(executor);
8970
+ }
8971
+ function isMissingLedgerError(error) {
8972
+ if (!(error instanceof Error))
8973
+ return false;
8974
+ const message = error.message.toLowerCase();
8975
+ return message.includes(POSTGRES_MIGRATION_LEDGER_TABLE.toLowerCase()) && (message.includes("does not exist") || message.includes("no such table") || message.includes("undefined_table"));
8976
+ }
8170
8977
  // src/lib/templates.ts
8171
8978
  import { execFileSync } from "child_process";
8172
8979
  import { existsSync as existsSync6 } from "fs";
@@ -8251,6 +9058,7 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
8251
9058
  { name: "taskTitle", description: "Human-readable task title." },
8252
9059
  projectPathVariable(),
8253
9060
  { name: "todosProjectPath", description: "Todos storage project path used in worker/verifier commands." },
9061
+ { name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
8254
9062
  ...routeScopeVariables(),
8255
9063
  ...workerVerifierAgentVariables({ addDirs: true, branchNoun: "task/event" })
8256
9064
  ]
@@ -8291,6 +9099,8 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
8291
9099
  variables: [
8292
9100
  { name: "taskId", required: true, description: "Todos task id." },
8293
9101
  projectPathVariable(),
9102
+ { name: "todosProjectPath", description: "Todos storage project path used in lifecycle commands." },
9103
+ { name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
8294
9104
  { name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
8295
9105
  roleAuthProfileVariable("triage"),
8296
9106
  roleAuthProfileVariable("planner"),
@@ -8421,28 +9231,29 @@ function verifierRuntimeGuidance(input) {
8421
9231
  ].join(`
8422
9232
  `);
8423
9233
  }
8424
- function todosInspectLine(todosProjectPath, taskId) {
8425
- return `- Inspect first: todos --project ${todosProjectPath} inspect ${taskId}`;
9234
+ function todosInspectLine(todosProjectPath, taskId, todosDbPath) {
9235
+ return `- Inspect first: ${todosCommand(todosProjectPath, todosDbPath)} inspect ${taskId}`;
8426
9236
  }
8427
- function todosStartLine(todosProjectPath, taskId) {
8428
- return `- Claim/start if appropriate: todos --project ${todosProjectPath} start ${taskId}`;
9237
+ function todosStartLine(todosProjectPath, taskId, todosDbPath) {
9238
+ return `- Claim/start if appropriate: ${todosCommand(todosProjectPath, todosDbPath)} start ${taskId}`;
8429
9239
  }
8430
- function todosEvidenceLine(todosProjectPath, taskId, placeholder) {
8431
- return `- Record evidence: todos --project ${todosProjectPath} comment ${taskId} "<${placeholder}>"`;
9240
+ function todosEvidenceLine(todosProjectPath, taskId, placeholder, todosDbPath) {
9241
+ return `- Record evidence: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<${placeholder}>"`;
8432
9242
  }
8433
- function todosVerificationLine(todosProjectPath, taskId) {
8434
- return `- Record verification: todos --project ${todosProjectPath} comment ${taskId} "<verification evidence or blocker>"`;
9243
+ function todosVerificationLine(todosProjectPath, taskId, todosDbPath) {
9244
+ return `- Record verification: ${todosCommand(todosProjectPath, todosDbPath)} comment ${taskId} "<verification evidence or blocker>"`;
8435
9245
  }
8436
- function todosDoneLine(todosProjectPath, taskId) {
8437
- return `- If valid and complete: todos --project ${todosProjectPath} done ${taskId}`;
9246
+ function todosDoneLine(todosProjectPath, taskId, todosDbPath) {
9247
+ return `- If valid and complete: ${todosCommand(todosProjectPath, todosDbPath)} done ${taskId}`;
8438
9248
  }
8439
- function todosExactCommandsFragment(todosProjectPath, taskId, commandLines) {
9249
+ function todosExactCommandsFragment(todosProjectPath, taskId, commandLines, todosDbPath) {
8440
9250
  return [
8441
9251
  `Todos project path: ${todosProjectPath}`,
9252
+ todosDbPath ? `Todos DB path: ${todosDbPath}` : undefined,
8442
9253
  "Use these exact todos commands so worktree cwd inference cannot attach to the wrong project:",
8443
- todosInspectLine(todosProjectPath, taskId),
9254
+ todosInspectLine(todosProjectPath, taskId, todosDbPath),
8444
9255
  ...commandLines
8445
- ];
9256
+ ].filter((line) => Boolean(line));
8446
9257
  }
8447
9258
  function worktreePrompt(plan) {
8448
9259
  if (plan.enabled) {
@@ -8479,10 +9290,19 @@ function worktreeContextFragment(plan) {
8479
9290
  function shellQuote2(value) {
8480
9291
  return `'${value.replace(/'/g, `'\\''`)}'`;
8481
9292
  }
8482
- function sourceTaskGateCommand(todosProjectPath, taskId) {
9293
+ function todosEnvPrefix(todosDbPath) {
9294
+ return todosDbPath ? `TODOS_DB_PATH=${shellQuote2(todosDbPath)} HASNA_TODOS_DB_PATH=${shellQuote2(todosDbPath)} ` : "";
9295
+ }
9296
+ function todosCommand(todosProjectPath, todosDbPath) {
9297
+ return `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath}`;
9298
+ }
9299
+ function shellTodosCommand(todosProjectPath, todosDbPath) {
9300
+ return `${todosEnvPrefix(todosDbPath)}todos --project ${shellQuote2(todosProjectPath)}`;
9301
+ }
9302
+ function sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath) {
8483
9303
  return [
8484
9304
  "set -euo pipefail",
8485
- `todos --project ${shellQuote2(todosProjectPath)} --json inspect ${shellQuote2(taskId)} >/dev/null`,
9305
+ `${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote2(taskId)} >/dev/null`,
8486
9306
  `printf "source task %s resolved in todos project %s\\n" ${shellQuote2(taskId)} ${shellQuote2(todosProjectPath)}`
8487
9307
  ].join(`
8488
9308
  `);
@@ -8542,10 +9362,10 @@ var LIFECYCLE_GATE_SCRIPT_TAIL = [
8542
9362
  "console.log(`task lifecycle ${stage} gate passed for ${task.id || task.taskId || 'task'} status=${status || 'unknown'}`);"
8543
9363
  ].join(`
8544
9364
  `);
8545
- function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker) {
9365
+ function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker, todosDbPath) {
8546
9366
  return [
8547
9367
  "set -euo pipefail",
8548
- `task_json="$(todos --project ${shellQuote2(todosProjectPath)} --json inspect ${shellQuote2(taskId)})"`,
9368
+ `task_json="$(${shellTodosCommand(todosProjectPath, todosDbPath)} --json inspect ${shellQuote2(taskId)})"`,
8549
9369
  `TASK_JSON="$task_json" STAGE=${shellQuote2(stage)} bun - <<'BUN'`,
8550
9370
  LIFECYCLE_GATE_SCRIPT_HEAD,
8551
9371
  `const goMarker = ${JSON.stringify(goMarker)};`,
@@ -8561,6 +9381,7 @@ var PR_HANDOFF_SCRIPT = [
8561
9381
  "const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
8562
9382
  "const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
8563
9383
  "const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
9384
+ "const todosDbPath = process.env.OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH || '';",
8564
9385
  "const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
8565
9386
  "const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
8566
9387
  "const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
@@ -8578,7 +9399,8 @@ var PR_HANDOFF_SCRIPT = [
8578
9399
  "};",
8579
9400
  "const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
8580
9401
  "const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
8581
- "const todos = (...args) => run(todosBin, todosArgs(...args));",
9402
+ "const todosEnv = todosDbPath ? { ...process.env, TODOS_DB_PATH: todosDbPath, HASNA_TODOS_DB_PATH: todosDbPath } : process.env;",
9403
+ "const todos = (...args) => run(todosBin, todosArgs(...args), { env: todosEnv });",
8582
9404
  "const comment = (text) => {",
8583
9405
  " const result = todos('comment', taskId, text);",
8584
9406
  " if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
@@ -8707,6 +9529,7 @@ function prHandoffCommand(opts) {
8707
9529
  `export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote2(opts.artifactPath)}`,
8708
9530
  `export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote2(opts.taskId)}`,
8709
9531
  `export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote2(opts.todosProjectPath)}`,
9532
+ opts.todosDbPath ? `export OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH=${shellQuote2(opts.todosDbPath)}` : undefined,
8710
9533
  `export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote2(opts.worktreeCwd)}`,
8711
9534
  `export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote2(opts.worktreeRoot)}`,
8712
9535
  `export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote2(opts.expectedBranch)}`,
@@ -8717,7 +9540,7 @@ function prHandoffCommand(opts) {
8717
9540
  "bun - <<'BUN'",
8718
9541
  PR_HANDOFF_SCRIPT,
8719
9542
  "BUN"
8720
- ].join(`
9543
+ ].filter((line) => Boolean(line)).join(`
8721
9544
  `);
8722
9545
  }
8723
9546
  // src/lib/templates-custom.ts
@@ -9307,12 +10130,12 @@ function commandStep(opts) {
9307
10130
  ...opts.blockedExitCodes ? { blockedExitCodes: opts.blockedExitCodes } : {}
9308
10131
  };
9309
10132
  }
9310
- function sourceTaskGateStep(todosProjectPath, taskId, plan, description) {
10133
+ function sourceTaskGateStep(todosProjectPath, taskId, plan, description, todosDbPath) {
9311
10134
  return commandStep({
9312
10135
  id: "source-task-gate",
9313
10136
  name: "Source Task Gate",
9314
10137
  description,
9315
- command: sourceTaskGateCommand(todosProjectPath, taskId),
10138
+ command: sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath),
9316
10139
  cwd: plan.originalCwd,
9317
10140
  timeoutMs: 60000,
9318
10141
  blockedExitCodes: GATE_BLOCKED_EXIT_CODES
@@ -9324,7 +10147,7 @@ function lifecycleGateStep(opts) {
9324
10147
  name: opts.stage === "triage" ? "Triage Gate" : "Planner Gate",
9325
10148
  description: opts.description,
9326
10149
  dependsOn: opts.dependsOn,
9327
- command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker),
10150
+ command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker, opts.todosDbPath),
9328
10151
  cwd: opts.plan.originalCwd,
9329
10152
  timeoutMs: 2 * 60000,
9330
10153
  blockedExitCodes: GATE_BLOCKED_EXIT_CODES
@@ -9333,7 +10156,7 @@ function lifecycleGateStep(opts) {
9333
10156
  function prHandoffArtifactPath(plan, taskId) {
9334
10157
  return join7(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
9335
10158
  }
9336
- function prHandoffStep(input, plan, todosProjectPath) {
10159
+ function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
9337
10160
  return commandStep({
9338
10161
  id: "pr-handoff",
9339
10162
  name: "PR Handoff",
@@ -9343,6 +10166,7 @@ function prHandoffStep(input, plan, todosProjectPath) {
9343
10166
  artifactPath: prHandoffArtifactPath(plan, input.taskId),
9344
10167
  taskId: input.taskId,
9345
10168
  todosProjectPath,
10169
+ todosDbPath,
9346
10170
  worktreeCwd: plan.cwd,
9347
10171
  worktreeRoot: plan.path ?? plan.cwd,
9348
10172
  expectedBranch: plan.branch ?? ""
@@ -9429,6 +10253,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9429
10253
  if (!input.projectPath?.trim())
9430
10254
  throw new Error("projectPath is required");
9431
10255
  const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
10256
+ const todosDbPath = input.todosDbPath;
9432
10257
  const plan = worktreePlan(input, input.taskId);
9433
10258
  const taskContext = {
9434
10259
  taskId: input.taskId,
@@ -9439,15 +10264,17 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9439
10264
  projectPath: input.projectPath,
9440
10265
  routeProjectPath: input.routeProjectPath,
9441
10266
  projectGroup: input.projectGroup,
10267
+ todosProjectPath: input.todosProjectPath ?? (input.todosDbPath ? todosProjectPath : undefined),
10268
+ todosDbPath,
9442
10269
  worktree: worktreeContextFragment(plan)
9443
10270
  };
9444
10271
  const workerPrompt = [
9445
10272
  ...goalHeaderFragment(`Complete todos task ${input.taskId} in ${input.projectPath}.`, "worker", "task"),
9446
10273
  worktreePrompt(plan),
9447
10274
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9448
- todosStartLine(todosProjectPath, input.taskId),
9449
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers")
9450
- ]),
10275
+ todosStartLine(todosProjectPath, input.taskId, todosDbPath),
10276
+ todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers", todosDbPath)
10277
+ ], todosDbPath),
9451
10278
  "Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
9452
10279
  "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.",
9453
10280
  NO_TMUX_DISPATCH_FRAGMENT,
@@ -9460,9 +10287,9 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9460
10287
  ...goalHeaderFragment(`Verify todos task ${input.taskId} after the worker step.`, "verifier", "task"),
9461
10288
  worktreePrompt(plan),
9462
10289
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9463
- todosVerificationLine(todosProjectPath, input.taskId),
9464
- todosDoneLine(todosProjectPath, input.taskId)
9465
- ]),
10290
+ todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
10291
+ todosDoneLine(todosProjectPath, input.taskId, todosDbPath)
10292
+ ], todosDbPath),
9466
10293
  adversarialReviewFragment("the task, repository state, commits, tests, and worker evidence", TASK_REVIEW_FOCUS),
9467
10294
  verifierRuntimeGuidance(input),
9468
10295
  TASK_VERIFIER_DECISION_FRAGMENT,
@@ -9477,7 +10304,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
9477
10304
  description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
9478
10305
  version: 1,
9479
10306
  steps: [
9480
- sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable."),
10307
+ sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable.", todosDbPath),
9481
10308
  ...workerVerifierSteps({
9482
10309
  input,
9483
10310
  seed: input.taskId,
@@ -9497,6 +10324,7 @@ function renderTaskLifecycleWorkflow(input) {
9497
10324
  if (!input.projectPath?.trim())
9498
10325
  throw new Error("projectPath is required");
9499
10326
  const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
10327
+ const todosDbPath = input.todosDbPath;
9500
10328
  const plan = worktreePlan(input, input.taskId);
9501
10329
  const taskContext = {
9502
10330
  taskId: input.taskId,
@@ -9508,6 +10336,7 @@ function renderTaskLifecycleWorkflow(input) {
9508
10336
  routeProjectPath: input.routeProjectPath,
9509
10337
  projectGroup: input.projectGroup,
9510
10338
  todosProjectPath,
10339
+ todosDbPath,
9511
10340
  worktree: worktreeContextFragment(plan)
9512
10341
  };
9513
10342
  const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
@@ -9521,8 +10350,8 @@ function renderTaskLifecycleWorkflow(input) {
9521
10350
  const shared = [
9522
10351
  worktreePrompt(plan),
9523
10352
  ...todosExactCommandsFragment(todosProjectPath, input.taskId, [
9524
- todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
9525
- ]),
10353
+ todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker", todosDbPath)
10354
+ ], todosDbPath),
9526
10355
  NO_TMUX_DISPATCH_FRAGMENT,
9527
10356
  "Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
9528
10357
  "",
@@ -9531,7 +10360,7 @@ function renderTaskLifecycleWorkflow(input) {
9531
10360
  ].join(`
9532
10361
  `);
9533
10362
  const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
9534
- const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
10363
+ const blockTaskCommand = `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
9535
10364
  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.`;
9536
10365
  const triagePrompt = [
9537
10366
  ...goalHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
@@ -9559,7 +10388,7 @@ function renderTaskLifecycleWorkflow(input) {
9559
10388
  const workerPrompt = [
9560
10389
  ...goalHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
9561
10390
  shared,
9562
- todosStartLine(todosProjectPath, input.taskId),
10391
+ todosStartLine(todosProjectPath, input.taskId, todosDbPath),
9563
10392
  "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.",
9564
10393
  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,
9565
10394
  WORKER_LEAVES_COMPLETION_FRAGMENT
@@ -9568,8 +10397,8 @@ function renderTaskLifecycleWorkflow(input) {
9568
10397
  const verifierPrompt = [
9569
10398
  ...goalHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
9570
10399
  shared,
9571
- todosVerificationLine(todosProjectPath, input.taskId),
9572
- todosDoneLine(todosProjectPath, input.taskId),
10400
+ todosVerificationLine(todosProjectPath, input.taskId, todosDbPath),
10401
+ todosDoneLine(todosProjectPath, input.taskId, todosDbPath),
9573
10402
  adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
9574
10403
  verifierRuntimeGuidance(input),
9575
10404
  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,
@@ -9578,7 +10407,7 @@ function renderTaskLifecycleWorkflow(input) {
9578
10407
  ].filter(Boolean).join(`
9579
10408
  `);
9580
10409
  const steps = [
9581
- sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable."),
10410
+ sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable.", todosDbPath),
9582
10411
  {
9583
10412
  id: "triage",
9584
10413
  name: "Triage",
@@ -9592,6 +10421,7 @@ function renderTaskLifecycleWorkflow(input) {
9592
10421
  description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
9593
10422
  dependsOn: ["triage"],
9594
10423
  todosProjectPath,
10424
+ todosDbPath,
9595
10425
  taskId: input.taskId,
9596
10426
  goMarker: gateMarker("triage", "go"),
9597
10427
  blockedMarker: gateMarker("triage", "blocked"),
@@ -9610,6 +10440,7 @@ function renderTaskLifecycleWorkflow(input) {
9610
10440
  description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
9611
10441
  dependsOn: ["planner"],
9612
10442
  todosProjectPath,
10443
+ todosDbPath,
9613
10444
  taskId: input.taskId,
9614
10445
  goMarker: gateMarker("planner", "go"),
9615
10446
  blockedMarker: gateMarker("planner", "blocked"),
@@ -9625,7 +10456,7 @@ function renderTaskLifecycleWorkflow(input) {
9625
10456
  }
9626
10457
  ];
9627
10458
  if (input.prHandoff) {
9628
- steps.push(prHandoffStep(input, plan, todosProjectPath));
10459
+ steps.push(prHandoffStep(input, plan, todosProjectPath, todosDbPath));
9629
10460
  }
9630
10461
  steps.push({
9631
10462
  id: "verifier",
@@ -9850,6 +10681,7 @@ function renderLifecycleBoundedTemplate(id, values) {
9850
10681
  taskTitle: values.taskTitle,
9851
10682
  taskDescription: values.taskDescription,
9852
10683
  todosProjectPath: values.todosProjectPath ?? values.todosProject,
10684
+ todosDbPath: values.todosDbPath,
9853
10685
  triageAuthProfile: values.triageAuthProfile,
9854
10686
  plannerAuthProfile: values.plannerAuthProfile,
9855
10687
  triageAccount: accountVar(values.triageAccount, values.accountTool),
@@ -9914,6 +10746,7 @@ function renderBuiltinLoopTemplate(id, values) {
9914
10746
  taskTitle: values.taskTitle,
9915
10747
  taskDescription: values.taskDescription,
9916
10748
  todosProjectPath: values.todosProjectPath ?? values.todosProject,
10749
+ todosDbPath: values.todosDbPath,
9917
10750
  eventId: values.eventId,
9918
10751
  eventType: values.eventType
9919
10752
  });
@@ -9955,6 +10788,7 @@ function renderLoopTemplate(id, values, opts = {}) {
9955
10788
  }
9956
10789
  // src/lib/hygiene.ts
9957
10790
  import { basename as basename4 } from "path";
10791
+ import { homedir as homedir4 } from "os";
9958
10792
  var PROVIDER_TOKENS = new Set([
9959
10793
  "codewith",
9960
10794
  "claude",
@@ -9969,11 +10803,14 @@ var PROVIDER_TOKENS = new Set([
9969
10803
  var REPO_GENERIC_TOKENS = new Set(["repo", "repoops"]);
9970
10804
  var CADENCE_SUFFIX_TOKENS = new Set(["hourly", "daily", "weekly", "monthly"]);
9971
10805
  var CADENCE_SUFFIX_PATTERN = /^(?:every-?)?\d+(?:s|m|h|d|w)$/;
10806
+ function userHome() {
10807
+ return process.env.HOME || homedir4();
10808
+ }
9972
10809
  function slugify(value) {
9973
10810
  return value.normalize("NFKD").replace(/[^\w\s.-]/g, "-").replace(/[_\s.:/]+/g, "-").replace(/[^a-zA-Z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
9974
10811
  }
9975
10812
  function repoSlugFromCwd(cwd) {
9976
- if (!cwd || cwd === process.env.HOME || cwd === "/home/hasna")
10813
+ if (!cwd || cwd === userHome())
9977
10814
  return "";
9978
10815
  if (cwd.includes("/.hasna/loops/"))
9979
10816
  return "";
@@ -10156,7 +10993,7 @@ function commandText(loop) {
10156
10993
  return [loop.target.command, ...loop.target.args ?? []].join(" ");
10157
10994
  }
10158
10995
  function scriptNeedles(scriptsDir) {
10159
- const home = process.env.HOME ?? "/home/hasna";
10996
+ const home = userHome();
10160
10997
  const normalized = scriptsDir.replace(/\/+$/g, "");
10161
10998
  const values = [
10162
10999
  normalized,
@@ -10174,7 +11011,7 @@ function scriptNeedles(scriptsDir) {
10174
11011
  return [...new Set(values)];
10175
11012
  }
10176
11013
  function buildScriptInventoryReport(store, opts = {}) {
10177
- const scriptsDir = opts.scriptsDir ?? `${process.env.HOME ?? "/home/hasna"}/.hasna/loops/scripts`;
11014
+ const scriptsDir = opts.scriptsDir ?? `${userHome()}/.hasna/loops/scripts`;
10178
11015
  const needles = scriptNeedles(scriptsDir);
10179
11016
  const loops2 = managedLoops(store, { includeInactive: opts.includeInactive, includeStopped: true, limit: opts.limit });
10180
11017
  const scriptBacked = loops2.map((loop) => {
@@ -10210,6 +11047,7 @@ export {
10210
11047
  runDoctor,
10211
11048
  rollupSummary,
10212
11049
  resolveLoopMachine,
11050
+ resolveLoopDeploymentMode,
10213
11051
  resolveGoalModel,
10214
11052
  renderTodosTaskWorkerVerifierWorkflow,
10215
11053
  renderLoopTemplate,
@@ -10222,8 +11060,10 @@ export {
10222
11060
  parseDuration,
10223
11061
  parseCron,
10224
11062
  openAutomationsRuntimeBinding,
11063
+ normalizeLoopDeploymentMode,
10225
11064
  nextCronRun,
10226
11065
  loops,
11066
+ loopControlPlaneConfig,
10227
11067
  listToolsForCli,
10228
11068
  listOpenMachines,
10229
11069
  listLoopTemplates,
@@ -10235,19 +11075,29 @@ export {
10235
11075
  executeTarget,
10236
11076
  executeLoopTarget,
10237
11077
  executeLoop,
11078
+ deploymentStatusLine,
11079
+ createSqliteLoopStorage,
11080
+ createPostgresStorage,
10238
11081
  createLoopsMcpServer,
10239
11082
  computeNextAfter,
10240
11083
  classifyRunFailure,
11084
+ checksumStorageSql,
10241
11085
  buildScriptInventoryReport,
10242
11086
  buildNameHygieneReport,
10243
11087
  buildHealthReport,
10244
11088
  buildDuplicateOverlapReport,
11089
+ buildDeploymentStatus,
10245
11090
  ValidationError,
10246
11091
  TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
10247
11092
  Store,
11093
+ SqliteLoopStorage,
11094
+ PostgresStorage,
11095
+ POSTGRES_STORAGE_MIGRATIONS,
11096
+ POSTGRES_MIGRATION_LEDGER_TABLE,
10248
11097
  LoopsClient,
10249
11098
  LoopNotFoundError,
10250
11099
  LoopArchivedError,
11100
+ LOOP_DEPLOYMENT_MODES,
10251
11101
  LOOPS_MCP_TOOLS,
10252
11102
  EVENT_WORKER_VERIFIER_TEMPLATE_ID,
10253
11103
  CodedError,