@hasna/loops 0.3.2 → 0.3.4
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 +8 -0
- package/dist/cli/index.js +857 -191
- package/dist/daemon/index.js +756 -167
- package/dist/index.d.ts +1 -0
- package/dist/index.js +779 -171
- package/dist/lib/executor.d.ts +11 -2
- package/dist/lib/format.d.ts +2 -1
- package/dist/lib/machines.d.ts +16 -0
- package/dist/lib/scheduler.d.ts +10 -1
- package/dist/lib/store.d.ts +21 -12
- package/dist/lib/store.js +309 -109
- package/dist/sdk/index.js +753 -165
- package/dist/types.d.ts +19 -0
- package/docs/USAGE.md +8 -0
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -345,6 +345,7 @@ function rowToLoop(row) {
|
|
|
345
345
|
status: row.status,
|
|
346
346
|
schedule: JSON.parse(row.schedule_json),
|
|
347
347
|
target: JSON.parse(row.target_json),
|
|
348
|
+
machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
|
|
348
349
|
nextRunAt: row.next_run_at ?? undefined,
|
|
349
350
|
retryScheduledFor: row.retry_scheduled_for ?? undefined,
|
|
350
351
|
catchUp: row.catch_up,
|
|
@@ -487,6 +488,7 @@ class Store {
|
|
|
487
488
|
status TEXT NOT NULL,
|
|
488
489
|
schedule_json TEXT NOT NULL,
|
|
489
490
|
target_json TEXT NOT NULL,
|
|
491
|
+
machine_json TEXT,
|
|
490
492
|
next_run_at TEXT,
|
|
491
493
|
retry_scheduled_for TEXT,
|
|
492
494
|
catch_up TEXT NOT NULL,
|
|
@@ -608,10 +610,21 @@ class Store {
|
|
|
608
610
|
);
|
|
609
611
|
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
|
|
610
612
|
`);
|
|
613
|
+
try {
|
|
614
|
+
this.db.query("ALTER TABLE loops ADD COLUMN machine_json TEXT").run();
|
|
615
|
+
} catch {}
|
|
611
616
|
try {
|
|
612
617
|
this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
|
|
613
618
|
} catch {}
|
|
614
619
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
620
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
|
|
621
|
+
}
|
|
622
|
+
assertDaemonLeaseFence(opts = {}, now = nowIso()) {
|
|
623
|
+
if (!opts.daemonLeaseId)
|
|
624
|
+
return;
|
|
625
|
+
const row = this.db.query("SELECT id FROM daemon_lease WHERE id = ? AND expires_at > ?").get(opts.daemonLeaseId, now);
|
|
626
|
+
if (!row)
|
|
627
|
+
throw new Error("daemon lease lost");
|
|
615
628
|
}
|
|
616
629
|
createLoop(input, from = new Date) {
|
|
617
630
|
const now = nowIso();
|
|
@@ -622,6 +635,7 @@ class Store {
|
|
|
622
635
|
status: "active",
|
|
623
636
|
schedule: input.schedule,
|
|
624
637
|
target: input.target,
|
|
638
|
+
machine: input.machine,
|
|
625
639
|
nextRunAt: initialNextRun(input.schedule, from),
|
|
626
640
|
catchUp: input.catchUp ?? "latest",
|
|
627
641
|
catchUpLimit: input.catchUpLimit ?? 50,
|
|
@@ -633,9 +647,9 @@ class Store {
|
|
|
633
647
|
createdAt: now,
|
|
634
648
|
updatedAt: now
|
|
635
649
|
};
|
|
636
|
-
this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, next_run_at, retry_scheduled_for,
|
|
650
|
+
this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
|
|
637
651
|
catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
|
|
638
|
-
VALUES ($id, $name, $description, $status, $schedule, $target, $nextRun, NULL, $catchUp, $catchUpLimit,
|
|
652
|
+
VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $catchUp, $catchUpLimit,
|
|
639
653
|
$overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
|
|
640
654
|
$id: loop.id,
|
|
641
655
|
$name: loop.name,
|
|
@@ -643,6 +657,7 @@ class Store {
|
|
|
643
657
|
$status: loop.status,
|
|
644
658
|
$schedule: JSON.stringify(loop.schedule),
|
|
645
659
|
$target: JSON.stringify(loop.target),
|
|
660
|
+
$machine: loop.machine ? JSON.stringify(loop.machine) : null,
|
|
646
661
|
$nextRun: loop.nextRunAt ?? null,
|
|
647
662
|
$catchUp: loop.catchUp,
|
|
648
663
|
$catchUpLimit: loop.catchUpLimit,
|
|
@@ -682,21 +697,31 @@ class Store {
|
|
|
682
697
|
ORDER BY next_run_at ASC`).all(now.toISOString());
|
|
683
698
|
return rows.map(rowToLoop);
|
|
684
699
|
}
|
|
685
|
-
updateLoop(id, patch) {
|
|
700
|
+
updateLoop(id, patch, opts = {}) {
|
|
686
701
|
const current = this.getLoop(id);
|
|
687
702
|
if (!current)
|
|
688
703
|
throw new Error(`loop not found: ${id}`);
|
|
689
|
-
const
|
|
704
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
705
|
+
const merged = { ...current, ...patch, updatedAt: updated };
|
|
690
706
|
this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
|
|
691
|
-
expires_at=$expiresAt, updated_at=$updated
|
|
707
|
+
expires_at=$expiresAt, updated_at=$updated
|
|
708
|
+
WHERE id=$id
|
|
709
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
710
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
711
|
+
))`).run({
|
|
692
712
|
$id: id,
|
|
693
713
|
$status: merged.status,
|
|
694
714
|
$nextRun: merged.nextRunAt ?? null,
|
|
695
715
|
$retrySlot: merged.retryScheduledFor ?? null,
|
|
696
716
|
$expiresAt: merged.expiresAt ?? null,
|
|
697
|
-
$updated: merged.updatedAt
|
|
717
|
+
$updated: merged.updatedAt,
|
|
718
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
719
|
+
$now: updated
|
|
698
720
|
});
|
|
699
|
-
|
|
721
|
+
const after = this.getLoop(id);
|
|
722
|
+
if (!after)
|
|
723
|
+
throw new Error(`loop not found after update: ${id}`);
|
|
724
|
+
return after;
|
|
700
725
|
}
|
|
701
726
|
deleteLoop(idOrName) {
|
|
702
727
|
const loop = this.requireLoop(idOrName);
|
|
@@ -760,11 +785,14 @@ class Store {
|
|
|
760
785
|
const now = nowIso();
|
|
761
786
|
if (input.idempotencyKey) {
|
|
762
787
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
763
|
-
if (existing)
|
|
788
|
+
if (existing) {
|
|
789
|
+
this.assertDaemonLeaseFence(input);
|
|
764
790
|
return rowToWorkflowRun(existing);
|
|
791
|
+
}
|
|
765
792
|
}
|
|
766
793
|
this.db.exec("BEGIN IMMEDIATE");
|
|
767
794
|
try {
|
|
795
|
+
this.assertDaemonLeaseFence(input, now);
|
|
768
796
|
if (input.idempotencyKey) {
|
|
769
797
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
770
798
|
if (existing) {
|
|
@@ -863,31 +891,60 @@ class Store {
|
|
|
863
891
|
const run = this.getWorkflowRun(workflowRunId);
|
|
864
892
|
return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
|
|
865
893
|
}
|
|
866
|
-
startWorkflowStepRun(workflowRunId, stepId) {
|
|
867
|
-
const now =
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
894
|
+
startWorkflowStepRun(workflowRunId, stepId, opts = {}) {
|
|
895
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
896
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
897
|
+
try {
|
|
898
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
899
|
+
SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
|
|
900
|
+
pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
901
|
+
WHERE workflow_run_id=$workflowRunId
|
|
902
|
+
AND step_id=$stepId
|
|
903
|
+
AND status IN ('pending', 'failed', 'timed_out')
|
|
904
|
+
AND EXISTS (
|
|
905
|
+
SELECT 1 FROM workflow_runs
|
|
906
|
+
WHERE id=$workflowRunId AND status='running'
|
|
907
|
+
)
|
|
908
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
909
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
910
|
+
))`).run({
|
|
911
|
+
$workflowRunId: workflowRunId,
|
|
912
|
+
$stepId: stepId,
|
|
913
|
+
$started: now,
|
|
914
|
+
$updated: now,
|
|
915
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
916
|
+
$now: now
|
|
917
|
+
});
|
|
918
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
919
|
+
if (!run)
|
|
920
|
+
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
921
|
+
if (res.changes !== 1) {
|
|
922
|
+
throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
|
|
923
|
+
}
|
|
924
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
925
|
+
this.db.exec("COMMIT");
|
|
926
|
+
return run;
|
|
927
|
+
} catch (error) {
|
|
928
|
+
try {
|
|
929
|
+
this.db.exec("ROLLBACK");
|
|
930
|
+
} catch {}
|
|
931
|
+
throw error;
|
|
883
932
|
}
|
|
884
|
-
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
885
|
-
return run;
|
|
886
933
|
}
|
|
887
|
-
markWorkflowStepPid(workflowRunId, stepId, pid) {
|
|
888
|
-
const now =
|
|
934
|
+
markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
|
|
935
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
889
936
|
this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
|
|
890
|
-
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
937
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
938
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
939
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
940
|
+
))`).run({
|
|
941
|
+
$workflowRunId: workflowRunId,
|
|
942
|
+
$stepId: stepId,
|
|
943
|
+
$pid: pid,
|
|
944
|
+
$updated: now,
|
|
945
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
946
|
+
$now: now
|
|
947
|
+
});
|
|
891
948
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
892
949
|
if (!run)
|
|
893
950
|
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
@@ -915,60 +972,110 @@ class Store {
|
|
|
915
972
|
recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
|
|
916
973
|
};
|
|
917
974
|
}
|
|
918
|
-
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
975
|
+
finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
|
|
919
976
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
977
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
978
|
+
try {
|
|
979
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
980
|
+
pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
981
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
982
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
983
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
984
|
+
))`).run({
|
|
985
|
+
$workflowRunId: workflowRunId,
|
|
986
|
+
$stepId: stepId,
|
|
987
|
+
$status: patch.status,
|
|
988
|
+
$finished: finishedAt,
|
|
989
|
+
$exitCode: patch.exitCode ?? null,
|
|
990
|
+
$durationMs: patch.durationMs ?? null,
|
|
991
|
+
$stdout: patch.stdout ?? null,
|
|
992
|
+
$stderr: patch.stderr ?? null,
|
|
993
|
+
$error: patch.error ?? null,
|
|
994
|
+
$updated: finishedAt,
|
|
995
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
996
|
+
$now: (opts.now ?? new Date(finishedAt)).toISOString()
|
|
938
997
|
});
|
|
998
|
+
if (res.changes === 1) {
|
|
999
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
1000
|
+
exitCode: patch.exitCode,
|
|
1001
|
+
error: patch.error
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
this.db.exec("COMMIT");
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
try {
|
|
1007
|
+
this.db.exec("ROLLBACK");
|
|
1008
|
+
} catch {}
|
|
1009
|
+
throw error;
|
|
939
1010
|
}
|
|
940
1011
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
941
1012
|
if (!run)
|
|
942
1013
|
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
943
1014
|
return run;
|
|
944
1015
|
}
|
|
945
|
-
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
946
|
-
const now =
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1016
|
+
skipWorkflowStepRun(workflowRunId, stepId, reason, opts = {}) {
|
|
1017
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1018
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1019
|
+
try {
|
|
1020
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
|
|
1021
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')
|
|
1022
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1023
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1024
|
+
))`).run({
|
|
1025
|
+
$workflowRunId: workflowRunId,
|
|
1026
|
+
$stepId: stepId,
|
|
1027
|
+
$finished: now,
|
|
1028
|
+
$error: reason,
|
|
1029
|
+
$updated: now,
|
|
1030
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1031
|
+
$now: now
|
|
1032
|
+
});
|
|
1033
|
+
if (res.changes === 1)
|
|
1034
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
1035
|
+
this.db.exec("COMMIT");
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
try {
|
|
1038
|
+
this.db.exec("ROLLBACK");
|
|
1039
|
+
} catch {}
|
|
1040
|
+
throw error;
|
|
1041
|
+
}
|
|
951
1042
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
952
1043
|
if (!run)
|
|
953
1044
|
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
954
1045
|
return run;
|
|
955
1046
|
}
|
|
956
|
-
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
1047
|
+
finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
|
|
957
1048
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1049
|
+
let changed = false;
|
|
1050
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1051
|
+
try {
|
|
1052
|
+
const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
1053
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1054
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1055
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1056
|
+
))`).run({
|
|
1057
|
+
$id: workflowRunId,
|
|
1058
|
+
$status: status,
|
|
1059
|
+
$finished: finishedAt,
|
|
1060
|
+
$durationMs: patch.durationMs ?? null,
|
|
1061
|
+
$error: patch.error ?? null,
|
|
1062
|
+
$updated: finishedAt,
|
|
1063
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1064
|
+
$now: (opts.now ?? new Date(finishedAt)).toISOString()
|
|
1065
|
+
});
|
|
1066
|
+
changed = res.changes === 1;
|
|
1067
|
+
if (changed)
|
|
1068
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
1069
|
+
this.db.exec("COMMIT");
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
try {
|
|
1072
|
+
this.db.exec("ROLLBACK");
|
|
1073
|
+
} catch {}
|
|
1074
|
+
throw error;
|
|
1075
|
+
}
|
|
967
1076
|
const run = this.getWorkflowRun(workflowRunId);
|
|
968
1077
|
if (!run)
|
|
969
1078
|
throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
|
|
970
|
-
if (res.changes === 1)
|
|
971
|
-
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
972
1079
|
return run;
|
|
973
1080
|
}
|
|
974
1081
|
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
@@ -1022,10 +1129,24 @@ class Store {
|
|
|
1022
1129
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
1023
1130
|
return (row?.count ?? 0) > 0;
|
|
1024
1131
|
}
|
|
1025
|
-
markRunPid(id, pid, claimedBy) {
|
|
1026
|
-
const now =
|
|
1132
|
+
markRunPid(id, pid, claimedBy, opts = {}) {
|
|
1133
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1027
1134
|
const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1028
|
-
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1135
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1136
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1137
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1138
|
+
))`).run({
|
|
1139
|
+
$id: id,
|
|
1140
|
+
$pid: pid,
|
|
1141
|
+
$updated: now,
|
|
1142
|
+
$claimedBy: claimedBy,
|
|
1143
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1144
|
+
$now: now
|
|
1145
|
+
}) : this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1146
|
+
WHERE id=$id AND status='running'
|
|
1147
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1148
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1149
|
+
))`).run({ $id: id, $pid: pid, $updated: now, $daemonLeaseId: opts.daemonLeaseId ?? null, $now: now });
|
|
1029
1150
|
if (res.changes !== 1)
|
|
1030
1151
|
return;
|
|
1031
1152
|
return this.getRun(id);
|
|
@@ -1040,7 +1161,7 @@ class Store {
|
|
|
1040
1161
|
AND wsr.pid IS NOT NULL`).all(loopRunId);
|
|
1041
1162
|
return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
|
|
1042
1163
|
}
|
|
1043
|
-
createSkippedRun(loop, scheduledFor, reason) {
|
|
1164
|
+
createSkippedRun(loop, scheduledFor, reason, opts = {}) {
|
|
1044
1165
|
const now = nowIso();
|
|
1045
1166
|
const run = {
|
|
1046
1167
|
id: genId(),
|
|
@@ -1054,21 +1175,31 @@ class Store {
|
|
|
1054
1175
|
createdAt: now,
|
|
1055
1176
|
updatedAt: now
|
|
1056
1177
|
};
|
|
1057
|
-
this.db.
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1178
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1179
|
+
try {
|
|
1180
|
+
this.assertDaemonLeaseFence(opts, now);
|
|
1181
|
+
this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
|
|
1182
|
+
claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
|
|
1183
|
+
VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
|
|
1184
|
+
NULL, NULL, $error, $created, $updated)`).run({
|
|
1185
|
+
$id: run.id,
|
|
1186
|
+
$loopId: run.loopId,
|
|
1187
|
+
$loopName: run.loopName,
|
|
1188
|
+
$scheduledFor: run.scheduledFor,
|
|
1189
|
+
$attempt: run.attempt,
|
|
1190
|
+
$status: run.status,
|
|
1191
|
+
$finished: run.finishedAt ?? null,
|
|
1192
|
+
$error: run.error ?? null,
|
|
1193
|
+
$created: run.createdAt,
|
|
1194
|
+
$updated: run.updatedAt
|
|
1195
|
+
});
|
|
1196
|
+
this.db.exec("COMMIT");
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
try {
|
|
1199
|
+
this.db.exec("ROLLBACK");
|
|
1200
|
+
} catch {}
|
|
1201
|
+
throw error;
|
|
1202
|
+
}
|
|
1072
1203
|
return this.getRunBySlot(loop.id, scheduledFor) ?? run;
|
|
1073
1204
|
}
|
|
1074
1205
|
getRun(id) {
|
|
@@ -1079,11 +1210,20 @@ class Store {
|
|
|
1079
1210
|
const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
|
|
1080
1211
|
return row ? rowToRun(row) : undefined;
|
|
1081
1212
|
}
|
|
1082
|
-
|
|
1213
|
+
nextRetryableRun(loopId, maxAttempts, afterScheduledFor) {
|
|
1214
|
+
const row = afterScheduledFor ? this.db.query(`SELECT * FROM loop_runs
|
|
1215
|
+
WHERE loop_id = ? AND scheduled_for > ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
|
|
1216
|
+
ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, afterScheduledFor, maxAttempts) : this.db.query(`SELECT * FROM loop_runs
|
|
1217
|
+
WHERE loop_id = ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
|
|
1218
|
+
ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, maxAttempts);
|
|
1219
|
+
return row ? rowToRun(row) : undefined;
|
|
1220
|
+
}
|
|
1221
|
+
claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
|
|
1083
1222
|
const startedAt = now.toISOString();
|
|
1084
1223
|
const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
|
|
1085
1224
|
this.db.exec("BEGIN IMMEDIATE");
|
|
1086
1225
|
try {
|
|
1226
|
+
this.assertDaemonLeaseFence(opts, startedAt);
|
|
1087
1227
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
1088
1228
|
if (existing) {
|
|
1089
1229
|
if (existing.status === "running") {
|
|
@@ -1178,11 +1318,15 @@ class Store {
|
|
|
1178
1318
|
$error: patch.error ?? null,
|
|
1179
1319
|
$updated: finishedAt,
|
|
1180
1320
|
$claimedBy: opts.claimedBy ?? null,
|
|
1181
|
-
$now: (opts.now ?? new Date).toISOString()
|
|
1321
|
+
$now: (opts.now ?? new Date).toISOString(),
|
|
1322
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1182
1323
|
};
|
|
1183
1324
|
const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1184
1325
|
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
1185
|
-
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1326
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1327
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1328
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1329
|
+
))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1186
1330
|
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
|
|
1187
1331
|
const run = this.getRun(id);
|
|
1188
1332
|
if (!run)
|
|
@@ -1191,10 +1335,20 @@ class Store {
|
|
|
1191
1335
|
return run;
|
|
1192
1336
|
return run;
|
|
1193
1337
|
}
|
|
1194
|
-
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
|
|
1338
|
+
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
1195
1339
|
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
1196
1340
|
const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
|
|
1197
|
-
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1341
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1342
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1343
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1344
|
+
))`).run({
|
|
1345
|
+
$id: id,
|
|
1346
|
+
$claimedBy: claimedBy,
|
|
1347
|
+
$expires: expiresAt,
|
|
1348
|
+
$updated: now.toISOString(),
|
|
1349
|
+
$now: now.toISOString(),
|
|
1350
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1351
|
+
});
|
|
1198
1352
|
if (res.changes !== 1)
|
|
1199
1353
|
return;
|
|
1200
1354
|
return this.getRun(id);
|
|
@@ -1213,7 +1367,7 @@ class Store {
|
|
|
1213
1367
|
}
|
|
1214
1368
|
return rows.map(rowToRun);
|
|
1215
1369
|
}
|
|
1216
|
-
recoverExpiredRunLeases(now = new Date) {
|
|
1370
|
+
recoverExpiredRunLeases(now = new Date, opts = {}) {
|
|
1217
1371
|
const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
|
|
1218
1372
|
const recovered = [];
|
|
1219
1373
|
for (const row of rows) {
|
|
@@ -1222,20 +1376,63 @@ class Store {
|
|
|
1222
1376
|
if (this.hasLiveWorkflowStepProcesses(row.id))
|
|
1223
1377
|
continue;
|
|
1224
1378
|
const finished = now.toISOString();
|
|
1225
|
-
this.db.
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1379
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1380
|
+
try {
|
|
1381
|
+
const res = this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
|
|
1382
|
+
error='run lease expired before completion', updated_at=$updated
|
|
1383
|
+
WHERE id=$id AND status='running' AND lease_expires_at <= $now
|
|
1384
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1385
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1386
|
+
))`).run({
|
|
1387
|
+
$id: row.id,
|
|
1388
|
+
$finished: finished,
|
|
1389
|
+
$updated: finished,
|
|
1390
|
+
$now: finished,
|
|
1391
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1238
1392
|
});
|
|
1393
|
+
if (res.changes !== 1) {
|
|
1394
|
+
this.db.exec("COMMIT");
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
const workflowRows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')").all(row.id);
|
|
1398
|
+
for (const workflowRow of workflowRows) {
|
|
1399
|
+
const workflowRes = this.db.query(`UPDATE workflow_runs
|
|
1400
|
+
SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1401
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1402
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1403
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1404
|
+
))`).run({
|
|
1405
|
+
$id: workflowRow.id,
|
|
1406
|
+
$finished: finished,
|
|
1407
|
+
$updated: finished,
|
|
1408
|
+
$now: finished,
|
|
1409
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1410
|
+
});
|
|
1411
|
+
if (workflowRes.changes !== 1)
|
|
1412
|
+
continue;
|
|
1413
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
1414
|
+
SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1415
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')
|
|
1416
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1417
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1418
|
+
))`).run({
|
|
1419
|
+
$workflowRunId: workflowRow.id,
|
|
1420
|
+
$finished: finished,
|
|
1421
|
+
$updated: finished,
|
|
1422
|
+
$now: finished,
|
|
1423
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1424
|
+
});
|
|
1425
|
+
this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
|
|
1426
|
+
error: "parent loop run lease expired before completion",
|
|
1427
|
+
loopRunId: row.id
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
this.db.exec("COMMIT");
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
try {
|
|
1433
|
+
this.db.exec("ROLLBACK");
|
|
1434
|
+
} catch {}
|
|
1435
|
+
throw error;
|
|
1239
1436
|
}
|
|
1240
1437
|
const run = this.getRun(row.id);
|
|
1241
1438
|
if (run)
|
|
@@ -1243,11 +1440,14 @@ class Store {
|
|
|
1243
1440
|
}
|
|
1244
1441
|
return recovered;
|
|
1245
1442
|
}
|
|
1246
|
-
expireLoops(now = new Date) {
|
|
1443
|
+
expireLoops(now = new Date, opts = {}) {
|
|
1247
1444
|
const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
|
|
1248
1445
|
const expired = [];
|
|
1249
|
-
for (const row of rows)
|
|
1250
|
-
|
|
1446
|
+
for (const row of rows) {
|
|
1447
|
+
const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
|
|
1448
|
+
if (updated.status === "expired")
|
|
1449
|
+
expired.push(updated);
|
|
1450
|
+
}
|
|
1251
1451
|
return expired;
|
|
1252
1452
|
}
|
|
1253
1453
|
countLoops(status) {
|
|
@@ -1290,7 +1490,7 @@ class Store {
|
|
|
1290
1490
|
}
|
|
1291
1491
|
heartbeatDaemonLease(id, ttlMs, now = new Date) {
|
|
1292
1492
|
const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
|
|
1293
|
-
const res = this.db.query(`UPDATE daemon_lease SET heartbeat_at=$heartbeat, expires_at=$expires, updated_at=$updated WHERE id=$id`).run({ $id: id, $heartbeat: now.toISOString(), $expires: expiresAt, $updated: now.toISOString() });
|
|
1493
|
+
const res = this.db.query(`UPDATE daemon_lease SET heartbeat_at=$heartbeat, expires_at=$expires, updated_at=$updated WHERE id=$id AND expires_at > $now`).run({ $id: id, $heartbeat: now.toISOString(), $expires: expiresAt, $updated: now.toISOString(), $now: now.toISOString() });
|
|
1294
1494
|
if (res.changes !== 1)
|
|
1295
1495
|
return;
|
|
1296
1496
|
return this.getDaemonLease();
|
|
@@ -1308,8 +1508,9 @@ class Store {
|
|
|
1308
1508
|
}
|
|
1309
1509
|
|
|
1310
1510
|
// src/lib/executor.ts
|
|
1311
|
-
import { spawn } from "child_process";
|
|
1511
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
1312
1512
|
import { once } from "events";
|
|
1513
|
+
import { resolveMachineCommand } from "@hasna/machines/consumer";
|
|
1313
1514
|
|
|
1314
1515
|
// src/lib/accounts.ts
|
|
1315
1516
|
import { spawnSync } from "child_process";
|
|
@@ -1466,6 +1667,59 @@ function commandNotFoundMessage(command, env = process.env) {
|
|
|
1466
1667
|
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
1467
1668
|
}
|
|
1468
1669
|
|
|
1670
|
+
// src/lib/machines.ts
|
|
1671
|
+
import {
|
|
1672
|
+
discoverMachineTopology,
|
|
1673
|
+
resolveMachineRoute
|
|
1674
|
+
} from "@hasna/machines/consumer";
|
|
1675
|
+
function compact(value) {
|
|
1676
|
+
const text = value?.trim();
|
|
1677
|
+
return text ? text : undefined;
|
|
1678
|
+
}
|
|
1679
|
+
function entryToSummary(entry, topology) {
|
|
1680
|
+
return {
|
|
1681
|
+
id: entry.machine_id,
|
|
1682
|
+
hostname: compact(entry.hostname),
|
|
1683
|
+
platform: compact(entry.platform),
|
|
1684
|
+
user: compact(entry.user),
|
|
1685
|
+
workspacePath: compact(entry.workspace_path),
|
|
1686
|
+
route: entry.ssh.route,
|
|
1687
|
+
local: entry.machine_id === topology.local_machine_id || entry.ssh.route === "local",
|
|
1688
|
+
heartbeatStatus: entry.heartbeat_status,
|
|
1689
|
+
tailscaleOnline: entry.tailscale.online,
|
|
1690
|
+
tags: entry.tags
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
function machineFromRoute(route, topology) {
|
|
1694
|
+
if (!route.ok || !route.machine_id) {
|
|
1695
|
+
throw new Error(`OpenMachines route not found for machine: ${route.requested_machine_id}`);
|
|
1696
|
+
}
|
|
1697
|
+
const entry = topology.machines.find((machine) => machine.machine_id === route.machine_id);
|
|
1698
|
+
return {
|
|
1699
|
+
id: route.machine_id,
|
|
1700
|
+
requestedId: route.requested_machine_id !== route.machine_id ? route.requested_machine_id : undefined,
|
|
1701
|
+
route: route.route,
|
|
1702
|
+
local: route.local,
|
|
1703
|
+
confidence: route.confidence,
|
|
1704
|
+
workspacePath: compact(entry?.workspace_path),
|
|
1705
|
+
resolvedAt: route.generated_at,
|
|
1706
|
+
packageVersion: route.package.version,
|
|
1707
|
+
warnings: route.warnings.length ? route.warnings : undefined
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
function listOpenMachines() {
|
|
1711
|
+
const topology = discoverMachineTopology();
|
|
1712
|
+
return topology.machines.map((entry) => entryToSummary(entry, topology));
|
|
1713
|
+
}
|
|
1714
|
+
function resolveLoopMachine(machineId) {
|
|
1715
|
+
const topology = discoverMachineTopology();
|
|
1716
|
+
const route = resolveMachineRoute(machineId, { topology });
|
|
1717
|
+
return machineFromRoute(route, topology);
|
|
1718
|
+
}
|
|
1719
|
+
function refreshLoopMachine(machine) {
|
|
1720
|
+
return resolveLoopMachine(machine.id);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1469
1723
|
// src/lib/executor.ts
|
|
1470
1724
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
1471
1725
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
@@ -1486,6 +1740,23 @@ var AUTH_ENV_KEYS = [
|
|
|
1486
1740
|
"XDG_STATE_HOME",
|
|
1487
1741
|
"XDG_CACHE_HOME"
|
|
1488
1742
|
];
|
|
1743
|
+
var TRANSPORT_ENV_KEYS = new Set([
|
|
1744
|
+
"BUN_INSTALL",
|
|
1745
|
+
"HOME",
|
|
1746
|
+
"LANG",
|
|
1747
|
+
"LANGUAGE",
|
|
1748
|
+
"LOGNAME",
|
|
1749
|
+
"PATH",
|
|
1750
|
+
"SHELL",
|
|
1751
|
+
"SSH_AGENT_PID",
|
|
1752
|
+
"SSH_AUTH_SOCK",
|
|
1753
|
+
"TERM",
|
|
1754
|
+
"TMP",
|
|
1755
|
+
"TMPDIR",
|
|
1756
|
+
"TEMP",
|
|
1757
|
+
"USER",
|
|
1758
|
+
"XDG_RUNTIME_DIR"
|
|
1759
|
+
]);
|
|
1489
1760
|
function appendBounded(current, chunk, maxBytes) {
|
|
1490
1761
|
const next = current + chunk.toString("utf8");
|
|
1491
1762
|
if (Buffer.byteLength(next, "utf8") <= maxBytes)
|
|
@@ -1512,6 +1783,29 @@ function killProcessGroup(pid) {
|
|
|
1512
1783
|
}
|
|
1513
1784
|
}, 2000).unref();
|
|
1514
1785
|
}
|
|
1786
|
+
function shellQuote(value) {
|
|
1787
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1788
|
+
}
|
|
1789
|
+
function metadataEnv(metadata) {
|
|
1790
|
+
const env = {};
|
|
1791
|
+
if (metadata.loopId)
|
|
1792
|
+
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1793
|
+
if (metadata.loopName)
|
|
1794
|
+
env.LOOPS_LOOP_NAME = metadata.loopName;
|
|
1795
|
+
if (metadata.runId)
|
|
1796
|
+
env.LOOPS_RUN_ID = metadata.runId;
|
|
1797
|
+
if (metadata.scheduledFor)
|
|
1798
|
+
env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
|
|
1799
|
+
if (metadata.workflowId)
|
|
1800
|
+
env.LOOPS_WORKFLOW_ID = metadata.workflowId;
|
|
1801
|
+
if (metadata.workflowName)
|
|
1802
|
+
env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
|
|
1803
|
+
if (metadata.workflowRunId)
|
|
1804
|
+
env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
|
|
1805
|
+
if (metadata.workflowStepId)
|
|
1806
|
+
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1807
|
+
return env;
|
|
1808
|
+
}
|
|
1515
1809
|
function providerCommand(provider) {
|
|
1516
1810
|
switch (provider) {
|
|
1517
1811
|
case "claude":
|
|
@@ -1540,7 +1834,7 @@ function agentArgs(target) {
|
|
|
1540
1834
|
args.push("--model", target.model);
|
|
1541
1835
|
if (target.agent)
|
|
1542
1836
|
args.push("--agent", target.agent);
|
|
1543
|
-
args.push(...target.extraArgs ?? []
|
|
1837
|
+
args.push(...target.extraArgs ?? []);
|
|
1544
1838
|
return args;
|
|
1545
1839
|
case "cursor":
|
|
1546
1840
|
args.push("-p");
|
|
@@ -1548,7 +1842,7 @@ function agentArgs(target) {
|
|
|
1548
1842
|
args.push("--model", target.model);
|
|
1549
1843
|
if (target.agent)
|
|
1550
1844
|
args.push("--agent", target.agent);
|
|
1551
|
-
args.push(...target.extraArgs ?? []
|
|
1845
|
+
args.push(...target.extraArgs ?? []);
|
|
1552
1846
|
return args;
|
|
1553
1847
|
case "codewith":
|
|
1554
1848
|
args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
|
|
@@ -1560,7 +1854,7 @@ function agentArgs(target) {
|
|
|
1560
1854
|
args.push("--model", target.model);
|
|
1561
1855
|
if (target.agent)
|
|
1562
1856
|
args.push("--agent", target.agent);
|
|
1563
|
-
args.push(...target.extraArgs ?? []
|
|
1857
|
+
args.push(...target.extraArgs ?? []);
|
|
1564
1858
|
return args;
|
|
1565
1859
|
case "codex":
|
|
1566
1860
|
args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
|
|
@@ -1570,7 +1864,7 @@ function agentArgs(target) {
|
|
|
1570
1864
|
args.push("--cd", target.cwd);
|
|
1571
1865
|
if (target.model)
|
|
1572
1866
|
args.push("--model", target.model);
|
|
1573
|
-
args.push(...target.extraArgs ?? []
|
|
1867
|
+
args.push(...target.extraArgs ?? []);
|
|
1574
1868
|
return args;
|
|
1575
1869
|
case "aicopilot":
|
|
1576
1870
|
args.push("run", "--format", "json");
|
|
@@ -1582,7 +1876,7 @@ function agentArgs(target) {
|
|
|
1582
1876
|
args.push("--model", target.model);
|
|
1583
1877
|
if (target.agent)
|
|
1584
1878
|
args.push("--agent", target.agent);
|
|
1585
|
-
args.push(...target.extraArgs ?? []
|
|
1879
|
+
args.push(...target.extraArgs ?? []);
|
|
1586
1880
|
return args;
|
|
1587
1881
|
case "opencode":
|
|
1588
1882
|
args.push("run", "--format", "json");
|
|
@@ -1594,7 +1888,7 @@ function agentArgs(target) {
|
|
|
1594
1888
|
args.push("--model", target.model);
|
|
1595
1889
|
if (target.agent)
|
|
1596
1890
|
args.push("--agent", target.agent);
|
|
1597
|
-
args.push(...target.extraArgs ?? []
|
|
1891
|
+
args.push(...target.extraArgs ?? []);
|
|
1598
1892
|
return args;
|
|
1599
1893
|
}
|
|
1600
1894
|
}
|
|
@@ -1619,7 +1913,8 @@ function commandSpec(target) {
|
|
|
1619
1913
|
cwd: agentTarget.cwd,
|
|
1620
1914
|
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
1621
1915
|
account: agentTarget.account,
|
|
1622
|
-
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
|
|
1916
|
+
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
1917
|
+
stdin: agentTarget.prompt
|
|
1623
1918
|
};
|
|
1624
1919
|
}
|
|
1625
1920
|
function executionEnv(spec, metadata, opts) {
|
|
@@ -1632,26 +1927,213 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1632
1927
|
}
|
|
1633
1928
|
Object.assign(env, spec.env ?? {});
|
|
1634
1929
|
env.PATH = normalizeExecutionPath(env);
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
if (
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1930
|
+
Object.assign(env, metadataEnv(metadata));
|
|
1931
|
+
return env;
|
|
1932
|
+
}
|
|
1933
|
+
function resolvedMachine(opts) {
|
|
1934
|
+
if (!opts.machine)
|
|
1935
|
+
return;
|
|
1936
|
+
return (opts.machineResolver ?? refreshLoopMachine)(opts.machine);
|
|
1937
|
+
}
|
|
1938
|
+
function commandForShell(spec) {
|
|
1939
|
+
if (!spec.args.length)
|
|
1940
|
+
return spec.command;
|
|
1941
|
+
return [spec.command, ...spec.args.map(shellQuote)].join(" ");
|
|
1942
|
+
}
|
|
1943
|
+
function hereDoc(value) {
|
|
1944
|
+
let delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
|
|
1945
|
+
while (value.split(/\r?\n/).includes(delimiter2)) {
|
|
1946
|
+
delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
|
|
1947
|
+
}
|
|
1948
|
+
return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
|
|
1949
|
+
}
|
|
1950
|
+
function remoteBootstrapLines(spec, metadata) {
|
|
1951
|
+
const lines = [
|
|
1952
|
+
"set -e",
|
|
1953
|
+
'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$HOME/.cargo/bin:$HOME/.npm-global/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin${PATH:+:$PATH}"'
|
|
1954
|
+
];
|
|
1955
|
+
if (spec.cwd)
|
|
1956
|
+
lines.push(`cd ${shellQuote(spec.cwd)}`);
|
|
1957
|
+
if (spec.account) {
|
|
1958
|
+
if (!spec.accountTool)
|
|
1959
|
+
throw new Error("account.tool is required when no provider tool can be inferred");
|
|
1960
|
+
lines.push("if ! command -v accounts >/dev/null 2>&1; then echo 'accounts CLI is not available on remote machine' >&2; exit 127; fi", `unset ${AUTH_ENV_KEYS.join(" ")}`, `eval "$(accounts env ${shellQuote(spec.account.profile)} --tool ${shellQuote(spec.accountTool)})"`, `export LOOPS_ACCOUNT_PROFILE=${shellQuote(spec.account.profile)}`, `export LOOPS_ACCOUNT_TOOL=${shellQuote(spec.accountTool)}`);
|
|
1961
|
+
}
|
|
1962
|
+
for (const [key, value] of Object.entries({ ...metadataEnv(metadata), ...spec.env ?? {} })) {
|
|
1963
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
1964
|
+
continue;
|
|
1965
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
1966
|
+
}
|
|
1967
|
+
return lines;
|
|
1968
|
+
}
|
|
1969
|
+
function remoteScript(spec, metadata) {
|
|
1970
|
+
const lines = remoteBootstrapLines(spec, metadata);
|
|
1971
|
+
let stdinRedirect = "";
|
|
1972
|
+
if (spec.stdin !== undefined) {
|
|
1973
|
+
lines.push('__OPENLOOPS_STDIN="$(mktemp -t openloops-stdin.XXXXXX)"', `trap 'rm -f "$__OPENLOOPS_STDIN"' EXIT`);
|
|
1974
|
+
lines.push(...hereDoc(spec.stdin));
|
|
1975
|
+
stdinRedirect = ' < "$__OPENLOOPS_STDIN"';
|
|
1976
|
+
}
|
|
1977
|
+
const invocation = spec.shell ? `sh -lc ${shellQuote(commandForShell(spec))}${stdinRedirect}` : `${[spec.command, ...spec.args].map(shellQuote).join(" ")}${stdinRedirect}`;
|
|
1978
|
+
lines.push(invocation);
|
|
1979
|
+
return `${lines.join(`
|
|
1980
|
+
`)}
|
|
1981
|
+
`;
|
|
1982
|
+
}
|
|
1983
|
+
function remotePreflightScript(spec, metadata) {
|
|
1984
|
+
return [
|
|
1985
|
+
...remoteBootstrapLines(spec, metadata),
|
|
1986
|
+
"command -v bash >/dev/null 2>&1",
|
|
1987
|
+
`command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
|
|
1988
|
+
].join(`
|
|
1989
|
+
`);
|
|
1990
|
+
}
|
|
1991
|
+
function transportEnv(opts) {
|
|
1992
|
+
const source = opts.env ?? process.env;
|
|
1993
|
+
const env = {};
|
|
1994
|
+
for (const [key, value] of Object.entries(source)) {
|
|
1995
|
+
if (value === undefined)
|
|
1996
|
+
continue;
|
|
1997
|
+
if (TRANSPORT_ENV_KEYS.has(key) || key.startsWith("LC_"))
|
|
1998
|
+
env[key] = value;
|
|
1999
|
+
}
|
|
2000
|
+
env.PATH = normalizeExecutionPath(env);
|
|
1651
2001
|
return env;
|
|
1652
2002
|
}
|
|
2003
|
+
function preflightRemoteSpec(spec, machine, metadata, opts) {
|
|
2004
|
+
const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
|
|
2005
|
+
const result = spawnSync2(plan.command, plan.args, {
|
|
2006
|
+
encoding: "utf8",
|
|
2007
|
+
env: transportEnv(opts),
|
|
2008
|
+
input: remotePreflightScript(spec, metadata),
|
|
2009
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2010
|
+
timeout: 15000
|
|
2011
|
+
});
|
|
2012
|
+
if (result.error)
|
|
2013
|
+
throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
|
|
2014
|
+
if ((result.status ?? 1) !== 0) {
|
|
2015
|
+
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
2016
|
+
throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
2020
|
+
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
2021
|
+
const startedAt = nowIso();
|
|
2022
|
+
let stdout = "";
|
|
2023
|
+
let stderr = "";
|
|
2024
|
+
let timedOut = false;
|
|
2025
|
+
let exitCode;
|
|
2026
|
+
let error;
|
|
2027
|
+
let plan;
|
|
2028
|
+
let script;
|
|
2029
|
+
try {
|
|
2030
|
+
plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
|
|
2031
|
+
script = remoteScript(spec, metadata);
|
|
2032
|
+
} catch (err) {
|
|
2033
|
+
return {
|
|
2034
|
+
status: "failed",
|
|
2035
|
+
stdout: "",
|
|
2036
|
+
stderr: "",
|
|
2037
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2038
|
+
startedAt,
|
|
2039
|
+
finishedAt: nowIso(),
|
|
2040
|
+
durationMs: 0
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
const child = spawn(plan.command, plan.args, {
|
|
2044
|
+
env: transportEnv(opts),
|
|
2045
|
+
detached: true,
|
|
2046
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2047
|
+
});
|
|
2048
|
+
if (child.pid)
|
|
2049
|
+
opts.onSpawn?.(child.pid);
|
|
2050
|
+
child.stdin?.on("error", (err) => {
|
|
2051
|
+
if (err.code !== "EPIPE")
|
|
2052
|
+
error = err.message;
|
|
2053
|
+
});
|
|
2054
|
+
child.stdin?.end(script);
|
|
2055
|
+
const abortHandler = () => {
|
|
2056
|
+
error = "cancelled";
|
|
2057
|
+
if (child.pid)
|
|
2058
|
+
killProcessGroup(child.pid);
|
|
2059
|
+
};
|
|
2060
|
+
if (opts.signal?.aborted)
|
|
2061
|
+
abortHandler();
|
|
2062
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
2063
|
+
child.stdout?.on("data", (chunk) => {
|
|
2064
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
2065
|
+
});
|
|
2066
|
+
child.stderr?.on("data", (chunk) => {
|
|
2067
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
2068
|
+
});
|
|
2069
|
+
const timer = setTimeout(() => {
|
|
2070
|
+
timedOut = true;
|
|
2071
|
+
if (child.pid)
|
|
2072
|
+
killProcessGroup(child.pid);
|
|
2073
|
+
}, spec.timeoutMs);
|
|
2074
|
+
timer.unref();
|
|
2075
|
+
try {
|
|
2076
|
+
const [code, signal] = await once(child, "exit");
|
|
2077
|
+
if (typeof code === "number")
|
|
2078
|
+
exitCode = code;
|
|
2079
|
+
if (signal)
|
|
2080
|
+
error = `terminated by ${signal}`;
|
|
2081
|
+
} catch (err) {
|
|
2082
|
+
error = err instanceof Error ? err.message : String(err);
|
|
2083
|
+
} finally {
|
|
2084
|
+
clearTimeout(timer);
|
|
2085
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
2086
|
+
}
|
|
2087
|
+
const finishedAt = nowIso();
|
|
2088
|
+
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
2089
|
+
if (timedOut) {
|
|
2090
|
+
return {
|
|
2091
|
+
status: "timed_out",
|
|
2092
|
+
exitCode,
|
|
2093
|
+
stdout,
|
|
2094
|
+
stderr,
|
|
2095
|
+
error: `timed out after ${spec.timeoutMs}ms`,
|
|
2096
|
+
pid: child.pid,
|
|
2097
|
+
startedAt,
|
|
2098
|
+
finishedAt,
|
|
2099
|
+
durationMs
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
if (error || exitCode !== 0) {
|
|
2103
|
+
return {
|
|
2104
|
+
status: "failed",
|
|
2105
|
+
exitCode,
|
|
2106
|
+
stdout,
|
|
2107
|
+
stderr,
|
|
2108
|
+
error: error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`,
|
|
2109
|
+
pid: child.pid,
|
|
2110
|
+
startedAt,
|
|
2111
|
+
finishedAt,
|
|
2112
|
+
durationMs
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
return {
|
|
2116
|
+
status: "succeeded",
|
|
2117
|
+
exitCode,
|
|
2118
|
+
stdout,
|
|
2119
|
+
stderr,
|
|
2120
|
+
pid: child.pid,
|
|
2121
|
+
startedAt,
|
|
2122
|
+
finishedAt,
|
|
2123
|
+
durationMs
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
1653
2126
|
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1654
2127
|
const spec = commandSpec(target);
|
|
2128
|
+
const machine = resolvedMachine(opts);
|
|
2129
|
+
if (machine && !machine.local) {
|
|
2130
|
+
preflightRemoteSpec(spec, machine, metadata, opts);
|
|
2131
|
+
return {
|
|
2132
|
+
command: spec.command,
|
|
2133
|
+
accountProfile: spec.account?.profile,
|
|
2134
|
+
accountTool: spec.accountTool
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
1655
2137
|
const env = executionEnv(spec, metadata, opts);
|
|
1656
2138
|
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1657
2139
|
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
@@ -1664,6 +2146,9 @@ function preflightTarget(target, metadata = {}, opts = {}) {
|
|
|
1664
2146
|
}
|
|
1665
2147
|
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1666
2148
|
const spec = commandSpec(target);
|
|
2149
|
+
const machine = resolvedMachine(opts);
|
|
2150
|
+
if (machine && !machine.local)
|
|
2151
|
+
return executeRemoteSpec(spec, machine, metadata, opts);
|
|
1667
2152
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
1668
2153
|
const startedAt = nowIso();
|
|
1669
2154
|
let stdout = "";
|
|
@@ -1688,10 +2173,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1688
2173
|
env,
|
|
1689
2174
|
shell: spec.shell ?? false,
|
|
1690
2175
|
detached: true,
|
|
1691
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
2176
|
+
stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
|
|
1692
2177
|
});
|
|
1693
2178
|
if (child.pid)
|
|
1694
2179
|
opts.onSpawn?.(child.pid);
|
|
2180
|
+
if (spec.stdin !== undefined && child.stdin) {
|
|
2181
|
+
child.stdin.on("error", (err) => {
|
|
2182
|
+
if (err.code !== "EPIPE")
|
|
2183
|
+
error = err.message;
|
|
2184
|
+
});
|
|
2185
|
+
child.stdin.end(spec.stdin);
|
|
2186
|
+
}
|
|
1695
2187
|
const abortHandler = () => {
|
|
1696
2188
|
error = "cancelled";
|
|
1697
2189
|
if (child.pid)
|
|
@@ -1700,10 +2192,10 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1700
2192
|
if (opts.signal?.aborted)
|
|
1701
2193
|
abortHandler();
|
|
1702
2194
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1703
|
-
child.stdout
|
|
2195
|
+
child.stdout?.on("data", (chunk) => {
|
|
1704
2196
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
1705
2197
|
});
|
|
1706
|
-
child.stderr
|
|
2198
|
+
child.stderr?.on("data", (chunk) => {
|
|
1707
2199
|
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
1708
2200
|
});
|
|
1709
2201
|
const timer = setTimeout(() => {
|
|
@@ -1772,7 +2264,7 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
1772
2264
|
loopName: loop.name,
|
|
1773
2265
|
runId: run.id,
|
|
1774
2266
|
scheduledFor: run.scheduledFor
|
|
1775
|
-
}, opts);
|
|
2267
|
+
}, { ...opts, machine: opts.machine ?? loop.machine });
|
|
1776
2268
|
}
|
|
1777
2269
|
|
|
1778
2270
|
// src/lib/workflow-runner.ts
|
|
@@ -1802,7 +2294,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1802
2294
|
loop: opts.loop,
|
|
1803
2295
|
loopRun: opts.loopRun,
|
|
1804
2296
|
scheduledFor: opts.scheduledFor,
|
|
1805
|
-
idempotencyKey: opts.idempotencyKey
|
|
2297
|
+
idempotencyKey: opts.idempotencyKey,
|
|
2298
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
1806
2299
|
});
|
|
1807
2300
|
const startedAt = run.startedAt ?? nowIso();
|
|
1808
2301
|
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
@@ -1836,12 +2329,14 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1836
2329
|
return !dependencyStep?.continueOnFailure;
|
|
1837
2330
|
});
|
|
1838
2331
|
if (blockedBy) {
|
|
1839
|
-
|
|
2332
|
+
opts.beforePersist?.();
|
|
2333
|
+
store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
|
|
1840
2334
|
blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
|
|
1841
2335
|
terminalStatus = "failed";
|
|
1842
2336
|
continue;
|
|
1843
2337
|
}
|
|
1844
|
-
|
|
2338
|
+
opts.beforePersist?.();
|
|
2339
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
|
|
1845
2340
|
if (startedStep.status !== "running") {
|
|
1846
2341
|
terminalStatus = "failed";
|
|
1847
2342
|
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
@@ -1871,9 +2366,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1871
2366
|
try {
|
|
1872
2367
|
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
1873
2368
|
...opts,
|
|
2369
|
+
machine: opts.machine ?? opts.loop?.machine,
|
|
1874
2370
|
signal: controller.signal,
|
|
1875
2371
|
onSpawn: (pid) => {
|
|
1876
|
-
|
|
2372
|
+
opts.beforePersist?.();
|
|
2373
|
+
store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
|
|
1877
2374
|
opts.onSpawn?.(pid);
|
|
1878
2375
|
}
|
|
1879
2376
|
});
|
|
@@ -1901,6 +2398,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1901
2398
|
blockingError = "workflow run was cancelled";
|
|
1902
2399
|
break;
|
|
1903
2400
|
}
|
|
2401
|
+
opts.beforePersist?.();
|
|
1904
2402
|
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1905
2403
|
status: result.status,
|
|
1906
2404
|
finishedAt: result.finishedAt,
|
|
@@ -1909,6 +2407,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1909
2407
|
stderr: result.stderr,
|
|
1910
2408
|
exitCode: result.exitCode,
|
|
1911
2409
|
error: result.error
|
|
2410
|
+
}, {
|
|
2411
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
1912
2412
|
});
|
|
1913
2413
|
if (result.status !== "succeeded" && !step.continueOnFailure) {
|
|
1914
2414
|
terminalStatus = result.status;
|
|
@@ -1920,7 +2420,9 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1920
2420
|
for (const step of ordered) {
|
|
1921
2421
|
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1922
2422
|
if (existing?.status === "pending" || existing?.status === "running") {
|
|
1923
|
-
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run"
|
|
2423
|
+
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
|
|
2424
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
2425
|
+
});
|
|
1924
2426
|
}
|
|
1925
2427
|
}
|
|
1926
2428
|
}
|
|
@@ -1930,10 +2432,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1930
2432
|
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1931
2433
|
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
1932
2434
|
}
|
|
2435
|
+
opts.beforePersist?.();
|
|
1933
2436
|
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
1934
2437
|
finishedAt,
|
|
1935
2438
|
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
1936
2439
|
error: blockingError
|
|
2440
|
+
}, {
|
|
2441
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
1937
2442
|
});
|
|
1938
2443
|
const steps = store.listWorkflowStepRuns(run.id);
|
|
1939
2444
|
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
@@ -1981,52 +2486,81 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
1981
2486
|
|
|
1982
2487
|
// src/lib/scheduler.ts
|
|
1983
2488
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
1984
|
-
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
2489
|
+
if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
1985
2490
|
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
1986
2491
|
}
|
|
1987
2492
|
return now.toISOString();
|
|
1988
2493
|
}
|
|
1989
2494
|
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
2495
|
+
if (loop.status !== "active")
|
|
2496
|
+
return false;
|
|
1990
2497
|
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
1991
2498
|
return false;
|
|
1992
2499
|
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
1993
2500
|
}
|
|
2501
|
+
function manualRunSource(loop, scheduledFor, now = new Date) {
|
|
2502
|
+
if (loop.status !== "active")
|
|
2503
|
+
return "ad_hoc";
|
|
2504
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
2505
|
+
return "ad_hoc";
|
|
2506
|
+
if (loop.retryScheduledFor && scheduledFor === loop.retryScheduledFor)
|
|
2507
|
+
return "retry_slot";
|
|
2508
|
+
return "due_slot";
|
|
2509
|
+
}
|
|
1994
2510
|
function nextAfterRetry(loop, now) {
|
|
1995
2511
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
1996
2512
|
}
|
|
1997
|
-
function
|
|
2513
|
+
function isDaemonLeaseLost(error) {
|
|
2514
|
+
return error instanceof Error && error.message === "daemon lease lost";
|
|
2515
|
+
}
|
|
2516
|
+
function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
|
|
1998
2517
|
if (run.status === "running")
|
|
1999
2518
|
return;
|
|
2000
2519
|
const current = store.getLoop(loop.id);
|
|
2001
2520
|
if (!current || current.status !== "active")
|
|
2002
2521
|
return;
|
|
2003
|
-
|
|
2522
|
+
if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
|
|
2523
|
+
return;
|
|
2524
|
+
const shouldRetry = !succeeded && run.attempt < current.maxAttempts;
|
|
2004
2525
|
if (shouldRetry) {
|
|
2005
|
-
store.updateLoop(
|
|
2526
|
+
store.updateLoop(current.id, {
|
|
2006
2527
|
status: "active",
|
|
2007
|
-
nextRunAt: nextAfterRetry(
|
|
2528
|
+
nextRunAt: nextAfterRetry(current, finishedAt),
|
|
2008
2529
|
retryScheduledFor: run.scheduledFor
|
|
2009
|
-
});
|
|
2530
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
const deferredRetry = store.nextRetryableRun(current.id, current.maxAttempts, run.scheduledFor);
|
|
2534
|
+
if (deferredRetry) {
|
|
2535
|
+
store.updateLoop(current.id, {
|
|
2536
|
+
status: "active",
|
|
2537
|
+
nextRunAt: nextAfterRetry(current, finishedAt),
|
|
2538
|
+
retryScheduledFor: deferredRetry.scheduledFor
|
|
2539
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2010
2540
|
return;
|
|
2011
2541
|
}
|
|
2012
|
-
const nextRunAt = computeNextAfter(
|
|
2013
|
-
store.updateLoop(
|
|
2542
|
+
const nextRunAt = computeNextAfter(current.schedule, new Date(run.scheduledFor), finishedAt);
|
|
2543
|
+
store.updateLoop(current.id, {
|
|
2014
2544
|
status: nextRunAt ? "active" : "stopped",
|
|
2015
2545
|
nextRunAt,
|
|
2016
2546
|
retryScheduledFor: undefined
|
|
2017
|
-
});
|
|
2547
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2018
2548
|
}
|
|
2019
2549
|
async function executeClaimedRun(deps) {
|
|
2020
2550
|
let heartbeat;
|
|
2021
2551
|
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
2022
2552
|
heartbeat = setInterval(() => {
|
|
2023
|
-
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs
|
|
2553
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs, new Date, {
|
|
2554
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2555
|
+
});
|
|
2024
2556
|
}, heartbeatEveryMs);
|
|
2025
2557
|
heartbeat.unref();
|
|
2026
2558
|
try {
|
|
2027
2559
|
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
2028
|
-
|
|
2560
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2561
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId, { daemonLeaseId: deps.daemonLeaseId })
|
|
2029
2562
|
})))(deps.loop, deps.run);
|
|
2563
|
+
deps.beforeFinalize?.(deps.loop, deps.run);
|
|
2030
2564
|
return deps.store.finalizeRun(deps.run.id, {
|
|
2031
2565
|
status: result.status,
|
|
2032
2566
|
finishedAt: result.finishedAt,
|
|
@@ -2038,10 +2572,16 @@ async function executeClaimedRun(deps) {
|
|
|
2038
2572
|
pid: result.pid
|
|
2039
2573
|
}, {
|
|
2040
2574
|
claimedBy: deps.runnerId,
|
|
2575
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2041
2576
|
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
2042
2577
|
});
|
|
2043
2578
|
} catch (err) {
|
|
2044
2579
|
deps.onError?.(deps.loop, err);
|
|
2580
|
+
try {
|
|
2581
|
+
deps.beforeFinalize?.(deps.loop, deps.run);
|
|
2582
|
+
} catch {
|
|
2583
|
+
return deps.store.getRun(deps.run.id) ?? deps.run;
|
|
2584
|
+
}
|
|
2045
2585
|
const finishedAt = new Date;
|
|
2046
2586
|
return deps.store.finalizeRun(deps.run.id, {
|
|
2047
2587
|
status: "failed",
|
|
@@ -2052,6 +2592,7 @@ async function executeClaimedRun(deps) {
|
|
|
2052
2592
|
error: err instanceof Error ? err.message : String(err)
|
|
2053
2593
|
}, {
|
|
2054
2594
|
claimedBy: deps.runnerId,
|
|
2595
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2055
2596
|
now: deps.now?.() ?? finishedAt
|
|
2056
2597
|
});
|
|
2057
2598
|
} finally {
|
|
@@ -2061,15 +2602,33 @@ async function executeClaimedRun(deps) {
|
|
|
2061
2602
|
}
|
|
2062
2603
|
async function runSlot(deps, loop, scheduledFor) {
|
|
2063
2604
|
const now = deps.now?.() ?? new Date;
|
|
2605
|
+
deps.beforeRun?.(loop, scheduledFor);
|
|
2064
2606
|
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2065
|
-
|
|
2066
|
-
|
|
2607
|
+
let skipped;
|
|
2608
|
+
try {
|
|
2609
|
+
skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
|
|
2610
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2611
|
+
});
|
|
2612
|
+
} catch (error) {
|
|
2613
|
+
if (deps.daemonLeaseId && isDaemonLeaseLost(error))
|
|
2614
|
+
return;
|
|
2615
|
+
throw error;
|
|
2616
|
+
}
|
|
2617
|
+
advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
|
|
2067
2618
|
deps.onRun?.(skipped);
|
|
2068
2619
|
return skipped;
|
|
2069
2620
|
}
|
|
2070
|
-
|
|
2621
|
+
let claim;
|
|
2622
|
+
try {
|
|
2623
|
+
claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2624
|
+
} catch (error) {
|
|
2625
|
+
if (deps.daemonLeaseId && isDaemonLeaseLost(error))
|
|
2626
|
+
return;
|
|
2627
|
+
throw error;
|
|
2628
|
+
}
|
|
2071
2629
|
if (!claim)
|
|
2072
2630
|
return;
|
|
2631
|
+
deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
|
|
2073
2632
|
deps.onRun?.(claim.run);
|
|
2074
2633
|
const finalRun = await executeClaimedRun({
|
|
2075
2634
|
store: deps.store,
|
|
@@ -2078,21 +2637,42 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
2078
2637
|
run: claim.run,
|
|
2079
2638
|
now: deps.now,
|
|
2080
2639
|
execute: deps.execute,
|
|
2640
|
+
beforeFinalize: deps.beforeFinalize,
|
|
2641
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2081
2642
|
onError: deps.onError
|
|
2082
2643
|
});
|
|
2083
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2644
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded", { daemonLeaseId: deps.daemonLeaseId });
|
|
2084
2645
|
deps.onRun?.(finalRun);
|
|
2085
2646
|
return finalRun;
|
|
2086
2647
|
}
|
|
2087
2648
|
async function tick(deps) {
|
|
2088
2649
|
const now = deps.now?.() ?? new Date;
|
|
2089
|
-
const recovered = deps.store.recoverExpiredRunLeases(now);
|
|
2650
|
+
const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2651
|
+
const recoveredByLoop = new Map;
|
|
2090
2652
|
for (const run of recovered) {
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2653
|
+
recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
|
|
2654
|
+
}
|
|
2655
|
+
for (const runs of recoveredByLoop.values()) {
|
|
2656
|
+
const loop = deps.store.getLoop(runs[0].loopId);
|
|
2657
|
+
if (!loop)
|
|
2658
|
+
continue;
|
|
2659
|
+
const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
|
|
2660
|
+
if (retryable) {
|
|
2661
|
+
advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
|
|
2662
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2663
|
+
});
|
|
2664
|
+
continue;
|
|
2665
|
+
}
|
|
2666
|
+
for (const run of runs) {
|
|
2667
|
+
const current = deps.store.getLoop(run.loopId);
|
|
2668
|
+
if (current) {
|
|
2669
|
+
advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
|
|
2670
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2671
|
+
});
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2094
2674
|
}
|
|
2095
|
-
const expired = deps.store.expireLoops(now);
|
|
2675
|
+
const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2096
2676
|
const claimed = [];
|
|
2097
2677
|
const completed = [];
|
|
2098
2678
|
const skipped = [];
|
|
@@ -2158,9 +2738,17 @@ class LoopsClient {
|
|
|
2158
2738
|
async runNow(idOrName) {
|
|
2159
2739
|
const loop = this.get(idOrName);
|
|
2160
2740
|
const now = new Date;
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2741
|
+
let scheduledFor = manualRunScheduledFor(loop, now);
|
|
2742
|
+
let shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
|
|
2743
|
+
let claim = this.store.claimRun(loop, scheduledFor, this.runnerId, now);
|
|
2744
|
+
if (!claim && shouldAdvance) {
|
|
2745
|
+
const existing = this.store.getRunBySlot(loop.id, scheduledFor);
|
|
2746
|
+
if (existing && existing.status !== "running") {
|
|
2747
|
+
scheduledFor = now.toISOString();
|
|
2748
|
+
shouldAdvance = false;
|
|
2749
|
+
claim = this.store.claimRun(loop, scheduledFor, this.runnerId, now);
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2164
2752
|
if (!claim)
|
|
2165
2753
|
throw new Error(`could not claim manual run for ${idOrName}`);
|
|
2166
2754
|
const run = await executeClaimedRun({ store: this.store, runnerId: this.runnerId, loop: claim.loop, run: claim.run });
|
|
@@ -2178,7 +2766,7 @@ function loops(opts = {}) {
|
|
|
2178
2766
|
return new LoopsClient(opts);
|
|
2179
2767
|
}
|
|
2180
2768
|
// src/lib/doctor.ts
|
|
2181
|
-
import { spawnSync as
|
|
2769
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2182
2770
|
import { accessSync as accessSync2, constants as constants2 } from "fs";
|
|
2183
2771
|
|
|
2184
2772
|
// src/daemon/control.ts
|
|
@@ -2316,11 +2904,11 @@ var PROVIDER_COMMANDS = [
|
|
|
2316
2904
|
"codex"
|
|
2317
2905
|
];
|
|
2318
2906
|
function hasCommand(command) {
|
|
2319
|
-
const result =
|
|
2907
|
+
const result = spawnSync3("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
|
|
2320
2908
|
return (result.status ?? 1) === 0;
|
|
2321
2909
|
}
|
|
2322
2910
|
function commandVersion(command) {
|
|
2323
|
-
const result =
|
|
2911
|
+
const result = spawnSync3(command, ["--version"], {
|
|
2324
2912
|
encoding: "utf8",
|
|
2325
2913
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2326
2914
|
});
|
|
@@ -2346,6 +2934,23 @@ function runDoctor(store) {
|
|
|
2346
2934
|
checks.push(bunVersion ? { id: "bun", status: "ok", message: "bun is available", detail: bunVersion } : { id: "bun", status: "fail", message: "bun is not available on PATH" });
|
|
2347
2935
|
const accountsVersion = commandVersion("accounts");
|
|
2348
2936
|
checks.push(accountsVersion ? { id: "accounts", status: "ok", message: "accounts is available", detail: accountsVersion } : { id: "accounts", status: "warn", message: "accounts CLI is not available; account-routed steps will fail" });
|
|
2937
|
+
try {
|
|
2938
|
+
const machines = listOpenMachines();
|
|
2939
|
+
const local = machines.find((machine) => machine.local);
|
|
2940
|
+
checks.push({
|
|
2941
|
+
id: "machines",
|
|
2942
|
+
status: "ok",
|
|
2943
|
+
message: `OpenMachines topology available (${machines.length} machine(s))`,
|
|
2944
|
+
detail: local ? `local=${local.id}` : undefined
|
|
2945
|
+
});
|
|
2946
|
+
} catch (error) {
|
|
2947
|
+
checks.push({
|
|
2948
|
+
id: "machines",
|
|
2949
|
+
status: "warn",
|
|
2950
|
+
message: "OpenMachines topology is not available; machine-assigned loops will fail",
|
|
2951
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2349
2954
|
for (const command of PROVIDER_COMMANDS) {
|
|
2350
2955
|
checks.push(hasCommand(command) ? { id: `provider:${command}`, status: "ok", message: `${command} is available` } : { id: `provider:${command}`, status: "warn", message: `${command} is not on PATH` });
|
|
2351
2956
|
}
|
|
@@ -2358,16 +2963,16 @@ function runDoctor(store) {
|
|
|
2358
2963
|
if (loop.target.type === "workflow") {
|
|
2359
2964
|
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
2360
2965
|
for (const step of workflowExecutionOrder(workflow)) {
|
|
2361
|
-
preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id });
|
|
2966
|
+
preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id }, { machine: loop.machine });
|
|
2362
2967
|
}
|
|
2363
2968
|
} else {
|
|
2364
|
-
preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name });
|
|
2969
|
+
preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name }, { machine: loop.machine });
|
|
2365
2970
|
}
|
|
2366
2971
|
checks.push({ id: `loop:${loop.id}:preflight`, status: "ok", message: `active loop target is ready: ${loop.name}` });
|
|
2367
2972
|
} catch (error) {
|
|
2368
2973
|
checks.push({
|
|
2369
2974
|
id: `loop:${loop.id}:preflight`,
|
|
2370
|
-
status: "
|
|
2975
|
+
status: "fail",
|
|
2371
2976
|
message: `active loop target preflight failed: ${loop.name}`,
|
|
2372
2977
|
detail: error instanceof Error ? error.message : String(error)
|
|
2373
2978
|
});
|
|
@@ -2383,12 +2988,15 @@ export {
|
|
|
2383
2988
|
workflowBodyFromJson,
|
|
2384
2989
|
tick,
|
|
2385
2990
|
runDoctor,
|
|
2991
|
+
resolveLoopMachine,
|
|
2992
|
+
refreshLoopMachine,
|
|
2386
2993
|
preflightWorkflow,
|
|
2387
2994
|
preflightTarget,
|
|
2388
2995
|
parseDuration,
|
|
2389
2996
|
parseCron,
|
|
2390
2997
|
nextCronRun,
|
|
2391
2998
|
loops,
|
|
2999
|
+
listOpenMachines,
|
|
2392
3000
|
initialNextRun,
|
|
2393
3001
|
executeWorkflow,
|
|
2394
3002
|
executeTarget,
|