@hasna/loops 0.3.56 → 0.3.58

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
@@ -459,6 +459,9 @@ function validateTarget(value, label, opts) {
459
459
  }
460
460
  if (value.model !== undefined)
461
461
  assertString(value.model, `${label}.model`);
462
+ if (value.provider === "opencode" && (typeof value.model !== "string" || value.model.trim() === "")) {
463
+ throw new Error(`${label}.model is required for provider opencode; pass a provider/model id such as openrouter/google/gemini-2.5-flash`);
464
+ }
462
465
  if (value.variant !== undefined)
463
466
  assertString(value.variant, `${label}.variant`);
464
467
  if (value.agent !== undefined)
@@ -1255,12 +1258,21 @@ class Store {
1255
1258
  if (!row)
1256
1259
  throw new Error("daemon lease lost");
1257
1260
  }
1261
+ assertNoNestedWorkflowGoal(target, goal) {
1262
+ if (!goal || target.type !== "workflow")
1263
+ return;
1264
+ const workflow = this.getWorkflow(target.workflowId);
1265
+ if (workflow?.goal) {
1266
+ throw new Error(`workflow loop cannot define a loop-level goal when workflow ${workflow.name} already has a top-level goal; remove one goal wrapper`);
1267
+ }
1268
+ }
1258
1269
  createLoop(input, from = new Date) {
1259
1270
  const now = nowIso();
1260
1271
  const target = input.target.type === "workflow" ? input.target : normalizeCreateWorkflowInput({
1261
1272
  name: "loop-target-validation",
1262
1273
  steps: [{ id: "target", target: input.target }]
1263
1274
  }).steps[0].target;
1275
+ this.assertNoNestedWorkflowGoal(target, input.goal);
1264
1276
  const loop = {
1265
1277
  id: genId(),
1266
1278
  name: input.name,
@@ -1432,6 +1444,9 @@ class Store {
1432
1444
  if (this.hasRunningRun(current.id))
1433
1445
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1434
1446
  const workflow = this.requireWorkflow(workflowId);
1447
+ if (current.goal && workflow.goal) {
1448
+ throw new Error(`workflow loop cannot retarget ${current.name} to workflow ${workflow.name} because both define top-level goals`);
1449
+ }
1435
1450
  const target = { ...current.target, workflowId: workflow.id };
1436
1451
  if (opts.workflowTimeoutMs !== undefined)
1437
1452
  target.timeoutMs = opts.workflowTimeoutMs;
@@ -1470,6 +1485,9 @@ class Store {
1470
1485
  throw new Error(`loop is not a workflow loop: ${idOrName}`);
1471
1486
  if (this.hasRunningRun(current.id))
1472
1487
  throw new Error(`refusing to retarget running loop: ${current.id}`);
1488
+ if (current.goal && normalized.goal) {
1489
+ throw new Error(`workflow loop cannot retarget ${current.name} to a workflow that also defines a top-level goal`);
1490
+ }
1473
1491
  const previousWorkflow = this.requireWorkflow(current.target.workflowId);
1474
1492
  const workflow = {
1475
1493
  id: genId(),
@@ -1761,7 +1779,7 @@ class Store {
1761
1779
  if (!sourceDedupeKey)
1762
1780
  throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
1763
1781
  const now = nowIso();
1764
- const claimableStatuses = ["queued", "deferred", "failed", "dead_letter", "cancelled"];
1782
+ const claimableStatuses = ["queued", "deferred"];
1765
1783
  const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
1766
1784
  const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
1767
1785
  const result = this.db.query(`UPDATE workflow_invocations
@@ -1842,28 +1860,35 @@ class Store {
1842
1860
  project_group=excluded.project_group,
1843
1861
  priority=excluded.priority,
1844
1862
  status=CASE
1845
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
1863
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
1846
1864
  THEN workflow_work_items.status
1847
1865
  ELSE excluded.status
1848
1866
  END,
1849
1867
  workflow_id=CASE
1850
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
1868
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
1851
1869
  ELSE NULL
1852
1870
  END,
1853
1871
  loop_id=CASE
1854
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
1872
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
1855
1873
  ELSE NULL
1856
1874
  END,
1857
1875
  workflow_run_id=CASE
1858
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
1876
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
1859
1877
  ELSE NULL
1860
1878
  END,
1861
1879
  lease_expires_at=CASE
1862
- WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
1880
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
1863
1881
  ELSE NULL
1864
1882
  END,
1865
1883
  next_attempt_at=excluded.next_attempt_at,
1866
- last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
1884
+ last_reason=CASE
1885
+ WHEN workflow_work_items.attempts > 0
1886
+ AND workflow_work_items.status IN ('queued', 'deferred')
1887
+ AND workflow_work_items.last_reason IS NOT NULL
1888
+ AND excluded.last_reason IS NOT NULL
1889
+ THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
1890
+ ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
1891
+ END,
1867
1892
  updated_at=excluded.updated_at`).run({
1868
1893
  $id: id,
1869
1894
  $routeKey: input.routeKey,
@@ -1916,11 +1941,39 @@ class Store {
1916
1941
  const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
1917
1942
  return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
1918
1943
  }
1944
+ requeueWorkflowWorkItem(id, patch = {}) {
1945
+ const current = this.getWorkflowWorkItem(id);
1946
+ if (!current)
1947
+ throw new Error(`workflow work item not found: ${id}`);
1948
+ const requeueableStatuses = ["succeeded", "failed", "dead_letter", "cancelled"];
1949
+ if (!requeueableStatuses.includes(current.status)) {
1950
+ throw new Error(`workflow work item is not requeueable: ${id} status=${current.status}`);
1951
+ }
1952
+ const now = nowIso();
1953
+ const reason = patch.reason?.trim() || `requeued from ${current.status}`;
1954
+ const placeholders = requeueableStatuses.map(() => "?").join(",");
1955
+ const res = this.db.query(`UPDATE workflow_work_items
1956
+ SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
1957
+ next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
1958
+ WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
1959
+ const item = this.getWorkflowWorkItem(id);
1960
+ if (!item)
1961
+ throw new Error(`workflow work item not found after requeue: ${id}`);
1962
+ if (res.changes !== 1)
1963
+ throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
1964
+ return item;
1965
+ }
1919
1966
  admitWorkflowWorkItem(id, patch) {
1920
1967
  const now = nowIso();
1921
1968
  const res = this.db.query(`UPDATE workflow_work_items
1922
1969
  SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
1923
- next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
1970
+ next_attempt_at=NULL,
1971
+ lease_expires_at=NULL,
1972
+ last_reason=CASE
1973
+ WHEN last_reason IS NOT NULL AND $reason IS NOT NULL THEN last_reason || '; ' || $reason
1974
+ ELSE COALESCE($reason, last_reason)
1975
+ END,
1976
+ updated_at=$updated
1924
1977
  WHERE id=$id AND status IN ('queued', 'deferred')`).run({
1925
1978
  $id: id,
1926
1979
  $workflowId: patch.workflowId,
@@ -3454,6 +3507,9 @@ function assertSupportedAgentOptions(target) {
3454
3507
  assertStringOption(target.agent, `${target.provider}.agent`);
3455
3508
  assertStringOption(target.authProfile, `${target.provider}.authProfile`);
3456
3509
  assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
3510
+ if (target.provider === "opencode" && (target.model === undefined || target.model.trim() === "")) {
3511
+ throw new Error("opencode.model is required; pass a provider/model id such as openrouter/google/gemini-2.5-flash");
3512
+ }
3457
3513
  if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
3458
3514
  throw new Error(`${target.provider}.configIsolation must be safe or none`);
3459
3515
  }
@@ -4226,6 +4282,78 @@ function sameBlockerKey(values) {
4226
4282
  return values.map((value) => value.trim()).filter(Boolean).join(`
4227
4283
  `) || "goal completion remains unproven";
4228
4284
  }
4285
+ function planStatusForGoal(goal) {
4286
+ if (goal.status === "usageLimited")
4287
+ return "blocked";
4288
+ return goal.status;
4289
+ }
4290
+ function syncReadyFlags(store, goal, nodes, opts) {
4291
+ const withReady = updateReadyFlags(nodes, planStatusForGoal(goal));
4292
+ for (const node of withReady) {
4293
+ const current = nodes.find((entry) => entry.key === node.key);
4294
+ if (current && current.ready !== node.ready) {
4295
+ store.updateGoalPlanNode(goal.goalId, node.key, { ready: node.ready }, { daemonLeaseId: opts.daemonLeaseId });
4296
+ }
4297
+ }
4298
+ return withReady;
4299
+ }
4300
+ function summarizeLabels(values, limit = 5) {
4301
+ if (values.length <= limit)
4302
+ return values.join(", ");
4303
+ return `${values.slice(0, limit).join(", ")} and ${values.length - limit} more`;
4304
+ }
4305
+ function noReadyDiagnostic(goal, nodes) {
4306
+ const planStatus = planStatusForGoal(goal);
4307
+ const byKey = new Map(nodes.map((node) => [node.key, node]));
4308
+ const pendingNodes = nodes.filter((node) => node.status === "pending");
4309
+ const incompleteNodes = nodes.filter((node) => node.status !== "complete");
4310
+ const pendingDetails = pendingNodes.map((node) => ({
4311
+ key: node.key,
4312
+ status: node.status,
4313
+ ready: node.ready,
4314
+ tokenBudget: node.tokenBudget,
4315
+ tokensUsed: node.tokensUsed,
4316
+ budgetExhausted: nodeBudgetExhausted(node),
4317
+ unmetDependencies: node.dependsOn.map((dependency) => ({ key: dependency, status: byKey.get(dependency)?.status ?? "missing" })).filter((dependency) => dependency.status !== "complete")
4318
+ }));
4319
+ const incompleteDetails = incompleteNodes.map((node) => ({
4320
+ key: node.key,
4321
+ status: node.status,
4322
+ ready: node.ready
4323
+ }));
4324
+ let owner = "goal-plan";
4325
+ let cause = "goal plan has no ready nodes";
4326
+ if (planStatus !== "active") {
4327
+ owner = "goal";
4328
+ cause = `goal status is ${goal.status}`;
4329
+ } else if (pendingNodes.length === 0) {
4330
+ owner = "goal-plan";
4331
+ cause = `goal plan has no pending runnable nodes; incomplete nodes: ${summarizeLabels(incompleteDetails.map((node) => `${node.key}:${node.status}`))}`;
4332
+ } else if (pendingDetails.every((node) => node.budgetExhausted)) {
4333
+ owner = "goal-plan-node";
4334
+ cause = `all pending nodes are budget-exhausted: ${summarizeLabels(pendingDetails.map((node) => node.key))}`;
4335
+ } else {
4336
+ const missingDependencies = pendingDetails.flatMap((node) => node.unmetDependencies.filter((dependency) => dependency.status === "missing").map((dependency) => `${node.key}->${dependency.key}`));
4337
+ if (missingDependencies.length > 0) {
4338
+ owner = "goal-plan";
4339
+ cause = `pending nodes reference missing dependencies: ${summarizeLabels(missingDependencies)}`;
4340
+ } else if (pendingDetails.every((node) => node.unmetDependencies.length > 0 || node.budgetExhausted)) {
4341
+ owner = "goal-plan-node";
4342
+ const waiting = pendingDetails.filter((node) => node.unmetDependencies.length > 0).map((node) => `${node.key} waits on ${node.unmetDependencies.map((dependency) => `${dependency.key}:${dependency.status}`).join(",")}`);
4343
+ const exhausted = pendingDetails.filter((node) => node.budgetExhausted).map((node) => `${node.key} budget exhausted`);
4344
+ cause = `pending nodes are blocked by prerequisites: ${summarizeLabels([...waiting, ...exhausted])}`;
4345
+ }
4346
+ }
4347
+ const blocker = `${cause}; owner=${owner}`;
4348
+ return {
4349
+ owner,
4350
+ blocker,
4351
+ planStatus,
4352
+ rollup: rollupSummary(nodes),
4353
+ pendingNodes: pendingDetails,
4354
+ incompleteNodes: incompleteDetails
4355
+ };
4356
+ }
4229
4357
  function metadataFor(goal, node, context) {
4230
4358
  return {
4231
4359
  loopId: context?.loopId,
@@ -4286,13 +4414,14 @@ async function planGoal(store, goal, spec, model, opts) {
4286
4414
  }, { daemonLeaseId: opts.daemonLeaseId });
4287
4415
  return store.createGoalPlanNodes(goal.goalId, rawNodes, { daemonLeaseId: opts.daemonLeaseId });
4288
4416
  }
4289
- function stdoutFor(goal, nodes, evidence, validation) {
4417
+ function stdoutFor(goal, nodes, evidence, validation, diagnostics) {
4290
4418
  return JSON.stringify({
4291
4419
  goal,
4292
4420
  rollup: rollupSummary(nodes),
4293
4421
  nodes,
4294
4422
  evidence,
4295
- validation
4423
+ validation,
4424
+ diagnostics
4296
4425
  }, null, 2);
4297
4426
  }
4298
4427
  async function runGoal(store, input, opts = {}) {
@@ -4325,6 +4454,7 @@ async function runGoal(store, input, opts = {}) {
4325
4454
  let validation;
4326
4455
  let lastBlocker = "";
4327
4456
  let repeatedBlockerCount = 0;
4457
+ let lastDiagnostic;
4328
4458
  if (budgetExhausted(goal)) {
4329
4459
  goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
4330
4460
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted after planning", startedAt);
@@ -4335,13 +4465,13 @@ async function runGoal(store, input, opts = {}) {
4335
4465
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal cancelled", startedAt);
4336
4466
  }
4337
4467
  goal = store.requireGoal(goal.goalId);
4338
- nodes = store.listGoalPlanNodes(goal.goalId);
4468
+ nodes = syncReadyFlags(store, goal, store.listGoalPlanNodes(goal.goalId), opts);
4339
4469
  if (budgetExhausted(goal)) {
4340
4470
  goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
4341
4471
  return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted", startedAt);
4342
4472
  }
4343
4473
  const readyKeys = readyNodeKeys({
4344
- status: goal.status === "active" ? "active" : goal.status === "budgetLimited" ? "budgetLimited" : "blocked",
4474
+ status: planStatusForGoal(goal),
4345
4475
  nodes
4346
4476
  });
4347
4477
  if (readyKeys.length > 0) {
@@ -4440,7 +4570,9 @@ ${result.stderr}`);
4440
4570
  }
4441
4571
  continue;
4442
4572
  }
4443
- const blocker = "no ready goal nodes and goal plan is incomplete";
4573
+ const diagnostic = noReadyDiagnostic(goal, nodes);
4574
+ lastDiagnostic = diagnostic;
4575
+ const blocker = diagnostic.blocker;
4444
4576
  if (blocker === lastBlocker)
4445
4577
  repeatedBlockerCount += 1;
4446
4578
  else {
@@ -4452,15 +4584,16 @@ ${result.stderr}`);
4452
4584
  turn,
4453
4585
  phase: "status",
4454
4586
  status: repeatedBlockerCount >= 3 ? "blocked" : "active",
4455
- evidence: { blocker }
4587
+ evidence: diagnostic
4456
4588
  }, { daemonLeaseId: opts.daemonLeaseId });
4457
4589
  if (repeatedBlockerCount >= 3) {
4458
4590
  goal = store.updateGoalStatus(goal.goalId, "blocked", { daemonLeaseId: opts.daemonLeaseId });
4459
- return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation), blocker, startedAt);
4591
+ return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation, diagnostic), blocker, startedAt);
4460
4592
  }
4461
4593
  }
4462
4594
  goal = store.updateGoalStatus(goal.goalId, "usageLimited", { daemonLeaseId: opts.daemonLeaseId });
4463
- return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation), "goal max turns exhausted", startedAt);
4595
+ const exhaustedError = lastDiagnostic?.blocker ?? (lastBlocker ? `${lastBlocker}; max turns exhausted` : "goal max turns exhausted");
4596
+ return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation, lastDiagnostic), exhaustedError, startedAt);
4464
4597
  }
4465
4598
 
4466
4599
  // src/lib/workflow-runner.ts
@@ -4489,6 +4622,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
4489
4622
  const workflowWithoutGoal = { ...workflow, goal: undefined };
4490
4623
  return runGoal(store, workflow.goal, {
4491
4624
  ...opts,
4625
+ model: opts.goalModel,
4492
4626
  context: {
4493
4627
  loopId: opts.loop?.id,
4494
4628
  loopName: opts.loop?.name,
@@ -4581,6 +4715,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
4581
4715
  if (step.goal) {
4582
4716
  result = await runGoal(store, step.goal, {
4583
4717
  ...opts,
4718
+ model: opts.goalModel,
4584
4719
  target: targetWithStepAccount(step),
4585
4720
  signal: controller.signal,
4586
4721
  context: {
@@ -4722,6 +4857,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4722
4857
  if (loop.goal) {
4723
4858
  return runGoal(store, loop.goal, {
4724
4859
  ...opts,
4860
+ model: opts.goalModel,
4725
4861
  target: loop.target,
4726
4862
  context: {
4727
4863
  loopId: loop.id,
@@ -4743,8 +4879,10 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4743
4879
  }
4744
4880
  }
4745
4881
  if (loop.goal) {
4882
+ const workflowForLoopGoal = workflow.goal ? { ...workflow, goal: undefined } : workflow;
4746
4883
  return runGoal(store, loop.goal, {
4747
4884
  ...opts,
4885
+ model: opts.goalModel,
4748
4886
  context: {
4749
4887
  loopId: loop.id,
4750
4888
  loopName: loop.name,
@@ -4753,7 +4891,7 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
4753
4891
  workflowId: workflow.id,
4754
4892
  workflowName: workflow.name
4755
4893
  },
4756
- executeNode: async (node) => executeWorkflow(store, workflow, {
4894
+ executeNode: async (node) => executeWorkflow(store, workflowForLoopGoal, {
4757
4895
  ...opts,
4758
4896
  loop,
4759
4897
  loopRun: run,
@@ -0,0 +1,228 @@
1
+ # Automation Runtime Design
2
+
3
+ OpenLoops can execute workflow work that external automation systems have
4
+ already materialized, but it must not become the automation product surface.
5
+ `@hasna/automations` and `@hasna/actions` own automation specs, trigger
6
+ materialization, queue state, approvals, DLQ/replay, idempotency, and audit
7
+ evidence. OpenLoops owns workflow invocation, admission, execution, run
8
+ manifests, and provider routing once work is explicitly handed off.
9
+
10
+ ## Planned Workflow Upsert SDK
11
+
12
+ External compilers should not write OpenLoops SQLite rows directly. The stable
13
+ contract should be an idempotent CLI/SDK upsert that accepts a fully rendered
14
+ one-shot workflow loop request and returns durable refs.
15
+
16
+ Proposed SDK shape:
17
+
18
+ ```ts
19
+ type WorkflowUpsertRequest = {
20
+ idempotencyKey: string;
21
+ source: { kind: "action" | "automation" | "event"; id: string; dedupeKey?: string };
22
+ subject: { kind: "repo" | "task" | "pr" | "run"; id?: string; path?: string; url?: string };
23
+ workflow: { name: string; description?: string; steps: WorkflowStepInput[] };
24
+ loop: { name: string; schedule: { type: "once"; at: string }; machine?: LoopMachineRef };
25
+ route?: { projectPath?: string; projectGroup?: string; concurrencyGroup?: string };
26
+ execution?: AutomationExecutionPolicy;
27
+ mode?: "dry-run" | "preflight" | "commit";
28
+ dispatch?: "schedule" | "run-now" | "none";
29
+ };
30
+
31
+ type AutomationExecutionPolicy = {
32
+ mode: "standard" | "strict";
33
+ envAllowlist?: string[];
34
+ secretRefs?: Array<{ env: string; ref: string; required?: boolean }>;
35
+ allowTools?: string[];
36
+ allowCommands?: string[];
37
+ requireEnforcement?: boolean;
38
+ redactionProfile?: "default" | "strict";
39
+ };
40
+
41
+ type WorkflowUpsertResult = {
42
+ ok: boolean;
43
+ dryRun: boolean;
44
+ idempotencyKey: string;
45
+ specHash: string;
46
+ refs: {
47
+ workflowId?: string;
48
+ loopId?: string;
49
+ invocationId?: string;
50
+ workItemId?: string;
51
+ runId?: string;
52
+ manifestPath?: string;
53
+ };
54
+ action: "created" | "updated" | "reused" | "rejected";
55
+ preflight?: { ok: boolean; checks: unknown[]; error?: string };
56
+ };
57
+ ```
58
+
59
+ Required semantics:
60
+
61
+ - `mode="dry-run"` validates, canonicalizes, hashes, and returns the same JSON
62
+ shape without mutating OpenLoops state.
63
+ - `mode="preflight"` additionally checks provider binaries, machine routing,
64
+ accounts/auth profiles, prompt files, and workflow target compatibility before
65
+ commit.
66
+ - `mode="commit"` is idempotent on `idempotencyKey` plus `specHash`: identical
67
+ requests return existing refs; changed specs create a new workflow version or
68
+ one-shot loop while preserving previous run history.
69
+ - `dispatch="schedule"` stores a one-shot loop for the daemon; `run-now` claims
70
+ an immediate manual slot; `none` only materializes refs for another owner to
71
+ trigger later.
72
+ - All persisted output is redacted before storage, and returned refs are enough
73
+ for the caller to inspect, cancel, replay, or resolve the run without querying
74
+ SQLite directly.
75
+
76
+ ## CLI Surface
77
+
78
+ The planned CLI should mirror the SDK:
79
+
80
+ ```bash
81
+ loops workflows upsert-one-shot ./workflow.json \
82
+ --idempotency-key actions:<action-id>:<version> \
83
+ --source-kind action \
84
+ --source-id <action-id> \
85
+ --subject-kind repo \
86
+ --subject-path /path/to/repo \
87
+ --loop-name action-<action-id> \
88
+ --at 2026-07-01T12:00:00Z \
89
+ --mode preflight \
90
+ --dispatch schedule \
91
+ --json
92
+ ```
93
+
94
+ `--mode dry-run` must not create a database file in an empty `LOOPS_DATA_DIR`.
95
+ `--mode preflight` must report the exact provider/account/worktree check that
96
+ would fail. `--mode commit` must return the durable refs required for later
97
+ inspection and replay.
98
+
99
+ ## Planned Strict Automation Execution
100
+
101
+ Automation-generated targets need a stricter execution mode than ordinary local
102
+ agent loops. Strict mode should make execution reproducible, auditable, and
103
+ secret-safe without depending on ambient shell state.
104
+
105
+ ### Policy
106
+
107
+ `execution.mode="strict"` means:
108
+
109
+ - Do not inherit full `process.env`. Start from a minimal runtime environment:
110
+ `PATH`, `HOME`, `TMPDIR`, locale keys, OpenLoops metadata keys, and tool
111
+ config dirs explicitly produced by account/secret resolution.
112
+ - Pass only named `envAllowlist` values and `secretRefs`; never embed secret
113
+ values in workflow specs, prompts, metadata, manifests, or task comments.
114
+ - Resolve `secretRefs` at run time through the owning secret/action system and
115
+ inject them only into the child process that needs them.
116
+ - Default provider posture is safe: `configIsolation="safe"`, no
117
+ `permissionMode="bypass"`, worktree mode `required` for repo mutation, and
118
+ provider-native sandboxing where available.
119
+ - Persist stdout, stderr, error, workflow events, and manifests only after
120
+ redaction. Strict redaction must treat `env`, `secret`, `token`, `key`,
121
+ `authorization`, `stdout`, `stderr`, `error`, `prompt`, and provider raw
122
+ response payloads as sensitive by default.
123
+
124
+ ### Allowlist Enforcement
125
+
126
+ Current `allowlist` metadata is advisory. Strict automation requires real
127
+ enforcement:
128
+
129
+ - If `requireEnforcement=true`, preflight must reject providers that cannot
130
+ enforce the requested tool or command allowlist.
131
+ - Codewith/Codex should map allowed tools and commands to provider-native flags
132
+ or policy files when those surfaces are available.
133
+ - Command targets should run through a small policy wrapper that rejects
134
+ unlisted executables before `exec`.
135
+ - Providers without enforceable allowlists may still run in `standard` mode,
136
+ but strict automation should fail before storing or dispatching them.
137
+
138
+ ### CLI Surface
139
+
140
+ Planned flags:
141
+
142
+ ```bash
143
+ loops workflows upsert-one-shot ./workflow.json \
144
+ --execution-mode strict \
145
+ --env-allow PATH,HOME,TMPDIR \
146
+ --secret-ref OPENROUTER_API_KEY=hasna/openrouter/live/api-key \
147
+ --allow-command bun,test,git \
148
+ --allow-tool functions.exec_command \
149
+ --require-allowlist-enforcement \
150
+ --redaction-profile strict \
151
+ --mode preflight \
152
+ --json
153
+ ```
154
+
155
+ Strict-mode dry runs must show the effective policy and redacted env names, not
156
+ secret values. Strict-mode preflight must fail with a policy classification when
157
+ any required env, secret, account profile, provider sandbox, or allowlist
158
+ enforcement surface is missing.
159
+
160
+ ## Planned DLQ And Replay
161
+
162
+ OpenLoops already models terminal route work items and has a manual
163
+ `routes requeue` command. The automation runtime contract needs a stricter DLQ
164
+ layer for exhausted side-effecting work, because external action systems need to
165
+ distinguish "failed attempt", "dead until operator action", and "resolved".
166
+
167
+ ### States
168
+
169
+ Route work items should keep these meanings:
170
+
171
+ - `failed`: a terminal attempt failed, but policy may still allow an explicit
172
+ replay after the cause is fixed.
173
+ - `dead_letter`: automatic attempts are exhausted or the failure is classified
174
+ as unsafe to retry without operator action.
175
+ - `cancelled`: an operator or owner intentionally stopped the work.
176
+ - `succeeded`: the work completed and remains deduped history.
177
+ - `resolved`: planned external DLQ state for `@hasna/actions`; OpenLoops can
178
+ mirror it as `dead_letter` plus a resolution event until a first-class status
179
+ is added.
180
+
181
+ ### Commands
182
+
183
+ Planned command surface:
184
+
185
+ ```bash
186
+ loops dlq list --route-key actions --status dead_letter --json
187
+ loops dlq show <work-item-id> --json
188
+ loops dlq replay <work-item-id> --reason "credential rotated" --idempotency-key <new-key> --json
189
+ loops dlq resolve <work-item-id> --reason "superseded upstream" --resolution superseded --json
190
+ ```
191
+
192
+ `list` and `show` must join the work item, invocation, workflow, loop, latest
193
+ workflow run, step runs, run manifest path, classification, attempt count, and
194
+ last redacted error. `replay` must be idempotent: the same replay request with
195
+ the same new idempotency key returns the same refs; a different key records a
196
+ new replay attempt linked to the original work item. `resolve` must never run
197
+ provider code; it only records why the DLQ entry no longer needs execution.
198
+
199
+ ### Replay Semantics
200
+
201
+ - Replay requires a human-readable `--reason`; automation callers also pass the
202
+ original action id and a new replay idempotency key.
203
+ - Replay clears stale loop/workflow/run refs only for terminal work items and
204
+ never for `queued`, `admitted`, or `running` items.
205
+ - Replay preserves source, subject, manifest, and previous failure history so
206
+ audits can explain why another attempt exists.
207
+ - Replay of side-effecting work should default to `mode=preflight`; promotion
208
+ to `mode=commit` is a separate operator/API decision unless the owning
209
+ `@hasna/actions` policy marks the action safely replayable.
210
+ - If `@hasna/actions` owns the queue, OpenLoops calls the action replay API and
211
+ stores the returned action/run refs instead of fabricating queue state.
212
+
213
+ ### Dead-Letter Classification
214
+
215
+ The first implementation should classify terminal failures into:
216
+
217
+ - `preflight`: missing provider binary, account profile, worktree, prompt file,
218
+ or machine route.
219
+ - `auth`: invalid credentials or expired provider account.
220
+ - `policy`: manual gate, approval gate, no-auto tag, or strict execution mode
221
+ denial.
222
+ - `transient`: network, rate limit, provider overload, or lease loss.
223
+ - `bug`: deterministic command/test failure, schema mismatch, or code exception.
224
+ - `unknown`: fallback when no classifier is confident.
225
+
226
+ Only `transient` and explicitly marked `bug` fixes should be eligible for
227
+ automatic replay policy. `auth`, `policy`, and `preflight` need operator or
228
+ owning-system resolution evidence before replay.