@hasna/loops 0.3.38 → 0.3.40
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 +95 -14
- package/dist/cli/index.js +1491 -176
- package/dist/daemon/index.js +499 -43
- package/dist/index.d.ts +1 -1
- package/dist/index.js +823 -53
- package/dist/lib/format.d.ts +3 -1
- package/dist/lib/run-artifacts.d.ts +15 -0
- package/dist/lib/store.d.ts +34 -1
- package/dist/lib/store.js +424 -8
- package/dist/lib/templates.d.ts +10 -0
- package/dist/sdk/index.d.ts +2 -1
- package/dist/sdk/index.js +529 -33
- package/dist/types.d.ts +109 -0
- package/docs/USAGE.md +57 -20
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
// src/lib/store.ts
|
|
5
5
|
import { Database } from "bun:sqlite";
|
|
6
|
-
import { mkdirSync as
|
|
7
|
-
import {
|
|
6
|
+
import { mkdirSync as mkdirSync3, mkdtempSync, rmSync } from "fs";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { dirname, join as join3 } from "path";
|
|
8
9
|
|
|
9
10
|
// src/lib/ids.ts
|
|
10
11
|
import { randomBytes } from "crypto";
|
|
@@ -365,6 +366,8 @@ function validateTarget(value, label) {
|
|
|
365
366
|
assertObject(value, label);
|
|
366
367
|
if (value.type === "command") {
|
|
367
368
|
assertString(value.command, `${label}.command`);
|
|
369
|
+
optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
|
|
370
|
+
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
368
371
|
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
369
372
|
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
370
373
|
}
|
|
@@ -376,6 +379,8 @@ function validateTarget(value, label) {
|
|
|
376
379
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
377
380
|
if (!providers.includes(value.provider))
|
|
378
381
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
382
|
+
optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
|
|
383
|
+
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
379
384
|
if (value.authProfile !== undefined) {
|
|
380
385
|
assertString(value.authProfile, `${label}.authProfile`);
|
|
381
386
|
if (value.provider !== "codewith")
|
|
@@ -530,6 +535,52 @@ function workflowBodyFromJson(value, fallbackName) {
|
|
|
530
535
|
});
|
|
531
536
|
}
|
|
532
537
|
|
|
538
|
+
// src/lib/run-artifacts.ts
|
|
539
|
+
import { createHash } from "crypto";
|
|
540
|
+
import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
|
|
541
|
+
import { basename, join as join2 } from "path";
|
|
542
|
+
function shortHash(value) {
|
|
543
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
544
|
+
}
|
|
545
|
+
function safeRunPathSlug(value, fallback) {
|
|
546
|
+
const raw = value?.trim() || fallback;
|
|
547
|
+
const slug = raw.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72);
|
|
548
|
+
return slug || fallback;
|
|
549
|
+
}
|
|
550
|
+
function workflowRunSubjectKey(kind, rawSubjectRef) {
|
|
551
|
+
const raw = rawSubjectRef?.trim() || "subject";
|
|
552
|
+
const kindSlug = safeRunPathSlug(kind, "subject").slice(0, 24);
|
|
553
|
+
const subjectSlug = safeRunPathSlug(raw, "subject").slice(0, 48);
|
|
554
|
+
return `${kindSlug}-${subjectSlug}-${shortHash(`${kindSlug}
|
|
555
|
+
${raw}`)}`;
|
|
556
|
+
}
|
|
557
|
+
function workflowRunProjectSlug(projectKey) {
|
|
558
|
+
if (!projectKey?.trim())
|
|
559
|
+
return "global";
|
|
560
|
+
return safeRunPathSlug(projectKey.startsWith("/") ? basename(projectKey) : projectKey, "project");
|
|
561
|
+
}
|
|
562
|
+
function writeWorkflowRunManifest(args) {
|
|
563
|
+
const projectSlug = workflowRunProjectSlug(args.projectKey);
|
|
564
|
+
const subjectKey = workflowRunSubjectKey(args.subjectKind, args.rawSubjectRef);
|
|
565
|
+
const dir = join2(args.loopsDataDir, "runs", projectSlug, subjectKey, args.workflowRunId);
|
|
566
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
567
|
+
const manifestPath = join2(dir, "manifest.json");
|
|
568
|
+
writeFileSync(manifestPath, JSON.stringify({
|
|
569
|
+
version: 1,
|
|
570
|
+
workflowRunId: args.workflowRunId,
|
|
571
|
+
workflowId: args.workflowId,
|
|
572
|
+
workflowName: args.workflowName,
|
|
573
|
+
invocationId: args.invocationId,
|
|
574
|
+
workItemId: args.workItemId,
|
|
575
|
+
projectSlug,
|
|
576
|
+
subjectKey,
|
|
577
|
+
requiredReading: [],
|
|
578
|
+
createdAt: new Date().toISOString(),
|
|
579
|
+
...args.payload
|
|
580
|
+
}, null, 2), { mode: 384 });
|
|
581
|
+
return manifestPath;
|
|
582
|
+
}
|
|
583
|
+
|
|
533
584
|
// src/lib/store.ts
|
|
534
585
|
function rowToLoop(row) {
|
|
535
586
|
return {
|
|
@@ -599,8 +650,11 @@ function rowToWorkflowRun(row) {
|
|
|
599
650
|
workflowName: row.workflow_name,
|
|
600
651
|
loopId: row.loop_id ?? undefined,
|
|
601
652
|
loopRunId: row.loop_run_id ?? undefined,
|
|
653
|
+
invocationId: row.invocation_id ?? undefined,
|
|
654
|
+
workItemId: row.work_item_id ?? undefined,
|
|
602
655
|
scheduledFor: row.scheduled_for ?? undefined,
|
|
603
656
|
idempotencyKey: row.idempotency_key ?? undefined,
|
|
657
|
+
manifestPath: row.manifest_path ?? undefined,
|
|
604
658
|
status: row.status,
|
|
605
659
|
startedAt: row.started_at ?? undefined,
|
|
606
660
|
finishedAt: row.finished_at ?? undefined,
|
|
@@ -611,6 +665,44 @@ function rowToWorkflowRun(row) {
|
|
|
611
665
|
updatedAt: row.updated_at
|
|
612
666
|
};
|
|
613
667
|
}
|
|
668
|
+
function rowToWorkflowInvocation(row) {
|
|
669
|
+
return {
|
|
670
|
+
id: row.id,
|
|
671
|
+
workflowId: row.workflow_id ?? undefined,
|
|
672
|
+
templateId: row.template_id ?? undefined,
|
|
673
|
+
sourceRef: JSON.parse(row.source_json),
|
|
674
|
+
subjectRef: JSON.parse(row.subject_json),
|
|
675
|
+
intent: row.intent,
|
|
676
|
+
scope: row.scope_json ? JSON.parse(row.scope_json) : undefined,
|
|
677
|
+
outputPolicy: row.output_policy_json ? JSON.parse(row.output_policy_json) : undefined,
|
|
678
|
+
createdAt: row.created_at,
|
|
679
|
+
updatedAt: row.updated_at
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function rowToWorkflowWorkItem(row) {
|
|
683
|
+
return {
|
|
684
|
+
id: row.id,
|
|
685
|
+
routeKey: row.route_key,
|
|
686
|
+
idempotencyKey: row.idempotency_key,
|
|
687
|
+
invocationId: row.invocation_id,
|
|
688
|
+
sourceType: row.source_type,
|
|
689
|
+
sourceRef: row.source_ref,
|
|
690
|
+
subjectRef: row.subject_ref,
|
|
691
|
+
projectKey: row.project_key ?? undefined,
|
|
692
|
+
projectGroup: row.project_group ?? undefined,
|
|
693
|
+
priority: row.priority,
|
|
694
|
+
status: row.status,
|
|
695
|
+
attempts: row.attempts,
|
|
696
|
+
nextAttemptAt: row.next_attempt_at ?? undefined,
|
|
697
|
+
leaseExpiresAt: row.lease_expires_at ?? undefined,
|
|
698
|
+
workflowId: row.workflow_id ?? undefined,
|
|
699
|
+
loopId: row.loop_id ?? undefined,
|
|
700
|
+
workflowRunId: row.workflow_run_id ?? undefined,
|
|
701
|
+
lastReason: row.last_reason ?? undefined,
|
|
702
|
+
createdAt: row.created_at,
|
|
703
|
+
updatedAt: row.updated_at
|
|
704
|
+
};
|
|
705
|
+
}
|
|
614
706
|
function rowToWorkflowStepRun(row) {
|
|
615
707
|
return {
|
|
616
708
|
id: row.id,
|
|
@@ -724,13 +816,23 @@ function rowToLease(row) {
|
|
|
724
816
|
updatedAt: row.updated_at
|
|
725
817
|
};
|
|
726
818
|
}
|
|
819
|
+
function workItemStatusForLoopRun(status, attempt, maxAttempts) {
|
|
820
|
+
if (status === "succeeded")
|
|
821
|
+
return "succeeded";
|
|
822
|
+
if (["failed", "timed_out", "abandoned"].includes(status)) {
|
|
823
|
+
return maxAttempts !== undefined && attempt < maxAttempts ? "admitted" : "failed";
|
|
824
|
+
}
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
727
827
|
|
|
728
828
|
class Store {
|
|
729
829
|
db;
|
|
830
|
+
rootDir;
|
|
730
831
|
constructor(path) {
|
|
731
832
|
const file = path ?? dbPath();
|
|
732
833
|
if (file !== ":memory:")
|
|
733
|
-
|
|
834
|
+
mkdirSync3(dirname(file), { recursive: true, mode: 448 });
|
|
835
|
+
this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
|
|
734
836
|
this.db = new Database(file);
|
|
735
837
|
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
736
838
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
@@ -825,8 +927,11 @@ class Store {
|
|
|
825
927
|
workflow_name TEXT NOT NULL,
|
|
826
928
|
loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
|
|
827
929
|
loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
|
|
930
|
+
invocation_id TEXT,
|
|
931
|
+
work_item_id TEXT,
|
|
828
932
|
scheduled_for TEXT,
|
|
829
933
|
idempotency_key TEXT,
|
|
934
|
+
manifest_path TEXT,
|
|
830
935
|
status TEXT NOT NULL,
|
|
831
936
|
started_at TEXT,
|
|
832
937
|
finished_at TEXT,
|
|
@@ -843,6 +948,59 @@ class Store {
|
|
|
843
948
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
|
|
844
949
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
|
845
950
|
|
|
951
|
+
CREATE TABLE IF NOT EXISTS workflow_invocations (
|
|
952
|
+
id TEXT PRIMARY KEY,
|
|
953
|
+
workflow_id TEXT,
|
|
954
|
+
template_id TEXT,
|
|
955
|
+
source_kind TEXT NOT NULL,
|
|
956
|
+
source_id TEXT,
|
|
957
|
+
source_dedupe_key TEXT,
|
|
958
|
+
source_json TEXT NOT NULL,
|
|
959
|
+
subject_kind TEXT NOT NULL,
|
|
960
|
+
subject_id TEXT,
|
|
961
|
+
subject_path TEXT,
|
|
962
|
+
subject_url TEXT,
|
|
963
|
+
subject_json TEXT NOT NULL,
|
|
964
|
+
intent TEXT NOT NULL,
|
|
965
|
+
scope_json TEXT,
|
|
966
|
+
output_policy_json TEXT,
|
|
967
|
+
created_at TEXT NOT NULL,
|
|
968
|
+
updated_at TEXT NOT NULL
|
|
969
|
+
);
|
|
970
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
|
|
971
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
|
|
972
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe
|
|
973
|
+
ON workflow_invocations(source_kind, source_dedupe_key)
|
|
974
|
+
WHERE source_dedupe_key IS NOT NULL;
|
|
975
|
+
|
|
976
|
+
CREATE TABLE IF NOT EXISTS workflow_work_items (
|
|
977
|
+
id TEXT PRIMARY KEY,
|
|
978
|
+
route_key TEXT NOT NULL,
|
|
979
|
+
idempotency_key TEXT NOT NULL,
|
|
980
|
+
invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
|
|
981
|
+
source_type TEXT NOT NULL,
|
|
982
|
+
source_ref TEXT NOT NULL,
|
|
983
|
+
subject_ref TEXT NOT NULL,
|
|
984
|
+
project_key TEXT,
|
|
985
|
+
project_group TEXT,
|
|
986
|
+
priority INTEGER NOT NULL,
|
|
987
|
+
status TEXT NOT NULL,
|
|
988
|
+
attempts INTEGER NOT NULL,
|
|
989
|
+
next_attempt_at TEXT,
|
|
990
|
+
lease_expires_at TEXT,
|
|
991
|
+
workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
|
|
992
|
+
loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
|
|
993
|
+
workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
|
|
994
|
+
last_reason TEXT,
|
|
995
|
+
created_at TEXT NOT NULL,
|
|
996
|
+
updated_at TEXT NOT NULL,
|
|
997
|
+
UNIQUE(route_key, idempotency_key)
|
|
998
|
+
);
|
|
999
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
|
|
1000
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
|
|
1001
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
|
|
1002
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
|
|
1003
|
+
|
|
846
1004
|
CREATE TABLE IF NOT EXISTS workflow_step_runs (
|
|
847
1005
|
id TEXT PRIMARY KEY,
|
|
848
1006
|
workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
@@ -955,12 +1113,17 @@ class Store {
|
|
|
955
1113
|
this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
|
|
956
1114
|
this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
|
|
957
1115
|
this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
|
|
1116
|
+
this.addColumnIfMissing("workflow_runs", "invocation_id", "TEXT");
|
|
1117
|
+
this.addColumnIfMissing("workflow_runs", "work_item_id", "TEXT");
|
|
1118
|
+
this.addColumnIfMissing("workflow_runs", "manifest_path", "TEXT");
|
|
958
1119
|
this.addColumnIfMissing("workflow_step_runs", "pid", "INTEGER");
|
|
959
1120
|
this.addColumnIfMissing("workflow_step_runs", "goal_run_id", "TEXT");
|
|
1121
|
+
this.createWorkflowRunBackfillIndexes();
|
|
960
1122
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
961
1123
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
|
|
962
1124
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
|
|
963
1125
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
|
|
1126
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0005_workflow_invocations_and_admission", nowIso());
|
|
964
1127
|
}
|
|
965
1128
|
addColumnIfMissing(table, column, definition) {
|
|
966
1129
|
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
@@ -968,6 +1131,12 @@ class Store {
|
|
|
968
1131
|
return;
|
|
969
1132
|
this.db.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
|
|
970
1133
|
}
|
|
1134
|
+
createWorkflowRunBackfillIndexes() {
|
|
1135
|
+
this.db.exec(`
|
|
1136
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
|
|
1137
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
|
|
1138
|
+
`);
|
|
1139
|
+
}
|
|
971
1140
|
assertDaemonLeaseFence(opts = {}, now = nowIso()) {
|
|
972
1141
|
if (!opts.daemonLeaseId)
|
|
973
1142
|
return;
|
|
@@ -1083,6 +1252,10 @@ class Store {
|
|
|
1083
1252
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1084
1253
|
$now: updated
|
|
1085
1254
|
});
|
|
1255
|
+
if (patch.status && patch.status !== "active") {
|
|
1256
|
+
const status = patch.status === "paused" ? "deferred" : "cancelled";
|
|
1257
|
+
this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
|
|
1258
|
+
}
|
|
1086
1259
|
const after = this.getLoop(id);
|
|
1087
1260
|
if (!after)
|
|
1088
1261
|
throw new Error(`loop not found after update: ${id}`);
|
|
@@ -1127,6 +1300,7 @@ class Store {
|
|
|
1127
1300
|
$archivedFromStatus: loop.status,
|
|
1128
1301
|
$updated: updated
|
|
1129
1302
|
});
|
|
1303
|
+
this.setWorkflowWorkItemsForLoop(loop.id, "deferred", "loop archived", updated);
|
|
1130
1304
|
const archived = this.getLoop(loop.id);
|
|
1131
1305
|
if (!archived)
|
|
1132
1306
|
throw new Error(`loop not found after archive: ${loop.id}`);
|
|
@@ -1152,6 +1326,7 @@ class Store {
|
|
|
1152
1326
|
}
|
|
1153
1327
|
deleteLoop(idOrName) {
|
|
1154
1328
|
const loop = this.requireLoop(idOrName);
|
|
1329
|
+
this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
|
|
1155
1330
|
const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
|
|
1156
1331
|
return res.changes > 0;
|
|
1157
1332
|
}
|
|
@@ -1210,6 +1385,185 @@ class Store {
|
|
|
1210
1385
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1211
1386
|
return archived;
|
|
1212
1387
|
}
|
|
1388
|
+
createWorkflowInvocation(input) {
|
|
1389
|
+
const now = nowIso();
|
|
1390
|
+
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
1391
|
+
if (sourceDedupeKey) {
|
|
1392
|
+
const existing = this.db.query("SELECT * FROM workflow_invocations WHERE source_kind = ? AND source_dedupe_key = ? LIMIT 1").get(input.sourceRef.kind, sourceDedupeKey);
|
|
1393
|
+
if (existing)
|
|
1394
|
+
return rowToWorkflowInvocation(existing);
|
|
1395
|
+
}
|
|
1396
|
+
const id = input.id ?? genId();
|
|
1397
|
+
this.db.query(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
|
|
1398
|
+
source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
|
|
1399
|
+
output_policy_json, created_at, updated_at)
|
|
1400
|
+
VALUES ($id, $workflowId, $templateId, $sourceKind, $sourceId, $sourceDedupeKey, $sourceJson,
|
|
1401
|
+
$subjectKind, $subjectId, $subjectPath, $subjectUrl, $subjectJson, $intent, $scopeJson,
|
|
1402
|
+
$outputPolicyJson, $created, $updated)`).run({
|
|
1403
|
+
$id: id,
|
|
1404
|
+
$workflowId: input.workflowId ?? null,
|
|
1405
|
+
$templateId: input.templateId ?? null,
|
|
1406
|
+
$sourceKind: input.sourceRef.kind,
|
|
1407
|
+
$sourceId: input.sourceRef.id ?? null,
|
|
1408
|
+
$sourceDedupeKey: sourceDedupeKey ?? null,
|
|
1409
|
+
$sourceJson: JSON.stringify(input.sourceRef),
|
|
1410
|
+
$subjectKind: input.subjectRef.kind,
|
|
1411
|
+
$subjectId: input.subjectRef.id ?? null,
|
|
1412
|
+
$subjectPath: input.subjectRef.path ?? null,
|
|
1413
|
+
$subjectUrl: input.subjectRef.url ?? null,
|
|
1414
|
+
$subjectJson: JSON.stringify(input.subjectRef),
|
|
1415
|
+
$intent: input.intent,
|
|
1416
|
+
$scopeJson: input.scope ? JSON.stringify(input.scope) : null,
|
|
1417
|
+
$outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
|
|
1418
|
+
$created: now,
|
|
1419
|
+
$updated: now
|
|
1420
|
+
});
|
|
1421
|
+
const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
|
|
1422
|
+
if (!row)
|
|
1423
|
+
throw new Error(`workflow invocation not found after create: ${id}`);
|
|
1424
|
+
return rowToWorkflowInvocation(row);
|
|
1425
|
+
}
|
|
1426
|
+
getWorkflowInvocation(id) {
|
|
1427
|
+
const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
|
|
1428
|
+
return row ? rowToWorkflowInvocation(row) : undefined;
|
|
1429
|
+
}
|
|
1430
|
+
listWorkflowInvocations(opts = {}) {
|
|
1431
|
+
const rows = this.db.query("SELECT * FROM workflow_invocations ORDER BY created_at DESC LIMIT ?").all(opts.limit ?? 100);
|
|
1432
|
+
return rows.map(rowToWorkflowInvocation);
|
|
1433
|
+
}
|
|
1434
|
+
upsertWorkflowWorkItem(input) {
|
|
1435
|
+
const now = nowIso();
|
|
1436
|
+
const id = genId();
|
|
1437
|
+
const status = input.status ?? "queued";
|
|
1438
|
+
this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
|
|
1439
|
+
subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
1440
|
+
workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
|
|
1441
|
+
VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
|
|
1442
|
+
$projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
1443
|
+
$lastReason, $created, $updated)
|
|
1444
|
+
ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
|
|
1445
|
+
invocation_id=excluded.invocation_id,
|
|
1446
|
+
source_type=excluded.source_type,
|
|
1447
|
+
source_ref=excluded.source_ref,
|
|
1448
|
+
subject_ref=excluded.subject_ref,
|
|
1449
|
+
project_key=excluded.project_key,
|
|
1450
|
+
project_group=excluded.project_group,
|
|
1451
|
+
priority=excluded.priority,
|
|
1452
|
+
status=CASE
|
|
1453
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
|
|
1454
|
+
THEN workflow_work_items.status
|
|
1455
|
+
ELSE excluded.status
|
|
1456
|
+
END,
|
|
1457
|
+
workflow_id=CASE
|
|
1458
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
|
|
1459
|
+
ELSE NULL
|
|
1460
|
+
END,
|
|
1461
|
+
loop_id=CASE
|
|
1462
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
|
|
1463
|
+
ELSE NULL
|
|
1464
|
+
END,
|
|
1465
|
+
workflow_run_id=CASE
|
|
1466
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
|
|
1467
|
+
ELSE NULL
|
|
1468
|
+
END,
|
|
1469
|
+
lease_expires_at=CASE
|
|
1470
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
|
|
1471
|
+
ELSE NULL
|
|
1472
|
+
END,
|
|
1473
|
+
next_attempt_at=excluded.next_attempt_at,
|
|
1474
|
+
last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
|
|
1475
|
+
updated_at=excluded.updated_at`).run({
|
|
1476
|
+
$id: id,
|
|
1477
|
+
$routeKey: input.routeKey,
|
|
1478
|
+
$idempotencyKey: input.idempotencyKey,
|
|
1479
|
+
$invocationId: input.invocationId,
|
|
1480
|
+
$sourceType: input.sourceType,
|
|
1481
|
+
$sourceRef: input.sourceRef,
|
|
1482
|
+
$subjectRef: input.subjectRef,
|
|
1483
|
+
$projectKey: input.projectKey ?? null,
|
|
1484
|
+
$projectGroup: input.projectGroup ?? null,
|
|
1485
|
+
$priority: input.priority ?? 0,
|
|
1486
|
+
$status: status,
|
|
1487
|
+
$nextAttemptAt: input.nextAttemptAt ?? null,
|
|
1488
|
+
$lastReason: input.lastReason ?? null,
|
|
1489
|
+
$created: now,
|
|
1490
|
+
$updated: now
|
|
1491
|
+
});
|
|
1492
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(input.routeKey, input.idempotencyKey);
|
|
1493
|
+
if (!row)
|
|
1494
|
+
throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
|
|
1495
|
+
return rowToWorkflowWorkItem(row);
|
|
1496
|
+
}
|
|
1497
|
+
getWorkflowWorkItem(id) {
|
|
1498
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE id = ?").get(id);
|
|
1499
|
+
return row ? rowToWorkflowWorkItem(row) : undefined;
|
|
1500
|
+
}
|
|
1501
|
+
findWorkflowWorkItem(routeKey, idempotencyKey) {
|
|
1502
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(routeKey, idempotencyKey);
|
|
1503
|
+
return row ? rowToWorkflowWorkItem(row) : undefined;
|
|
1504
|
+
}
|
|
1505
|
+
listWorkflowWorkItems(opts = {}) {
|
|
1506
|
+
const limit = opts.limit ?? 100;
|
|
1507
|
+
let rows;
|
|
1508
|
+
if (opts.status && opts.routeKey) {
|
|
1509
|
+
rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.routeKey, opts.status, limit);
|
|
1510
|
+
} else if (opts.status) {
|
|
1511
|
+
rows = this.db.query("SELECT * FROM workflow_work_items WHERE status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.status, limit);
|
|
1512
|
+
} else if (opts.routeKey) {
|
|
1513
|
+
rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? ORDER BY created_at DESC LIMIT ?").all(opts.routeKey, limit);
|
|
1514
|
+
} else {
|
|
1515
|
+
rows = this.db.query("SELECT * FROM workflow_work_items ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
1516
|
+
}
|
|
1517
|
+
return rows.map(rowToWorkflowWorkItem);
|
|
1518
|
+
}
|
|
1519
|
+
countActiveWorkflowWorkItems(args = {}) {
|
|
1520
|
+
const active = ["admitted", "running"];
|
|
1521
|
+
const placeholders = active.map(() => "?").join(",");
|
|
1522
|
+
const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
|
|
1523
|
+
const project = args.projectKey ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_key = ?`).get(...active, args.projectKey)?.count ?? 0 : 0;
|
|
1524
|
+
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;
|
|
1525
|
+
return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
|
|
1526
|
+
}
|
|
1527
|
+
admitWorkflowWorkItem(id, patch) {
|
|
1528
|
+
const now = nowIso();
|
|
1529
|
+
const res = this.db.query(`UPDATE workflow_work_items
|
|
1530
|
+
SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
|
|
1531
|
+
next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
|
|
1532
|
+
WHERE id=$id AND status IN ('queued', 'deferred')`).run({
|
|
1533
|
+
$id: id,
|
|
1534
|
+
$workflowId: patch.workflowId,
|
|
1535
|
+
$loopId: patch.loopId,
|
|
1536
|
+
$reason: patch.reason ?? null,
|
|
1537
|
+
$updated: now
|
|
1538
|
+
});
|
|
1539
|
+
const item = this.getWorkflowWorkItem(id);
|
|
1540
|
+
if (!item)
|
|
1541
|
+
throw new Error(`workflow work item not found after admit: ${id}`);
|
|
1542
|
+
if (res.changes !== 1)
|
|
1543
|
+
throw new Error(`workflow work item is not claimable: ${id} status=${item.status}`);
|
|
1544
|
+
return item;
|
|
1545
|
+
}
|
|
1546
|
+
setWorkflowWorkItemsForLoop(loopId, status, reason, updated, statuses = ["admitted", "running"]) {
|
|
1547
|
+
const placeholders = statuses.map(() => "?").join(",");
|
|
1548
|
+
this.db.query(`UPDATE workflow_work_items
|
|
1549
|
+
SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
|
|
1550
|
+
WHERE loop_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, loopId, ...statuses);
|
|
1551
|
+
}
|
|
1552
|
+
setWorkflowWorkItemsForWorkflowRun(workflowRunId, status, reason, updated, statuses = ["admitted", "running"]) {
|
|
1553
|
+
const placeholders = statuses.map(() => "?").join(",");
|
|
1554
|
+
this.db.query(`UPDATE workflow_work_items
|
|
1555
|
+
SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
|
|
1556
|
+
WHERE workflow_run_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, workflowRunId, ...statuses);
|
|
1557
|
+
}
|
|
1558
|
+
setWorkflowWorkItemsForLoopRun(run, reason, updated) {
|
|
1559
|
+
const loop = this.getLoop(run.loopId);
|
|
1560
|
+
const status = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
1561
|
+
if (!status)
|
|
1562
|
+
return;
|
|
1563
|
+
const statuses = status === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
1564
|
+
const nextReason = status === "admitted" ? reason ? `attempt failed; retry pending: ${reason}` : "attempt failed; retry pending" : reason;
|
|
1565
|
+
this.setWorkflowWorkItemsForLoop(run.loopId, status, nextReason, updated, statuses);
|
|
1566
|
+
}
|
|
1213
1567
|
createGoal(input, opts = {}) {
|
|
1214
1568
|
const now = nowIso();
|
|
1215
1569
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -1483,6 +1837,10 @@ class Store {
|
|
|
1483
1837
|
}
|
|
1484
1838
|
createWorkflowRun(input) {
|
|
1485
1839
|
const now = nowIso();
|
|
1840
|
+
const targetInput = input.loop?.target.type === "workflow" ? input.loop.target.input : undefined;
|
|
1841
|
+
const invocationId = input.invocationId ?? targetInput?.workflowInvocationId ?? targetInput?.invocationId;
|
|
1842
|
+
const workItemId = input.workItemId ?? targetInput?.workflowWorkItemId ?? targetInput?.workItemId;
|
|
1843
|
+
let manifestPath;
|
|
1486
1844
|
if (input.idempotencyKey) {
|
|
1487
1845
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
1488
1846
|
if (existing) {
|
|
@@ -1501,21 +1859,59 @@ class Store {
|
|
|
1501
1859
|
}
|
|
1502
1860
|
}
|
|
1503
1861
|
const runId = genId();
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1862
|
+
const workItem = workItemId ? this.getWorkflowWorkItem(workItemId) : undefined;
|
|
1863
|
+
const invocation = invocationId ? this.getWorkflowInvocation(invocationId) : undefined;
|
|
1864
|
+
manifestPath = invocation || workItem ? writeWorkflowRunManifest({
|
|
1865
|
+
loopsDataDir: this.rootDir,
|
|
1866
|
+
workflowRunId: runId,
|
|
1867
|
+
workflowId: input.workflow.id,
|
|
1868
|
+
workflowName: input.workflow.name,
|
|
1869
|
+
invocationId,
|
|
1870
|
+
workItemId,
|
|
1871
|
+
projectKey: workItem?.projectKey ?? invocation?.scope?.projectPath,
|
|
1872
|
+
subjectKind: invocation?.subjectRef.kind,
|
|
1873
|
+
rawSubjectRef: workItem?.subjectRef ?? invocation?.subjectRef.path ?? invocation?.subjectRef.id ?? invocation?.subjectRef.url,
|
|
1874
|
+
payload: {
|
|
1875
|
+
workflowInvocation: invocation,
|
|
1876
|
+
workflowWorkItem: workItem,
|
|
1877
|
+
loopId: input.loop?.id,
|
|
1878
|
+
loopRunId: input.loopRun?.id,
|
|
1879
|
+
scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor
|
|
1880
|
+
}
|
|
1881
|
+
}) : undefined;
|
|
1882
|
+
this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, invocation_id, work_item_id,
|
|
1883
|
+
scheduled_for, idempotency_key, manifest_path, status, started_at, finished_at, duration_ms, error,
|
|
1884
|
+
created_at, updated_at)
|
|
1885
|
+
VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $invocationId, $workItemId, $scheduledFor,
|
|
1886
|
+
$idempotencyKey, $manifestPath, 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
|
|
1508
1887
|
$id: runId,
|
|
1509
1888
|
$workflowId: input.workflow.id,
|
|
1510
1889
|
$workflowName: input.workflow.name,
|
|
1511
1890
|
$loopId: input.loop?.id ?? null,
|
|
1512
1891
|
$loopRunId: input.loopRun?.id ?? null,
|
|
1892
|
+
$invocationId: invocationId ?? null,
|
|
1893
|
+
$workItemId: workItemId ?? null,
|
|
1513
1894
|
$scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
|
|
1514
1895
|
$idempotencyKey: input.idempotencyKey ?? null,
|
|
1896
|
+
$manifestPath: manifestPath ?? null,
|
|
1515
1897
|
$started: now,
|
|
1516
1898
|
$created: now,
|
|
1517
1899
|
$updated: now
|
|
1518
1900
|
});
|
|
1901
|
+
if (workItemId) {
|
|
1902
|
+
const workItemRes = this.db.query(`UPDATE workflow_work_items
|
|
1903
|
+
SET status='running', workflow_run_id=$workflowRunId, lease_expires_at=$leaseExpiresAt, updated_at=$updated
|
|
1904
|
+
WHERE id=$id AND status IN ('admitted', 'queued', 'deferred', 'running')`).run({
|
|
1905
|
+
$id: workItemId,
|
|
1906
|
+
$workflowRunId: runId,
|
|
1907
|
+
$leaseExpiresAt: input.loop ? new Date(Date.now() + input.loop.leaseMs).toISOString() : null,
|
|
1908
|
+
$updated: now
|
|
1909
|
+
});
|
|
1910
|
+
if (workItemRes.changes !== 1) {
|
|
1911
|
+
const current = this.getWorkflowWorkItem(workItemId);
|
|
1912
|
+
throw new Error(`workflow work item is not runnable: ${workItemId}${current ? ` status=${current.status}` : ""}`);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1519
1915
|
input.workflow.steps.forEach((step, sequence) => {
|
|
1520
1916
|
const account = step.account ?? step.target.account;
|
|
1521
1917
|
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
@@ -1541,7 +1937,10 @@ class Store {
|
|
|
1541
1937
|
workflowName: input.workflow.name,
|
|
1542
1938
|
stepCount: input.workflow.steps.length,
|
|
1543
1939
|
loopId: input.loop?.id,
|
|
1544
|
-
loopRunId: input.loopRun?.id
|
|
1940
|
+
loopRunId: input.loopRun?.id,
|
|
1941
|
+
invocationId,
|
|
1942
|
+
workItemId,
|
|
1943
|
+
manifestPath
|
|
1545
1944
|
}),
|
|
1546
1945
|
$created: now
|
|
1547
1946
|
});
|
|
@@ -1554,6 +1953,8 @@ class Store {
|
|
|
1554
1953
|
try {
|
|
1555
1954
|
this.db.exec("ROLLBACK");
|
|
1556
1955
|
} catch {}
|
|
1956
|
+
if (manifestPath)
|
|
1957
|
+
rmSync(manifestPath, { force: true });
|
|
1557
1958
|
throw error;
|
|
1558
1959
|
}
|
|
1559
1960
|
}
|
|
@@ -1766,6 +2167,10 @@ class Store {
|
|
|
1766
2167
|
changed = res.changes === 1;
|
|
1767
2168
|
if (changed)
|
|
1768
2169
|
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
2170
|
+
if (changed) {
|
|
2171
|
+
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2172
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2173
|
+
}
|
|
1769
2174
|
this.db.exec("COMMIT");
|
|
1770
2175
|
} catch (error) {
|
|
1771
2176
|
try {
|
|
@@ -1790,6 +2195,7 @@ class Store {
|
|
|
1790
2195
|
this.db.query(`UPDATE workflow_step_runs
|
|
1791
2196
|
SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
|
|
1792
2197
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2198
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
1793
2199
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
1794
2200
|
}
|
|
1795
2201
|
this.db.exec("COMMIT");
|
|
@@ -2043,6 +2449,8 @@ class Store {
|
|
|
2043
2449
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2044
2450
|
if (opts.claimedBy && res.changes !== 1)
|
|
2045
2451
|
return run;
|
|
2452
|
+
if (res.changes === 1)
|
|
2453
|
+
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2046
2454
|
return run;
|
|
2047
2455
|
}
|
|
2048
2456
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2136,6 +2544,14 @@ class Store {
|
|
|
2136
2544
|
error: "parent loop run lease expired before completion",
|
|
2137
2545
|
loopRunId: row.id
|
|
2138
2546
|
});
|
|
2547
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2548
|
+
}
|
|
2549
|
+
const loop = this.getLoop(row.loop_id);
|
|
2550
|
+
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
2551
|
+
if (itemStatus) {
|
|
2552
|
+
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2553
|
+
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2554
|
+
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2139
2555
|
}
|
|
2140
2556
|
this.db.exec("COMMIT");
|
|
2141
2557
|
} catch (error) {
|
|
@@ -2244,11 +2660,11 @@ class Store {
|
|
|
2244
2660
|
}
|
|
2245
2661
|
|
|
2246
2662
|
// src/cli/index.ts
|
|
2247
|
-
import { createHash as
|
|
2248
|
-
import { closeSync, existsSync as existsSync4, mkdirSync as
|
|
2663
|
+
import { createHash as createHash3, randomUUID } from "crypto";
|
|
2664
|
+
import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, openSync as openSync2, readFileSync as readFileSync2, realpathSync, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
2249
2665
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
2250
|
-
import { join as
|
|
2251
|
-
import { tmpdir } from "os";
|
|
2666
|
+
import { join as join6, resolve as resolve2 } from "path";
|
|
2667
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2252
2668
|
import { Database as Database2 } from "bun:sqlite";
|
|
2253
2669
|
import { Command } from "commander";
|
|
2254
2670
|
|
|
@@ -2336,6 +2752,12 @@ function publicWorkflow(workflow) {
|
|
|
2336
2752
|
function publicWorkflowRun(run) {
|
|
2337
2753
|
return { ...run, error: redact(run.error) };
|
|
2338
2754
|
}
|
|
2755
|
+
function publicWorkflowInvocation(invocation) {
|
|
2756
|
+
return redactSensitivePayload(invocation);
|
|
2757
|
+
}
|
|
2758
|
+
function publicWorkflowWorkItem(item) {
|
|
2759
|
+
return { ...item, lastReason: redact(item.lastReason, 240) };
|
|
2760
|
+
}
|
|
2339
2761
|
function publicWorkflowStepRun(run, showOutput = false) {
|
|
2340
2762
|
return {
|
|
2341
2763
|
...run,
|
|
@@ -2466,7 +2888,7 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
2466
2888
|
// src/lib/env.ts
|
|
2467
2889
|
import { accessSync, constants } from "fs";
|
|
2468
2890
|
import { homedir as homedir2 } from "os";
|
|
2469
|
-
import { delimiter, join as
|
|
2891
|
+
import { delimiter, join as join4 } from "path";
|
|
2470
2892
|
function compactPathParts(parts) {
|
|
2471
2893
|
const seen = new Set;
|
|
2472
2894
|
const result = [];
|
|
@@ -2482,14 +2904,14 @@ function compactPathParts(parts) {
|
|
|
2482
2904
|
function commonExecutableDirs(env = process.env) {
|
|
2483
2905
|
const home = env.HOME || homedir2();
|
|
2484
2906
|
return compactPathParts([
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
env.BUN_INSTALL ?
|
|
2907
|
+
join4(home, ".local", "bin"),
|
|
2908
|
+
join4(home, ".bun", "bin"),
|
|
2909
|
+
join4(home, ".cargo", "bin"),
|
|
2910
|
+
join4(home, ".npm-global", "bin"),
|
|
2911
|
+
join4(home, "bin"),
|
|
2912
|
+
env.BUN_INSTALL ? join4(env.BUN_INSTALL, "bin") : undefined,
|
|
2491
2913
|
env.PNPM_HOME,
|
|
2492
|
-
env.NPM_CONFIG_PREFIX ?
|
|
2914
|
+
env.NPM_CONFIG_PREFIX ? join4(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
2493
2915
|
"/opt/homebrew/bin",
|
|
2494
2916
|
"/usr/local/bin",
|
|
2495
2917
|
"/usr/bin",
|
|
@@ -2513,7 +2935,7 @@ function executableExists(command, env = process.env) {
|
|
|
2513
2935
|
if (command.includes("/"))
|
|
2514
2936
|
return isExecutable(command);
|
|
2515
2937
|
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
2516
|
-
if (dir && isExecutable(
|
|
2938
|
+
if (dir && isExecutable(join4(dir, command)))
|
|
2517
2939
|
return true;
|
|
2518
2940
|
}
|
|
2519
2941
|
return false;
|
|
@@ -2871,6 +3293,7 @@ function commandSpec(target) {
|
|
|
2871
3293
|
shell: commandTarget.shell,
|
|
2872
3294
|
env: commandTarget.env,
|
|
2873
3295
|
timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3296
|
+
idleTimeoutMs: commandTarget.idleTimeoutMs,
|
|
2874
3297
|
account: commandTarget.account,
|
|
2875
3298
|
accountTool: commandTarget.account?.tool
|
|
2876
3299
|
};
|
|
@@ -2881,6 +3304,7 @@ function commandSpec(target) {
|
|
|
2881
3304
|
args: agentArgs(agentTarget),
|
|
2882
3305
|
cwd: agentTarget.cwd,
|
|
2883
3306
|
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3307
|
+
idleTimeoutMs: agentTarget.idleTimeoutMs,
|
|
2884
3308
|
account: agentTarget.account,
|
|
2885
3309
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
2886
3310
|
nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
|
|
@@ -3028,6 +3452,7 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3028
3452
|
let stdout = "";
|
|
3029
3453
|
let stderr = "";
|
|
3030
3454
|
let timedOut = false;
|
|
3455
|
+
let idleTimedOut = false;
|
|
3031
3456
|
let exitCode;
|
|
3032
3457
|
let error;
|
|
3033
3458
|
let plan;
|
|
@@ -3066,18 +3491,34 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3066
3491
|
if (opts.signal?.aborted)
|
|
3067
3492
|
abortHandler();
|
|
3068
3493
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
3069
|
-
child.stdout?.on("data", (chunk) => {
|
|
3070
|
-
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3071
|
-
});
|
|
3072
|
-
child.stderr?.on("data", (chunk) => {
|
|
3073
|
-
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3074
|
-
});
|
|
3075
3494
|
const timer = setTimeout(() => {
|
|
3076
3495
|
timedOut = true;
|
|
3077
3496
|
if (child.pid)
|
|
3078
3497
|
killProcessGroup(child.pid);
|
|
3079
3498
|
}, spec.timeoutMs);
|
|
3080
3499
|
timer.unref();
|
|
3500
|
+
let idleTimer;
|
|
3501
|
+
const resetIdleTimer = () => {
|
|
3502
|
+
if (!spec.idleTimeoutMs)
|
|
3503
|
+
return;
|
|
3504
|
+
if (idleTimer)
|
|
3505
|
+
clearTimeout(idleTimer);
|
|
3506
|
+
idleTimer = setTimeout(() => {
|
|
3507
|
+
idleTimedOut = true;
|
|
3508
|
+
if (child.pid)
|
|
3509
|
+
killProcessGroup(child.pid);
|
|
3510
|
+
}, spec.idleTimeoutMs);
|
|
3511
|
+
idleTimer.unref();
|
|
3512
|
+
};
|
|
3513
|
+
resetIdleTimer();
|
|
3514
|
+
child.stdout?.on("data", (chunk) => {
|
|
3515
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3516
|
+
resetIdleTimer();
|
|
3517
|
+
});
|
|
3518
|
+
child.stderr?.on("data", (chunk) => {
|
|
3519
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3520
|
+
resetIdleTimer();
|
|
3521
|
+
});
|
|
3081
3522
|
try {
|
|
3082
3523
|
const [code, signal] = await once(child, "exit");
|
|
3083
3524
|
if (typeof code === "number")
|
|
@@ -3088,17 +3529,19 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3088
3529
|
error = err instanceof Error ? err.message : String(err);
|
|
3089
3530
|
} finally {
|
|
3090
3531
|
clearTimeout(timer);
|
|
3532
|
+
if (idleTimer)
|
|
3533
|
+
clearTimeout(idleTimer);
|
|
3091
3534
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
3092
3535
|
}
|
|
3093
3536
|
const finishedAt = nowIso();
|
|
3094
3537
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
3095
|
-
if (timedOut) {
|
|
3538
|
+
if (timedOut || idleTimedOut) {
|
|
3096
3539
|
return {
|
|
3097
3540
|
status: "timed_out",
|
|
3098
3541
|
exitCode,
|
|
3099
3542
|
stdout,
|
|
3100
3543
|
stderr,
|
|
3101
|
-
error: `timed out after ${spec.timeoutMs}ms`,
|
|
3544
|
+
error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
|
|
3102
3545
|
pid: child.pid,
|
|
3103
3546
|
startedAt,
|
|
3104
3547
|
finishedAt,
|
|
@@ -3164,6 +3607,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3164
3607
|
let stdout = "";
|
|
3165
3608
|
let stderr = "";
|
|
3166
3609
|
let timedOut = false;
|
|
3610
|
+
let idleTimedOut = false;
|
|
3167
3611
|
let exitCode;
|
|
3168
3612
|
let error;
|
|
3169
3613
|
const env = executionEnv(spec, metadata, opts);
|
|
@@ -3226,18 +3670,34 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3226
3670
|
if (opts.signal?.aborted)
|
|
3227
3671
|
abortHandler();
|
|
3228
3672
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
3229
|
-
child.stdout?.on("data", (chunk) => {
|
|
3230
|
-
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3231
|
-
});
|
|
3232
|
-
child.stderr?.on("data", (chunk) => {
|
|
3233
|
-
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3234
|
-
});
|
|
3235
3673
|
const timer = setTimeout(() => {
|
|
3236
3674
|
timedOut = true;
|
|
3237
3675
|
if (child.pid)
|
|
3238
3676
|
killProcessGroup(child.pid);
|
|
3239
3677
|
}, spec.timeoutMs);
|
|
3240
3678
|
timer.unref();
|
|
3679
|
+
let idleTimer;
|
|
3680
|
+
const resetIdleTimer = () => {
|
|
3681
|
+
if (!spec.idleTimeoutMs)
|
|
3682
|
+
return;
|
|
3683
|
+
if (idleTimer)
|
|
3684
|
+
clearTimeout(idleTimer);
|
|
3685
|
+
idleTimer = setTimeout(() => {
|
|
3686
|
+
idleTimedOut = true;
|
|
3687
|
+
if (child.pid)
|
|
3688
|
+
killProcessGroup(child.pid);
|
|
3689
|
+
}, spec.idleTimeoutMs);
|
|
3690
|
+
idleTimer.unref();
|
|
3691
|
+
};
|
|
3692
|
+
resetIdleTimer();
|
|
3693
|
+
child.stdout?.on("data", (chunk) => {
|
|
3694
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3695
|
+
resetIdleTimer();
|
|
3696
|
+
});
|
|
3697
|
+
child.stderr?.on("data", (chunk) => {
|
|
3698
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3699
|
+
resetIdleTimer();
|
|
3700
|
+
});
|
|
3241
3701
|
try {
|
|
3242
3702
|
const [code, signal] = await once(child, "exit");
|
|
3243
3703
|
if (typeof code === "number")
|
|
@@ -3248,17 +3708,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3248
3708
|
error = err instanceof Error ? err.message : String(err);
|
|
3249
3709
|
} finally {
|
|
3250
3710
|
clearTimeout(timer);
|
|
3711
|
+
if (idleTimer)
|
|
3712
|
+
clearTimeout(idleTimer);
|
|
3251
3713
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
3252
3714
|
}
|
|
3253
3715
|
const finishedAt = nowIso();
|
|
3254
3716
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
3255
|
-
if (timedOut) {
|
|
3717
|
+
if (timedOut || idleTimedOut) {
|
|
3256
3718
|
return {
|
|
3257
3719
|
status: "timed_out",
|
|
3258
3720
|
exitCode,
|
|
3259
3721
|
stdout,
|
|
3260
3722
|
stderr,
|
|
3261
|
-
error: `timed out after ${spec.timeoutMs}ms`,
|
|
3723
|
+
error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
|
|
3262
3724
|
pid: child.pid,
|
|
3263
3725
|
startedAt,
|
|
3264
3726
|
finishedAt,
|
|
@@ -4300,7 +4762,7 @@ async function tick(deps) {
|
|
|
4300
4762
|
}
|
|
4301
4763
|
|
|
4302
4764
|
// src/daemon/control.ts
|
|
4303
|
-
import { existsSync as existsSync2, mkdirSync as
|
|
4765
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4304
4766
|
import { hostname } from "os";
|
|
4305
4767
|
import { dirname as dirname2 } from "path";
|
|
4306
4768
|
|
|
@@ -4338,11 +4800,11 @@ function readPid(path = pidFilePath()) {
|
|
|
4338
4800
|
}
|
|
4339
4801
|
}
|
|
4340
4802
|
function writePid(pid = process.pid, path = pidFilePath()) {
|
|
4341
|
-
|
|
4342
|
-
|
|
4803
|
+
mkdirSync4(dirname2(path), { recursive: true, mode: 448 });
|
|
4804
|
+
writeFileSync2(path, String(pid));
|
|
4343
4805
|
}
|
|
4344
4806
|
function removePid(path = pidFilePath()) {
|
|
4345
|
-
|
|
4807
|
+
rmSync2(path, { force: true });
|
|
4346
4808
|
}
|
|
4347
4809
|
function isAlive(pid) {
|
|
4348
4810
|
try {
|
|
@@ -4601,7 +5063,7 @@ async function startDaemon(opts) {
|
|
|
4601
5063
|
}
|
|
4602
5064
|
|
|
4603
5065
|
// src/daemon/install.ts
|
|
4604
|
-
import { chmodSync, mkdirSync as
|
|
5066
|
+
import { chmodSync, mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
4605
5067
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
4606
5068
|
import { dirname as dirname3 } from "path";
|
|
4607
5069
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
@@ -4609,8 +5071,8 @@ function installStartup(cliEntry, execPath = process.execPath, args = ["daemon",
|
|
|
4609
5071
|
const pathEnv = normalizeExecutionPath(process.env);
|
|
4610
5072
|
if (process.platform === "linux") {
|
|
4611
5073
|
const path = systemdServicePath();
|
|
4612
|
-
|
|
4613
|
-
|
|
5074
|
+
mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
|
|
5075
|
+
writeFileSync3(path, `[Unit]
|
|
4614
5076
|
Description=Hasna OpenLoops daemon
|
|
4615
5077
|
After=default.target
|
|
4616
5078
|
|
|
@@ -4636,8 +5098,8 @@ WantedBy=default.target
|
|
|
4636
5098
|
}
|
|
4637
5099
|
if (process.platform === "darwin") {
|
|
4638
5100
|
const path = launchdPlistPath();
|
|
4639
|
-
|
|
4640
|
-
|
|
5101
|
+
mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
|
|
5102
|
+
writeFileSync3(path, `<?xml version="1.0" encoding="UTF-8"?>
|
|
4641
5103
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4642
5104
|
<plist version="1.0">
|
|
4643
5105
|
<dict>
|
|
@@ -4796,7 +5258,7 @@ function runDoctor(store) {
|
|
|
4796
5258
|
}
|
|
4797
5259
|
|
|
4798
5260
|
// src/lib/health.ts
|
|
4799
|
-
import { createHash } from "crypto";
|
|
5261
|
+
import { createHash as createHash2 } from "crypto";
|
|
4800
5262
|
var EVIDENCE_CHARS = 2000;
|
|
4801
5263
|
var FINGERPRINT_EVIDENCE_CHARS = 120;
|
|
4802
5264
|
var CLASSIFICATIONS = [
|
|
@@ -4828,7 +5290,7 @@ function searchableText(run) {
|
|
|
4828
5290
|
`).toLowerCase();
|
|
4829
5291
|
}
|
|
4830
5292
|
function stableFingerprint(parts) {
|
|
4831
|
-
return
|
|
5293
|
+
return createHash2("sha256").update(parts.join(`
|
|
4832
5294
|
`)).digest("hex").slice(0, 16);
|
|
4833
5295
|
}
|
|
4834
5296
|
function stableFailureFingerprint(run, classification) {
|
|
@@ -5016,7 +5478,7 @@ function buildHealthReport(store, opts = {}) {
|
|
|
5016
5478
|
}
|
|
5017
5479
|
|
|
5018
5480
|
// src/lib/hygiene.ts
|
|
5019
|
-
import { basename } from "path";
|
|
5481
|
+
import { basename as basename2 } from "path";
|
|
5020
5482
|
var PROVIDER_TOKENS = new Set([
|
|
5021
5483
|
"codewith",
|
|
5022
5484
|
"claude",
|
|
@@ -5037,7 +5499,7 @@ function repoSlugFromCwd(cwd) {
|
|
|
5037
5499
|
return "";
|
|
5038
5500
|
if (cwd.includes("/.hasna/loops/"))
|
|
5039
5501
|
return "";
|
|
5040
|
-
return slugify(
|
|
5502
|
+
return slugify(basename2(cwd));
|
|
5041
5503
|
}
|
|
5042
5504
|
function scopeForLoop(loop) {
|
|
5043
5505
|
const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
|
|
@@ -5254,7 +5716,7 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
5254
5716
|
// package.json
|
|
5255
5717
|
var package_default = {
|
|
5256
5718
|
name: "@hasna/loops",
|
|
5257
|
-
version: "0.3.
|
|
5719
|
+
version: "0.3.40",
|
|
5258
5720
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5259
5721
|
type: "module",
|
|
5260
5722
|
main: "dist/index.js",
|
|
@@ -5346,10 +5808,17 @@ function packageVersion() {
|
|
|
5346
5808
|
import { execFileSync } from "child_process";
|
|
5347
5809
|
import { existsSync as existsSync3 } from "fs";
|
|
5348
5810
|
import { homedir as homedir3 } from "os";
|
|
5349
|
-
import { basename as
|
|
5811
|
+
import { basename as basename3, isAbsolute, join as join5, relative, resolve } from "path";
|
|
5350
5812
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
5351
5813
|
var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
5352
5814
|
var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
5815
|
+
var TASK_LIFECYCLE_TEMPLATE_ID = "task-lifecycle";
|
|
5816
|
+
var PR_REVIEW_TEMPLATE_ID = "pr-review";
|
|
5817
|
+
var SCHEDULED_AUDIT_TEMPLATE_ID = "scheduled-audit";
|
|
5818
|
+
var KNOWLEDGE_REFRESH_TEMPLATE_ID = "knowledge-refresh";
|
|
5819
|
+
var REPORT_ONLY_TEMPLATE_ID = "report-only";
|
|
5820
|
+
var INCIDENT_RESPONSE_TEMPLATE_ID = "incident-response";
|
|
5821
|
+
var DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID = "deterministic-check-create-task";
|
|
5353
5822
|
var TEMPLATE_SUMMARIES = [
|
|
5354
5823
|
{
|
|
5355
5824
|
id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
@@ -5371,7 +5840,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5371
5840
|
{ name: "model", description: "Provider model." },
|
|
5372
5841
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
5373
5842
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
5374
|
-
{ name: "sandbox", default: "
|
|
5843
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5844
|
+
{ name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
|
|
5375
5845
|
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
5376
5846
|
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
5377
5847
|
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." }
|
|
@@ -5399,7 +5869,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5399
5869
|
{ name: "model", description: "Provider model." },
|
|
5400
5870
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
5401
5871
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
5402
|
-
{ name: "sandbox", default: "
|
|
5872
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5873
|
+
{ name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
|
|
5403
5874
|
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
5404
5875
|
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
5405
5876
|
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." }
|
|
@@ -5425,12 +5896,112 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5425
5896
|
{ name: "model", description: "Provider model." },
|
|
5426
5897
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
5427
5898
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
5428
|
-
{ name: "sandbox", default: "
|
|
5899
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5900
|
+
{ name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
|
|
5429
5901
|
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
5430
5902
|
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
5431
5903
|
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
|
|
5432
5904
|
{ name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
|
|
5433
5905
|
]
|
|
5906
|
+
},
|
|
5907
|
+
{
|
|
5908
|
+
id: TASK_LIFECYCLE_TEMPLATE_ID,
|
|
5909
|
+
name: "Task Lifecycle",
|
|
5910
|
+
description: "Run the standard task-created lifecycle: triage/dedupe, plan, worker execution, independent verification, and todos closure/follow-up evidence.",
|
|
5911
|
+
kind: "workflow",
|
|
5912
|
+
variables: [
|
|
5913
|
+
{ name: "taskId", required: true, description: "Todos task id." },
|
|
5914
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5915
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5916
|
+
{ name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
|
|
5917
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5918
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5919
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
5920
|
+
]
|
|
5921
|
+
},
|
|
5922
|
+
{
|
|
5923
|
+
id: PR_REVIEW_TEMPLATE_ID,
|
|
5924
|
+
name: "PR Review",
|
|
5925
|
+
description: "Review and drive a pull request toward merge-ready state with a worker and fresh adversarial verifier.",
|
|
5926
|
+
kind: "workflow",
|
|
5927
|
+
variables: [
|
|
5928
|
+
{ name: "prUrl", description: "Pull request URL." },
|
|
5929
|
+
{ name: "prNumber", description: "Pull request number." },
|
|
5930
|
+
{ name: "projectPath", required: true, description: "Repository working directory." },
|
|
5931
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5932
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5933
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5934
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
5935
|
+
]
|
|
5936
|
+
},
|
|
5937
|
+
{
|
|
5938
|
+
id: SCHEDULED_AUDIT_TEMPLATE_ID,
|
|
5939
|
+
name: "Scheduled Audit",
|
|
5940
|
+
description: "Run a bounded scheduled audit, record evidence, create follow-up tasks for actionable findings, then verify the audit result.",
|
|
5941
|
+
kind: "workflow",
|
|
5942
|
+
variables: [
|
|
5943
|
+
{ name: "objective", required: true, description: "Audit objective." },
|
|
5944
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5945
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5946
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5947
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5948
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
5949
|
+
]
|
|
5950
|
+
},
|
|
5951
|
+
{
|
|
5952
|
+
id: KNOWLEDGE_REFRESH_TEMPLATE_ID,
|
|
5953
|
+
name: "Knowledge Refresh",
|
|
5954
|
+
description: "Review recent knowledge, improve structure/schema where needed, create deduped tasks for code changes, and verify the knowledge update.",
|
|
5955
|
+
kind: "workflow",
|
|
5956
|
+
variables: [
|
|
5957
|
+
{ name: "scope", description: "Knowledge scope or label to refresh." },
|
|
5958
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5959
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5960
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5961
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5962
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
5963
|
+
]
|
|
5964
|
+
},
|
|
5965
|
+
{
|
|
5966
|
+
id: REPORT_ONLY_TEMPLATE_ID,
|
|
5967
|
+
name: "Report Only",
|
|
5968
|
+
description: "Produce a bounded report without mutating repositories; verifier checks evidence, scope, and absence of unauthorized changes.",
|
|
5969
|
+
kind: "workflow",
|
|
5970
|
+
variables: [
|
|
5971
|
+
{ name: "objective", required: true, description: "Report objective." },
|
|
5972
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5973
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5974
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5975
|
+
{ name: "sandbox", default: "read-only", description: "Provider sandbox mode." },
|
|
5976
|
+
{ name: "worktreeMode", default: "main", description: "Report-only workflows normally inspect the main checkout read-only." }
|
|
5977
|
+
]
|
|
5978
|
+
},
|
|
5979
|
+
{
|
|
5980
|
+
id: INCIDENT_RESPONSE_TEMPLATE_ID,
|
|
5981
|
+
name: "Incident Response",
|
|
5982
|
+
description: "Triage an incident, gather bounded evidence, apply only allowed narrow mitigation, create follow-up tasks, and verify the response.",
|
|
5983
|
+
kind: "workflow",
|
|
5984
|
+
variables: [
|
|
5985
|
+
{ name: "incidentId", description: "Incident or task id." },
|
|
5986
|
+
{ name: "objective", required: true, description: "Incident response objective." },
|
|
5987
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5988
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5989
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5990
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5991
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
5992
|
+
]
|
|
5993
|
+
},
|
|
5994
|
+
{
|
|
5995
|
+
id: DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID,
|
|
5996
|
+
name: "Deterministic Check Create Task",
|
|
5997
|
+
description: "Run a deterministic check command that writes compact evidence and upserts one deduped todos task when its expectation is not met.",
|
|
5998
|
+
kind: "workflow",
|
|
5999
|
+
variables: [
|
|
6000
|
+
{ name: "checkCommand", required: true, description: "Shell command that performs the check and task upsert." },
|
|
6001
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
6002
|
+
{ name: "name", description: "Workflow name." },
|
|
6003
|
+
{ name: "timeoutMs", default: "300000", description: "Check timeout in milliseconds." }
|
|
6004
|
+
]
|
|
5434
6005
|
}
|
|
5435
6006
|
];
|
|
5436
6007
|
function compactJson(value) {
|
|
@@ -5492,7 +6063,7 @@ function defaultWorktreeRoot(root) {
|
|
|
5492
6063
|
const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
|
|
5493
6064
|
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
5494
6065
|
}
|
|
5495
|
-
return
|
|
6066
|
+
return join5(homedir3(), ".hasna", "loops", "worktrees");
|
|
5496
6067
|
}
|
|
5497
6068
|
function gitRootFor(path) {
|
|
5498
6069
|
if (!existsSync3(path))
|
|
@@ -5593,11 +6164,11 @@ function worktreePlan(input, seed) {
|
|
|
5593
6164
|
};
|
|
5594
6165
|
}
|
|
5595
6166
|
const root = defaultWorktreeRoot(input.worktreeRoot);
|
|
5596
|
-
const repoSlug = slugSegment(
|
|
6167
|
+
const repoSlug = slugSegment(basename3(repoRoot), "repo");
|
|
5597
6168
|
const seedSlug = `${slugSegment(seed, "run").slice(0, 48)}-${stableHex(`${repoRoot}:${seed}`)}`;
|
|
5598
|
-
const worktreePath =
|
|
6169
|
+
const worktreePath = join5(root, repoSlug, seedSlug);
|
|
5599
6170
|
const relativeCwd = relative(repoRoot, originalCwd);
|
|
5600
|
-
const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ?
|
|
6171
|
+
const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join5(worktreePath, relativeCwd) : worktreePath;
|
|
5601
6172
|
const branchPrefix = (input.worktreeBranchPrefix?.trim() || "openloops").replace(/^\/+|\/+$/g, "") || "openloops";
|
|
5602
6173
|
const branch = `${branchPrefix}/${repoSlug}/${seedSlug}`;
|
|
5603
6174
|
const prepareStep = {
|
|
@@ -5655,10 +6226,20 @@ function assertNativeAuthProfileSupport(input, provider) {
|
|
|
5655
6226
|
return;
|
|
5656
6227
|
throw new Error(`authProfile, authProfilePool, workerAuthProfile, and verifierAuthProfile are supported only for provider codewith; use account/accountPool for ${provider} profile isolation`);
|
|
5657
6228
|
}
|
|
6229
|
+
function failClosedSandbox(input, provider, sandbox) {
|
|
6230
|
+
if (!["codewith", "codex"].includes(provider))
|
|
6231
|
+
return;
|
|
6232
|
+
if (sandbox !== "danger-full-access")
|
|
6233
|
+
return;
|
|
6234
|
+
if (input.manualBreakGlass)
|
|
6235
|
+
return;
|
|
6236
|
+
throw new Error("danger-full-access is manual break-glass only for generated worker/verifier workflows; use sandbox=workspace-write or set manualBreakGlass=true with explicit operator approval");
|
|
6237
|
+
}
|
|
5658
6238
|
function agentTarget(input, prompt, role, seed, plan) {
|
|
5659
6239
|
const provider = input.provider ?? "codewith";
|
|
5660
6240
|
assertNativeAuthProfileSupport(input, provider);
|
|
5661
|
-
const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "
|
|
6241
|
+
const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "workspace-write" : provider === "cursor" ? "enabled" : undefined);
|
|
6242
|
+
failClosedSandbox(input, provider, sandbox);
|
|
5662
6243
|
return {
|
|
5663
6244
|
type: "agent",
|
|
5664
6245
|
provider,
|
|
@@ -5682,6 +6263,7 @@ function agentTarget(input, prompt, role, seed, plan) {
|
|
|
5682
6263
|
branch: plan.branch,
|
|
5683
6264
|
reason: plan.reason
|
|
5684
6265
|
},
|
|
6266
|
+
allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
|
|
5685
6267
|
routing: {
|
|
5686
6268
|
projectPath: input.routeProjectPath ?? input.projectPath,
|
|
5687
6269
|
...input.projectGroup ? { projectGroup: input.projectGroup } : {}
|
|
@@ -5771,7 +6353,10 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
5771
6353
|
name: "Verifier",
|
|
5772
6354
|
description: "Adversarially verify worker output and update todos.",
|
|
5773
6355
|
dependsOn: ["worker"],
|
|
5774
|
-
target:
|
|
6356
|
+
target: {
|
|
6357
|
+
...agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
|
|
6358
|
+
idleTimeoutMs: 10 * 60000
|
|
6359
|
+
},
|
|
5775
6360
|
timeoutMs: 30 * 60000
|
|
5776
6361
|
}
|
|
5777
6362
|
])
|
|
@@ -5849,7 +6434,10 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
5849
6434
|
name: "Verifier",
|
|
5850
6435
|
description: "Adversarially verify event handling.",
|
|
5851
6436
|
dependsOn: ["worker"],
|
|
5852
|
-
target:
|
|
6437
|
+
target: {
|
|
6438
|
+
...agentTarget(input, verifierPrompt, "verifier", seed, plan),
|
|
6439
|
+
idleTimeoutMs: 10 * 60000
|
|
6440
|
+
},
|
|
5853
6441
|
timeoutMs: 30 * 60000
|
|
5854
6442
|
}
|
|
5855
6443
|
])
|
|
@@ -5900,13 +6488,142 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
|
|
|
5900
6488
|
name: "Verifier",
|
|
5901
6489
|
description: "Adversarially verify the bounded objective result.",
|
|
5902
6490
|
dependsOn: ["worker"],
|
|
5903
|
-
target:
|
|
6491
|
+
target: {
|
|
6492
|
+
...agentTarget(input, verifierPrompt, "verifier", seed, plan),
|
|
6493
|
+
idleTimeoutMs: 10 * 60000
|
|
6494
|
+
},
|
|
5904
6495
|
timeoutMs: Math.min(timeoutMs, 30 * 60000)
|
|
5905
6496
|
}
|
|
5906
6497
|
])
|
|
5907
6498
|
};
|
|
5908
6499
|
}
|
|
6500
|
+
function renderLifecycleBoundedTemplate(id, values) {
|
|
6501
|
+
const projectPath = values.projectPath ?? values.cwd ?? process.cwd();
|
|
6502
|
+
const common = {
|
|
6503
|
+
name: values.name,
|
|
6504
|
+
projectPath,
|
|
6505
|
+
routeProjectPath: values.routeProjectPath,
|
|
6506
|
+
projectGroup: values.projectGroup,
|
|
6507
|
+
provider: values.provider,
|
|
6508
|
+
authProfile: values.authProfile,
|
|
6509
|
+
authProfilePool: listVar(values.authProfilePool),
|
|
6510
|
+
workerAuthProfile: values.workerAuthProfile,
|
|
6511
|
+
verifierAuthProfile: values.verifierAuthProfile,
|
|
6512
|
+
account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
|
|
6513
|
+
accountPool: accountPoolVar(values.accountPool, values.accountTool),
|
|
6514
|
+
model: values.model,
|
|
6515
|
+
variant: values.variant,
|
|
6516
|
+
agent: values.agent,
|
|
6517
|
+
permissionMode: values.permissionMode,
|
|
6518
|
+
sandbox: values.sandbox,
|
|
6519
|
+
manualBreakGlass: booleanVar(values.manualBreakGlass),
|
|
6520
|
+
worktreeMode: values.worktreeMode ?? (id === REPORT_ONLY_TEMPLATE_ID ? "main" : "required"),
|
|
6521
|
+
worktreeRoot: values.worktreeRoot,
|
|
6522
|
+
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
6523
|
+
timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
|
|
6524
|
+
};
|
|
6525
|
+
if (id === TASK_LIFECYCLE_TEMPLATE_ID) {
|
|
6526
|
+
const taskId = values.taskId ?? "";
|
|
6527
|
+
if (!taskId.trim())
|
|
6528
|
+
throw new Error("taskId is required");
|
|
6529
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6530
|
+
...common,
|
|
6531
|
+
name: values.name ?? `task-lifecycle-${slugSegment(taskId)}-worker-verifier`,
|
|
6532
|
+
objective: values.objective ?? `Run the full task lifecycle for todos task ${taskId}.`,
|
|
6533
|
+
prompt: values.prompt ?? "Triage and dedupe the task, verify it is eligible for loop execution, create or update a concise plan artifact/comment, execute only the allowed scope, validate, record evidence, and let the verifier decide final task state. Add follow-up tasks instead of broadening scope."
|
|
6534
|
+
});
|
|
6535
|
+
}
|
|
6536
|
+
if (id === PR_REVIEW_TEMPLATE_ID) {
|
|
6537
|
+
const pr = values.prUrl ?? values.prNumber ?? "";
|
|
6538
|
+
if (!pr.trim())
|
|
6539
|
+
throw new Error("prUrl or prNumber is required");
|
|
6540
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6541
|
+
...common,
|
|
6542
|
+
name: values.name ?? `pr-review-${slugSegment(pr)}-worker-verifier`,
|
|
6543
|
+
objective: values.objective ?? `Review and drive PR ${pr} toward merge-ready state.`,
|
|
6544
|
+
prompt: values.prompt ?? "Inspect PR state, checks, conflicts, branch freshness, review requirements, and repo policy. Apply only owned logical fixes in the isolated worktree, validate, update the PR/task with evidence, and do not merge unless policy/checks make it clearly safe."
|
|
6545
|
+
});
|
|
6546
|
+
}
|
|
6547
|
+
if (id === SCHEDULED_AUDIT_TEMPLATE_ID) {
|
|
6548
|
+
const objective = values.objective ?? "";
|
|
6549
|
+
if (!objective.trim())
|
|
6550
|
+
throw new Error("objective is required");
|
|
6551
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6552
|
+
...common,
|
|
6553
|
+
name: values.name ?? `scheduled-audit-${stableIndex(`${projectPath}:${objective}`, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
|
|
6554
|
+
objective,
|
|
6555
|
+
prompt: values.prompt ?? "Run the bounded audit, write compact evidence, create deduped todos tasks for actionable issues, and avoid implementation unless the task explicitly allows it."
|
|
6556
|
+
});
|
|
6557
|
+
}
|
|
6558
|
+
if (id === KNOWLEDGE_REFRESH_TEMPLATE_ID) {
|
|
6559
|
+
const scope = values.scope ?? values.label ?? "recent knowledge";
|
|
6560
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6561
|
+
...common,
|
|
6562
|
+
name: values.name ?? `knowledge-refresh-${slugSegment(scope)}-worker-verifier`,
|
|
6563
|
+
objective: values.objective ?? `Refresh and verify ${scope}.`,
|
|
6564
|
+
prompt: values.prompt ?? "Inspect recent knowledge records, improve structure/schema where appropriate, avoid duplicates, create tasks for code changes instead of doing unrelated implementation, and record verification evidence."
|
|
6565
|
+
});
|
|
6566
|
+
}
|
|
6567
|
+
if (id === REPORT_ONLY_TEMPLATE_ID) {
|
|
6568
|
+
const objective = values.objective ?? "";
|
|
6569
|
+
if (!objective.trim())
|
|
6570
|
+
throw new Error("objective is required");
|
|
6571
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6572
|
+
...common,
|
|
6573
|
+
name: values.name ?? `report-only-${stableIndex(`${projectPath}:${objective}`, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
|
|
6574
|
+
objective,
|
|
6575
|
+
prompt: values.prompt ?? "Produce a report only. Do not mutate repositories, tasks, secrets, databases, or external systems except for writing the requested report/evidence artifact."
|
|
6576
|
+
});
|
|
6577
|
+
}
|
|
6578
|
+
if (id === INCIDENT_RESPONSE_TEMPLATE_ID) {
|
|
6579
|
+
const objective = values.objective ?? "";
|
|
6580
|
+
if (!objective.trim())
|
|
6581
|
+
throw new Error("objective is required");
|
|
6582
|
+
const incident = values.incidentId ?? values.taskId ?? "incident";
|
|
6583
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6584
|
+
...common,
|
|
6585
|
+
name: values.name ?? `incident-response-${slugSegment(incident)}-worker-verifier`,
|
|
6586
|
+
objective,
|
|
6587
|
+
prompt: values.prompt ?? "Triage first, gather bounded evidence, mitigate only narrow allowed issues, preserve data/history/secrets, create follow-up tasks for larger fixes, and require verifier confirmation before closure."
|
|
6588
|
+
});
|
|
6589
|
+
}
|
|
6590
|
+
return;
|
|
6591
|
+
}
|
|
6592
|
+
function renderDeterministicCheckCreateTaskWorkflow(values) {
|
|
6593
|
+
const projectPath = values.projectPath ?? values.cwd ?? process.cwd();
|
|
6594
|
+
const checkCommand = values.checkCommand ?? "";
|
|
6595
|
+
if (!checkCommand.trim())
|
|
6596
|
+
throw new Error("checkCommand is required");
|
|
6597
|
+
const seed = `${projectPath}:${checkCommand}`;
|
|
6598
|
+
return {
|
|
6599
|
+
name: values.name ?? `deterministic-check-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}`,
|
|
6600
|
+
description: values.description ?? "Deterministic check that writes compact evidence and upserts one deduped todos task when the expectation is not met.",
|
|
6601
|
+
version: 1,
|
|
6602
|
+
steps: [
|
|
6603
|
+
{
|
|
6604
|
+
id: "check",
|
|
6605
|
+
name: "Check",
|
|
6606
|
+
description: "Run the deterministic check/task-upsert command.",
|
|
6607
|
+
target: {
|
|
6608
|
+
type: "command",
|
|
6609
|
+
command: "bash",
|
|
6610
|
+
args: ["-lc", checkCommand],
|
|
6611
|
+
cwd: projectPath,
|
|
6612
|
+
timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000,
|
|
6613
|
+
idleTimeoutMs: values.idleTimeoutMs ? Number(values.idleTimeoutMs) : 60000
|
|
6614
|
+
},
|
|
6615
|
+
timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000
|
|
6616
|
+
}
|
|
6617
|
+
]
|
|
6618
|
+
};
|
|
6619
|
+
}
|
|
5909
6620
|
function renderLoopTemplate(id, values) {
|
|
6621
|
+
if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
|
|
6622
|
+
return renderDeterministicCheckCreateTaskWorkflow(values);
|
|
6623
|
+
}
|
|
6624
|
+
const lifecycle = renderLifecycleBoundedTemplate(id, values);
|
|
6625
|
+
if (lifecycle)
|
|
6626
|
+
return lifecycle;
|
|
5910
6627
|
if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
|
|
5911
6628
|
return renderTodosTaskWorkerVerifierWorkflow({
|
|
5912
6629
|
taskId: values.taskId ?? "",
|
|
@@ -5927,6 +6644,7 @@ function renderLoopTemplate(id, values) {
|
|
|
5927
6644
|
agent: values.agent,
|
|
5928
6645
|
permissionMode: values.permissionMode,
|
|
5929
6646
|
sandbox: values.sandbox,
|
|
6647
|
+
manualBreakGlass: booleanVar(values.manualBreakGlass),
|
|
5930
6648
|
worktreeMode: values.worktreeMode,
|
|
5931
6649
|
worktreeRoot: values.worktreeRoot,
|
|
5932
6650
|
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
@@ -5957,6 +6675,7 @@ function renderLoopTemplate(id, values) {
|
|
|
5957
6675
|
agent: values.agent,
|
|
5958
6676
|
permissionMode: values.permissionMode,
|
|
5959
6677
|
sandbox: values.sandbox,
|
|
6678
|
+
manualBreakGlass: booleanVar(values.manualBreakGlass),
|
|
5960
6679
|
worktreeMode: values.worktreeMode,
|
|
5961
6680
|
worktreeRoot: values.worktreeRoot,
|
|
5962
6681
|
worktreeBranchPrefix: values.worktreeBranchPrefix
|
|
@@ -5982,6 +6701,7 @@ function renderLoopTemplate(id, values) {
|
|
|
5982
6701
|
agent: values.agent,
|
|
5983
6702
|
permissionMode: values.permissionMode,
|
|
5984
6703
|
sandbox: values.sandbox,
|
|
6704
|
+
manualBreakGlass: booleanVar(values.manualBreakGlass),
|
|
5985
6705
|
worktreeMode: values.worktreeMode,
|
|
5986
6706
|
worktreeRoot: values.worktreeRoot,
|
|
5987
6707
|
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
@@ -5994,6 +6714,16 @@ function listVar(value) {
|
|
|
5994
6714
|
const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
5995
6715
|
return values?.length ? values : undefined;
|
|
5996
6716
|
}
|
|
6717
|
+
function booleanVar(value) {
|
|
6718
|
+
if (value === undefined)
|
|
6719
|
+
return;
|
|
6720
|
+
const normalized = value.trim().toLowerCase();
|
|
6721
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
6722
|
+
return true;
|
|
6723
|
+
if (["0", "false", "no", "off", ""].includes(normalized))
|
|
6724
|
+
return false;
|
|
6725
|
+
throw new Error(`expected boolean value, got ${value}`);
|
|
6726
|
+
}
|
|
5997
6727
|
function accountPoolVar(value, tool) {
|
|
5998
6728
|
return listVar(value)?.map((profile) => ({ profile, tool }));
|
|
5999
6729
|
}
|
|
@@ -6224,8 +6954,8 @@ function runLocalCommand(command, args, opts = {}) {
|
|
|
6224
6954
|
};
|
|
6225
6955
|
}
|
|
6226
6956
|
function runLocalCommandWithStdoutFile(command, args, opts = {}) {
|
|
6227
|
-
const tempDir =
|
|
6228
|
-
const stdoutPath =
|
|
6957
|
+
const tempDir = mkdtempSync2(join6(tmpdir2(), "loops-command-output-"));
|
|
6958
|
+
const stdoutPath = join6(tempDir, "stdout");
|
|
6229
6959
|
const stdoutFd = openSync2(stdoutPath, "w");
|
|
6230
6960
|
let result;
|
|
6231
6961
|
try {
|
|
@@ -6249,7 +6979,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
|
|
|
6249
6979
|
error: result.error ? String(result.error.message || result.error) : ""
|
|
6250
6980
|
};
|
|
6251
6981
|
} finally {
|
|
6252
|
-
|
|
6982
|
+
rmSync3(tempDir, { recursive: true, force: true });
|
|
6253
6983
|
}
|
|
6254
6984
|
}
|
|
6255
6985
|
function ensureTodosTaskList(project, slug, name, description) {
|
|
@@ -6265,23 +6995,23 @@ function ensureTodosTaskList(project, slug, name, description) {
|
|
|
6265
6995
|
}
|
|
6266
6996
|
function backupLoopsDatabase(reason) {
|
|
6267
6997
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
|
|
6268
|
-
const backupDir =
|
|
6269
|
-
|
|
6270
|
-
const backupPath =
|
|
6998
|
+
const backupDir = join6(dataDir(), "backups");
|
|
6999
|
+
mkdirSync6(backupDir, { recursive: true, mode: 448 });
|
|
7000
|
+
const backupPath = join6(backupDir, `loops.db.bak-${reason}-${stamp}`);
|
|
6271
7001
|
const db = new Database2(dbPath(), { readonly: true });
|
|
6272
7002
|
try {
|
|
6273
|
-
|
|
7003
|
+
writeFileSync4(backupPath, db.serialize(), { mode: 384 });
|
|
6274
7004
|
} finally {
|
|
6275
7005
|
db.close();
|
|
6276
7006
|
}
|
|
6277
7007
|
return backupPath;
|
|
6278
7008
|
}
|
|
6279
7009
|
function stableHash(parts) {
|
|
6280
|
-
return
|
|
7010
|
+
return createHash3("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
|
|
6281
7011
|
`)).digest("hex").slice(0, 16);
|
|
6282
7012
|
}
|
|
6283
7013
|
function routeCursorsPath() {
|
|
6284
|
-
return
|
|
7014
|
+
return join6(dataDir(), "route-cursors.json");
|
|
6285
7015
|
}
|
|
6286
7016
|
function readRouteCursors() {
|
|
6287
7017
|
const path = routeCursorsPath();
|
|
@@ -6299,15 +7029,15 @@ function writeRouteCursor(key, lastFingerprint) {
|
|
|
6299
7029
|
return;
|
|
6300
7030
|
const cursors = readRouteCursors();
|
|
6301
7031
|
cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
|
|
6302
|
-
|
|
7032
|
+
writeFileSync4(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
|
|
6303
7033
|
}
|
|
6304
7034
|
function writeRouteEvidence(kind, value, evidenceDir) {
|
|
6305
7035
|
if (!evidenceDir)
|
|
6306
7036
|
return;
|
|
6307
|
-
|
|
7037
|
+
mkdirSync6(evidenceDir, { recursive: true, mode: 448 });
|
|
6308
7038
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
6309
|
-
const evidencePath =
|
|
6310
|
-
|
|
7039
|
+
const evidencePath = join6(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
7040
|
+
writeFileSync4(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
|
|
6311
7041
|
return evidencePath;
|
|
6312
7042
|
}
|
|
6313
7043
|
function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
@@ -6426,7 +7156,7 @@ function slugSegment2(value, fallback = "event") {
|
|
|
6426
7156
|
return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
|
|
6427
7157
|
}
|
|
6428
7158
|
function stableSuffix(value) {
|
|
6429
|
-
return
|
|
7159
|
+
return createHash3("sha256").update(value).digest("hex").slice(0, 12);
|
|
6430
7160
|
}
|
|
6431
7161
|
function taskEventField(data, keys) {
|
|
6432
7162
|
for (const key of keys) {
|
|
@@ -6561,6 +7291,33 @@ function taskRouteEligibility(data, metadata) {
|
|
|
6561
7291
|
}
|
|
6562
7292
|
return { eligible: true, tags };
|
|
6563
7293
|
}
|
|
7294
|
+
function generatedRouteSandboxPreflight(workflow) {
|
|
7295
|
+
const checks = [];
|
|
7296
|
+
for (const step of workflow.steps) {
|
|
7297
|
+
if (step.target.type !== "agent")
|
|
7298
|
+
continue;
|
|
7299
|
+
const target = step.target;
|
|
7300
|
+
const worktreeEnabled = Boolean(target.worktree?.enabled);
|
|
7301
|
+
if (target.sandbox === "danger-full-access") {
|
|
7302
|
+
const manual = target.allowlist?.commands?.includes("manual-break-glass");
|
|
7303
|
+
if (!manual) {
|
|
7304
|
+
throw new Error(`route step ${step.id} uses danger-full-access without manual break-glass evidence`);
|
|
7305
|
+
}
|
|
7306
|
+
checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "manual-break-glass" });
|
|
7307
|
+
continue;
|
|
7308
|
+
}
|
|
7309
|
+
if (["codewith", "codex"].includes(target.provider) && (target.sandbox === "workspace-write" || target.sandbox === "read-only") || target.provider === "cursor" && target.sandbox === "enabled") {
|
|
7310
|
+
checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "provider-native-sandbox" });
|
|
7311
|
+
continue;
|
|
7312
|
+
}
|
|
7313
|
+
if (worktreeEnabled) {
|
|
7314
|
+
checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "isolated-worktree" });
|
|
7315
|
+
continue;
|
|
7316
|
+
}
|
|
7317
|
+
throw new Error(`route step ${step.id} has no verified unattended isolation; use provider sandbox workspace-write/read-only/enabled, worktreeMode=required, or explicit manual break-glass`);
|
|
7318
|
+
}
|
|
7319
|
+
return checks;
|
|
7320
|
+
}
|
|
6564
7321
|
function routeThrottleLimitsFromOpts(opts) {
|
|
6565
7322
|
return {
|
|
6566
7323
|
maxActive: positiveInteger(opts.maxActive, "--max-active"),
|
|
@@ -6594,46 +7351,10 @@ function normalizeRoutePath(value) {
|
|
|
6594
7351
|
function routeProjectGroup(optsGroup, data, metadata) {
|
|
6595
7352
|
return optsGroup?.trim() || taskEventField(data, ["project_group", "projectGroup", "repo_group", "repoGroup", "workspace_group", "workspaceGroup"]) || taskEventField(metadata, ["project_group", "projectGroup", "repo_group", "repoGroup", "workspace_group", "workspaceGroup"]);
|
|
6596
7353
|
}
|
|
6597
|
-
function firstWorkflowRouting(workflow) {
|
|
6598
|
-
for (const step of workflow.steps) {
|
|
6599
|
-
if (step.target.type !== "agent")
|
|
6600
|
-
continue;
|
|
6601
|
-
const target = step.target;
|
|
6602
|
-
const projectPath = normalizeRoutePath(target.routing?.projectPath ?? target.worktree?.originalCwd ?? target.cwd);
|
|
6603
|
-
const projectGroup = target.routing?.projectGroup?.trim();
|
|
6604
|
-
if (projectPath || projectGroup) {
|
|
6605
|
-
return {
|
|
6606
|
-
...projectPath ? { projectPath } : {},
|
|
6607
|
-
...projectGroup ? { projectGroup } : {}
|
|
6608
|
-
};
|
|
6609
|
-
}
|
|
6610
|
-
}
|
|
6611
|
-
return;
|
|
6612
|
-
}
|
|
6613
|
-
function activeWorkflowLoopRoutes(store) {
|
|
6614
|
-
const values = [];
|
|
6615
|
-
for (const loop of store.listLoops({ status: "active", limit: 1e4 })) {
|
|
6616
|
-
if (loop.target.type !== "workflow")
|
|
6617
|
-
continue;
|
|
6618
|
-
const workflow = store.getWorkflow(loop.target.workflowId);
|
|
6619
|
-
if (!workflow || workflow.status !== "active")
|
|
6620
|
-
continue;
|
|
6621
|
-
const routing = firstWorkflowRouting(workflow);
|
|
6622
|
-
if (routing)
|
|
6623
|
-
values.push({ loop, routing });
|
|
6624
|
-
}
|
|
6625
|
-
return values;
|
|
6626
|
-
}
|
|
6627
7354
|
function routeThrottleDecision(store, args) {
|
|
6628
7355
|
const projectPath = normalizeRoutePath(args.projectPath) ?? resolve2(args.projectPath);
|
|
6629
7356
|
const projectGroup = args.projectGroup?.trim() || undefined;
|
|
6630
|
-
const
|
|
6631
|
-
const counts = {
|
|
6632
|
-
global: active.length,
|
|
6633
|
-
project: active.filter((entry) => normalizeRoutePath(entry.routing.projectPath) === projectPath).length
|
|
6634
|
-
};
|
|
6635
|
-
if (projectGroup)
|
|
6636
|
-
counts.projectGroup = active.filter((entry) => entry.routing.projectGroup?.trim() === projectGroup).length;
|
|
7357
|
+
const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup });
|
|
6637
7358
|
const base = {
|
|
6638
7359
|
projectPath,
|
|
6639
7360
|
...projectGroup ? { projectGroup } : {},
|
|
@@ -6666,12 +7387,8 @@ function routeThrottleDryRunPreview(args) {
|
|
|
6666
7387
|
limits: args.limits
|
|
6667
7388
|
};
|
|
6668
7389
|
}
|
|
6669
|
-
function
|
|
6670
|
-
const
|
|
6671
|
-
return store.listLoops({ includeArchived: true, limit: 1e5 }).find((loop) => loop.description?.includes(marker));
|
|
6672
|
-
}
|
|
6673
|
-
async function readEventEnvelopeFromStdin() {
|
|
6674
|
-
const raw = process.env.HASNA_EVENT_JSON || await Bun.stdin.text();
|
|
7390
|
+
async function readEventEnvelopeInput(opts = {}) {
|
|
7391
|
+
const raw = opts.eventJson ?? (opts.eventFile ? readFileSync2(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
|
|
6675
7392
|
const event = JSON.parse(raw);
|
|
6676
7393
|
if (!event || typeof event !== "object" || Array.isArray(event))
|
|
6677
7394
|
throw new Error("event JSON must be an object");
|
|
@@ -6683,6 +7400,9 @@ async function readEventEnvelopeFromStdin() {
|
|
|
6683
7400
|
throw new Error("event.source is required");
|
|
6684
7401
|
return event;
|
|
6685
7402
|
}
|
|
7403
|
+
async function readEventEnvelopeFromStdin() {
|
|
7404
|
+
return readEventEnvelopeInput();
|
|
7405
|
+
}
|
|
6686
7406
|
function routeTodosTaskEvent(event, opts) {
|
|
6687
7407
|
const data = eventData(event);
|
|
6688
7408
|
const metadata = eventMetadata(event);
|
|
@@ -6717,24 +7437,27 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6717
7437
|
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
6718
7438
|
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
6719
7439
|
const loopName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
|
|
6720
|
-
const legacyLoopName = `${namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
|
|
6721
7440
|
if (!opts.dryRun) {
|
|
6722
7441
|
const store2 = new Store;
|
|
6723
7442
|
try {
|
|
6724
|
-
const
|
|
6725
|
-
if (
|
|
6726
|
-
const
|
|
7443
|
+
const existingItem = store2.findWorkflowWorkItem("todos-task", idempotencyKey);
|
|
7444
|
+
if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
|
|
7445
|
+
const existingLoop = store2.getLoop(existingItem.loopId);
|
|
7446
|
+
const existingWorkflow = existingItem.workflowId ? store2.getWorkflow(existingItem.workflowId) : undefined;
|
|
7447
|
+
const existingInvocation = store2.getWorkflowInvocation(existingItem.invocationId);
|
|
6727
7448
|
return {
|
|
6728
7449
|
kind: "deduped",
|
|
6729
7450
|
value: {
|
|
6730
7451
|
deduped: true,
|
|
6731
7452
|
idempotencyKey,
|
|
6732
|
-
dedupedBy:
|
|
7453
|
+
dedupedBy: "work-item",
|
|
6733
7454
|
event,
|
|
7455
|
+
invocation: existingInvocation ? publicWorkflowInvocation(existingInvocation) : undefined,
|
|
7456
|
+
workItem: publicWorkflowWorkItem(existingItem),
|
|
6734
7457
|
workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined,
|
|
6735
|
-
loop: publicLoop(existingLoop)
|
|
7458
|
+
loop: existingLoop ? publicLoop(existingLoop) : undefined
|
|
6736
7459
|
},
|
|
6737
|
-
human: `deduped existing
|
|
7460
|
+
human: `deduped existing work item ${existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
|
|
6738
7461
|
};
|
|
6739
7462
|
}
|
|
6740
7463
|
} finally {
|
|
@@ -6768,6 +7491,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6768
7491
|
agent: opts.agent,
|
|
6769
7492
|
permissionMode,
|
|
6770
7493
|
sandbox,
|
|
7494
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
6771
7495
|
worktreeMode: opts.worktreeMode ?? "auto",
|
|
6772
7496
|
worktreeRoot: opts.worktreeRoot,
|
|
6773
7497
|
worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
|
|
@@ -6776,11 +7500,53 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6776
7500
|
});
|
|
6777
7501
|
workflowBody.name = workflowName;
|
|
6778
7502
|
workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
7503
|
+
const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
|
|
7504
|
+
const invocationInput = {
|
|
7505
|
+
templateId: "todos-task-worker-verifier",
|
|
7506
|
+
sourceRef: {
|
|
7507
|
+
kind: "event",
|
|
7508
|
+
id: event.id,
|
|
7509
|
+
dedupeKey: idempotencyKey,
|
|
7510
|
+
raw: { type: event.type, source: event.source, subject: event.subject }
|
|
7511
|
+
},
|
|
7512
|
+
subjectRef: {
|
|
7513
|
+
kind: "task",
|
|
7514
|
+
id: taskId,
|
|
7515
|
+
path: routeProjectPath,
|
|
7516
|
+
raw: { title: taskTitle, description: taskDescription }
|
|
7517
|
+
},
|
|
7518
|
+
intent: "route",
|
|
7519
|
+
scope: {
|
|
7520
|
+
projectPath: routeProjectPath,
|
|
7521
|
+
projectGroup,
|
|
7522
|
+
worktreePolicy: opts.worktreeMode ?? "auto",
|
|
7523
|
+
permissions: permissionMode,
|
|
7524
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
7525
|
+
accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
|
|
7526
|
+
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
7527
|
+
},
|
|
7528
|
+
outputPolicy: {
|
|
7529
|
+
report: "always",
|
|
7530
|
+
createTask: "on_failure"
|
|
7531
|
+
}
|
|
7532
|
+
};
|
|
7533
|
+
const workItemInput = {
|
|
7534
|
+
routeKey: "todos-task",
|
|
7535
|
+
idempotencyKey,
|
|
7536
|
+
invocationId: "<created-invocation-id>",
|
|
7537
|
+
sourceType: event.type,
|
|
7538
|
+
sourceRef: event.id,
|
|
7539
|
+
subjectRef: taskId,
|
|
7540
|
+
projectKey: routeProjectPath,
|
|
7541
|
+
projectGroup,
|
|
7542
|
+
priority: 0,
|
|
7543
|
+
status: "queued"
|
|
7544
|
+
};
|
|
6779
7545
|
const loopInput = {
|
|
6780
7546
|
name: loopName,
|
|
6781
7547
|
description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
|
|
6782
7548
|
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
6783
|
-
target: { type: "workflow", workflowId: "<created-workflow-id>" },
|
|
7549
|
+
target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
|
|
6784
7550
|
overlap: "skip",
|
|
6785
7551
|
maxAttempts: 1,
|
|
6786
7552
|
retryDelayMs: 60000,
|
|
@@ -6795,7 +7561,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6795
7561
|
}, {}) : undefined;
|
|
6796
7562
|
return {
|
|
6797
7563
|
kind: "created",
|
|
6798
|
-
value: { deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, throttle, preflight },
|
|
7564
|
+
value: { deduped: false, idempotencyKey, event, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
|
|
6799
7565
|
human: `dry-run ${loopName}`
|
|
6800
7566
|
};
|
|
6801
7567
|
}
|
|
@@ -6803,27 +7569,44 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6803
7569
|
try {
|
|
6804
7570
|
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
6805
7571
|
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
7572
|
+
generatedRouteSandboxPreflight(workflowPreflightSpec);
|
|
6806
7573
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
6807
7574
|
name: workflowBody.name,
|
|
6808
7575
|
type: "todos-task-event-workflow",
|
|
6809
7576
|
event: event.id
|
|
6810
7577
|
}, {}) : undefined;
|
|
6811
7578
|
const outcome = store.writeTransaction(() => {
|
|
6812
|
-
const
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
7579
|
+
const invocation = store.createWorkflowInvocation(invocationInput);
|
|
7580
|
+
const existingItem = store.findWorkflowWorkItem("todos-task", idempotencyKey);
|
|
7581
|
+
if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
|
|
7582
|
+
const existingLoop = store.getLoop(existingItem.loopId);
|
|
7583
|
+
const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
7584
|
+
return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
|
|
6816
7585
|
}
|
|
6817
7586
|
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
7587
|
+
const workItem = store.upsertWorkflowWorkItem({
|
|
7588
|
+
...workItemInput,
|
|
7589
|
+
invocationId: invocation.id,
|
|
7590
|
+
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
7591
|
+
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
7592
|
+
});
|
|
6818
7593
|
if (throttle && !throttle.allowed)
|
|
6819
|
-
return { kind: "throttled", throttle };
|
|
7594
|
+
return { kind: "throttled", invocation, workItem, throttle };
|
|
6820
7595
|
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
6821
7596
|
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
6822
7597
|
const loop = store.createLoop({
|
|
6823
7598
|
...loopInput,
|
|
6824
|
-
target: {
|
|
7599
|
+
target: {
|
|
7600
|
+
type: "workflow",
|
|
7601
|
+
workflowId: workflow.id,
|
|
7602
|
+
input: {
|
|
7603
|
+
workflowInvocationId: invocation.id,
|
|
7604
|
+
workflowWorkItemId: workItem.id
|
|
7605
|
+
}
|
|
7606
|
+
}
|
|
6825
7607
|
});
|
|
6826
|
-
|
|
7608
|
+
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by todos-task route" });
|
|
7609
|
+
return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
|
|
6827
7610
|
});
|
|
6828
7611
|
if (outcome.kind === "deduped") {
|
|
6829
7612
|
return {
|
|
@@ -6831,12 +7614,14 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6831
7614
|
value: {
|
|
6832
7615
|
deduped: true,
|
|
6833
7616
|
idempotencyKey,
|
|
6834
|
-
dedupedBy:
|
|
7617
|
+
dedupedBy: "work-item",
|
|
6835
7618
|
event,
|
|
7619
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7620
|
+
workItem: publicWorkflowWorkItem(outcome.existingItem),
|
|
6836
7621
|
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
6837
|
-
loop: publicLoop(outcome.existingLoop)
|
|
7622
|
+
loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
|
|
6838
7623
|
},
|
|
6839
|
-
human: `deduped existing
|
|
7624
|
+
human: `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
|
|
6840
7625
|
};
|
|
6841
7626
|
}
|
|
6842
7627
|
if (outcome.kind === "throttled") {
|
|
@@ -6848,6 +7633,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6848
7633
|
reason: outcome.throttle.reason,
|
|
6849
7634
|
idempotencyKey,
|
|
6850
7635
|
event,
|
|
7636
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7637
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
6851
7638
|
throttle: outcome.throttle,
|
|
6852
7639
|
workflow: workflowBody,
|
|
6853
7640
|
loop: loopInput
|
|
@@ -6861,9 +7648,12 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6861
7648
|
deduped: false,
|
|
6862
7649
|
idempotencyKey,
|
|
6863
7650
|
event,
|
|
7651
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7652
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
6864
7653
|
workflow: publicWorkflow(outcome.workflow),
|
|
6865
7654
|
loop: publicLoop(outcome.loop),
|
|
6866
7655
|
throttle: outcome.throttle,
|
|
7656
|
+
sandboxPreflight,
|
|
6867
7657
|
preflight
|
|
6868
7658
|
},
|
|
6869
7659
|
human: `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`
|
|
@@ -6872,14 +7662,226 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6872
7662
|
store.close();
|
|
6873
7663
|
}
|
|
6874
7664
|
}
|
|
6875
|
-
function
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
7665
|
+
function routeGenericEvent(event, opts) {
|
|
7666
|
+
const data = eventData(event);
|
|
7667
|
+
const metadata = eventMetadata(event);
|
|
7668
|
+
const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd", "repo_path", "repoPath"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "project_path", "projectPath", "project_canonical_path", "cwd", "repo_path", "repoPath"]) ?? process.cwd();
|
|
7669
|
+
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
|
|
7670
|
+
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
7671
|
+
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
7672
|
+
const eventSuffix = event.id.slice(0, 8);
|
|
7673
|
+
const source = slugSegment2(event.source, "source");
|
|
7674
|
+
const type = slugSegment2(event.type, "type");
|
|
7675
|
+
const workflowName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:workflow`;
|
|
7676
|
+
const loopName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:run`;
|
|
7677
|
+
const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
|
|
7678
|
+
const provider = opts.provider ?? "codewith";
|
|
7679
|
+
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
|
|
7680
|
+
throw new Error("unsupported provider");
|
|
7681
|
+
const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
|
|
7682
|
+
const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
|
|
7683
|
+
const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
|
|
7684
|
+
const workflowBody = renderEventWorkerVerifierWorkflow({
|
|
7685
|
+
eventId: event.id,
|
|
7686
|
+
eventType: event.type,
|
|
7687
|
+
eventSource: event.source,
|
|
7688
|
+
eventSubject: stringField(event.subject),
|
|
7689
|
+
eventMessage: stringField(event.message),
|
|
7690
|
+
eventJson: JSON.stringify(event),
|
|
7691
|
+
projectPath,
|
|
7692
|
+
routeProjectPath,
|
|
7693
|
+
projectGroup,
|
|
7694
|
+
provider,
|
|
7695
|
+
authProfile,
|
|
7696
|
+
authProfilePool: splitList(opts.authProfilePool),
|
|
7697
|
+
workerAuthProfile: opts.workerAuthProfile,
|
|
7698
|
+
verifierAuthProfile: opts.verifierAuthProfile,
|
|
7699
|
+
account: accountFromOpts(opts),
|
|
7700
|
+
accountPool: accountPoolFromOpts(opts),
|
|
7701
|
+
workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
|
|
7702
|
+
verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
|
|
7703
|
+
model: opts.model,
|
|
7704
|
+
variant: opts.variant,
|
|
7705
|
+
agent: opts.agent,
|
|
7706
|
+
permissionMode,
|
|
7707
|
+
sandbox,
|
|
7708
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
7709
|
+
worktreeMode: opts.worktreeMode ?? "auto",
|
|
7710
|
+
worktreeRoot: opts.worktreeRoot,
|
|
7711
|
+
worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops"
|
|
7712
|
+
});
|
|
7713
|
+
workflowBody.name = workflowName;
|
|
7714
|
+
workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
7715
|
+
const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
|
|
7716
|
+
const invocationInput = {
|
|
7717
|
+
templateId: "event-worker-verifier",
|
|
7718
|
+
sourceRef: {
|
|
7719
|
+
kind: "event",
|
|
7720
|
+
id: event.id,
|
|
7721
|
+
dedupeKey: idempotencyKey,
|
|
7722
|
+
raw: { source: event.source, type: event.type }
|
|
7723
|
+
},
|
|
7724
|
+
subjectRef: {
|
|
7725
|
+
kind: "event",
|
|
7726
|
+
id: stringField(event.subject) ?? event.id,
|
|
7727
|
+
path: routeProjectPath,
|
|
7728
|
+
raw: { message: stringField(event.message) }
|
|
7729
|
+
},
|
|
7730
|
+
intent: "route",
|
|
7731
|
+
scope: {
|
|
7732
|
+
projectPath: routeProjectPath,
|
|
7733
|
+
projectGroup,
|
|
7734
|
+
worktreePolicy: opts.worktreeMode ?? "auto",
|
|
7735
|
+
permissions: permissionMode,
|
|
7736
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
7737
|
+
accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
|
|
7738
|
+
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
7739
|
+
},
|
|
7740
|
+
outputPolicy: {
|
|
7741
|
+
report: "always",
|
|
7742
|
+
createTask: "on_failure"
|
|
7743
|
+
}
|
|
7744
|
+
};
|
|
7745
|
+
const workItemInput = {
|
|
7746
|
+
routeKey: "generic-event",
|
|
7747
|
+
idempotencyKey,
|
|
7748
|
+
invocationId: "<created-invocation-id>",
|
|
7749
|
+
sourceType: event.type,
|
|
7750
|
+
sourceRef: event.id,
|
|
7751
|
+
subjectRef: stringField(event.subject) ?? event.id,
|
|
7752
|
+
projectKey: routeProjectPath,
|
|
7753
|
+
projectGroup,
|
|
7754
|
+
priority: 0,
|
|
7755
|
+
status: "queued"
|
|
7756
|
+
};
|
|
7757
|
+
const loopInput = {
|
|
7758
|
+
name: loopName,
|
|
7759
|
+
description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
|
|
7760
|
+
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
7761
|
+
target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
|
|
7762
|
+
overlap: "skip",
|
|
7763
|
+
maxAttempts: 1,
|
|
7764
|
+
retryDelayMs: 60000,
|
|
7765
|
+
leaseMs: 90 * 60000
|
|
7766
|
+
};
|
|
7767
|
+
if (opts.dryRun) {
|
|
7768
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
7769
|
+
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
|
|
7770
|
+
name: workflowBody.name,
|
|
7771
|
+
type: "generic-event-workflow",
|
|
7772
|
+
event: event.id
|
|
7773
|
+
}, {}) : undefined;
|
|
7774
|
+
return {
|
|
7775
|
+
kind: "created",
|
|
7776
|
+
value: { event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
|
|
7777
|
+
human: `dry-run ${loopName}`
|
|
7778
|
+
};
|
|
7779
|
+
}
|
|
7780
|
+
const store = new Store;
|
|
7781
|
+
try {
|
|
7782
|
+
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
7783
|
+
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
7784
|
+
generatedRouteSandboxPreflight(workflowPreflightSpec);
|
|
7785
|
+
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
7786
|
+
name: workflowBody.name,
|
|
7787
|
+
type: "generic-event-workflow",
|
|
7788
|
+
event: event.id
|
|
7789
|
+
}, {}) : undefined;
|
|
7790
|
+
const outcome = store.writeTransaction(() => {
|
|
7791
|
+
const invocation = store.createWorkflowInvocation(invocationInput);
|
|
7792
|
+
const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
|
|
7793
|
+
if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
|
|
7794
|
+
const existingLoop = store.getLoop(existingItem.loopId);
|
|
7795
|
+
const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
7796
|
+
return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
|
|
7797
|
+
}
|
|
7798
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
7799
|
+
const workItem = store.upsertWorkflowWorkItem({
|
|
7800
|
+
...workItemInput,
|
|
7801
|
+
invocationId: invocation.id,
|
|
7802
|
+
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
7803
|
+
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
7804
|
+
});
|
|
7805
|
+
if (throttle && !throttle.allowed)
|
|
7806
|
+
return { kind: "throttled", invocation, workItem, throttle };
|
|
7807
|
+
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
7808
|
+
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
7809
|
+
const loop = store.createLoop({
|
|
7810
|
+
...loopInput,
|
|
7811
|
+
target: {
|
|
7812
|
+
type: "workflow",
|
|
7813
|
+
workflowId: workflow.id,
|
|
7814
|
+
input: {
|
|
7815
|
+
workflowInvocationId: invocation.id,
|
|
7816
|
+
workflowWorkItemId: workItem.id
|
|
7817
|
+
}
|
|
7818
|
+
}
|
|
7819
|
+
});
|
|
7820
|
+
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
|
|
7821
|
+
return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
|
|
7822
|
+
});
|
|
7823
|
+
if (outcome.kind === "deduped") {
|
|
7824
|
+
return {
|
|
7825
|
+
kind: "deduped",
|
|
7826
|
+
value: {
|
|
7827
|
+
deduped: true,
|
|
7828
|
+
idempotencyKey,
|
|
7829
|
+
dedupedBy: "work-item",
|
|
7830
|
+
event,
|
|
7831
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7832
|
+
workItem: publicWorkflowWorkItem(outcome.existingItem),
|
|
7833
|
+
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
7834
|
+
loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
|
|
7835
|
+
},
|
|
7836
|
+
human: `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
|
|
7837
|
+
};
|
|
7838
|
+
}
|
|
7839
|
+
if (outcome.kind === "throttled") {
|
|
7840
|
+
return {
|
|
7841
|
+
kind: "throttled",
|
|
7842
|
+
value: {
|
|
7843
|
+
skipped: true,
|
|
7844
|
+
queuedAtSource: true,
|
|
7845
|
+
reason: outcome.throttle.reason,
|
|
7846
|
+
idempotencyKey,
|
|
7847
|
+
event,
|
|
7848
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7849
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
7850
|
+
throttle: outcome.throttle,
|
|
7851
|
+
workflow: workflowBody,
|
|
7852
|
+
loop: loopInput
|
|
7853
|
+
},
|
|
7854
|
+
human: `skipped event ${event.id}: ${outcome.throttle.reason}`
|
|
7855
|
+
};
|
|
7856
|
+
}
|
|
7857
|
+
return {
|
|
7858
|
+
kind: "created",
|
|
7859
|
+
value: {
|
|
7860
|
+
deduped: false,
|
|
7861
|
+
idempotencyKey,
|
|
7862
|
+
event,
|
|
7863
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7864
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
7865
|
+
workflow: publicWorkflow(outcome.workflow),
|
|
7866
|
+
loop: publicLoop(outcome.loop),
|
|
7867
|
+
throttle: outcome.throttle,
|
|
7868
|
+
sandboxPreflight,
|
|
7869
|
+
preflight
|
|
7870
|
+
},
|
|
7871
|
+
human: `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`
|
|
7872
|
+
};
|
|
7873
|
+
} finally {
|
|
7874
|
+
store.close();
|
|
7875
|
+
}
|
|
7876
|
+
}
|
|
7877
|
+
function taskField(task, keys) {
|
|
7878
|
+
for (const key of keys) {
|
|
7879
|
+
const value = stringField(task[key]);
|
|
7880
|
+
if (value)
|
|
7881
|
+
return value;
|
|
7882
|
+
}
|
|
7883
|
+
return;
|
|
7884
|
+
}
|
|
6883
7885
|
function taskListId(task) {
|
|
6884
7886
|
return taskField(task, ["task_list_id", "taskListId"]) ?? stringField(task.task_list?.id);
|
|
6885
7887
|
}
|
|
@@ -7119,9 +8121,155 @@ addGoalOptions(addMachineOptions(addScheduleOptions(create.command("workflow <na
|
|
|
7119
8121
|
});
|
|
7120
8122
|
var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
|
|
7121
8123
|
var templates = program.command("templates").alias("template").description("render and store reusable loop/workflow templates");
|
|
8124
|
+
var routes = program.command("routes").alias("route").description("inspect workflow invocation/admission routes");
|
|
7122
8125
|
var events = program.command("events").description("handle Hasna event envelopes from stdin or command transport");
|
|
7123
8126
|
var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
|
|
7124
8127
|
var goal = program.command("goal").description("inspect goal runs");
|
|
8128
|
+
function addRouteEventOptions(command) {
|
|
8129
|
+
return command.option("--event-file <file>", "read event envelope JSON from a file instead of stdin/HASNA_EVENT_JSON").option("--event-json <json>", "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix").option("--preflight", "check generated workflow steps before storing the workflow loop");
|
|
8130
|
+
}
|
|
8131
|
+
function addTodosDrainOptions(command) {
|
|
8132
|
+
return command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing workflow loops").option("--dry-run", "preview selected tasks and generated workflow loops without storing anything");
|
|
8133
|
+
}
|
|
8134
|
+
function routeEventByKind(kind, event, opts) {
|
|
8135
|
+
if (kind === "todos-task")
|
|
8136
|
+
return routeTodosTaskEvent(event, opts);
|
|
8137
|
+
if (kind === "generic")
|
|
8138
|
+
return routeGenericEvent(event, opts);
|
|
8139
|
+
throw new Error("route kind must be todos-task or generic");
|
|
8140
|
+
}
|
|
8141
|
+
function routeDrainArgs(opts) {
|
|
8142
|
+
const args = ["events", "drain", "todos-task"];
|
|
8143
|
+
const add = (flag, value) => {
|
|
8144
|
+
if (value !== undefined && value !== false && value !== "")
|
|
8145
|
+
args.push(flag, String(value));
|
|
8146
|
+
};
|
|
8147
|
+
const addBool = (flag, value) => {
|
|
8148
|
+
if (value === true)
|
|
8149
|
+
args.push(flag);
|
|
8150
|
+
};
|
|
8151
|
+
add("--todos-project", opts.todosProject);
|
|
8152
|
+
add("--todos-project-id", opts.todosProjectId);
|
|
8153
|
+
add("--task-list", opts.taskList);
|
|
8154
|
+
add("--project-path-prefix", opts.projectPathPrefix);
|
|
8155
|
+
add("--tags", opts.tags ?? opts.tag);
|
|
8156
|
+
add("--limit", opts.limit);
|
|
8157
|
+
add("--scan-limit", opts.scanLimit);
|
|
8158
|
+
add("--max-dispatch", opts.maxDispatch);
|
|
8159
|
+
add("--evidence-dir", opts.evidenceDir);
|
|
8160
|
+
addBool("--compact", opts.compact);
|
|
8161
|
+
add("--provider", opts.provider);
|
|
8162
|
+
add("--auth-profile", opts.authProfile);
|
|
8163
|
+
add("--auth-profile-pool", opts.authProfilePool);
|
|
8164
|
+
add("--worker-auth-profile", opts.workerAuthProfile);
|
|
8165
|
+
add("--verifier-auth-profile", opts.verifierAuthProfile);
|
|
8166
|
+
add("--account", opts.account);
|
|
8167
|
+
add("--account-pool", opts.accountPool);
|
|
8168
|
+
add("--worker-account", opts.workerAccount);
|
|
8169
|
+
add("--verifier-account", opts.verifierAccount);
|
|
8170
|
+
add("--account-tool", opts.accountTool);
|
|
8171
|
+
add("--model", opts.model);
|
|
8172
|
+
add("--variant", opts.variant);
|
|
8173
|
+
add("--agent", opts.agent);
|
|
8174
|
+
add("--permission-mode", opts.permissionMode);
|
|
8175
|
+
add("--sandbox", opts.sandbox);
|
|
8176
|
+
addBool("--manual-break-glass", opts.manualBreakGlass);
|
|
8177
|
+
add("--project-path", opts.projectPath);
|
|
8178
|
+
add("--project-group", opts.projectGroup);
|
|
8179
|
+
add("--max-active", opts.maxActive);
|
|
8180
|
+
add("--max-active-per-project", opts.maxActivePerProject);
|
|
8181
|
+
add("--max-active-per-project-group", opts.maxActivePerProjectGroup);
|
|
8182
|
+
add("--worktree-mode", opts.worktreeMode);
|
|
8183
|
+
add("--worktree-root", opts.worktreeRoot);
|
|
8184
|
+
add("--worktree-branch-prefix", opts.worktreeBranchPrefix);
|
|
8185
|
+
add("--name-prefix", opts.namePrefix);
|
|
8186
|
+
addBool("--preflight", opts.preflight);
|
|
8187
|
+
return args;
|
|
8188
|
+
}
|
|
8189
|
+
function drainTodosTaskRoutes(opts) {
|
|
8190
|
+
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
8191
|
+
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
8192
|
+
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
8193
|
+
const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
|
|
8194
|
+
const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
|
|
8195
|
+
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
|
|
8196
|
+
const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
|
|
8197
|
+
const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
|
|
8198
|
+
const ready = loadReadyTodosTasks(opts, scanLimit);
|
|
8199
|
+
const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
|
|
8200
|
+
projectId: opts.todosProjectId,
|
|
8201
|
+
taskListId: taskListFilter,
|
|
8202
|
+
projectPathPrefix: opts.projectPathPrefix,
|
|
8203
|
+
tags: requiredTags
|
|
8204
|
+
}));
|
|
8205
|
+
const candidates = filteredCandidates.slice(0, candidateLimit);
|
|
8206
|
+
const results = [];
|
|
8207
|
+
let created = 0;
|
|
8208
|
+
for (const task of candidates) {
|
|
8209
|
+
if (created >= maxDispatch)
|
|
8210
|
+
break;
|
|
8211
|
+
const event = taskDrainEvent(task);
|
|
8212
|
+
const result = routeTodosTaskEvent(event, opts);
|
|
8213
|
+
results.push(result);
|
|
8214
|
+
if (result.kind === "created" && !opts.dryRun)
|
|
8215
|
+
created += 1;
|
|
8216
|
+
if (result.kind === "created" && opts.dryRun)
|
|
8217
|
+
created += 1;
|
|
8218
|
+
}
|
|
8219
|
+
const report = {
|
|
8220
|
+
drainedAt: new Date().toISOString(),
|
|
8221
|
+
todosProject,
|
|
8222
|
+
todosProjectId: opts.todosProjectId,
|
|
8223
|
+
taskList: opts.taskList,
|
|
8224
|
+
taskListId: taskListFilter,
|
|
8225
|
+
projectPathPrefix: opts.projectPathPrefix,
|
|
8226
|
+
tags: requiredTags,
|
|
8227
|
+
limit: candidateLimit,
|
|
8228
|
+
scanLimit,
|
|
8229
|
+
filtersApplied: hasPostFilters,
|
|
8230
|
+
scanned: ready.length,
|
|
8231
|
+
candidates: candidates.length,
|
|
8232
|
+
filteredCandidates: filteredCandidates.length,
|
|
8233
|
+
scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
|
|
8234
|
+
considered: results.length,
|
|
8235
|
+
created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
|
|
8236
|
+
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
8237
|
+
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
8238
|
+
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
8239
|
+
maxDispatch,
|
|
8240
|
+
source: "todos ready",
|
|
8241
|
+
dryRun: Boolean(opts.dryRun),
|
|
8242
|
+
results: results.map((result) => ({ kind: result.kind, ...result.value }))
|
|
8243
|
+
};
|
|
8244
|
+
const evidencePath = writeRouteEvidence("todos-task-drain", report, opts.evidenceDir);
|
|
8245
|
+
const output = opts.compact ? {
|
|
8246
|
+
drainedAt: report.drainedAt,
|
|
8247
|
+
todosProject: report.todosProject,
|
|
8248
|
+
todosProjectId: report.todosProjectId,
|
|
8249
|
+
taskList: report.taskList,
|
|
8250
|
+
taskListId: report.taskListId,
|
|
8251
|
+
projectPathPrefix: report.projectPathPrefix,
|
|
8252
|
+
tags: report.tags,
|
|
8253
|
+
limit: report.limit,
|
|
8254
|
+
scanLimit: report.scanLimit,
|
|
8255
|
+
filtersApplied: report.filtersApplied,
|
|
8256
|
+
scanned: report.scanned,
|
|
8257
|
+
candidates: report.candidates,
|
|
8258
|
+
filteredCandidates: report.filteredCandidates,
|
|
8259
|
+
scanExhausted: report.scanExhausted,
|
|
8260
|
+
considered: report.considered,
|
|
8261
|
+
created: report.created,
|
|
8262
|
+
deduped: report.deduped,
|
|
8263
|
+
throttled: report.throttled,
|
|
8264
|
+
skipped: report.skipped,
|
|
8265
|
+
maxDispatch: report.maxDispatch,
|
|
8266
|
+
source: report.source,
|
|
8267
|
+
dryRun: report.dryRun,
|
|
8268
|
+
evidencePath,
|
|
8269
|
+
results: results.map(compactDrainResult)
|
|
8270
|
+
} : { ...report, evidencePath };
|
|
8271
|
+
print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
|
|
8272
|
+
}
|
|
7125
8273
|
templates.command("list").alias("ls").description("list built-in OpenLoops templates").action(() => {
|
|
7126
8274
|
const values = listLoopTemplates();
|
|
7127
8275
|
if (isJson())
|
|
@@ -7152,14 +8300,102 @@ templates.command("create-workflow <id>").description("render and store a templa
|
|
|
7152
8300
|
store.close();
|
|
7153
8301
|
}
|
|
7154
8302
|
});
|
|
8303
|
+
routes.command("list").description("list admission work items").option("--status <status>", "filter by work item status").option("--route-key <key>", "filter by route key").option("--limit <n>", "maximum rows", "50").action((opts) => {
|
|
8304
|
+
const store = new Store;
|
|
8305
|
+
try {
|
|
8306
|
+
const items = store.listWorkflowWorkItems({
|
|
8307
|
+
status: opts.status,
|
|
8308
|
+
routeKey: opts.routeKey,
|
|
8309
|
+
limit: positiveInteger(opts.limit, "--limit") ?? 50
|
|
8310
|
+
});
|
|
8311
|
+
if (isJson())
|
|
8312
|
+
print(items.map(publicWorkflowWorkItem));
|
|
8313
|
+
else {
|
|
8314
|
+
for (const item of items) {
|
|
8315
|
+
console.log(`${item.id} ${item.status.padEnd(10)} ${item.routeKey} ${item.subjectRef} ${item.loopId ?? "-"}`);
|
|
8316
|
+
}
|
|
8317
|
+
}
|
|
8318
|
+
} finally {
|
|
8319
|
+
store.close();
|
|
8320
|
+
}
|
|
8321
|
+
});
|
|
8322
|
+
routes.command("show <id>").description("show one admission work item").action((id) => {
|
|
8323
|
+
const store = new Store;
|
|
8324
|
+
try {
|
|
8325
|
+
const item = store.getWorkflowWorkItem(id);
|
|
8326
|
+
if (!item)
|
|
8327
|
+
throw new Error(`route work item not found: ${id}`);
|
|
8328
|
+
const invocation = store.getWorkflowInvocation(item.invocationId);
|
|
8329
|
+
const workflow = item.workflowId ? store.getWorkflow(item.workflowId) : undefined;
|
|
8330
|
+
const loop = item.loopId ? store.getLoop(item.loopId) : undefined;
|
|
8331
|
+
print({
|
|
8332
|
+
item: publicWorkflowWorkItem(item),
|
|
8333
|
+
invocation: invocation ? publicWorkflowInvocation(invocation) : undefined,
|
|
8334
|
+
workflow: workflow ? publicWorkflow(workflow) : undefined,
|
|
8335
|
+
loop: loop ? publicLoop(loop) : undefined
|
|
8336
|
+
}, `${item.id} ${item.status} ${item.routeKey} ${item.subjectRef}`);
|
|
8337
|
+
} finally {
|
|
8338
|
+
store.close();
|
|
8339
|
+
}
|
|
8340
|
+
});
|
|
8341
|
+
routes.command("invocations").description("list workflow invocations").option("--limit <n>", "maximum rows", "50").action((opts) => {
|
|
8342
|
+
const store = new Store;
|
|
8343
|
+
try {
|
|
8344
|
+
const invocations = store.listWorkflowInvocations({ limit: positiveInteger(opts.limit, "--limit") ?? 50 });
|
|
8345
|
+
if (isJson())
|
|
8346
|
+
print(invocations.map(publicWorkflowInvocation));
|
|
8347
|
+
else {
|
|
8348
|
+
for (const invocation of invocations) {
|
|
8349
|
+
console.log(`${invocation.id} ${invocation.intent.padEnd(8)} ${invocation.sourceRef.kind}:${invocation.sourceRef.id ?? "-"} -> ${invocation.subjectRef.kind}:${invocation.subjectRef.id ?? invocation.subjectRef.path ?? "-"}`);
|
|
8350
|
+
}
|
|
8351
|
+
}
|
|
8352
|
+
} finally {
|
|
8353
|
+
store.close();
|
|
8354
|
+
}
|
|
8355
|
+
});
|
|
8356
|
+
addRouteEventOptions(routes.command("preview <kind>").description("preview a route-created workflow invocation without storing it")).action(async (kind, opts) => {
|
|
8357
|
+
const event = await readEventEnvelopeInput(opts);
|
|
8358
|
+
const result = routeEventByKind(kind, event, { ...opts, dryRun: true });
|
|
8359
|
+
print(result.value, result.human);
|
|
8360
|
+
});
|
|
8361
|
+
addRouteEventOptions(routes.command("create <kind>").description("create a route workflow invocation and admit it when capacity allows")).action(async (kind, opts) => {
|
|
8362
|
+
const event = await readEventEnvelopeInput(opts);
|
|
8363
|
+
const result = routeEventByKind(kind, event, { ...opts, dryRun: false });
|
|
8364
|
+
print(result.value, result.human);
|
|
8365
|
+
});
|
|
8366
|
+
addTodosDrainOptions(routes.command("drain <kind>").description("drain a durable source queue into bounded route workflow loops")).action((kind, opts) => {
|
|
8367
|
+
if (kind !== "todos-task")
|
|
8368
|
+
throw new Error("route drain currently supports kind todos-task");
|
|
8369
|
+
drainTodosTaskRoutes(opts);
|
|
8370
|
+
});
|
|
8371
|
+
addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>").description("schedule a deterministic route drain loop"))).action((kind, name, opts) => {
|
|
8372
|
+
if (kind !== "todos-task")
|
|
8373
|
+
throw new Error("route schedule currently supports kind todos-task");
|
|
8374
|
+
const store = new Store;
|
|
8375
|
+
try {
|
|
8376
|
+
const target = {
|
|
8377
|
+
type: "command",
|
|
8378
|
+
command: "loops",
|
|
8379
|
+
args: ["--json", ...routeDrainArgs({ ...opts, compact: opts.compact ?? true })],
|
|
8380
|
+
timeoutMs: parseDuration("20m"),
|
|
8381
|
+
preflight: runtimePreflightFromOpts(opts)
|
|
8382
|
+
};
|
|
8383
|
+
const input = baseCreateInput(name, opts, target);
|
|
8384
|
+
const preflight = opts.preflight ? preflightLoopTarget(input.target, { name, type: "route-drain", kind }, { loopName: name }, { machine: input.machine }) : undefined;
|
|
8385
|
+
const loop = store.createLoop(input);
|
|
8386
|
+
printCreatedLoop(loop, `created route drain loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`, preflight);
|
|
8387
|
+
} finally {
|
|
8388
|
+
store.close();
|
|
8389
|
+
}
|
|
8390
|
+
});
|
|
7155
8391
|
var eventsHandle = events.command("handle").description("handle a Hasna event envelope");
|
|
7156
|
-
eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
8392
|
+
eventsHandle.command("todos-task").description("create a one-shot worker/verifier workflow loop for a todos task event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
7157
8393
|
const event = await readEventEnvelopeFromStdin();
|
|
7158
8394
|
const result = routeTodosTaskEvent(event, opts);
|
|
7159
8395
|
print(result.value, result.human);
|
|
7160
8396
|
});
|
|
7161
8397
|
var eventsDrain = events.command("drain").description("drain durable source queues into bounded OpenLoops workflows");
|
|
7162
|
-
eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops").option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing workflow loops").option("--dry-run", "preview selected tasks and generated workflow loops without storing anything").action((opts) => {
|
|
8398
|
+
eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops").option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing workflow loops").option("--dry-run", "preview selected tasks and generated workflow loops without storing anything").action((opts) => {
|
|
7163
8399
|
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
7164
8400
|
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
7165
8401
|
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
@@ -7243,7 +8479,7 @@ eventsDrain.command("todos-task").description("drain ready todos tasks into boun
|
|
|
7243
8479
|
} : { ...report, evidencePath };
|
|
7244
8480
|
print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
|
|
7245
8481
|
});
|
|
7246
|
-
eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
8482
|
+
eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
|
|
7247
8483
|
const event = await readEventEnvelopeFromStdin();
|
|
7248
8484
|
const data = eventData(event);
|
|
7249
8485
|
const metadata = eventMetadata(event);
|
|
@@ -7256,19 +8492,7 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
7256
8492
|
const type = slugSegment2(event.type, "type");
|
|
7257
8493
|
const workflowName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
|
|
7258
8494
|
const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
|
|
7259
|
-
|
|
7260
|
-
const store2 = new Store;
|
|
7261
|
-
try {
|
|
7262
|
-
const existingLoop = store2.findLoopByName(loopName);
|
|
7263
|
-
if (existingLoop) {
|
|
7264
|
-
const existingWorkflow = existingLoop.target.type === "workflow" ? store2.getWorkflow(existingLoop.target.workflowId) : undefined;
|
|
7265
|
-
print({ deduped: true, event, workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined, loop: publicLoop(existingLoop) }, `deduped existing loop ${existingLoop.id} (${existingLoop.name})`);
|
|
7266
|
-
return;
|
|
7267
|
-
}
|
|
7268
|
-
} finally {
|
|
7269
|
-
store2.close();
|
|
7270
|
-
}
|
|
7271
|
-
}
|
|
8495
|
+
const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
|
|
7272
8496
|
const provider = opts.provider;
|
|
7273
8497
|
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
|
|
7274
8498
|
throw new Error("unsupported provider");
|
|
@@ -7299,17 +8523,60 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
7299
8523
|
agent: opts.agent,
|
|
7300
8524
|
permissionMode,
|
|
7301
8525
|
sandbox,
|
|
8526
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
7302
8527
|
worktreeMode: opts.worktreeMode,
|
|
7303
8528
|
worktreeRoot: opts.worktreeRoot,
|
|
7304
8529
|
worktreeBranchPrefix: opts.worktreeBranchPrefix
|
|
7305
8530
|
});
|
|
7306
8531
|
workflowBody.name = workflowName;
|
|
7307
8532
|
workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
8533
|
+
const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
|
|
8534
|
+
const invocationInput = {
|
|
8535
|
+
templateId: "event-worker-verifier",
|
|
8536
|
+
sourceRef: {
|
|
8537
|
+
kind: "event",
|
|
8538
|
+
id: event.id,
|
|
8539
|
+
dedupeKey: idempotencyKey,
|
|
8540
|
+
raw: { source: event.source, type: event.type }
|
|
8541
|
+
},
|
|
8542
|
+
subjectRef: {
|
|
8543
|
+
kind: "event",
|
|
8544
|
+
id: stringField(event.subject) ?? event.id,
|
|
8545
|
+
path: routeProjectPath,
|
|
8546
|
+
raw: { message: stringField(event.message) }
|
|
8547
|
+
},
|
|
8548
|
+
intent: "route",
|
|
8549
|
+
scope: {
|
|
8550
|
+
projectPath: routeProjectPath,
|
|
8551
|
+
projectGroup,
|
|
8552
|
+
worktreePolicy: opts.worktreeMode ?? "auto",
|
|
8553
|
+
permissions: permissionMode,
|
|
8554
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
8555
|
+
accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
|
|
8556
|
+
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
8557
|
+
},
|
|
8558
|
+
outputPolicy: {
|
|
8559
|
+
report: "always",
|
|
8560
|
+
createTask: "on_failure"
|
|
8561
|
+
}
|
|
8562
|
+
};
|
|
8563
|
+
const workItemInput = {
|
|
8564
|
+
routeKey: "generic-event",
|
|
8565
|
+
idempotencyKey,
|
|
8566
|
+
invocationId: "<created-invocation-id>",
|
|
8567
|
+
sourceType: event.type,
|
|
8568
|
+
sourceRef: event.id,
|
|
8569
|
+
subjectRef: stringField(event.subject) ?? event.id,
|
|
8570
|
+
projectKey: routeProjectPath,
|
|
8571
|
+
projectGroup,
|
|
8572
|
+
priority: 0,
|
|
8573
|
+
status: "queued"
|
|
8574
|
+
};
|
|
7308
8575
|
const loopInput = {
|
|
7309
8576
|
name: loopName,
|
|
7310
|
-
description: `Run ${workflowBody.name} once for event ${event.id}`,
|
|
8577
|
+
description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
|
|
7311
8578
|
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
7312
|
-
target: { type: "workflow", workflowId: "<created-workflow-id>" },
|
|
8579
|
+
target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
|
|
7313
8580
|
overlap: "skip",
|
|
7314
8581
|
maxAttempts: 1,
|
|
7315
8582
|
retryDelayMs: 60000,
|
|
@@ -7322,44 +8589,92 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
7322
8589
|
type: "generic-event-workflow",
|
|
7323
8590
|
event: event.id
|
|
7324
8591
|
}, {}) : undefined;
|
|
7325
|
-
print({ event, workflow: workflowBody, loop: loopInput, throttle, preflight }, `dry-run ${loopName}`);
|
|
8592
|
+
print({ event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight }, `dry-run ${loopName}`);
|
|
7326
8593
|
return;
|
|
7327
8594
|
}
|
|
7328
8595
|
const store = new Store;
|
|
7329
8596
|
try {
|
|
7330
8597
|
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
7331
8598
|
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
8599
|
+
generatedRouteSandboxPreflight(workflowPreflightSpec);
|
|
7332
8600
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
7333
8601
|
name: workflowBody.name,
|
|
7334
8602
|
type: "generic-event-workflow",
|
|
7335
8603
|
event: event.id
|
|
7336
8604
|
}, {}) : undefined;
|
|
7337
8605
|
const outcome = store.writeTransaction(() => {
|
|
7338
|
-
const
|
|
7339
|
-
|
|
7340
|
-
|
|
7341
|
-
|
|
8606
|
+
const invocation = store.createWorkflowInvocation(invocationInput);
|
|
8607
|
+
const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
|
|
8608
|
+
if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
|
|
8609
|
+
const existingLoop = store.getLoop(existingItem.loopId);
|
|
8610
|
+
const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
8611
|
+
return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
|
|
7342
8612
|
}
|
|
7343
8613
|
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
8614
|
+
const workItem = store.upsertWorkflowWorkItem({
|
|
8615
|
+
...workItemInput,
|
|
8616
|
+
invocationId: invocation.id,
|
|
8617
|
+
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
8618
|
+
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
8619
|
+
});
|
|
7344
8620
|
if (throttle && !throttle.allowed)
|
|
7345
|
-
return { kind: "throttled", throttle };
|
|
8621
|
+
return { kind: "throttled", invocation, workItem, throttle };
|
|
7346
8622
|
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
7347
8623
|
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
7348
8624
|
const loop = store.createLoop({
|
|
7349
8625
|
...loopInput,
|
|
7350
|
-
target: {
|
|
8626
|
+
target: {
|
|
8627
|
+
type: "workflow",
|
|
8628
|
+
workflowId: workflow.id,
|
|
8629
|
+
input: {
|
|
8630
|
+
workflowInvocationId: invocation.id,
|
|
8631
|
+
workflowWorkItemId: workItem.id
|
|
8632
|
+
}
|
|
8633
|
+
}
|
|
7351
8634
|
});
|
|
7352
|
-
|
|
8635
|
+
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
|
|
8636
|
+
return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
|
|
7353
8637
|
});
|
|
7354
8638
|
if (outcome.kind === "deduped") {
|
|
7355
|
-
print({
|
|
8639
|
+
print({
|
|
8640
|
+
deduped: true,
|
|
8641
|
+
idempotencyKey,
|
|
8642
|
+
dedupedBy: "work-item",
|
|
8643
|
+
event,
|
|
8644
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8645
|
+
workItem: publicWorkflowWorkItem(outcome.existingItem),
|
|
8646
|
+
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
8647
|
+
loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
|
|
8648
|
+
}, `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`);
|
|
7356
8649
|
return;
|
|
7357
8650
|
}
|
|
7358
8651
|
if (outcome.kind === "throttled") {
|
|
7359
|
-
print({
|
|
8652
|
+
print({
|
|
8653
|
+
skipped: true,
|
|
8654
|
+
queuedAtSource: true,
|
|
8655
|
+
reason: outcome.throttle.reason,
|
|
8656
|
+
idempotencyKey,
|
|
8657
|
+
event,
|
|
8658
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8659
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
8660
|
+
throttle: outcome.throttle,
|
|
8661
|
+
workflow: workflowBody,
|
|
8662
|
+
loop: loopInput
|
|
8663
|
+
}, `skipped event ${event.id}: ${outcome.throttle.reason}`);
|
|
7360
8664
|
return;
|
|
7361
8665
|
}
|
|
7362
|
-
print({
|
|
8666
|
+
print({
|
|
8667
|
+
deduped: false,
|
|
8668
|
+
idempotencyKey,
|
|
8669
|
+
event,
|
|
8670
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8671
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
8672
|
+
workflow: publicWorkflow(outcome.workflow),
|
|
8673
|
+
loop: publicLoop(outcome.loop),
|
|
8674
|
+
throttle: outcome.throttle,
|
|
8675
|
+
sandboxPreflight,
|
|
8676
|
+
preflight
|
|
8677
|
+
}, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
|
|
7363
8678
|
} finally {
|
|
7364
8679
|
store.close();
|
|
7365
8680
|
}
|