@hasna/loops 0.3.38 → 0.3.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -14
- package/dist/cli/index.js +1491 -176
- package/dist/daemon/index.js +499 -43
- package/dist/index.d.ts +1 -1
- package/dist/index.js +823 -53
- package/dist/lib/format.d.ts +3 -1
- package/dist/lib/run-artifacts.d.ts +15 -0
- package/dist/lib/store.d.ts +34 -1
- package/dist/lib/store.js +424 -8
- package/dist/lib/templates.d.ts +10 -0
- package/dist/sdk/index.d.ts +2 -1
- package/dist/sdk/index.js +529 -33
- package/dist/types.d.ts +109 -0
- package/docs/USAGE.md +57 -20
- package/package.json +1 -1
package/dist/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,
|
|
@@ -843,6 +948,59 @@ class Store {
|
|
|
843
948
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
|
|
844
949
|
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
|
845
950
|
|
|
951
|
+
CREATE TABLE IF NOT EXISTS workflow_invocations (
|
|
952
|
+
id TEXT PRIMARY KEY,
|
|
953
|
+
workflow_id TEXT,
|
|
954
|
+
template_id TEXT,
|
|
955
|
+
source_kind TEXT NOT NULL,
|
|
956
|
+
source_id TEXT,
|
|
957
|
+
source_dedupe_key TEXT,
|
|
958
|
+
source_json TEXT NOT NULL,
|
|
959
|
+
subject_kind TEXT NOT NULL,
|
|
960
|
+
subject_id TEXT,
|
|
961
|
+
subject_path TEXT,
|
|
962
|
+
subject_url TEXT,
|
|
963
|
+
subject_json TEXT NOT NULL,
|
|
964
|
+
intent TEXT NOT NULL,
|
|
965
|
+
scope_json TEXT,
|
|
966
|
+
output_policy_json TEXT,
|
|
967
|
+
created_at TEXT NOT NULL,
|
|
968
|
+
updated_at TEXT NOT NULL
|
|
969
|
+
);
|
|
970
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_invocations_source ON workflow_invocations(source_kind, source_id);
|
|
971
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_invocations_subject ON workflow_invocations(subject_kind, subject_id, subject_path);
|
|
972
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_invocations_dedupe
|
|
973
|
+
ON workflow_invocations(source_kind, source_dedupe_key)
|
|
974
|
+
WHERE source_dedupe_key IS NOT NULL;
|
|
975
|
+
|
|
976
|
+
CREATE TABLE IF NOT EXISTS workflow_work_items (
|
|
977
|
+
id TEXT PRIMARY KEY,
|
|
978
|
+
route_key TEXT NOT NULL,
|
|
979
|
+
idempotency_key TEXT NOT NULL,
|
|
980
|
+
invocation_id TEXT NOT NULL REFERENCES workflow_invocations(id) ON DELETE CASCADE,
|
|
981
|
+
source_type TEXT NOT NULL,
|
|
982
|
+
source_ref TEXT NOT NULL,
|
|
983
|
+
subject_ref TEXT NOT NULL,
|
|
984
|
+
project_key TEXT,
|
|
985
|
+
project_group TEXT,
|
|
986
|
+
priority INTEGER NOT NULL,
|
|
987
|
+
status TEXT NOT NULL,
|
|
988
|
+
attempts INTEGER NOT NULL,
|
|
989
|
+
next_attempt_at TEXT,
|
|
990
|
+
lease_expires_at TEXT,
|
|
991
|
+
workflow_id TEXT REFERENCES workflow_specs(id) ON DELETE SET NULL,
|
|
992
|
+
loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
|
|
993
|
+
workflow_run_id TEXT REFERENCES workflow_runs(id) ON DELETE SET NULL,
|
|
994
|
+
last_reason TEXT,
|
|
995
|
+
created_at TEXT NOT NULL,
|
|
996
|
+
updated_at TEXT NOT NULL,
|
|
997
|
+
UNIQUE(route_key, idempotency_key)
|
|
998
|
+
);
|
|
999
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_status_next ON workflow_work_items(status, next_attempt_at, priority DESC, created_at ASC);
|
|
1000
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_project ON workflow_work_items(project_key, status);
|
|
1001
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_group ON workflow_work_items(project_group, status);
|
|
1002
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_work_items_invocation ON workflow_work_items(invocation_id);
|
|
1003
|
+
|
|
846
1004
|
CREATE TABLE IF NOT EXISTS workflow_step_runs (
|
|
847
1005
|
id TEXT PRIMARY KEY,
|
|
848
1006
|
workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
@@ -955,12 +1113,17 @@ class Store {
|
|
|
955
1113
|
this.addColumnIfMissing("loop_runs", "goal_run_id", "TEXT");
|
|
956
1114
|
this.addColumnIfMissing("workflow_specs", "goal_json", "TEXT");
|
|
957
1115
|
this.addColumnIfMissing("workflow_runs", "goal_run_id", "TEXT");
|
|
1116
|
+
this.addColumnIfMissing("workflow_runs", "invocation_id", "TEXT");
|
|
1117
|
+
this.addColumnIfMissing("workflow_runs", "work_item_id", "TEXT");
|
|
1118
|
+
this.addColumnIfMissing("workflow_runs", "manifest_path", "TEXT");
|
|
958
1119
|
this.addColumnIfMissing("workflow_step_runs", "pid", "INTEGER");
|
|
959
1120
|
this.addColumnIfMissing("workflow_step_runs", "goal_run_id", "TEXT");
|
|
1121
|
+
this.createWorkflowRunBackfillIndexes();
|
|
960
1122
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
961
1123
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
|
|
962
1124
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0003_goals", nowIso());
|
|
963
1125
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0004_loop_archive_metadata", nowIso());
|
|
1126
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0005_workflow_invocations_and_admission", nowIso());
|
|
964
1127
|
}
|
|
965
1128
|
addColumnIfMissing(table, column, definition) {
|
|
966
1129
|
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
@@ -968,6 +1131,12 @@ class Store {
|
|
|
968
1131
|
return;
|
|
969
1132
|
this.db.query(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
|
|
970
1133
|
}
|
|
1134
|
+
createWorkflowRunBackfillIndexes() {
|
|
1135
|
+
this.db.exec(`
|
|
1136
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_invocation ON workflow_runs(invocation_id);
|
|
1137
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_work_item ON workflow_runs(work_item_id);
|
|
1138
|
+
`);
|
|
1139
|
+
}
|
|
971
1140
|
assertDaemonLeaseFence(opts = {}, now = nowIso()) {
|
|
972
1141
|
if (!opts.daemonLeaseId)
|
|
973
1142
|
return;
|
|
@@ -1083,6 +1252,10 @@ class Store {
|
|
|
1083
1252
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1084
1253
|
$now: updated
|
|
1085
1254
|
});
|
|
1255
|
+
if (patch.status && patch.status !== "active") {
|
|
1256
|
+
const status = patch.status === "paused" ? "deferred" : "cancelled";
|
|
1257
|
+
this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
|
|
1258
|
+
}
|
|
1086
1259
|
const after = this.getLoop(id);
|
|
1087
1260
|
if (!after)
|
|
1088
1261
|
throw new Error(`loop not found after update: ${id}`);
|
|
@@ -1127,6 +1300,7 @@ class Store {
|
|
|
1127
1300
|
$archivedFromStatus: loop.status,
|
|
1128
1301
|
$updated: updated
|
|
1129
1302
|
});
|
|
1303
|
+
this.setWorkflowWorkItemsForLoop(loop.id, "deferred", "loop archived", updated);
|
|
1130
1304
|
const archived = this.getLoop(loop.id);
|
|
1131
1305
|
if (!archived)
|
|
1132
1306
|
throw new Error(`loop not found after archive: ${loop.id}`);
|
|
@@ -1152,6 +1326,7 @@ class Store {
|
|
|
1152
1326
|
}
|
|
1153
1327
|
deleteLoop(idOrName) {
|
|
1154
1328
|
const loop = this.requireLoop(idOrName);
|
|
1329
|
+
this.setWorkflowWorkItemsForLoop(loop.id, "cancelled", "loop deleted", nowIso());
|
|
1155
1330
|
const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
|
|
1156
1331
|
return res.changes > 0;
|
|
1157
1332
|
}
|
|
@@ -1210,6 +1385,185 @@ class Store {
|
|
|
1210
1385
|
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
1211
1386
|
return archived;
|
|
1212
1387
|
}
|
|
1388
|
+
createWorkflowInvocation(input) {
|
|
1389
|
+
const now = nowIso();
|
|
1390
|
+
const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
|
|
1391
|
+
if (sourceDedupeKey) {
|
|
1392
|
+
const existing = this.db.query("SELECT * FROM workflow_invocations WHERE source_kind = ? AND source_dedupe_key = ? LIMIT 1").get(input.sourceRef.kind, sourceDedupeKey);
|
|
1393
|
+
if (existing)
|
|
1394
|
+
return rowToWorkflowInvocation(existing);
|
|
1395
|
+
}
|
|
1396
|
+
const id = input.id ?? genId();
|
|
1397
|
+
this.db.query(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
|
|
1398
|
+
source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
|
|
1399
|
+
output_policy_json, created_at, updated_at)
|
|
1400
|
+
VALUES ($id, $workflowId, $templateId, $sourceKind, $sourceId, $sourceDedupeKey, $sourceJson,
|
|
1401
|
+
$subjectKind, $subjectId, $subjectPath, $subjectUrl, $subjectJson, $intent, $scopeJson,
|
|
1402
|
+
$outputPolicyJson, $created, $updated)`).run({
|
|
1403
|
+
$id: id,
|
|
1404
|
+
$workflowId: input.workflowId ?? null,
|
|
1405
|
+
$templateId: input.templateId ?? null,
|
|
1406
|
+
$sourceKind: input.sourceRef.kind,
|
|
1407
|
+
$sourceId: input.sourceRef.id ?? null,
|
|
1408
|
+
$sourceDedupeKey: sourceDedupeKey ?? null,
|
|
1409
|
+
$sourceJson: JSON.stringify(input.sourceRef),
|
|
1410
|
+
$subjectKind: input.subjectRef.kind,
|
|
1411
|
+
$subjectId: input.subjectRef.id ?? null,
|
|
1412
|
+
$subjectPath: input.subjectRef.path ?? null,
|
|
1413
|
+
$subjectUrl: input.subjectRef.url ?? null,
|
|
1414
|
+
$subjectJson: JSON.stringify(input.subjectRef),
|
|
1415
|
+
$intent: input.intent,
|
|
1416
|
+
$scopeJson: input.scope ? JSON.stringify(input.scope) : null,
|
|
1417
|
+
$outputPolicyJson: input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
|
|
1418
|
+
$created: now,
|
|
1419
|
+
$updated: now
|
|
1420
|
+
});
|
|
1421
|
+
const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
|
|
1422
|
+
if (!row)
|
|
1423
|
+
throw new Error(`workflow invocation not found after create: ${id}`);
|
|
1424
|
+
return rowToWorkflowInvocation(row);
|
|
1425
|
+
}
|
|
1426
|
+
getWorkflowInvocation(id) {
|
|
1427
|
+
const row = this.db.query("SELECT * FROM workflow_invocations WHERE id = ?").get(id);
|
|
1428
|
+
return row ? rowToWorkflowInvocation(row) : undefined;
|
|
1429
|
+
}
|
|
1430
|
+
listWorkflowInvocations(opts = {}) {
|
|
1431
|
+
const rows = this.db.query("SELECT * FROM workflow_invocations ORDER BY created_at DESC LIMIT ?").all(opts.limit ?? 100);
|
|
1432
|
+
return rows.map(rowToWorkflowInvocation);
|
|
1433
|
+
}
|
|
1434
|
+
upsertWorkflowWorkItem(input) {
|
|
1435
|
+
const now = nowIso();
|
|
1436
|
+
const id = genId();
|
|
1437
|
+
const status = input.status ?? "queued";
|
|
1438
|
+
this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
|
|
1439
|
+
subject_ref, project_key, project_group, priority, status, attempts, next_attempt_at, lease_expires_at,
|
|
1440
|
+
workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
|
|
1441
|
+
VALUES ($id, $routeKey, $idempotencyKey, $invocationId, $sourceType, $sourceRef, $subjectRef,
|
|
1442
|
+
$projectKey, $projectGroup, $priority, $status, 0, $nextAttemptAt, NULL, NULL, NULL, NULL,
|
|
1443
|
+
$lastReason, $created, $updated)
|
|
1444
|
+
ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
|
|
1445
|
+
invocation_id=excluded.invocation_id,
|
|
1446
|
+
source_type=excluded.source_type,
|
|
1447
|
+
source_ref=excluded.source_ref,
|
|
1448
|
+
subject_ref=excluded.subject_ref,
|
|
1449
|
+
project_key=excluded.project_key,
|
|
1450
|
+
project_group=excluded.project_group,
|
|
1451
|
+
priority=excluded.priority,
|
|
1452
|
+
status=CASE
|
|
1453
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running')
|
|
1454
|
+
THEN workflow_work_items.status
|
|
1455
|
+
ELSE excluded.status
|
|
1456
|
+
END,
|
|
1457
|
+
workflow_id=CASE
|
|
1458
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_id
|
|
1459
|
+
ELSE NULL
|
|
1460
|
+
END,
|
|
1461
|
+
loop_id=CASE
|
|
1462
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.loop_id
|
|
1463
|
+
ELSE NULL
|
|
1464
|
+
END,
|
|
1465
|
+
workflow_run_id=CASE
|
|
1466
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.workflow_run_id
|
|
1467
|
+
ELSE NULL
|
|
1468
|
+
END,
|
|
1469
|
+
lease_expires_at=CASE
|
|
1470
|
+
WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running') THEN workflow_work_items.lease_expires_at
|
|
1471
|
+
ELSE NULL
|
|
1472
|
+
END,
|
|
1473
|
+
next_attempt_at=excluded.next_attempt_at,
|
|
1474
|
+
last_reason=COALESCE(excluded.last_reason, workflow_work_items.last_reason),
|
|
1475
|
+
updated_at=excluded.updated_at`).run({
|
|
1476
|
+
$id: id,
|
|
1477
|
+
$routeKey: input.routeKey,
|
|
1478
|
+
$idempotencyKey: input.idempotencyKey,
|
|
1479
|
+
$invocationId: input.invocationId,
|
|
1480
|
+
$sourceType: input.sourceType,
|
|
1481
|
+
$sourceRef: input.sourceRef,
|
|
1482
|
+
$subjectRef: input.subjectRef,
|
|
1483
|
+
$projectKey: input.projectKey ?? null,
|
|
1484
|
+
$projectGroup: input.projectGroup ?? null,
|
|
1485
|
+
$priority: input.priority ?? 0,
|
|
1486
|
+
$status: status,
|
|
1487
|
+
$nextAttemptAt: input.nextAttemptAt ?? null,
|
|
1488
|
+
$lastReason: input.lastReason ?? null,
|
|
1489
|
+
$created: now,
|
|
1490
|
+
$updated: now
|
|
1491
|
+
});
|
|
1492
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(input.routeKey, input.idempotencyKey);
|
|
1493
|
+
if (!row)
|
|
1494
|
+
throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
|
|
1495
|
+
return rowToWorkflowWorkItem(row);
|
|
1496
|
+
}
|
|
1497
|
+
getWorkflowWorkItem(id) {
|
|
1498
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE id = ?").get(id);
|
|
1499
|
+
return row ? rowToWorkflowWorkItem(row) : undefined;
|
|
1500
|
+
}
|
|
1501
|
+
findWorkflowWorkItem(routeKey, idempotencyKey) {
|
|
1502
|
+
const row = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND idempotency_key = ? LIMIT 1").get(routeKey, idempotencyKey);
|
|
1503
|
+
return row ? rowToWorkflowWorkItem(row) : undefined;
|
|
1504
|
+
}
|
|
1505
|
+
listWorkflowWorkItems(opts = {}) {
|
|
1506
|
+
const limit = opts.limit ?? 100;
|
|
1507
|
+
let rows;
|
|
1508
|
+
if (opts.status && opts.routeKey) {
|
|
1509
|
+
rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? AND status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.routeKey, opts.status, limit);
|
|
1510
|
+
} else if (opts.status) {
|
|
1511
|
+
rows = this.db.query("SELECT * FROM workflow_work_items WHERE status = ? ORDER BY priority DESC, created_at ASC LIMIT ?").all(opts.status, limit);
|
|
1512
|
+
} else if (opts.routeKey) {
|
|
1513
|
+
rows = this.db.query("SELECT * FROM workflow_work_items WHERE route_key = ? ORDER BY created_at DESC LIMIT ?").all(opts.routeKey, limit);
|
|
1514
|
+
} else {
|
|
1515
|
+
rows = this.db.query("SELECT * FROM workflow_work_items ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
1516
|
+
}
|
|
1517
|
+
return rows.map(rowToWorkflowWorkItem);
|
|
1518
|
+
}
|
|
1519
|
+
countActiveWorkflowWorkItems(args = {}) {
|
|
1520
|
+
const active = ["admitted", "running"];
|
|
1521
|
+
const placeholders = active.map(() => "?").join(",");
|
|
1522
|
+
const global = this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders})`).get(...active)?.count ?? 0;
|
|
1523
|
+
const project = args.projectKey ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_key = ?`).get(...active, args.projectKey)?.count ?? 0 : 0;
|
|
1524
|
+
const projectGroup = args.projectGroup ? this.db.query(`SELECT COUNT(*) AS count FROM workflow_work_items WHERE status IN (${placeholders}) AND project_group = ?`).get(...active, args.projectGroup)?.count ?? 0 : undefined;
|
|
1525
|
+
return { global, project, ...projectGroup !== undefined ? { projectGroup } : {} };
|
|
1526
|
+
}
|
|
1527
|
+
admitWorkflowWorkItem(id, patch) {
|
|
1528
|
+
const now = nowIso();
|
|
1529
|
+
const res = this.db.query(`UPDATE workflow_work_items
|
|
1530
|
+
SET status='admitted', attempts=attempts + 1, workflow_id=$workflowId, loop_id=$loopId,
|
|
1531
|
+
next_attempt_at=NULL, lease_expires_at=NULL, last_reason=$reason, updated_at=$updated
|
|
1532
|
+
WHERE id=$id AND status IN ('queued', 'deferred')`).run({
|
|
1533
|
+
$id: id,
|
|
1534
|
+
$workflowId: patch.workflowId,
|
|
1535
|
+
$loopId: patch.loopId,
|
|
1536
|
+
$reason: patch.reason ?? null,
|
|
1537
|
+
$updated: now
|
|
1538
|
+
});
|
|
1539
|
+
const item = this.getWorkflowWorkItem(id);
|
|
1540
|
+
if (!item)
|
|
1541
|
+
throw new Error(`workflow work item not found after admit: ${id}`);
|
|
1542
|
+
if (res.changes !== 1)
|
|
1543
|
+
throw new Error(`workflow work item is not claimable: ${id} status=${item.status}`);
|
|
1544
|
+
return item;
|
|
1545
|
+
}
|
|
1546
|
+
setWorkflowWorkItemsForLoop(loopId, status, reason, updated, statuses = ["admitted", "running"]) {
|
|
1547
|
+
const placeholders = statuses.map(() => "?").join(",");
|
|
1548
|
+
this.db.query(`UPDATE workflow_work_items
|
|
1549
|
+
SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
|
|
1550
|
+
WHERE loop_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, loopId, ...statuses);
|
|
1551
|
+
}
|
|
1552
|
+
setWorkflowWorkItemsForWorkflowRun(workflowRunId, status, reason, updated, statuses = ["admitted", "running"]) {
|
|
1553
|
+
const placeholders = statuses.map(() => "?").join(",");
|
|
1554
|
+
this.db.query(`UPDATE workflow_work_items
|
|
1555
|
+
SET status=?, lease_expires_at=NULL, last_reason=COALESCE(?, last_reason), updated_at=?
|
|
1556
|
+
WHERE workflow_run_id = ? AND status IN (${placeholders})`).run(status, reason ?? null, updated, workflowRunId, ...statuses);
|
|
1557
|
+
}
|
|
1558
|
+
setWorkflowWorkItemsForLoopRun(run, reason, updated) {
|
|
1559
|
+
const loop = this.getLoop(run.loopId);
|
|
1560
|
+
const status = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
|
|
1561
|
+
if (!status)
|
|
1562
|
+
return;
|
|
1563
|
+
const statuses = status === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
1564
|
+
const nextReason = status === "admitted" ? reason ? `attempt failed; retry pending: ${reason}` : "attempt failed; retry pending" : reason;
|
|
1565
|
+
this.setWorkflowWorkItemsForLoop(run.loopId, status, nextReason, updated, statuses);
|
|
1566
|
+
}
|
|
1213
1567
|
createGoal(input, opts = {}) {
|
|
1214
1568
|
const now = nowIso();
|
|
1215
1569
|
this.db.exec("BEGIN IMMEDIATE");
|
|
@@ -1483,6 +1837,10 @@ class Store {
|
|
|
1483
1837
|
}
|
|
1484
1838
|
createWorkflowRun(input) {
|
|
1485
1839
|
const now = nowIso();
|
|
1840
|
+
const targetInput = input.loop?.target.type === "workflow" ? input.loop.target.input : undefined;
|
|
1841
|
+
const invocationId = input.invocationId ?? targetInput?.workflowInvocationId ?? targetInput?.invocationId;
|
|
1842
|
+
const workItemId = input.workItemId ?? targetInput?.workflowWorkItemId ?? targetInput?.workItemId;
|
|
1843
|
+
let manifestPath;
|
|
1486
1844
|
if (input.idempotencyKey) {
|
|
1487
1845
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
1488
1846
|
if (existing) {
|
|
@@ -1501,21 +1859,59 @@ class Store {
|
|
|
1501
1859
|
}
|
|
1502
1860
|
}
|
|
1503
1861
|
const runId = genId();
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1862
|
+
const workItem = workItemId ? this.getWorkflowWorkItem(workItemId) : undefined;
|
|
1863
|
+
const invocation = invocationId ? this.getWorkflowInvocation(invocationId) : undefined;
|
|
1864
|
+
manifestPath = invocation || workItem ? writeWorkflowRunManifest({
|
|
1865
|
+
loopsDataDir: this.rootDir,
|
|
1866
|
+
workflowRunId: runId,
|
|
1867
|
+
workflowId: input.workflow.id,
|
|
1868
|
+
workflowName: input.workflow.name,
|
|
1869
|
+
invocationId,
|
|
1870
|
+
workItemId,
|
|
1871
|
+
projectKey: workItem?.projectKey ?? invocation?.scope?.projectPath,
|
|
1872
|
+
subjectKind: invocation?.subjectRef.kind,
|
|
1873
|
+
rawSubjectRef: workItem?.subjectRef ?? invocation?.subjectRef.path ?? invocation?.subjectRef.id ?? invocation?.subjectRef.url,
|
|
1874
|
+
payload: {
|
|
1875
|
+
workflowInvocation: invocation,
|
|
1876
|
+
workflowWorkItem: workItem,
|
|
1877
|
+
loopId: input.loop?.id,
|
|
1878
|
+
loopRunId: input.loopRun?.id,
|
|
1879
|
+
scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor
|
|
1880
|
+
}
|
|
1881
|
+
}) : undefined;
|
|
1882
|
+
this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, invocation_id, work_item_id,
|
|
1883
|
+
scheduled_for, idempotency_key, manifest_path, status, started_at, finished_at, duration_ms, error,
|
|
1884
|
+
created_at, updated_at)
|
|
1885
|
+
VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $invocationId, $workItemId, $scheduledFor,
|
|
1886
|
+
$idempotencyKey, $manifestPath, 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
|
|
1508
1887
|
$id: runId,
|
|
1509
1888
|
$workflowId: input.workflow.id,
|
|
1510
1889
|
$workflowName: input.workflow.name,
|
|
1511
1890
|
$loopId: input.loop?.id ?? null,
|
|
1512
1891
|
$loopRunId: input.loopRun?.id ?? null,
|
|
1892
|
+
$invocationId: invocationId ?? null,
|
|
1893
|
+
$workItemId: workItemId ?? null,
|
|
1513
1894
|
$scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
|
|
1514
1895
|
$idempotencyKey: input.idempotencyKey ?? null,
|
|
1896
|
+
$manifestPath: manifestPath ?? null,
|
|
1515
1897
|
$started: now,
|
|
1516
1898
|
$created: now,
|
|
1517
1899
|
$updated: now
|
|
1518
1900
|
});
|
|
1901
|
+
if (workItemId) {
|
|
1902
|
+
const workItemRes = this.db.query(`UPDATE workflow_work_items
|
|
1903
|
+
SET status='running', workflow_run_id=$workflowRunId, lease_expires_at=$leaseExpiresAt, updated_at=$updated
|
|
1904
|
+
WHERE id=$id AND status IN ('admitted', 'queued', 'deferred', 'running')`).run({
|
|
1905
|
+
$id: workItemId,
|
|
1906
|
+
$workflowRunId: runId,
|
|
1907
|
+
$leaseExpiresAt: input.loop ? new Date(Date.now() + input.loop.leaseMs).toISOString() : null,
|
|
1908
|
+
$updated: now
|
|
1909
|
+
});
|
|
1910
|
+
if (workItemRes.changes !== 1) {
|
|
1911
|
+
const current = this.getWorkflowWorkItem(workItemId);
|
|
1912
|
+
throw new Error(`workflow work item is not runnable: ${workItemId}${current ? ` status=${current.status}` : ""}`);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1519
1915
|
input.workflow.steps.forEach((step, sequence) => {
|
|
1520
1916
|
const account = step.account ?? step.target.account;
|
|
1521
1917
|
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
@@ -1541,7 +1937,10 @@ class Store {
|
|
|
1541
1937
|
workflowName: input.workflow.name,
|
|
1542
1938
|
stepCount: input.workflow.steps.length,
|
|
1543
1939
|
loopId: input.loop?.id,
|
|
1544
|
-
loopRunId: input.loopRun?.id
|
|
1940
|
+
loopRunId: input.loopRun?.id,
|
|
1941
|
+
invocationId,
|
|
1942
|
+
workItemId,
|
|
1943
|
+
manifestPath
|
|
1545
1944
|
}),
|
|
1546
1945
|
$created: now
|
|
1547
1946
|
});
|
|
@@ -1554,6 +1953,8 @@ class Store {
|
|
|
1554
1953
|
try {
|
|
1555
1954
|
this.db.exec("ROLLBACK");
|
|
1556
1955
|
} catch {}
|
|
1956
|
+
if (manifestPath)
|
|
1957
|
+
rmSync(manifestPath, { force: true });
|
|
1557
1958
|
throw error;
|
|
1558
1959
|
}
|
|
1559
1960
|
}
|
|
@@ -1766,6 +2167,10 @@ class Store {
|
|
|
1766
2167
|
changed = res.changes === 1;
|
|
1767
2168
|
if (changed)
|
|
1768
2169
|
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
2170
|
+
if (changed) {
|
|
2171
|
+
const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
|
|
2172
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
|
|
2173
|
+
}
|
|
1769
2174
|
this.db.exec("COMMIT");
|
|
1770
2175
|
} catch (error) {
|
|
1771
2176
|
try {
|
|
@@ -1790,6 +2195,7 @@ class Store {
|
|
|
1790
2195
|
this.db.query(`UPDATE workflow_step_runs
|
|
1791
2196
|
SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
|
|
1792
2197
|
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
2198
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
|
|
1793
2199
|
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
1794
2200
|
}
|
|
1795
2201
|
this.db.exec("COMMIT");
|
|
@@ -2043,6 +2449,8 @@ class Store {
|
|
|
2043
2449
|
throw new Error(`run not found after finalize: ${id}`);
|
|
2044
2450
|
if (opts.claimedBy && res.changes !== 1)
|
|
2045
2451
|
return run;
|
|
2452
|
+
if (res.changes === 1)
|
|
2453
|
+
this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
|
|
2046
2454
|
return run;
|
|
2047
2455
|
}
|
|
2048
2456
|
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
@@ -2136,6 +2544,14 @@ class Store {
|
|
|
2136
2544
|
error: "parent loop run lease expired before completion",
|
|
2137
2545
|
loopRunId: row.id
|
|
2138
2546
|
});
|
|
2547
|
+
this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
|
|
2548
|
+
}
|
|
2549
|
+
const loop = this.getLoop(row.loop_id);
|
|
2550
|
+
const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
|
|
2551
|
+
if (itemStatus) {
|
|
2552
|
+
const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
|
|
2553
|
+
const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
|
|
2554
|
+
this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
|
|
2139
2555
|
}
|
|
2140
2556
|
this.db.exec("COMMIT");
|
|
2141
2557
|
} catch (error) {
|
|
@@ -2356,7 +2772,7 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
2356
2772
|
// src/lib/env.ts
|
|
2357
2773
|
import { accessSync, constants } from "fs";
|
|
2358
2774
|
import { homedir as homedir2 } from "os";
|
|
2359
|
-
import { delimiter, join as
|
|
2775
|
+
import { delimiter, join as join4 } from "path";
|
|
2360
2776
|
function compactPathParts(parts) {
|
|
2361
2777
|
const seen = new Set;
|
|
2362
2778
|
const result = [];
|
|
@@ -2372,14 +2788,14 @@ function compactPathParts(parts) {
|
|
|
2372
2788
|
function commonExecutableDirs(env = process.env) {
|
|
2373
2789
|
const home = env.HOME || homedir2();
|
|
2374
2790
|
return compactPathParts([
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
env.BUN_INSTALL ?
|
|
2791
|
+
join4(home, ".local", "bin"),
|
|
2792
|
+
join4(home, ".bun", "bin"),
|
|
2793
|
+
join4(home, ".cargo", "bin"),
|
|
2794
|
+
join4(home, ".npm-global", "bin"),
|
|
2795
|
+
join4(home, "bin"),
|
|
2796
|
+
env.BUN_INSTALL ? join4(env.BUN_INSTALL, "bin") : undefined,
|
|
2381
2797
|
env.PNPM_HOME,
|
|
2382
|
-
env.NPM_CONFIG_PREFIX ?
|
|
2798
|
+
env.NPM_CONFIG_PREFIX ? join4(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
2383
2799
|
"/opt/homebrew/bin",
|
|
2384
2800
|
"/usr/local/bin",
|
|
2385
2801
|
"/usr/bin",
|
|
@@ -2403,7 +2819,7 @@ function executableExists(command, env = process.env) {
|
|
|
2403
2819
|
if (command.includes("/"))
|
|
2404
2820
|
return isExecutable(command);
|
|
2405
2821
|
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
2406
|
-
if (dir && isExecutable(
|
|
2822
|
+
if (dir && isExecutable(join4(dir, command)))
|
|
2407
2823
|
return true;
|
|
2408
2824
|
}
|
|
2409
2825
|
return false;
|
|
@@ -2761,6 +3177,7 @@ function commandSpec(target) {
|
|
|
2761
3177
|
shell: commandTarget.shell,
|
|
2762
3178
|
env: commandTarget.env,
|
|
2763
3179
|
timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3180
|
+
idleTimeoutMs: commandTarget.idleTimeoutMs,
|
|
2764
3181
|
account: commandTarget.account,
|
|
2765
3182
|
accountTool: commandTarget.account?.tool
|
|
2766
3183
|
};
|
|
@@ -2771,6 +3188,7 @@ function commandSpec(target) {
|
|
|
2771
3188
|
args: agentArgs(agentTarget),
|
|
2772
3189
|
cwd: agentTarget.cwd,
|
|
2773
3190
|
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
3191
|
+
idleTimeoutMs: agentTarget.idleTimeoutMs,
|
|
2774
3192
|
account: agentTarget.account,
|
|
2775
3193
|
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
2776
3194
|
nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
|
|
@@ -2918,6 +3336,7 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
2918
3336
|
let stdout = "";
|
|
2919
3337
|
let stderr = "";
|
|
2920
3338
|
let timedOut = false;
|
|
3339
|
+
let idleTimedOut = false;
|
|
2921
3340
|
let exitCode;
|
|
2922
3341
|
let error;
|
|
2923
3342
|
let plan;
|
|
@@ -2956,18 +3375,34 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
2956
3375
|
if (opts.signal?.aborted)
|
|
2957
3376
|
abortHandler();
|
|
2958
3377
|
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
3378
|
const timer = setTimeout(() => {
|
|
2966
3379
|
timedOut = true;
|
|
2967
3380
|
if (child.pid)
|
|
2968
3381
|
killProcessGroup(child.pid);
|
|
2969
3382
|
}, spec.timeoutMs);
|
|
2970
3383
|
timer.unref();
|
|
3384
|
+
let idleTimer;
|
|
3385
|
+
const resetIdleTimer = () => {
|
|
3386
|
+
if (!spec.idleTimeoutMs)
|
|
3387
|
+
return;
|
|
3388
|
+
if (idleTimer)
|
|
3389
|
+
clearTimeout(idleTimer);
|
|
3390
|
+
idleTimer = setTimeout(() => {
|
|
3391
|
+
idleTimedOut = true;
|
|
3392
|
+
if (child.pid)
|
|
3393
|
+
killProcessGroup(child.pid);
|
|
3394
|
+
}, spec.idleTimeoutMs);
|
|
3395
|
+
idleTimer.unref();
|
|
3396
|
+
};
|
|
3397
|
+
resetIdleTimer();
|
|
3398
|
+
child.stdout?.on("data", (chunk) => {
|
|
3399
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3400
|
+
resetIdleTimer();
|
|
3401
|
+
});
|
|
3402
|
+
child.stderr?.on("data", (chunk) => {
|
|
3403
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3404
|
+
resetIdleTimer();
|
|
3405
|
+
});
|
|
2971
3406
|
try {
|
|
2972
3407
|
const [code, signal] = await once(child, "exit");
|
|
2973
3408
|
if (typeof code === "number")
|
|
@@ -2978,17 +3413,19 @@ async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
|
2978
3413
|
error = err instanceof Error ? err.message : String(err);
|
|
2979
3414
|
} finally {
|
|
2980
3415
|
clearTimeout(timer);
|
|
3416
|
+
if (idleTimer)
|
|
3417
|
+
clearTimeout(idleTimer);
|
|
2981
3418
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
2982
3419
|
}
|
|
2983
3420
|
const finishedAt = nowIso();
|
|
2984
3421
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
2985
|
-
if (timedOut) {
|
|
3422
|
+
if (timedOut || idleTimedOut) {
|
|
2986
3423
|
return {
|
|
2987
3424
|
status: "timed_out",
|
|
2988
3425
|
exitCode,
|
|
2989
3426
|
stdout,
|
|
2990
3427
|
stderr,
|
|
2991
|
-
error: `timed out after ${spec.timeoutMs}ms`,
|
|
3428
|
+
error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
|
|
2992
3429
|
pid: child.pid,
|
|
2993
3430
|
startedAt,
|
|
2994
3431
|
finishedAt,
|
|
@@ -3054,6 +3491,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3054
3491
|
let stdout = "";
|
|
3055
3492
|
let stderr = "";
|
|
3056
3493
|
let timedOut = false;
|
|
3494
|
+
let idleTimedOut = false;
|
|
3057
3495
|
let exitCode;
|
|
3058
3496
|
let error;
|
|
3059
3497
|
const env = executionEnv(spec, metadata, opts);
|
|
@@ -3116,18 +3554,34 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3116
3554
|
if (opts.signal?.aborted)
|
|
3117
3555
|
abortHandler();
|
|
3118
3556
|
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
3557
|
const timer = setTimeout(() => {
|
|
3126
3558
|
timedOut = true;
|
|
3127
3559
|
if (child.pid)
|
|
3128
3560
|
killProcessGroup(child.pid);
|
|
3129
3561
|
}, spec.timeoutMs);
|
|
3130
3562
|
timer.unref();
|
|
3563
|
+
let idleTimer;
|
|
3564
|
+
const resetIdleTimer = () => {
|
|
3565
|
+
if (!spec.idleTimeoutMs)
|
|
3566
|
+
return;
|
|
3567
|
+
if (idleTimer)
|
|
3568
|
+
clearTimeout(idleTimer);
|
|
3569
|
+
idleTimer = setTimeout(() => {
|
|
3570
|
+
idleTimedOut = true;
|
|
3571
|
+
if (child.pid)
|
|
3572
|
+
killProcessGroup(child.pid);
|
|
3573
|
+
}, spec.idleTimeoutMs);
|
|
3574
|
+
idleTimer.unref();
|
|
3575
|
+
};
|
|
3576
|
+
resetIdleTimer();
|
|
3577
|
+
child.stdout?.on("data", (chunk) => {
|
|
3578
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
3579
|
+
resetIdleTimer();
|
|
3580
|
+
});
|
|
3581
|
+
child.stderr?.on("data", (chunk) => {
|
|
3582
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
3583
|
+
resetIdleTimer();
|
|
3584
|
+
});
|
|
3131
3585
|
try {
|
|
3132
3586
|
const [code, signal] = await once(child, "exit");
|
|
3133
3587
|
if (typeof code === "number")
|
|
@@ -3138,17 +3592,19 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
3138
3592
|
error = err instanceof Error ? err.message : String(err);
|
|
3139
3593
|
} finally {
|
|
3140
3594
|
clearTimeout(timer);
|
|
3595
|
+
if (idleTimer)
|
|
3596
|
+
clearTimeout(idleTimer);
|
|
3141
3597
|
opts.signal?.removeEventListener("abort", abortHandler);
|
|
3142
3598
|
}
|
|
3143
3599
|
const finishedAt = nowIso();
|
|
3144
3600
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
3145
|
-
if (timedOut) {
|
|
3601
|
+
if (timedOut || idleTimedOut) {
|
|
3146
3602
|
return {
|
|
3147
3603
|
status: "timed_out",
|
|
3148
3604
|
exitCode,
|
|
3149
3605
|
stdout,
|
|
3150
3606
|
stderr,
|
|
3151
|
-
error: `timed out after ${spec.timeoutMs}ms`,
|
|
3607
|
+
error: idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`,
|
|
3152
3608
|
pid: child.pid,
|
|
3153
3609
|
startedAt,
|
|
3154
3610
|
finishedAt,
|
|
@@ -4190,7 +4646,7 @@ async function tick(deps) {
|
|
|
4190
4646
|
}
|
|
4191
4647
|
|
|
4192
4648
|
// src/daemon/control.ts
|
|
4193
|
-
import { existsSync as existsSync2, mkdirSync as
|
|
4649
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync4, readFileSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4194
4650
|
import { hostname } from "os";
|
|
4195
4651
|
import { dirname as dirname2 } from "path";
|
|
4196
4652
|
|
|
@@ -4228,11 +4684,11 @@ function readPid(path = pidFilePath()) {
|
|
|
4228
4684
|
}
|
|
4229
4685
|
}
|
|
4230
4686
|
function writePid(pid = process.pid, path = pidFilePath()) {
|
|
4231
|
-
|
|
4232
|
-
|
|
4687
|
+
mkdirSync4(dirname2(path), { recursive: true, mode: 448 });
|
|
4688
|
+
writeFileSync2(path, String(pid));
|
|
4233
4689
|
}
|
|
4234
4690
|
function removePid(path = pidFilePath()) {
|
|
4235
|
-
|
|
4691
|
+
rmSync2(path, { force: true });
|
|
4236
4692
|
}
|
|
4237
4693
|
function isAlive(pid) {
|
|
4238
4694
|
try {
|
|
@@ -4488,7 +4944,7 @@ async function startDaemon(opts) {
|
|
|
4488
4944
|
}
|
|
4489
4945
|
|
|
4490
4946
|
// src/daemon/install.ts
|
|
4491
|
-
import { chmodSync, mkdirSync as
|
|
4947
|
+
import { chmodSync, mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
4492
4948
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
4493
4949
|
import { dirname as dirname3 } from "path";
|
|
4494
4950
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
@@ -4496,8 +4952,8 @@ function installStartup(cliEntry, execPath = process.execPath, args = ["daemon",
|
|
|
4496
4952
|
const pathEnv = normalizeExecutionPath(process.env);
|
|
4497
4953
|
if (process.platform === "linux") {
|
|
4498
4954
|
const path = systemdServicePath();
|
|
4499
|
-
|
|
4500
|
-
|
|
4955
|
+
mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
|
|
4956
|
+
writeFileSync3(path, `[Unit]
|
|
4501
4957
|
Description=Hasna OpenLoops daemon
|
|
4502
4958
|
After=default.target
|
|
4503
4959
|
|
|
@@ -4523,8 +4979,8 @@ WantedBy=default.target
|
|
|
4523
4979
|
}
|
|
4524
4980
|
if (process.platform === "darwin") {
|
|
4525
4981
|
const path = launchdPlistPath();
|
|
4526
|
-
|
|
4527
|
-
|
|
4982
|
+
mkdirSync5(dirname3(path), { recursive: true, mode: 448 });
|
|
4983
|
+
writeFileSync3(path, `<?xml version="1.0" encoding="UTF-8"?>
|
|
4528
4984
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4529
4985
|
<plist version="1.0">
|
|
4530
4986
|
<dict>
|
|
@@ -4574,7 +5030,7 @@ function enableStartup(result) {
|
|
|
4574
5030
|
// package.json
|
|
4575
5031
|
var package_default = {
|
|
4576
5032
|
name: "@hasna/loops",
|
|
4577
|
-
version: "0.3.
|
|
5033
|
+
version: "0.3.40",
|
|
4578
5034
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4579
5035
|
type: "module",
|
|
4580
5036
|
main: "dist/index.js",
|