@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/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 =
|
|
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(),
|
|
@@ -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) {
|
|
@@ -7417,15 +7435,15 @@ function openAutomationsRuntimeBinding(overrides = {}) {
|
|
|
7417
7435
|
failCommand: "automations queue fail",
|
|
7418
7436
|
eventHandoff: {
|
|
7419
7437
|
envelopeCommand: "automations webhooks event",
|
|
7420
|
-
handlerCommand: "loops
|
|
7421
|
-
pipeExample: "automations --json webhooks event <route> --body-json '<json>' | loops --json
|
|
7438
|
+
handlerCommand: "loops routes create generic",
|
|
7439
|
+
pipeExample: "automations --json webhooks event <route> --body-json '<json>' | loops --json routes create generic",
|
|
7422
7440
|
boundary: "Use only for explicit event-envelope workflow handoff. OpenAutomations still owns deterministic automation materialization and queue state; OpenLoops owns workflow invocation."
|
|
7423
7441
|
},
|
|
7424
7442
|
requiredEnvironment: ["HASNA_AUTOMATIONS_DIR"],
|
|
7425
7443
|
guarantees: [
|
|
7426
7444
|
"OpenAutomations owns automation specs, run materialization, queue state, DLQ, replay, idempotency, and approvals.",
|
|
7427
7445
|
"OpenLoops may execute claimed actions through explicit command or SDK handoff only.",
|
|
7428
|
-
"OpenLoops may consume exported event envelopes only through explicit
|
|
7446
|
+
"OpenLoops may consume exported event envelopes only through explicit routes create commands.",
|
|
7429
7447
|
"Workers must complete or fail actions by action id and runner id so OpenAutomations can enforce queue leases."
|
|
7430
7448
|
],
|
|
7431
7449
|
nonGoals: [
|
package/dist/types.d.ts
CHANGED
|
@@ -55,7 +55,7 @@ export interface OpenAutomationsRuntimeBinding {
|
|
|
55
55
|
failCommand: "automations queue fail";
|
|
56
56
|
eventHandoff: {
|
|
57
57
|
envelopeCommand: "automations webhooks event";
|
|
58
|
-
handlerCommand: "loops
|
|
58
|
+
handlerCommand: "loops routes create generic";
|
|
59
59
|
pipeExample: string;
|
|
60
60
|
boundary: string;
|
|
61
61
|
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# OpenLoops Deployment Modes
|
|
2
|
+
|
|
3
|
+
OpenLoops supports one active source of truth at a time. The public package
|
|
4
|
+
defines the mode vocabulary, local cache behavior, API shape, and runner
|
|
5
|
+
contract. Hosted multi-tenant operation is implemented outside this public
|
|
6
|
+
package.
|
|
7
|
+
|
|
8
|
+
## Modes
|
|
9
|
+
|
|
10
|
+
| Mode | Source of truth | Local storage role | Executor |
|
|
11
|
+
| --- | --- | --- | --- |
|
|
12
|
+
| `local` | SQLite in `LOOPS_DATA_DIR` | Authoritative | `loops-daemon` |
|
|
13
|
+
| `self_hosted` | A user-operated `loops-api` control plane contract | Cache and offline spool | `loops-runner` foundation |
|
|
14
|
+
| `cloud` | A configured hosted control plane contract | Cache and offline spool | `loops-runner` foundation |
|
|
15
|
+
|
|
16
|
+
`local` remains the default. It must keep working without network access,
|
|
17
|
+
tokens, Postgres, or hosted infrastructure.
|
|
18
|
+
|
|
19
|
+
`self_hosted` is for users or teams running their own control plane. The public
|
|
20
|
+
`@hasna/loops` package owns the API and runner contract for this mode.
|
|
21
|
+
|
|
22
|
+
`cloud` is the hosted control-plane contract. The public package exposes the
|
|
23
|
+
client and runner contract, but tenant auth, account administration, and hosted
|
|
24
|
+
infrastructure stay outside this package. The public package must not depend on
|
|
25
|
+
private hosted packages or resource names. This release exposes status
|
|
26
|
+
surfaces only; non-local claim/lease execution is follow-up work.
|
|
27
|
+
|
|
28
|
+
## Mode Resolution
|
|
29
|
+
|
|
30
|
+
`LOOPS_MODE` or `HASNA_LOOPS_MODE` may be set to `local`, `self_hosted`, or
|
|
31
|
+
`cloud`. Hyphenated `self-hosted` is normalized to `self_hosted`.
|
|
32
|
+
|
|
33
|
+
When no explicit mode is set, OpenLoops resolves the mode from configuration:
|
|
34
|
+
|
|
35
|
+
1. `LOOPS_CLOUD_API_URL` or `HASNA_LOOPS_CLOUD_API_URL` selects `cloud`.
|
|
36
|
+
2. `LOOPS_API_URL`, `HASNA_LOOPS_API_URL`, `LOOPS_DATABASE_URL`, or
|
|
37
|
+
`HASNA_LOOPS_DATABASE_URL` selects `self_hosted`.
|
|
38
|
+
3. Otherwise OpenLoops uses `local`.
|
|
39
|
+
|
|
40
|
+
`LOOPS_API_URL` and `HASNA_LOOPS_API_URL` belong to `self_hosted`.
|
|
41
|
+
`cloud` uses only `LOOPS_CLOUD_API_URL` or `HASNA_LOOPS_CLOUD_API_URL`.
|
|
42
|
+
|
|
43
|
+
Tokens are represented only as presence signals in status output. Self-hosted
|
|
44
|
+
status uses `LOOPS_API_TOKEN` or `HASNA_LOOPS_API_TOKEN`. Cloud status uses
|
|
45
|
+
`LOOPS_CLOUD_TOKEN` or `HASNA_LOOPS_CLOUD_TOKEN`. URL credentials, query
|
|
46
|
+
strings, and fragments are not returned in status output.
|
|
47
|
+
|
|
48
|
+
## Commands
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
loops mode
|
|
52
|
+
loops --json mode
|
|
53
|
+
loops self-hosted status
|
|
54
|
+
loops cloud status
|
|
55
|
+
loops-api status
|
|
56
|
+
loops-runner status
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Human status output is intentionally compact:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
deploymentMode=local active source=default truth=local_sqlite local=authoritative control_plane=none
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
JSON uses these field names:
|
|
66
|
+
|
|
67
|
+
- `deploymentMode`: the requested status perspective.
|
|
68
|
+
- `activeDeploymentMode`: the mode selected from the current environment.
|
|
69
|
+
- `deploymentModeSource`: the env var or default that selected the active mode.
|
|
70
|
+
- `sourceOfTruth`: `local_sqlite`, `self_hosted_control_plane`, or
|
|
71
|
+
`cloud_control_plane`.
|
|
72
|
+
- `localStore.role`: `authoritative` in local mode, `cache_and_spool` in
|
|
73
|
+
non-local modes.
|
|
74
|
+
- `controlPlane.configured`: true only when the current mode has enough
|
|
75
|
+
configuration to be usable. Cloud requires both a cloud URL and a cloud token
|
|
76
|
+
presence signal.
|
|
77
|
+
- `controlPlane.apiUrl`: a display-safe URL without credentials, query string,
|
|
78
|
+
or fragment.
|
|
79
|
+
|
|
80
|
+
`loops-api` is a separate process in the same public package. It is not a
|
|
81
|
+
separate package at this stage because self-hosted users and the hosted service
|
|
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.
|
|
87
|
+
|
|
88
|
+
`loops-runner` is the process that connects a machine to a non-local control
|
|
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.
|
|
95
|
+
|
|
96
|
+
## Machine Placement
|
|
97
|
+
|
|
98
|
+
A loop can eventually target:
|
|
99
|
+
|
|
100
|
+
- one specific machine,
|
|
101
|
+
- a machine pool selected by capability labels,
|
|
102
|
+
- or multiple machines intentionally.
|
|
103
|
+
|
|
104
|
+
Single-run loops need a lease so only one runner executes each scheduled slot.
|
|
105
|
+
Multi-machine loops must record per-machine run evidence so duplicate work is
|
|
106
|
+
distinguishable from intentional fan-out.
|
|
107
|
+
|
|
108
|
+
This is separate from existing local OpenMachines dispatch. In `local` mode,
|
|
109
|
+
`loops-daemon` can still dispatch a loop target to a configured remote machine
|
|
110
|
+
through the existing OpenMachines transport. In `self_hosted` or `cloud`,
|
|
111
|
+
machine execution is runner-pull: a registered `loops-runner` claims work from
|
|
112
|
+
the control plane. Operators should not treat local remote dispatch as cloud
|
|
113
|
+
mode.
|
|
114
|
+
|
|
115
|
+
## Follow-Up Work
|
|
116
|
+
|
|
117
|
+
Non-local execution needs these follow-up releases before it is live:
|
|
118
|
+
|
|
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.
|
|
123
|
+
- Import/export migration between local SQLite state and a control plane.
|
|
124
|
+
- Hosted product integration outside the public package.
|
|
125
|
+
|
|
126
|
+
## Public Package Boundary
|
|
127
|
+
|
|
128
|
+
The public package owns:
|
|
129
|
+
|
|
130
|
+
- local SQLite scheduling and daemon execution,
|
|
131
|
+
- the mode resolver and status surfaces,
|
|
132
|
+
- the self-hosted API contract,
|
|
133
|
+
- the runner contract,
|
|
134
|
+
- local cache/spool semantics,
|
|
135
|
+
- import/export and migration commands,
|
|
136
|
+
- SDK, MCP, and CLI primitives.
|
|
137
|
+
|
|
138
|
+
Hosted product code owns:
|
|
139
|
+
|
|
140
|
+
- tenant account management,
|
|
141
|
+
- hosted authentication and invitations,
|
|
142
|
+
- hosted observability and admin operations,
|
|
143
|
+
- cloud infrastructure deployment,
|
|
144
|
+
- product UI and customer lifecycle flows.
|
|
145
|
+
|
|
146
|
+
The public package must document cloud behavior as a contract unless a hosted
|
|
147
|
+
URL and token are explicitly configured. It must not claim that cloud service is
|
|
148
|
+
live by default.
|
package/docs/USAGE.md
CHANGED
|
@@ -11,8 +11,36 @@ It supports deterministic command loops, JSON-defined workflows, and guarded CLI
|
|
|
11
11
|
- `opencode run`
|
|
12
12
|
- `codex exec`
|
|
13
13
|
|
|
14
|
+
## Deployment Modes
|
|
15
|
+
|
|
16
|
+
OpenLoops defaults to `local`, where SQLite in `LOOPS_DATA_DIR` is
|
|
17
|
+
authoritative and `loops-daemon` executes scheduled work. The package also
|
|
18
|
+
defines `self_hosted` and `cloud` contracts for future non-local control
|
|
19
|
+
planes:
|
|
20
|
+
|
|
21
|
+
- `self_hosted`: user-operated `loops-api` control-plane contract; this
|
|
22
|
+
release exposes API/runner status foundations only.
|
|
23
|
+
- `cloud`: hosted control-plane contract; this release exposes client/runner
|
|
24
|
+
status only, and requires `LOOPS_CLOUD_API_URL` plus `LOOPS_CLOUD_TOKEN` or
|
|
25
|
+
`HASNA_LOOPS_CLOUD_TOKEN` before status can report ready.
|
|
26
|
+
|
|
27
|
+
Useful status commands:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
loops mode
|
|
31
|
+
loops --json mode
|
|
32
|
+
loops self-hosted status
|
|
33
|
+
loops cloud status
|
|
34
|
+
loops-api status
|
|
35
|
+
loops-runner status
|
|
36
|
+
```
|
|
37
|
+
|
|
14
38
|
## Install
|
|
15
39
|
|
|
40
|
+
**OpenLoops requires the [Bun](https://bun.sh) runtime (`bun >= 1.0`).** The
|
|
41
|
+
installed binaries are `loops`, `loops-daemon`, `loops-api`, `loops-runner`, and
|
|
42
|
+
`loops-mcp`; each uses a `#!/usr/bin/env bun` shebang.
|
|
43
|
+
|
|
16
44
|
From npm:
|
|
17
45
|
|
|
18
46
|
```bash
|
|
@@ -57,8 +85,9 @@ The package also exports the server factory for embedded hosts:
|
|
|
57
85
|
import { createLoopsMcpServer } from "@hasna/loops/mcp";
|
|
58
86
|
```
|
|
59
87
|
|
|
60
|
-
Available read tools include `loops_list`, `loops_show`, `
|
|
61
|
-
`loops_doctor`, `
|
|
88
|
+
Available read tools include `loops_list`, `loops_show`, `loops_runs`,
|
|
89
|
+
`loops_doctor`, `loops_workflows_list`, `loops_workflow_read`, and
|
|
90
|
+
`loops_workflow_validate`.
|
|
62
91
|
Resources are available at `loops://runtime` and `loops://tools`.
|
|
63
92
|
Those tools use the same `Store`, public redaction helpers, and workflow parser
|
|
64
93
|
as the CLI and SDK, so read output and validation behavior stay aligned across
|
|
@@ -66,15 +95,19 @@ surfaces.
|
|
|
66
95
|
|
|
67
96
|
Mutation tools are disabled by default. Start the server with
|
|
68
97
|
`LOOPS_MCP_ALLOW_MUTATIONS=true` only for a trusted local MCP host that should be
|
|
69
|
-
allowed to change loop state.
|
|
70
|
-
|
|
71
|
-
`
|
|
72
|
-
|
|
98
|
+
allowed to change loop state. The guarded mutation tools use canonical names:
|
|
99
|
+
`loops_pause`, `loops_resume`, `loops_stop`, `loops_run_now`, `loops_archive`,
|
|
100
|
+
`loops_unarchive`, `loops_create_command`, and `loops_create_workflow`.
|
|
101
|
+
Deprecated `loop_*` aliases are still registered where compatibility needs them,
|
|
102
|
+
but callers should use the `loops_*` names. Mutation tools do not require or
|
|
103
|
+
accept confirmation-string parameters; the server-side environment opt-in is the
|
|
104
|
+
gate. MCP `loops_run_now` schedules the loop for immediate daemon pickup; inline
|
|
105
|
+
execution remains CLI-only.
|
|
73
106
|
|
|
74
107
|
Keep host-affecting or long-running operations on the CLI: daemon
|
|
75
|
-
start/stop/install/logs, inline `run-now`, `tick`, loop
|
|
76
|
-
|
|
77
|
-
|
|
108
|
+
start/stop/install/logs, inline `run-now`, `tick`, loop deletion, workflow
|
|
109
|
+
create/migrate/cancel/recover, agent loop creation, template materialization,
|
|
110
|
+
and event-route drains.
|
|
78
111
|
|
|
79
112
|
## Create Loops
|
|
80
113
|
|
|
@@ -95,8 +128,8 @@ loops create command repo-status \
|
|
|
95
128
|
```
|
|
96
129
|
|
|
97
130
|
`--preflight` is available on `loops create command`, `loops create agent`,
|
|
98
|
-
`loops create workflow`, `loops workflows create`, and
|
|
99
|
-
|
|
131
|
+
`loops create workflow`, `loops workflows create`, and route commands such as
|
|
132
|
+
`loops routes create todos-task` and `loops routes create generic`. It checks
|
|
100
133
|
target executables and configured account profiles before loop or workflow rows
|
|
101
134
|
are stored, so a missing command, provider binary, OpenAccounts profile, native
|
|
102
135
|
Codewith auth profile, or workflow step dependency fails without creating a
|
|
@@ -340,11 +373,11 @@ loops templates render todos-task-worker-verifier \
|
|
|
340
373
|
--var taskTitle="Fix parser" \
|
|
341
374
|
--var projectPath=/path/to/repo \
|
|
342
375
|
--var provider=codewith \
|
|
343
|
-
--var authProfilePool=
|
|
376
|
+
--var authProfilePool=account001,account002,account003 \
|
|
344
377
|
--var sandbox=workspace-write \
|
|
345
378
|
--var todosProjectPath=$HOME/.hasna/loops \
|
|
346
379
|
--var addDirs=$HOME/.hasna/todos,$HOME/.hasna/loops
|
|
347
|
-
loops
|
|
380
|
+
loops workflows create --template todos-task-worker-verifier \
|
|
348
381
|
--var taskId=<task-id> \
|
|
349
382
|
--var projectPath=/path/to/repo
|
|
350
383
|
loops templates render event-worker-verifier \
|
|
@@ -357,7 +390,7 @@ loops templates render bounded-agent-worker-verifier \
|
|
|
357
390
|
--var objective="Check docs drift and queue tasks for gaps" \
|
|
358
391
|
--var projectPath=/path/to/repo \
|
|
359
392
|
--var provider=codewith \
|
|
360
|
-
--var authProfilePool=
|
|
393
|
+
--var authProfilePool=account001,account002 \
|
|
361
394
|
--var sandbox=workspace-write
|
|
362
395
|
loops templates render pr-review \
|
|
363
396
|
--var prUrl=https://github.com/hasna/loops/pull/123 \
|
|
@@ -441,14 +474,15 @@ loops templates show custom-report
|
|
|
441
474
|
loops templates render custom-report \
|
|
442
475
|
--var objective="Check docs drift" \
|
|
443
476
|
--var projectPath=/path/to/repo
|
|
444
|
-
loops
|
|
477
|
+
loops workflows create --template custom-report \
|
|
445
478
|
--var objective="Check docs drift" \
|
|
446
479
|
--var projectPath=/path/to/repo
|
|
447
480
|
```
|
|
448
481
|
|
|
449
482
|
Use `--source builtin`, `--source custom`, or `--source all` on
|
|
450
|
-
`list`, `show`, `render`, and
|
|
451
|
-
|
|
483
|
+
`templates list`, `templates show`, `templates render`, and
|
|
484
|
+
`workflows create --template` when automation needs an explicit source. Custom
|
|
485
|
+
template ids and names cannot override built-ins.
|
|
452
486
|
Custom templates fail closed for `danger-full-access`, dangerous passthrough
|
|
453
487
|
arguments, and implicit Codewith/Codex full-access defaults. If a custom
|
|
454
488
|
Codewith/Codex template uses `permissionMode: "bypass"`, it must also set
|
|
@@ -491,16 +525,16 @@ PR review and merge route workers may fetch, rebase, or merge base branches
|
|
|
491
525
|
inside the isolated worktree when the task requires it, but they must not
|
|
492
526
|
mutate the primary main checkout.
|
|
493
527
|
|
|
494
|
-
For event-driven task automation, `loops
|
|
528
|
+
For event-driven task automation, `loops routes create todos-task` reads a
|
|
495
529
|
Hasna event envelope from stdin or `HASNA_EVENT_JSON`, records a
|
|
496
530
|
`WorkflowInvocation`, upserts an admission work item, and admits that work item
|
|
497
531
|
into a deduped one-shot workflow loop when route capacity allows:
|
|
498
532
|
|
|
499
533
|
```bash
|
|
500
|
-
cat task-created-event.json | loops
|
|
534
|
+
cat task-created-event.json | loops routes create todos-task \
|
|
501
535
|
--template task-lifecycle \
|
|
502
536
|
--provider codewith \
|
|
503
|
-
--auth-profile-pool
|
|
537
|
+
--auth-profile-pool account001,account002,account003 \
|
|
504
538
|
--permission-mode bypass \
|
|
505
539
|
--sandbox workspace-write \
|
|
506
540
|
--todos-project "$HOME/.hasna/loops" \
|
|
@@ -530,7 +564,7 @@ selected.
|
|
|
530
564
|
loops routes drain todos-task \
|
|
531
565
|
--dry-run \
|
|
532
566
|
--provider-rule area=frontend:claude:claude-ui-a,claude-ui-b \
|
|
533
|
-
--provider-rule area=backend:codewith:
|
|
567
|
+
--provider-rule area=backend:codewith:account001,account002 \
|
|
534
568
|
--worktree-mode required
|
|
535
569
|
```
|
|
536
570
|
|
|
@@ -568,9 +602,9 @@ Use route throttles to avoid stampeding agents when a producer creates many
|
|
|
568
602
|
tasks at once:
|
|
569
603
|
|
|
570
604
|
```bash
|
|
571
|
-
cat task-created-event.json | loops
|
|
605
|
+
cat task-created-event.json | loops routes create todos-task \
|
|
572
606
|
--provider codewith \
|
|
573
|
-
--auth-profile-pool
|
|
607
|
+
--auth-profile-pool account001,account002,account003 \
|
|
574
608
|
--project-group oss \
|
|
575
609
|
--max-active-per-project 1 \
|
|
576
610
|
--max-active-per-project-group 4 \
|
|
@@ -633,14 +667,14 @@ hand. It scans `todos ready --json`, so tasks with incomplete dependencies,
|
|
|
633
667
|
locks, or non-pending states stay queued in todos and are not routed:
|
|
634
668
|
|
|
635
669
|
```bash
|
|
636
|
-
loops
|
|
670
|
+
loops routes drain todos-task \
|
|
637
671
|
--todos-project "$HOME/.hasna/loops" \
|
|
638
672
|
--template task-lifecycle \
|
|
639
673
|
--task-list repoops-pr-queue \
|
|
640
674
|
--tags auto:route \
|
|
641
|
-
--project-path-prefix /
|
|
675
|
+
--project-path-prefix "$HOME/workspace/example/opensource" \
|
|
642
676
|
--provider codewith \
|
|
643
|
-
--auth-profile-pool
|
|
677
|
+
--auth-profile-pool account001,account002,account003 \
|
|
644
678
|
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
|
|
645
679
|
--project-group oss \
|
|
646
680
|
--max-dispatch 2 \
|
|
@@ -665,17 +699,17 @@ when requested, and leaves excess ready tasks in todos for a later drain pass.
|
|
|
665
699
|
Use `--dry-run` to preview candidates and rendered workflows without mutating
|
|
666
700
|
OpenLoops state.
|
|
667
701
|
|
|
668
|
-
For
|
|
702
|
+
For an OSS task-created route, keep the drain deterministic and narrow:
|
|
669
703
|
|
|
670
704
|
```bash
|
|
671
705
|
loops routes schedule todos-task oss-task-route-drain \
|
|
672
706
|
--every 5m \
|
|
673
707
|
--todos-project "$HOME/.hasna/loops" \
|
|
674
708
|
--template task-lifecycle \
|
|
675
|
-
--project-path-prefix /
|
|
709
|
+
--project-path-prefix "$HOME/workspace/example/opensource" \
|
|
676
710
|
--tags auto:route \
|
|
677
711
|
--provider codewith \
|
|
678
|
-
--auth-profile-pool
|
|
712
|
+
--auth-profile-pool account001,account002,account003 \
|
|
679
713
|
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
|
|
680
714
|
--project-group oss \
|
|
681
715
|
--max-dispatch 2 \
|
|
@@ -688,7 +722,7 @@ loops routes schedule todos-task oss-task-route-drain \
|
|
|
688
722
|
--compact
|
|
689
723
|
```
|
|
690
724
|
|
|
691
|
-
Only tasks under
|
|
725
|
+
Only tasks under `$HOME/workspace/example/opensource` that explicitly opt
|
|
692
726
|
in with the `auto:route` tag, `route_enabled=true`, or
|
|
693
727
|
`automation.allowed=true` should be routed. Keep repo-mutating worker/verifier
|
|
694
728
|
runs on a Codewith account pool with `--worktree-mode required`. Do not dispatch
|
|
@@ -709,9 +743,9 @@ For other Hasna apps that expose `@hasna/events` webhooks, use the generic
|
|
|
709
743
|
handler:
|
|
710
744
|
|
|
711
745
|
```bash
|
|
712
|
-
cat event.json | loops
|
|
746
|
+
cat event.json | loops routes create generic \
|
|
713
747
|
--provider codewith \
|
|
714
|
-
--auth-profile-pool
|
|
748
|
+
--auth-profile-pool account001,account002,account003 \
|
|
715
749
|
--permission-mode bypass \
|
|
716
750
|
--sandbox workspace-write \
|
|
717
751
|
--project-path /path/to/repo \
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/loops",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"loops": "dist/cli/index.js",
|
|
10
10
|
"loops-daemon": "dist/daemon/index.js",
|
|
11
|
+
"loops-api": "dist/api/index.js",
|
|
12
|
+
"loops-runner": "dist/runner/index.js",
|
|
11
13
|
"loops-mcp": "dist/mcp/index.js"
|
|
12
14
|
},
|
|
13
15
|
"exports": {
|
|
@@ -23,9 +25,37 @@
|
|
|
23
25
|
"types": "./dist/mcp/index.d.ts",
|
|
24
26
|
"import": "./dist/mcp/index.js"
|
|
25
27
|
},
|
|
28
|
+
"./api": {
|
|
29
|
+
"types": "./dist/api/index.d.ts",
|
|
30
|
+
"import": "./dist/api/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./runner": {
|
|
33
|
+
"types": "./dist/runner/index.d.ts",
|
|
34
|
+
"import": "./dist/runner/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./mode": {
|
|
37
|
+
"types": "./dist/lib/mode.d.ts",
|
|
38
|
+
"import": "./dist/lib/mode.js"
|
|
39
|
+
},
|
|
26
40
|
"./storage": {
|
|
27
41
|
"types": "./dist/lib/store.d.ts",
|
|
28
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"
|
|
29
59
|
}
|
|
30
60
|
},
|
|
31
61
|
"files": [
|
|
@@ -36,14 +66,15 @@
|
|
|
36
66
|
"LICENSE"
|
|
37
67
|
],
|
|
38
68
|
"scripts": {
|
|
39
|
-
"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",
|
|
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",
|
|
40
70
|
"build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
|
|
41
71
|
"typecheck": "tsc --noEmit",
|
|
42
72
|
"test": "bun test",
|
|
73
|
+
"test:boundary": "bun run scripts/no-private-cloud-boundary.mjs",
|
|
43
74
|
"prepare": "test -d dist || bun run build",
|
|
44
75
|
"dev:cli": "bun run src/cli/index.ts",
|
|
45
76
|
"dev:daemon": "bun run src/daemon/index.ts",
|
|
46
|
-
"prepublishOnly": "bun run build"
|
|
77
|
+
"prepublishOnly": "bun run build && bun run test:boundary"
|
|
47
78
|
},
|
|
48
79
|
"keywords": [
|
|
49
80
|
"loops",
|