@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/daemon/index.js
CHANGED
|
@@ -347,6 +347,7 @@ function rowToLoop(row) {
|
|
|
347
347
|
status: row.status,
|
|
348
348
|
schedule: JSON.parse(row.schedule_json),
|
|
349
349
|
target: JSON.parse(row.target_json),
|
|
350
|
+
machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
|
|
350
351
|
nextRunAt: row.next_run_at ?? undefined,
|
|
351
352
|
retryScheduledFor: row.retry_scheduled_for ?? undefined,
|
|
352
353
|
catchUp: row.catch_up,
|
|
@@ -489,6 +490,7 @@ class Store {
|
|
|
489
490
|
status TEXT NOT NULL,
|
|
490
491
|
schedule_json TEXT NOT NULL,
|
|
491
492
|
target_json TEXT NOT NULL,
|
|
493
|
+
machine_json TEXT,
|
|
492
494
|
next_run_at TEXT,
|
|
493
495
|
retry_scheduled_for TEXT,
|
|
494
496
|
catch_up TEXT NOT NULL,
|
|
@@ -610,10 +612,21 @@ class Store {
|
|
|
610
612
|
);
|
|
611
613
|
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
|
|
612
614
|
`);
|
|
615
|
+
try {
|
|
616
|
+
this.db.query("ALTER TABLE loops ADD COLUMN machine_json TEXT").run();
|
|
617
|
+
} catch {}
|
|
613
618
|
try {
|
|
614
619
|
this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
|
|
615
620
|
} catch {}
|
|
616
621
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
622
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
|
|
623
|
+
}
|
|
624
|
+
assertDaemonLeaseFence(opts = {}, now = nowIso()) {
|
|
625
|
+
if (!opts.daemonLeaseId)
|
|
626
|
+
return;
|
|
627
|
+
const row = this.db.query("SELECT id FROM daemon_lease WHERE id = ? AND expires_at > ?").get(opts.daemonLeaseId, now);
|
|
628
|
+
if (!row)
|
|
629
|
+
throw new Error("daemon lease lost");
|
|
617
630
|
}
|
|
618
631
|
createLoop(input, from = new Date) {
|
|
619
632
|
const now = nowIso();
|
|
@@ -624,6 +637,7 @@ class Store {
|
|
|
624
637
|
status: "active",
|
|
625
638
|
schedule: input.schedule,
|
|
626
639
|
target: input.target,
|
|
640
|
+
machine: input.machine,
|
|
627
641
|
nextRunAt: initialNextRun(input.schedule, from),
|
|
628
642
|
catchUp: input.catchUp ?? "latest",
|
|
629
643
|
catchUpLimit: input.catchUpLimit ?? 50,
|
|
@@ -635,9 +649,9 @@ class Store {
|
|
|
635
649
|
createdAt: now,
|
|
636
650
|
updatedAt: now
|
|
637
651
|
};
|
|
638
|
-
this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, next_run_at, retry_scheduled_for,
|
|
652
|
+
this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
|
|
639
653
|
catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
|
|
640
|
-
VALUES ($id, $name, $description, $status, $schedule, $target, $nextRun, NULL, $catchUp, $catchUpLimit,
|
|
654
|
+
VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $catchUp, $catchUpLimit,
|
|
641
655
|
$overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
|
|
642
656
|
$id: loop.id,
|
|
643
657
|
$name: loop.name,
|
|
@@ -645,6 +659,7 @@ class Store {
|
|
|
645
659
|
$status: loop.status,
|
|
646
660
|
$schedule: JSON.stringify(loop.schedule),
|
|
647
661
|
$target: JSON.stringify(loop.target),
|
|
662
|
+
$machine: loop.machine ? JSON.stringify(loop.machine) : null,
|
|
648
663
|
$nextRun: loop.nextRunAt ?? null,
|
|
649
664
|
$catchUp: loop.catchUp,
|
|
650
665
|
$catchUpLimit: loop.catchUpLimit,
|
|
@@ -684,21 +699,31 @@ class Store {
|
|
|
684
699
|
ORDER BY next_run_at ASC`).all(now.toISOString());
|
|
685
700
|
return rows.map(rowToLoop);
|
|
686
701
|
}
|
|
687
|
-
updateLoop(id, patch) {
|
|
702
|
+
updateLoop(id, patch, opts = {}) {
|
|
688
703
|
const current = this.getLoop(id);
|
|
689
704
|
if (!current)
|
|
690
705
|
throw new Error(`loop not found: ${id}`);
|
|
691
|
-
const
|
|
706
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
707
|
+
const merged = { ...current, ...patch, updatedAt: updated };
|
|
692
708
|
this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
|
|
693
|
-
expires_at=$expiresAt, updated_at=$updated
|
|
709
|
+
expires_at=$expiresAt, updated_at=$updated
|
|
710
|
+
WHERE id=$id
|
|
711
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
712
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
713
|
+
))`).run({
|
|
694
714
|
$id: id,
|
|
695
715
|
$status: merged.status,
|
|
696
716
|
$nextRun: merged.nextRunAt ?? null,
|
|
697
717
|
$retrySlot: merged.retryScheduledFor ?? null,
|
|
698
718
|
$expiresAt: merged.expiresAt ?? null,
|
|
699
|
-
$updated: merged.updatedAt
|
|
719
|
+
$updated: merged.updatedAt,
|
|
720
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
721
|
+
$now: updated
|
|
700
722
|
});
|
|
701
|
-
|
|
723
|
+
const after = this.getLoop(id);
|
|
724
|
+
if (!after)
|
|
725
|
+
throw new Error(`loop not found after update: ${id}`);
|
|
726
|
+
return after;
|
|
702
727
|
}
|
|
703
728
|
deleteLoop(idOrName) {
|
|
704
729
|
const loop = this.requireLoop(idOrName);
|
|
@@ -762,11 +787,14 @@ class Store {
|
|
|
762
787
|
const now = nowIso();
|
|
763
788
|
if (input.idempotencyKey) {
|
|
764
789
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
765
|
-
if (existing)
|
|
790
|
+
if (existing) {
|
|
791
|
+
this.assertDaemonLeaseFence(input);
|
|
766
792
|
return rowToWorkflowRun(existing);
|
|
793
|
+
}
|
|
767
794
|
}
|
|
768
795
|
this.db.exec("BEGIN IMMEDIATE");
|
|
769
796
|
try {
|
|
797
|
+
this.assertDaemonLeaseFence(input, now);
|
|
770
798
|
if (input.idempotencyKey) {
|
|
771
799
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
772
800
|
if (existing) {
|
|
@@ -865,31 +893,60 @@ class Store {
|
|
|
865
893
|
const run = this.getWorkflowRun(workflowRunId);
|
|
866
894
|
return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
|
|
867
895
|
}
|
|
868
|
-
startWorkflowStepRun(workflowRunId, stepId) {
|
|
869
|
-
const now =
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
896
|
+
startWorkflowStepRun(workflowRunId, stepId, opts = {}) {
|
|
897
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
898
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
899
|
+
try {
|
|
900
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
901
|
+
SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
|
|
902
|
+
pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
903
|
+
WHERE workflow_run_id=$workflowRunId
|
|
904
|
+
AND step_id=$stepId
|
|
905
|
+
AND status IN ('pending', 'failed', 'timed_out')
|
|
906
|
+
AND EXISTS (
|
|
907
|
+
SELECT 1 FROM workflow_runs
|
|
908
|
+
WHERE id=$workflowRunId AND status='running'
|
|
909
|
+
)
|
|
910
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
911
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
912
|
+
))`).run({
|
|
913
|
+
$workflowRunId: workflowRunId,
|
|
914
|
+
$stepId: stepId,
|
|
915
|
+
$started: now,
|
|
916
|
+
$updated: now,
|
|
917
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
918
|
+
$now: now
|
|
919
|
+
});
|
|
920
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
921
|
+
if (!run)
|
|
922
|
+
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
923
|
+
if (res.changes !== 1) {
|
|
924
|
+
throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
|
|
925
|
+
}
|
|
926
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
927
|
+
this.db.exec("COMMIT");
|
|
928
|
+
return run;
|
|
929
|
+
} catch (error) {
|
|
930
|
+
try {
|
|
931
|
+
this.db.exec("ROLLBACK");
|
|
932
|
+
} catch {}
|
|
933
|
+
throw error;
|
|
885
934
|
}
|
|
886
|
-
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
887
|
-
return run;
|
|
888
935
|
}
|
|
889
|
-
markWorkflowStepPid(workflowRunId, stepId, pid) {
|
|
890
|
-
const now =
|
|
936
|
+
markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
|
|
937
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
891
938
|
this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
|
|
892
|
-
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
939
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
940
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
941
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
942
|
+
))`).run({
|
|
943
|
+
$workflowRunId: workflowRunId,
|
|
944
|
+
$stepId: stepId,
|
|
945
|
+
$pid: pid,
|
|
946
|
+
$updated: now,
|
|
947
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
948
|
+
$now: now
|
|
949
|
+
});
|
|
893
950
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
894
951
|
if (!run)
|
|
895
952
|
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
@@ -917,60 +974,110 @@ class Store {
|
|
|
917
974
|
recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
|
|
918
975
|
};
|
|
919
976
|
}
|
|
920
|
-
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
977
|
+
finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
|
|
921
978
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
979
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
980
|
+
try {
|
|
981
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
982
|
+
pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
983
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
984
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
985
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
986
|
+
))`).run({
|
|
987
|
+
$workflowRunId: workflowRunId,
|
|
988
|
+
$stepId: stepId,
|
|
989
|
+
$status: patch.status,
|
|
990
|
+
$finished: finishedAt,
|
|
991
|
+
$exitCode: patch.exitCode ?? null,
|
|
992
|
+
$durationMs: patch.durationMs ?? null,
|
|
993
|
+
$stdout: patch.stdout ?? null,
|
|
994
|
+
$stderr: patch.stderr ?? null,
|
|
995
|
+
$error: patch.error ?? null,
|
|
996
|
+
$updated: finishedAt,
|
|
997
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
998
|
+
$now: (opts.now ?? new Date(finishedAt)).toISOString()
|
|
940
999
|
});
|
|
1000
|
+
if (res.changes === 1) {
|
|
1001
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
1002
|
+
exitCode: patch.exitCode,
|
|
1003
|
+
error: patch.error
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
this.db.exec("COMMIT");
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
try {
|
|
1009
|
+
this.db.exec("ROLLBACK");
|
|
1010
|
+
} catch {}
|
|
1011
|
+
throw error;
|
|
941
1012
|
}
|
|
942
1013
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
943
1014
|
if (!run)
|
|
944
1015
|
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
945
1016
|
return run;
|
|
946
1017
|
}
|
|
947
|
-
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
948
|
-
const now =
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1018
|
+
skipWorkflowStepRun(workflowRunId, stepId, reason, opts = {}) {
|
|
1019
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1020
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1021
|
+
try {
|
|
1022
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
|
|
1023
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')
|
|
1024
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1025
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1026
|
+
))`).run({
|
|
1027
|
+
$workflowRunId: workflowRunId,
|
|
1028
|
+
$stepId: stepId,
|
|
1029
|
+
$finished: now,
|
|
1030
|
+
$error: reason,
|
|
1031
|
+
$updated: now,
|
|
1032
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1033
|
+
$now: now
|
|
1034
|
+
});
|
|
1035
|
+
if (res.changes === 1)
|
|
1036
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
1037
|
+
this.db.exec("COMMIT");
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
try {
|
|
1040
|
+
this.db.exec("ROLLBACK");
|
|
1041
|
+
} catch {}
|
|
1042
|
+
throw error;
|
|
1043
|
+
}
|
|
953
1044
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
954
1045
|
if (!run)
|
|
955
1046
|
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
956
1047
|
return run;
|
|
957
1048
|
}
|
|
958
|
-
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
1049
|
+
finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
|
|
959
1050
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1051
|
+
let changed = false;
|
|
1052
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1053
|
+
try {
|
|
1054
|
+
const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
1055
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1056
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1057
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1058
|
+
))`).run({
|
|
1059
|
+
$id: workflowRunId,
|
|
1060
|
+
$status: status,
|
|
1061
|
+
$finished: finishedAt,
|
|
1062
|
+
$durationMs: patch.durationMs ?? null,
|
|
1063
|
+
$error: patch.error ?? null,
|
|
1064
|
+
$updated: finishedAt,
|
|
1065
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1066
|
+
$now: (opts.now ?? new Date(finishedAt)).toISOString()
|
|
1067
|
+
});
|
|
1068
|
+
changed = res.changes === 1;
|
|
1069
|
+
if (changed)
|
|
1070
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
1071
|
+
this.db.exec("COMMIT");
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
try {
|
|
1074
|
+
this.db.exec("ROLLBACK");
|
|
1075
|
+
} catch {}
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
969
1078
|
const run = this.getWorkflowRun(workflowRunId);
|
|
970
1079
|
if (!run)
|
|
971
1080
|
throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
|
|
972
|
-
if (res.changes === 1)
|
|
973
|
-
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
974
1081
|
return run;
|
|
975
1082
|
}
|
|
976
1083
|
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
@@ -1024,10 +1131,24 @@ class Store {
|
|
|
1024
1131
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
1025
1132
|
return (row?.count ?? 0) > 0;
|
|
1026
1133
|
}
|
|
1027
|
-
markRunPid(id, pid, claimedBy) {
|
|
1028
|
-
const now =
|
|
1134
|
+
markRunPid(id, pid, claimedBy, opts = {}) {
|
|
1135
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1029
1136
|
const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1030
|
-
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1137
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1138
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1139
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1140
|
+
))`).run({
|
|
1141
|
+
$id: id,
|
|
1142
|
+
$pid: pid,
|
|
1143
|
+
$updated: now,
|
|
1144
|
+
$claimedBy: claimedBy,
|
|
1145
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1146
|
+
$now: now
|
|
1147
|
+
}) : this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1148
|
+
WHERE id=$id AND status='running'
|
|
1149
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1150
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1151
|
+
))`).run({ $id: id, $pid: pid, $updated: now, $daemonLeaseId: opts.daemonLeaseId ?? null, $now: now });
|
|
1031
1152
|
if (res.changes !== 1)
|
|
1032
1153
|
return;
|
|
1033
1154
|
return this.getRun(id);
|
|
@@ -1042,7 +1163,7 @@ class Store {
|
|
|
1042
1163
|
AND wsr.pid IS NOT NULL`).all(loopRunId);
|
|
1043
1164
|
return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
|
|
1044
1165
|
}
|
|
1045
|
-
createSkippedRun(loop, scheduledFor, reason) {
|
|
1166
|
+
createSkippedRun(loop, scheduledFor, reason, opts = {}) {
|
|
1046
1167
|
const now = nowIso();
|
|
1047
1168
|
const run = {
|
|
1048
1169
|
id: genId(),
|
|
@@ -1056,21 +1177,31 @@ class Store {
|
|
|
1056
1177
|
createdAt: now,
|
|
1057
1178
|
updatedAt: now
|
|
1058
1179
|
};
|
|
1059
|
-
this.db.
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1180
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1181
|
+
try {
|
|
1182
|
+
this.assertDaemonLeaseFence(opts, now);
|
|
1183
|
+
this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
|
|
1184
|
+
claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
|
|
1185
|
+
VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
|
|
1186
|
+
NULL, NULL, $error, $created, $updated)`).run({
|
|
1187
|
+
$id: run.id,
|
|
1188
|
+
$loopId: run.loopId,
|
|
1189
|
+
$loopName: run.loopName,
|
|
1190
|
+
$scheduledFor: run.scheduledFor,
|
|
1191
|
+
$attempt: run.attempt,
|
|
1192
|
+
$status: run.status,
|
|
1193
|
+
$finished: run.finishedAt ?? null,
|
|
1194
|
+
$error: run.error ?? null,
|
|
1195
|
+
$created: run.createdAt,
|
|
1196
|
+
$updated: run.updatedAt
|
|
1197
|
+
});
|
|
1198
|
+
this.db.exec("COMMIT");
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
try {
|
|
1201
|
+
this.db.exec("ROLLBACK");
|
|
1202
|
+
} catch {}
|
|
1203
|
+
throw error;
|
|
1204
|
+
}
|
|
1074
1205
|
return this.getRunBySlot(loop.id, scheduledFor) ?? run;
|
|
1075
1206
|
}
|
|
1076
1207
|
getRun(id) {
|
|
@@ -1081,11 +1212,20 @@ class Store {
|
|
|
1081
1212
|
const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
|
|
1082
1213
|
return row ? rowToRun(row) : undefined;
|
|
1083
1214
|
}
|
|
1084
|
-
|
|
1215
|
+
nextRetryableRun(loopId, maxAttempts, afterScheduledFor) {
|
|
1216
|
+
const row = afterScheduledFor ? this.db.query(`SELECT * FROM loop_runs
|
|
1217
|
+
WHERE loop_id = ? AND scheduled_for > ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
|
|
1218
|
+
ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, afterScheduledFor, maxAttempts) : this.db.query(`SELECT * FROM loop_runs
|
|
1219
|
+
WHERE loop_id = ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
|
|
1220
|
+
ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, maxAttempts);
|
|
1221
|
+
return row ? rowToRun(row) : undefined;
|
|
1222
|
+
}
|
|
1223
|
+
claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
|
|
1085
1224
|
const startedAt = now.toISOString();
|
|
1086
1225
|
const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
|
|
1087
1226
|
this.db.exec("BEGIN IMMEDIATE");
|
|
1088
1227
|
try {
|
|
1228
|
+
this.assertDaemonLeaseFence(opts, startedAt);
|
|
1089
1229
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
1090
1230
|
if (existing) {
|
|
1091
1231
|
if (existing.status === "running") {
|
|
@@ -1180,11 +1320,15 @@ class Store {
|
|
|
1180
1320
|
$error: patch.error ?? null,
|
|
1181
1321
|
$updated: finishedAt,
|
|
1182
1322
|
$claimedBy: opts.claimedBy ?? null,
|
|
1183
|
-
$now: (opts.now ?? new Date).toISOString()
|
|
1323
|
+
$now: (opts.now ?? new Date).toISOString(),
|
|
1324
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1184
1325
|
};
|
|
1185
1326
|
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,
|
|
1186
1327
|
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
1187
|
-
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1328
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1329
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1330
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1331
|
+
))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1188
1332
|
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
|
|
1189
1333
|
const run = this.getRun(id);
|
|
1190
1334
|
if (!run)
|
|
@@ -1193,10 +1337,20 @@ class Store {
|
|
|
1193
1337
|
return run;
|
|
1194
1338
|
return run;
|
|
1195
1339
|
}
|
|
1196
|
-
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
|
|
1340
|
+
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
1197
1341
|
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
1198
1342
|
const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
|
|
1199
|
-
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1343
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1344
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1345
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1346
|
+
))`).run({
|
|
1347
|
+
$id: id,
|
|
1348
|
+
$claimedBy: claimedBy,
|
|
1349
|
+
$expires: expiresAt,
|
|
1350
|
+
$updated: now.toISOString(),
|
|
1351
|
+
$now: now.toISOString(),
|
|
1352
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1353
|
+
});
|
|
1200
1354
|
if (res.changes !== 1)
|
|
1201
1355
|
return;
|
|
1202
1356
|
return this.getRun(id);
|
|
@@ -1215,7 +1369,7 @@ class Store {
|
|
|
1215
1369
|
}
|
|
1216
1370
|
return rows.map(rowToRun);
|
|
1217
1371
|
}
|
|
1218
|
-
recoverExpiredRunLeases(now = new Date) {
|
|
1372
|
+
recoverExpiredRunLeases(now = new Date, opts = {}) {
|
|
1219
1373
|
const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
|
|
1220
1374
|
const recovered = [];
|
|
1221
1375
|
for (const row of rows) {
|
|
@@ -1224,20 +1378,63 @@ class Store {
|
|
|
1224
1378
|
if (this.hasLiveWorkflowStepProcesses(row.id))
|
|
1225
1379
|
continue;
|
|
1226
1380
|
const finished = now.toISOString();
|
|
1227
|
-
this.db.
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1381
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1382
|
+
try {
|
|
1383
|
+
const res = this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
|
|
1384
|
+
error='run lease expired before completion', updated_at=$updated
|
|
1385
|
+
WHERE id=$id AND status='running' AND lease_expires_at <= $now
|
|
1386
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1387
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1388
|
+
))`).run({
|
|
1389
|
+
$id: row.id,
|
|
1390
|
+
$finished: finished,
|
|
1391
|
+
$updated: finished,
|
|
1392
|
+
$now: finished,
|
|
1393
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1240
1394
|
});
|
|
1395
|
+
if (res.changes !== 1) {
|
|
1396
|
+
this.db.exec("COMMIT");
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
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);
|
|
1400
|
+
for (const workflowRow of workflowRows) {
|
|
1401
|
+
const workflowRes = this.db.query(`UPDATE workflow_runs
|
|
1402
|
+
SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1403
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1404
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1405
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1406
|
+
))`).run({
|
|
1407
|
+
$id: workflowRow.id,
|
|
1408
|
+
$finished: finished,
|
|
1409
|
+
$updated: finished,
|
|
1410
|
+
$now: finished,
|
|
1411
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1412
|
+
});
|
|
1413
|
+
if (workflowRes.changes !== 1)
|
|
1414
|
+
continue;
|
|
1415
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
1416
|
+
SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1417
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')
|
|
1418
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1419
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1420
|
+
))`).run({
|
|
1421
|
+
$workflowRunId: workflowRow.id,
|
|
1422
|
+
$finished: finished,
|
|
1423
|
+
$updated: finished,
|
|
1424
|
+
$now: finished,
|
|
1425
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1426
|
+
});
|
|
1427
|
+
this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
|
|
1428
|
+
error: "parent loop run lease expired before completion",
|
|
1429
|
+
loopRunId: row.id
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
this.db.exec("COMMIT");
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
try {
|
|
1435
|
+
this.db.exec("ROLLBACK");
|
|
1436
|
+
} catch {}
|
|
1437
|
+
throw error;
|
|
1241
1438
|
}
|
|
1242
1439
|
const run = this.getRun(row.id);
|
|
1243
1440
|
if (run)
|
|
@@ -1245,11 +1442,14 @@ class Store {
|
|
|
1245
1442
|
}
|
|
1246
1443
|
return recovered;
|
|
1247
1444
|
}
|
|
1248
|
-
expireLoops(now = new Date) {
|
|
1445
|
+
expireLoops(now = new Date, opts = {}) {
|
|
1249
1446
|
const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
|
|
1250
1447
|
const expired = [];
|
|
1251
|
-
for (const row of rows)
|
|
1252
|
-
|
|
1448
|
+
for (const row of rows) {
|
|
1449
|
+
const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
|
|
1450
|
+
if (updated.status === "expired")
|
|
1451
|
+
expired.push(updated);
|
|
1452
|
+
}
|
|
1253
1453
|
return expired;
|
|
1254
1454
|
}
|
|
1255
1455
|
countLoops(status) {
|
|
@@ -1292,7 +1492,7 @@ class Store {
|
|
|
1292
1492
|
}
|
|
1293
1493
|
heartbeatDaemonLease(id, ttlMs, now = new Date) {
|
|
1294
1494
|
const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
|
|
1295
|
-
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() });
|
|
1495
|
+
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() });
|
|
1296
1496
|
if (res.changes !== 1)
|
|
1297
1497
|
return;
|
|
1298
1498
|
return this.getDaemonLease();
|
|
@@ -1318,8 +1518,9 @@ import { hostname as hostname2 } from "os";
|
|
|
1318
1518
|
import { spawn as spawn2 } from "child_process";
|
|
1319
1519
|
|
|
1320
1520
|
// src/lib/executor.ts
|
|
1321
|
-
import { spawn } from "child_process";
|
|
1521
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
1322
1522
|
import { once } from "events";
|
|
1523
|
+
import { resolveMachineCommand } from "@hasna/machines/consumer";
|
|
1323
1524
|
|
|
1324
1525
|
// src/lib/accounts.ts
|
|
1325
1526
|
import { spawnSync } from "child_process";
|
|
@@ -1476,6 +1677,59 @@ function commandNotFoundMessage(command, env = process.env) {
|
|
|
1476
1677
|
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
1477
1678
|
}
|
|
1478
1679
|
|
|
1680
|
+
// src/lib/machines.ts
|
|
1681
|
+
import {
|
|
1682
|
+
discoverMachineTopology,
|
|
1683
|
+
resolveMachineRoute
|
|
1684
|
+
} from "@hasna/machines/consumer";
|
|
1685
|
+
function compact(value) {
|
|
1686
|
+
const text = value?.trim();
|
|
1687
|
+
return text ? text : undefined;
|
|
1688
|
+
}
|
|
1689
|
+
function entryToSummary(entry, topology) {
|
|
1690
|
+
return {
|
|
1691
|
+
id: entry.machine_id,
|
|
1692
|
+
hostname: compact(entry.hostname),
|
|
1693
|
+
platform: compact(entry.platform),
|
|
1694
|
+
user: compact(entry.user),
|
|
1695
|
+
workspacePath: compact(entry.workspace_path),
|
|
1696
|
+
route: entry.ssh.route,
|
|
1697
|
+
local: entry.machine_id === topology.local_machine_id || entry.ssh.route === "local",
|
|
1698
|
+
heartbeatStatus: entry.heartbeat_status,
|
|
1699
|
+
tailscaleOnline: entry.tailscale.online,
|
|
1700
|
+
tags: entry.tags
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
function machineFromRoute(route, topology) {
|
|
1704
|
+
if (!route.ok || !route.machine_id) {
|
|
1705
|
+
throw new Error(`OpenMachines route not found for machine: ${route.requested_machine_id}`);
|
|
1706
|
+
}
|
|
1707
|
+
const entry = topology.machines.find((machine) => machine.machine_id === route.machine_id);
|
|
1708
|
+
return {
|
|
1709
|
+
id: route.machine_id,
|
|
1710
|
+
requestedId: route.requested_machine_id !== route.machine_id ? route.requested_machine_id : undefined,
|
|
1711
|
+
route: route.route,
|
|
1712
|
+
local: route.local,
|
|
1713
|
+
confidence: route.confidence,
|
|
1714
|
+
workspacePath: compact(entry?.workspace_path),
|
|
1715
|
+
resolvedAt: route.generated_at,
|
|
1716
|
+
packageVersion: route.package.version,
|
|
1717
|
+
warnings: route.warnings.length ? route.warnings : undefined
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
function listOpenMachines() {
|
|
1721
|
+
const topology = discoverMachineTopology();
|
|
1722
|
+
return topology.machines.map((entry) => entryToSummary(entry, topology));
|
|
1723
|
+
}
|
|
1724
|
+
function resolveLoopMachine(machineId) {
|
|
1725
|
+
const topology = discoverMachineTopology();
|
|
1726
|
+
const route = resolveMachineRoute(machineId, { topology });
|
|
1727
|
+
return machineFromRoute(route, topology);
|
|
1728
|
+
}
|
|
1729
|
+
function refreshLoopMachine(machine) {
|
|
1730
|
+
return resolveLoopMachine(machine.id);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1479
1733
|
// src/lib/executor.ts
|
|
1480
1734
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
1481
1735
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
@@ -1496,6 +1750,23 @@ var AUTH_ENV_KEYS = [
|
|
|
1496
1750
|
"XDG_STATE_HOME",
|
|
1497
1751
|
"XDG_CACHE_HOME"
|
|
1498
1752
|
];
|
|
1753
|
+
var TRANSPORT_ENV_KEYS = new Set([
|
|
1754
|
+
"BUN_INSTALL",
|
|
1755
|
+
"HOME",
|
|
1756
|
+
"LANG",
|
|
1757
|
+
"LANGUAGE",
|
|
1758
|
+
"LOGNAME",
|
|
1759
|
+
"PATH",
|
|
1760
|
+
"SHELL",
|
|
1761
|
+
"SSH_AGENT_PID",
|
|
1762
|
+
"SSH_AUTH_SOCK",
|
|
1763
|
+
"TERM",
|
|
1764
|
+
"TMP",
|
|
1765
|
+
"TMPDIR",
|
|
1766
|
+
"TEMP",
|
|
1767
|
+
"USER",
|
|
1768
|
+
"XDG_RUNTIME_DIR"
|
|
1769
|
+
]);
|
|
1499
1770
|
function appendBounded(current, chunk, maxBytes) {
|
|
1500
1771
|
const next = current + chunk.toString("utf8");
|
|
1501
1772
|
if (Buffer.byteLength(next, "utf8") <= maxBytes)
|
|
@@ -1522,6 +1793,29 @@ function killProcessGroup(pid) {
|
|
|
1522
1793
|
}
|
|
1523
1794
|
}, 2000).unref();
|
|
1524
1795
|
}
|
|
1796
|
+
function shellQuote(value) {
|
|
1797
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1798
|
+
}
|
|
1799
|
+
function metadataEnv(metadata) {
|
|
1800
|
+
const env = {};
|
|
1801
|
+
if (metadata.loopId)
|
|
1802
|
+
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1803
|
+
if (metadata.loopName)
|
|
1804
|
+
env.LOOPS_LOOP_NAME = metadata.loopName;
|
|
1805
|
+
if (metadata.runId)
|
|
1806
|
+
env.LOOPS_RUN_ID = metadata.runId;
|
|
1807
|
+
if (metadata.scheduledFor)
|
|
1808
|
+
env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
|
|
1809
|
+
if (metadata.workflowId)
|
|
1810
|
+
env.LOOPS_WORKFLOW_ID = metadata.workflowId;
|
|
1811
|
+
if (metadata.workflowName)
|
|
1812
|
+
env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
|
|
1813
|
+
if (metadata.workflowRunId)
|
|
1814
|
+
env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
|
|
1815
|
+
if (metadata.workflowStepId)
|
|
1816
|
+
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1817
|
+
return env;
|
|
1818
|
+
}
|
|
1525
1819
|
function providerCommand(provider) {
|
|
1526
1820
|
switch (provider) {
|
|
1527
1821
|
case "claude":
|
|
@@ -1550,7 +1844,7 @@ function agentArgs(target) {
|
|
|
1550
1844
|
args.push("--model", target.model);
|
|
1551
1845
|
if (target.agent)
|
|
1552
1846
|
args.push("--agent", target.agent);
|
|
1553
|
-
args.push(...target.extraArgs ?? []
|
|
1847
|
+
args.push(...target.extraArgs ?? []);
|
|
1554
1848
|
return args;
|
|
1555
1849
|
case "cursor":
|
|
1556
1850
|
args.push("-p");
|
|
@@ -1558,7 +1852,7 @@ function agentArgs(target) {
|
|
|
1558
1852
|
args.push("--model", target.model);
|
|
1559
1853
|
if (target.agent)
|
|
1560
1854
|
args.push("--agent", target.agent);
|
|
1561
|
-
args.push(...target.extraArgs ?? []
|
|
1855
|
+
args.push(...target.extraArgs ?? []);
|
|
1562
1856
|
return args;
|
|
1563
1857
|
case "codewith":
|
|
1564
1858
|
args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
|
|
@@ -1570,7 +1864,7 @@ function agentArgs(target) {
|
|
|
1570
1864
|
args.push("--model", target.model);
|
|
1571
1865
|
if (target.agent)
|
|
1572
1866
|
args.push("--agent", target.agent);
|
|
1573
|
-
args.push(...target.extraArgs ?? []
|
|
1867
|
+
args.push(...target.extraArgs ?? []);
|
|
1574
1868
|
return args;
|
|
1575
1869
|
case "codex":
|
|
1576
1870
|
args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
|
|
@@ -1580,7 +1874,7 @@ function agentArgs(target) {
|
|
|
1580
1874
|
args.push("--cd", target.cwd);
|
|
1581
1875
|
if (target.model)
|
|
1582
1876
|
args.push("--model", target.model);
|
|
1583
|
-
args.push(...target.extraArgs ?? []
|
|
1877
|
+
args.push(...target.extraArgs ?? []);
|
|
1584
1878
|
return args;
|
|
1585
1879
|
case "aicopilot":
|
|
1586
1880
|
args.push("run", "--format", "json");
|
|
@@ -1592,7 +1886,7 @@ function agentArgs(target) {
|
|
|
1592
1886
|
args.push("--model", target.model);
|
|
1593
1887
|
if (target.agent)
|
|
1594
1888
|
args.push("--agent", target.agent);
|
|
1595
|
-
args.push(...target.extraArgs ?? []
|
|
1889
|
+
args.push(...target.extraArgs ?? []);
|
|
1596
1890
|
return args;
|
|
1597
1891
|
case "opencode":
|
|
1598
1892
|
args.push("run", "--format", "json");
|
|
@@ -1604,7 +1898,7 @@ function agentArgs(target) {
|
|
|
1604
1898
|
args.push("--model", target.model);
|
|
1605
1899
|
if (target.agent)
|
|
1606
1900
|
args.push("--agent", target.agent);
|
|
1607
|
-
args.push(...target.extraArgs ?? []
|
|
1901
|
+
args.push(...target.extraArgs ?? []);
|
|
1608
1902
|
return args;
|
|
1609
1903
|
}
|
|
1610
1904
|
}
|
|
@@ -1629,7 +1923,8 @@ function commandSpec(target) {
|
|
|
1629
1923
|
cwd: agentTarget.cwd,
|
|
1630
1924
|
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
1631
1925
|
account: agentTarget.account,
|
|
1632
|
-
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
|
|
1926
|
+
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
1927
|
+
stdin: agentTarget.prompt
|
|
1633
1928
|
};
|
|
1634
1929
|
}
|
|
1635
1930
|
function executionEnv(spec, metadata, opts) {
|
|
@@ -1642,26 +1937,213 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1642
1937
|
}
|
|
1643
1938
|
Object.assign(env, spec.env ?? {});
|
|
1644
1939
|
env.PATH = normalizeExecutionPath(env);
|
|
1645
|
-
|
|
1646
|
-
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1647
|
-
if (metadata.loopName)
|
|
1648
|
-
env.LOOPS_LOOP_NAME = metadata.loopName;
|
|
1649
|
-
if (metadata.runId)
|
|
1650
|
-
env.LOOPS_RUN_ID = metadata.runId;
|
|
1651
|
-
if (metadata.scheduledFor)
|
|
1652
|
-
env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
|
|
1653
|
-
if (metadata.workflowId)
|
|
1654
|
-
env.LOOPS_WORKFLOW_ID = metadata.workflowId;
|
|
1655
|
-
if (metadata.workflowName)
|
|
1656
|
-
env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
|
|
1657
|
-
if (metadata.workflowRunId)
|
|
1658
|
-
env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
|
|
1659
|
-
if (metadata.workflowStepId)
|
|
1660
|
-
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1940
|
+
Object.assign(env, metadataEnv(metadata));
|
|
1661
1941
|
return env;
|
|
1662
1942
|
}
|
|
1943
|
+
function resolvedMachine(opts) {
|
|
1944
|
+
if (!opts.machine)
|
|
1945
|
+
return;
|
|
1946
|
+
return (opts.machineResolver ?? refreshLoopMachine)(opts.machine);
|
|
1947
|
+
}
|
|
1948
|
+
function commandForShell(spec) {
|
|
1949
|
+
if (!spec.args.length)
|
|
1950
|
+
return spec.command;
|
|
1951
|
+
return [spec.command, ...spec.args.map(shellQuote)].join(" ");
|
|
1952
|
+
}
|
|
1953
|
+
function hereDoc(value) {
|
|
1954
|
+
let delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
|
|
1955
|
+
while (value.split(/\r?\n/).includes(delimiter2)) {
|
|
1956
|
+
delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
|
|
1957
|
+
}
|
|
1958
|
+
return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
|
|
1959
|
+
}
|
|
1960
|
+
function remoteBootstrapLines(spec, metadata) {
|
|
1961
|
+
const lines = [
|
|
1962
|
+
"set -e",
|
|
1963
|
+
'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}"'
|
|
1964
|
+
];
|
|
1965
|
+
if (spec.cwd)
|
|
1966
|
+
lines.push(`cd ${shellQuote(spec.cwd)}`);
|
|
1967
|
+
if (spec.account) {
|
|
1968
|
+
if (!spec.accountTool)
|
|
1969
|
+
throw new Error("account.tool is required when no provider tool can be inferred");
|
|
1970
|
+
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)}`);
|
|
1971
|
+
}
|
|
1972
|
+
for (const [key, value] of Object.entries({ ...metadataEnv(metadata), ...spec.env ?? {} })) {
|
|
1973
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
1974
|
+
continue;
|
|
1975
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
1976
|
+
}
|
|
1977
|
+
return lines;
|
|
1978
|
+
}
|
|
1979
|
+
function remoteScript(spec, metadata) {
|
|
1980
|
+
const lines = remoteBootstrapLines(spec, metadata);
|
|
1981
|
+
let stdinRedirect = "";
|
|
1982
|
+
if (spec.stdin !== undefined) {
|
|
1983
|
+
lines.push('__OPENLOOPS_STDIN="$(mktemp -t openloops-stdin.XXXXXX)"', `trap 'rm -f "$__OPENLOOPS_STDIN"' EXIT`);
|
|
1984
|
+
lines.push(...hereDoc(spec.stdin));
|
|
1985
|
+
stdinRedirect = ' < "$__OPENLOOPS_STDIN"';
|
|
1986
|
+
}
|
|
1987
|
+
const invocation = spec.shell ? `sh -lc ${shellQuote(commandForShell(spec))}${stdinRedirect}` : `${[spec.command, ...spec.args].map(shellQuote).join(" ")}${stdinRedirect}`;
|
|
1988
|
+
lines.push(invocation);
|
|
1989
|
+
return `${lines.join(`
|
|
1990
|
+
`)}
|
|
1991
|
+
`;
|
|
1992
|
+
}
|
|
1993
|
+
function remotePreflightScript(spec, metadata) {
|
|
1994
|
+
return [
|
|
1995
|
+
...remoteBootstrapLines(spec, metadata),
|
|
1996
|
+
"command -v bash >/dev/null 2>&1",
|
|
1997
|
+
`command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
|
|
1998
|
+
].join(`
|
|
1999
|
+
`);
|
|
2000
|
+
}
|
|
2001
|
+
function transportEnv(opts) {
|
|
2002
|
+
const source = opts.env ?? process.env;
|
|
2003
|
+
const env = {};
|
|
2004
|
+
for (const [key, value] of Object.entries(source)) {
|
|
2005
|
+
if (value === undefined)
|
|
2006
|
+
continue;
|
|
2007
|
+
if (TRANSPORT_ENV_KEYS.has(key) || key.startsWith("LC_"))
|
|
2008
|
+
env[key] = value;
|
|
2009
|
+
}
|
|
2010
|
+
env.PATH = normalizeExecutionPath(env);
|
|
2011
|
+
return env;
|
|
2012
|
+
}
|
|
2013
|
+
function preflightRemoteSpec(spec, machine, metadata, opts) {
|
|
2014
|
+
const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
|
|
2015
|
+
const result = spawnSync2(plan.command, plan.args, {
|
|
2016
|
+
encoding: "utf8",
|
|
2017
|
+
env: transportEnv(opts),
|
|
2018
|
+
input: remotePreflightScript(spec, metadata),
|
|
2019
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2020
|
+
timeout: 15000
|
|
2021
|
+
});
|
|
2022
|
+
if (result.error)
|
|
2023
|
+
throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
|
|
2024
|
+
if ((result.status ?? 1) !== 0) {
|
|
2025
|
+
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
2026
|
+
throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
2030
|
+
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
2031
|
+
const startedAt = nowIso();
|
|
2032
|
+
let stdout = "";
|
|
2033
|
+
let stderr = "";
|
|
2034
|
+
let timedOut = false;
|
|
2035
|
+
let exitCode;
|
|
2036
|
+
let error;
|
|
2037
|
+
let plan;
|
|
2038
|
+
let script;
|
|
2039
|
+
try {
|
|
2040
|
+
plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
|
|
2041
|
+
script = remoteScript(spec, metadata);
|
|
2042
|
+
} catch (err) {
|
|
2043
|
+
return {
|
|
2044
|
+
status: "failed",
|
|
2045
|
+
stdout: "",
|
|
2046
|
+
stderr: "",
|
|
2047
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2048
|
+
startedAt,
|
|
2049
|
+
finishedAt: nowIso(),
|
|
2050
|
+
durationMs: 0
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
const child = spawn(plan.command, plan.args, {
|
|
2054
|
+
env: transportEnv(opts),
|
|
2055
|
+
detached: true,
|
|
2056
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2057
|
+
});
|
|
2058
|
+
if (child.pid)
|
|
2059
|
+
opts.onSpawn?.(child.pid);
|
|
2060
|
+
child.stdin?.on("error", (err) => {
|
|
2061
|
+
if (err.code !== "EPIPE")
|
|
2062
|
+
error = err.message;
|
|
2063
|
+
});
|
|
2064
|
+
child.stdin?.end(script);
|
|
2065
|
+
const abortHandler = () => {
|
|
2066
|
+
error = "cancelled";
|
|
2067
|
+
if (child.pid)
|
|
2068
|
+
killProcessGroup(child.pid);
|
|
2069
|
+
};
|
|
2070
|
+
if (opts.signal?.aborted)
|
|
2071
|
+
abortHandler();
|
|
2072
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
2073
|
+
child.stdout?.on("data", (chunk) => {
|
|
2074
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
2075
|
+
});
|
|
2076
|
+
child.stderr?.on("data", (chunk) => {
|
|
2077
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
2078
|
+
});
|
|
2079
|
+
const timer = setTimeout(() => {
|
|
2080
|
+
timedOut = true;
|
|
2081
|
+
if (child.pid)
|
|
2082
|
+
killProcessGroup(child.pid);
|
|
2083
|
+
}, spec.timeoutMs);
|
|
2084
|
+
timer.unref();
|
|
2085
|
+
try {
|
|
2086
|
+
const [code, signal] = await once(child, "exit");
|
|
2087
|
+
if (typeof code === "number")
|
|
2088
|
+
exitCode = code;
|
|
2089
|
+
if (signal)
|
|
2090
|
+
error = `terminated by ${signal}`;
|
|
2091
|
+
} catch (err) {
|
|
2092
|
+
error = err instanceof Error ? err.message : String(err);
|
|
2093
|
+
} finally {
|
|
2094
|
+
clearTimeout(timer);
|
|
2095
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
2096
|
+
}
|
|
2097
|
+
const finishedAt = nowIso();
|
|
2098
|
+
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
2099
|
+
if (timedOut) {
|
|
2100
|
+
return {
|
|
2101
|
+
status: "timed_out",
|
|
2102
|
+
exitCode,
|
|
2103
|
+
stdout,
|
|
2104
|
+
stderr,
|
|
2105
|
+
error: `timed out after ${spec.timeoutMs}ms`,
|
|
2106
|
+
pid: child.pid,
|
|
2107
|
+
startedAt,
|
|
2108
|
+
finishedAt,
|
|
2109
|
+
durationMs
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
if (error || exitCode !== 0) {
|
|
2113
|
+
return {
|
|
2114
|
+
status: "failed",
|
|
2115
|
+
exitCode,
|
|
2116
|
+
stdout,
|
|
2117
|
+
stderr,
|
|
2118
|
+
error: error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`,
|
|
2119
|
+
pid: child.pid,
|
|
2120
|
+
startedAt,
|
|
2121
|
+
finishedAt,
|
|
2122
|
+
durationMs
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
return {
|
|
2126
|
+
status: "succeeded",
|
|
2127
|
+
exitCode,
|
|
2128
|
+
stdout,
|
|
2129
|
+
stderr,
|
|
2130
|
+
pid: child.pid,
|
|
2131
|
+
startedAt,
|
|
2132
|
+
finishedAt,
|
|
2133
|
+
durationMs
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
1663
2136
|
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1664
2137
|
const spec = commandSpec(target);
|
|
2138
|
+
const machine = resolvedMachine(opts);
|
|
2139
|
+
if (machine && !machine.local) {
|
|
2140
|
+
preflightRemoteSpec(spec, machine, metadata, opts);
|
|
2141
|
+
return {
|
|
2142
|
+
command: spec.command,
|
|
2143
|
+
accountProfile: spec.account?.profile,
|
|
2144
|
+
accountTool: spec.accountTool
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
1665
2147
|
const env = executionEnv(spec, metadata, opts);
|
|
1666
2148
|
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1667
2149
|
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
@@ -1674,6 +2156,9 @@ function preflightTarget(target, metadata = {}, opts = {}) {
|
|
|
1674
2156
|
}
|
|
1675
2157
|
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1676
2158
|
const spec = commandSpec(target);
|
|
2159
|
+
const machine = resolvedMachine(opts);
|
|
2160
|
+
if (machine && !machine.local)
|
|
2161
|
+
return executeRemoteSpec(spec, machine, metadata, opts);
|
|
1677
2162
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
1678
2163
|
const startedAt = nowIso();
|
|
1679
2164
|
let stdout = "";
|
|
@@ -1698,10 +2183,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1698
2183
|
env,
|
|
1699
2184
|
shell: spec.shell ?? false,
|
|
1700
2185
|
detached: true,
|
|
1701
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
2186
|
+
stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
|
|
1702
2187
|
});
|
|
1703
2188
|
if (child.pid)
|
|
1704
2189
|
opts.onSpawn?.(child.pid);
|
|
2190
|
+
if (spec.stdin !== undefined && child.stdin) {
|
|
2191
|
+
child.stdin.on("error", (err) => {
|
|
2192
|
+
if (err.code !== "EPIPE")
|
|
2193
|
+
error = err.message;
|
|
2194
|
+
});
|
|
2195
|
+
child.stdin.end(spec.stdin);
|
|
2196
|
+
}
|
|
1705
2197
|
const abortHandler = () => {
|
|
1706
2198
|
error = "cancelled";
|
|
1707
2199
|
if (child.pid)
|
|
@@ -1710,10 +2202,10 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1710
2202
|
if (opts.signal?.aborted)
|
|
1711
2203
|
abortHandler();
|
|
1712
2204
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1713
|
-
child.stdout
|
|
2205
|
+
child.stdout?.on("data", (chunk) => {
|
|
1714
2206
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
1715
2207
|
});
|
|
1716
|
-
child.stderr
|
|
2208
|
+
child.stderr?.on("data", (chunk) => {
|
|
1717
2209
|
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
1718
2210
|
});
|
|
1719
2211
|
const timer = setTimeout(() => {
|
|
@@ -1782,7 +2274,7 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
1782
2274
|
loopName: loop.name,
|
|
1783
2275
|
runId: run.id,
|
|
1784
2276
|
scheduledFor: run.scheduledFor
|
|
1785
|
-
}, opts);
|
|
2277
|
+
}, { ...opts, machine: opts.machine ?? loop.machine });
|
|
1786
2278
|
}
|
|
1787
2279
|
|
|
1788
2280
|
// src/lib/workflow-runner.ts
|
|
@@ -1812,7 +2304,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1812
2304
|
loop: opts.loop,
|
|
1813
2305
|
loopRun: opts.loopRun,
|
|
1814
2306
|
scheduledFor: opts.scheduledFor,
|
|
1815
|
-
idempotencyKey: opts.idempotencyKey
|
|
2307
|
+
idempotencyKey: opts.idempotencyKey,
|
|
2308
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
1816
2309
|
});
|
|
1817
2310
|
const startedAt = run.startedAt ?? nowIso();
|
|
1818
2311
|
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
@@ -1846,12 +2339,14 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1846
2339
|
return !dependencyStep?.continueOnFailure;
|
|
1847
2340
|
});
|
|
1848
2341
|
if (blockedBy) {
|
|
1849
|
-
|
|
2342
|
+
opts.beforePersist?.();
|
|
2343
|
+
store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
|
|
1850
2344
|
blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
|
|
1851
2345
|
terminalStatus = "failed";
|
|
1852
2346
|
continue;
|
|
1853
2347
|
}
|
|
1854
|
-
|
|
2348
|
+
opts.beforePersist?.();
|
|
2349
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
|
|
1855
2350
|
if (startedStep.status !== "running") {
|
|
1856
2351
|
terminalStatus = "failed";
|
|
1857
2352
|
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
@@ -1881,9 +2376,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1881
2376
|
try {
|
|
1882
2377
|
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
1883
2378
|
...opts,
|
|
2379
|
+
machine: opts.machine ?? opts.loop?.machine,
|
|
1884
2380
|
signal: controller.signal,
|
|
1885
2381
|
onSpawn: (pid) => {
|
|
1886
|
-
|
|
2382
|
+
opts.beforePersist?.();
|
|
2383
|
+
store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
|
|
1887
2384
|
opts.onSpawn?.(pid);
|
|
1888
2385
|
}
|
|
1889
2386
|
});
|
|
@@ -1911,6 +2408,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1911
2408
|
blockingError = "workflow run was cancelled";
|
|
1912
2409
|
break;
|
|
1913
2410
|
}
|
|
2411
|
+
opts.beforePersist?.();
|
|
1914
2412
|
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1915
2413
|
status: result.status,
|
|
1916
2414
|
finishedAt: result.finishedAt,
|
|
@@ -1919,6 +2417,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1919
2417
|
stderr: result.stderr,
|
|
1920
2418
|
exitCode: result.exitCode,
|
|
1921
2419
|
error: result.error
|
|
2420
|
+
}, {
|
|
2421
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
1922
2422
|
});
|
|
1923
2423
|
if (result.status !== "succeeded" && !step.continueOnFailure) {
|
|
1924
2424
|
terminalStatus = result.status;
|
|
@@ -1930,7 +2430,9 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1930
2430
|
for (const step of ordered) {
|
|
1931
2431
|
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1932
2432
|
if (existing?.status === "pending" || existing?.status === "running") {
|
|
1933
|
-
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run"
|
|
2433
|
+
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
|
|
2434
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
2435
|
+
});
|
|
1934
2436
|
}
|
|
1935
2437
|
}
|
|
1936
2438
|
}
|
|
@@ -1940,10 +2442,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1940
2442
|
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1941
2443
|
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
1942
2444
|
}
|
|
2445
|
+
opts.beforePersist?.();
|
|
1943
2446
|
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
1944
2447
|
finishedAt,
|
|
1945
2448
|
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
1946
2449
|
error: blockingError
|
|
2450
|
+
}, {
|
|
2451
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
1947
2452
|
});
|
|
1948
2453
|
const steps = store.listWorkflowStepRuns(run.id);
|
|
1949
2454
|
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
@@ -1991,52 +2496,81 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
1991
2496
|
|
|
1992
2497
|
// src/lib/scheduler.ts
|
|
1993
2498
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
1994
|
-
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
2499
|
+
if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
1995
2500
|
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
1996
2501
|
}
|
|
1997
2502
|
return now.toISOString();
|
|
1998
2503
|
}
|
|
1999
2504
|
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
2505
|
+
if (loop.status !== "active")
|
|
2506
|
+
return false;
|
|
2000
2507
|
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
2001
2508
|
return false;
|
|
2002
2509
|
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
2003
2510
|
}
|
|
2511
|
+
function manualRunSource(loop, scheduledFor, now = new Date) {
|
|
2512
|
+
if (loop.status !== "active")
|
|
2513
|
+
return "ad_hoc";
|
|
2514
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
2515
|
+
return "ad_hoc";
|
|
2516
|
+
if (loop.retryScheduledFor && scheduledFor === loop.retryScheduledFor)
|
|
2517
|
+
return "retry_slot";
|
|
2518
|
+
return "due_slot";
|
|
2519
|
+
}
|
|
2004
2520
|
function nextAfterRetry(loop, now) {
|
|
2005
2521
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
2006
2522
|
}
|
|
2007
|
-
function
|
|
2523
|
+
function isDaemonLeaseLost(error) {
|
|
2524
|
+
return error instanceof Error && error.message === "daemon lease lost";
|
|
2525
|
+
}
|
|
2526
|
+
function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
|
|
2008
2527
|
if (run.status === "running")
|
|
2009
2528
|
return;
|
|
2010
2529
|
const current = store.getLoop(loop.id);
|
|
2011
2530
|
if (!current || current.status !== "active")
|
|
2012
2531
|
return;
|
|
2013
|
-
|
|
2532
|
+
if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
|
|
2533
|
+
return;
|
|
2534
|
+
const shouldRetry = !succeeded && run.attempt < current.maxAttempts;
|
|
2014
2535
|
if (shouldRetry) {
|
|
2015
|
-
store.updateLoop(
|
|
2536
|
+
store.updateLoop(current.id, {
|
|
2016
2537
|
status: "active",
|
|
2017
|
-
nextRunAt: nextAfterRetry(
|
|
2538
|
+
nextRunAt: nextAfterRetry(current, finishedAt),
|
|
2018
2539
|
retryScheduledFor: run.scheduledFor
|
|
2019
|
-
});
|
|
2540
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
const deferredRetry = store.nextRetryableRun(current.id, current.maxAttempts, run.scheduledFor);
|
|
2544
|
+
if (deferredRetry) {
|
|
2545
|
+
store.updateLoop(current.id, {
|
|
2546
|
+
status: "active",
|
|
2547
|
+
nextRunAt: nextAfterRetry(current, finishedAt),
|
|
2548
|
+
retryScheduledFor: deferredRetry.scheduledFor
|
|
2549
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2020
2550
|
return;
|
|
2021
2551
|
}
|
|
2022
|
-
const nextRunAt = computeNextAfter(
|
|
2023
|
-
store.updateLoop(
|
|
2552
|
+
const nextRunAt = computeNextAfter(current.schedule, new Date(run.scheduledFor), finishedAt);
|
|
2553
|
+
store.updateLoop(current.id, {
|
|
2024
2554
|
status: nextRunAt ? "active" : "stopped",
|
|
2025
2555
|
nextRunAt,
|
|
2026
2556
|
retryScheduledFor: undefined
|
|
2027
|
-
});
|
|
2557
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2028
2558
|
}
|
|
2029
2559
|
async function executeClaimedRun(deps) {
|
|
2030
2560
|
let heartbeat;
|
|
2031
2561
|
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
2032
2562
|
heartbeat = setInterval(() => {
|
|
2033
|
-
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs
|
|
2563
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs, new Date, {
|
|
2564
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2565
|
+
});
|
|
2034
2566
|
}, heartbeatEveryMs);
|
|
2035
2567
|
heartbeat.unref();
|
|
2036
2568
|
try {
|
|
2037
2569
|
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
2038
|
-
|
|
2570
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2571
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId, { daemonLeaseId: deps.daemonLeaseId })
|
|
2039
2572
|
})))(deps.loop, deps.run);
|
|
2573
|
+
deps.beforeFinalize?.(deps.loop, deps.run);
|
|
2040
2574
|
return deps.store.finalizeRun(deps.run.id, {
|
|
2041
2575
|
status: result.status,
|
|
2042
2576
|
finishedAt: result.finishedAt,
|
|
@@ -2048,10 +2582,16 @@ async function executeClaimedRun(deps) {
|
|
|
2048
2582
|
pid: result.pid
|
|
2049
2583
|
}, {
|
|
2050
2584
|
claimedBy: deps.runnerId,
|
|
2585
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2051
2586
|
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
2052
2587
|
});
|
|
2053
2588
|
} catch (err) {
|
|
2054
2589
|
deps.onError?.(deps.loop, err);
|
|
2590
|
+
try {
|
|
2591
|
+
deps.beforeFinalize?.(deps.loop, deps.run);
|
|
2592
|
+
} catch {
|
|
2593
|
+
return deps.store.getRun(deps.run.id) ?? deps.run;
|
|
2594
|
+
}
|
|
2055
2595
|
const finishedAt = new Date;
|
|
2056
2596
|
return deps.store.finalizeRun(deps.run.id, {
|
|
2057
2597
|
status: "failed",
|
|
@@ -2062,6 +2602,7 @@ async function executeClaimedRun(deps) {
|
|
|
2062
2602
|
error: err instanceof Error ? err.message : String(err)
|
|
2063
2603
|
}, {
|
|
2064
2604
|
claimedBy: deps.runnerId,
|
|
2605
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2065
2606
|
now: deps.now?.() ?? finishedAt
|
|
2066
2607
|
});
|
|
2067
2608
|
} finally {
|
|
@@ -2071,15 +2612,33 @@ async function executeClaimedRun(deps) {
|
|
|
2071
2612
|
}
|
|
2072
2613
|
async function runSlot(deps, loop, scheduledFor) {
|
|
2073
2614
|
const now = deps.now?.() ?? new Date;
|
|
2615
|
+
deps.beforeRun?.(loop, scheduledFor);
|
|
2074
2616
|
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2075
|
-
|
|
2076
|
-
|
|
2617
|
+
let skipped;
|
|
2618
|
+
try {
|
|
2619
|
+
skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
|
|
2620
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2621
|
+
});
|
|
2622
|
+
} catch (error) {
|
|
2623
|
+
if (deps.daemonLeaseId && isDaemonLeaseLost(error))
|
|
2624
|
+
return;
|
|
2625
|
+
throw error;
|
|
2626
|
+
}
|
|
2627
|
+
advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
|
|
2077
2628
|
deps.onRun?.(skipped);
|
|
2078
2629
|
return skipped;
|
|
2079
2630
|
}
|
|
2080
|
-
|
|
2631
|
+
let claim;
|
|
2632
|
+
try {
|
|
2633
|
+
claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2634
|
+
} catch (error) {
|
|
2635
|
+
if (deps.daemonLeaseId && isDaemonLeaseLost(error))
|
|
2636
|
+
return;
|
|
2637
|
+
throw error;
|
|
2638
|
+
}
|
|
2081
2639
|
if (!claim)
|
|
2082
2640
|
return;
|
|
2641
|
+
deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
|
|
2083
2642
|
deps.onRun?.(claim.run);
|
|
2084
2643
|
const finalRun = await executeClaimedRun({
|
|
2085
2644
|
store: deps.store,
|
|
@@ -2088,21 +2647,42 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
2088
2647
|
run: claim.run,
|
|
2089
2648
|
now: deps.now,
|
|
2090
2649
|
execute: deps.execute,
|
|
2650
|
+
beforeFinalize: deps.beforeFinalize,
|
|
2651
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2091
2652
|
onError: deps.onError
|
|
2092
2653
|
});
|
|
2093
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2654
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded", { daemonLeaseId: deps.daemonLeaseId });
|
|
2094
2655
|
deps.onRun?.(finalRun);
|
|
2095
2656
|
return finalRun;
|
|
2096
2657
|
}
|
|
2097
2658
|
async function tick(deps) {
|
|
2098
2659
|
const now = deps.now?.() ?? new Date;
|
|
2099
|
-
const recovered = deps.store.recoverExpiredRunLeases(now);
|
|
2660
|
+
const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2661
|
+
const recoveredByLoop = new Map;
|
|
2100
2662
|
for (const run of recovered) {
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2663
|
+
recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
|
|
2664
|
+
}
|
|
2665
|
+
for (const runs of recoveredByLoop.values()) {
|
|
2666
|
+
const loop = deps.store.getLoop(runs[0].loopId);
|
|
2667
|
+
if (!loop)
|
|
2668
|
+
continue;
|
|
2669
|
+
const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
|
|
2670
|
+
if (retryable) {
|
|
2671
|
+
advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
|
|
2672
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2673
|
+
});
|
|
2674
|
+
continue;
|
|
2675
|
+
}
|
|
2676
|
+
for (const run of runs) {
|
|
2677
|
+
const current = deps.store.getLoop(run.loopId);
|
|
2678
|
+
if (current) {
|
|
2679
|
+
advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
|
|
2680
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2104
2684
|
}
|
|
2105
|
-
const expired = deps.store.expireLoops(now);
|
|
2685
|
+
const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2106
2686
|
const claimed = [];
|
|
2107
2687
|
const completed = [];
|
|
2108
2688
|
const skipped = [];
|
|
@@ -2320,8 +2900,10 @@ async function runDaemon(opts = {}) {
|
|
|
2320
2900
|
const result = await tick({
|
|
2321
2901
|
store,
|
|
2322
2902
|
runnerId,
|
|
2903
|
+
daemonLeaseId: leaseId,
|
|
2904
|
+
beforeRun: () => ensureLease(),
|
|
2323
2905
|
execute: async (loop, run) => {
|
|
2324
|
-
const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs /
|
|
2906
|
+
const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
|
|
2325
2907
|
const timer = setInterval(() => {
|
|
2326
2908
|
try {
|
|
2327
2909
|
ensureLease();
|
|
@@ -2333,8 +2915,14 @@ async function runDaemon(opts = {}) {
|
|
|
2333
2915
|
try {
|
|
2334
2916
|
const result2 = await executeLoopTarget(store, loop, run, {
|
|
2335
2917
|
signal: runAbort.signal,
|
|
2336
|
-
|
|
2918
|
+
beforePersist: () => ensureLease(),
|
|
2919
|
+
daemonLeaseId: leaseId,
|
|
2920
|
+
onSpawn: (pid) => {
|
|
2921
|
+
ensureLease();
|
|
2922
|
+
store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
|
|
2923
|
+
}
|
|
2337
2924
|
});
|
|
2925
|
+
ensureLease();
|
|
2338
2926
|
if (leaseLost)
|
|
2339
2927
|
throw new Error("daemon lease lost during run");
|
|
2340
2928
|
return result2;
|
|
@@ -2342,6 +2930,7 @@ async function runDaemon(opts = {}) {
|
|
|
2342
2930
|
clearInterval(timer);
|
|
2343
2931
|
}
|
|
2344
2932
|
},
|
|
2933
|
+
beforeFinalize: () => ensureLease(),
|
|
2345
2934
|
onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
2346
2935
|
});
|
|
2347
2936
|
const changed = result.completed.length + result.skipped.length + result.recovered.length + result.expired.length;
|
|
@@ -2387,7 +2976,7 @@ async function startDaemon(opts) {
|
|
|
2387
2976
|
|
|
2388
2977
|
// src/daemon/install.ts
|
|
2389
2978
|
import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
2390
|
-
import { spawnSync as
|
|
2979
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2391
2980
|
import { dirname as dirname3 } from "path";
|
|
2392
2981
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
2393
2982
|
const command = [execPath, cliEntry, ...args].join(" ");
|
|
@@ -2457,7 +3046,7 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2457
3046
|
function enableStartup(result) {
|
|
2458
3047
|
const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
|
|
2459
3048
|
return commands.map((command) => {
|
|
2460
|
-
const run =
|
|
3049
|
+
const run = spawnSync3("sh", ["-c", command], {
|
|
2461
3050
|
encoding: "utf8",
|
|
2462
3051
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2463
3052
|
});
|
|
@@ -2472,7 +3061,7 @@ function enableStartup(result) {
|
|
|
2472
3061
|
|
|
2473
3062
|
// src/daemon/index.ts
|
|
2474
3063
|
var program = new Command;
|
|
2475
|
-
program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.
|
|
3064
|
+
program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.4");
|
|
2476
3065
|
program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
|
|
2477
3066
|
program.command("start").action(async () => {
|
|
2478
3067
|
const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
|