@hasna/loops 0.3.38 → 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 +57 -20
- package/package.json +1 -1
package/dist/sdk/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/lib/store.ts
|
|
3
3
|
import { Database } from "bun:sqlite";
|
|
4
|
-
import { mkdirSync as
|
|
5
|
-
import {
|
|
4
|
+
import { mkdirSync as mkdirSync3, mkdtempSync, rmSync } from "fs";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { dirname, join as join3 } from "path";
|
|
6
7
|
|
|
7
8
|
// src/lib/ids.ts
|
|
8
9
|
import { randomBytes } from "crypto";
|
|
@@ -363,6 +364,8 @@ function validateTarget(value, label) {
|
|
|
363
364
|
assertObject(value, label);
|
|
364
365
|
if (value.type === "command") {
|
|
365
366
|
assertString(value.command, `${label}.command`);
|
|
367
|
+
optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
|
|
368
|
+
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
366
369
|
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
367
370
|
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
368
371
|
}
|
|
@@ -374,6 +377,8 @@ function validateTarget(value, label) {
|
|
|
374
377
|
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
375
378
|
if (!providers.includes(value.provider))
|
|
376
379
|
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
380
|
+
optionalPositiveInteger(value.timeoutMs, `${label}.timeoutMs`);
|
|
381
|
+
optionalPositiveInteger(value.idleTimeoutMs, `${label}.idleTimeoutMs`);
|
|
377
382
|
if (value.authProfile !== undefined) {
|
|
378
383
|
assertString(value.authProfile, `${label}.authProfile`);
|
|
379
384
|
if (value.provider !== "codewith")
|
|
@@ -528,6 +533,52 @@ function workflowBodyFromJson(value, fallbackName) {
|
|
|
528
533
|
});
|
|
529
534
|
}
|
|
530
535
|
|
|
536
|
+
// src/lib/run-artifacts.ts
|
|
537
|
+
import { createHash } from "crypto";
|
|
538
|
+
import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
|
|
539
|
+
import { basename, join as join2 } from "path";
|
|
540
|
+
function shortHash(value) {
|
|
541
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
542
|
+
}
|
|
543
|
+
function safeRunPathSlug(value, fallback) {
|
|
544
|
+
const raw = value?.trim() || fallback;
|
|
545
|
+
const slug = raw.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 72);
|
|
546
|
+
return slug || fallback;
|
|
547
|
+
}
|
|
548
|
+
function workflowRunSubjectKey(kind, rawSubjectRef) {
|
|
549
|
+
const raw = rawSubjectRef?.trim() || "subject";
|
|
550
|
+
const kindSlug = safeRunPathSlug(kind, "subject").slice(0, 24);
|
|
551
|
+
const subjectSlug = safeRunPathSlug(raw, "subject").slice(0, 48);
|
|
552
|
+
return `${kindSlug}-${subjectSlug}-${shortHash(`${kindSlug}
|
|
553
|
+
${raw}`)}`;
|
|
554
|
+
}
|
|
555
|
+
function workflowRunProjectSlug(projectKey) {
|
|
556
|
+
if (!projectKey?.trim())
|
|
557
|
+
return "global";
|
|
558
|
+
return safeRunPathSlug(projectKey.startsWith("/") ? basename(projectKey) : projectKey, "project");
|
|
559
|
+
}
|
|
560
|
+
function writeWorkflowRunManifest(args) {
|
|
561
|
+
const projectSlug = workflowRunProjectSlug(args.projectKey);
|
|
562
|
+
const subjectKey = workflowRunSubjectKey(args.subjectKind, args.rawSubjectRef);
|
|
563
|
+
const dir = join2(args.loopsDataDir, "runs", projectSlug, subjectKey, args.workflowRunId);
|
|
564
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
565
|
+
const manifestPath = join2(dir, "manifest.json");
|
|
566
|
+
writeFileSync(manifestPath, JSON.stringify({
|
|
567
|
+
version: 1,
|
|
568
|
+
workflowRunId: args.workflowRunId,
|
|
569
|
+
workflowId: args.workflowId,
|
|
570
|
+
workflowName: args.workflowName,
|
|
571
|
+
invocationId: args.invocationId,
|
|
572
|
+
workItemId: args.workItemId,
|
|
573
|
+
projectSlug,
|
|
574
|
+
subjectKey,
|
|
575
|
+
requiredReading: [],
|
|
576
|
+
createdAt: new Date().toISOString(),
|
|
577
|
+
...args.payload
|
|
578
|
+
}, null, 2), { mode: 384 });
|
|
579
|
+
return manifestPath;
|
|
580
|
+
}
|
|
581
|
+
|
|
531
582
|
// src/lib/store.ts
|
|
532
583
|
function rowToLoop(row) {
|
|
533
584
|
return {
|
|
@@ -597,8 +648,11 @@ function rowToWorkflowRun(row) {
|
|
|
597
648
|
workflowName: row.workflow_name,
|
|
598
649
|
loopId: row.loop_id ?? undefined,
|
|
599
650
|
loopRunId: row.loop_run_id ?? undefined,
|
|
651
|
+
invocationId: row.invocation_id ?? undefined,
|
|
652
|
+
workItemId: row.work_item_id ?? undefined,
|
|
600
653
|
scheduledFor: row.scheduled_for ?? undefined,
|
|
601
654
|
idempotencyKey: row.idempotency_key ?? undefined,
|
|
655
|
+
manifestPath: row.manifest_path ?? undefined,
|
|
602
656
|
status: row.status,
|
|
603
657
|
startedAt: row.started_at ?? undefined,
|
|
604
658
|
finishedAt: row.finished_at ?? undefined,
|
|
@@ -609,6 +663,44 @@ function rowToWorkflowRun(row) {
|
|
|
609
663
|
updatedAt: row.updated_at
|
|
610
664
|
};
|
|
611
665
|
}
|
|
666
|
+
function rowToWorkflowInvocation(row) {
|
|
667
|
+
return {
|
|
668
|
+
id: row.id,
|
|
669
|
+
workflowId: row.workflow_id ?? undefined,
|
|
670
|
+
templateId: row.template_id ?? undefined,
|
|
671
|
+
sourceRef: JSON.parse(row.source_json),
|
|
672
|
+
subjectRef: JSON.parse(row.subject_json),
|
|
673
|
+
intent: row.intent,
|
|
674
|
+
scope: row.scope_json ? JSON.parse(row.scope_json) : undefined,
|
|
675
|
+
outputPolicy: row.output_policy_json ? JSON.parse(row.output_policy_json) : undefined,
|
|
676
|
+
createdAt: row.created_at,
|
|
677
|
+
updatedAt: row.updated_at
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
function rowToWorkflowWorkItem(row) {
|
|
681
|
+
return {
|
|
682
|
+
id: row.id,
|
|
683
|
+
routeKey: row.route_key,
|
|
684
|
+
idempotencyKey: row.idempotency_key,
|
|
685
|
+
invocationId: row.invocation_id,
|
|
686
|
+
sourceType: row.source_type,
|
|
687
|
+
sourceRef: row.source_ref,
|
|
688
|
+
subjectRef: row.subject_ref,
|
|
689
|
+
projectKey: row.project_key ?? undefined,
|
|
690
|
+
projectGroup: row.project_group ?? undefined,
|
|
691
|
+
priority: row.priority,
|
|
692
|
+
status: row.status,
|
|
693
|
+
attempts: row.attempts,
|
|
694
|
+
nextAttemptAt: row.next_attempt_at ?? undefined,
|
|
695
|
+
leaseExpiresAt: row.lease_expires_at ?? undefined,
|
|
696
|
+
workflowId: row.workflow_id ?? undefined,
|
|
697
|
+
loopId: row.loop_id ?? undefined,
|
|
698
|
+
workflowRunId: row.workflow_run_id ?? undefined,
|
|
699
|
+
lastReason: row.last_reason ?? undefined,
|
|
700
|
+
createdAt: row.created_at,
|
|
701
|
+
updatedAt: row.updated_at
|
|
702
|
+
};
|
|
703
|
+
}
|
|
612
704
|
function rowToWorkflowStepRun(row) {
|
|
613
705
|
return {
|
|
614
706
|
id: row.id,
|
|
@@ -722,13 +814,23 @@ function rowToLease(row) {
|
|
|
722
814
|
updatedAt: row.updated_at
|
|
723
815
|
};
|
|
724
816
|
}
|
|
817
|
+
function workItemStatusForLoopRun(status, attempt, maxAttempts) {
|
|
818
|
+
if (status === "succeeded")
|
|
819
|
+
return "succeeded";
|
|
820
|
+
if (["failed", "timed_out", "abandoned"].includes(status)) {
|
|
821
|
+
return maxAttempts !== undefined && attempt < maxAttempts ? "admitted" : "failed";
|
|
822
|
+
}
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
725
825
|
|
|
726
826
|
class Store {
|
|
727
827
|
db;
|
|
828
|
+
rootDir;
|
|
728
829
|
constructor(path) {
|
|
729
830
|
const file = path ?? dbPath();
|
|
730
831
|
if (file !== ":memory:")
|
|
731
|
-
|
|
832
|
+
mkdirSync3(dirname(file), { recursive: true, mode: 448 });
|
|
833
|
+
this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
|
|
732
834
|
this.db = new Database(file);
|
|
733
835
|
this.db.exec("PRAGMA busy_timeout = 5000;");
|
|
734
836
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
@@ -823,8 +925,11 @@ class Store {
|
|
|
823
925
|
workflow_name TEXT NOT NULL,
|
|
824
926
|
loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
|
|
825
927
|
loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
|
|
928
|
+
invocation_id TEXT,
|
|
929
|
+
work_item_id TEXT,
|
|
826
930
|
scheduled_for TEXT,
|
|
827
931
|
idempotency_key TEXT,
|
|
932
|
+
manifest_path TEXT,
|
|
828
933
|
status TEXT NOT NULL,
|
|
829
934
|
started_at TEXT,
|
|
830
935
|
finished_at TEXT,
|
|
@@ -839,8 +944,63 @@ class Store {
|
|
|
839
944
|
WHERE idempotency_key IS NOT NULL;
|
|
840
945
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
|
|
841
946
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
|
|
947
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
|
|
948
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
|
|
842
949
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
|
843
950
|
|
|
951
|
+
CREATE TABLE IF NOT EXISTS workflow_invocations (
|
|
952
|
+
id TEXT PRIMARY KEY,
|
|
953
|
+
workflow_id TEXT,
|
|
954
|
+
template_id TEXT,
|
|
955
|
+
source_kind TEXT NOT NULL,
|
|
956
|
+
source_id TEXT,
|
|
957
|
+
source_dedupe_key TEXT,
|
|
958
|
+
source_json TEXT NOT NULL,
|
|
959
|
+
subject_kind TEXT NOT NULL,
|
|
960
|
+
subject_id TEXT,
|
|
961
|
+
subject_path TEXT,
|
|
962
|
+
subject_url TEXT,
|
|
963
|
+
subject_json TEXT NOT NULL,
|
|
964
|
+
intent TEXT NOT NULL,
|
|
965
|
+
scope_json TEXT,
|
|
966
|
+
output_policy_json TEXT,
|
|
967
|
+
created_at TEXT NOT NULL,
|
|
968
|
+
updated_at TEXT NOT NULL
|
|
969
|
+
);
|
|
970
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
|
|
971
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
|
|
972
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe
|
|
973
|
+
ON workflow_invocations(source_kind, source_dedupe_key)
|
|
974
|
+
WHERE source_dedupe_key IS NOT NULL;
|
|
975
|
+
|
|
976
|
+
CREATE TABLE IF NOT EXISTS workflow_work_items (
|
|
977
|
+
id TEXT PRIMARY KEY,
|
|
978
|
+
route_key TEXT NOT NULL,
|
|
979
|
+
idempotency_key TEXT NOT NULL,
|
|
980
|
+
invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
|
|
981
|
+
source_type TEXT NOT NULL,
|
|
982
|
+
source_ref TEXT NOT NULL,
|
|
983
|
+
subject_ref TEXT NOT NULL,
|
|
984
|
+
project_key TEXT,
|
|
985
|
+
project_group TEXT,
|
|
986
|
+
priority INTEGER NOT NULL,
|
|
987
|
+
status TEXT NOT NULL,
|
|
988
|
+
attempts INTEGER NOT NULL,
|
|
989
|
+
next_attempt_at TEXT,
|
|
990
|
+
lease_expires_at TEXT,
|
|
991
|
+
workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
|
|
992
|
+
loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
|
|
993
|
+
workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
|
|
994
|
+
last_reason TEXT,
|
|
995
|
+
created_at TEXT NOT NULL,
|
|
996
|
+
updated_at TEXT NOT NULL,
|
|
997
|
+
UNIQUE(route_key, idempotency_key)
|
|
998
|
+
);
|
|
999
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
|
|
1000
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
|
|
1001
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
|
|
1002
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
|
|
1003
|
+
|
|
844
1004
|
CREATE TABLE IF NOT EXISTS workflow_step_runs (
|
|
845
1005
|
id TEXT PRIMARY KEY,
|
|
846
1006
|
workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
@@ -953,12 +1113,16 @@ class Store {
|
|
|
953
1113
|
this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
|
|
954
1114
|
this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
|
|
955
1115
|
this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
|
|
1116
|
+
this.addColumnIfMissing("workflow_runs", "invocation_id", "TEXT");
|
|
1117
|
+
this.addColumnIfMissing("workflow_runs", "work_item_id", "TEXT");
|
|
1118
|
+
this.addColumnIfMissing("workflow_runs", "manifest_path", "TEXT");
|
|
956
1119
|
this.addColumnIfMissing("workflow_step_runs", "pid", "INTEGER");
|
|
957
1120
|
this.addColumnIfMissing("workflow_step_runs", "goal_run_id", "TEXT");
|
|
958
1121
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
959
1122
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
|
|
960
1123
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
|
|
961
1124
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
|
|
1125
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0005_workflow_invocations_and_admission", nowIso());
|
|
962
1126
|
}
|
|
963
1127
|
addColumnIfMissing(table, column, definition) {
|
|
964
1128
|
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
@@ -1081,6 +1245,10 @@ class Store {
|
|
|
1081
1245
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1082
1246
|
$now: updated
|
|
1083
1247
|
});
|
|
1248
|
+
if (patch.status && patch.status !== "active") {
|
|
1249
|
+
const status = patch.status === "paused" ? "deferred" : "cancelled";
|
|
1250
|
+
this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
|
|
1251
|
+
}
|
|
1084
1252
|
const after = this.getLoop(id);
|
|
1085
1253
|
if (!after)
|
|
1086
1254
|
throw new Error(`loop not found after update: ${id}`);
|
|
@@ -1125,6 +1293,7 @@ class Store {
|
|
|
1125
1293
|
$archivedFromStatus: loop.status,
|
|
1126
1294
|
$updated: updated
|
|
1127
1295
|
});
|
|
1296
|
+
this.setWorkflowWorkItemsForLoop(loop.id, "deferred", "loop archived", updated);
|
|
1128
1297
|
const archived = this.getLoop(loop.id);
|
|
1129
1298
|
if (!archived)
|
|
1130
1299
|
throw new Error(`loop not found after archive: ${loop.id}`);
|
|
@@ -1150,6 +1319,7 @@ class Store {
|
|
|
1150
1319
|
}
|
|
1151
1320
|
deleteLoop(idOrName) {
|
|
1152
1321
|
const loop = this.requireLoop(idOrName);
|
|
1322
|
+
this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
|
|
1153
1323
|
const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
|
|
1154
1324
|
return res.changes > 0;
|
|
1155
1325
|
}
|
|
@@ -1208,6 +1378,185 @@ class Store {
|
|
|
1208
1378
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1209
1379
|
return archived;
|
|
1210
1380
|
}
|
|
1381
|
+
createWorkflowInvocation(input) {
|
|
1382
|
+
const now = nowIso();
|
|
1383
|
+
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
1384
|
+
if (sourceDedupeKey) {
|
|
1385
|
+
const existing = this.db.query("SELECT * FROM workflow_invocations WHERE source_kind = ? AND source_dedupe_key = ? LIMIT 1").get(input.sourceRef.kind, sourceDedupeKey);
|
|
1386
|
+
if (existing)
|
|
1387
|
+
return rowToWorkflowInvocation(existing);
|
|
1388
|
+
}
|
|
1389
|
+
const id = input.id ?? genId();
|
|
1390
|
+
this.db.query(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
|
|
1391
|
+
source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
|
|
1392
|
+
output_policy_json, created_at, updated_at)
|
|
1393
|
+
VALUES ($id, $workflowId, $templateId, $sourceKind, $sourceId, $sourceDedupeKey, $sourceJson,
|
|
1394
|
+
$subjectKind, $subjectId, $subjectPath, $subjectUrl, $subjectJson, $intent, $scopeJson,
|
|
1395
|
+
$outputPolicyJson, $created, $updated)`).run({
|
|
1396
|
+
$id: id,
|
|
1397
|
+
$workflowId: input.workflowId ?? null,
|
|
1398
|
+
$templateId: input.templateId ?? null,
|
|
1399
|
+
$sourceKind: input.sourceRef.kind,
|
|
1400
|
+
$sourceId: input.sourceRef.id ?? null,
|
|
1401
|
+
$sourceDedupeKey: sourceDedupeKey ?? null,
|
|
1402
|
+
$sourceJson: JSON.stringify(input.sourceRef),
|
|
1403
|
+
$subjectKind: input.subjectRef.kind,
|
|
1404
|
+
$subjectId: input.subjectRef.id ?? null,
|
|
1405
|
+
$subjectPath: input.subjectRef.path ?? null,
|
|
1406
|
+
$subjectUrl: input.subjectRef.url ?? null,
|
|
1407
|
+
$subjectJson: JSON.stringify(input.subjectRef),
|
|
1408
|
+
$intent: input.intent,
|
|
1409
|
+
$scopeJson: input.scope ? JSON.stringify(input.scope) : null,
|
|
1410
|
+
$outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
|
|
1411
|
+
$created: now,
|
|
1412
|
+
$updated: now
|
|
1413
|
+
});
|
|
1414
|
+
const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
|
|
1415
|
+
if (!row)
|
|
1416
|
+
throw new Error(`workflow invocation not found after create: ${id}`);
|
|
1417
|
+
return rowToWorkflowInvocation(row);
|
|
1418
|
+
}
|
|
1419
|
+
getWorkflowInvocation(id) {
|
|
1420
|
+
const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
|
|
1421
|
+
return row ? rowToWorkflowInvocation(row) : undefined;
|
|
1422
|
+
}
|
|
1423
|
+
listWorkflowInvocations(opts = {}) {
|
|
1424
|
+
const rows = this.db.query("SELECT * FROM workflow_invocations ORDER BY created_at DESC LIMIT ?").all(opts.limit ?? 100);
|
|
1425
|
+
return rows.map(rowToWorkflowInvocation);
|
|
1426
|
+
}
|
|
1427
|
+
upsertWorkflowWorkItem(input) {
|
|
1428
|
+
const now = nowIso();
|
|
1429
|
+
const id = genId();
|
|
1430
|
+
const status = input.status ?? "queued";
|
|
1431
|
+
this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
|
|
1432
|
+
subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
1433
|
+
workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
|
|
1434
|
+
VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
|
|
1435
|
+
$projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
1436
|
+
$lastReason, $created, $updated)
|
|
1437
|
+
ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
|
|
1438
|
+
invocation_id=excluded.invocation_id,
|
|
1439
|
+
source_type=excluded.source_type,
|
|
1440
|
+
source_ref=excluded.source_ref,
|
|
1441
|
+
subject_ref=excluded.subject_ref,
|
|
1442
|
+
project_key=excluded.project_key,
|
|
1443
|
+
project_group=excluded.project_group,
|
|
1444
|
+
priority=excluded.priority,
|
|
1445
|
+
status=CASE
|
|
1446
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
|
|
1447
|
+
THEN workflow_work_items.status
|
|
1448
|
+
ELSE excluded.status
|
|
1449
|
+
END,
|
|
1450
|
+
workflow_id=CASE
|
|
1451
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
|
|
1452
|
+
ELSE NULL
|
|
1453
|
+
END,
|
|
1454
|
+
loop_id=CASE
|
|
1455
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
|
|
1456
|
+
ELSE NULL
|
|
1457
|
+
END,
|
|
1458
|
+
workflow_run_id=CASE
|
|
1459
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
|
|
1460
|
+
ELSE NULL
|
|
1461
|
+
END,
|
|
1462
|
+
lease_expires_at=CASE
|
|
1463
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
|
|
1464
|
+
ELSE NULL
|
|
1465
|
+
END,
|
|
1466
|
+
next_attempt_at=excluded.next_attempt_at,
|
|
1467
|
+
last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
|
|
1468
|
+
updated_at=excluded.updated_at`).run({
|
|
1469
|
+
$id: id,
|
|
1470
|
+
$routeKey: input.routeKey,
|
|
1471
|
+
$idempotencyKey: input.idempotencyKey,
|
|
1472
|
+
$invocationId: input.invocationId,
|
|
1473
|
+
$sourceType: input.sourceType,
|
|
1474
|
+
$sourceRef: input.sourceRef,
|
|
1475
|
+
$subjectRef: input.subjectRef,
|
|
1476
|
+
$projectKey: input.projectKey ?? null,
|
|
1477
|
+
$projectGroup: input.projectGroup ?? null,
|
|
1478
|
+
$priority: input.priority ?? 0,
|
|
1479
|
+
$status: status,
|
|
1480
|
+
$nextAttemptAt: input.nextAttemptAt ?? null,
|
|
1481
|
+
$lastReason: input.lastReason ?? null,
|
|
1482
|
+
$created: now,
|
|
1483
|
+
$updated: now
|
|
1484
|
+
});
|
|
1485
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(input.routeKey, input.idempotencyKey);
|
|
1486
|
+
if (!row)
|
|
1487
|
+
throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
|
|
1488
|
+
return rowToWorkflowWorkItem(row);
|
|
1489
|
+
}
|
|
1490
|
+
getWorkflowWorkItem(id) {
|
|
1491
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE id = ?").get(id);
|
|
1492
|
+
return row ? rowToWorkflowWorkItem(row) : undefined;
|
|
1493
|
+
}
|
|
1494
|
+
findWorkflowWorkItem(routeKey, idempotencyKey) {
|
|
1495
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(routeKey, idempotencyKey);
|
|
1496
|
+
return row ? rowToWorkflowWorkItem(row) : undefined;
|
|
1497
|
+
}
|
|
1498
|
+
listWorkflowWorkItems(opts = {}) {
|
|
1499
|
+
const limit = opts.limit ?? 100;
|
|
1500
|
+
let rows;
|
|
1501
|
+
if (opts.status && opts.routeKey) {
|
|
1502
|
+
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);
|
|
1503
|
+
} else if (opts.status) {
|
|
1504
|
+
rows = this.db.query("SELECT * FROM workflow_work_items WHERE status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.status, limit);
|
|
1505
|
+
} else if (opts.routeKey) {
|
|
1506
|
+
rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? ORDER BY created_at DESC LIMIT ?").all(opts.routeKey, limit);
|
|
1507
|
+
} else {
|
|
1508
|
+
rows = this.db.query("SELECT * FROM workflow_work_items ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
1509
|
+
}
|
|
1510
|
+
return rows.map(rowToWorkflowWorkItem);
|
|
1511
|
+
}
|
|
1512
|
+
countActiveWorkflowWorkItems(args = {}) {
|
|
1513
|
+
const active = ["admitted", "running"];
|
|
1514
|
+
const placeholders = active.map(() => "?").join(",");
|
|
1515
|
+
const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
|
|
1516
|
+
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;
|
|
1517
|
+
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;
|
|
1518
|
+
return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
|
|
1519
|
+
}
|
|
1520
|
+
admitWorkflowWorkItem(id, patch) {
|
|
1521
|
+
const now = nowIso();
|
|
1522
|
+
const res = this.db.query(`UPDATE workflow_work_items
|
|
1523
|
+
SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
|
|
1524
|
+
next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
|
|
1525
|
+
WHERE id=$id AND status IN ('queued', 'deferred')`).run({
|
|
1526
|
+
$id: id,
|
|
1527
|
+
$workflowId: patch.workflowId,
|
|
1528
|
+
$loopId: patch.loopId,
|
|
1529
|
+
$reason: patch.reason ?? null,
|
|
1530
|
+
$updated: now
|
|
1531
|
+
});
|
|
1532
|
+
const item = this.getWorkflowWorkItem(id);
|
|
1533
|
+
if (!item)
|
|
1534
|
+
throw new Error(`workflow work item not found after admit: ${id}`);
|
|
1535
|
+
if (res.changes !== 1)
|
|
1536
|
+
throw new Error(`workflow work item is not claimable: ${id} status=${item.status}`);
|
|
1537
|
+
return item;
|
|
1538
|
+
}
|
|
1539
|
+
setWorkflowWorkItemsForLoop(loopId, status, reason, updated, statuses = ["admitted", "running"]) {
|
|
1540
|
+
const placeholders = statuses.map(() => "?").join(",");
|
|
1541
|
+
this.db.query(`UPDATE workflow_work_items
|
|
1542
|
+
SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
|
|
1543
|
+
WHERE loop_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, loopId, ...statuses);
|
|
1544
|
+
}
|
|
1545
|
+
setWorkflowWorkItemsForWorkflowRun(workflowRunId, status, reason, updated, statuses = ["admitted", "running"]) {
|
|
1546
|
+
const placeholders = statuses.map(() => "?").join(",");
|
|
1547
|
+
this.db.query(`UPDATE workflow_work_items
|
|
1548
|
+
SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
|
|
1549
|
+
WHERE workflow_run_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, workflowRunId, ...statuses);
|
|
1550
|
+
}
|
|
1551
|
+
setWorkflowWorkItemsForLoopRun(run, reason, updated) {
|
|
1552
|
+
const loop = this.getLoop(run.loopId);
|
|
1553
|
+
const status = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
1554
|
+
if (!status)
|
|
1555
|
+
return;
|
|
1556
|
+
const statuses = status === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
1557
|
+
const nextReason = status === "admitted" ? reason ? `attempt failed; retry pending: ${reason}` : "attempt failed; retry pending" : reason;
|
|
1558
|
+
this.setWorkflowWorkItemsForLoop(run.loopId, status, nextReason, updated, statuses);
|
|
1559
|
+
}
|
|
1211
1560
|
createGoal(input, opts = {}) {
|
|
1212
1561
|
const now = nowIso();
|
|
1213
1562
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -1481,6 +1830,10 @@ class Store {
|
|
|
1481
1830
|
}
|
|
1482
1831
|
createWorkflowRun(input) {
|
|
1483
1832
|
const now = nowIso();
|
|
1833
|
+
const targetInput = input.loop?.target.type === "workflow" ? input.loop.target.input : undefined;
|
|
1834
|
+
const invocationId = input.invocationId ?? targetInput?.workflowInvocationId ?? targetInput?.invocationId;
|
|
1835
|
+
const workItemId = input.workItemId ?? targetInput?.workflowWorkItemId ?? targetInput?.workItemId;
|
|
1836
|
+
let manifestPath;
|
|
1484
1837
|
if (input.idempotencyKey) {
|
|
1485
1838
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
1486
1839
|
if (existing) {
|
|
@@ -1499,21 +1852,59 @@ class Store {
|
|
|
1499
1852
|
}
|
|
1500
1853
|
}
|
|
1501
1854
|
const runId = genId();
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1855
|
+
const workItem = workItemId ? this.getWorkflowWorkItem(workItemId) : undefined;
|
|
1856
|
+
const invocation = invocationId ? this.getWorkflowInvocation(invocationId) : undefined;
|
|
1857
|
+
manifestPath = invocation || workItem ? writeWorkflowRunManifest({
|
|
1858
|
+
loopsDataDir: this.rootDir,
|
|
1859
|
+
workflowRunId: runId,
|
|
1860
|
+
workflowId: input.workflow.id,
|
|
1861
|
+
workflowName: input.workflow.name,
|
|
1862
|
+
invocationId,
|
|
1863
|
+
workItemId,
|
|
1864
|
+
projectKey: workItem?.projectKey ?? invocation?.scope?.projectPath,
|
|
1865
|
+
subjectKind: invocation?.subjectRef.kind,
|
|
1866
|
+
rawSubjectRef: workItem?.subjectRef ?? invocation?.subjectRef.path ?? invocation?.subjectRef.id ?? invocation?.subjectRef.url,
|
|
1867
|
+
payload: {
|
|
1868
|
+
workflowInvocation: invocation,
|
|
1869
|
+
workflowWorkItem: workItem,
|
|
1870
|
+
loopId: input.loop?.id,
|
|
1871
|
+
loopRunId: input.loopRun?.id,
|
|
1872
|
+
scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor
|
|
1873
|
+
}
|
|
1874
|
+
}) : undefined;
|
|
1875
|
+
this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, invocation_id, work_item_id,
|
|
1876
|
+
scheduled_for, idempotency_key, manifest_path, status, started_at, finished_at, duration_ms, error,
|
|
1877
|
+
created_at, updated_at)
|
|
1878
|
+
VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $invocationId, $workItemId, $scheduledFor,
|
|
1879
|
+
$idempotencyKey, $manifestPath, 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
|
|
1506
1880
|
$id: runId,
|
|
1507
1881
|
$workflowId: input.workflow.id,
|
|
1508
1882
|
$workflowName: input.workflow.name,
|
|
1509
1883
|
$loopId: input.loop?.id ?? null,
|
|
1510
1884
|
$loopRunId: input.loopRun?.id ?? null,
|
|
1885
|
+
$invocationId: invocationId ?? null,
|
|
1886
|
+
$workItemId: workItemId ?? null,
|
|
1511
1887
|
$scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
|
|
1512
1888
|
$idempotencyKey: input.idempotencyKey ?? null,
|
|
1889
|
+
$manifestPath: manifestPath ?? null,
|
|
1513
1890
|
$started: now,
|
|
1514
1891
|
$created: now,
|
|
1515
1892
|
$updated: now
|
|
1516
1893
|
});
|
|
1894
|
+
if (workItemId) {
|
|
1895
|
+
const workItemRes = this.db.query(`UPDATE workflow_work_items
|
|
1896
|
+
SET status='running', workflow_run_id=$workflowRunId, lease_expires_at=$leaseExpiresAt, updated_at=$updated
|
|
1897
|
+
WHERE id=$id AND status IN ('admitted', 'queued', 'deferred', 'running')`).run({
|
|
1898
|
+
$id: workItemId,
|
|
1899
|
+
$workflowRunId: runId,
|
|
1900
|
+
$leaseExpiresAt: input.loop ? new Date(Date.now() + input.loop.leaseMs).toISOString() : null,
|
|
1901
|
+
$updated: now
|
|
1902
|
+
});
|
|
1903
|
+
if (workItemRes.changes !== 1) {
|
|
1904
|
+
const current = this.getWorkflowWorkItem(workItemId);
|
|
1905
|
+
throw new Error(`workflow work item is not runnable: ${workItemId}${current ? ` status=${current.status}` : ""}`);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1517
1908
|
input.workflow.steps.forEach((step, sequence) => {
|
|
1518
1909
|
const account = step.account ?? step.target.account;
|
|
1519
1910
|
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
@@ -1539,7 +1930,10 @@ class Store {
|
|
|
1539
1930
|
workflowName: input.workflow.name,
|
|
1540
1931
|
stepCount: input.workflow.steps.length,
|
|
1541
1932
|
loopId: input.loop?.id,
|
|
1542
|
-
loopRunId: input.loopRun?.id
|
|
1933
|
+
loopRunId: input.loopRun?.id,
|
|
1934
|
+
invocationId,
|
|
1935
|
+
workItemId,
|
|
1936
|
+
manifestPath
|
|
1543
1937
|
}),
|
|
1544
1938
|
$created: now
|
|
1545
1939
|
});
|
|
@@ -1552,6 +1946,8 @@ class Store {
|
|
|
1552
1946
|
try {
|
|
1553
1947
|
this.db.exec("ROLLBACK");
|
|
1554
1948
|
} catch {}
|
|
1949
|
+
if (manifestPath)
|
|
1950
|
+
rmSync(manifestPath, { force: true });
|
|
1555
1951
|
throw error;
|
|
1556
1952
|
}
|
|
1557
1953
|
}
|
|
@@ -1764,6 +2160,10 @@ class Store {
|
|
|
1764
2160
|
changed = res.changes === 1;
|
|
1765
2161
|
if (changed)
|
|
1766
2162
|
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
2163
|
+
if (changed) {
|
|
2164
|
+
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2165
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2166
|
+
}
|
|
1767
2167
|
this.db.exec("COMMIT");
|
|
1768
2168
|
} catch (error) {
|
|
1769
2169
|
try {
|
|
@@ -1788,6 +2188,7 @@ class Store {
|
|
|
1788
2188
|
this.db.query(`UPDATE workflow_step_runs
|
|
1789
2189
|
SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
|
|
1790
2190
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2191
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
1791
2192
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
1792
2193
|
}
|
|
1793
2194
|
this.db.exec("COMMIT");
|
|
@@ -2041,6 +2442,8 @@ class Store {
|
|
|
2041
2442
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2042
2443
|
if (opts.claimedBy && res.changes !== 1)
|
|
2043
2444
|
return run;
|
|
2445
|
+
if (res.changes === 1)
|
|
2446
|
+
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2044
2447
|
return run;
|
|
2045
2448
|
}
|
|
2046
2449
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2134,6 +2537,14 @@ class Store {
|
|
|
2134
2537
|
error: "parent loop run lease expired before completion",
|
|
2135
2538
|
loopRunId: row.id
|
|
2136
2539
|
});
|
|
2540
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2541
|
+
}
|
|
2542
|
+
const loop = this.getLoop(row.loop_id);
|
|
2543
|
+
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
2544
|
+
if (itemStatus) {
|
|
2545
|
+
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2546
|
+
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2547
|
+
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2137
2548
|
}
|
|
2138
2549
|
this.db.exec("COMMIT");
|
|
2139
2550
|
} catch (error) {
|
|
@@ -2346,7 +2757,7 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
2346
2757
|
// src/lib/env.ts
|
|
2347
2758
|
import { accessSync, constants } from "fs";
|
|
2348
2759
|
import { homedir as homedir2 } from "os";
|
|
2349
|
-
import { delimiter, join as
|
|
2760
|
+
import { delimiter, join as join4 } from "path";
|
|
2350
2761
|
function compactPathParts(parts) {
|
|
2351
2762
|
const seen = new Set;
|
|
2352
2763
|
const result = [];
|
|
@@ -2362,14 +2773,14 @@ function compactPathParts(parts) {
|
|
|
2362
2773
|
function commonExecutableDirs(env = process.env) {
|
|
2363
2774
|
const home = env.HOME || homedir2();
|
|
2364
2775
|
return compactPathParts([
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
env.BUN_INSTALL ?
|
|
2776
|
+
join4(home, ".local", "bin"),
|
|
2777
|
+
join4(home, ".bun", "bin"),
|
|
2778
|
+
join4(home, ".cargo", "bin"),
|
|
2779
|
+
join4(home, ".npm-global", "bin"),
|
|
2780
|
+
join4(home, "bin"),
|
|
2781
|
+
env.BUN_INSTALL ? join4(env.BUN_INSTALL, "bin") : undefined,
|
|
2371
2782
|
env.PNPM_HOME,
|
|
2372
|
-
env.NPM_CONFIG_PREFIX ?
|
|
2783
|
+
env.NPM_CONFIG_PREFIX ? join4(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
2373
2784
|
"/opt/homebrew/bin",
|
|
2374
2785
|
"/usr/local/bin",
|
|
2375
2786
|
"/usr/bin",
|
|
@@ -2393,7 +2804,7 @@ function executableExists(command, env = process.env) {
|
|
|
2393
2804
|
if (command.includes("/"))
|
|
2394
2805
|
return isExecutable(command);
|
|
2395
2806
|
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
2396
|
-
if (dir && isExecutable(
|
|
2807
|
+
if (dir && isExecutable(join4(dir, command)))
|
|
2397
2808
|
return true;
|
|
2398
2809
|
}
|
|
2399
2810
|
return false;
|
|
@@ -2751,6 +3162,7 @@ function commandSpec(target) {
|
|
|
2751
3162
|
shell: commandTarget.shell,
|
|
2752
3163
|
env: commandTarget.env,
|
|
2753
3164
|
timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3165
|
+
idleTimeoutMs: commandTarget.idleTimeoutMs,
|
|
2754
3166
|
account: commandTarget.account,
|
|
2755
3167
|
accountTool: commandTarget.account?.tool
|
|
2756
3168
|
};
|
|
@@ -2761,6 +3173,7 @@ function commandSpec(target) {
|
|
|
2761
3173
|
args: agentArgs(agentTarget),
|
|
2762
3174
|
cwd: agentTarget.cwd,
|
|
2763
3175
|
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3176
|
+
idleTimeoutMs: agentTarget.idleTimeoutMs,
|
|
2764
3177
|
account: agentTarget.account,
|
|
2765
3178
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
2766
3179
|
nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
|
|
@@ -2908,6 +3321,7 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
2908
3321
|
let stdout = "";
|
|
2909
3322
|
let stderr = "";
|
|
2910
3323
|
let timedOut = false;
|
|
3324
|
+
let idleTimedOut = false;
|
|
2911
3325
|
let exitCode;
|
|
2912
3326
|
let error;
|
|
2913
3327
|
let plan;
|
|
@@ -2946,18 +3360,34 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
2946
3360
|
if (opts.signal?.aborted)
|
|
2947
3361
|
abortHandler();
|
|
2948
3362
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
2949
|
-
child.stdout?.on("data", (chunk) => {
|
|
2950
|
-
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
2951
|
-
});
|
|
2952
|
-
child.stderr?.on("data", (chunk) => {
|
|
2953
|
-
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
2954
|
-
});
|
|
2955
3363
|
const timer = setTimeout(() => {
|
|
2956
3364
|
timedOut = true;
|
|
2957
3365
|
if (child.pid)
|
|
2958
3366
|
killProcessGroup(child.pid);
|
|
2959
3367
|
}, spec.timeoutMs);
|
|
2960
3368
|
timer.unref();
|
|
3369
|
+
let idleTimer;
|
|
3370
|
+
const resetIdleTimer = () => {
|
|
3371
|
+
if (!spec.idleTimeoutMs)
|
|
3372
|
+
return;
|
|
3373
|
+
if (idleTimer)
|
|
3374
|
+
clearTimeout(idleTimer);
|
|
3375
|
+
idleTimer = setTimeout(() => {
|
|
3376
|
+
idleTimedOut = true;
|
|
3377
|
+
if (child.pid)
|
|
3378
|
+
killProcessGroup(child.pid);
|
|
3379
|
+
}, spec.idleTimeoutMs);
|
|
3380
|
+
idleTimer.unref();
|
|
3381
|
+
};
|
|
3382
|
+
resetIdleTimer();
|
|
3383
|
+
child.stdout?.on("data", (chunk) => {
|
|
3384
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3385
|
+
resetIdleTimer();
|
|
3386
|
+
});
|
|
3387
|
+
child.stderr?.on("data", (chunk) => {
|
|
3388
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3389
|
+
resetIdleTimer();
|
|
3390
|
+
});
|
|
2961
3391
|
try {
|
|
2962
3392
|
const [code, signal] = await once(child, "exit");
|
|
2963
3393
|
if (typeof code === "number")
|
|
@@ -2968,17 +3398,19 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
2968
3398
|
error = err instanceof Error ? err.message : String(err);
|
|
2969
3399
|
} finally {
|
|
2970
3400
|
clearTimeout(timer);
|
|
3401
|
+
if (idleTimer)
|
|
3402
|
+
clearTimeout(idleTimer);
|
|
2971
3403
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
2972
3404
|
}
|
|
2973
3405
|
const finishedAt = nowIso();
|
|
2974
3406
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
2975
|
-
if (timedOut) {
|
|
3407
|
+
if (timedOut || idleTimedOut) {
|
|
2976
3408
|
return {
|
|
2977
3409
|
status: "timed_out",
|
|
2978
3410
|
exitCode,
|
|
2979
3411
|
stdout,
|
|
2980
3412
|
stderr,
|
|
2981
|
-
error: `timed out after ${spec.timeoutMs}ms`,
|
|
3413
|
+
error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
|
|
2982
3414
|
pid: child.pid,
|
|
2983
3415
|
startedAt,
|
|
2984
3416
|
finishedAt,
|
|
@@ -3044,6 +3476,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3044
3476
|
let stdout = "";
|
|
3045
3477
|
let stderr = "";
|
|
3046
3478
|
let timedOut = false;
|
|
3479
|
+
let idleTimedOut = false;
|
|
3047
3480
|
let exitCode;
|
|
3048
3481
|
let error;
|
|
3049
3482
|
const env = executionEnv(spec, metadata, opts);
|
|
@@ -3106,18 +3539,34 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3106
3539
|
if (opts.signal?.aborted)
|
|
3107
3540
|
abortHandler();
|
|
3108
3541
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
3109
|
-
child.stdout?.on("data", (chunk) => {
|
|
3110
|
-
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3111
|
-
});
|
|
3112
|
-
child.stderr?.on("data", (chunk) => {
|
|
3113
|
-
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3114
|
-
});
|
|
3115
3542
|
const timer = setTimeout(() => {
|
|
3116
3543
|
timedOut = true;
|
|
3117
3544
|
if (child.pid)
|
|
3118
3545
|
killProcessGroup(child.pid);
|
|
3119
3546
|
}, spec.timeoutMs);
|
|
3120
3547
|
timer.unref();
|
|
3548
|
+
let idleTimer;
|
|
3549
|
+
const resetIdleTimer = () => {
|
|
3550
|
+
if (!spec.idleTimeoutMs)
|
|
3551
|
+
return;
|
|
3552
|
+
if (idleTimer)
|
|
3553
|
+
clearTimeout(idleTimer);
|
|
3554
|
+
idleTimer = setTimeout(() => {
|
|
3555
|
+
idleTimedOut = true;
|
|
3556
|
+
if (child.pid)
|
|
3557
|
+
killProcessGroup(child.pid);
|
|
3558
|
+
}, spec.idleTimeoutMs);
|
|
3559
|
+
idleTimer.unref();
|
|
3560
|
+
};
|
|
3561
|
+
resetIdleTimer();
|
|
3562
|
+
child.stdout?.on("data", (chunk) => {
|
|
3563
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3564
|
+
resetIdleTimer();
|
|
3565
|
+
});
|
|
3566
|
+
child.stderr?.on("data", (chunk) => {
|
|
3567
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3568
|
+
resetIdleTimer();
|
|
3569
|
+
});
|
|
3121
3570
|
try {
|
|
3122
3571
|
const [code, signal] = await once(child, "exit");
|
|
3123
3572
|
if (typeof code === "number")
|
|
@@ -3128,17 +3577,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3128
3577
|
error = err instanceof Error ? err.message : String(err);
|
|
3129
3578
|
} finally {
|
|
3130
3579
|
clearTimeout(timer);
|
|
3580
|
+
if (idleTimer)
|
|
3581
|
+
clearTimeout(idleTimer);
|
|
3131
3582
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
3132
3583
|
}
|
|
3133
3584
|
const finishedAt = nowIso();
|
|
3134
3585
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
3135
|
-
if (timedOut) {
|
|
3586
|
+
if (timedOut || idleTimedOut) {
|
|
3136
3587
|
return {
|
|
3137
3588
|
status: "timed_out",
|
|
3138
3589
|
exitCode,
|
|
3139
3590
|
stdout,
|
|
3140
3591
|
stderr,
|
|
3141
|
-
error: `timed out after ${spec.timeoutMs}ms`,
|
|
3592
|
+
error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
|
|
3142
3593
|
pid: child.pid,
|
|
3143
3594
|
startedAt,
|
|
3144
3595
|
finishedAt,
|
|
@@ -4270,8 +4721,48 @@ class LoopsClient {
|
|
|
4270
4721
|
function loops(opts = {}) {
|
|
4271
4722
|
return new LoopsClient(opts);
|
|
4272
4723
|
}
|
|
4724
|
+
function openAutomationsRuntimeBinding(overrides = {}) {
|
|
4725
|
+
const defaults = {
|
|
4726
|
+
integration: "open-automations",
|
|
4727
|
+
role: "runtime",
|
|
4728
|
+
handoff: "claim-queue",
|
|
4729
|
+
queueOwner: "open-automations",
|
|
4730
|
+
runtimeOwner: "open-loops",
|
|
4731
|
+
statusCommand: "automations status",
|
|
4732
|
+
claimCommand: "automations queue claim",
|
|
4733
|
+
completeCommand: "automations queue complete",
|
|
4734
|
+
failCommand: "automations queue fail",
|
|
4735
|
+
eventHandoff: {
|
|
4736
|
+
envelopeCommand: "automations webhooks event",
|
|
4737
|
+
handlerCommand: "loops events handle generic",
|
|
4738
|
+
pipeExample: "automations --json webhooks event <route> --body-json '<json>' | loops --json events handle generic",
|
|
4739
|
+
boundary: "Use only for explicit event-envelope workflow handoff. OpenAutomations still owns deterministic automation materialization and queue state; OpenLoops owns workflow invocation."
|
|
4740
|
+
},
|
|
4741
|
+
requiredEnvironment: ["HASNA_AUTOMATIONS_DIR"],
|
|
4742
|
+
guarantees: [
|
|
4743
|
+
"OpenAutomations owns automation specs, run materialization, queue state, DLQ, replay, idempotency, and approvals.",
|
|
4744
|
+
"OpenLoops may execute claimed actions through explicit command or SDK handoff only.",
|
|
4745
|
+
"OpenLoops may consume exported event envelopes only through explicit events handle commands.",
|
|
4746
|
+
"Workers must complete or fail actions by action id and runner id so OpenAutomations can enforce queue leases."
|
|
4747
|
+
],
|
|
4748
|
+
nonGoals: [
|
|
4749
|
+
"OpenLoops must not become the OpenAutomations product surface.",
|
|
4750
|
+
"OpenLoops must not store automation specs or replace the OpenAutomations queue.",
|
|
4751
|
+
"OpenLoops must not infer automation trigger semantics from event transport alone."
|
|
4752
|
+
]
|
|
4753
|
+
};
|
|
4754
|
+
return {
|
|
4755
|
+
...defaults,
|
|
4756
|
+
...overrides,
|
|
4757
|
+
eventHandoff: overrides.eventHandoff ?? defaults.eventHandoff,
|
|
4758
|
+
requiredEnvironment: overrides.requiredEnvironment ?? defaults.requiredEnvironment,
|
|
4759
|
+
guarantees: overrides.guarantees ?? defaults.guarantees,
|
|
4760
|
+
nonGoals: overrides.nonGoals ?? defaults.nonGoals
|
|
4761
|
+
};
|
|
4762
|
+
}
|
|
4273
4763
|
export {
|
|
4274
4764
|
runGoal,
|
|
4765
|
+
openAutomationsRuntimeBinding,
|
|
4275
4766
|
loops,
|
|
4276
4767
|
LoopsClient
|
|
4277
4768
|
};
|