@hasna/loops 0.4.14 → 0.4.22

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +119 -29
  3. package/dist/api/index.d.ts +1 -0
  4. package/dist/api/index.js +825 -7
  5. package/dist/cli/index.js +5419 -2619
  6. package/dist/daemon/index.js +478 -84
  7. package/dist/generated/storage-kit/index.d.ts +1 -1
  8. package/dist/index.js +2521 -226
  9. package/dist/lib/cloud/mode.d.ts +17 -0
  10. package/dist/lib/cloud/resolve.d.ts +16 -0
  11. package/dist/lib/cloud/storage.d.ts +82 -0
  12. package/dist/lib/cloud/transport.d.ts +148 -0
  13. package/dist/lib/format.d.ts +2 -1
  14. package/dist/lib/migration.d.ts +40 -0
  15. package/dist/lib/mode.js +3 -3
  16. package/dist/lib/route/index.d.ts +2 -0
  17. package/dist/lib/route/policies.d.ts +51 -0
  18. package/dist/lib/route/provider-admission.d.ts +37 -0
  19. package/dist/lib/route/throttle.d.ts +4 -0
  20. package/dist/lib/route/types.d.ts +8 -0
  21. package/dist/lib/run-receipts.d.ts +14 -0
  22. package/dist/lib/storage/contract.d.ts +7 -1
  23. package/dist/lib/storage/index.js +710 -31
  24. package/dist/lib/storage/postgres-loop-storage.d.ts +6 -0
  25. package/dist/lib/storage/postgres-schema.js +29 -0
  26. package/dist/lib/storage/postgres.js +29 -0
  27. package/dist/lib/storage/sqlite.d.ts +6 -0
  28. package/dist/lib/storage/sqlite.js +412 -18
  29. package/dist/lib/store/index.d.ts +268 -0
  30. package/dist/lib/store.d.ts +48 -1
  31. package/dist/lib/store.js +396 -18
  32. package/dist/lib/template-kit.d.ts +25 -0
  33. package/dist/lib/templates.d.ts +16 -1
  34. package/dist/mcp/http.d.ts +16 -0
  35. package/dist/mcp/index.js +1658 -168
  36. package/dist/runner/index.js +76 -9
  37. package/dist/sdk/http.d.ts +67 -0
  38. package/dist/sdk/http.js +21 -0
  39. package/dist/sdk/index.d.ts +50 -17
  40. package/dist/sdk/index.js +1509 -132
  41. package/dist/serve/index.js +1598 -122
  42. package/dist/types.d.ts +49 -0
  43. package/docs/AUTOMATION_RUNTIME_DESIGN.md +442 -1
  44. package/docs/CUTOVER-RUNBOOK.md +73 -56
  45. package/docs/DEPLOYMENT_MODES.md +35 -18
  46. package/docs/RUNTIME_BOUNDARY.md +203 -0
  47. package/docs/USAGE.md +145 -27
  48. package/package.json +3 -3
