@hasna/loops 0.3.37 → 0.3.39
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 +1489 -179
- package/dist/daemon/index.js +494 -43
- package/dist/index.d.ts +1 -1
- package/dist/index.js +818 -53
- package/dist/lib/format.d.ts +3 -1
- package/dist/lib/run-artifacts.d.ts +15 -0
- package/dist/lib/store.d.ts +33 -1
- package/dist/lib/store.js +419 -8
- package/dist/lib/templates.d.ts +10 -0
- package/dist/sdk/index.d.ts +2 -1
- package/dist/sdk/index.js +524 -33
- package/dist/types.d.ts +109 -0
- package/docs/USAGE.md +71 -30
- 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,
|
|
@@ -841,8 +946,63 @@ class Store {
|
|
|
841
946
|
WHERE idempotency_key IS NOT NULL;
|
|
842
947
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
|
|
843
948
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
|
|
949
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
|
|
950
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
|
|
844
951
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
|
845
952
|
|
|
953
|
+
CREATE TABLE IF NOT EXISTS workflow_invocations (
|
|
954
|
+
id TEXT PRIMARY KEY,
|
|
955
|
+
workflow_id TEXT,
|
|
956
|
+
template_id TEXT,
|
|
957
|
+
source_kind TEXT NOT NULL,
|
|
958
|
+
source_id TEXT,
|
|
959
|
+
source_dedupe_key TEXT,
|
|
960
|
+
source_json TEXT NOT NULL,
|
|
961
|
+
subject_kind TEXT NOT NULL,
|
|
962
|
+
subject_id TEXT,
|
|
963
|
+
subject_path TEXT,
|
|
964
|
+
subject_url TEXT,
|
|
965
|
+
subject_json TEXT NOT NULL,
|
|
966
|
+
intent TEXT NOT NULL,
|
|
967
|
+
scope_json TEXT,
|
|
968
|
+
output_policy_json TEXT,
|
|
969
|
+
created_at TEXT NOT NULL,
|
|
970
|
+
updated_at TEXT NOT NULL
|
|
971
|
+
);
|
|
972
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
|
|
973
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
|
|
974
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe
|
|
975
|
+
ON workflow_invocations(source_kind, source_dedupe_key)
|
|
976
|
+
WHERE source_dedupe_key IS NOT NULL;
|
|
977
|
+
|
|
978
|
+
CREATE TABLE IF NOT EXISTS workflow_work_items (
|
|
979
|
+
id TEXT PRIMARY KEY,
|
|
980
|
+
route_key TEXT NOT NULL,
|
|
981
|
+
idempotency_key TEXT NOT NULL,
|
|
982
|
+
invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
|
|
983
|
+
source_type TEXT NOT NULL,
|
|
984
|
+
source_ref TEXT NOT NULL,
|
|
985
|
+
subject_ref TEXT NOT NULL,
|
|
986
|
+
project_key TEXT,
|
|
987
|
+
project_group TEXT,
|
|
988
|
+
priority INTEGER NOT NULL,
|
|
989
|
+
status TEXT NOT NULL,
|
|
990
|
+
attempts INTEGER NOT NULL,
|
|
991
|
+
next_attempt_at TEXT,
|
|
992
|
+
lease_expires_at TEXT,
|
|
993
|
+
workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
|
|
994
|
+
loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
|
|
995
|
+
workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
|
|
996
|
+
last_reason TEXT,
|
|
997
|
+
created_at TEXT NOT NULL,
|
|
998
|
+
updated_at TEXT NOT NULL,
|
|
999
|
+
UNIQUE(route_key, idempotency_key)
|
|
1000
|
+
);
|
|
1001
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
|
|
1002
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
|
|
1003
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
|
|
1004
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
|
|
1005
|
+
|
|
846
1006
|
CREATE TABLE IF NOT EXISTS workflow_step_runs (
|
|
847
1007
|
id TEXT PRIMARY KEY,
|
|
848
1008
|
workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
@@ -955,12 +1115,16 @@ class Store {
|
|
|
955
1115
|
this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
|
|
956
1116
|
this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
|
|
957
1117
|
this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
|
|
1118
|
+
this.addColumnIfMissing("workflow_runs", "invocation_id", "TEXT");
|
|
1119
|
+
this.addColumnIfMissing("workflow_runs", "work_item_id", "TEXT");
|
|
1120
|
+
this.addColumnIfMissing("workflow_runs", "manifest_path", "TEXT");
|
|
958
1121
|
this.addColumnIfMissing("workflow_step_runs", "pid", "INTEGER");
|
|
959
1122
|
this.addColumnIfMissing("workflow_step_runs", "goal_run_id", "TEXT");
|
|
960
1123
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
961
1124
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
|
|
962
1125
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
|
|
963
1126
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
|
|
1127
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0005_workflow_invocations_and_admission", nowIso());
|
|
964
1128
|
}
|
|
965
1129
|
addColumnIfMissing(table, column, definition) {
|
|
966
1130
|
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
@@ -1083,6 +1247,10 @@ class Store {
|
|
|
1083
1247
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1084
1248
|
$now: updated
|
|
1085
1249
|
});
|
|
1250
|
+
if (patch.status && patch.status !== "active") {
|
|
1251
|
+
const status = patch.status === "paused" ? "deferred" : "cancelled";
|
|
1252
|
+
this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
|
|
1253
|
+
}
|
|
1086
1254
|
const after = this.getLoop(id);
|
|
1087
1255
|
if (!after)
|
|
1088
1256
|
throw new Error(`loop not found after update: ${id}`);
|
|
@@ -1127,6 +1295,7 @@ class Store {
|
|
|
1127
1295
|
$archivedFromStatus: loop.status,
|
|
1128
1296
|
$updated: updated
|
|
1129
1297
|
});
|
|
1298
|
+
this.setWorkflowWorkItemsForLoop(loop.id, "deferred", "loop archived", updated);
|
|
1130
1299
|
const archived = this.getLoop(loop.id);
|
|
1131
1300
|
if (!archived)
|
|
1132
1301
|
throw new Error(`loop not found after archive: ${loop.id}`);
|
|
@@ -1152,6 +1321,7 @@ class Store {
|
|
|
1152
1321
|
}
|
|
1153
1322
|
deleteLoop(idOrName) {
|
|
1154
1323
|
const loop = this.requireLoop(idOrName);
|
|
1324
|
+
this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
|
|
1155
1325
|
const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
|
|
1156
1326
|
return res.changes > 0;
|
|
1157
1327
|
}
|
|
@@ -1210,6 +1380,185 @@ class Store {
|
|
|
1210
1380
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1211
1381
|
return archived;
|
|
1212
1382
|
}
|
|
1383
|
+
createWorkflowInvocation(input) {
|
|
1384
|
+
const now = nowIso();
|
|
1385
|
+
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
1386
|
+
if (sourceDedupeKey) {
|
|
1387
|
+
const existing = this.db.query("SELECT * FROM workflow_invocations WHERE source_kind = ? AND source_dedupe_key = ? LIMIT 1").get(input.sourceRef.kind, sourceDedupeKey);
|
|
1388
|
+
if (existing)
|
|
1389
|
+
return rowToWorkflowInvocation(existing);
|
|
1390
|
+
}
|
|
1391
|
+
const id = input.id ?? genId();
|
|
1392
|
+
this.db.query(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
|
|
1393
|
+
source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
|
|
1394
|
+
output_policy_json, created_at, updated_at)
|
|
1395
|
+
VALUES ($id, $workflowId, $templateId, $sourceKind, $sourceId, $sourceDedupeKey, $sourceJson,
|
|
1396
|
+
$subjectKind, $subjectId, $subjectPath, $subjectUrl, $subjectJson, $intent, $scopeJson,
|
|
1397
|
+
$outputPolicyJson, $created, $updated)`).run({
|
|
1398
|
+
$id: id,
|
|
1399
|
+
$workflowId: input.workflowId ?? null,
|
|
1400
|
+
$templateId: input.templateId ?? null,
|
|
1401
|
+
$sourceKind: input.sourceRef.kind,
|
|
1402
|
+
$sourceId: input.sourceRef.id ?? null,
|
|
1403
|
+
$sourceDedupeKey: sourceDedupeKey ?? null,
|
|
1404
|
+
$sourceJson: JSON.stringify(input.sourceRef),
|
|
1405
|
+
$subjectKind: input.subjectRef.kind,
|
|
1406
|
+
$subjectId: input.subjectRef.id ?? null,
|
|
1407
|
+
$subjectPath: input.subjectRef.path ?? null,
|
|
1408
|
+
$subjectUrl: input.subjectRef.url ?? null,
|
|
1409
|
+
$subjectJson: JSON.stringify(input.subjectRef),
|
|
1410
|
+
$intent: input.intent,
|
|
1411
|
+
$scopeJson: input.scope ? JSON.stringify(input.scope) : null,
|
|
1412
|
+
$outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
|
|
1413
|
+
$created: now,
|
|
1414
|
+
$updated: now
|
|
1415
|
+
});
|
|
1416
|
+
const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
|
|
1417
|
+
if (!row)
|
|
1418
|
+
throw new Error(`workflow invocation not found after create: ${id}`);
|
|
1419
|
+
return rowToWorkflowInvocation(row);
|
|
1420
|
+
}
|
|
1421
|
+
getWorkflowInvocation(id) {
|
|
1422
|
+
const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
|
|
1423
|
+
return row ? rowToWorkflowInvocation(row) : undefined;
|
|
1424
|
+
}
|
|
1425
|
+
listWorkflowInvocations(opts = {}) {
|
|
1426
|
+
const rows = this.db.query("SELECT * FROM workflow_invocations ORDER BY created_at DESC LIMIT ?").all(opts.limit ?? 100);
|
|
1427
|
+
return rows.map(rowToWorkflowInvocation);
|
|
1428
|
+
}
|
|
1429
|
+
upsertWorkflowWorkItem(input) {
|
|
1430
|
+
const now = nowIso();
|
|
1431
|
+
const id = genId();
|
|
1432
|
+
const status = input.status ?? "queued";
|
|
1433
|
+
this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
|
|
1434
|
+
subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
1435
|
+
workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
|
|
1436
|
+
VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
|
|
1437
|
+
$projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
1438
|
+
$lastReason, $created, $updated)
|
|
1439
|
+
ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
|
|
1440
|
+
invocation_id=excluded.invocation_id,
|
|
1441
|
+
source_type=excluded.source_type,
|
|
1442
|
+
source_ref=excluded.source_ref,
|
|
1443
|
+
subject_ref=excluded.subject_ref,
|
|
1444
|
+
project_key=excluded.project_key,
|
|
1445
|
+
project_group=excluded.project_group,
|
|
1446
|
+
priority=excluded.priority,
|
|
1447
|
+
status=CASE
|
|
1448
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
|
|
1449
|
+
THEN workflow_work_items.status
|
|
1450
|
+
ELSE excluded.status
|
|
1451
|
+
END,
|
|
1452
|
+
workflow_id=CASE
|
|
1453
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
|
|
1454
|
+
ELSE NULL
|
|
1455
|
+
END,
|
|
1456
|
+
loop_id=CASE
|
|
1457
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
|
|
1458
|
+
ELSE NULL
|
|
1459
|
+
END,
|
|
1460
|
+
workflow_run_id=CASE
|
|
1461
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
|
|
1462
|
+
ELSE NULL
|
|
1463
|
+
END,
|
|
1464
|
+
lease_expires_at=CASE
|
|
1465
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
|
|
1466
|
+
ELSE NULL
|
|
1467
|
+
END,
|
|
1468
|
+
next_attempt_at=excluded.next_attempt_at,
|
|
1469
|
+
last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
|
|
1470
|
+
updated_at=excluded.updated_at`).run({
|
|
1471
|
+
$id: id,
|
|
1472
|
+
$routeKey: input.routeKey,
|
|
1473
|
+
$idempotencyKey: input.idempotencyKey,
|
|
1474
|
+
$invocationId: input.invocationId,
|
|
1475
|
+
$sourceType: input.sourceType,
|
|
1476
|
+
$sourceRef: input.sourceRef,
|
|
1477
|
+
$subjectRef: input.subjectRef,
|
|
1478
|
+
$projectKey: input.projectKey ?? null,
|
|
1479
|
+
$projectGroup: input.projectGroup ?? null,
|
|
1480
|
+
$priority: input.priority ?? 0,
|
|
1481
|
+
$status: status,
|
|
1482
|
+
$nextAttemptAt: input.nextAttemptAt ?? null,
|
|
1483
|
+
$lastReason: input.lastReason ?? null,
|
|
1484
|
+
$created: now,
|
|
1485
|
+
$updated: now
|
|
1486
|
+
});
|
|
1487
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(input.routeKey, input.idempotencyKey);
|
|
1488
|
+
if (!row)
|
|
1489
|
+
throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
|
|
1490
|
+
return rowToWorkflowWorkItem(row);
|
|
1491
|
+
}
|
|
1492
|
+
getWorkflowWorkItem(id) {
|
|
1493
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE id = ?").get(id);
|
|
1494
|
+
return row ? rowToWorkflowWorkItem(row) : undefined;
|
|
1495
|
+
}
|
|
1496
|
+
findWorkflowWorkItem(routeKey, idempotencyKey) {
|
|
1497
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(routeKey, idempotencyKey);
|
|
1498
|
+
return row ? rowToWorkflowWorkItem(row) : undefined;
|
|
1499
|
+
}
|
|
1500
|
+
listWorkflowWorkItems(opts = {}) {
|
|
1501
|
+
const limit = opts.limit ?? 100;
|
|
1502
|
+
let rows;
|
|
1503
|
+
if (opts.status && opts.routeKey) {
|
|
1504
|
+
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);
|
|
1505
|
+
} else if (opts.status) {
|
|
1506
|
+
rows = this.db.query("SELECT * FROM workflow_work_items WHERE status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.status, limit);
|
|
1507
|
+
} else if (opts.routeKey) {
|
|
1508
|
+
rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? ORDER BY created_at DESC LIMIT ?").all(opts.routeKey, limit);
|
|
1509
|
+
} else {
|
|
1510
|
+
rows = this.db.query("SELECT * FROM workflow_work_items ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
1511
|
+
}
|
|
1512
|
+
return rows.map(rowToWorkflowWorkItem);
|
|
1513
|
+
}
|
|
1514
|
+
countActiveWorkflowWorkItems(args = {}) {
|
|
1515
|
+
const active = ["admitted", "running"];
|
|
1516
|
+
const placeholders = active.map(() => "?").join(",");
|
|
1517
|
+
const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
|
|
1518
|
+
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;
|
|
1519
|
+
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;
|
|
1520
|
+
return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
|
|
1521
|
+
}
|
|
1522
|
+
admitWorkflowWorkItem(id, patch) {
|
|
1523
|
+
const now = nowIso();
|
|
1524
|
+
const res = this.db.query(`UPDATE workflow_work_items
|
|
1525
|
+
SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
|
|
1526
|
+
next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
|
|
1527
|
+
WHERE id=$id AND status IN ('queued', 'deferred')`).run({
|
|
1528
|
+
$id: id,
|
|
1529
|
+
$workflowId: patch.workflowId,
|
|
1530
|
+
$loopId: patch.loopId,
|
|
1531
|
+
$reason: patch.reason ?? null,
|
|
1532
|
+
$updated: now
|
|
1533
|
+
});
|
|
1534
|
+
const item = this.getWorkflowWorkItem(id);
|
|
1535
|
+
if (!item)
|
|
1536
|
+
throw new Error(`workflow work item not found after admit: ${id}`);
|
|
1537
|
+
if (res.changes !== 1)
|
|
1538
|
+
throw new Error(`workflow work item is not claimable: ${id} status=${item.status}`);
|
|
1539
|
+
return item;
|
|
1540
|
+
}
|
|
1541
|
+
setWorkflowWorkItemsForLoop(loopId, status, reason, updated, statuses = ["admitted", "running"]) {
|
|
1542
|
+
const placeholders = statuses.map(() => "?").join(",");
|
|
1543
|
+
this.db.query(`UPDATE workflow_work_items
|
|
1544
|
+
SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
|
|
1545
|
+
WHERE loop_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, loopId, ...statuses);
|
|
1546
|
+
}
|
|
1547
|
+
setWorkflowWorkItemsForWorkflowRun(workflowRunId, status, reason, updated, statuses = ["admitted", "running"]) {
|
|
1548
|
+
const placeholders = statuses.map(() => "?").join(",");
|
|
1549
|
+
this.db.query(`UPDATE workflow_work_items
|
|
1550
|
+
SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
|
|
1551
|
+
WHERE workflow_run_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, workflowRunId, ...statuses);
|
|
1552
|
+
}
|
|
1553
|
+
setWorkflowWorkItemsForLoopRun(run, reason, updated) {
|
|
1554
|
+
const loop = this.getLoop(run.loopId);
|
|
1555
|
+
const status = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
1556
|
+
if (!status)
|
|
1557
|
+
return;
|
|
1558
|
+
const statuses = status === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
1559
|
+
const nextReason = status === "admitted" ? reason ? `attempt failed; retry pending: ${reason}` : "attempt failed; retry pending" : reason;
|
|
1560
|
+
this.setWorkflowWorkItemsForLoop(run.loopId, status, nextReason, updated, statuses);
|
|
1561
|
+
}
|
|
1213
1562
|
createGoal(input, opts = {}) {
|
|
1214
1563
|
const now = nowIso();
|
|
1215
1564
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -1483,6 +1832,10 @@ class Store {
|
|
|
1483
1832
|
}
|
|
1484
1833
|
createWorkflowRun(input) {
|
|
1485
1834
|
const now = nowIso();
|
|
1835
|
+
const targetInput = input.loop?.target.type === "workflow" ? input.loop.target.input : undefined;
|
|
1836
|
+
const invocationId = input.invocationId ?? targetInput?.workflowInvocationId ?? targetInput?.invocationId;
|
|
1837
|
+
const workItemId = input.workItemId ?? targetInput?.workflowWorkItemId ?? targetInput?.workItemId;
|
|
1838
|
+
let manifestPath;
|
|
1486
1839
|
if (input.idempotencyKey) {
|
|
1487
1840
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
1488
1841
|
if (existing) {
|
|
@@ -1501,21 +1854,59 @@ class Store {
|
|
|
1501
1854
|
}
|
|
1502
1855
|
}
|
|
1503
1856
|
const runId = genId();
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1857
|
+
const workItem = workItemId ? this.getWorkflowWorkItem(workItemId) : undefined;
|
|
1858
|
+
const invocation = invocationId ? this.getWorkflowInvocation(invocationId) : undefined;
|
|
1859
|
+
manifestPath = invocation || workItem ? writeWorkflowRunManifest({
|
|
1860
|
+
loopsDataDir: this.rootDir,
|
|
1861
|
+
workflowRunId: runId,
|
|
1862
|
+
workflowId: input.workflow.id,
|
|
1863
|
+
workflowName: input.workflow.name,
|
|
1864
|
+
invocationId,
|
|
1865
|
+
workItemId,
|
|
1866
|
+
projectKey: workItem?.projectKey ?? invocation?.scope?.projectPath,
|
|
1867
|
+
subjectKind: invocation?.subjectRef.kind,
|
|
1868
|
+
rawSubjectRef: workItem?.subjectRef ?? invocation?.subjectRef.path ?? invocation?.subjectRef.id ?? invocation?.subjectRef.url,
|
|
1869
|
+
payload: {
|
|
1870
|
+
workflowInvocation: invocation,
|
|
1871
|
+
workflowWorkItem: workItem,
|
|
1872
|
+
loopId: input.loop?.id,
|
|
1873
|
+
loopRunId: input.loopRun?.id,
|
|
1874
|
+
scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor
|
|
1875
|
+
}
|
|
1876
|
+
}) : undefined;
|
|
1877
|
+
this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, invocation_id, work_item_id,
|
|
1878
|
+
scheduled_for, idempotency_key, manifest_path, status, started_at, finished_at, duration_ms, error,
|
|
1879
|
+
created_at, updated_at)
|
|
1880
|
+
VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $invocationId, $workItemId, $scheduledFor,
|
|
1881
|
+
$idempotencyKey, $manifestPath, 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
|
|
1508
1882
|
$id: runId,
|
|
1509
1883
|
$workflowId: input.workflow.id,
|
|
1510
1884
|
$workflowName: input.workflow.name,
|
|
1511
1885
|
$loopId: input.loop?.id ?? null,
|
|
1512
1886
|
$loopRunId: input.loopRun?.id ?? null,
|
|
1887
|
+
$invocationId: invocationId ?? null,
|
|
1888
|
+
$workItemId: workItemId ?? null,
|
|
1513
1889
|
$scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
|
|
1514
1890
|
$idempotencyKey: input.idempotencyKey ?? null,
|
|
1891
|
+
$manifestPath: manifestPath ?? null,
|
|
1515
1892
|
$started: now,
|
|
1516
1893
|
$created: now,
|
|
1517
1894
|
$updated: now
|
|
1518
1895
|
});
|
|
1896
|
+
if (workItemId) {
|
|
1897
|
+
const workItemRes = this.db.query(`UPDATE workflow_work_items
|
|
1898
|
+
SET status='running', workflow_run_id=$workflowRunId, lease_expires_at=$leaseExpiresAt, updated_at=$updated
|
|
1899
|
+
WHERE id=$id AND status IN ('admitted', 'queued', 'deferred', 'running')`).run({
|
|
1900
|
+
$id: workItemId,
|
|
1901
|
+
$workflowRunId: runId,
|
|
1902
|
+
$leaseExpiresAt: input.loop ? new Date(Date.now() + input.loop.leaseMs).toISOString() : null,
|
|
1903
|
+
$updated: now
|
|
1904
|
+
});
|
|
1905
|
+
if (workItemRes.changes !== 1) {
|
|
1906
|
+
const current = this.getWorkflowWorkItem(workItemId);
|
|
1907
|
+
throw new Error(`workflow work item is not runnable: ${workItemId}${current ? ` status=${current.status}` : ""}`);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1519
1910
|
input.workflow.steps.forEach((step, sequence) => {
|
|
1520
1911
|
const account = step.account ?? step.target.account;
|
|
1521
1912
|
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
@@ -1541,7 +1932,10 @@ class Store {
|
|
|
1541
1932
|
workflowName: input.workflow.name,
|
|
1542
1933
|
stepCount: input.workflow.steps.length,
|
|
1543
1934
|
loopId: input.loop?.id,
|
|
1544
|
-
loopRunId: input.loopRun?.id
|
|
1935
|
+
loopRunId: input.loopRun?.id,
|
|
1936
|
+
invocationId,
|
|
1937
|
+
workItemId,
|
|
1938
|
+
manifestPath
|
|
1545
1939
|
}),
|
|
1546
1940
|
$created: now
|
|
1547
1941
|
});
|
|
@@ -1554,6 +1948,8 @@ class Store {
|
|
|
1554
1948
|
try {
|
|
1555
1949
|
this.db.exec("ROLLBACK");
|
|
1556
1950
|
} catch {}
|
|
1951
|
+
if (manifestPath)
|
|
1952
|
+
rmSync(manifestPath, { force: true });
|
|
1557
1953
|
throw error;
|
|
1558
1954
|
}
|
|
1559
1955
|
}
|
|
@@ -1766,6 +2162,10 @@ class Store {
|
|
|
1766
2162
|
changed = res.changes === 1;
|
|
1767
2163
|
if (changed)
|
|
1768
2164
|
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
2165
|
+
if (changed) {
|
|
2166
|
+
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2167
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2168
|
+
}
|
|
1769
2169
|
this.db.exec("COMMIT");
|
|
1770
2170
|
} catch (error) {
|
|
1771
2171
|
try {
|
|
@@ -1790,6 +2190,7 @@ class Store {
|
|
|
1790
2190
|
this.db.query(`UPDATE workflow_step_runs
|
|
1791
2191
|
SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
|
|
1792
2192
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2193
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
1793
2194
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
1794
2195
|
}
|
|
1795
2196
|
this.db.exec("COMMIT");
|
|
@@ -2043,6 +2444,8 @@ class Store {
|
|
|
2043
2444
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2044
2445
|
if (opts.claimedBy && res.changes !== 1)
|
|
2045
2446
|
return run;
|
|
2447
|
+
if (res.changes === 1)
|
|
2448
|
+
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2046
2449
|
return run;
|
|
2047
2450
|
}
|
|
2048
2451
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2136,6 +2539,14 @@ class Store {
|
|
|
2136
2539
|
error: "parent loop run lease expired before completion",
|
|
2137
2540
|
loopRunId: row.id
|
|
2138
2541
|
});
|
|
2542
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2543
|
+
}
|
|
2544
|
+
const loop = this.getLoop(row.loop_id);
|
|
2545
|
+
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
2546
|
+
if (itemStatus) {
|
|
2547
|
+
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2548
|
+
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2549
|
+
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2139
2550
|
}
|
|
2140
2551
|
this.db.exec("COMMIT");
|
|
2141
2552
|
} catch (error) {
|
|
@@ -2244,11 +2655,11 @@ class Store {
|
|
|
2244
2655
|
}
|
|
2245
2656
|
|
|
2246
2657
|
// src/cli/index.ts
|
|
2247
|
-
import { createHash as
|
|
2248
|
-
import { closeSync, existsSync as existsSync4, mkdirSync as
|
|
2658
|
+
import { createHash as createHash3, randomUUID } from "crypto";
|
|
2659
|
+
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
2660
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
2250
|
-
import { join as
|
|
2251
|
-
import { tmpdir } from "os";
|
|
2661
|
+
import { join as join6, resolve as resolve2 } from "path";
|
|
2662
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2252
2663
|
import { Database as Database2 } from "bun:sqlite";
|
|
2253
2664
|
import { Command } from "commander";
|
|
2254
2665
|
|
|
@@ -2336,6 +2747,12 @@ function publicWorkflow(workflow) {
|
|
|
2336
2747
|
function publicWorkflowRun(run) {
|
|
2337
2748
|
return { ...run, error: redact(run.error) };
|
|
2338
2749
|
}
|
|
2750
|
+
function publicWorkflowInvocation(invocation) {
|
|
2751
|
+
return redactSensitivePayload(invocation);
|
|
2752
|
+
}
|
|
2753
|
+
function publicWorkflowWorkItem(item) {
|
|
2754
|
+
return { ...item, lastReason: redact(item.lastReason, 240) };
|
|
2755
|
+
}
|
|
2339
2756
|
function publicWorkflowStepRun(run, showOutput = false) {
|
|
2340
2757
|
return {
|
|
2341
2758
|
...run,
|
|
@@ -2466,7 +2883,7 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
2466
2883
|
// src/lib/env.ts
|
|
2467
2884
|
import { accessSync, constants } from "fs";
|
|
2468
2885
|
import { homedir as homedir2 } from "os";
|
|
2469
|
-
import { delimiter, join as
|
|
2886
|
+
import { delimiter, join as join4 } from "path";
|
|
2470
2887
|
function compactPathParts(parts) {
|
|
2471
2888
|
const seen = new Set;
|
|
2472
2889
|
const result = [];
|
|
@@ -2482,14 +2899,14 @@ function compactPathParts(parts) {
|
|
|
2482
2899
|
function commonExecutableDirs(env = process.env) {
|
|
2483
2900
|
const home = env.HOME || homedir2();
|
|
2484
2901
|
return compactPathParts([
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
env.BUN_INSTALL ?
|
|
2902
|
+
join4(home, ".local", "bin"),
|
|
2903
|
+
join4(home, ".bun", "bin"),
|
|
2904
|
+
join4(home, ".cargo", "bin"),
|
|
2905
|
+
join4(home, ".npm-global", "bin"),
|
|
2906
|
+
join4(home, "bin"),
|
|
2907
|
+
env.BUN_INSTALL ? join4(env.BUN_INSTALL, "bin") : undefined,
|
|
2491
2908
|
env.PNPM_HOME,
|
|
2492
|
-
env.NPM_CONFIG_PREFIX ?
|
|
2909
|
+
env.NPM_CONFIG_PREFIX ? join4(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
2493
2910
|
"/opt/homebrew/bin",
|
|
2494
2911
|
"/usr/local/bin",
|
|
2495
2912
|
"/usr/bin",
|
|
@@ -2513,7 +2930,7 @@ function executableExists(command, env = process.env) {
|
|
|
2513
2930
|
if (command.includes("/"))
|
|
2514
2931
|
return isExecutable(command);
|
|
2515
2932
|
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
2516
|
-
if (dir && isExecutable(
|
|
2933
|
+
if (dir && isExecutable(join4(dir, command)))
|
|
2517
2934
|
return true;
|
|
2518
2935
|
}
|
|
2519
2936
|
return false;
|
|
@@ -2871,6 +3288,7 @@ function commandSpec(target) {
|
|
|
2871
3288
|
shell: commandTarget.shell,
|
|
2872
3289
|
env: commandTarget.env,
|
|
2873
3290
|
timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3291
|
+
idleTimeoutMs: commandTarget.idleTimeoutMs,
|
|
2874
3292
|
account: commandTarget.account,
|
|
2875
3293
|
accountTool: commandTarget.account?.tool
|
|
2876
3294
|
};
|
|
@@ -2881,6 +3299,7 @@ function commandSpec(target) {
|
|
|
2881
3299
|
args: agentArgs(agentTarget),
|
|
2882
3300
|
cwd: agentTarget.cwd,
|
|
2883
3301
|
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3302
|
+
idleTimeoutMs: agentTarget.idleTimeoutMs,
|
|
2884
3303
|
account: agentTarget.account,
|
|
2885
3304
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
2886
3305
|
nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
|
|
@@ -3028,6 +3447,7 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3028
3447
|
let stdout = "";
|
|
3029
3448
|
let stderr = "";
|
|
3030
3449
|
let timedOut = false;
|
|
3450
|
+
let idleTimedOut = false;
|
|
3031
3451
|
let exitCode;
|
|
3032
3452
|
let error;
|
|
3033
3453
|
let plan;
|
|
@@ -3066,18 +3486,34 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3066
3486
|
if (opts.signal?.aborted)
|
|
3067
3487
|
abortHandler();
|
|
3068
3488
|
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
3489
|
const timer = setTimeout(() => {
|
|
3076
3490
|
timedOut = true;
|
|
3077
3491
|
if (child.pid)
|
|
3078
3492
|
killProcessGroup(child.pid);
|
|
3079
3493
|
}, spec.timeoutMs);
|
|
3080
3494
|
timer.unref();
|
|
3495
|
+
let idleTimer;
|
|
3496
|
+
const resetIdleTimer = () => {
|
|
3497
|
+
if (!spec.idleTimeoutMs)
|
|
3498
|
+
return;
|
|
3499
|
+
if (idleTimer)
|
|
3500
|
+
clearTimeout(idleTimer);
|
|
3501
|
+
idleTimer = setTimeout(() => {
|
|
3502
|
+
idleTimedOut = true;
|
|
3503
|
+
if (child.pid)
|
|
3504
|
+
killProcessGroup(child.pid);
|
|
3505
|
+
}, spec.idleTimeoutMs);
|
|
3506
|
+
idleTimer.unref();
|
|
3507
|
+
};
|
|
3508
|
+
resetIdleTimer();
|
|
3509
|
+
child.stdout?.on("data", (chunk) => {
|
|
3510
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3511
|
+
resetIdleTimer();
|
|
3512
|
+
});
|
|
3513
|
+
child.stderr?.on("data", (chunk) => {
|
|
3514
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3515
|
+
resetIdleTimer();
|
|
3516
|
+
});
|
|
3081
3517
|
try {
|
|
3082
3518
|
const [code, signal] = await once(child, "exit");
|
|
3083
3519
|
if (typeof code === "number")
|
|
@@ -3088,17 +3524,19 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
3088
3524
|
error = err instanceof Error ? err.message : String(err);
|
|
3089
3525
|
} finally {
|
|
3090
3526
|
clearTimeout(timer);
|
|
3527
|
+
if (idleTimer)
|
|
3528
|
+
clearTimeout(idleTimer);
|
|
3091
3529
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
3092
3530
|
}
|
|
3093
3531
|
const finishedAt = nowIso();
|
|
3094
3532
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
3095
|
-
if (timedOut) {
|
|
3533
|
+
if (timedOut || idleTimedOut) {
|
|
3096
3534
|
return {
|
|
3097
3535
|
status: "timed_out",
|
|
3098
3536
|
exitCode,
|
|
3099
3537
|
stdout,
|
|
3100
3538
|
stderr,
|
|
3101
|
-
error: `timed out after ${spec.timeoutMs}ms`,
|
|
3539
|
+
error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
|
|
3102
3540
|
pid: child.pid,
|
|
3103
3541
|
startedAt,
|
|
3104
3542
|
finishedAt,
|
|
@@ -3164,6 +3602,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3164
3602
|
let stdout = "";
|
|
3165
3603
|
let stderr = "";
|
|
3166
3604
|
let timedOut = false;
|
|
3605
|
+
let idleTimedOut = false;
|
|
3167
3606
|
let exitCode;
|
|
3168
3607
|
let error;
|
|
3169
3608
|
const env = executionEnv(spec, metadata, opts);
|
|
@@ -3226,18 +3665,34 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3226
3665
|
if (opts.signal?.aborted)
|
|
3227
3666
|
abortHandler();
|
|
3228
3667
|
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
3668
|
const timer = setTimeout(() => {
|
|
3236
3669
|
timedOut = true;
|
|
3237
3670
|
if (child.pid)
|
|
3238
3671
|
killProcessGroup(child.pid);
|
|
3239
3672
|
}, spec.timeoutMs);
|
|
3240
3673
|
timer.unref();
|
|
3674
|
+
let idleTimer;
|
|
3675
|
+
const resetIdleTimer = () => {
|
|
3676
|
+
if (!spec.idleTimeoutMs)
|
|
3677
|
+
return;
|
|
3678
|
+
if (idleTimer)
|
|
3679
|
+
clearTimeout(idleTimer);
|
|
3680
|
+
idleTimer = setTimeout(() => {
|
|
3681
|
+
idleTimedOut = true;
|
|
3682
|
+
if (child.pid)
|
|
3683
|
+
killProcessGroup(child.pid);
|
|
3684
|
+
}, spec.idleTimeoutMs);
|
|
3685
|
+
idleTimer.unref();
|
|
3686
|
+
};
|
|
3687
|
+
resetIdleTimer();
|
|
3688
|
+
child.stdout?.on("data", (chunk) => {
|
|
3689
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3690
|
+
resetIdleTimer();
|
|
3691
|
+
});
|
|
3692
|
+
child.stderr?.on("data", (chunk) => {
|
|
3693
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3694
|
+
resetIdleTimer();
|
|
3695
|
+
});
|
|
3241
3696
|
try {
|
|
3242
3697
|
const [code, signal] = await once(child, "exit");
|
|
3243
3698
|
if (typeof code === "number")
|
|
@@ -3248,17 +3703,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3248
3703
|
error = err instanceof Error ? err.message : String(err);
|
|
3249
3704
|
} finally {
|
|
3250
3705
|
clearTimeout(timer);
|
|
3706
|
+
if (idleTimer)
|
|
3707
|
+
clearTimeout(idleTimer);
|
|
3251
3708
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
3252
3709
|
}
|
|
3253
3710
|
const finishedAt = nowIso();
|
|
3254
3711
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
3255
|
-
if (timedOut) {
|
|
3712
|
+
if (timedOut || idleTimedOut) {
|
|
3256
3713
|
return {
|
|
3257
3714
|
status: "timed_out",
|
|
3258
3715
|
exitCode,
|
|
3259
3716
|
stdout,
|
|
3260
3717
|
stderr,
|
|
3261
|
-
error: `timed out after ${spec.timeoutMs}ms`,
|
|
3718
|
+
error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
|
|
3262
3719
|
pid: child.pid,
|
|
3263
3720
|
startedAt,
|
|
3264
3721
|
finishedAt,
|
|
@@ -4300,7 +4757,7 @@ async function tick(deps) {
|
|
|
4300
4757
|
}
|
|
4301
4758
|
|
|
4302
4759
|
// src/daemon/control.ts
|
|
4303
|
-
import { existsSync as existsSync2, mkdirSync as
|
|
4760
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4304
4761
|
import { hostname } from "os";
|
|
4305
4762
|
import { dirname as dirname2 } from "path";
|
|
4306
4763
|
|
|
@@ -4338,11 +4795,11 @@ function readPid(path = pidFilePath()) {
|
|
|
4338
4795
|
}
|
|
4339
4796
|
}
|
|
4340
4797
|
function writePid(pid = process.pid, path = pidFilePath()) {
|
|
4341
|
-
|
|
4342
|
-
|
|
4798
|
+
mkdirSync4(dirname2(path), { recursive: true, mode: 448 });
|
|
4799
|
+
writeFileSync2(path, String(pid));
|
|
4343
4800
|
}
|
|
4344
4801
|
function removePid(path = pidFilePath()) {
|
|
4345
|
-
|
|
4802
|
+
rmSync2(path, { force: true });
|
|
4346
4803
|
}
|
|
4347
4804
|
function isAlive(pid) {
|
|
4348
4805
|
try {
|
|
@@ -4601,7 +5058,7 @@ async function startDaemon(opts) {
|
|
|
4601
5058
|
}
|
|
4602
5059
|
|
|
4603
5060
|
// src/daemon/install.ts
|
|
4604
|
-
import { chmodSync, mkdirSync as
|
|
5061
|
+
import { chmodSync, mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
4605
5062
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
4606
5063
|
import { dirname as dirname3 } from "path";
|
|
4607
5064
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
@@ -4609,8 +5066,8 @@ function installStartup(cliEntry, execPath = process.execPath, args = ["daemon",
|
|
|
4609
5066
|
const pathEnv = normalizeExecutionPath(process.env);
|
|
4610
5067
|
if (process.platform === "linux") {
|
|
4611
5068
|
const path = systemdServicePath();
|
|
4612
|
-
|
|
4613
|
-
|
|
5069
|
+
mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
|
|
5070
|
+
writeFileSync3(path, `[Unit]
|
|
4614
5071
|
Description=Hasna OpenLoops daemon
|
|
4615
5072
|
After=default.target
|
|
4616
5073
|
|
|
@@ -4636,8 +5093,8 @@ WantedBy=default.target
|
|
|
4636
5093
|
}
|
|
4637
5094
|
if (process.platform === "darwin") {
|
|
4638
5095
|
const path = launchdPlistPath();
|
|
4639
|
-
|
|
4640
|
-
|
|
5096
|
+
mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
|
|
5097
|
+
writeFileSync3(path, `<?xml version="1.0" encoding="UTF-8"?>
|
|
4641
5098
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4642
5099
|
<plist version="1.0">
|
|
4643
5100
|
<dict>
|
|
@@ -4796,7 +5253,7 @@ function runDoctor(store) {
|
|
|
4796
5253
|
}
|
|
4797
5254
|
|
|
4798
5255
|
// src/lib/health.ts
|
|
4799
|
-
import { createHash } from "crypto";
|
|
5256
|
+
import { createHash as createHash2 } from "crypto";
|
|
4800
5257
|
var EVIDENCE_CHARS = 2000;
|
|
4801
5258
|
var FINGERPRINT_EVIDENCE_CHARS = 120;
|
|
4802
5259
|
var CLASSIFICATIONS = [
|
|
@@ -4828,7 +5285,7 @@ function searchableText(run) {
|
|
|
4828
5285
|
`).toLowerCase();
|
|
4829
5286
|
}
|
|
4830
5287
|
function stableFingerprint(parts) {
|
|
4831
|
-
return
|
|
5288
|
+
return createHash2("sha256").update(parts.join(`
|
|
4832
5289
|
`)).digest("hex").slice(0, 16);
|
|
4833
5290
|
}
|
|
4834
5291
|
function stableFailureFingerprint(run, classification) {
|
|
@@ -5016,7 +5473,7 @@ function buildHealthReport(store, opts = {}) {
|
|
|
5016
5473
|
}
|
|
5017
5474
|
|
|
5018
5475
|
// src/lib/hygiene.ts
|
|
5019
|
-
import { basename } from "path";
|
|
5476
|
+
import { basename as basename2 } from "path";
|
|
5020
5477
|
var PROVIDER_TOKENS = new Set([
|
|
5021
5478
|
"codewith",
|
|
5022
5479
|
"claude",
|
|
@@ -5037,7 +5494,7 @@ function repoSlugFromCwd(cwd) {
|
|
|
5037
5494
|
return "";
|
|
5038
5495
|
if (cwd.includes("/.hasna/loops/"))
|
|
5039
5496
|
return "";
|
|
5040
|
-
return slugify(
|
|
5497
|
+
return slugify(basename2(cwd));
|
|
5041
5498
|
}
|
|
5042
5499
|
function scopeForLoop(loop) {
|
|
5043
5500
|
const cwd = loop.target.type === "command" || loop.target.type === "agent" ? loop.target.cwd : undefined;
|
|
@@ -5254,7 +5711,7 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
5254
5711
|
// package.json
|
|
5255
5712
|
var package_default = {
|
|
5256
5713
|
name: "@hasna/loops",
|
|
5257
|
-
version: "0.3.
|
|
5714
|
+
version: "0.3.39",
|
|
5258
5715
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
5259
5716
|
type: "module",
|
|
5260
5717
|
main: "dist/index.js",
|
|
@@ -5346,10 +5803,17 @@ function packageVersion() {
|
|
|
5346
5803
|
import { execFileSync } from "child_process";
|
|
5347
5804
|
import { existsSync as existsSync3 } from "fs";
|
|
5348
5805
|
import { homedir as homedir3 } from "os";
|
|
5349
|
-
import { basename as
|
|
5806
|
+
import { basename as basename3, isAbsolute, join as join5, relative, resolve } from "path";
|
|
5350
5807
|
var TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID = "todos-task-worker-verifier";
|
|
5351
5808
|
var EVENT_WORKER_VERIFIER_TEMPLATE_ID = "event-worker-verifier";
|
|
5352
5809
|
var BOUNDED_AGENT_WORKER_VERIFIER_TEMPLATE_ID = "bounded-agent-worker-verifier";
|
|
5810
|
+
var TASK_LIFECYCLE_TEMPLATE_ID = "task-lifecycle";
|
|
5811
|
+
var PR_REVIEW_TEMPLATE_ID = "pr-review";
|
|
5812
|
+
var SCHEDULED_AUDIT_TEMPLATE_ID = "scheduled-audit";
|
|
5813
|
+
var KNOWLEDGE_REFRESH_TEMPLATE_ID = "knowledge-refresh";
|
|
5814
|
+
var REPORT_ONLY_TEMPLATE_ID = "report-only";
|
|
5815
|
+
var INCIDENT_RESPONSE_TEMPLATE_ID = "incident-response";
|
|
5816
|
+
var DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID = "deterministic-check-create-task";
|
|
5353
5817
|
var TEMPLATE_SUMMARIES = [
|
|
5354
5818
|
{
|
|
5355
5819
|
id: TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
@@ -5371,7 +5835,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5371
5835
|
{ name: "model", description: "Provider model." },
|
|
5372
5836
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
5373
5837
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
5374
|
-
{ name: "sandbox", default: "
|
|
5838
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5839
|
+
{ name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
|
|
5375
5840
|
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
5376
5841
|
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
5377
5842
|
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated task/event worktree branches." }
|
|
@@ -5399,7 +5864,8 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5399
5864
|
{ name: "model", description: "Provider model." },
|
|
5400
5865
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
5401
5866
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
5402
|
-
{ name: "sandbox", default: "
|
|
5867
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5868
|
+
{ name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
|
|
5403
5869
|
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
5404
5870
|
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
5405
5871
|
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated event worktree branches." }
|
|
@@ -5425,12 +5891,112 @@ var TEMPLATE_SUMMARIES = [
|
|
|
5425
5891
|
{ name: "model", description: "Provider model." },
|
|
5426
5892
|
{ name: "variant", description: "Provider reasoning/model effort variant." },
|
|
5427
5893
|
{ name: "permissionMode", default: "bypass", description: "Provider permission mode: default, plan, auto, or bypass." },
|
|
5428
|
-
{ name: "sandbox", default: "
|
|
5894
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5895
|
+
{ name: "manualBreakGlass", default: "false", description: "Allow explicit danger-full-access in a generated workflow. Intended for manual emergency use only." },
|
|
5429
5896
|
{ name: "worktreeMode", default: "auto", description: "Worktree isolation mode: auto, required, off, or main." },
|
|
5430
5897
|
{ name: "worktreeRoot", default: "~/.hasna/loops/worktrees", description: "Base directory for OpenLoops-managed git worktrees." },
|
|
5431
5898
|
{ name: "worktreeBranchPrefix", default: "openloops", description: "Branch prefix for generated bounded-agent worktree branches." },
|
|
5432
5899
|
{ name: "timeoutMs", default: "2700000", description: "Step timeout in milliseconds." }
|
|
5433
5900
|
]
|
|
5901
|
+
},
|
|
5902
|
+
{
|
|
5903
|
+
id: TASK_LIFECYCLE_TEMPLATE_ID,
|
|
5904
|
+
name: "Task Lifecycle",
|
|
5905
|
+
description: "Run the standard task-created lifecycle: triage/dedupe, plan, worker execution, independent verification, and todos closure/follow-up evidence.",
|
|
5906
|
+
kind: "workflow",
|
|
5907
|
+
variables: [
|
|
5908
|
+
{ name: "taskId", required: true, description: "Todos task id." },
|
|
5909
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5910
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5911
|
+
{ name: "accountPool", description: "Comma-separated OpenAccounts profiles for non-Codewith providers." },
|
|
5912
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5913
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5914
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
5915
|
+
]
|
|
5916
|
+
},
|
|
5917
|
+
{
|
|
5918
|
+
id: PR_REVIEW_TEMPLATE_ID,
|
|
5919
|
+
name: "PR Review",
|
|
5920
|
+
description: "Review and drive a pull request toward merge-ready state with a worker and fresh adversarial verifier.",
|
|
5921
|
+
kind: "workflow",
|
|
5922
|
+
variables: [
|
|
5923
|
+
{ name: "prUrl", description: "Pull request URL." },
|
|
5924
|
+
{ name: "prNumber", description: "Pull request number." },
|
|
5925
|
+
{ name: "projectPath", required: true, description: "Repository working directory." },
|
|
5926
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5927
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5928
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5929
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
5930
|
+
]
|
|
5931
|
+
},
|
|
5932
|
+
{
|
|
5933
|
+
id: SCHEDULED_AUDIT_TEMPLATE_ID,
|
|
5934
|
+
name: "Scheduled Audit",
|
|
5935
|
+
description: "Run a bounded scheduled audit, record evidence, create follow-up tasks for actionable findings, then verify the audit result.",
|
|
5936
|
+
kind: "workflow",
|
|
5937
|
+
variables: [
|
|
5938
|
+
{ name: "objective", required: true, description: "Audit objective." },
|
|
5939
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5940
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5941
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5942
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5943
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
5944
|
+
]
|
|
5945
|
+
},
|
|
5946
|
+
{
|
|
5947
|
+
id: KNOWLEDGE_REFRESH_TEMPLATE_ID,
|
|
5948
|
+
name: "Knowledge Refresh",
|
|
5949
|
+
description: "Review recent knowledge, improve structure/schema where needed, create deduped tasks for code changes, and verify the knowledge update.",
|
|
5950
|
+
kind: "workflow",
|
|
5951
|
+
variables: [
|
|
5952
|
+
{ name: "scope", description: "Knowledge scope or label to refresh." },
|
|
5953
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5954
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5955
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5956
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5957
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
5958
|
+
]
|
|
5959
|
+
},
|
|
5960
|
+
{
|
|
5961
|
+
id: REPORT_ONLY_TEMPLATE_ID,
|
|
5962
|
+
name: "Report Only",
|
|
5963
|
+
description: "Produce a bounded report without mutating repositories; verifier checks evidence, scope, and absence of unauthorized changes.",
|
|
5964
|
+
kind: "workflow",
|
|
5965
|
+
variables: [
|
|
5966
|
+
{ name: "objective", required: true, description: "Report objective." },
|
|
5967
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5968
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5969
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5970
|
+
{ name: "sandbox", default: "read-only", description: "Provider sandbox mode." },
|
|
5971
|
+
{ name: "worktreeMode", default: "main", description: "Report-only workflows normally inspect the main checkout read-only." }
|
|
5972
|
+
]
|
|
5973
|
+
},
|
|
5974
|
+
{
|
|
5975
|
+
id: INCIDENT_RESPONSE_TEMPLATE_ID,
|
|
5976
|
+
name: "Incident Response",
|
|
5977
|
+
description: "Triage an incident, gather bounded evidence, apply only allowed narrow mitigation, create follow-up tasks, and verify the response.",
|
|
5978
|
+
kind: "workflow",
|
|
5979
|
+
variables: [
|
|
5980
|
+
{ name: "incidentId", description: "Incident or task id." },
|
|
5981
|
+
{ name: "objective", required: true, description: "Incident response objective." },
|
|
5982
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5983
|
+
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
5984
|
+
{ name: "provider", default: "codewith", description: "Agent provider." },
|
|
5985
|
+
{ name: "sandbox", default: "workspace-write", description: "Provider sandbox mode." },
|
|
5986
|
+
{ name: "worktreeMode", default: "required", description: "Worktree isolation mode." }
|
|
5987
|
+
]
|
|
5988
|
+
},
|
|
5989
|
+
{
|
|
5990
|
+
id: DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID,
|
|
5991
|
+
name: "Deterministic Check Create Task",
|
|
5992
|
+
description: "Run a deterministic check command that writes compact evidence and upserts one deduped todos task when its expectation is not met.",
|
|
5993
|
+
kind: "workflow",
|
|
5994
|
+
variables: [
|
|
5995
|
+
{ name: "checkCommand", required: true, description: "Shell command that performs the check and task upsert." },
|
|
5996
|
+
{ name: "projectPath", required: true, description: "Repository or project working directory." },
|
|
5997
|
+
{ name: "name", description: "Workflow name." },
|
|
5998
|
+
{ name: "timeoutMs", default: "300000", description: "Check timeout in milliseconds." }
|
|
5999
|
+
]
|
|
5434
6000
|
}
|
|
5435
6001
|
];
|
|
5436
6002
|
function compactJson(value) {
|
|
@@ -5492,7 +6058,7 @@ function defaultWorktreeRoot(root) {
|
|
|
5492
6058
|
const expanded = root.trim().replace(/^~(?=$|\/)/, homedir3());
|
|
5493
6059
|
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
5494
6060
|
}
|
|
5495
|
-
return
|
|
6061
|
+
return join5(homedir3(), ".hasna", "loops", "worktrees");
|
|
5496
6062
|
}
|
|
5497
6063
|
function gitRootFor(path) {
|
|
5498
6064
|
if (!existsSync3(path))
|
|
@@ -5593,11 +6159,11 @@ function worktreePlan(input, seed) {
|
|
|
5593
6159
|
};
|
|
5594
6160
|
}
|
|
5595
6161
|
const root = defaultWorktreeRoot(input.worktreeRoot);
|
|
5596
|
-
const repoSlug = slugSegment(
|
|
6162
|
+
const repoSlug = slugSegment(basename3(repoRoot), "repo");
|
|
5597
6163
|
const seedSlug = `${slugSegment(seed, "run").slice(0, 48)}-${stableHex(`${repoRoot}:${seed}`)}`;
|
|
5598
|
-
const worktreePath =
|
|
6164
|
+
const worktreePath = join5(root, repoSlug, seedSlug);
|
|
5599
6165
|
const relativeCwd = relative(repoRoot, originalCwd);
|
|
5600
|
-
const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ?
|
|
6166
|
+
const cwd = relativeCwd && !relativeCwd.startsWith("..") && !isAbsolute(relativeCwd) ? join5(worktreePath, relativeCwd) : worktreePath;
|
|
5601
6167
|
const branchPrefix = (input.worktreeBranchPrefix?.trim() || "openloops").replace(/^\/+|\/+$/g, "") || "openloops";
|
|
5602
6168
|
const branch = `${branchPrefix}/${repoSlug}/${seedSlug}`;
|
|
5603
6169
|
const prepareStep = {
|
|
@@ -5655,10 +6221,20 @@ function assertNativeAuthProfileSupport(input, provider) {
|
|
|
5655
6221
|
return;
|
|
5656
6222
|
throw new Error(`authProfile, authProfilePool, workerAuthProfile, and verifierAuthProfile are supported only for provider codewith; use account/accountPool for ${provider} profile isolation`);
|
|
5657
6223
|
}
|
|
6224
|
+
function failClosedSandbox(input, provider, sandbox) {
|
|
6225
|
+
if (!["codewith", "codex"].includes(provider))
|
|
6226
|
+
return;
|
|
6227
|
+
if (sandbox !== "danger-full-access")
|
|
6228
|
+
return;
|
|
6229
|
+
if (input.manualBreakGlass)
|
|
6230
|
+
return;
|
|
6231
|
+
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");
|
|
6232
|
+
}
|
|
5658
6233
|
function agentTarget(input, prompt, role, seed, plan) {
|
|
5659
6234
|
const provider = input.provider ?? "codewith";
|
|
5660
6235
|
assertNativeAuthProfileSupport(input, provider);
|
|
5661
|
-
const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "
|
|
6236
|
+
const sandbox = input.sandbox ?? (provider === "codewith" || provider === "codex" ? "workspace-write" : provider === "cursor" ? "enabled" : undefined);
|
|
6237
|
+
failClosedSandbox(input, provider, sandbox);
|
|
5662
6238
|
return {
|
|
5663
6239
|
type: "agent",
|
|
5664
6240
|
provider,
|
|
@@ -5682,6 +6258,7 @@ function agentTarget(input, prompt, role, seed, plan) {
|
|
|
5682
6258
|
branch: plan.branch,
|
|
5683
6259
|
reason: plan.reason
|
|
5684
6260
|
},
|
|
6261
|
+
allowlist: input.manualBreakGlass ? { enforcement: "metadata_only", commands: ["manual-break-glass"] } : undefined,
|
|
5685
6262
|
routing: {
|
|
5686
6263
|
projectPath: input.routeProjectPath ?? input.projectPath,
|
|
5687
6264
|
...input.projectGroup ? { projectGroup: input.projectGroup } : {}
|
|
@@ -5771,7 +6348,10 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
5771
6348
|
name: "Verifier",
|
|
5772
6349
|
description: "Adversarially verify worker output and update todos.",
|
|
5773
6350
|
dependsOn: ["worker"],
|
|
5774
|
-
target:
|
|
6351
|
+
target: {
|
|
6352
|
+
...agentTarget(input, verifierPrompt, "verifier", input.taskId, plan),
|
|
6353
|
+
idleTimeoutMs: 10 * 60000
|
|
6354
|
+
},
|
|
5775
6355
|
timeoutMs: 30 * 60000
|
|
5776
6356
|
}
|
|
5777
6357
|
])
|
|
@@ -5849,7 +6429,10 @@ function renderEventWorkerVerifierWorkflow(input) {
|
|
|
5849
6429
|
name: "Verifier",
|
|
5850
6430
|
description: "Adversarially verify event handling.",
|
|
5851
6431
|
dependsOn: ["worker"],
|
|
5852
|
-
target:
|
|
6432
|
+
target: {
|
|
6433
|
+
...agentTarget(input, verifierPrompt, "verifier", seed, plan),
|
|
6434
|
+
idleTimeoutMs: 10 * 60000
|
|
6435
|
+
},
|
|
5853
6436
|
timeoutMs: 30 * 60000
|
|
5854
6437
|
}
|
|
5855
6438
|
])
|
|
@@ -5900,13 +6483,142 @@ function renderBoundedAgentWorkerVerifierWorkflow(input) {
|
|
|
5900
6483
|
name: "Verifier",
|
|
5901
6484
|
description: "Adversarially verify the bounded objective result.",
|
|
5902
6485
|
dependsOn: ["worker"],
|
|
5903
|
-
target:
|
|
6486
|
+
target: {
|
|
6487
|
+
...agentTarget(input, verifierPrompt, "verifier", seed, plan),
|
|
6488
|
+
idleTimeoutMs: 10 * 60000
|
|
6489
|
+
},
|
|
5904
6490
|
timeoutMs: Math.min(timeoutMs, 30 * 60000)
|
|
5905
6491
|
}
|
|
5906
6492
|
])
|
|
5907
6493
|
};
|
|
5908
6494
|
}
|
|
6495
|
+
function renderLifecycleBoundedTemplate(id, values) {
|
|
6496
|
+
const projectPath = values.projectPath ?? values.cwd ?? process.cwd();
|
|
6497
|
+
const common = {
|
|
6498
|
+
name: values.name,
|
|
6499
|
+
projectPath,
|
|
6500
|
+
routeProjectPath: values.routeProjectPath,
|
|
6501
|
+
projectGroup: values.projectGroup,
|
|
6502
|
+
provider: values.provider,
|
|
6503
|
+
authProfile: values.authProfile,
|
|
6504
|
+
authProfilePool: listVar(values.authProfilePool),
|
|
6505
|
+
workerAuthProfile: values.workerAuthProfile,
|
|
6506
|
+
verifierAuthProfile: values.verifierAuthProfile,
|
|
6507
|
+
account: values.account ? { profile: values.account, tool: values.accountTool } : undefined,
|
|
6508
|
+
accountPool: accountPoolVar(values.accountPool, values.accountTool),
|
|
6509
|
+
model: values.model,
|
|
6510
|
+
variant: values.variant,
|
|
6511
|
+
agent: values.agent,
|
|
6512
|
+
permissionMode: values.permissionMode,
|
|
6513
|
+
sandbox: values.sandbox,
|
|
6514
|
+
manualBreakGlass: booleanVar(values.manualBreakGlass),
|
|
6515
|
+
worktreeMode: values.worktreeMode ?? (id === REPORT_ONLY_TEMPLATE_ID ? "main" : "required"),
|
|
6516
|
+
worktreeRoot: values.worktreeRoot,
|
|
6517
|
+
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
6518
|
+
timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : undefined
|
|
6519
|
+
};
|
|
6520
|
+
if (id === TASK_LIFECYCLE_TEMPLATE_ID) {
|
|
6521
|
+
const taskId = values.taskId ?? "";
|
|
6522
|
+
if (!taskId.trim())
|
|
6523
|
+
throw new Error("taskId is required");
|
|
6524
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6525
|
+
...common,
|
|
6526
|
+
name: values.name ?? `task-lifecycle-${slugSegment(taskId)}-worker-verifier`,
|
|
6527
|
+
objective: values.objective ?? `Run the full task lifecycle for todos task ${taskId}.`,
|
|
6528
|
+
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."
|
|
6529
|
+
});
|
|
6530
|
+
}
|
|
6531
|
+
if (id === PR_REVIEW_TEMPLATE_ID) {
|
|
6532
|
+
const pr = values.prUrl ?? values.prNumber ?? "";
|
|
6533
|
+
if (!pr.trim())
|
|
6534
|
+
throw new Error("prUrl or prNumber is required");
|
|
6535
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6536
|
+
...common,
|
|
6537
|
+
name: values.name ?? `pr-review-${slugSegment(pr)}-worker-verifier`,
|
|
6538
|
+
objective: values.objective ?? `Review and drive PR ${pr} toward merge-ready state.`,
|
|
6539
|
+
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."
|
|
6540
|
+
});
|
|
6541
|
+
}
|
|
6542
|
+
if (id === SCHEDULED_AUDIT_TEMPLATE_ID) {
|
|
6543
|
+
const objective = values.objective ?? "";
|
|
6544
|
+
if (!objective.trim())
|
|
6545
|
+
throw new Error("objective is required");
|
|
6546
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6547
|
+
...common,
|
|
6548
|
+
name: values.name ?? `scheduled-audit-${stableIndex(`${projectPath}:${objective}`, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
|
|
6549
|
+
objective,
|
|
6550
|
+
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."
|
|
6551
|
+
});
|
|
6552
|
+
}
|
|
6553
|
+
if (id === KNOWLEDGE_REFRESH_TEMPLATE_ID) {
|
|
6554
|
+
const scope = values.scope ?? values.label ?? "recent knowledge";
|
|
6555
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6556
|
+
...common,
|
|
6557
|
+
name: values.name ?? `knowledge-refresh-${slugSegment(scope)}-worker-verifier`,
|
|
6558
|
+
objective: values.objective ?? `Refresh and verify ${scope}.`,
|
|
6559
|
+
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."
|
|
6560
|
+
});
|
|
6561
|
+
}
|
|
6562
|
+
if (id === REPORT_ONLY_TEMPLATE_ID) {
|
|
6563
|
+
const objective = values.objective ?? "";
|
|
6564
|
+
if (!objective.trim())
|
|
6565
|
+
throw new Error("objective is required");
|
|
6566
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6567
|
+
...common,
|
|
6568
|
+
name: values.name ?? `report-only-${stableIndex(`${projectPath}:${objective}`, 4294967295).toString(16).padStart(8, "0")}-worker-verifier`,
|
|
6569
|
+
objective,
|
|
6570
|
+
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."
|
|
6571
|
+
});
|
|
6572
|
+
}
|
|
6573
|
+
if (id === INCIDENT_RESPONSE_TEMPLATE_ID) {
|
|
6574
|
+
const objective = values.objective ?? "";
|
|
6575
|
+
if (!objective.trim())
|
|
6576
|
+
throw new Error("objective is required");
|
|
6577
|
+
const incident = values.incidentId ?? values.taskId ?? "incident";
|
|
6578
|
+
return renderBoundedAgentWorkerVerifierWorkflow({
|
|
6579
|
+
...common,
|
|
6580
|
+
name: values.name ?? `incident-response-${slugSegment(incident)}-worker-verifier`,
|
|
6581
|
+
objective,
|
|
6582
|
+
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."
|
|
6583
|
+
});
|
|
6584
|
+
}
|
|
6585
|
+
return;
|
|
6586
|
+
}
|
|
6587
|
+
function renderDeterministicCheckCreateTaskWorkflow(values) {
|
|
6588
|
+
const projectPath = values.projectPath ?? values.cwd ?? process.cwd();
|
|
6589
|
+
const checkCommand = values.checkCommand ?? "";
|
|
6590
|
+
if (!checkCommand.trim())
|
|
6591
|
+
throw new Error("checkCommand is required");
|
|
6592
|
+
const seed = `${projectPath}:${checkCommand}`;
|
|
6593
|
+
return {
|
|
6594
|
+
name: values.name ?? `deterministic-check-${stableIndex(seed, 4294967295).toString(16).padStart(8, "0")}`,
|
|
6595
|
+
description: values.description ?? "Deterministic check that writes compact evidence and upserts one deduped todos task when the expectation is not met.",
|
|
6596
|
+
version: 1,
|
|
6597
|
+
steps: [
|
|
6598
|
+
{
|
|
6599
|
+
id: "check",
|
|
6600
|
+
name: "Check",
|
|
6601
|
+
description: "Run the deterministic check/task-upsert command.",
|
|
6602
|
+
target: {
|
|
6603
|
+
type: "command",
|
|
6604
|
+
command: "bash",
|
|
6605
|
+
args: ["-lc", checkCommand],
|
|
6606
|
+
cwd: projectPath,
|
|
6607
|
+
timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000,
|
|
6608
|
+
idleTimeoutMs: values.idleTimeoutMs ? Number(values.idleTimeoutMs) : 60000
|
|
6609
|
+
},
|
|
6610
|
+
timeoutMs: values.timeoutMs ? Number(values.timeoutMs) : 5 * 60000
|
|
6611
|
+
}
|
|
6612
|
+
]
|
|
6613
|
+
};
|
|
6614
|
+
}
|
|
5909
6615
|
function renderLoopTemplate(id, values) {
|
|
6616
|
+
if (id === DETERMINISTIC_CHECK_CREATE_TASK_TEMPLATE_ID) {
|
|
6617
|
+
return renderDeterministicCheckCreateTaskWorkflow(values);
|
|
6618
|
+
}
|
|
6619
|
+
const lifecycle = renderLifecycleBoundedTemplate(id, values);
|
|
6620
|
+
if (lifecycle)
|
|
6621
|
+
return lifecycle;
|
|
5910
6622
|
if (id === TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID) {
|
|
5911
6623
|
return renderTodosTaskWorkerVerifierWorkflow({
|
|
5912
6624
|
taskId: values.taskId ?? "",
|
|
@@ -5927,6 +6639,7 @@ function renderLoopTemplate(id, values) {
|
|
|
5927
6639
|
agent: values.agent,
|
|
5928
6640
|
permissionMode: values.permissionMode,
|
|
5929
6641
|
sandbox: values.sandbox,
|
|
6642
|
+
manualBreakGlass: booleanVar(values.manualBreakGlass),
|
|
5930
6643
|
worktreeMode: values.worktreeMode,
|
|
5931
6644
|
worktreeRoot: values.worktreeRoot,
|
|
5932
6645
|
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
@@ -5957,6 +6670,7 @@ function renderLoopTemplate(id, values) {
|
|
|
5957
6670
|
agent: values.agent,
|
|
5958
6671
|
permissionMode: values.permissionMode,
|
|
5959
6672
|
sandbox: values.sandbox,
|
|
6673
|
+
manualBreakGlass: booleanVar(values.manualBreakGlass),
|
|
5960
6674
|
worktreeMode: values.worktreeMode,
|
|
5961
6675
|
worktreeRoot: values.worktreeRoot,
|
|
5962
6676
|
worktreeBranchPrefix: values.worktreeBranchPrefix
|
|
@@ -5982,6 +6696,7 @@ function renderLoopTemplate(id, values) {
|
|
|
5982
6696
|
agent: values.agent,
|
|
5983
6697
|
permissionMode: values.permissionMode,
|
|
5984
6698
|
sandbox: values.sandbox,
|
|
6699
|
+
manualBreakGlass: booleanVar(values.manualBreakGlass),
|
|
5985
6700
|
worktreeMode: values.worktreeMode,
|
|
5986
6701
|
worktreeRoot: values.worktreeRoot,
|
|
5987
6702
|
worktreeBranchPrefix: values.worktreeBranchPrefix,
|
|
@@ -5994,6 +6709,16 @@ function listVar(value) {
|
|
|
5994
6709
|
const values = value?.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
5995
6710
|
return values?.length ? values : undefined;
|
|
5996
6711
|
}
|
|
6712
|
+
function booleanVar(value) {
|
|
6713
|
+
if (value === undefined)
|
|
6714
|
+
return;
|
|
6715
|
+
const normalized = value.trim().toLowerCase();
|
|
6716
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
6717
|
+
return true;
|
|
6718
|
+
if (["0", "false", "no", "off", ""].includes(normalized))
|
|
6719
|
+
return false;
|
|
6720
|
+
throw new Error(`expected boolean value, got ${value}`);
|
|
6721
|
+
}
|
|
5997
6722
|
function accountPoolVar(value, tool) {
|
|
5998
6723
|
return listVar(value)?.map((profile) => ({ profile, tool }));
|
|
5999
6724
|
}
|
|
@@ -6224,8 +6949,8 @@ function runLocalCommand(command, args, opts = {}) {
|
|
|
6224
6949
|
};
|
|
6225
6950
|
}
|
|
6226
6951
|
function runLocalCommandWithStdoutFile(command, args, opts = {}) {
|
|
6227
|
-
const tempDir =
|
|
6228
|
-
const stdoutPath =
|
|
6952
|
+
const tempDir = mkdtempSync2(join6(tmpdir2(), "loops-command-output-"));
|
|
6953
|
+
const stdoutPath = join6(tempDir, "stdout");
|
|
6229
6954
|
const stdoutFd = openSync2(stdoutPath, "w");
|
|
6230
6955
|
let result;
|
|
6231
6956
|
try {
|
|
@@ -6249,7 +6974,7 @@ function runLocalCommandWithStdoutFile(command, args, opts = {}) {
|
|
|
6249
6974
|
error: result.error ? String(result.error.message || result.error) : ""
|
|
6250
6975
|
};
|
|
6251
6976
|
} finally {
|
|
6252
|
-
|
|
6977
|
+
rmSync3(tempDir, { recursive: true, force: true });
|
|
6253
6978
|
}
|
|
6254
6979
|
}
|
|
6255
6980
|
function ensureTodosTaskList(project, slug, name, description) {
|
|
@@ -6265,23 +6990,23 @@ function ensureTodosTaskList(project, slug, name, description) {
|
|
|
6265
6990
|
}
|
|
6266
6991
|
function backupLoopsDatabase(reason) {
|
|
6267
6992
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z");
|
|
6268
|
-
const backupDir =
|
|
6269
|
-
|
|
6270
|
-
const backupPath =
|
|
6993
|
+
const backupDir = join6(dataDir(), "backups");
|
|
6994
|
+
mkdirSync6(backupDir, { recursive: true, mode: 448 });
|
|
6995
|
+
const backupPath = join6(backupDir, `loops.db.bak-${reason}-${stamp}`);
|
|
6271
6996
|
const db = new Database2(dbPath(), { readonly: true });
|
|
6272
6997
|
try {
|
|
6273
|
-
|
|
6998
|
+
writeFileSync4(backupPath, db.serialize(), { mode: 384 });
|
|
6274
6999
|
} finally {
|
|
6275
7000
|
db.close();
|
|
6276
7001
|
}
|
|
6277
7002
|
return backupPath;
|
|
6278
7003
|
}
|
|
6279
7004
|
function stableHash(parts) {
|
|
6280
|
-
return
|
|
7005
|
+
return createHash3("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
|
|
6281
7006
|
`)).digest("hex").slice(0, 16);
|
|
6282
7007
|
}
|
|
6283
7008
|
function routeCursorsPath() {
|
|
6284
|
-
return
|
|
7009
|
+
return join6(dataDir(), "route-cursors.json");
|
|
6285
7010
|
}
|
|
6286
7011
|
function readRouteCursors() {
|
|
6287
7012
|
const path = routeCursorsPath();
|
|
@@ -6299,15 +7024,15 @@ function writeRouteCursor(key, lastFingerprint) {
|
|
|
6299
7024
|
return;
|
|
6300
7025
|
const cursors = readRouteCursors();
|
|
6301
7026
|
cursors[key] = { lastFingerprint, updatedAt: new Date().toISOString() };
|
|
6302
|
-
|
|
7027
|
+
writeFileSync4(routeCursorsPath(), JSON.stringify(cursors, null, 2), { mode: 384 });
|
|
6303
7028
|
}
|
|
6304
7029
|
function writeRouteEvidence(kind, value, evidenceDir) {
|
|
6305
7030
|
if (!evidenceDir)
|
|
6306
7031
|
return;
|
|
6307
|
-
|
|
7032
|
+
mkdirSync6(evidenceDir, { recursive: true, mode: 448 });
|
|
6308
7033
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
6309
|
-
const evidencePath =
|
|
6310
|
-
|
|
7034
|
+
const evidencePath = join6(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
7035
|
+
writeFileSync4(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
|
|
6311
7036
|
return evidencePath;
|
|
6312
7037
|
}
|
|
6313
7038
|
function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
@@ -6426,7 +7151,7 @@ function slugSegment2(value, fallback = "event") {
|
|
|
6426
7151
|
return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
|
|
6427
7152
|
}
|
|
6428
7153
|
function stableSuffix(value) {
|
|
6429
|
-
return
|
|
7154
|
+
return createHash3("sha256").update(value).digest("hex").slice(0, 12);
|
|
6430
7155
|
}
|
|
6431
7156
|
function taskEventField(data, keys) {
|
|
6432
7157
|
for (const key of keys) {
|
|
@@ -6561,6 +7286,33 @@ function taskRouteEligibility(data, metadata) {
|
|
|
6561
7286
|
}
|
|
6562
7287
|
return { eligible: true, tags };
|
|
6563
7288
|
}
|
|
7289
|
+
function generatedRouteSandboxPreflight(workflow) {
|
|
7290
|
+
const checks = [];
|
|
7291
|
+
for (const step of workflow.steps) {
|
|
7292
|
+
if (step.target.type !== "agent")
|
|
7293
|
+
continue;
|
|
7294
|
+
const target = step.target;
|
|
7295
|
+
const worktreeEnabled = Boolean(target.worktree?.enabled);
|
|
7296
|
+
if (target.sandbox === "danger-full-access") {
|
|
7297
|
+
const manual = target.allowlist?.commands?.includes("manual-break-glass");
|
|
7298
|
+
if (!manual) {
|
|
7299
|
+
throw new Error(`route step ${step.id} uses danger-full-access without manual break-glass evidence`);
|
|
7300
|
+
}
|
|
7301
|
+
checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "manual-break-glass" });
|
|
7302
|
+
continue;
|
|
7303
|
+
}
|
|
7304
|
+
if (["codewith", "codex"].includes(target.provider) && (target.sandbox === "workspace-write" || target.sandbox === "read-only") || target.provider === "cursor" && target.sandbox === "enabled") {
|
|
7305
|
+
checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "provider-native-sandbox" });
|
|
7306
|
+
continue;
|
|
7307
|
+
}
|
|
7308
|
+
if (worktreeEnabled) {
|
|
7309
|
+
checks.push({ stepId: step.id, provider: target.provider, sandbox: target.sandbox, worktreeEnabled, method: "isolated-worktree" });
|
|
7310
|
+
continue;
|
|
7311
|
+
}
|
|
7312
|
+
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`);
|
|
7313
|
+
}
|
|
7314
|
+
return checks;
|
|
7315
|
+
}
|
|
6564
7316
|
function routeThrottleLimitsFromOpts(opts) {
|
|
6565
7317
|
return {
|
|
6566
7318
|
maxActive: positiveInteger(opts.maxActive, "--max-active"),
|
|
@@ -6594,46 +7346,10 @@ function normalizeRoutePath(value) {
|
|
|
6594
7346
|
function routeProjectGroup(optsGroup, data, metadata) {
|
|
6595
7347
|
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
7348
|
}
|
|
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
7349
|
function routeThrottleDecision(store, args) {
|
|
6628
7350
|
const projectPath = normalizeRoutePath(args.projectPath) ?? resolve2(args.projectPath);
|
|
6629
7351
|
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;
|
|
7352
|
+
const counts = store.countActiveWorkflowWorkItems({ projectKey: projectPath, projectGroup });
|
|
6637
7353
|
const base = {
|
|
6638
7354
|
projectPath,
|
|
6639
7355
|
...projectGroup ? { projectGroup } : {},
|
|
@@ -6666,12 +7382,8 @@ function routeThrottleDryRunPreview(args) {
|
|
|
6666
7382
|
limits: args.limits
|
|
6667
7383
|
};
|
|
6668
7384
|
}
|
|
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();
|
|
7385
|
+
async function readEventEnvelopeInput(opts = {}) {
|
|
7386
|
+
const raw = opts.eventJson ?? (opts.eventFile ? readFileSync2(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
|
|
6675
7387
|
const event = JSON.parse(raw);
|
|
6676
7388
|
if (!event || typeof event !== "object" || Array.isArray(event))
|
|
6677
7389
|
throw new Error("event JSON must be an object");
|
|
@@ -6683,6 +7395,9 @@ async function readEventEnvelopeFromStdin() {
|
|
|
6683
7395
|
throw new Error("event.source is required");
|
|
6684
7396
|
return event;
|
|
6685
7397
|
}
|
|
7398
|
+
async function readEventEnvelopeFromStdin() {
|
|
7399
|
+
return readEventEnvelopeInput();
|
|
7400
|
+
}
|
|
6686
7401
|
function routeTodosTaskEvent(event, opts) {
|
|
6687
7402
|
const data = eventData(event);
|
|
6688
7403
|
const metadata = eventMetadata(event);
|
|
@@ -6717,24 +7432,27 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6717
7432
|
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
6718
7433
|
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
6719
7434
|
const loopName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:run`;
|
|
6720
|
-
const legacyLoopName = `${namePrefix}:${taskId.slice(0, 8)}:${event.id.slice(0, 8)}:run`;
|
|
6721
7435
|
if (!opts.dryRun) {
|
|
6722
7436
|
const store2 = new Store;
|
|
6723
7437
|
try {
|
|
6724
|
-
const
|
|
6725
|
-
if (
|
|
6726
|
-
const
|
|
7438
|
+
const existingItem = store2.findWorkflowWorkItem("todos-task", idempotencyKey);
|
|
7439
|
+
if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
|
|
7440
|
+
const existingLoop = store2.getLoop(existingItem.loopId);
|
|
7441
|
+
const existingWorkflow = existingItem.workflowId ? store2.getWorkflow(existingItem.workflowId) : undefined;
|
|
7442
|
+
const existingInvocation = store2.getWorkflowInvocation(existingItem.invocationId);
|
|
6727
7443
|
return {
|
|
6728
7444
|
kind: "deduped",
|
|
6729
7445
|
value: {
|
|
6730
7446
|
deduped: true,
|
|
6731
7447
|
idempotencyKey,
|
|
6732
|
-
dedupedBy:
|
|
7448
|
+
dedupedBy: "work-item",
|
|
6733
7449
|
event,
|
|
7450
|
+
invocation: existingInvocation ? publicWorkflowInvocation(existingInvocation) : undefined,
|
|
7451
|
+
workItem: publicWorkflowWorkItem(existingItem),
|
|
6734
7452
|
workflow: existingWorkflow ? publicWorkflow(existingWorkflow) : undefined,
|
|
6735
|
-
loop: publicLoop(existingLoop)
|
|
7453
|
+
loop: existingLoop ? publicLoop(existingLoop) : undefined
|
|
6736
7454
|
},
|
|
6737
|
-
human: `deduped existing
|
|
7455
|
+
human: `deduped existing work item ${existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
|
|
6738
7456
|
};
|
|
6739
7457
|
}
|
|
6740
7458
|
} finally {
|
|
@@ -6768,6 +7486,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6768
7486
|
agent: opts.agent,
|
|
6769
7487
|
permissionMode,
|
|
6770
7488
|
sandbox,
|
|
7489
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
6771
7490
|
worktreeMode: opts.worktreeMode ?? "auto",
|
|
6772
7491
|
worktreeRoot: opts.worktreeRoot,
|
|
6773
7492
|
worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops",
|
|
@@ -6776,11 +7495,53 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6776
7495
|
});
|
|
6777
7496
|
workflowBody.name = workflowName;
|
|
6778
7497
|
workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
7498
|
+
const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
|
|
7499
|
+
const invocationInput = {
|
|
7500
|
+
templateId: "todos-task-worker-verifier",
|
|
7501
|
+
sourceRef: {
|
|
7502
|
+
kind: "event",
|
|
7503
|
+
id: event.id,
|
|
7504
|
+
dedupeKey: idempotencyKey,
|
|
7505
|
+
raw: { type: event.type, source: event.source, subject: event.subject }
|
|
7506
|
+
},
|
|
7507
|
+
subjectRef: {
|
|
7508
|
+
kind: "task",
|
|
7509
|
+
id: taskId,
|
|
7510
|
+
path: routeProjectPath,
|
|
7511
|
+
raw: { title: taskTitle, description: taskDescription }
|
|
7512
|
+
},
|
|
7513
|
+
intent: "route",
|
|
7514
|
+
scope: {
|
|
7515
|
+
projectPath: routeProjectPath,
|
|
7516
|
+
projectGroup,
|
|
7517
|
+
worktreePolicy: opts.worktreeMode ?? "auto",
|
|
7518
|
+
permissions: permissionMode,
|
|
7519
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
7520
|
+
accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
|
|
7521
|
+
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
7522
|
+
},
|
|
7523
|
+
outputPolicy: {
|
|
7524
|
+
report: "always",
|
|
7525
|
+
createTask: "on_failure"
|
|
7526
|
+
}
|
|
7527
|
+
};
|
|
7528
|
+
const workItemInput = {
|
|
7529
|
+
routeKey: "todos-task",
|
|
7530
|
+
idempotencyKey,
|
|
7531
|
+
invocationId: "<created-invocation-id>",
|
|
7532
|
+
sourceType: event.type,
|
|
7533
|
+
sourceRef: event.id,
|
|
7534
|
+
subjectRef: taskId,
|
|
7535
|
+
projectKey: routeProjectPath,
|
|
7536
|
+
projectGroup,
|
|
7537
|
+
priority: 0,
|
|
7538
|
+
status: "queued"
|
|
7539
|
+
};
|
|
6779
7540
|
const loopInput = {
|
|
6780
7541
|
name: loopName,
|
|
6781
7542
|
description: `Run ${workflowBody.name} once for task ${taskId}; idempotency=${idempotencyKey}; event=${event.id}`,
|
|
6782
7543
|
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
6783
|
-
target: { type: "workflow", workflowId: "<created-workflow-id>" },
|
|
7544
|
+
target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
|
|
6784
7545
|
overlap: "skip",
|
|
6785
7546
|
maxAttempts: 1,
|
|
6786
7547
|
retryDelayMs: 60000,
|
|
@@ -6795,7 +7556,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6795
7556
|
}, {}) : undefined;
|
|
6796
7557
|
return {
|
|
6797
7558
|
kind: "created",
|
|
6798
|
-
value: { deduped: false, idempotencyKey, event, workflow: workflowBody, loop: loopInput, throttle, preflight },
|
|
7559
|
+
value: { deduped: false, idempotencyKey, event, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
|
|
6799
7560
|
human: `dry-run ${loopName}`
|
|
6800
7561
|
};
|
|
6801
7562
|
}
|
|
@@ -6803,27 +7564,44 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6803
7564
|
try {
|
|
6804
7565
|
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
6805
7566
|
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
7567
|
+
generatedRouteSandboxPreflight(workflowPreflightSpec);
|
|
6806
7568
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
6807
7569
|
name: workflowBody.name,
|
|
6808
7570
|
type: "todos-task-event-workflow",
|
|
6809
7571
|
event: event.id
|
|
6810
7572
|
}, {}) : undefined;
|
|
6811
7573
|
const outcome = store.writeTransaction(() => {
|
|
6812
|
-
const
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
7574
|
+
const invocation = store.createWorkflowInvocation(invocationInput);
|
|
7575
|
+
const existingItem = store.findWorkflowWorkItem("todos-task", idempotencyKey);
|
|
7576
|
+
if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
|
|
7577
|
+
const existingLoop = store.getLoop(existingItem.loopId);
|
|
7578
|
+
const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
7579
|
+
return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
|
|
6816
7580
|
}
|
|
6817
7581
|
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
7582
|
+
const workItem = store.upsertWorkflowWorkItem({
|
|
7583
|
+
...workItemInput,
|
|
7584
|
+
invocationId: invocation.id,
|
|
7585
|
+
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
7586
|
+
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
7587
|
+
});
|
|
6818
7588
|
if (throttle && !throttle.allowed)
|
|
6819
|
-
return { kind: "throttled", throttle };
|
|
7589
|
+
return { kind: "throttled", invocation, workItem, throttle };
|
|
6820
7590
|
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
6821
7591
|
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
6822
7592
|
const loop = store.createLoop({
|
|
6823
7593
|
...loopInput,
|
|
6824
|
-
target: {
|
|
7594
|
+
target: {
|
|
7595
|
+
type: "workflow",
|
|
7596
|
+
workflowId: workflow.id,
|
|
7597
|
+
input: {
|
|
7598
|
+
workflowInvocationId: invocation.id,
|
|
7599
|
+
workflowWorkItemId: workItem.id
|
|
7600
|
+
}
|
|
7601
|
+
}
|
|
6825
7602
|
});
|
|
6826
|
-
|
|
7603
|
+
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by todos-task route" });
|
|
7604
|
+
return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
|
|
6827
7605
|
});
|
|
6828
7606
|
if (outcome.kind === "deduped") {
|
|
6829
7607
|
return {
|
|
@@ -6831,12 +7609,14 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6831
7609
|
value: {
|
|
6832
7610
|
deduped: true,
|
|
6833
7611
|
idempotencyKey,
|
|
6834
|
-
dedupedBy:
|
|
7612
|
+
dedupedBy: "work-item",
|
|
6835
7613
|
event,
|
|
7614
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7615
|
+
workItem: publicWorkflowWorkItem(outcome.existingItem),
|
|
6836
7616
|
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
6837
|
-
loop: publicLoop(outcome.existingLoop)
|
|
7617
|
+
loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
|
|
6838
7618
|
},
|
|
6839
|
-
human: `deduped existing
|
|
7619
|
+
human: `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
|
|
6840
7620
|
};
|
|
6841
7621
|
}
|
|
6842
7622
|
if (outcome.kind === "throttled") {
|
|
@@ -6848,6 +7628,8 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6848
7628
|
reason: outcome.throttle.reason,
|
|
6849
7629
|
idempotencyKey,
|
|
6850
7630
|
event,
|
|
7631
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7632
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
6851
7633
|
throttle: outcome.throttle,
|
|
6852
7634
|
workflow: workflowBody,
|
|
6853
7635
|
loop: loopInput
|
|
@@ -6861,9 +7643,12 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6861
7643
|
deduped: false,
|
|
6862
7644
|
idempotencyKey,
|
|
6863
7645
|
event,
|
|
7646
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7647
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
6864
7648
|
workflow: publicWorkflow(outcome.workflow),
|
|
6865
7649
|
loop: publicLoop(outcome.loop),
|
|
6866
7650
|
throttle: outcome.throttle,
|
|
7651
|
+
sandboxPreflight,
|
|
6867
7652
|
preflight
|
|
6868
7653
|
},
|
|
6869
7654
|
human: `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`
|
|
@@ -6872,17 +7657,229 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
6872
7657
|
store.close();
|
|
6873
7658
|
}
|
|
6874
7659
|
}
|
|
6875
|
-
function
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
}
|
|
7660
|
+
function routeGenericEvent(event, opts) {
|
|
7661
|
+
const data = eventData(event);
|
|
7662
|
+
const metadata = eventMetadata(event);
|
|
7663
|
+
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();
|
|
7664
|
+
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
|
|
7665
|
+
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
7666
|
+
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
7667
|
+
const eventSuffix = event.id.slice(0, 8);
|
|
7668
|
+
const source = slugSegment2(event.source, "source");
|
|
7669
|
+
const type = slugSegment2(event.type, "type");
|
|
7670
|
+
const workflowName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:workflow`;
|
|
7671
|
+
const loopName = `${opts.namePrefix ?? "event:generic"}:${source}:${type}:${eventSuffix}:run`;
|
|
7672
|
+
const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
|
|
7673
|
+
const provider = opts.provider ?? "codewith";
|
|
7674
|
+
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
|
|
7675
|
+
throw new Error("unsupported provider");
|
|
7676
|
+
const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
|
|
7677
|
+
const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
|
|
7678
|
+
const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
|
|
7679
|
+
const workflowBody = renderEventWorkerVerifierWorkflow({
|
|
7680
|
+
eventId: event.id,
|
|
7681
|
+
eventType: event.type,
|
|
7682
|
+
eventSource: event.source,
|
|
7683
|
+
eventSubject: stringField(event.subject),
|
|
7684
|
+
eventMessage: stringField(event.message),
|
|
7685
|
+
eventJson: JSON.stringify(event),
|
|
7686
|
+
projectPath,
|
|
7687
|
+
routeProjectPath,
|
|
7688
|
+
projectGroup,
|
|
7689
|
+
provider,
|
|
7690
|
+
authProfile,
|
|
7691
|
+
authProfilePool: splitList(opts.authProfilePool),
|
|
7692
|
+
workerAuthProfile: opts.workerAuthProfile,
|
|
7693
|
+
verifierAuthProfile: opts.verifierAuthProfile,
|
|
7694
|
+
account: accountFromOpts(opts),
|
|
7695
|
+
accountPool: accountPoolFromOpts(opts),
|
|
7696
|
+
workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
|
|
7697
|
+
verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
|
|
7698
|
+
model: opts.model,
|
|
7699
|
+
variant: opts.variant,
|
|
7700
|
+
agent: opts.agent,
|
|
7701
|
+
permissionMode,
|
|
7702
|
+
sandbox,
|
|
7703
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
7704
|
+
worktreeMode: opts.worktreeMode ?? "auto",
|
|
7705
|
+
worktreeRoot: opts.worktreeRoot,
|
|
7706
|
+
worktreeBranchPrefix: opts.worktreeBranchPrefix ?? "openloops"
|
|
7707
|
+
});
|
|
7708
|
+
workflowBody.name = workflowName;
|
|
7709
|
+
workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
7710
|
+
const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
|
|
7711
|
+
const invocationInput = {
|
|
7712
|
+
templateId: "event-worker-verifier",
|
|
7713
|
+
sourceRef: {
|
|
7714
|
+
kind: "event",
|
|
7715
|
+
id: event.id,
|
|
7716
|
+
dedupeKey: idempotencyKey,
|
|
7717
|
+
raw: { source: event.source, type: event.type }
|
|
7718
|
+
},
|
|
7719
|
+
subjectRef: {
|
|
7720
|
+
kind: "event",
|
|
7721
|
+
id: stringField(event.subject) ?? event.id,
|
|
7722
|
+
path: routeProjectPath,
|
|
7723
|
+
raw: { message: stringField(event.message) }
|
|
7724
|
+
},
|
|
7725
|
+
intent: "route",
|
|
7726
|
+
scope: {
|
|
7727
|
+
projectPath: routeProjectPath,
|
|
7728
|
+
projectGroup,
|
|
7729
|
+
worktreePolicy: opts.worktreeMode ?? "auto",
|
|
7730
|
+
permissions: permissionMode,
|
|
7731
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
7732
|
+
accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
|
|
7733
|
+
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
7734
|
+
},
|
|
7735
|
+
outputPolicy: {
|
|
7736
|
+
report: "always",
|
|
7737
|
+
createTask: "on_failure"
|
|
7738
|
+
}
|
|
7739
|
+
};
|
|
7740
|
+
const workItemInput = {
|
|
7741
|
+
routeKey: "generic-event",
|
|
7742
|
+
idempotencyKey,
|
|
7743
|
+
invocationId: "<created-invocation-id>",
|
|
7744
|
+
sourceType: event.type,
|
|
7745
|
+
sourceRef: event.id,
|
|
7746
|
+
subjectRef: stringField(event.subject) ?? event.id,
|
|
7747
|
+
projectKey: routeProjectPath,
|
|
7748
|
+
projectGroup,
|
|
7749
|
+
priority: 0,
|
|
7750
|
+
status: "queued"
|
|
7751
|
+
};
|
|
7752
|
+
const loopInput = {
|
|
7753
|
+
name: loopName,
|
|
7754
|
+
description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
|
|
7755
|
+
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
7756
|
+
target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
|
|
7757
|
+
overlap: "skip",
|
|
7758
|
+
maxAttempts: 1,
|
|
7759
|
+
retryDelayMs: 60000,
|
|
7760
|
+
leaseMs: 90 * 60000
|
|
7761
|
+
};
|
|
7762
|
+
if (opts.dryRun) {
|
|
7763
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
7764
|
+
const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
|
|
7765
|
+
name: workflowBody.name,
|
|
7766
|
+
type: "generic-event-workflow",
|
|
7767
|
+
event: event.id
|
|
7768
|
+
}, {}) : undefined;
|
|
7769
|
+
return {
|
|
7770
|
+
kind: "created",
|
|
7771
|
+
value: { event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight },
|
|
7772
|
+
human: `dry-run ${loopName}`
|
|
7773
|
+
};
|
|
7774
|
+
}
|
|
7775
|
+
const store = new Store;
|
|
7776
|
+
try {
|
|
7777
|
+
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
7778
|
+
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
7779
|
+
generatedRouteSandboxPreflight(workflowPreflightSpec);
|
|
7780
|
+
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
7781
|
+
name: workflowBody.name,
|
|
7782
|
+
type: "generic-event-workflow",
|
|
7783
|
+
event: event.id
|
|
7784
|
+
}, {}) : undefined;
|
|
7785
|
+
const outcome = store.writeTransaction(() => {
|
|
7786
|
+
const invocation = store.createWorkflowInvocation(invocationInput);
|
|
7787
|
+
const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
|
|
7788
|
+
if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
|
|
7789
|
+
const existingLoop = store.getLoop(existingItem.loopId);
|
|
7790
|
+
const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
7791
|
+
return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
|
|
7792
|
+
}
|
|
7793
|
+
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
7794
|
+
const workItem = store.upsertWorkflowWorkItem({
|
|
7795
|
+
...workItemInput,
|
|
7796
|
+
invocationId: invocation.id,
|
|
7797
|
+
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
7798
|
+
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
7799
|
+
});
|
|
7800
|
+
if (throttle && !throttle.allowed)
|
|
7801
|
+
return { kind: "throttled", invocation, workItem, throttle };
|
|
7802
|
+
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
7803
|
+
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
7804
|
+
const loop = store.createLoop({
|
|
7805
|
+
...loopInput,
|
|
7806
|
+
target: {
|
|
7807
|
+
type: "workflow",
|
|
7808
|
+
workflowId: workflow.id,
|
|
7809
|
+
input: {
|
|
7810
|
+
workflowInvocationId: invocation.id,
|
|
7811
|
+
workflowWorkItemId: workItem.id
|
|
7812
|
+
}
|
|
7813
|
+
}
|
|
7814
|
+
});
|
|
7815
|
+
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
|
|
7816
|
+
return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
|
|
7817
|
+
});
|
|
7818
|
+
if (outcome.kind === "deduped") {
|
|
7819
|
+
return {
|
|
7820
|
+
kind: "deduped",
|
|
7821
|
+
value: {
|
|
7822
|
+
deduped: true,
|
|
7823
|
+
idempotencyKey,
|
|
7824
|
+
dedupedBy: "work-item",
|
|
7825
|
+
event,
|
|
7826
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7827
|
+
workItem: publicWorkflowWorkItem(outcome.existingItem),
|
|
7828
|
+
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
7829
|
+
loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
|
|
7830
|
+
},
|
|
7831
|
+
human: `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`
|
|
7832
|
+
};
|
|
7833
|
+
}
|
|
7834
|
+
if (outcome.kind === "throttled") {
|
|
7835
|
+
return {
|
|
7836
|
+
kind: "throttled",
|
|
7837
|
+
value: {
|
|
7838
|
+
skipped: true,
|
|
7839
|
+
queuedAtSource: true,
|
|
7840
|
+
reason: outcome.throttle.reason,
|
|
7841
|
+
idempotencyKey,
|
|
7842
|
+
event,
|
|
7843
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7844
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
7845
|
+
throttle: outcome.throttle,
|
|
7846
|
+
workflow: workflowBody,
|
|
7847
|
+
loop: loopInput
|
|
7848
|
+
},
|
|
7849
|
+
human: `skipped event ${event.id}: ${outcome.throttle.reason}`
|
|
7850
|
+
};
|
|
7851
|
+
}
|
|
7852
|
+
return {
|
|
7853
|
+
kind: "created",
|
|
7854
|
+
value: {
|
|
7855
|
+
deduped: false,
|
|
7856
|
+
idempotencyKey,
|
|
7857
|
+
event,
|
|
7858
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
7859
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
7860
|
+
workflow: publicWorkflow(outcome.workflow),
|
|
7861
|
+
loop: publicLoop(outcome.loop),
|
|
7862
|
+
throttle: outcome.throttle,
|
|
7863
|
+
sandboxPreflight,
|
|
7864
|
+
preflight
|
|
7865
|
+
},
|
|
7866
|
+
human: `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`
|
|
7867
|
+
};
|
|
7868
|
+
} finally {
|
|
7869
|
+
store.close();
|
|
7870
|
+
}
|
|
7871
|
+
}
|
|
7872
|
+
function taskField(task, keys) {
|
|
7873
|
+
for (const key of keys) {
|
|
7874
|
+
const value = stringField(task[key]);
|
|
7875
|
+
if (value)
|
|
7876
|
+
return value;
|
|
7877
|
+
}
|
|
7878
|
+
return;
|
|
7879
|
+
}
|
|
7880
|
+
function taskListId(task) {
|
|
7881
|
+
return taskField(task, ["task_list_id", "taskListId"]) ?? stringField(task.task_list?.id);
|
|
7882
|
+
}
|
|
6886
7883
|
function taskProjectId(task) {
|
|
6887
7884
|
return taskField(task, ["project_id", "projectId"]);
|
|
6888
7885
|
}
|
|
@@ -7119,9 +8116,155 @@ addGoalOptions(addMachineOptions(addScheduleOptions(create.command("workflow <na
|
|
|
7119
8116
|
});
|
|
7120
8117
|
var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
|
|
7121
8118
|
var templates = program.command("templates").alias("template").description("render and store reusable loop/workflow templates");
|
|
8119
|
+
var routes = program.command("routes").alias("route").description("inspect workflow invocation/admission routes");
|
|
7122
8120
|
var events = program.command("events").description("handle Hasna event envelopes from stdin or command transport");
|
|
7123
8121
|
var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
|
|
7124
8122
|
var goal = program.command("goal").description("inspect goal runs");
|
|
8123
|
+
function addRouteEventOptions(command) {
|
|
8124
|
+
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");
|
|
8125
|
+
}
|
|
8126
|
+
function addTodosDrainOptions(command) {
|
|
8127
|
+
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");
|
|
8128
|
+
}
|
|
8129
|
+
function routeEventByKind(kind, event, opts) {
|
|
8130
|
+
if (kind === "todos-task")
|
|
8131
|
+
return routeTodosTaskEvent(event, opts);
|
|
8132
|
+
if (kind === "generic")
|
|
8133
|
+
return routeGenericEvent(event, opts);
|
|
8134
|
+
throw new Error("route kind must be todos-task or generic");
|
|
8135
|
+
}
|
|
8136
|
+
function routeDrainArgs(opts) {
|
|
8137
|
+
const args = ["events", "drain", "todos-task"];
|
|
8138
|
+
const add = (flag, value) => {
|
|
8139
|
+
if (value !== undefined && value !== false && value !== "")
|
|
8140
|
+
args.push(flag, String(value));
|
|
8141
|
+
};
|
|
8142
|
+
const addBool = (flag, value) => {
|
|
8143
|
+
if (value === true)
|
|
8144
|
+
args.push(flag);
|
|
8145
|
+
};
|
|
8146
|
+
add("--todos-project", opts.todosProject);
|
|
8147
|
+
add("--todos-project-id", opts.todosProjectId);
|
|
8148
|
+
add("--task-list", opts.taskList);
|
|
8149
|
+
add("--project-path-prefix", opts.projectPathPrefix);
|
|
8150
|
+
add("--tags", opts.tags ?? opts.tag);
|
|
8151
|
+
add("--limit", opts.limit);
|
|
8152
|
+
add("--scan-limit", opts.scanLimit);
|
|
8153
|
+
add("--max-dispatch", opts.maxDispatch);
|
|
8154
|
+
add("--evidence-dir", opts.evidenceDir);
|
|
8155
|
+
addBool("--compact", opts.compact);
|
|
8156
|
+
add("--provider", opts.provider);
|
|
8157
|
+
add("--auth-profile", opts.authProfile);
|
|
8158
|
+
add("--auth-profile-pool", opts.authProfilePool);
|
|
8159
|
+
add("--worker-auth-profile", opts.workerAuthProfile);
|
|
8160
|
+
add("--verifier-auth-profile", opts.verifierAuthProfile);
|
|
8161
|
+
add("--account", opts.account);
|
|
8162
|
+
add("--account-pool", opts.accountPool);
|
|
8163
|
+
add("--worker-account", opts.workerAccount);
|
|
8164
|
+
add("--verifier-account", opts.verifierAccount);
|
|
8165
|
+
add("--account-tool", opts.accountTool);
|
|
8166
|
+
add("--model", opts.model);
|
|
8167
|
+
add("--variant", opts.variant);
|
|
8168
|
+
add("--agent", opts.agent);
|
|
8169
|
+
add("--permission-mode", opts.permissionMode);
|
|
8170
|
+
add("--sandbox", opts.sandbox);
|
|
8171
|
+
addBool("--manual-break-glass", opts.manualBreakGlass);
|
|
8172
|
+
add("--project-path", opts.projectPath);
|
|
8173
|
+
add("--project-group", opts.projectGroup);
|
|
8174
|
+
add("--max-active", opts.maxActive);
|
|
8175
|
+
add("--max-active-per-project", opts.maxActivePerProject);
|
|
8176
|
+
add("--max-active-per-project-group", opts.maxActivePerProjectGroup);
|
|
8177
|
+
add("--worktree-mode", opts.worktreeMode);
|
|
8178
|
+
add("--worktree-root", opts.worktreeRoot);
|
|
8179
|
+
add("--worktree-branch-prefix", opts.worktreeBranchPrefix);
|
|
8180
|
+
add("--name-prefix", opts.namePrefix);
|
|
8181
|
+
addBool("--preflight", opts.preflight);
|
|
8182
|
+
return args;
|
|
8183
|
+
}
|
|
8184
|
+
function drainTodosTaskRoutes(opts) {
|
|
8185
|
+
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
8186
|
+
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
8187
|
+
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
8188
|
+
const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
|
|
8189
|
+
const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
|
|
8190
|
+
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
|
|
8191
|
+
const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
|
|
8192
|
+
const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
|
|
8193
|
+
const ready = loadReadyTodosTasks(opts, scanLimit);
|
|
8194
|
+
const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
|
|
8195
|
+
projectId: opts.todosProjectId,
|
|
8196
|
+
taskListId: taskListFilter,
|
|
8197
|
+
projectPathPrefix: opts.projectPathPrefix,
|
|
8198
|
+
tags: requiredTags
|
|
8199
|
+
}));
|
|
8200
|
+
const candidates = filteredCandidates.slice(0, candidateLimit);
|
|
8201
|
+
const results = [];
|
|
8202
|
+
let created = 0;
|
|
8203
|
+
for (const task of candidates) {
|
|
8204
|
+
if (created >= maxDispatch)
|
|
8205
|
+
break;
|
|
8206
|
+
const event = taskDrainEvent(task);
|
|
8207
|
+
const result = routeTodosTaskEvent(event, opts);
|
|
8208
|
+
results.push(result);
|
|
8209
|
+
if (result.kind === "created" && !opts.dryRun)
|
|
8210
|
+
created += 1;
|
|
8211
|
+
if (result.kind === "created" && opts.dryRun)
|
|
8212
|
+
created += 1;
|
|
8213
|
+
}
|
|
8214
|
+
const report = {
|
|
8215
|
+
drainedAt: new Date().toISOString(),
|
|
8216
|
+
todosProject,
|
|
8217
|
+
todosProjectId: opts.todosProjectId,
|
|
8218
|
+
taskList: opts.taskList,
|
|
8219
|
+
taskListId: taskListFilter,
|
|
8220
|
+
projectPathPrefix: opts.projectPathPrefix,
|
|
8221
|
+
tags: requiredTags,
|
|
8222
|
+
limit: candidateLimit,
|
|
8223
|
+
scanLimit,
|
|
8224
|
+
filtersApplied: hasPostFilters,
|
|
8225
|
+
scanned: ready.length,
|
|
8226
|
+
candidates: candidates.length,
|
|
8227
|
+
filteredCandidates: filteredCandidates.length,
|
|
8228
|
+
scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
|
|
8229
|
+
considered: results.length,
|
|
8230
|
+
created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
|
|
8231
|
+
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
8232
|
+
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
8233
|
+
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
8234
|
+
maxDispatch,
|
|
8235
|
+
source: "todos ready",
|
|
8236
|
+
dryRun: Boolean(opts.dryRun),
|
|
8237
|
+
results: results.map((result) => ({ kind: result.kind, ...result.value }))
|
|
8238
|
+
};
|
|
8239
|
+
const evidencePath = writeRouteEvidence("todos-task-drain", report, opts.evidenceDir);
|
|
8240
|
+
const output = opts.compact ? {
|
|
8241
|
+
drainedAt: report.drainedAt,
|
|
8242
|
+
todosProject: report.todosProject,
|
|
8243
|
+
todosProjectId: report.todosProjectId,
|
|
8244
|
+
taskList: report.taskList,
|
|
8245
|
+
taskListId: report.taskListId,
|
|
8246
|
+
projectPathPrefix: report.projectPathPrefix,
|
|
8247
|
+
tags: report.tags,
|
|
8248
|
+
limit: report.limit,
|
|
8249
|
+
scanLimit: report.scanLimit,
|
|
8250
|
+
filtersApplied: report.filtersApplied,
|
|
8251
|
+
scanned: report.scanned,
|
|
8252
|
+
candidates: report.candidates,
|
|
8253
|
+
filteredCandidates: report.filteredCandidates,
|
|
8254
|
+
scanExhausted: report.scanExhausted,
|
|
8255
|
+
considered: report.considered,
|
|
8256
|
+
created: report.created,
|
|
8257
|
+
deduped: report.deduped,
|
|
8258
|
+
throttled: report.throttled,
|
|
8259
|
+
skipped: report.skipped,
|
|
8260
|
+
maxDispatch: report.maxDispatch,
|
|
8261
|
+
source: report.source,
|
|
8262
|
+
dryRun: report.dryRun,
|
|
8263
|
+
evidencePath,
|
|
8264
|
+
results: results.map(compactDrainResult)
|
|
8265
|
+
} : { ...report, evidencePath };
|
|
8266
|
+
print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
|
|
8267
|
+
}
|
|
7125
8268
|
templates.command("list").alias("ls").description("list built-in OpenLoops templates").action(() => {
|
|
7126
8269
|
const values = listLoopTemplates();
|
|
7127
8270
|
if (isJson())
|
|
@@ -7152,14 +8295,102 @@ templates.command("create-workflow <id>").description("render and store a templa
|
|
|
7152
8295
|
store.close();
|
|
7153
8296
|
}
|
|
7154
8297
|
});
|
|
8298
|
+
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) => {
|
|
8299
|
+
const store = new Store;
|
|
8300
|
+
try {
|
|
8301
|
+
const items = store.listWorkflowWorkItems({
|
|
8302
|
+
status: opts.status,
|
|
8303
|
+
routeKey: opts.routeKey,
|
|
8304
|
+
limit: positiveInteger(opts.limit, "--limit") ?? 50
|
|
8305
|
+
});
|
|
8306
|
+
if (isJson())
|
|
8307
|
+
print(items.map(publicWorkflowWorkItem));
|
|
8308
|
+
else {
|
|
8309
|
+
for (const item of items) {
|
|
8310
|
+
console.log(`${item.id} ${item.status.padEnd(10)} ${item.routeKey} ${item.subjectRef} ${item.loopId ?? "-"}`);
|
|
8311
|
+
}
|
|
8312
|
+
}
|
|
8313
|
+
} finally {
|
|
8314
|
+
store.close();
|
|
8315
|
+
}
|
|
8316
|
+
});
|
|
8317
|
+
routes.command("show <id>").description("show one admission work item").action((id) => {
|
|
8318
|
+
const store = new Store;
|
|
8319
|
+
try {
|
|
8320
|
+
const item = store.getWorkflowWorkItem(id);
|
|
8321
|
+
if (!item)
|
|
8322
|
+
throw new Error(`route work item not found: ${id}`);
|
|
8323
|
+
const invocation = store.getWorkflowInvocation(item.invocationId);
|
|
8324
|
+
const workflow = item.workflowId ? store.getWorkflow(item.workflowId) : undefined;
|
|
8325
|
+
const loop = item.loopId ? store.getLoop(item.loopId) : undefined;
|
|
8326
|
+
print({
|
|
8327
|
+
item: publicWorkflowWorkItem(item),
|
|
8328
|
+
invocation: invocation ? publicWorkflowInvocation(invocation) : undefined,
|
|
8329
|
+
workflow: workflow ? publicWorkflow(workflow) : undefined,
|
|
8330
|
+
loop: loop ? publicLoop(loop) : undefined
|
|
8331
|
+
}, `${item.id} ${item.status} ${item.routeKey} ${item.subjectRef}`);
|
|
8332
|
+
} finally {
|
|
8333
|
+
store.close();
|
|
8334
|
+
}
|
|
8335
|
+
});
|
|
8336
|
+
routes.command("invocations").description("list workflow invocations").option("--limit <n>", "maximum rows", "50").action((opts) => {
|
|
8337
|
+
const store = new Store;
|
|
8338
|
+
try {
|
|
8339
|
+
const invocations = store.listWorkflowInvocations({ limit: positiveInteger(opts.limit, "--limit") ?? 50 });
|
|
8340
|
+
if (isJson())
|
|
8341
|
+
print(invocations.map(publicWorkflowInvocation));
|
|
8342
|
+
else {
|
|
8343
|
+
for (const invocation of invocations) {
|
|
8344
|
+
console.log(`${invocation.id} ${invocation.intent.padEnd(8)} ${invocation.sourceRef.kind}:${invocation.sourceRef.id ?? "-"} -> ${invocation.subjectRef.kind}:${invocation.subjectRef.id ?? invocation.subjectRef.path ?? "-"}`);
|
|
8345
|
+
}
|
|
8346
|
+
}
|
|
8347
|
+
} finally {
|
|
8348
|
+
store.close();
|
|
8349
|
+
}
|
|
8350
|
+
});
|
|
8351
|
+
addRouteEventOptions(routes.command("preview <kind>").description("preview a route-created workflow invocation without storing it")).action(async (kind, opts) => {
|
|
8352
|
+
const event = await readEventEnvelopeInput(opts);
|
|
8353
|
+
const result = routeEventByKind(kind, event, { ...opts, dryRun: true });
|
|
8354
|
+
print(result.value, result.human);
|
|
8355
|
+
});
|
|
8356
|
+
addRouteEventOptions(routes.command("create <kind>").description("create a route workflow invocation and admit it when capacity allows")).action(async (kind, opts) => {
|
|
8357
|
+
const event = await readEventEnvelopeInput(opts);
|
|
8358
|
+
const result = routeEventByKind(kind, event, { ...opts, dryRun: false });
|
|
8359
|
+
print(result.value, result.human);
|
|
8360
|
+
});
|
|
8361
|
+
addTodosDrainOptions(routes.command("drain <kind>").description("drain a durable source queue into bounded route workflow loops")).action((kind, opts) => {
|
|
8362
|
+
if (kind !== "todos-task")
|
|
8363
|
+
throw new Error("route drain currently supports kind todos-task");
|
|
8364
|
+
drainTodosTaskRoutes(opts);
|
|
8365
|
+
});
|
|
8366
|
+
addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>").description("schedule a deterministic route drain loop"))).action((kind, name, opts) => {
|
|
8367
|
+
if (kind !== "todos-task")
|
|
8368
|
+
throw new Error("route schedule currently supports kind todos-task");
|
|
8369
|
+
const store = new Store;
|
|
8370
|
+
try {
|
|
8371
|
+
const target = {
|
|
8372
|
+
type: "command",
|
|
8373
|
+
command: "loops",
|
|
8374
|
+
args: ["--json", ...routeDrainArgs({ ...opts, compact: opts.compact ?? true })],
|
|
8375
|
+
timeoutMs: parseDuration("20m"),
|
|
8376
|
+
preflight: runtimePreflightFromOpts(opts)
|
|
8377
|
+
};
|
|
8378
|
+
const input = baseCreateInput(name, opts, target);
|
|
8379
|
+
const preflight = opts.preflight ? preflightLoopTarget(input.target, { name, type: "route-drain", kind }, { loopName: name }, { machine: input.machine }) : undefined;
|
|
8380
|
+
const loop = store.createLoop(input);
|
|
8381
|
+
printCreatedLoop(loop, `created route drain loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`, preflight);
|
|
8382
|
+
} finally {
|
|
8383
|
+
store.close();
|
|
8384
|
+
}
|
|
8385
|
+
});
|
|
7155
8386
|
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) => {
|
|
8387
|
+
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
8388
|
const event = await readEventEnvelopeFromStdin();
|
|
7158
8389
|
const result = routeTodosTaskEvent(event, opts);
|
|
7159
8390
|
print(result.value, result.human);
|
|
7160
8391
|
});
|
|
7161
8392
|
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) => {
|
|
8393
|
+
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
8394
|
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
7164
8395
|
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
7165
8396
|
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
@@ -7243,7 +8474,7 @@ eventsDrain.command("todos-task").description("drain ready todos tasks into boun
|
|
|
7243
8474
|
} : { ...report, evidencePath };
|
|
7244
8475
|
print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
|
|
7245
8476
|
});
|
|
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) => {
|
|
8477
|
+
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
8478
|
const event = await readEventEnvelopeFromStdin();
|
|
7248
8479
|
const data = eventData(event);
|
|
7249
8480
|
const metadata = eventMetadata(event);
|
|
@@ -7256,19 +8487,7 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
7256
8487
|
const type = slugSegment2(event.type, "type");
|
|
7257
8488
|
const workflowName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
|
|
7258
8489
|
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
|
-
}
|
|
8490
|
+
const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
|
|
7272
8491
|
const provider = opts.provider;
|
|
7273
8492
|
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
|
|
7274
8493
|
throw new Error("unsupported provider");
|
|
@@ -7299,17 +8518,60 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
7299
8518
|
agent: opts.agent,
|
|
7300
8519
|
permissionMode,
|
|
7301
8520
|
sandbox,
|
|
8521
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
7302
8522
|
worktreeMode: opts.worktreeMode,
|
|
7303
8523
|
worktreeRoot: opts.worktreeRoot,
|
|
7304
8524
|
worktreeBranchPrefix: opts.worktreeBranchPrefix
|
|
7305
8525
|
});
|
|
7306
8526
|
workflowBody.name = workflowName;
|
|
7307
8527
|
workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
|
|
8528
|
+
const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
|
|
8529
|
+
const invocationInput = {
|
|
8530
|
+
templateId: "event-worker-verifier",
|
|
8531
|
+
sourceRef: {
|
|
8532
|
+
kind: "event",
|
|
8533
|
+
id: event.id,
|
|
8534
|
+
dedupeKey: idempotencyKey,
|
|
8535
|
+
raw: { source: event.source, type: event.type }
|
|
8536
|
+
},
|
|
8537
|
+
subjectRef: {
|
|
8538
|
+
kind: "event",
|
|
8539
|
+
id: stringField(event.subject) ?? event.id,
|
|
8540
|
+
path: routeProjectPath,
|
|
8541
|
+
raw: { message: stringField(event.message) }
|
|
8542
|
+
},
|
|
8543
|
+
intent: "route",
|
|
8544
|
+
scope: {
|
|
8545
|
+
projectPath: routeProjectPath,
|
|
8546
|
+
projectGroup,
|
|
8547
|
+
worktreePolicy: opts.worktreeMode ?? "auto",
|
|
8548
|
+
permissions: permissionMode,
|
|
8549
|
+
manualBreakGlass: Boolean(opts.manualBreakGlass),
|
|
8550
|
+
accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
|
|
8551
|
+
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
8552
|
+
},
|
|
8553
|
+
outputPolicy: {
|
|
8554
|
+
report: "always",
|
|
8555
|
+
createTask: "on_failure"
|
|
8556
|
+
}
|
|
8557
|
+
};
|
|
8558
|
+
const workItemInput = {
|
|
8559
|
+
routeKey: "generic-event",
|
|
8560
|
+
idempotencyKey,
|
|
8561
|
+
invocationId: "<created-invocation-id>",
|
|
8562
|
+
sourceType: event.type,
|
|
8563
|
+
sourceRef: event.id,
|
|
8564
|
+
subjectRef: stringField(event.subject) ?? event.id,
|
|
8565
|
+
projectKey: routeProjectPath,
|
|
8566
|
+
projectGroup,
|
|
8567
|
+
priority: 0,
|
|
8568
|
+
status: "queued"
|
|
8569
|
+
};
|
|
7308
8570
|
const loopInput = {
|
|
7309
8571
|
name: loopName,
|
|
7310
|
-
description: `Run ${workflowBody.name} once for event ${event.id}`,
|
|
8572
|
+
description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
|
|
7311
8573
|
schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
|
|
7312
|
-
target: { type: "workflow", workflowId: "<created-workflow-id>" },
|
|
8574
|
+
target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
|
|
7313
8575
|
overlap: "skip",
|
|
7314
8576
|
maxAttempts: 1,
|
|
7315
8577
|
retryDelayMs: 60000,
|
|
@@ -7322,44 +8584,92 @@ eventsHandle.command("generic").description("create a one-shot worker/verifier w
|
|
|
7322
8584
|
type: "generic-event-workflow",
|
|
7323
8585
|
event: event.id
|
|
7324
8586
|
}, {}) : undefined;
|
|
7325
|
-
print({ event, workflow: workflowBody, loop: loopInput, throttle, preflight }, `dry-run ${loopName}`);
|
|
8587
|
+
print({ event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight }, `dry-run ${loopName}`);
|
|
7326
8588
|
return;
|
|
7327
8589
|
}
|
|
7328
8590
|
const store = new Store;
|
|
7329
8591
|
try {
|
|
7330
8592
|
const existingWorkflowForPreflight = store.findWorkflowByName(workflowBody.name);
|
|
7331
8593
|
const workflowPreflightSpec = existingWorkflowForPreflight ?? workflowSpecForPreflight(workflowBody, "event-preflight");
|
|
8594
|
+
generatedRouteSandboxPreflight(workflowPreflightSpec);
|
|
7332
8595
|
const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
|
|
7333
8596
|
name: workflowBody.name,
|
|
7334
8597
|
type: "generic-event-workflow",
|
|
7335
8598
|
event: event.id
|
|
7336
8599
|
}, {}) : undefined;
|
|
7337
8600
|
const outcome = store.writeTransaction(() => {
|
|
7338
|
-
const
|
|
7339
|
-
|
|
7340
|
-
|
|
7341
|
-
|
|
8601
|
+
const invocation = store.createWorkflowInvocation(invocationInput);
|
|
8602
|
+
const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
|
|
8603
|
+
if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
|
|
8604
|
+
const existingLoop = store.getLoop(existingItem.loopId);
|
|
8605
|
+
const existingWorkflow2 = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
8606
|
+
return { kind: "deduped", existingItem, existingLoop, existingWorkflow: existingWorkflow2, invocation };
|
|
7342
8607
|
}
|
|
7343
8608
|
const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
|
|
8609
|
+
const workItem = store.upsertWorkflowWorkItem({
|
|
8610
|
+
...workItemInput,
|
|
8611
|
+
invocationId: invocation.id,
|
|
8612
|
+
status: throttle && !throttle.allowed ? "deferred" : "queued",
|
|
8613
|
+
lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
|
|
8614
|
+
});
|
|
7344
8615
|
if (throttle && !throttle.allowed)
|
|
7345
|
-
return { kind: "throttled", throttle };
|
|
8616
|
+
return { kind: "throttled", invocation, workItem, throttle };
|
|
7346
8617
|
const existingWorkflow = store.findWorkflowByName(workflowBody.name);
|
|
7347
8618
|
const workflow = existingWorkflow ?? store.createWorkflow(workflowBody);
|
|
7348
8619
|
const loop = store.createLoop({
|
|
7349
8620
|
...loopInput,
|
|
7350
|
-
target: {
|
|
8621
|
+
target: {
|
|
8622
|
+
type: "workflow",
|
|
8623
|
+
workflowId: workflow.id,
|
|
8624
|
+
input: {
|
|
8625
|
+
workflowInvocationId: invocation.id,
|
|
8626
|
+
workflowWorkItemId: workItem.id
|
|
8627
|
+
}
|
|
8628
|
+
}
|
|
7351
8629
|
});
|
|
7352
|
-
|
|
8630
|
+
const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
|
|
8631
|
+
return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
|
|
7353
8632
|
});
|
|
7354
8633
|
if (outcome.kind === "deduped") {
|
|
7355
|
-
print({
|
|
8634
|
+
print({
|
|
8635
|
+
deduped: true,
|
|
8636
|
+
idempotencyKey,
|
|
8637
|
+
dedupedBy: "work-item",
|
|
8638
|
+
event,
|
|
8639
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8640
|
+
workItem: publicWorkflowWorkItem(outcome.existingItem),
|
|
8641
|
+
workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
|
|
8642
|
+
loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
|
|
8643
|
+
}, `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`);
|
|
7356
8644
|
return;
|
|
7357
8645
|
}
|
|
7358
8646
|
if (outcome.kind === "throttled") {
|
|
7359
|
-
print({
|
|
8647
|
+
print({
|
|
8648
|
+
skipped: true,
|
|
8649
|
+
queuedAtSource: true,
|
|
8650
|
+
reason: outcome.throttle.reason,
|
|
8651
|
+
idempotencyKey,
|
|
8652
|
+
event,
|
|
8653
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8654
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
8655
|
+
throttle: outcome.throttle,
|
|
8656
|
+
workflow: workflowBody,
|
|
8657
|
+
loop: loopInput
|
|
8658
|
+
}, `skipped event ${event.id}: ${outcome.throttle.reason}`);
|
|
7360
8659
|
return;
|
|
7361
8660
|
}
|
|
7362
|
-
print({
|
|
8661
|
+
print({
|
|
8662
|
+
deduped: false,
|
|
8663
|
+
idempotencyKey,
|
|
8664
|
+
event,
|
|
8665
|
+
invocation: publicWorkflowInvocation(outcome.invocation),
|
|
8666
|
+
workItem: publicWorkflowWorkItem(outcome.workItem),
|
|
8667
|
+
workflow: publicWorkflow(outcome.workflow),
|
|
8668
|
+
loop: publicLoop(outcome.loop),
|
|
8669
|
+
throttle: outcome.throttle,
|
|
8670
|
+
sandboxPreflight,
|
|
8671
|
+
preflight
|
|
8672
|
+
}, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
|
|
7363
8673
|
} finally {
|
|
7364
8674
|
store.close();
|
|
7365
8675
|
}
|