@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/README.md +188 -14
- package/dist/cli/index.js +1421 -176
- package/dist/daemon/index.js +164 -20
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4049 -2874
- package/dist/lib/executor.d.ts +2 -0
- package/dist/lib/health.d.ts +2 -2
- package/dist/lib/store.d.ts +4 -0
- package/dist/lib/store.js +61 -8
- package/dist/lib/templates.d.ts +4 -0
- package/dist/mcp/index.d.ts +12 -0
- package/dist/mcp/index.js +5841 -0
- package/dist/sdk/index.js +155 -17
- 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)
|
|
@@ -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"
|
|
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=
|
|
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,
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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.
|