@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/daemon/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) {
|
|
@@ -2356,7 +2767,7 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
2356
2767
|
// src/lib/env.ts
|
|
2357
2768
|
import { accessSync, constants } from "fs";
|
|
2358
2769
|
import { homedir as homedir2 } from "os";
|
|
2359
|
-
import { delimiter, join as
|
|
2770
|
+
import { delimiter, join as join4 } from "path";
|
|
2360
2771
|
function compactPathParts(parts) {
|
|
2361
2772
|
const seen = new Set;
|
|
2362
2773
|
const result = [];
|
|
@@ -2372,14 +2783,14 @@ function compactPathParts(parts) {
|
|
|
2372
2783
|
function commonExecutableDirs(env = process.env) {
|
|
2373
2784
|
const home = env.HOME || homedir2();
|
|
2374
2785
|
return compactPathParts([
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
env.BUN_INSTALL ?
|
|
2786
|
+
join4(home, ".local", "bin"),
|
|
2787
|
+
join4(home, ".bun", "bin"),
|
|
2788
|
+
join4(home, ".cargo", "bin"),
|
|
2789
|
+
join4(home, ".npm-global", "bin"),
|
|
2790
|
+
join4(home, "bin"),
|
|
2791
|
+
env.BUN_INSTALL ? join4(env.BUN_INSTALL, "bin") : undefined,
|
|
2381
2792
|
env.PNPM_HOME,
|
|
2382
|
-
env.NPM_CONFIG_PREFIX ?
|
|
2793
|
+
env.NPM_CONFIG_PREFIX ? join4(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
2383
2794
|
"/opt/homebrew/bin",
|
|
2384
2795
|
"/usr/local/bin",
|
|
2385
2796
|
"/usr/bin",
|
|
@@ -2403,7 +2814,7 @@ function executableExists(command, env = process.env) {
|
|
|
2403
2814
|
if (command.includes("/"))
|
|
2404
2815
|
return isExecutable(command);
|
|
2405
2816
|
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
2406
|
-
if (dir && isExecutable(
|
|
2817
|
+
if (dir && isExecutable(join4(dir, command)))
|
|
2407
2818
|
return true;
|
|
2408
2819
|
}
|
|
2409
2820
|
return false;
|
|
@@ -2761,6 +3172,7 @@ function commandSpec(target) {
|
|
|
2761
3172
|
shell: commandTarget.shell,
|
|
2762
3173
|
env: commandTarget.env,
|
|
2763
3174
|
timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3175
|
+
idleTimeoutMs: commandTarget.idleTimeoutMs,
|
|
2764
3176
|
account: commandTarget.account,
|
|
2765
3177
|
accountTool: commandTarget.account?.tool
|
|
2766
3178
|
};
|
|
@@ -2771,6 +3183,7 @@ function commandSpec(target) {
|
|
|
2771
3183
|
args: agentArgs(agentTarget),
|
|
2772
3184
|
cwd: agentTarget.cwd,
|
|
2773
3185
|
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3186
|
+
idleTimeoutMs: agentTarget.idleTimeoutMs,
|
|
2774
3187
|
account: agentTarget.account,
|
|
2775
3188
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
2776
3189
|
nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
|
|
@@ -2918,6 +3331,7 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
2918
3331
|
let stdout = "";
|
|
2919
3332
|
let stderr = "";
|
|
2920
3333
|
let timedOut = false;
|
|
3334
|
+
let idleTimedOut = false;
|
|
2921
3335
|
let exitCode;
|
|
2922
3336
|
let error;
|
|
2923
3337
|
let plan;
|
|
@@ -2956,18 +3370,34 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
2956
3370
|
if (opts.signal?.aborted)
|
|
2957
3371
|
abortHandler();
|
|
2958
3372
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
2959
|
-
child.stdout?.on("data", (chunk) => {
|
|
2960
|
-
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
2961
|
-
});
|
|
2962
|
-
child.stderr?.on("data", (chunk) => {
|
|
2963
|
-
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
2964
|
-
});
|
|
2965
3373
|
const timer = setTimeout(() => {
|
|
2966
3374
|
timedOut = true;
|
|
2967
3375
|
if (child.pid)
|
|
2968
3376
|
killProcessGroup(child.pid);
|
|
2969
3377
|
}, spec.timeoutMs);
|
|
2970
3378
|
timer.unref();
|
|
3379
|
+
let idleTimer;
|
|
3380
|
+
const resetIdleTimer = () => {
|
|
3381
|
+
if (!spec.idleTimeoutMs)
|
|
3382
|
+
return;
|
|
3383
|
+
if (idleTimer)
|
|
3384
|
+
clearTimeout(idleTimer);
|
|
3385
|
+
idleTimer = setTimeout(() => {
|
|
3386
|
+
idleTimedOut = true;
|
|
3387
|
+
if (child.pid)
|
|
3388
|
+
killProcessGroup(child.pid);
|
|
3389
|
+
}, spec.idleTimeoutMs);
|
|
3390
|
+
idleTimer.unref();
|
|
3391
|
+
};
|
|
3392
|
+
resetIdleTimer();
|
|
3393
|
+
child.stdout?.on("data", (chunk) => {
|
|
3394
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3395
|
+
resetIdleTimer();
|
|
3396
|
+
});
|
|
3397
|
+
child.stderr?.on("data", (chunk) => {
|
|
3398
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3399
|
+
resetIdleTimer();
|
|
3400
|
+
});
|
|
2971
3401
|
try {
|
|
2972
3402
|
const [code, signal] = await once(child, "exit");
|
|
2973
3403
|
if (typeof code === "number")
|
|
@@ -2978,17 +3408,19 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
2978
3408
|
error = err instanceof Error ? err.message : String(err);
|
|
2979
3409
|
} finally {
|
|
2980
3410
|
clearTimeout(timer);
|
|
3411
|
+
if (idleTimer)
|
|
3412
|
+
clearTimeout(idleTimer);
|
|
2981
3413
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
2982
3414
|
}
|
|
2983
3415
|
const finishedAt = nowIso();
|
|
2984
3416
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
2985
|
-
if (timedOut) {
|
|
3417
|
+
if (timedOut || idleTimedOut) {
|
|
2986
3418
|
return {
|
|
2987
3419
|
status: "timed_out",
|
|
2988
3420
|
exitCode,
|
|
2989
3421
|
stdout,
|
|
2990
3422
|
stderr,
|
|
2991
|
-
error: `timed out after ${spec.timeoutMs}ms`,
|
|
3423
|
+
error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
|
|
2992
3424
|
pid: child.pid,
|
|
2993
3425
|
startedAt,
|
|
2994
3426
|
finishedAt,
|
|
@@ -3054,6 +3486,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3054
3486
|
let stdout = "";
|
|
3055
3487
|
let stderr = "";
|
|
3056
3488
|
let timedOut = false;
|
|
3489
|
+
let idleTimedOut = false;
|
|
3057
3490
|
let exitCode;
|
|
3058
3491
|
let error;
|
|
3059
3492
|
const env = executionEnv(spec, metadata, opts);
|
|
@@ -3116,18 +3549,34 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3116
3549
|
if (opts.signal?.aborted)
|
|
3117
3550
|
abortHandler();
|
|
3118
3551
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
3119
|
-
child.stdout?.on("data", (chunk) => {
|
|
3120
|
-
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3121
|
-
});
|
|
3122
|
-
child.stderr?.on("data", (chunk) => {
|
|
3123
|
-
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3124
|
-
});
|
|
3125
3552
|
const timer = setTimeout(() => {
|
|
3126
3553
|
timedOut = true;
|
|
3127
3554
|
if (child.pid)
|
|
3128
3555
|
killProcessGroup(child.pid);
|
|
3129
3556
|
}, spec.timeoutMs);
|
|
3130
3557
|
timer.unref();
|
|
3558
|
+
let idleTimer;
|
|
3559
|
+
const resetIdleTimer = () => {
|
|
3560
|
+
if (!spec.idleTimeoutMs)
|
|
3561
|
+
return;
|
|
3562
|
+
if (idleTimer)
|
|
3563
|
+
clearTimeout(idleTimer);
|
|
3564
|
+
idleTimer = setTimeout(() => {
|
|
3565
|
+
idleTimedOut = true;
|
|
3566
|
+
if (child.pid)
|
|
3567
|
+
killProcessGroup(child.pid);
|
|
3568
|
+
}, spec.idleTimeoutMs);
|
|
3569
|
+
idleTimer.unref();
|
|
3570
|
+
};
|
|
3571
|
+
resetIdleTimer();
|
|
3572
|
+
child.stdout?.on("data", (chunk) => {
|
|
3573
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3574
|
+
resetIdleTimer();
|
|
3575
|
+
});
|
|
3576
|
+
child.stderr?.on("data", (chunk) => {
|
|
3577
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3578
|
+
resetIdleTimer();
|
|
3579
|
+
});
|
|
3131
3580
|
try {
|
|
3132
3581
|
const [code, signal] = await once(child, "exit");
|
|
3133
3582
|
if (typeof code === "number")
|
|
@@ -3138,17 +3587,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3138
3587
|
error = err instanceof Error ? err.message : String(err);
|
|
3139
3588
|
} finally {
|
|
3140
3589
|
clearTimeout(timer);
|
|
3590
|
+
if (idleTimer)
|
|
3591
|
+
clearTimeout(idleTimer);
|
|
3141
3592
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
3142
3593
|
}
|
|
3143
3594
|
const finishedAt = nowIso();
|
|
3144
3595
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
3145
|
-
if (timedOut) {
|
|
3596
|
+
if (timedOut || idleTimedOut) {
|
|
3146
3597
|
return {
|
|
3147
3598
|
status: "timed_out",
|
|
3148
3599
|
exitCode,
|
|
3149
3600
|
stdout,
|
|
3150
3601
|
stderr,
|
|
3151
|
-
error: `timed out after ${spec.timeoutMs}ms`,
|
|
3602
|
+
error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
|
|
3152
3603
|
pid: child.pid,
|
|
3153
3604
|
startedAt,
|
|
3154
3605
|
finishedAt,
|
|
@@ -4190,7 +4641,7 @@ async function tick(deps) {
|
|
|
4190
4641
|
}
|
|
4191
4642
|
|
|
4192
4643
|
// src/daemon/control.ts
|
|
4193
|
-
import { existsSync as existsSync2, mkdirSync as
|
|
4644
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4194
4645
|
import { hostname } from "os";
|
|
4195
4646
|
import { dirname as dirname2 } from "path";
|
|
4196
4647
|
|
|
@@ -4228,11 +4679,11 @@ function readPid(path = pidFilePath()) {
|
|
|
4228
4679
|
}
|
|
4229
4680
|
}
|
|
4230
4681
|
function writePid(pid = process.pid, path = pidFilePath()) {
|
|
4231
|
-
|
|
4232
|
-
|
|
4682
|
+
mkdirSync4(dirname2(path), { recursive: true, mode: 448 });
|
|
4683
|
+
writeFileSync2(path, String(pid));
|
|
4233
4684
|
}
|
|
4234
4685
|
function removePid(path = pidFilePath()) {
|
|
4235
|
-
|
|
4686
|
+
rmSync2(path, { force: true });
|
|
4236
4687
|
}
|
|
4237
4688
|
function isAlive(pid) {
|
|
4238
4689
|
try {
|
|
@@ -4488,7 +4939,7 @@ async function startDaemon(opts) {
|
|
|
4488
4939
|
}
|
|
4489
4940
|
|
|
4490
4941
|
// src/daemon/install.ts
|
|
4491
|
-
import { chmodSync, mkdirSync as
|
|
4942
|
+
import { chmodSync, mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
4492
4943
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
4493
4944
|
import { dirname as dirname3 } from "path";
|
|
4494
4945
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
@@ -4496,8 +4947,8 @@ function installStartup(cliEntry, execPath = process.execPath, args = ["daemon",
|
|
|
4496
4947
|
const pathEnv = normalizeExecutionPath(process.env);
|
|
4497
4948
|
if (process.platform === "linux") {
|
|
4498
4949
|
const path = systemdServicePath();
|
|
4499
|
-
|
|
4500
|
-
|
|
4950
|
+
mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
|
|
4951
|
+
writeFileSync3(path, `[Unit]
|
|
4501
4952
|
Description=Hasna OpenLoops daemon
|
|
4502
4953
|
After=default.target
|
|
4503
4954
|
|
|
@@ -4523,8 +4974,8 @@ WantedBy=default.target
|
|
|
4523
4974
|
}
|
|
4524
4975
|
if (process.platform === "darwin") {
|
|
4525
4976
|
const path = launchdPlistPath();
|
|
4526
|
-
|
|
4527
|
-
|
|
4977
|
+
mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
|
|
4978
|
+
writeFileSync3(path, `<?xml version="1.0" encoding="UTF-8"?>
|
|
4528
4979
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4529
4980
|
<plist version="1.0">
|
|
4530
4981
|
<dict>
|
|
@@ -4574,7 +5025,7 @@ function enableStartup(result) {
|
|
|
4574
5025
|
// package.json
|
|
4575
5026
|
var package_default = {
|
|
4576
5027
|
name: "@hasna/loops",
|
|
4577
|
-
version: "0.3.
|
|
5028
|
+
version: "0.3.39",
|
|
4578
5029
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4579
5030
|
type: "module",
|
|
4580
5031
|
main: "dist/index.js",
|