@hasna/loops 0.3.48 → 0.3.49
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 +32 -0
- package/dist/cli/index.js +200 -9
- package/dist/daemon/index.js +101 -4
- package/dist/index.js +100 -3
- package/dist/lib/store.d.ts +7 -0
- package/dist/lib/store.js +100 -3
- package/dist/sdk/index.js +100 -3
- package/docs/USAGE.md +41 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -315,6 +315,38 @@ loops routes show <work-item-id>
|
|
|
315
315
|
loops routes invocations
|
|
316
316
|
```
|
|
317
317
|
|
|
318
|
+
For Hasna OSS task-created routing, use a deterministic drain instead of tmux
|
|
319
|
+
dispatch:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
loops routes schedule todos-task oss-task-route-drain \
|
|
323
|
+
--every 5m \
|
|
324
|
+
--todos-project "$HOME/.hasna/loops" \
|
|
325
|
+
--project-path-prefix /home/hasna/workspace/hasna/opensource \
|
|
326
|
+
--tags auto:route \
|
|
327
|
+
--provider codewith \
|
|
328
|
+
--auth-profile-pool account004,account005,account006 \
|
|
329
|
+
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
|
|
330
|
+
--project-group oss \
|
|
331
|
+
--max-dispatch 2 \
|
|
332
|
+
--scan-limit 5000 \
|
|
333
|
+
--max-active-per-project 1 \
|
|
334
|
+
--max-active-per-project-group 4 \
|
|
335
|
+
--max-active 12 \
|
|
336
|
+
--worktree-mode required \
|
|
337
|
+
--evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
|
|
338
|
+
--compact
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Only route tasks that explicitly opt in with `auto:route`, `route_enabled=true`,
|
|
342
|
+
or `automation.allowed=true`. Use Codewith account pools, required worktrees,
|
|
343
|
+
max-active throttles, and bounded evidence directories. Do not dispatch or paste
|
|
344
|
+
task prompts into tmux panes. Keep `--scan-limit` large enough for the local
|
|
345
|
+
ready-task backlog; otherwise low-priority or newly-created tasks can sit beyond
|
|
346
|
+
the scanned window. Generated one-shot task/event route workflow specs are
|
|
347
|
+
archived automatically when their workflow run reaches a terminal state;
|
|
348
|
+
workflow run history and manifests are preserved.
|
|
349
|
+
|
|
318
350
|
Workflow run manifests are written under
|
|
319
351
|
`.hasna/loops/runs/<project-slug>/<subject-key>/<run-id>/manifest.json`.
|
|
320
352
|
`subject-key` is a safe derived path segment, not the raw subject reference.
|
package/dist/cli/index.js
CHANGED
|
@@ -623,6 +623,8 @@ function writeWorkflowRunManifest(args) {
|
|
|
623
623
|
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
624
624
|
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
625
625
|
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
626
|
+
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
|
|
627
|
+
var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
|
|
626
628
|
function rowToLoop(row) {
|
|
627
629
|
return {
|
|
628
630
|
id: row.id,
|
|
@@ -1443,10 +1445,23 @@ class Store {
|
|
|
1443
1445
|
})();
|
|
1444
1446
|
}
|
|
1445
1447
|
listWorkflows(opts = {}) {
|
|
1446
|
-
const
|
|
1447
|
-
|
|
1448
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
1449
|
+
let rows;
|
|
1450
|
+
if (opts.status && opts.limit !== undefined) {
|
|
1451
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
|
|
1452
|
+
} else if (opts.status) {
|
|
1453
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
|
|
1454
|
+
} else if (opts.limit !== undefined) {
|
|
1455
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
|
|
1456
|
+
} else {
|
|
1457
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
|
|
1458
|
+
}
|
|
1448
1459
|
return rows.map(rowToWorkflow);
|
|
1449
1460
|
}
|
|
1461
|
+
countWorkflows(opts = {}) {
|
|
1462
|
+
const row = opts.status ? this.db.query("SELECT COUNT(*) AS count FROM workflow_specs WHERE status = ?").get(opts.status) : this.db.query("SELECT COUNT(*) AS count FROM workflow_specs").get();
|
|
1463
|
+
return row?.count ?? 0;
|
|
1464
|
+
}
|
|
1450
1465
|
archiveWorkflow(idOrName) {
|
|
1451
1466
|
const workflow = this.requireWorkflow(idOrName);
|
|
1452
1467
|
const updated = nowIso();
|
|
@@ -1456,6 +1471,64 @@ class Store {
|
|
|
1456
1471
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1457
1472
|
return archived;
|
|
1458
1473
|
}
|
|
1474
|
+
generatedRouteArchiveContext(args) {
|
|
1475
|
+
if (!args.loopId || !args.workItemId)
|
|
1476
|
+
return;
|
|
1477
|
+
const workItem = this.getWorkflowWorkItem(args.workItemId);
|
|
1478
|
+
if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
|
|
1479
|
+
return;
|
|
1480
|
+
const invocation = this.getWorkflowInvocation(workItem.invocationId);
|
|
1481
|
+
if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
|
|
1482
|
+
return;
|
|
1483
|
+
const loop = this.getLoop(args.loopId);
|
|
1484
|
+
if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
|
|
1485
|
+
return;
|
|
1486
|
+
const input = loop.target.input ?? {};
|
|
1487
|
+
if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
|
|
1488
|
+
return;
|
|
1489
|
+
if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
|
|
1490
|
+
return;
|
|
1491
|
+
if (workItem.workflowId && workItem.workflowId !== args.workflowId)
|
|
1492
|
+
return;
|
|
1493
|
+
const workflow = this.getWorkflow(args.workflowId);
|
|
1494
|
+
if (!workflow)
|
|
1495
|
+
return;
|
|
1496
|
+
return { workflow, loop, workItem, invocation };
|
|
1497
|
+
}
|
|
1498
|
+
maybeArchiveGeneratedRouteWorkflow(args) {
|
|
1499
|
+
const context = this.generatedRouteArchiveContext(args);
|
|
1500
|
+
if (!context)
|
|
1501
|
+
return;
|
|
1502
|
+
const { workflow, loop, workItem } = context;
|
|
1503
|
+
if (!workflow || workflow.status !== "active")
|
|
1504
|
+
return;
|
|
1505
|
+
const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
|
|
1506
|
+
WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
|
|
1507
|
+
if (nonTerminal > 0)
|
|
1508
|
+
return;
|
|
1509
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
|
|
1510
|
+
if (res.changes === 1 && args.workflowRunId) {
|
|
1511
|
+
this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
|
|
1512
|
+
workflowId: args.workflowId,
|
|
1513
|
+
loopId: loop.id,
|
|
1514
|
+
workItemId: workItem.id,
|
|
1515
|
+
routeKey: workItem.routeKey,
|
|
1516
|
+
reason: "terminal generated one-shot route workflow"
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
|
|
1521
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
1522
|
+
if (!run)
|
|
1523
|
+
return;
|
|
1524
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
1525
|
+
workflowId: run.workflowId,
|
|
1526
|
+
loopId: run.loopId,
|
|
1527
|
+
workItemId: run.workItemId,
|
|
1528
|
+
workflowRunId,
|
|
1529
|
+
updated
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1459
1532
|
createWorkflowInvocation(input) {
|
|
1460
1533
|
const now = nowIso();
|
|
1461
1534
|
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
@@ -2241,6 +2314,7 @@ class Store {
|
|
|
2241
2314
|
if (changed) {
|
|
2242
2315
|
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2243
2316
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2317
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
|
|
2244
2318
|
}
|
|
2245
2319
|
this.db.exec("COMMIT");
|
|
2246
2320
|
} catch (error) {
|
|
@@ -2268,6 +2342,7 @@ class Store {
|
|
|
2268
2342
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2269
2343
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
2270
2344
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
2345
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
|
|
2271
2346
|
}
|
|
2272
2347
|
this.db.exec("COMMIT");
|
|
2273
2348
|
return this.requireWorkflowRun(workflowRunId);
|
|
@@ -2520,8 +2595,20 @@ class Store {
|
|
|
2520
2595
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2521
2596
|
if (opts.claimedBy && res.changes !== 1)
|
|
2522
2597
|
return run;
|
|
2523
|
-
if (res.changes === 1)
|
|
2598
|
+
if (res.changes === 1) {
|
|
2524
2599
|
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2600
|
+
const loop = this.getLoop(run.loopId);
|
|
2601
|
+
const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
2602
|
+
if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
|
|
2603
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2604
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2605
|
+
workflowId: loop.target.workflowId,
|
|
2606
|
+
loopId: loop.id,
|
|
2607
|
+
workItemId,
|
|
2608
|
+
updated: finishedAt
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2525
2612
|
return run;
|
|
2526
2613
|
}
|
|
2527
2614
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2642,6 +2729,7 @@ class Store {
|
|
|
2642
2729
|
loopRunId: row.id
|
|
2643
2730
|
});
|
|
2644
2731
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2732
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
|
|
2645
2733
|
}
|
|
2646
2734
|
const loop = this.getLoop(row.loop_id);
|
|
2647
2735
|
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
@@ -2649,6 +2737,15 @@ class Store {
|
|
|
2649
2737
|
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2650
2738
|
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2651
2739
|
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2740
|
+
if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
|
|
2741
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2742
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2743
|
+
workflowId: loop.target.workflowId,
|
|
2744
|
+
loopId: loop.id,
|
|
2745
|
+
workItemId,
|
|
2746
|
+
updated: finished
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2652
2749
|
}
|
|
2653
2750
|
this.db.exec("COMMIT");
|
|
2654
2751
|
} catch (error) {
|
|
@@ -5811,7 +5908,7 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
5811
5908
|
// package.json
|
|
5812
5909
|
var package_default = {
|
|
5813
5910
|
name: "@hasna/loops",
|
|
5814
|
-
version: "0.3.
|
|
5911
|
+
version: "0.3.49",
|
|
5815
5912
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5816
5913
|
type: "module",
|
|
5817
5914
|
main: "dist/index.js",
|
|
@@ -6961,6 +7058,14 @@ function positiveInteger(raw, label) {
|
|
|
6961
7058
|
throw new Error(`${label} must be a positive integer`);
|
|
6962
7059
|
return value;
|
|
6963
7060
|
}
|
|
7061
|
+
function nonNegativeInteger(raw, label) {
|
|
7062
|
+
if (raw === undefined)
|
|
7063
|
+
return;
|
|
7064
|
+
const value = Number(raw);
|
|
7065
|
+
if (!Number.isInteger(value) || value < 0)
|
|
7066
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
7067
|
+
return value;
|
|
7068
|
+
}
|
|
6964
7069
|
function positiveDuration(raw, label) {
|
|
6965
7070
|
if (raw === undefined)
|
|
6966
7071
|
return;
|
|
@@ -7442,6 +7547,23 @@ function taskRouteEligibility(data, metadata) {
|
|
|
7442
7547
|
}
|
|
7443
7548
|
return { eligible: true, tags };
|
|
7444
7549
|
}
|
|
7550
|
+
function skippedDrainTask(task, event, reason) {
|
|
7551
|
+
const taskId = taskField(task, ["id", "task_id", "taskId"]) ?? event?.subject ?? "unknown";
|
|
7552
|
+
return {
|
|
7553
|
+
kind: "skipped",
|
|
7554
|
+
value: {
|
|
7555
|
+
skipped: true,
|
|
7556
|
+
reason,
|
|
7557
|
+
taskId,
|
|
7558
|
+
event,
|
|
7559
|
+
routeError: true
|
|
7560
|
+
},
|
|
7561
|
+
human: `skipped task ${taskId}: ${reason}`
|
|
7562
|
+
};
|
|
7563
|
+
}
|
|
7564
|
+
function isSkippableDrainRouteError(message) {
|
|
7565
|
+
return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
|
|
7566
|
+
}
|
|
7445
7567
|
function generatedRouteSandboxPreflight(workflow) {
|
|
7446
7568
|
const checks = [];
|
|
7447
7569
|
for (const step of workflow.steps) {
|
|
@@ -8401,8 +8523,17 @@ function drainTodosTaskRoutes(opts) {
|
|
|
8401
8523
|
for (const task of candidates) {
|
|
8402
8524
|
if (created >= maxDispatch)
|
|
8403
8525
|
break;
|
|
8404
|
-
|
|
8405
|
-
|
|
8526
|
+
let event;
|
|
8527
|
+
let result;
|
|
8528
|
+
try {
|
|
8529
|
+
event = taskDrainEvent(task);
|
|
8530
|
+
result = routeTodosTaskEvent(event, opts);
|
|
8531
|
+
} catch (error) {
|
|
8532
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
8533
|
+
if (!isSkippableDrainRouteError(message))
|
|
8534
|
+
throw error;
|
|
8535
|
+
result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed");
|
|
8536
|
+
}
|
|
8406
8537
|
results.push(result);
|
|
8407
8538
|
if (result.kind === "created" && !opts.dryRun)
|
|
8408
8539
|
created += 1;
|
|
@@ -8463,6 +8594,57 @@ function drainTodosTaskRoutes(opts) {
|
|
|
8463
8594
|
} : { ...report, evidencePath };
|
|
8464
8595
|
print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
|
|
8465
8596
|
}
|
|
8597
|
+
function formatTemplateVariable(template, name) {
|
|
8598
|
+
const variable = template.variables.find((entry) => entry.name === name);
|
|
8599
|
+
const placeholder = variable?.default ? variable.default : `<${name}>`;
|
|
8600
|
+
return ` --var ${name}=${placeholder}`;
|
|
8601
|
+
}
|
|
8602
|
+
function printTemplateDetails(template) {
|
|
8603
|
+
console.log(`${template.id} (${template.kind})`);
|
|
8604
|
+
console.log(template.name);
|
|
8605
|
+
console.log("");
|
|
8606
|
+
console.log(template.description);
|
|
8607
|
+
console.log("");
|
|
8608
|
+
console.log("Variables:");
|
|
8609
|
+
const nameWidth = Math.max(...template.variables.map((variable) => variable.name.length), 4);
|
|
8610
|
+
for (const variable of template.variables) {
|
|
8611
|
+
const required = variable.required ? "required" : "optional";
|
|
8612
|
+
const defaultValue = variable.default ? ` default=${variable.default}` : "";
|
|
8613
|
+
const description = variable.description ? ` ${variable.description}` : "";
|
|
8614
|
+
console.log(` ${variable.name.padEnd(nameWidth)} ${required}${defaultValue}${description}`);
|
|
8615
|
+
}
|
|
8616
|
+
const requiredVariables = template.variables.filter((variable) => variable.required).map((variable) => variable.name);
|
|
8617
|
+
const hintVariables = requiredVariables.length ? requiredVariables : template.variables.slice(0, 2).map((variable) => variable.name);
|
|
8618
|
+
const renderArgs = hintVariables.map((name) => formatTemplateVariable(template, name));
|
|
8619
|
+
const renderHint = renderArgs.length ? ` \\
|
|
8620
|
+
${renderArgs.join(" \\\n")}` : "";
|
|
8621
|
+
console.log("");
|
|
8622
|
+
console.log("Usage:");
|
|
8623
|
+
console.log(` loops templates render ${template.id}${renderHint}`);
|
|
8624
|
+
if (template.kind === "workflow")
|
|
8625
|
+
console.log(` loops templates create-workflow ${template.id}${renderHint}`);
|
|
8626
|
+
}
|
|
8627
|
+
function workflowStatusFromOpts(status, all) {
|
|
8628
|
+
if (all) {
|
|
8629
|
+
if (status && status !== "active")
|
|
8630
|
+
throw new Error("use either --all or --status, not both");
|
|
8631
|
+
return;
|
|
8632
|
+
}
|
|
8633
|
+
const value = status ?? "active";
|
|
8634
|
+
if (value === "all")
|
|
8635
|
+
return;
|
|
8636
|
+
if (value === "active" || value === "archived")
|
|
8637
|
+
return value;
|
|
8638
|
+
throw new Error("--status must be active, archived, or all");
|
|
8639
|
+
}
|
|
8640
|
+
function printWorkflowListWarning(args) {
|
|
8641
|
+
if (args.shown + args.offset >= args.total && args.offset === 0)
|
|
8642
|
+
return;
|
|
8643
|
+
const scope = args.status ?? "all";
|
|
8644
|
+
const nextOffset = args.offset + args.shown;
|
|
8645
|
+
const next = args.limit && nextOffset < args.total ? ` next page: --limit ${args.limit} --offset ${nextOffset}` : "";
|
|
8646
|
+
console.error(`showing ${args.offset + args.shown} of ${args.total} ${scope} workflows.${next}`);
|
|
8647
|
+
}
|
|
8466
8648
|
templates.command("list").alias("ls").description("list built-in OpenLoops templates").action(() => {
|
|
8467
8649
|
const values = listLoopTemplates();
|
|
8468
8650
|
if (isJson())
|
|
@@ -8477,7 +8659,10 @@ templates.command("show <id>").description("show a built-in template").action((i
|
|
|
8477
8659
|
const template = getLoopTemplate(id);
|
|
8478
8660
|
if (!template)
|
|
8479
8661
|
throw new Error(`template not found: ${id}`);
|
|
8480
|
-
|
|
8662
|
+
if (isJson())
|
|
8663
|
+
print(template);
|
|
8664
|
+
else
|
|
8665
|
+
printTemplateDetails(template);
|
|
8481
8666
|
});
|
|
8482
8667
|
templates.command("render <id>").description("render a template as workflow JSON").option("--var <key=value>", "template variable; may be repeated", collectValues, []).action((id, opts) => {
|
|
8483
8668
|
const workflow = renderLoopTemplate(id, parseVars(opts.var));
|
|
@@ -8677,10 +8862,14 @@ workflows.command("create <file>").description("validate and store a workflow JS
|
|
|
8677
8862
|
store.close();
|
|
8678
8863
|
}
|
|
8679
8864
|
});
|
|
8680
|
-
workflows.command("list").alias("ls").option("--status <status>", "active or
|
|
8865
|
+
workflows.command("list").alias("ls").option("--status <status>", "active, archived, or all", "active").option("--all", "include active and archived workflows").option("--limit <n>", "maximum rows to print; omitted means all matching workflows").option("--offset <n>", "number of matching rows to skip before printing", "0").action((opts) => {
|
|
8681
8866
|
const store = new Store;
|
|
8682
8867
|
try {
|
|
8683
|
-
const
|
|
8868
|
+
const status = workflowStatusFromOpts(opts.status, opts.all);
|
|
8869
|
+
const limit = positiveInteger(opts.limit, "--limit");
|
|
8870
|
+
const offset = nonNegativeInteger(opts.offset, "--offset") ?? 0;
|
|
8871
|
+
const workflowsList = store.listWorkflows({ status, limit, offset });
|
|
8872
|
+
const total = store.countWorkflows({ status });
|
|
8684
8873
|
if (isJson())
|
|
8685
8874
|
print(workflowsList.map(publicWorkflow));
|
|
8686
8875
|
else {
|
|
@@ -8688,6 +8877,8 @@ workflows.command("list").alias("ls").option("--status <status>", "active or arc
|
|
|
8688
8877
|
console.log(`${workflow.id} ${workflow.status.padEnd(8)} steps=${workflow.steps.length} ${workflow.name}`);
|
|
8689
8878
|
}
|
|
8690
8879
|
}
|
|
8880
|
+
if (limit !== undefined || offset > 0)
|
|
8881
|
+
printWorkflowListWarning({ shown: workflowsList.length, total, status, offset, limit });
|
|
8691
8882
|
} finally {
|
|
8692
8883
|
store.close();
|
|
8693
8884
|
}
|
package/dist/daemon/index.js
CHANGED
|
@@ -623,6 +623,8 @@ function writeWorkflowRunManifest(args) {
|
|
|
623
623
|
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
624
624
|
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
625
625
|
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
626
|
+
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
|
|
627
|
+
var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
|
|
626
628
|
function rowToLoop(row) {
|
|
627
629
|
return {
|
|
628
630
|
id: row.id,
|
|
@@ -1443,10 +1445,23 @@ class Store {
|
|
|
1443
1445
|
})();
|
|
1444
1446
|
}
|
|
1445
1447
|
listWorkflows(opts = {}) {
|
|
1446
|
-
const
|
|
1447
|
-
|
|
1448
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
1449
|
+
let rows;
|
|
1450
|
+
if (opts.status && opts.limit !== undefined) {
|
|
1451
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
|
|
1452
|
+
} else if (opts.status) {
|
|
1453
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
|
|
1454
|
+
} else if (opts.limit !== undefined) {
|
|
1455
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
|
|
1456
|
+
} else {
|
|
1457
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
|
|
1458
|
+
}
|
|
1448
1459
|
return rows.map(rowToWorkflow);
|
|
1449
1460
|
}
|
|
1461
|
+
countWorkflows(opts = {}) {
|
|
1462
|
+
const row = opts.status ? this.db.query("SELECT COUNT(*) AS count FROM workflow_specs WHERE status = ?").get(opts.status) : this.db.query("SELECT COUNT(*) AS count FROM workflow_specs").get();
|
|
1463
|
+
return row?.count ?? 0;
|
|
1464
|
+
}
|
|
1450
1465
|
archiveWorkflow(idOrName) {
|
|
1451
1466
|
const workflow = this.requireWorkflow(idOrName);
|
|
1452
1467
|
const updated = nowIso();
|
|
@@ -1456,6 +1471,64 @@ class Store {
|
|
|
1456
1471
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1457
1472
|
return archived;
|
|
1458
1473
|
}
|
|
1474
|
+
generatedRouteArchiveContext(args) {
|
|
1475
|
+
if (!args.loopId || !args.workItemId)
|
|
1476
|
+
return;
|
|
1477
|
+
const workItem = this.getWorkflowWorkItem(args.workItemId);
|
|
1478
|
+
if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
|
|
1479
|
+
return;
|
|
1480
|
+
const invocation = this.getWorkflowInvocation(workItem.invocationId);
|
|
1481
|
+
if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
|
|
1482
|
+
return;
|
|
1483
|
+
const loop = this.getLoop(args.loopId);
|
|
1484
|
+
if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
|
|
1485
|
+
return;
|
|
1486
|
+
const input = loop.target.input ?? {};
|
|
1487
|
+
if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
|
|
1488
|
+
return;
|
|
1489
|
+
if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
|
|
1490
|
+
return;
|
|
1491
|
+
if (workItem.workflowId && workItem.workflowId !== args.workflowId)
|
|
1492
|
+
return;
|
|
1493
|
+
const workflow = this.getWorkflow(args.workflowId);
|
|
1494
|
+
if (!workflow)
|
|
1495
|
+
return;
|
|
1496
|
+
return { workflow, loop, workItem, invocation };
|
|
1497
|
+
}
|
|
1498
|
+
maybeArchiveGeneratedRouteWorkflow(args) {
|
|
1499
|
+
const context = this.generatedRouteArchiveContext(args);
|
|
1500
|
+
if (!context)
|
|
1501
|
+
return;
|
|
1502
|
+
const { workflow, loop, workItem } = context;
|
|
1503
|
+
if (!workflow || workflow.status !== "active")
|
|
1504
|
+
return;
|
|
1505
|
+
const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
|
|
1506
|
+
WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
|
|
1507
|
+
if (nonTerminal > 0)
|
|
1508
|
+
return;
|
|
1509
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
|
|
1510
|
+
if (res.changes === 1 && args.workflowRunId) {
|
|
1511
|
+
this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
|
|
1512
|
+
workflowId: args.workflowId,
|
|
1513
|
+
loopId: loop.id,
|
|
1514
|
+
workItemId: workItem.id,
|
|
1515
|
+
routeKey: workItem.routeKey,
|
|
1516
|
+
reason: "terminal generated one-shot route workflow"
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
|
|
1521
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
1522
|
+
if (!run)
|
|
1523
|
+
return;
|
|
1524
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
1525
|
+
workflowId: run.workflowId,
|
|
1526
|
+
loopId: run.loopId,
|
|
1527
|
+
workItemId: run.workItemId,
|
|
1528
|
+
workflowRunId,
|
|
1529
|
+
updated
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1459
1532
|
createWorkflowInvocation(input) {
|
|
1460
1533
|
const now = nowIso();
|
|
1461
1534
|
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
@@ -2241,6 +2314,7 @@ class Store {
|
|
|
2241
2314
|
if (changed) {
|
|
2242
2315
|
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2243
2316
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2317
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
|
|
2244
2318
|
}
|
|
2245
2319
|
this.db.exec("COMMIT");
|
|
2246
2320
|
} catch (error) {
|
|
@@ -2268,6 +2342,7 @@ class Store {
|
|
|
2268
2342
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2269
2343
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
2270
2344
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
2345
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
|
|
2271
2346
|
}
|
|
2272
2347
|
this.db.exec("COMMIT");
|
|
2273
2348
|
return this.requireWorkflowRun(workflowRunId);
|
|
@@ -2520,8 +2595,20 @@ class Store {
|
|
|
2520
2595
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2521
2596
|
if (opts.claimedBy && res.changes !== 1)
|
|
2522
2597
|
return run;
|
|
2523
|
-
if (res.changes === 1)
|
|
2598
|
+
if (res.changes === 1) {
|
|
2524
2599
|
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2600
|
+
const loop = this.getLoop(run.loopId);
|
|
2601
|
+
const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
2602
|
+
if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
|
|
2603
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2604
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2605
|
+
workflowId: loop.target.workflowId,
|
|
2606
|
+
loopId: loop.id,
|
|
2607
|
+
workItemId,
|
|
2608
|
+
updated: finishedAt
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2525
2612
|
return run;
|
|
2526
2613
|
}
|
|
2527
2614
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2642,6 +2729,7 @@ class Store {
|
|
|
2642
2729
|
loopRunId: row.id
|
|
2643
2730
|
});
|
|
2644
2731
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2732
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
|
|
2645
2733
|
}
|
|
2646
2734
|
const loop = this.getLoop(row.loop_id);
|
|
2647
2735
|
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
@@ -2649,6 +2737,15 @@ class Store {
|
|
|
2649
2737
|
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2650
2738
|
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2651
2739
|
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2740
|
+
if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
|
|
2741
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2742
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2743
|
+
workflowId: loop.target.workflowId,
|
|
2744
|
+
loopId: loop.id,
|
|
2745
|
+
workItemId,
|
|
2746
|
+
updated: finished
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2652
2749
|
}
|
|
2653
2750
|
this.db.exec("COMMIT");
|
|
2654
2751
|
} catch (error) {
|
|
@@ -5143,7 +5240,7 @@ function enableStartup(result) {
|
|
|
5143
5240
|
// package.json
|
|
5144
5241
|
var package_default = {
|
|
5145
5242
|
name: "@hasna/loops",
|
|
5146
|
-
version: "0.3.
|
|
5243
|
+
version: "0.3.49",
|
|
5147
5244
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5148
5245
|
type: "module",
|
|
5149
5246
|
main: "dist/index.js",
|
package/dist/index.js
CHANGED
|
@@ -621,6 +621,8 @@ function writeWorkflowRunManifest(args) {
|
|
|
621
621
|
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
622
622
|
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
623
623
|
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
624
|
+
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
|
|
625
|
+
var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
|
|
624
626
|
function rowToLoop(row) {
|
|
625
627
|
return {
|
|
626
628
|
id: row.id,
|
|
@@ -1441,10 +1443,23 @@ class Store {
|
|
|
1441
1443
|
})();
|
|
1442
1444
|
}
|
|
1443
1445
|
listWorkflows(opts = {}) {
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
1447
|
+
let rows;
|
|
1448
|
+
if (opts.status && opts.limit !== undefined) {
|
|
1449
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
|
|
1450
|
+
} else if (opts.status) {
|
|
1451
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
|
|
1452
|
+
} else if (opts.limit !== undefined) {
|
|
1453
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
|
|
1454
|
+
} else {
|
|
1455
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
|
|
1456
|
+
}
|
|
1446
1457
|
return rows.map(rowToWorkflow);
|
|
1447
1458
|
}
|
|
1459
|
+
countWorkflows(opts = {}) {
|
|
1460
|
+
const row = opts.status ? this.db.query("SELECT COUNT(*) AS count FROM workflow_specs WHERE status = ?").get(opts.status) : this.db.query("SELECT COUNT(*) AS count FROM workflow_specs").get();
|
|
1461
|
+
return row?.count ?? 0;
|
|
1462
|
+
}
|
|
1448
1463
|
archiveWorkflow(idOrName) {
|
|
1449
1464
|
const workflow = this.requireWorkflow(idOrName);
|
|
1450
1465
|
const updated = nowIso();
|
|
@@ -1454,6 +1469,64 @@ class Store {
|
|
|
1454
1469
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1455
1470
|
return archived;
|
|
1456
1471
|
}
|
|
1472
|
+
generatedRouteArchiveContext(args) {
|
|
1473
|
+
if (!args.loopId || !args.workItemId)
|
|
1474
|
+
return;
|
|
1475
|
+
const workItem = this.getWorkflowWorkItem(args.workItemId);
|
|
1476
|
+
if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
|
|
1477
|
+
return;
|
|
1478
|
+
const invocation = this.getWorkflowInvocation(workItem.invocationId);
|
|
1479
|
+
if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
|
|
1480
|
+
return;
|
|
1481
|
+
const loop = this.getLoop(args.loopId);
|
|
1482
|
+
if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
|
|
1483
|
+
return;
|
|
1484
|
+
const input = loop.target.input ?? {};
|
|
1485
|
+
if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
|
|
1486
|
+
return;
|
|
1487
|
+
if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
|
|
1488
|
+
return;
|
|
1489
|
+
if (workItem.workflowId && workItem.workflowId !== args.workflowId)
|
|
1490
|
+
return;
|
|
1491
|
+
const workflow = this.getWorkflow(args.workflowId);
|
|
1492
|
+
if (!workflow)
|
|
1493
|
+
return;
|
|
1494
|
+
return { workflow, loop, workItem, invocation };
|
|
1495
|
+
}
|
|
1496
|
+
maybeArchiveGeneratedRouteWorkflow(args) {
|
|
1497
|
+
const context = this.generatedRouteArchiveContext(args);
|
|
1498
|
+
if (!context)
|
|
1499
|
+
return;
|
|
1500
|
+
const { workflow, loop, workItem } = context;
|
|
1501
|
+
if (!workflow || workflow.status !== "active")
|
|
1502
|
+
return;
|
|
1503
|
+
const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
|
|
1504
|
+
WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
|
|
1505
|
+
if (nonTerminal > 0)
|
|
1506
|
+
return;
|
|
1507
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
|
|
1508
|
+
if (res.changes === 1 && args.workflowRunId) {
|
|
1509
|
+
this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
|
|
1510
|
+
workflowId: args.workflowId,
|
|
1511
|
+
loopId: loop.id,
|
|
1512
|
+
workItemId: workItem.id,
|
|
1513
|
+
routeKey: workItem.routeKey,
|
|
1514
|
+
reason: "terminal generated one-shot route workflow"
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
|
|
1519
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
1520
|
+
if (!run)
|
|
1521
|
+
return;
|
|
1522
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
1523
|
+
workflowId: run.workflowId,
|
|
1524
|
+
loopId: run.loopId,
|
|
1525
|
+
workItemId: run.workItemId,
|
|
1526
|
+
workflowRunId,
|
|
1527
|
+
updated
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1457
1530
|
createWorkflowInvocation(input) {
|
|
1458
1531
|
const now = nowIso();
|
|
1459
1532
|
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
@@ -2239,6 +2312,7 @@ class Store {
|
|
|
2239
2312
|
if (changed) {
|
|
2240
2313
|
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2241
2314
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2315
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
|
|
2242
2316
|
}
|
|
2243
2317
|
this.db.exec("COMMIT");
|
|
2244
2318
|
} catch (error) {
|
|
@@ -2266,6 +2340,7 @@ class Store {
|
|
|
2266
2340
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2267
2341
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
2268
2342
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
2343
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
|
|
2269
2344
|
}
|
|
2270
2345
|
this.db.exec("COMMIT");
|
|
2271
2346
|
return this.requireWorkflowRun(workflowRunId);
|
|
@@ -2518,8 +2593,20 @@ class Store {
|
|
|
2518
2593
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2519
2594
|
if (opts.claimedBy && res.changes !== 1)
|
|
2520
2595
|
return run;
|
|
2521
|
-
if (res.changes === 1)
|
|
2596
|
+
if (res.changes === 1) {
|
|
2522
2597
|
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2598
|
+
const loop = this.getLoop(run.loopId);
|
|
2599
|
+
const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
2600
|
+
if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
|
|
2601
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2602
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2603
|
+
workflowId: loop.target.workflowId,
|
|
2604
|
+
loopId: loop.id,
|
|
2605
|
+
workItemId,
|
|
2606
|
+
updated: finishedAt
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2523
2610
|
return run;
|
|
2524
2611
|
}
|
|
2525
2612
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2640,6 +2727,7 @@ class Store {
|
|
|
2640
2727
|
loopRunId: row.id
|
|
2641
2728
|
});
|
|
2642
2729
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2730
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
|
|
2643
2731
|
}
|
|
2644
2732
|
const loop = this.getLoop(row.loop_id);
|
|
2645
2733
|
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
@@ -2647,6 +2735,15 @@ class Store {
|
|
|
2647
2735
|
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2648
2736
|
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2649
2737
|
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2738
|
+
if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
|
|
2739
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2740
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2741
|
+
workflowId: loop.target.workflowId,
|
|
2742
|
+
loopId: loop.id,
|
|
2743
|
+
workItemId,
|
|
2744
|
+
updated: finished
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2650
2747
|
}
|
|
2651
2748
|
this.db.exec("COMMIT");
|
|
2652
2749
|
} catch (error) {
|
package/dist/lib/store.d.ts
CHANGED
|
@@ -94,8 +94,15 @@ export declare class Store {
|
|
|
94
94
|
listWorkflows(opts?: {
|
|
95
95
|
status?: WorkflowSpec["status"];
|
|
96
96
|
limit?: number;
|
|
97
|
+
offset?: number;
|
|
97
98
|
}): WorkflowSpec[];
|
|
99
|
+
countWorkflows(opts?: {
|
|
100
|
+
status?: WorkflowSpec["status"];
|
|
101
|
+
}): number;
|
|
98
102
|
archiveWorkflow(idOrName: string): WorkflowSpec;
|
|
103
|
+
private generatedRouteArchiveContext;
|
|
104
|
+
private maybeArchiveGeneratedRouteWorkflow;
|
|
105
|
+
private maybeArchiveTerminalGeneratedRouteWorkflow;
|
|
99
106
|
createWorkflowInvocation(input: CreateWorkflowInvocationInput): WorkflowInvocation;
|
|
100
107
|
getWorkflowInvocation(id: string): WorkflowInvocation | undefined;
|
|
101
108
|
listWorkflowInvocations(opts?: {
|
package/dist/lib/store.js
CHANGED
|
@@ -621,6 +621,8 @@ function writeWorkflowRunManifest(args) {
|
|
|
621
621
|
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
622
622
|
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
623
623
|
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
624
|
+
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
|
|
625
|
+
var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
|
|
624
626
|
function rowToLoop(row) {
|
|
625
627
|
return {
|
|
626
628
|
id: row.id,
|
|
@@ -1441,10 +1443,23 @@ class Store {
|
|
|
1441
1443
|
})();
|
|
1442
1444
|
}
|
|
1443
1445
|
listWorkflows(opts = {}) {
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
1447
|
+
let rows;
|
|
1448
|
+
if (opts.status && opts.limit !== undefined) {
|
|
1449
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
|
|
1450
|
+
} else if (opts.status) {
|
|
1451
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
|
|
1452
|
+
} else if (opts.limit !== undefined) {
|
|
1453
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
|
|
1454
|
+
} else {
|
|
1455
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
|
|
1456
|
+
}
|
|
1446
1457
|
return rows.map(rowToWorkflow);
|
|
1447
1458
|
}
|
|
1459
|
+
countWorkflows(opts = {}) {
|
|
1460
|
+
const row = opts.status ? this.db.query("SELECT COUNT(*) AS count FROM workflow_specs WHERE status = ?").get(opts.status) : this.db.query("SELECT COUNT(*) AS count FROM workflow_specs").get();
|
|
1461
|
+
return row?.count ?? 0;
|
|
1462
|
+
}
|
|
1448
1463
|
archiveWorkflow(idOrName) {
|
|
1449
1464
|
const workflow = this.requireWorkflow(idOrName);
|
|
1450
1465
|
const updated = nowIso();
|
|
@@ -1454,6 +1469,64 @@ class Store {
|
|
|
1454
1469
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1455
1470
|
return archived;
|
|
1456
1471
|
}
|
|
1472
|
+
generatedRouteArchiveContext(args) {
|
|
1473
|
+
if (!args.loopId || !args.workItemId)
|
|
1474
|
+
return;
|
|
1475
|
+
const workItem = this.getWorkflowWorkItem(args.workItemId);
|
|
1476
|
+
if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
|
|
1477
|
+
return;
|
|
1478
|
+
const invocation = this.getWorkflowInvocation(workItem.invocationId);
|
|
1479
|
+
if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
|
|
1480
|
+
return;
|
|
1481
|
+
const loop = this.getLoop(args.loopId);
|
|
1482
|
+
if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
|
|
1483
|
+
return;
|
|
1484
|
+
const input = loop.target.input ?? {};
|
|
1485
|
+
if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
|
|
1486
|
+
return;
|
|
1487
|
+
if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
|
|
1488
|
+
return;
|
|
1489
|
+
if (workItem.workflowId && workItem.workflowId !== args.workflowId)
|
|
1490
|
+
return;
|
|
1491
|
+
const workflow = this.getWorkflow(args.workflowId);
|
|
1492
|
+
if (!workflow)
|
|
1493
|
+
return;
|
|
1494
|
+
return { workflow, loop, workItem, invocation };
|
|
1495
|
+
}
|
|
1496
|
+
maybeArchiveGeneratedRouteWorkflow(args) {
|
|
1497
|
+
const context = this.generatedRouteArchiveContext(args);
|
|
1498
|
+
if (!context)
|
|
1499
|
+
return;
|
|
1500
|
+
const { workflow, loop, workItem } = context;
|
|
1501
|
+
if (!workflow || workflow.status !== "active")
|
|
1502
|
+
return;
|
|
1503
|
+
const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
|
|
1504
|
+
WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
|
|
1505
|
+
if (nonTerminal > 0)
|
|
1506
|
+
return;
|
|
1507
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
|
|
1508
|
+
if (res.changes === 1 && args.workflowRunId) {
|
|
1509
|
+
this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
|
|
1510
|
+
workflowId: args.workflowId,
|
|
1511
|
+
loopId: loop.id,
|
|
1512
|
+
workItemId: workItem.id,
|
|
1513
|
+
routeKey: workItem.routeKey,
|
|
1514
|
+
reason: "terminal generated one-shot route workflow"
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
|
|
1519
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
1520
|
+
if (!run)
|
|
1521
|
+
return;
|
|
1522
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
1523
|
+
workflowId: run.workflowId,
|
|
1524
|
+
loopId: run.loopId,
|
|
1525
|
+
workItemId: run.workItemId,
|
|
1526
|
+
workflowRunId,
|
|
1527
|
+
updated
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1457
1530
|
createWorkflowInvocation(input) {
|
|
1458
1531
|
const now = nowIso();
|
|
1459
1532
|
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
@@ -2239,6 +2312,7 @@ class Store {
|
|
|
2239
2312
|
if (changed) {
|
|
2240
2313
|
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2241
2314
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2315
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
|
|
2242
2316
|
}
|
|
2243
2317
|
this.db.exec("COMMIT");
|
|
2244
2318
|
} catch (error) {
|
|
@@ -2266,6 +2340,7 @@ class Store {
|
|
|
2266
2340
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2267
2341
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
2268
2342
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
2343
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
|
|
2269
2344
|
}
|
|
2270
2345
|
this.db.exec("COMMIT");
|
|
2271
2346
|
return this.requireWorkflowRun(workflowRunId);
|
|
@@ -2518,8 +2593,20 @@ class Store {
|
|
|
2518
2593
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2519
2594
|
if (opts.claimedBy && res.changes !== 1)
|
|
2520
2595
|
return run;
|
|
2521
|
-
if (res.changes === 1)
|
|
2596
|
+
if (res.changes === 1) {
|
|
2522
2597
|
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2598
|
+
const loop = this.getLoop(run.loopId);
|
|
2599
|
+
const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
2600
|
+
if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
|
|
2601
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2602
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2603
|
+
workflowId: loop.target.workflowId,
|
|
2604
|
+
loopId: loop.id,
|
|
2605
|
+
workItemId,
|
|
2606
|
+
updated: finishedAt
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2523
2610
|
return run;
|
|
2524
2611
|
}
|
|
2525
2612
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2640,6 +2727,7 @@ class Store {
|
|
|
2640
2727
|
loopRunId: row.id
|
|
2641
2728
|
});
|
|
2642
2729
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2730
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
|
|
2643
2731
|
}
|
|
2644
2732
|
const loop = this.getLoop(row.loop_id);
|
|
2645
2733
|
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
@@ -2647,6 +2735,15 @@ class Store {
|
|
|
2647
2735
|
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2648
2736
|
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2649
2737
|
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2738
|
+
if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
|
|
2739
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2740
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2741
|
+
workflowId: loop.target.workflowId,
|
|
2742
|
+
loopId: loop.id,
|
|
2743
|
+
workItemId,
|
|
2744
|
+
updated: finished
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2650
2747
|
}
|
|
2651
2748
|
this.db.exec("COMMIT");
|
|
2652
2749
|
} catch (error) {
|
package/dist/sdk/index.js
CHANGED
|
@@ -621,6 +621,8 @@ function writeWorkflowRunManifest(args) {
|
|
|
621
621
|
var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
|
|
622
622
|
var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
|
|
623
623
|
var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
|
|
624
|
+
var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
|
|
625
|
+
var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
|
|
624
626
|
function rowToLoop(row) {
|
|
625
627
|
return {
|
|
626
628
|
id: row.id,
|
|
@@ -1441,10 +1443,23 @@ class Store {
|
|
|
1441
1443
|
})();
|
|
1442
1444
|
}
|
|
1443
1445
|
listWorkflows(opts = {}) {
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
1447
|
+
let rows;
|
|
1448
|
+
if (opts.status && opts.limit !== undefined) {
|
|
1449
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
|
|
1450
|
+
} else if (opts.status) {
|
|
1451
|
+
rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
|
|
1452
|
+
} else if (opts.limit !== undefined) {
|
|
1453
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
|
|
1454
|
+
} else {
|
|
1455
|
+
rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
|
|
1456
|
+
}
|
|
1446
1457
|
return rows.map(rowToWorkflow);
|
|
1447
1458
|
}
|
|
1459
|
+
countWorkflows(opts = {}) {
|
|
1460
|
+
const row = opts.status ? this.db.query("SELECT COUNT(*) AS count FROM workflow_specs WHERE status = ?").get(opts.status) : this.db.query("SELECT COUNT(*) AS count FROM workflow_specs").get();
|
|
1461
|
+
return row?.count ?? 0;
|
|
1462
|
+
}
|
|
1448
1463
|
archiveWorkflow(idOrName) {
|
|
1449
1464
|
const workflow = this.requireWorkflow(idOrName);
|
|
1450
1465
|
const updated = nowIso();
|
|
@@ -1454,6 +1469,64 @@ class Store {
|
|
|
1454
1469
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1455
1470
|
return archived;
|
|
1456
1471
|
}
|
|
1472
|
+
generatedRouteArchiveContext(args) {
|
|
1473
|
+
if (!args.loopId || !args.workItemId)
|
|
1474
|
+
return;
|
|
1475
|
+
const workItem = this.getWorkflowWorkItem(args.workItemId);
|
|
1476
|
+
if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
|
|
1477
|
+
return;
|
|
1478
|
+
const invocation = this.getWorkflowInvocation(workItem.invocationId);
|
|
1479
|
+
if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
|
|
1480
|
+
return;
|
|
1481
|
+
const loop = this.getLoop(args.loopId);
|
|
1482
|
+
if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
|
|
1483
|
+
return;
|
|
1484
|
+
const input = loop.target.input ?? {};
|
|
1485
|
+
if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
|
|
1486
|
+
return;
|
|
1487
|
+
if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
|
|
1488
|
+
return;
|
|
1489
|
+
if (workItem.workflowId && workItem.workflowId !== args.workflowId)
|
|
1490
|
+
return;
|
|
1491
|
+
const workflow = this.getWorkflow(args.workflowId);
|
|
1492
|
+
if (!workflow)
|
|
1493
|
+
return;
|
|
1494
|
+
return { workflow, loop, workItem, invocation };
|
|
1495
|
+
}
|
|
1496
|
+
maybeArchiveGeneratedRouteWorkflow(args) {
|
|
1497
|
+
const context = this.generatedRouteArchiveContext(args);
|
|
1498
|
+
if (!context)
|
|
1499
|
+
return;
|
|
1500
|
+
const { workflow, loop, workItem } = context;
|
|
1501
|
+
if (!workflow || workflow.status !== "active")
|
|
1502
|
+
return;
|
|
1503
|
+
const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
|
|
1504
|
+
WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
|
|
1505
|
+
if (nonTerminal > 0)
|
|
1506
|
+
return;
|
|
1507
|
+
const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
|
|
1508
|
+
if (res.changes === 1 && args.workflowRunId) {
|
|
1509
|
+
this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
|
|
1510
|
+
workflowId: args.workflowId,
|
|
1511
|
+
loopId: loop.id,
|
|
1512
|
+
workItemId: workItem.id,
|
|
1513
|
+
routeKey: workItem.routeKey,
|
|
1514
|
+
reason: "terminal generated one-shot route workflow"
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
|
|
1519
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
1520
|
+
if (!run)
|
|
1521
|
+
return;
|
|
1522
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
1523
|
+
workflowId: run.workflowId,
|
|
1524
|
+
loopId: run.loopId,
|
|
1525
|
+
workItemId: run.workItemId,
|
|
1526
|
+
workflowRunId,
|
|
1527
|
+
updated
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1457
1530
|
createWorkflowInvocation(input) {
|
|
1458
1531
|
const now = nowIso();
|
|
1459
1532
|
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
@@ -2239,6 +2312,7 @@ class Store {
|
|
|
2239
2312
|
if (changed) {
|
|
2240
2313
|
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2241
2314
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2315
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
|
|
2242
2316
|
}
|
|
2243
2317
|
this.db.exec("COMMIT");
|
|
2244
2318
|
} catch (error) {
|
|
@@ -2266,6 +2340,7 @@ class Store {
|
|
|
2266
2340
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2267
2341
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
2268
2342
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
2343
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
|
|
2269
2344
|
}
|
|
2270
2345
|
this.db.exec("COMMIT");
|
|
2271
2346
|
return this.requireWorkflowRun(workflowRunId);
|
|
@@ -2518,8 +2593,20 @@ class Store {
|
|
|
2518
2593
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2519
2594
|
if (opts.claimedBy && res.changes !== 1)
|
|
2520
2595
|
return run;
|
|
2521
|
-
if (res.changes === 1)
|
|
2596
|
+
if (res.changes === 1) {
|
|
2522
2597
|
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2598
|
+
const loop = this.getLoop(run.loopId);
|
|
2599
|
+
const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
2600
|
+
if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
|
|
2601
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2602
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2603
|
+
workflowId: loop.target.workflowId,
|
|
2604
|
+
loopId: loop.id,
|
|
2605
|
+
workItemId,
|
|
2606
|
+
updated: finishedAt
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2523
2610
|
return run;
|
|
2524
2611
|
}
|
|
2525
2612
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2640,6 +2727,7 @@ class Store {
|
|
|
2640
2727
|
loopRunId: row.id
|
|
2641
2728
|
});
|
|
2642
2729
|
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2730
|
+
this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
|
|
2643
2731
|
}
|
|
2644
2732
|
const loop = this.getLoop(row.loop_id);
|
|
2645
2733
|
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
@@ -2647,6 +2735,15 @@ class Store {
|
|
|
2647
2735
|
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2648
2736
|
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2649
2737
|
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2738
|
+
if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
|
|
2739
|
+
const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
|
|
2740
|
+
this.maybeArchiveGeneratedRouteWorkflow({
|
|
2741
|
+
workflowId: loop.target.workflowId,
|
|
2742
|
+
loopId: loop.id,
|
|
2743
|
+
workItemId,
|
|
2744
|
+
updated: finished
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2650
2747
|
}
|
|
2651
2748
|
this.db.exec("COMMIT");
|
|
2652
2749
|
} catch (error) {
|
package/docs/USAGE.md
CHANGED
|
@@ -367,13 +367,13 @@ loops events drain todos-task \
|
|
|
367
367
|
--todos-project "$HOME/.hasna/loops" \
|
|
368
368
|
--task-list repoops-pr-queue \
|
|
369
369
|
--tags auto:route \
|
|
370
|
-
--project-path-prefix
|
|
370
|
+
--project-path-prefix /home/hasna/workspace/hasna/opensource \
|
|
371
371
|
--provider codewith \
|
|
372
372
|
--auth-profile-pool account004,account005,account006 \
|
|
373
373
|
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
|
|
374
374
|
--project-group oss \
|
|
375
375
|
--max-dispatch 2 \
|
|
376
|
-
--scan-limit
|
|
376
|
+
--scan-limit 5000 \
|
|
377
377
|
--max-active-per-project 1 \
|
|
378
378
|
--max-active-per-project-group 4 \
|
|
379
379
|
--max-active 12 \
|
|
@@ -394,6 +394,45 @@ when requested, and leaves excess ready tasks in todos for a later drain pass.
|
|
|
394
394
|
Use `--dry-run` to preview candidates and rendered workflows without mutating
|
|
395
395
|
OpenLoops state.
|
|
396
396
|
|
|
397
|
+
For the Hasna OSS task-created route, keep the drain deterministic and narrow:
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
loops routes schedule todos-task oss-task-route-drain \
|
|
401
|
+
--every 5m \
|
|
402
|
+
--todos-project "$HOME/.hasna/loops" \
|
|
403
|
+
--project-path-prefix /home/hasna/workspace/hasna/opensource \
|
|
404
|
+
--tags auto:route \
|
|
405
|
+
--provider codewith \
|
|
406
|
+
--auth-profile-pool account004,account005,account006 \
|
|
407
|
+
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
|
|
408
|
+
--project-group oss \
|
|
409
|
+
--max-dispatch 2 \
|
|
410
|
+
--scan-limit 5000 \
|
|
411
|
+
--max-active-per-project 1 \
|
|
412
|
+
--max-active-per-project-group 4 \
|
|
413
|
+
--max-active 12 \
|
|
414
|
+
--worktree-mode required \
|
|
415
|
+
--evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
|
|
416
|
+
--compact
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Only tasks under `/home/hasna/workspace/hasna/opensource` that explicitly opt
|
|
420
|
+
in with the `auto:route` tag, `route_enabled=true`, or
|
|
421
|
+
`automation.allowed=true` should be routed. Keep repo-mutating worker/verifier
|
|
422
|
+
runs on a Codewith account pool with `--worktree-mode required`. Do not dispatch
|
|
423
|
+
or paste task prompts into tmux panes. Use max-active throttles and
|
|
424
|
+
`--max-dispatch` to bound agent fan-out, and write compact evidence into a
|
|
425
|
+
bounded reports directory so operators can audit each drain without unbounded
|
|
426
|
+
stdout or loop history growth. Keep `--scan-limit` large enough for the current
|
|
427
|
+
ready-task backlog; if the scan is exhausted before matching tasks appear, the
|
|
428
|
+
drain will correctly do no work.
|
|
429
|
+
|
|
430
|
+
Generated task/event route workflow specs are lifecycle-managed. After a
|
|
431
|
+
generated one-shot route workflow run reaches `succeeded`, `failed`,
|
|
432
|
+
`timed_out`, or `cancelled`, OpenLoops archives the generated workflow spec while
|
|
433
|
+
preserving workflow run, step, event, manifest, loop, invocation, and work-item
|
|
434
|
+
history.
|
|
435
|
+
|
|
397
436
|
For other Hasna apps that expose `@hasna/events` webhooks, use the generic
|
|
398
437
|
handler:
|
|
399
438
|
|
package/package.json
CHANGED