@hasna/loops 0.3.55 → 0.3.57
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/README.md +188 -14
- package/dist/cli/index.js +1345 -175
- package/dist/daemon/index.js +143 -19
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4024 -2849
- package/dist/lib/health.d.ts +2 -2
- package/dist/lib/store.d.ts +3 -0
- package/dist/lib/store.js +46 -8
- package/dist/lib/templates.d.ts +4 -0
- package/dist/mcp/index.d.ts +12 -0
- package/dist/mcp/index.js +5821 -0
- package/dist/sdk/index.js +134 -16
- package/docs/AUTOMATION_RUNTIME_DESIGN.md +228 -0
- package/docs/USAGE.md +123 -15
- package/package.json +9 -3
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)
|
|
@@ -1761,7 +1764,7 @@ class Store {
|
|
|
1761
1764
|
if (!sourceDedupeKey)
|
|
1762
1765
|
throw new Error("cannot refresh workflow invocation without sourceRef.dedupeKey");
|
|
1763
1766
|
const now = nowIso();
|
|
1764
|
-
const claimableStatuses = ["queued", "deferred"
|
|
1767
|
+
const claimableStatuses = ["queued", "deferred"];
|
|
1765
1768
|
const statusBindings = Object.fromEntries(claimableStatuses.map((status, index) => [`$status${index}`, status]));
|
|
1766
1769
|
const placeholders = claimableStatuses.map((_, index) => `$status${index}`).join(",");
|
|
1767
1770
|
const result = this.db.query(`UPDATE workflow_invocations
|
|
@@ -1842,28 +1845,35 @@ class Store {
|
|
|
1842
1845
|
project_group=excluded.project_group,
|
|
1843
1846
|
priority=excluded.priority,
|
|
1844
1847
|
status=CASE
|
|
1845
|
-
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
|
|
1848
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
|
|
1846
1849
|
THEN workflow_work_items.status
|
|
1847
1850
|
ELSE excluded.status
|
|
1848
1851
|
END,
|
|
1849
1852
|
workflow_id=CASE
|
|
1850
|
-
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
|
|
1853
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
|
|
1851
1854
|
ELSE NULL
|
|
1852
1855
|
END,
|
|
1853
1856
|
loop_id=CASE
|
|
1854
|
-
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
|
|
1857
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
|
|
1855
1858
|
ELSE NULL
|
|
1856
1859
|
END,
|
|
1857
1860
|
workflow_run_id=CASE
|
|
1858
|
-
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
|
|
1861
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
|
|
1859
1862
|
ELSE NULL
|
|
1860
1863
|
END,
|
|
1861
1864
|
lease_expires_at=CASE
|
|
1862
|
-
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
|
|
1865
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
|
|
1863
1866
|
ELSE NULL
|
|
1864
1867
|
END,
|
|
1865
1868
|
next_attempt_at=excluded.next_attempt_at,
|
|
1866
|
-
last_reason=
|
|
1869
|
+
last_reason=CASE
|
|
1870
|
+
WHEN workflow_work_items.attempts > 0
|
|
1871
|
+
AND workflow_work_items.status IN ('queued', 'deferred')
|
|
1872
|
+
AND workflow_work_items.last_reason IS NOT NULL
|
|
1873
|
+
AND excluded.last_reason IS NOT NULL
|
|
1874
|
+
THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
|
|
1875
|
+
ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
|
|
1876
|
+
END,
|
|
1867
1877
|
updated_at=excluded.updated_at`).run({
|
|
1868
1878
|
$id: id,
|
|
1869
1879
|
$routeKey: input.routeKey,
|
|
@@ -1916,11 +1926,39 @@ class Store {
|
|
|
1916
1926
|
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
1927
|
return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
|
|
1918
1928
|
}
|
|
1929
|
+
requeueWorkflowWorkItem(id, patch = {}) {
|
|
1930
|
+
const current = this.getWorkflowWorkItem(id);
|
|
1931
|
+
if (!current)
|
|
1932
|
+
throw new Error(`workflow work item not found: ${id}`);
|
|
1933
|
+
const requeueableStatuses = ["succeeded", "failed", "dead_letter", "cancelled"];
|
|
1934
|
+
if (!requeueableStatuses.includes(current.status)) {
|
|
1935
|
+
throw new Error(`workflow work item is not requeueable: ${id} status=${current.status}`);
|
|
1936
|
+
}
|
|
1937
|
+
const now = nowIso();
|
|
1938
|
+
const reason = patch.reason?.trim() || `requeued from ${current.status}`;
|
|
1939
|
+
const placeholders = requeueableStatuses.map(() => "?").join(",");
|
|
1940
|
+
const res = this.db.query(`UPDATE workflow_work_items
|
|
1941
|
+
SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
|
|
1942
|
+
next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
|
|
1943
|
+
WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
|
|
1944
|
+
const item = this.getWorkflowWorkItem(id);
|
|
1945
|
+
if (!item)
|
|
1946
|
+
throw new Error(`workflow work item not found after requeue: ${id}`);
|
|
1947
|
+
if (res.changes !== 1)
|
|
1948
|
+
throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
|
|
1949
|
+
return item;
|
|
1950
|
+
}
|
|
1919
1951
|
admitWorkflowWorkItem(id, patch) {
|
|
1920
1952
|
const now = nowIso();
|
|
1921
1953
|
const res = this.db.query(`UPDATE workflow_work_items
|
|
1922
1954
|
SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
|
|
1923
|
-
next_attempt_at=NULL,
|
|
1955
|
+
next_attempt_at=NULL,
|
|
1956
|
+
lease_expires_at=NULL,
|
|
1957
|
+
last_reason=CASE
|
|
1958
|
+
WHEN last_reason IS NOT NULL AND $reason IS NOT NULL THEN last_reason || '; ' || $reason
|
|
1959
|
+
ELSE COALESCE($reason, last_reason)
|
|
1960
|
+
END,
|
|
1961
|
+
updated_at=$updated
|
|
1924
1962
|
WHERE id=$id AND status IN ('queued', 'deferred')`).run({
|
|
1925
1963
|
$id: id,
|
|
1926
1964
|
$workflowId: patch.workflowId,
|
|
@@ -3454,6 +3492,9 @@ function assertSupportedAgentOptions(target) {
|
|
|
3454
3492
|
assertStringOption(target.agent, `${target.provider}.agent`);
|
|
3455
3493
|
assertStringOption(target.authProfile, `${target.provider}.authProfile`);
|
|
3456
3494
|
assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
|
|
3495
|
+
if (target.provider === "opencode" && (target.model === undefined || target.model.trim() === "")) {
|
|
3496
|
+
throw new Error("opencode.model is required; pass a provider/model id such as openrouter/google/gemini-2.5-flash");
|
|
3497
|
+
}
|
|
3457
3498
|
if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
|
|
3458
3499
|
throw new Error(`${target.provider}.configIsolation must be safe or none`);
|
|
3459
3500
|
}
|
|
@@ -4226,6 +4267,78 @@ function sameBlockerKey(values) {
|
|
|
4226
4267
|
return values.map((value) => value.trim()).filter(Boolean).join(`
|
|
4227
4268
|
`) || "goal completion remains unproven";
|
|
4228
4269
|
}
|
|
4270
|
+
function planStatusForGoal(goal) {
|
|
4271
|
+
if (goal.status === "usageLimited")
|
|
4272
|
+
return "blocked";
|
|
4273
|
+
return goal.status;
|
|
4274
|
+
}
|
|
4275
|
+
function syncReadyFlags(store, goal, nodes, opts) {
|
|
4276
|
+
const withReady = updateReadyFlags(nodes, planStatusForGoal(goal));
|
|
4277
|
+
for (const node of withReady) {
|
|
4278
|
+
const current = nodes.find((entry) => entry.key === node.key);
|
|
4279
|
+
if (current && current.ready !== node.ready) {
|
|
4280
|
+
store.updateGoalPlanNode(goal.goalId, node.key, { ready: node.ready }, { daemonLeaseId: opts.daemonLeaseId });
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
return withReady;
|
|
4284
|
+
}
|
|
4285
|
+
function summarizeLabels(values, limit = 5) {
|
|
4286
|
+
if (values.length <= limit)
|
|
4287
|
+
return values.join(", ");
|
|
4288
|
+
return `${values.slice(0, limit).join(", ")} and ${values.length - limit} more`;
|
|
4289
|
+
}
|
|
4290
|
+
function noReadyDiagnostic(goal, nodes) {
|
|
4291
|
+
const planStatus = planStatusForGoal(goal);
|
|
4292
|
+
const byKey = new Map(nodes.map((node) => [node.key, node]));
|
|
4293
|
+
const pendingNodes = nodes.filter((node) => node.status === "pending");
|
|
4294
|
+
const incompleteNodes = nodes.filter((node) => node.status !== "complete");
|
|
4295
|
+
const pendingDetails = pendingNodes.map((node) => ({
|
|
4296
|
+
key: node.key,
|
|
4297
|
+
status: node.status,
|
|
4298
|
+
ready: node.ready,
|
|
4299
|
+
tokenBudget: node.tokenBudget,
|
|
4300
|
+
tokensUsed: node.tokensUsed,
|
|
4301
|
+
budgetExhausted: nodeBudgetExhausted(node),
|
|
4302
|
+
unmetDependencies: node.dependsOn.map((dependency) => ({ key: dependency, status: byKey.get(dependency)?.status ?? "missing" })).filter((dependency) => dependency.status !== "complete")
|
|
4303
|
+
}));
|
|
4304
|
+
const incompleteDetails = incompleteNodes.map((node) => ({
|
|
4305
|
+
key: node.key,
|
|
4306
|
+
status: node.status,
|
|
4307
|
+
ready: node.ready
|
|
4308
|
+
}));
|
|
4309
|
+
let owner = "goal-plan";
|
|
4310
|
+
let cause = "goal plan has no ready nodes";
|
|
4311
|
+
if (planStatus !== "active") {
|
|
4312
|
+
owner = "goal";
|
|
4313
|
+
cause = `goal status is ${goal.status}`;
|
|
4314
|
+
} else if (pendingNodes.length === 0) {
|
|
4315
|
+
owner = "goal-plan";
|
|
4316
|
+
cause = `goal plan has no pending runnable nodes; incomplete nodes: ${summarizeLabels(incompleteDetails.map((node) => `${node.key}:${node.status}`))}`;
|
|
4317
|
+
} else if (pendingDetails.every((node) => node.budgetExhausted)) {
|
|
4318
|
+
owner = "goal-plan-node";
|
|
4319
|
+
cause = `all pending nodes are budget-exhausted: ${summarizeLabels(pendingDetails.map((node) => node.key))}`;
|
|
4320
|
+
} else {
|
|
4321
|
+
const missingDependencies = pendingDetails.flatMap((node) => node.unmetDependencies.filter((dependency) => dependency.status === "missing").map((dependency) => `${node.key}->${dependency.key}`));
|
|
4322
|
+
if (missingDependencies.length > 0) {
|
|
4323
|
+
owner = "goal-plan";
|
|
4324
|
+
cause = `pending nodes reference missing dependencies: ${summarizeLabels(missingDependencies)}`;
|
|
4325
|
+
} else if (pendingDetails.every((node) => node.unmetDependencies.length > 0 || node.budgetExhausted)) {
|
|
4326
|
+
owner = "goal-plan-node";
|
|
4327
|
+
const waiting = pendingDetails.filter((node) => node.unmetDependencies.length > 0).map((node) => `${node.key} waits on ${node.unmetDependencies.map((dependency) => `${dependency.key}:${dependency.status}`).join(",")}`);
|
|
4328
|
+
const exhausted = pendingDetails.filter((node) => node.budgetExhausted).map((node) => `${node.key} budget exhausted`);
|
|
4329
|
+
cause = `pending nodes are blocked by prerequisites: ${summarizeLabels([...waiting, ...exhausted])}`;
|
|
4330
|
+
}
|
|
4331
|
+
}
|
|
4332
|
+
const blocker = `${cause}; owner=${owner}`;
|
|
4333
|
+
return {
|
|
4334
|
+
owner,
|
|
4335
|
+
blocker,
|
|
4336
|
+
planStatus,
|
|
4337
|
+
rollup: rollupSummary(nodes),
|
|
4338
|
+
pendingNodes: pendingDetails,
|
|
4339
|
+
incompleteNodes: incompleteDetails
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
4229
4342
|
function metadataFor(goal, node, context) {
|
|
4230
4343
|
return {
|
|
4231
4344
|
loopId: context?.loopId,
|
|
@@ -4286,13 +4399,14 @@ async function planGoal(store, goal, spec, model, opts) {
|
|
|
4286
4399
|
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
4287
4400
|
return store.createGoalPlanNodes(goal.goalId, rawNodes, { daemonLeaseId: opts.daemonLeaseId });
|
|
4288
4401
|
}
|
|
4289
|
-
function stdoutFor(goal, nodes, evidence, validation) {
|
|
4402
|
+
function stdoutFor(goal, nodes, evidence, validation, diagnostics) {
|
|
4290
4403
|
return JSON.stringify({
|
|
4291
4404
|
goal,
|
|
4292
4405
|
rollup: rollupSummary(nodes),
|
|
4293
4406
|
nodes,
|
|
4294
4407
|
evidence,
|
|
4295
|
-
validation
|
|
4408
|
+
validation,
|
|
4409
|
+
diagnostics
|
|
4296
4410
|
}, null, 2);
|
|
4297
4411
|
}
|
|
4298
4412
|
async function runGoal(store, input, opts = {}) {
|
|
@@ -4325,6 +4439,7 @@ async function runGoal(store, input, opts = {}) {
|
|
|
4325
4439
|
let validation;
|
|
4326
4440
|
let lastBlocker = "";
|
|
4327
4441
|
let repeatedBlockerCount = 0;
|
|
4442
|
+
let lastDiagnostic;
|
|
4328
4443
|
if (budgetExhausted(goal)) {
|
|
4329
4444
|
goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
|
|
4330
4445
|
return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted after planning", startedAt);
|
|
@@ -4335,13 +4450,13 @@ async function runGoal(store, input, opts = {}) {
|
|
|
4335
4450
|
return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal cancelled", startedAt);
|
|
4336
4451
|
}
|
|
4337
4452
|
goal = store.requireGoal(goal.goalId);
|
|
4338
|
-
nodes = store.listGoalPlanNodes(goal.goalId);
|
|
4453
|
+
nodes = syncReadyFlags(store, goal, store.listGoalPlanNodes(goal.goalId), opts);
|
|
4339
4454
|
if (budgetExhausted(goal)) {
|
|
4340
4455
|
goal = store.updateGoalStatus(goal.goalId, "budgetLimited", { daemonLeaseId: opts.daemonLeaseId });
|
|
4341
4456
|
return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence), "goal token budget exhausted", startedAt);
|
|
4342
4457
|
}
|
|
4343
4458
|
const readyKeys = readyNodeKeys({
|
|
4344
|
-
status: goal
|
|
4459
|
+
status: planStatusForGoal(goal),
|
|
4345
4460
|
nodes
|
|
4346
4461
|
});
|
|
4347
4462
|
if (readyKeys.length > 0) {
|
|
@@ -4440,7 +4555,9 @@ ${result.stderr}`);
|
|
|
4440
4555
|
}
|
|
4441
4556
|
continue;
|
|
4442
4557
|
}
|
|
4443
|
-
const
|
|
4558
|
+
const diagnostic = noReadyDiagnostic(goal, nodes);
|
|
4559
|
+
lastDiagnostic = diagnostic;
|
|
4560
|
+
const blocker = diagnostic.blocker;
|
|
4444
4561
|
if (blocker === lastBlocker)
|
|
4445
4562
|
repeatedBlockerCount += 1;
|
|
4446
4563
|
else {
|
|
@@ -4452,15 +4569,16 @@ ${result.stderr}`);
|
|
|
4452
4569
|
turn,
|
|
4453
4570
|
phase: "status",
|
|
4454
4571
|
status: repeatedBlockerCount >= 3 ? "blocked" : "active",
|
|
4455
|
-
evidence:
|
|
4572
|
+
evidence: diagnostic
|
|
4456
4573
|
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
4457
4574
|
if (repeatedBlockerCount >= 3) {
|
|
4458
4575
|
goal = store.updateGoalStatus(goal.goalId, "blocked", { daemonLeaseId: opts.daemonLeaseId });
|
|
4459
|
-
return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation), blocker, startedAt);
|
|
4576
|
+
return resultFromGoal(goal, "failed", stdoutFor(goal, nodes, evidence, validation, diagnostic), blocker, startedAt);
|
|
4460
4577
|
}
|
|
4461
4578
|
}
|
|
4462
4579
|
goal = store.updateGoalStatus(goal.goalId, "usageLimited", { daemonLeaseId: opts.daemonLeaseId });
|
|
4463
|
-
|
|
4580
|
+
const exhaustedError = lastDiagnostic?.blocker ?? (lastBlocker ? `${lastBlocker}; max turns exhausted` : "goal max turns exhausted");
|
|
4581
|
+
return resultFromGoal(goal, "failed", stdoutFor(goal, store.listGoalPlanNodes(goal.goalId), evidence, validation, lastDiagnostic), exhaustedError, startedAt);
|
|
4464
4582
|
}
|
|
4465
4583
|
|
|
4466
4584
|
// src/lib/workflow-runner.ts
|
|
@@ -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.
|