@hasna/loops 0.3.48 → 0.3.50
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 +106 -0
- package/dist/cli/index.js +606 -36
- package/dist/daemon/index.js +101 -4
- package/dist/index.js +428 -13
- package/dist/lib/store.d.ts +7 -0
- package/dist/lib/store.js +100 -3
- package/dist/lib/templates.d.ts +24 -4
- package/dist/sdk/index.js +100 -3
- package/dist/types.d.ts +5 -0
- package/docs/USAGE.md +114 -2
- package/package.json +1 -1
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/lib/templates.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSummary } from "../types.js";
|
|
1
|
+
import type { AccountRef, AgentPermissionMode, AgentProvider, AgentSandbox, AgentWorktreeMode, CreateWorkflowInput, LoopTemplateSource, LoopTemplateSummary } from "../types.js";
|
|
2
2
|
export declare const TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
3
3
|
export declare const EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
4
4
|
export declare const BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
@@ -97,9 +97,29 @@ export interface BoundedAgentWorkflowTemplateInput {
|
|
|
97
97
|
worktreeBranchPrefix?: string;
|
|
98
98
|
timeoutMs?: number;
|
|
99
99
|
}
|
|
100
|
-
export
|
|
101
|
-
export
|
|
100
|
+
export type LoopTemplateSourceFilter = LoopTemplateSource | "all";
|
|
101
|
+
export interface ListLoopTemplatesOptions {
|
|
102
|
+
source?: LoopTemplateSourceFilter;
|
|
103
|
+
}
|
|
104
|
+
export interface CustomLoopTemplateImportOptions {
|
|
105
|
+
replace?: boolean;
|
|
106
|
+
}
|
|
107
|
+
export interface CustomLoopTemplateImportResult {
|
|
108
|
+
template: LoopTemplateSummary;
|
|
109
|
+
path: string;
|
|
110
|
+
replaced: boolean;
|
|
111
|
+
}
|
|
112
|
+
export declare function loopTemplatesDir(): string;
|
|
113
|
+
export declare function validateCustomLoopTemplateFile(file: string): LoopTemplateSummary;
|
|
114
|
+
export declare function importCustomLoopTemplate(file: string, opts?: CustomLoopTemplateImportOptions): CustomLoopTemplateImportResult;
|
|
115
|
+
export declare function validateLoopTemplateRegistry(opts?: ListLoopTemplatesOptions): {
|
|
116
|
+
ok: true;
|
|
117
|
+
templates: LoopTemplateSummary[];
|
|
118
|
+
customDir: string;
|
|
119
|
+
};
|
|
120
|
+
export declare function listLoopTemplates(opts?: ListLoopTemplatesOptions): LoopTemplateSummary[];
|
|
121
|
+
export declare function getLoopTemplate(id: string, opts?: ListLoopTemplatesOptions): LoopTemplateSummary | undefined;
|
|
102
122
|
export declare function renderTodosTaskWorkerVerifierWorkflow(input: TodosTaskWorkflowTemplateInput): CreateWorkflowInput;
|
|
103
123
|
export declare function renderEventWorkerVerifierWorkflow(input: EventWorkflowTemplateInput): CreateWorkflowInput;
|
|
104
124
|
export declare function renderBoundedAgentWorkerVerifierWorkflow(input: BoundedAgentWorkflowTemplateInput): CreateWorkflowInput;
|
|
105
|
-
export declare function renderLoopTemplate(id: string, values: Record<string, string | undefined
|
|
125
|
+
export declare function renderLoopTemplate(id: string, values: Record<string, string | undefined>, opts?: ListLoopTemplatesOptions): CreateWorkflowInput;
|
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/dist/types.d.ts
CHANGED
|
@@ -252,11 +252,14 @@ export interface CreateWorkflowInput {
|
|
|
252
252
|
version?: number;
|
|
253
253
|
}
|
|
254
254
|
export type LoopTemplateKind = "workflow" | "loop";
|
|
255
|
+
export type LoopTemplateSource = "builtin" | "custom";
|
|
256
|
+
export type LoopTemplateVariableType = "string" | "number" | "boolean" | "json" | "string[]";
|
|
255
257
|
export interface LoopTemplateVariable {
|
|
256
258
|
name: string;
|
|
257
259
|
description?: string;
|
|
258
260
|
required?: boolean;
|
|
259
261
|
default?: string;
|
|
262
|
+
type?: LoopTemplateVariableType;
|
|
260
263
|
}
|
|
261
264
|
export interface LoopTemplateSummary {
|
|
262
265
|
id: string;
|
|
@@ -264,6 +267,8 @@ export interface LoopTemplateSummary {
|
|
|
264
267
|
description: string;
|
|
265
268
|
kind: LoopTemplateKind;
|
|
266
269
|
variables: LoopTemplateVariable[];
|
|
270
|
+
source?: LoopTemplateSource;
|
|
271
|
+
sourcePath?: string;
|
|
267
272
|
}
|
|
268
273
|
export interface WorkflowRun {
|
|
269
274
|
id: string;
|
package/docs/USAGE.md
CHANGED
|
@@ -254,6 +254,65 @@ loops templates render deterministic-check-create-task \
|
|
|
254
254
|
--var checkCommand='your deterministic check and todos upsert command'
|
|
255
255
|
```
|
|
256
256
|
|
|
257
|
+
Custom reusable workflow templates live under the OpenLoops app data directory:
|
|
258
|
+
`~/.hasna/loops/templates` by default, or `$LOOPS_DATA_DIR/templates` when
|
|
259
|
+
`LOOPS_DATA_DIR` is set. Store templates as declarative JSON files; listing,
|
|
260
|
+
showing, and rendering templates never executes workflow steps or mutates the
|
|
261
|
+
registry.
|
|
262
|
+
|
|
263
|
+
```json
|
|
264
|
+
{
|
|
265
|
+
"id": "custom-report",
|
|
266
|
+
"name": "Custom Report",
|
|
267
|
+
"description": "Run a custom report workflow from the local template registry.",
|
|
268
|
+
"kind": "workflow",
|
|
269
|
+
"variables": [
|
|
270
|
+
{ "name": "objective", "required": true, "description": "Report objective." },
|
|
271
|
+
{ "name": "projectPath", "required": true, "description": "Working directory." },
|
|
272
|
+
{ "name": "timeoutMs", "default": "300000", "type": "number" }
|
|
273
|
+
],
|
|
274
|
+
"workflow": {
|
|
275
|
+
"name": "custom-report-${objective}",
|
|
276
|
+
"steps": [
|
|
277
|
+
{
|
|
278
|
+
"id": "worker",
|
|
279
|
+
"target": {
|
|
280
|
+
"type": "agent",
|
|
281
|
+
"provider": "codewith",
|
|
282
|
+
"prompt": "/goal ${objective}\nProduce the requested report only.",
|
|
283
|
+
"cwd": "${projectPath}",
|
|
284
|
+
"configIsolation": "safe",
|
|
285
|
+
"permissionMode": "bypass",
|
|
286
|
+
"sandbox": "workspace-write",
|
|
287
|
+
"timeoutMs": "${timeoutMs}"
|
|
288
|
+
},
|
|
289
|
+
"timeoutMs": "${timeoutMs}"
|
|
290
|
+
}
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
loops templates validate ./custom-report.json
|
|
298
|
+
loops templates import ./custom-report.json
|
|
299
|
+
loops templates list --source custom
|
|
300
|
+
loops templates show custom-report
|
|
301
|
+
loops templates render custom-report \
|
|
302
|
+
--var objective="Check docs drift" \
|
|
303
|
+
--var projectPath=/path/to/repo
|
|
304
|
+
loops templates create-workflow custom-report \
|
|
305
|
+
--var objective="Check docs drift" \
|
|
306
|
+
--var projectPath=/path/to/repo
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Use `--source builtin`, `--source custom`, or `--source all` on
|
|
310
|
+
`list`, `show`, `render`, and `create-workflow` when automation needs an
|
|
311
|
+
explicit source. Custom template ids and names cannot override built-ins.
|
|
312
|
+
Custom templates fail closed for `danger-full-access`; use built-in templates
|
|
313
|
+
with explicit break-glass handling for emergency workflows that need that
|
|
314
|
+
sandbox.
|
|
315
|
+
|
|
257
316
|
Repo-mutating task/event routes should set `worktreeMode=required` so the
|
|
258
317
|
workflow fails fast instead of falling back to the main checkout. When
|
|
259
318
|
`projectPath` is an existing git repository, OpenLoops inserts a
|
|
@@ -367,13 +426,13 @@ loops events drain todos-task \
|
|
|
367
426
|
--todos-project "$HOME/.hasna/loops" \
|
|
368
427
|
--task-list repoops-pr-queue \
|
|
369
428
|
--tags auto:route \
|
|
370
|
-
--project-path-prefix
|
|
429
|
+
--project-path-prefix /home/hasna/workspace/hasna/opensource \
|
|
371
430
|
--provider codewith \
|
|
372
431
|
--auth-profile-pool account004,account005,account006 \
|
|
373
432
|
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
|
|
374
433
|
--project-group oss \
|
|
375
434
|
--max-dispatch 2 \
|
|
376
|
-
--scan-limit
|
|
435
|
+
--scan-limit 5000 \
|
|
377
436
|
--max-active-per-project 1 \
|
|
378
437
|
--max-active-per-project-group 4 \
|
|
379
438
|
--max-active 12 \
|
|
@@ -394,6 +453,45 @@ when requested, and leaves excess ready tasks in todos for a later drain pass.
|
|
|
394
453
|
Use `--dry-run` to preview candidates and rendered workflows without mutating
|
|
395
454
|
OpenLoops state.
|
|
396
455
|
|
|
456
|
+
For the Hasna OSS task-created route, keep the drain deterministic and narrow:
|
|
457
|
+
|
|
458
|
+
```bash
|
|
459
|
+
loops routes schedule todos-task oss-task-route-drain \
|
|
460
|
+
--every 5m \
|
|
461
|
+
--todos-project "$HOME/.hasna/loops" \
|
|
462
|
+
--project-path-prefix /home/hasna/workspace/hasna/opensource \
|
|
463
|
+
--tags auto:route \
|
|
464
|
+
--provider codewith \
|
|
465
|
+
--auth-profile-pool account004,account005,account006 \
|
|
466
|
+
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
|
|
467
|
+
--project-group oss \
|
|
468
|
+
--max-dispatch 2 \
|
|
469
|
+
--scan-limit 5000 \
|
|
470
|
+
--max-active-per-project 1 \
|
|
471
|
+
--max-active-per-project-group 4 \
|
|
472
|
+
--max-active 12 \
|
|
473
|
+
--worktree-mode required \
|
|
474
|
+
--evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
|
|
475
|
+
--compact
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Only tasks under `/home/hasna/workspace/hasna/opensource` that explicitly opt
|
|
479
|
+
in with the `auto:route` tag, `route_enabled=true`, or
|
|
480
|
+
`automation.allowed=true` should be routed. Keep repo-mutating worker/verifier
|
|
481
|
+
runs on a Codewith account pool with `--worktree-mode required`. Do not dispatch
|
|
482
|
+
or paste task prompts into tmux panes. Use max-active throttles and
|
|
483
|
+
`--max-dispatch` to bound agent fan-out, and write compact evidence into a
|
|
484
|
+
bounded reports directory so operators can audit each drain without unbounded
|
|
485
|
+
stdout or loop history growth. Keep `--scan-limit` large enough for the current
|
|
486
|
+
ready-task backlog; if the scan is exhausted before matching tasks appear, the
|
|
487
|
+
drain will correctly do no work.
|
|
488
|
+
|
|
489
|
+
Generated task/event route workflow specs are lifecycle-managed. After a
|
|
490
|
+
generated one-shot route workflow run reaches `succeeded`, `failed`,
|
|
491
|
+
`timed_out`, or `cancelled`, OpenLoops archives the generated workflow spec while
|
|
492
|
+
preserving workflow run, step, event, manifest, loop, invocation, and work-item
|
|
493
|
+
history.
|
|
494
|
+
|
|
397
495
|
For other Hasna apps that expose `@hasna/events` webhooks, use the generic
|
|
398
496
|
handler:
|
|
399
497
|
|
|
@@ -515,6 +613,20 @@ production loop. Route commands store a small cursor in
|
|
|
515
613
|
through all findings over repeated scheduled runs instead of reprocessing only
|
|
516
614
|
the first batch.
|
|
517
615
|
|
|
616
|
+
For a deliberate operator rename, use the first-class rename command instead of
|
|
617
|
+
turning a one-off naming preference into hygiene policy:
|
|
618
|
+
|
|
619
|
+
```bash
|
|
620
|
+
loops rename machine-todos-drain-oss-repos-strict-5m machine-todos-drain-oss-repos
|
|
621
|
+
loops --json rename <loop-id> machine-ops-loop-health-route-tasks
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
`rename` preserves the loop id, schedule, run history, archive state, and target
|
|
625
|
+
configuration. It rejects empty or duplicate names and writes a SQLite backup
|
|
626
|
+
under `<LOOPS_DATA_DIR>/backups` before mutating the loop row. Human-facing
|
|
627
|
+
names should describe scope and responsibility; cadence belongs in schedule
|
|
628
|
+
metadata and operator tables.
|
|
629
|
+
|
|
518
630
|
Archive loops when retiring old automation but preserving history:
|
|
519
631
|
|
|
520
632
|
```bash
|
package/package.json
CHANGED