@hasna/loops 0.4.1 → 0.4.3

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/sdk/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"]);
@@ -1523,7 +1523,7 @@ class Store {
1523
1523
  }
1524
1524
  const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
1525
1525
  for (const migration of this.migrations()) {
1526
- if (!migration.baseline && applied.has(migration.id))
1526
+ if (!migration.baseline && !migration.reapply && applied.has(migration.id))
1527
1527
  continue;
1528
1528
  migration.apply();
1529
1529
  if (!applied.has(migration.id)) {
@@ -1573,6 +1573,14 @@ 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
+ reapply: true,
1580
+ apply: () => {
1581
+ this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
1582
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
1583
+ }
1576
1584
  }
1577
1585
  ];
1578
1586
  }
@@ -1614,6 +1622,7 @@ class Store {
1614
1622
  started_at TEXT,
1615
1623
  finished_at TEXT,
1616
1624
  claimed_by TEXT,
1625
+ claim_token TEXT,
1617
1626
  lease_expires_at TEXT,
1618
1627
  pid INTEGER,
1619
1628
  pgid INTEGER,
@@ -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(),
@@ -5544,11 +5561,12 @@ function publicLoop(loop) {
5544
5561
  target
5545
5562
  };
5546
5563
  }
5547
- function publicRun(run, showOutput = false) {
5564
+ function publicRun(run, showOutput = false, opts = {}) {
5548
5565
  return {
5549
5566
  ...run,
5550
5567
  stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
5551
- stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
5568
+ stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
5569
+ error: opts.redactError ? redact(run.error) : run.error
5552
5570
  };
5553
5571
  }
5554
5572
  function publicExecutorResult(result, showOutput = false) {
@@ -79,12 +79,19 @@ JSON uses these field names:
79
79
 
80
80
  `loops-api` is a separate process in the same public package. It is not a
81
81
  separate package at this stage because self-hosted users and the hosted service
82
- must share the same public contract.
82
+ must share the same public contract. The API server can expose storage-backed
83
+ `/v1` loop CRUD and run listing when an embedding host injects the public
84
+ storage contract. The standalone `loops-api serve` binary still fails closed
85
+ for those routes until a self-hosted storage adapter is wired by the operator or
86
+ platform host.
83
87
 
84
88
  `loops-runner` is the process that connects a machine to a non-local control
85
- plane. The initial foundation exposes status only. The Postgres schema, claim
86
- protocol, runner heartbeat/finalization protocol, and import/export migration
87
- must land before `loops-runner` is advertised as an executing worker.
89
+ plane. The current public package supports a bounded one-shot protocol:
90
+ registration, claim polling, claim-token fenced heartbeat/finalization, and
91
+ `loops-runner run-once` execution for non-workflow targets. Full fleet daemon
92
+ mode, workflow target execution over the remote protocol, and import/export
93
+ migration still need follow-up releases before `loops-runner` is advertised as
94
+ a complete always-on worker.
88
95
 
89
96
  ## Machine Placement
90
97
 
@@ -109,9 +116,10 @@ mode.
109
116
 
110
117
  Non-local execution needs these follow-up releases before it is live:
111
118
 
112
- - Postgres/control-plane persistence for loops, schedules, runners, claims, and
113
- evidence.
114
- - Runner placement, capability labels, leases, heartbeat, and finalization.
119
+ - A full Postgres control-plane adapter behind the public storage contract.
120
+ - Long-running runner daemon mode with backoff, fleet observability, and
121
+ durable machine registration records.
122
+ - Workflow target execution over the remote protocol.
115
123
  - Import/export migration between local SQLite state and a control plane.
116
124
  - Hosted product integration outside the public package.
117
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/loops",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -40,6 +40,22 @@
40
40
  "./storage": {
41
41
  "types": "./dist/lib/store.d.ts",
42
42
  "import": "./dist/lib/store.js"
43
+ },
44
+ "./storage/contract": {
45
+ "types": "./dist/lib/storage/contract.d.ts",
46
+ "import": "./dist/lib/storage/contract.js"
47
+ },
48
+ "./storage/sqlite": {
49
+ "types": "./dist/lib/storage/sqlite.d.ts",
50
+ "import": "./dist/lib/storage/sqlite.js"
51
+ },
52
+ "./storage/postgres": {
53
+ "types": "./dist/lib/storage/postgres.d.ts",
54
+ "import": "./dist/lib/storage/postgres.js"
55
+ },
56
+ "./storage/postgres-schema": {
57
+ "types": "./dist/lib/storage/postgres-schema.d.ts",
58
+ "import": "./dist/lib/storage/postgres-schema.js"
43
59
  }
44
60
  },
45
61
  "files": [
@@ -50,7 +66,7 @@
50
66
  "LICENSE"
51
67
  ],
52
68
  "scripts": {
53
- "build": "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
69
+ "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",
54
70
  "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
55
71
  "typecheck": "tsc --noEmit",
56
72
  "test": "bun test",