package/dist/types.d.ts CHANGED
@@ -212,6 +212,8 @@ export interface WorkflowWorkItem {
212
212
  subjectRef: string;
213
213
  projectKey?: string;
214
214
  projectGroup?: string;
215
+ /** Machine that reserved/admitted this route work item, when known. */
216
+ machineId?: string;
215
217
  /**
216
218
  * The drain/route identity (loop name) that admitted this item. Used to scope
217
219
  * the `--max-active` global admission count to a single route instead of the
@@ -239,6 +241,7 @@ export interface UpsertWorkflowWorkItemInput {
239
241
  subjectRef: string;
240
242
  projectKey?: string;
241
243
  projectGroup?: string;
244
+ machineId?: string;
242
245
  routeScope?: string;
243
246
  priority?: number;
244
247
  status?: Extract<WorkflowWorkItemStatus, "queued" | "deferred">;
@@ -394,6 +397,52 @@ export interface LoopRun {
394
397
  createdAt: string;
395
398
  updatedAt: string;
396
399
  }
400
+ export type RunReceiptMachine = string | Record<string, unknown>;
401
+ export interface RunReceiptSummary {
402
+ text?: string;
403
+ stdout_bytes: number;
404
+ stderr_bytes: number;
405
+ stdout_excerpt?: string;
406
+ stderr_excerpt?: string;
407
+ error?: string;
408
+ duration_ms?: number;
409
+ }
410
+ export interface RunReceipt {
411
+ loop_id: string;
412
+ run_id: string;
413
+ machine: RunReceiptMachine;
414
+ repo: string;
415
+ task_ids: string[];
416
+ knowledge_ids: string[];
417
+ digest_id: string;
418
+ started_at: string | null;
419
+ finished_at: string | null;
420
+ status: string;
421
+ exit_code: number | null;
422
+ summary: RunReceiptSummary;
423
+ evidence_paths: string[];
424
+ created_at: string;
425
+ updated_at: string;
426
+ }
427
+ export interface WriteRunReceiptInput {
428
+ loop_id?: string;
429
+ run_id: string;
430
+ machine?: RunReceiptMachine;
431
+ repo?: string;
432
+ task_ids?: string[];
433
+ knowledge_ids?: string[];
434
+ digest_id?: string;
435
+ started_at?: string | null;
436
+ finished_at?: string | null;
437
+ status?: string;
438
+ exit_code?: number | null;
439
+ summary?: string | Partial<RunReceiptSummary> | null;
440
+ evidence_paths?: string[];
441
+ stdout?: string;
442
+ stderr?: string;
443
+ error?: string;
444
+ duration_ms?: number;
445
+ }
397
446
  export interface CreateLoopInput {
398
447
  name: string;
399
448
  description?: string;
@@ -7,6 +7,9 @@ materialization, queue state, approvals, DLQ/replay, idempotency, and audit
7
7
  evidence. OpenLoops owns workflow invocation, admission, execution, run
8
8
  manifests, and provider routing once work is explicitly handed off.
9
9
 
10
+ For operator-facing ownership tables, handoff path examples, and anti-patterns,
11
+ see [Runtime Boundary](./RUNTIME_BOUNDARY.md).
12
+
10
13
  ## Planned Workflow Upsert SDK
11
14
 
12
15
  External compilers should not write OpenLoops SQLite rows directly. The stable
@@ -52,7 +55,8 @@ type WorkflowUpsertResult = {
52
55
  manifestPath?: string;
53
56
  };
54
57
  action: "created" | "updated" | "reused" | "rejected";
55
- preflight?: { ok: boolean; checks: unknown[]; error?: string };
58
+ preflight?: AutomationPreflightResult;
59
+ evidence?: AutomationEvidenceSummary;
56
60
  };
57
61
  ```
58
62
 
@@ -73,6 +77,222 @@ Required semantics:
73
77
  for the caller to inspect, cancel, replay, or resolve the run without querying
74
78
  SQLite directly.
75
79
 
80
+ ## Planned Actions Target Binding
81
+
82
+ `@hasna/actions` now exposes typed action contracts: `ActionManifest`,
83
+ `ActionInvocation`, `ActionRun`, `ActionRunStatus`, `ActionQueueStatus`,
84
+ `ActionAuditEvent`, `EvidenceRef`, and `ActionDeadLetter`. A future OpenLoops
85
+ action binding must reuse those contracts instead of adding an OpenLoops-owned
86
+ action status, action queue, or idempotency dialect.
87
+
88
+ The binding should be a workflow-admission descriptor, not a direct executor in
89
+ the first implementation:
90
+
91
+ ```ts
92
+ type ActionsRuntimeBinding = {
93
+ integration: "hasna-actions";
94
+ role: "workflow-runtime";
95
+ targetType: "action";
96
+ actionOwner: "@hasna/actions";
97
+ runtimeOwner: "@hasna/loops";
98
+ handoff: "workflow-upsert";
99
+ statusModel: "action-owned";
100
+ };
101
+
102
+ type ActionTargetBindingRequest = {
103
+ type: "action";
104
+ action: {
105
+ id: string;
106
+ version: string;
107
+ invocationId?: string;
108
+ runId?: string;
109
+ idempotencyKey?: string;
110
+ dedupeKey?: string;
111
+ };
112
+ subject?: WorkflowUpsertRequest["subject"];
113
+ workflow: WorkflowUpsertRequest["workflow"];
114
+ route?: WorkflowUpsertRequest["route"];
115
+ execution?: WorkflowUpsertRequest["execution"];
116
+ mode?: WorkflowUpsertRequest["mode"];
117
+ dispatch?: WorkflowUpsertRequest["dispatch"];
118
+ };
119
+ ```
120
+
121
+ The planned implementation path is:
122
+
123
+ 1. The action owner validates the manifest, input, actor, approvals, dry-run
124
+ support, idempotency requirement, guardrails, audit fields, evidence fields,
125
+ and rollback policy using `@hasna/actions`.
126
+ 2. The action owner passes only a normalized `ActionTargetBindingRequest` or
127
+ `WorkflowUpsertRequest` to OpenLoops. Large inputs, private prompts, secret
128
+ values, and mutable action state stay in the action store or a referenced
129
+ artifact.
130
+ 3. OpenLoops admits a one-shot workflow loop through the planned upsert
131
+ contract, records workflow/run/manifests refs, and reports those refs back to
132
+ the action owner.
133
+ 4. The action owner records `ActionAuditEvent` and `EvidenceRef` entries that
134
+ include the returned OpenLoops refs. OpenLoops may store source refs for
135
+ lookup, but `@hasna/actions` remains the source of truth for action runs and
136
+ queue state.
137
+
138
+ ### Request And Result Mapping
139
+
140
+ `ActionManifest.id` and `ActionManifest.version` map to
141
+ `WorkflowUpsertRequest.source.id` and the canonical workflow hash input:
142
+
143
+ ```ts
144
+ const request: WorkflowUpsertRequest = {
145
+ idempotencyKey:
146
+ invocation.idempotencyKey ??
147
+ `actions:${manifest.id}:${manifest.version}:${invocation.id}`,
148
+ source: {
149
+ kind: "action",
150
+ id: manifest.id,
151
+ dedupeKey: invocation.idempotencyKey ?? invocation.id,
152
+ },
153
+ subject: subjectFromAction(manifest, invocation),
154
+ workflow: renderedWorkflow,
155
+ loop: { name: `action-${manifest.id}`, schedule: { type: "once", at } },
156
+ route,
157
+ execution,
158
+ mode,
159
+ dispatch,
160
+ };
161
+ ```
162
+
163
+ `specHash` must be computed from the canonical OpenLoops workflow request plus
164
+ the stable action contract identity: action id, manifest version, idempotency
165
+ key or invocation id, selected executor binding kind/ref, route policy, and
166
+ execution policy. It must not include raw secret values, raw credentials, or
167
+ unredacted prompt bodies. If `@hasna/actions` later publishes its own manifest
168
+ hash, OpenLoops should include that hash instead of re-hashing action internals.
169
+
170
+ `WorkflowUpsertResult` keeps its existing semantics:
171
+
172
+ - `idempotencyKey` is the action-owned idempotency key selected above.
173
+ - `specHash` identifies the exact OpenLoops workflow admission request and
174
+ action contract identity that produced it.
175
+ - `refs.workflowId`, `refs.loopId`, `refs.invocationId`, `refs.workItemId`,
176
+ `refs.runId`, and `refs.manifestPath` are OpenLoops refs that the action
177
+ owner can store as `EvidenceRef` or audit event data.
178
+ - `action="created" | "updated" | "reused" | "rejected"` describes OpenLoops
179
+ materialization only. It is not an action run status.
180
+ - Action run fields such as `status`, `dedupedFromRunId`, `evidence`, `events`,
181
+ and `error` remain on `ActionRun`.
182
+
183
+ ### Status Translation
184
+
185
+ OpenLoops should translate its workflow and work-item state into action-owned
186
+ status updates without persisting a new action status enum.
187
+
188
+ Recommended initial translation:
189
+
190
+ - An accepted dry-run/preflight maps to an `ActionRun` that remains
191
+ `planned`, `previewed`, or `awaiting_approval` according to
192
+ `@hasna/actions`; OpenLoops only returns preflight details.
193
+ - A scheduled workflow maps to action queue status `queued` or
194
+ `waiting_approval`, depending on action-owned approval gates.
195
+ - A claimed/running workflow maps to action queue status `claimed` and action
196
+ run status `executing`.
197
+ - A successful workflow maps to action queue status `succeeded` and action run
198
+ status `succeeded`.
199
+ - A terminal workflow failure maps to action queue status `failed` or `dead`
200
+ and action run status `failed`; the action owner decides whether the
201
+ `ActionDeadLetter.replayable` flag is true.
202
+ - Operator cancellation maps to action queue status `cancelled` and action run
203
+ status `cancelled`.
204
+
205
+ These mappings are adapter behavior. OpenLoops should expose its own workflow
206
+ statuses for OpenLoops APIs and should call or return enough refs for
207
+ `@hasna/actions` to update `ActionRun`, `ActionQueueStatus`, audit events, and
208
+ dead-letter records.
209
+
210
+ ### Failure And Replay
211
+
212
+ Failure handling must flow through action-owned APIs:
213
+
214
+ - Provider, worktree, account, policy, and command failures are classified by
215
+ OpenLoops and returned as redacted workflow error evidence.
216
+ - The action owner turns those failures into `ActionError`,
217
+ `ActionDeadLetter`, `ActionAuditEvent`, and `EvidenceRef` records.
218
+ - Replay starts from an action-owned replay decision and a new action-owned
219
+ idempotency key. OpenLoops then receives a new upsert request whose
220
+ `source.dedupeKey` and `idempotencyKey` are the replay keys.
221
+ - OpenLoops DLQ commands may mirror or inspect linked workflow failures, but
222
+ they must not fabricate action queue entries or mark an action replayable on
223
+ their own.
224
+
225
+ ### Mode And Dispatch Examples
226
+
227
+ Dry-run without OpenLoops mutation:
228
+
229
+ ```bash
230
+ actions run deploy.manifest.json \
231
+ --input-file deploy-input.json \
232
+ --idempotency-key deploy:preview:42 \
233
+ --dry-run \
234
+ --json
235
+
236
+ loops workflows upsert-one-shot deploy-workflow.json \
237
+ --idempotency-key deploy:preview:42 \
238
+ --source-kind action \
239
+ --source-id deploy.service \
240
+ --mode dry-run \
241
+ --dispatch none \
242
+ --json
243
+ ```
244
+
245
+ Preflight before scheduling:
246
+
247
+ ```bash
248
+ loops workflows upsert-one-shot deploy-workflow.json \
249
+ --idempotency-key deploy:release:42 \
250
+ --source-kind action \
251
+ --source-id deploy.service \
252
+ --mode preflight \
253
+ --dispatch schedule \
254
+ --json
255
+ ```
256
+
257
+ Commit and run immediately after the action owner approves:
258
+
259
+ ```bash
260
+ actions approve <action-run-id> --reason "preview accepted" --json
261
+
262
+ loops workflows upsert-one-shot deploy-workflow.json \
263
+ --idempotency-key deploy:release:42 \
264
+ --source-kind action \
265
+ --source-id deploy.service \
266
+ --mode commit \
267
+ --dispatch run-now \
268
+ --json
269
+ ```
270
+
271
+ Commit without dispatch, for another owner to trigger later:
272
+
273
+ ```bash
274
+ loops workflows upsert-one-shot deploy-workflow.json \
275
+ --idempotency-key deploy:release:42 \
276
+ --source-kind action \
277
+ --source-id deploy.service \
278
+ --mode commit \
279
+ --dispatch none \
280
+ --json
281
+ ```
282
+
283
+ ### Non-Goals
284
+
285
+ - Do not let `@hasna/actions` write OpenLoops SQLite/Postgres rows directly.
286
+ - Do not duplicate the action queue in OpenLoops.
287
+ - Do not materialize triggers in OpenLoops; action and automation owners decide
288
+ when a concrete invocation exists.
289
+ - Do not store secret values, unredacted prompts, credentials, or private
290
+ action input payloads in workflow specs, prompts, manifests, task comments,
291
+ or run output.
292
+ - Do not add `target.type = "action"` as an executable target until the action
293
+ package has a stable runtime handoff API that can complete, fail, audit, and
294
+ replay action runs by action-owned refs.
295
+
76
296
  ## CLI Surface
77
297
 
78
298
  The planned CLI should mirror the SDK:
@@ -96,6 +316,227 @@ loops workflows upsert-one-shot ./workflow.json \
96
316
  would fail. `--mode commit` must return the durable refs required for later
97
317
  inspection and replay.
98
318
 
319
+ ## Planned Automation Preflight API
320
+
321
+ Automation-generated workflows need an aggregate preflight result that is richer
322
+ than the current `PreflightResult` command/account tuple, but still compact
323
+ enough to return from CLI, SDK, MCP, route drains, and run manifests. The API is
324
+ a reporting contract first; implementation should reuse existing validators in
325
+ `workflow-spec.ts`, provider capability metadata in `agent-adapter.ts`, account
326
+ resolution in `accounts.ts`, machine/worktree checks in `executor.ts`, and
327
+ redaction helpers in `redact.ts`.
328
+
329
+ Proposed SDK shape:
330
+
331
+ ```ts
332
+ type AutomationPreflightMode = "dry-run" | "preflight" | "commit";
333
+
334
+ type AutomationPreflightDomain =
335
+ | "spec"
336
+ | "provider"
337
+ | "account"
338
+ | "command"
339
+ | "secret"
340
+ | "connector"
341
+ | "permissions"
342
+ | "sandbox"
343
+ | "worktree"
344
+ | "machine"
345
+ | "allowlist"
346
+ | "redaction"
347
+ | "evidence";
348
+
349
+ type AutomationPreflightStatus = "pass" | "warn" | "fail" | "skipped";
350
+ type AutomationPreflightSeverity = "info" | "warning" | "error" | "fatal";
351
+
352
+ type AutomationPreflightClassification =
353
+ | "validation"
354
+ | "provider"
355
+ | "account"
356
+ | "auth"
357
+ | "command"
358
+ | "secret"
359
+ | "connector"
360
+ | "policy"
361
+ | "permission"
362
+ | "sandbox"
363
+ | "worktree"
364
+ | "machine"
365
+ | "redaction"
366
+ | "unknown";
367
+
368
+ type AutomationPreflightCheck = {
369
+ id: string;
370
+ domain: AutomationPreflightDomain;
371
+ status: AutomationPreflightStatus;
372
+ severity: AutomationPreflightSeverity;
373
+ classification: AutomationPreflightClassification;
374
+ stepId?: string;
375
+ targetType?: "command" | "agent" | "workflow";
376
+ provider?: AgentProvider;
377
+ account?: { profile: string; tool?: string };
378
+ command?: string;
379
+ subject?: { kind: string; ref?: string };
380
+ summary: string;
381
+ remediation?: string;
382
+ evidenceRefs?: Array<{ kind: "manifest" | "run" | "workflow" | "loop" | "workItem" | "external"; ref: string }>;
383
+ redacted: true;
384
+ };
385
+
386
+ type AutomationPreflightResult = {
387
+ ok: boolean;
388
+ mode: AutomationPreflightMode;
389
+ executionMode: "standard" | "strict";
390
+ idempotencyKey: string;
391
+ specHash: string;
392
+ policyHash?: string;
393
+ generatedAt: string;
394
+ checks: AutomationPreflightCheck[];
395
+ summary: {
396
+ pass: number;
397
+ warn: number;
398
+ fail: number;
399
+ skipped: number;
400
+ highestSeverity: AutomationPreflightSeverity;
401
+ firstFailure?: Pick<AutomationPreflightCheck, "id" | "domain" | "classification" | "summary" | "remediation">;
402
+ };
403
+ };
404
+ ```
405
+
406
+ Required check domains:
407
+
408
+ - `spec`: parse workflow JSON, canonicalize prompt file references, validate
409
+ command/action binding, enforce no embedded secrets in target env/prompt
410
+ metadata, and return the stable `specHash`.
411
+ - `provider`: verify provider name, executable binding, provider capability
412
+ support, prompt delivery mode, and bounded subprocess preflight for binaries
413
+ only.
414
+ - `account`: resolve Codewith `authProfile` or OpenAccounts `{profile, tool}`
415
+ refs, record profile/tool names only, and fail when the profile is missing or
416
+ the profile directory cannot be resolved.
417
+ - `command`: validate command targets with `shell=false` executable rules,
418
+ `preflightAnyOf`, and planned allow-command enforcement before execution.
419
+ - `secret`: check required secret refs by ref/name and owner response only;
420
+ never place secret values, exported env, or raw secret-manager payloads in the
421
+ result.
422
+ - `connector`: ask the owning action/connector system whether the connector
423
+ credential handle exists, is scoped for the source action, and is usable; do
424
+ not make OpenLoops the connector credential store.
425
+ - `permissions`: reject unsafe `permissionMode="bypass"`, unsafe
426
+ `configIsolation="none"`, mutable command targets without an explicit policy,
427
+ or missing approval evidence when strict automation requires it.
428
+ - `sandbox`: verify provider-native sandbox support and reject strict mode when
429
+ requested sandboxing cannot be proven.
430
+ - `worktree`: require `worktree.mode="required"` for repo mutation in strict
431
+ mode and verify worktree metadata contains repo root, path, branch, original
432
+ cwd, and target cwd without preparing the worktree during pure preflight.
433
+ - `machine`: check requested machine route, local/remote confidence, and remote
434
+ bootstrap availability; fail a strict request when the route does not match
435
+ the workflow scope.
436
+ - `allowlist`: in standard mode, preserve current `metadata_only` behavior as a
437
+ warning; in strict mode, fail unless command/tool allowlist enforcement can be
438
+ proven for every step that requests it.
439
+ - `redaction`: prove the result was built through the redaction policy that will
440
+ be used for persistence and manifests.
441
+ - `evidence`: include durable refs when available and enough context for the
442
+ caller to inspect the follow-on run without querying SQLite directly.
443
+
444
+ Preflight must be side-effect bounded:
445
+
446
+ - `dry-run` performs schema validation, canonicalization, policy normalization,
447
+ and hashing only. It must not create a database file in an empty data dir and
448
+ must not spawn account/provider probes.
449
+ - `preflight` may run bounded local or remote readiness probes such as
450
+ `command -v`, `accounts env <profile> --tool <tool>`, provider profile-list
451
+ checks, and connector readiness calls owned by the automation/action system.
452
+ It must not run target commands, dispatch agents, create workflows or loops,
453
+ write task comments, prepare worktrees, or mutate queue state.
454
+ - `commit` is idempotent. In strict mode it must run or reuse a fresh compatible
455
+ preflight result before storing or dispatching. In standard mode it may attach
456
+ warnings without changing current execution defaults.
457
+
458
+ Strict-mode failures are fail-closed for missing account profiles, missing
459
+ connector auth, missing required secret refs, unsupported sandbox posture,
460
+ unsupported provider allowlist enforcement, bypass permissions, non-required
461
+ worktrees for repo mutation, missing prompt files, and machine route mismatch.
462
+
463
+ ## Planned Compact Evidence API
464
+
465
+ Every public surface should expose the same compact evidence shape so callers
466
+ can hand results across `@hasna/actions`, `@hasna/automations`, OpenLoops route
467
+ drains, MCP tools, and CLI scripts without scraping SQLite or leaking secrets.
468
+
469
+ ```ts
470
+ type AutomationEvidenceRef = {
471
+ workflowId?: string;
472
+ loopId?: string;
473
+ invocationId?: string;
474
+ workItemId?: string;
475
+ runId?: string;
476
+ manifestPath?: string;
477
+ external?: Array<{ system: "actions" | "automations" | "connector" | string; ref: string }>;
478
+ };
479
+
480
+ type AutomationEvidenceSummary = {
481
+ version: 1;
482
+ kind: "workflow-upsert" | "preflight" | "route-admission" | "workflow-run";
483
+ generatedAt: string;
484
+ source: { kind: string; id: string; dedupeKey?: string };
485
+ subject: { kind: string; id?: string; path?: string; url?: string };
486
+ refs: AutomationEvidenceRef;
487
+ specHash: string;
488
+ policyHash?: string;
489
+ executionMode: "standard" | "strict";
490
+ dispatch: "schedule" | "run-now" | "none";
491
+ status: "accepted" | "reused" | "rejected" | "running" | "succeeded" | "failed" | "dead_letter";
492
+ preflight?: {
493
+ ok: boolean;
494
+ counts: AutomationPreflightResult["summary"];
495
+ failedCheckIds: string[];
496
+ warningCheckIds: string[];
497
+ };
498
+ redactionProfile: "default" | "strict";
499
+ redacted: true;
500
+ };
501
+ ```
502
+
503
+ Persistence and output rules:
504
+
505
+ - CLI `--json`, SDK results, MCP tool results, route dry-run output, route work
506
+ item evidence, and workflow run manifests all return `AutomationEvidenceSummary`
507
+ plus optional full `AutomationPreflightResult` when the caller explicitly asks
508
+ for preflight detail.
509
+ - Evidence contains names, refs, hashes, status, classifications, counts, and
510
+ remediation hints only. It never contains secret values, full env maps, raw
511
+ connector/provider payloads, private prompt text, stdout/stderr dumps, or
512
+ unsanitized errors.
513
+ - `manifestPath` is the filesystem ref for durable evidence, but the compact
514
+ summary must be enough for a normal caller to find the workflow, loop, run,
515
+ work item, and owning external action/automation refs through public APIs.
516
+ - Failed preflight evidence should be persistable when a commit attempt is
517
+ rejected, but pure `dry-run`/`preflight` calls should return it directly
518
+ without creating OpenLoops database rows.
519
+ - Redaction happens before persistence and before API return. Tests should
520
+ exercise both flat secret-looking text and nested JSON evidence values.
521
+
522
+ Implementation should expose the contract incrementally:
523
+
524
+ 1. Add shared types and a pure result builder that converts validator outcomes
525
+ into `AutomationPreflightCheck` entries.
526
+ 2. Wrap existing `preflightTarget`/`preflightWorkflow` with aggregate result
527
+ builders while keeping the current throwing APIs for compatibility.
528
+ 3. Add `loops workflows upsert-one-shot --mode dry-run|preflight|commit --json`
529
+ and SDK equivalents that return `WorkflowUpsertResult`.
530
+ 4. Thread compact evidence through route dry-runs, admitted work items, workflow
531
+ run manifests, MCP workflow validation, and the HTTP API.
532
+ 5. Add strict-mode enforcement only after provider allowlist and secret/connector
533
+ readiness adapters can prove enforcement without leaking payloads.
534
+
535
+ Expected validation for that future implementation: focused unit tests for
536
+ workflow parsing, executor preflight, route dry-run output, run manifests,
537
+ redaction, SDK/CLI/MCP/API JSON shape, then `bun run typecheck`, `bun test`,
538
+ `bun run build`, and `bun run test:boundary`.
539
+
99
540
  ## Planned Strict Automation Execution
100
541
 
101
542
  Automation-generated targets need a stricter execution mode than ordinary local
@@ -1,61 +1,78 @@
1
1
  # Loops Postgres Cutover Runbook
2
2
 
3
- Status: **backend foundation landed; daemon still runs on local sqlite.** Do NOT
4
- flip the running daemon to Postgres until every step below is green.
5
-
6
- PURE REMOTE (Amendment A1): in cloud mode, reads AND writes hit cloud Postgres
7
- directly. There is no hybrid/sync/cache mode. After a verified migration the
8
- local sqlite file is renamed to `<name>.db.pre-cloud-2026-07-06.bak`.
9
-
10
- ## What shipped in this PR
11
-
12
- - Vendored `@hasna/contracts` storage kit → `src/generated/storage-kit/`
13
- (pool/query/tls/mode/migrations/health). Regenerate: `bunx @hasna/contracts vendor-kit`.
14
- - `src/lib/storage/pg-executor.ts` — `PgPoolExecutor` adapting the vendored
15
- `pg.Pool` client to `PostgresQueryExecutor` (drives `PostgresStorage.migrate`).
16
- - `src/lib/storage/pg-runner-claim.ts` `claimNextRun` / `heartbeatRunLease`
17
- using `SELECT ... FOR UPDATE SKIP LOCKED` for concurrent runners.
18
- - `src/lib/storage/postgres-concurrency.test.ts` two-connection race test
19
- (skips unless `LOOPS_TEST_DATABASE_URL` is set; verified green against a
20
- throwaway local Postgres 16).
21
- - `pg` promoted to a direct dependency; `@types/pg` added as devDependency.
22
-
23
- ## Remaining before daemon cutover (tracked, NOT done here)
24
-
25
- 1. **Full `PostgresLoopStorage` implementing `LoopStorageContract`** (60+ methods
26
- in `src/lib/storage/contract.ts`). Only the migration ledger + claim/lease
27
- primitives exist today. The claim primitive here is the correctness core to
28
- build the rest on.
29
- 2. **Async consumer wiring.** All call sites construct `new Store()` synchronously
30
- (`src/cli`, `src/daemon`, `src/sdk`, `src/mcp`, `src/lib/route`). PURE REMOTE
31
- requires routing them through the async `LoopStorageContract` when
32
- `LOOPS_DATABASE_URL` is set (mode resolved by `src/lib/mode.ts`, which stays
33
- authoritative). `SqliteLoopStorage` already wraps the sync store for the
34
- local path.
35
- 3. **`loops migrate-local` command** (data migration TOOLING only): copy
36
- definitions + schedules + last 30 days of runs from local sqlite into cloud
37
- Postgres, then rename the sqlite file to the `.pre-cloud-2026-07-06.bak`
38
- backup. Does NOT enable cloud mode.
39
- 4. **Apply schema to shared RDS** via SSM tunnel on local port 15438 using the
40
- `hasna/oss/loops/database-url-owner` secret, then run
41
- `PostgresStorage(new PgPoolExecutor(...)).migrate()` (dry-run first). Kill the
42
- tunnel and remove temp files afterward. Never run the test suite against RDS.
43
-
44
- ## Enable steps (run only after 1–4 are green)
45
-
46
- 1. Confirm `PostgresStorage.migrate({ dryRun: true })` reports all four
47
- migrations already applied on the loops RDS database.
48
- 2. Stop the loops daemon: `loops daemon stop`.
49
- 3. `loops migrate-local --database-url "$LOOPS_DATABASE_URL"` (moves defs +
50
- schedules + 30d of runs; verify counts; sqlite renamed to backup).
51
- 4. Export `LOOPS_DATABASE_URL` (from `hasna/oss/loops/database-url`) and the
52
- cloud storage-mode env for the daemon unit; restart the daemon.
53
- 5. Verify `loops status` reports `deploymentMode: cloud`, `sourceOfTruth:
54
- cloud_control_plane`, and that a test loop run claims + finalizes against
55
- Postgres.
3
+ Status: **self-hosted control-plane backend landed; local daemon cutover is not
4
+ complete.** Do not flip scheduled production execution away from local SQLite
5
+ until the runner and migration follow-ups below are green.
6
+
7
+ Deployment vocabulary: `self_hosted` is the Hasna-owned AWS/RDS control-plane
8
+ deployment. `cloud` is the future hosted SaaS contract for outside users. The
9
+ vendored `@hasna/contracts` storage kit still calls direct Postgres storage
10
+ mode `cloud`; treat that as a storage-kit implementation term, not the
11
+ OpenLoops deployment mode.
12
+
13
+ ## What Shipped
14
+
15
+ - `loops-serve` HTTP control plane: RDS-direct Postgres, `GET /health`,
16
+ `/ready`, `/version`, `/openapi.json`, storage-backed `/v1` loop CRUD and run
17
+ listing, and runner registration/claim/heartbeat/finalize protocol routes.
18
+ - `@hasna/contracts` API-key auth on non-local `loops-serve` binds, backed by
19
+ the shared `api_keys` table and a signing secret.
20
+ - Full `PostgresLoopStorage` behind `LoopStorageContract`, plus
21
+ `PgPoolExecutor`, runner claim/lease helpers, and Postgres concurrency tests.
22
+ - `loops-serve migrate`, which applies the ledger-tracked Postgres migrations
23
+ and ensures the `api_keys` table.
24
+ - `@hasna/loops/sdk/http`, generated from `openapi/loops.json`.
25
+ - ARM64/Bun `Dockerfile`, local `docker-compose.yml`, `hasna.contract.json`,
26
+ and a generated `migrations/` mirror for review.
27
+
28
+ ## Local Postgres Smoke For loops-serve
29
+
30
+ Run the local development stack for the self-hosted service:
31
+
32
+ ```bash
33
+ docker compose run --rm loops-migrate
34
+ docker compose up --build loops-serve
35
+ curl -fsS http://127.0.0.1:8787/health
36
+ curl -fsS http://127.0.0.1:8787/ready
37
+ curl -fsS http://127.0.0.1:8787/openapi.json
38
+ ```
39
+
40
+ `loops-serve` may bind to loopback without an API signing secret for local
41
+ development. A non-local bind must set `HASNA_LOOPS_API_SIGNING_KEY`,
42
+ `HASNA_API_SIGNING_KEY`, or `API_KEY_SIGNING_SECRET`; otherwise the service
43
+ fails closed before exposing `/v1`.
44
+
45
+ ## AWS Self-Hosted Gates
46
+
47
+ 1. Apply migrations with the ECS one-shot task or an operator command:
48
+ `HASNA_LOOPS_DATABASE_URL=... loops-serve migrate --dry-run`, then
49
+ `HASNA_LOOPS_DATABASE_URL=... loops-serve migrate`.
50
+ 2. Start `loops-serve` with `HASNA_LOOPS_MODE=self_hosted`,
51
+ `HASNA_LOOPS_DATABASE_URL`, and the API signing secret from the approved
52
+ vault item. Do not log or copy the secret value into task evidence.
53
+ 3. Verify `/health`, `/ready`, `/version`, and `/openapi.json`.
54
+ 4. Verify an authenticated `/v1` read/write smoke against a throwaway loop and
55
+ a runner registration/claim/finalize smoke if a runner API URL is configured.
56
+ 5. Record package version, git SHA, image tag, database migration plan/result,
57
+ redacted API URL, health/readiness responses, and rollback handle.
58
+
59
+ ## Still Pending Before Daemon Cutover
60
+
61
+ - Long-running `loops-runner` daemon mode with backoff, fleet observability, and
62
+ durable machine registration records.
63
+ - Workflow target execution over the runner protocol.
64
+ - Id-preserving self-hosted import endpoints for workflow specs, loop
65
+ definitions, run history, workflow history, work items, goals, and audit rows.
66
+ - A no-loss migration path from local SQLite into the self-hosted control plane.
67
+ Current `loops export`/`loops import` are local-store tools, and
68
+ `loops self-hosted migrate|push|pull` remain previews.
69
+ - Hosted SaaS integration outside this public package.
56
70
 
57
71
  ## Rollback
58
72
 
59
- Cloud mode is off until `LOOPS_DATABASE_URL` is present in the daemon env.
60
- To roll back: unset it, restore the sqlite backup
61
- (`mv <name>.db.pre-cloud-2026-07-06.bak <name>.db`), restart the daemon.
73
+ For the self-hosted service, roll back by moving traffic to the previous image
74
+ or stopping the new `loops-serve` task. Local scheduled execution remains on
75
+ SQLite unless operators explicitly configure a runner/control-plane cutover, so
76
+ removing `LOOPS_API_URL`, `HASNA_LOOPS_API_URL`, `LOOPS_DATABASE_URL`, or
77
+ `HASNA_LOOPS_DATABASE_URL` returns the standalone CLI/daemon perspective to
78
+ `local`.