@hasna/loops 0.3.2 → 0.3.3
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 +493 -157
- package/dist/daemon/index.js +441 -145
- package/dist/index.js +440 -145
- package/dist/lib/executor.d.ts +2 -2
- package/dist/lib/format.d.ts +2 -1
- package/dist/lib/scheduler.d.ts +10 -1
- package/dist/lib/store.d.ts +21 -12
- package/dist/lib/store.js +299 -107
- package/dist/sdk/index.js +440 -145
- package/dist/types.d.ts +4 -0
- package/docs/USAGE.md +8 -0
- package/package.json +1 -1
package/dist/lib/store.js
CHANGED
|
@@ -613,6 +613,13 @@ class Store {
|
|
|
613
613
|
} catch {}
|
|
614
614
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
615
615
|
}
|
|
616
|
+
assertDaemonLeaseFence(opts = {}, now = nowIso()) {
|
|
617
|
+
if (!opts.daemonLeaseId)
|
|
618
|
+
return;
|
|
619
|
+
const row = this.db.query("SELECT id FROM daemon_lease WHERE id = ? AND expires_at > ?").get(opts.daemonLeaseId, now);
|
|
620
|
+
if (!row)
|
|
621
|
+
throw new Error("daemon lease lost");
|
|
622
|
+
}
|
|
616
623
|
createLoop(input, from = new Date) {
|
|
617
624
|
const now = nowIso();
|
|
618
625
|
const loop = {
|
|
@@ -682,21 +689,31 @@ class Store {
|
|
|
682
689
|
ORDER BY next_run_at ASC`).all(now.toISOString());
|
|
683
690
|
return rows.map(rowToLoop);
|
|
684
691
|
}
|
|
685
|
-
updateLoop(id, patch) {
|
|
692
|
+
updateLoop(id, patch, opts = {}) {
|
|
686
693
|
const current = this.getLoop(id);
|
|
687
694
|
if (!current)
|
|
688
695
|
throw new Error(`loop not found: ${id}`);
|
|
689
|
-
const
|
|
696
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
697
|
+
const merged = { ...current, ...patch, updatedAt: updated };
|
|
690
698
|
this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
|
|
691
|
-
expires_at=$expiresAt, updated_at=$updated
|
|
699
|
+
expires_at=$expiresAt, updated_at=$updated
|
|
700
|
+
WHERE id=$id
|
|
701
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
702
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
703
|
+
))`).run({
|
|
692
704
|
$id: id,
|
|
693
705
|
$status: merged.status,
|
|
694
706
|
$nextRun: merged.nextRunAt ?? null,
|
|
695
707
|
$retrySlot: merged.retryScheduledFor ?? null,
|
|
696
708
|
$expiresAt: merged.expiresAt ?? null,
|
|
697
|
-
$updated: merged.updatedAt
|
|
709
|
+
$updated: merged.updatedAt,
|
|
710
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
711
|
+
$now: updated
|
|
698
712
|
});
|
|
699
|
-
|
|
713
|
+
const after = this.getLoop(id);
|
|
714
|
+
if (!after)
|
|
715
|
+
throw new Error(`loop not found after update: ${id}`);
|
|
716
|
+
return after;
|
|
700
717
|
}
|
|
701
718
|
deleteLoop(idOrName) {
|
|
702
719
|
const loop = this.requireLoop(idOrName);
|
|
@@ -760,11 +777,14 @@ class Store {
|
|
|
760
777
|
const now = nowIso();
|
|
761
778
|
if (input.idempotencyKey) {
|
|
762
779
|
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)
|
|
780
|
+
if (existing) {
|
|
781
|
+
this.assertDaemonLeaseFence(input);
|
|
764
782
|
return rowToWorkflowRun(existing);
|
|
783
|
+
}
|
|
765
784
|
}
|
|
766
785
|
this.db.exec("BEGIN IMMEDIATE");
|
|
767
786
|
try {
|
|
787
|
+
this.assertDaemonLeaseFence(input, now);
|
|
768
788
|
if (input.idempotencyKey) {
|
|
769
789
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
770
790
|
if (existing) {
|
|
@@ -863,31 +883,60 @@ class Store {
|
|
|
863
883
|
const run = this.getWorkflowRun(workflowRunId);
|
|
864
884
|
return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
|
|
865
885
|
}
|
|
866
|
-
startWorkflowStepRun(workflowRunId, stepId) {
|
|
867
|
-
const now =
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
886
|
+
startWorkflowStepRun(workflowRunId, stepId, opts = {}) {
|
|
887
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
888
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
889
|
+
try {
|
|
890
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
891
|
+
SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
|
|
892
|
+
pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
893
|
+
WHERE workflow_run_id=$workflowRunId
|
|
894
|
+
AND step_id=$stepId
|
|
895
|
+
AND status IN ('pending', 'failed', 'timed_out')
|
|
896
|
+
AND EXISTS (
|
|
897
|
+
SELECT 1 FROM workflow_runs
|
|
898
|
+
WHERE id=$workflowRunId AND status='running'
|
|
899
|
+
)
|
|
900
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
901
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
902
|
+
))`).run({
|
|
903
|
+
$workflowRunId: workflowRunId,
|
|
904
|
+
$stepId: stepId,
|
|
905
|
+
$started: now,
|
|
906
|
+
$updated: now,
|
|
907
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
908
|
+
$now: now
|
|
909
|
+
});
|
|
910
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
911
|
+
if (!run)
|
|
912
|
+
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
913
|
+
if (res.changes !== 1) {
|
|
914
|
+
throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
|
|
915
|
+
}
|
|
916
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
917
|
+
this.db.exec("COMMIT");
|
|
918
|
+
return run;
|
|
919
|
+
} catch (error) {
|
|
920
|
+
try {
|
|
921
|
+
this.db.exec("ROLLBACK");
|
|
922
|
+
} catch {}
|
|
923
|
+
throw error;
|
|
883
924
|
}
|
|
884
|
-
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
885
|
-
return run;
|
|
886
925
|
}
|
|
887
|
-
markWorkflowStepPid(workflowRunId, stepId, pid) {
|
|
888
|
-
const now =
|
|
926
|
+
markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
|
|
927
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
889
928
|
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'
|
|
929
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
930
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
931
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
932
|
+
))`).run({
|
|
933
|
+
$workflowRunId: workflowRunId,
|
|
934
|
+
$stepId: stepId,
|
|
935
|
+
$pid: pid,
|
|
936
|
+
$updated: now,
|
|
937
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
938
|
+
$now: now
|
|
939
|
+
});
|
|
891
940
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
892
941
|
if (!run)
|
|
893
942
|
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
@@ -915,60 +964,110 @@ class Store {
|
|
|
915
964
|
recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
|
|
916
965
|
};
|
|
917
966
|
}
|
|
918
|
-
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
967
|
+
finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
|
|
919
968
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
969
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
970
|
+
try {
|
|
971
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
972
|
+
pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
973
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
974
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
975
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
976
|
+
))`).run({
|
|
977
|
+
$workflowRunId: workflowRunId,
|
|
978
|
+
$stepId: stepId,
|
|
979
|
+
$status: patch.status,
|
|
980
|
+
$finished: finishedAt,
|
|
981
|
+
$exitCode: patch.exitCode ?? null,
|
|
982
|
+
$durationMs: patch.durationMs ?? null,
|
|
983
|
+
$stdout: patch.stdout ?? null,
|
|
984
|
+
$stderr: patch.stderr ?? null,
|
|
985
|
+
$error: patch.error ?? null,
|
|
986
|
+
$updated: finishedAt,
|
|
987
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
988
|
+
$now: (opts.now ?? new Date(finishedAt)).toISOString()
|
|
938
989
|
});
|
|
990
|
+
if (res.changes === 1) {
|
|
991
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
992
|
+
exitCode: patch.exitCode,
|
|
993
|
+
error: patch.error
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
this.db.exec("COMMIT");
|
|
997
|
+
} catch (error) {
|
|
998
|
+
try {
|
|
999
|
+
this.db.exec("ROLLBACK");
|
|
1000
|
+
} catch {}
|
|
1001
|
+
throw error;
|
|
939
1002
|
}
|
|
940
1003
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
941
1004
|
if (!run)
|
|
942
1005
|
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
943
1006
|
return run;
|
|
944
1007
|
}
|
|
945
|
-
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
946
|
-
const now =
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1008
|
+
skipWorkflowStepRun(workflowRunId, stepId, reason, opts = {}) {
|
|
1009
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1010
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1011
|
+
try {
|
|
1012
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
|
|
1013
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')
|
|
1014
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1015
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1016
|
+
))`).run({
|
|
1017
|
+
$workflowRunId: workflowRunId,
|
|
1018
|
+
$stepId: stepId,
|
|
1019
|
+
$finished: now,
|
|
1020
|
+
$error: reason,
|
|
1021
|
+
$updated: now,
|
|
1022
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1023
|
+
$now: now
|
|
1024
|
+
});
|
|
1025
|
+
if (res.changes === 1)
|
|
1026
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
1027
|
+
this.db.exec("COMMIT");
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
try {
|
|
1030
|
+
this.db.exec("ROLLBACK");
|
|
1031
|
+
} catch {}
|
|
1032
|
+
throw error;
|
|
1033
|
+
}
|
|
951
1034
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
952
1035
|
if (!run)
|
|
953
1036
|
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
954
1037
|
return run;
|
|
955
1038
|
}
|
|
956
|
-
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
1039
|
+
finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
|
|
957
1040
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1041
|
+
let changed = false;
|
|
1042
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1043
|
+
try {
|
|
1044
|
+
const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
1045
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1046
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1047
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1048
|
+
))`).run({
|
|
1049
|
+
$id: workflowRunId,
|
|
1050
|
+
$status: status,
|
|
1051
|
+
$finished: finishedAt,
|
|
1052
|
+
$durationMs: patch.durationMs ?? null,
|
|
1053
|
+
$error: patch.error ?? null,
|
|
1054
|
+
$updated: finishedAt,
|
|
1055
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1056
|
+
$now: (opts.now ?? new Date(finishedAt)).toISOString()
|
|
1057
|
+
});
|
|
1058
|
+
changed = res.changes === 1;
|
|
1059
|
+
if (changed)
|
|
1060
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
1061
|
+
this.db.exec("COMMIT");
|
|
1062
|
+
} catch (error) {
|
|
1063
|
+
try {
|
|
1064
|
+
this.db.exec("ROLLBACK");
|
|
1065
|
+
} catch {}
|
|
1066
|
+
throw error;
|
|
1067
|
+
}
|
|
967
1068
|
const run = this.getWorkflowRun(workflowRunId);
|
|
968
1069
|
if (!run)
|
|
969
1070
|
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
1071
|
return run;
|
|
973
1072
|
}
|
|
974
1073
|
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
@@ -1022,10 +1121,24 @@ class Store {
|
|
|
1022
1121
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
1023
1122
|
return (row?.count ?? 0) > 0;
|
|
1024
1123
|
}
|
|
1025
|
-
markRunPid(id, pid, claimedBy) {
|
|
1026
|
-
const now =
|
|
1124
|
+
markRunPid(id, pid, claimedBy, opts = {}) {
|
|
1125
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1027
1126
|
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
|
|
1127
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1128
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1129
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1130
|
+
))`).run({
|
|
1131
|
+
$id: id,
|
|
1132
|
+
$pid: pid,
|
|
1133
|
+
$updated: now,
|
|
1134
|
+
$claimedBy: claimedBy,
|
|
1135
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1136
|
+
$now: now
|
|
1137
|
+
}) : this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1138
|
+
WHERE id=$id AND status='running'
|
|
1139
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1140
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1141
|
+
))`).run({ $id: id, $pid: pid, $updated: now, $daemonLeaseId: opts.daemonLeaseId ?? null, $now: now });
|
|
1029
1142
|
if (res.changes !== 1)
|
|
1030
1143
|
return;
|
|
1031
1144
|
return this.getRun(id);
|
|
@@ -1040,7 +1153,7 @@ class Store {
|
|
|
1040
1153
|
AND wsr.pid IS NOT NULL`).all(loopRunId);
|
|
1041
1154
|
return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
|
|
1042
1155
|
}
|
|
1043
|
-
createSkippedRun(loop, scheduledFor, reason) {
|
|
1156
|
+
createSkippedRun(loop, scheduledFor, reason, opts = {}) {
|
|
1044
1157
|
const now = nowIso();
|
|
1045
1158
|
const run = {
|
|
1046
1159
|
id: genId(),
|
|
@@ -1054,21 +1167,31 @@ class Store {
|
|
|
1054
1167
|
createdAt: now,
|
|
1055
1168
|
updatedAt: now
|
|
1056
1169
|
};
|
|
1057
|
-
this.db.
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1170
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1171
|
+
try {
|
|
1172
|
+
this.assertDaemonLeaseFence(opts, now);
|
|
1173
|
+
this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
|
|
1174
|
+
claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
|
|
1175
|
+
VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
|
|
1176
|
+
NULL, NULL, $error, $created, $updated)`).run({
|
|
1177
|
+
$id: run.id,
|
|
1178
|
+
$loopId: run.loopId,
|
|
1179
|
+
$loopName: run.loopName,
|
|
1180
|
+
$scheduledFor: run.scheduledFor,
|
|
1181
|
+
$attempt: run.attempt,
|
|
1182
|
+
$status: run.status,
|
|
1183
|
+
$finished: run.finishedAt ?? null,
|
|
1184
|
+
$error: run.error ?? null,
|
|
1185
|
+
$created: run.createdAt,
|
|
1186
|
+
$updated: run.updatedAt
|
|
1187
|
+
});
|
|
1188
|
+
this.db.exec("COMMIT");
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
try {
|
|
1191
|
+
this.db.exec("ROLLBACK");
|
|
1192
|
+
} catch {}
|
|
1193
|
+
throw error;
|
|
1194
|
+
}
|
|
1072
1195
|
return this.getRunBySlot(loop.id, scheduledFor) ?? run;
|
|
1073
1196
|
}
|
|
1074
1197
|
getRun(id) {
|
|
@@ -1079,11 +1202,20 @@ class Store {
|
|
|
1079
1202
|
const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
|
|
1080
1203
|
return row ? rowToRun(row) : undefined;
|
|
1081
1204
|
}
|
|
1082
|
-
|
|
1205
|
+
nextRetryableRun(loopId, maxAttempts, afterScheduledFor) {
|
|
1206
|
+
const row = afterScheduledFor ? this.db.query(`SELECT * FROM loop_runs
|
|
1207
|
+
WHERE loop_id = ? AND scheduled_for > ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
|
|
1208
|
+
ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, afterScheduledFor, maxAttempts) : this.db.query(`SELECT * FROM loop_runs
|
|
1209
|
+
WHERE loop_id = ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
|
|
1210
|
+
ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, maxAttempts);
|
|
1211
|
+
return row ? rowToRun(row) : undefined;
|
|
1212
|
+
}
|
|
1213
|
+
claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
|
|
1083
1214
|
const startedAt = now.toISOString();
|
|
1084
1215
|
const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
|
|
1085
1216
|
this.db.exec("BEGIN IMMEDIATE");
|
|
1086
1217
|
try {
|
|
1218
|
+
this.assertDaemonLeaseFence(opts, startedAt);
|
|
1087
1219
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
1088
1220
|
if (existing) {
|
|
1089
1221
|
if (existing.status === "running") {
|
|
@@ -1178,11 +1310,15 @@ class Store {
|
|
|
1178
1310
|
$error: patch.error ?? null,
|
|
1179
1311
|
$updated: finishedAt,
|
|
1180
1312
|
$claimedBy: opts.claimedBy ?? null,
|
|
1181
|
-
$now: (opts.now ?? new Date).toISOString()
|
|
1313
|
+
$now: (opts.now ?? new Date).toISOString(),
|
|
1314
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1182
1315
|
};
|
|
1183
1316
|
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
1317
|
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
|
|
1318
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1319
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1320
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1321
|
+
))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1186
1322
|
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
|
|
1187
1323
|
const run = this.getRun(id);
|
|
1188
1324
|
if (!run)
|
|
@@ -1191,10 +1327,20 @@ class Store {
|
|
|
1191
1327
|
return run;
|
|
1192
1328
|
return run;
|
|
1193
1329
|
}
|
|
1194
|
-
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
|
|
1330
|
+
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
1195
1331
|
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
1196
1332
|
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
|
|
1333
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1334
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1335
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1336
|
+
))`).run({
|
|
1337
|
+
$id: id,
|
|
1338
|
+
$claimedBy: claimedBy,
|
|
1339
|
+
$expires: expiresAt,
|
|
1340
|
+
$updated: now.toISOString(),
|
|
1341
|
+
$now: now.toISOString(),
|
|
1342
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1343
|
+
});
|
|
1198
1344
|
if (res.changes !== 1)
|
|
1199
1345
|
return;
|
|
1200
1346
|
return this.getRun(id);
|
|
@@ -1213,7 +1359,7 @@ class Store {
|
|
|
1213
1359
|
}
|
|
1214
1360
|
return rows.map(rowToRun);
|
|
1215
1361
|
}
|
|
1216
|
-
recoverExpiredRunLeases(now = new Date) {
|
|
1362
|
+
recoverExpiredRunLeases(now = new Date, opts = {}) {
|
|
1217
1363
|
const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
|
|
1218
1364
|
const recovered = [];
|
|
1219
1365
|
for (const row of rows) {
|
|
@@ -1222,20 +1368,63 @@ class Store {
|
|
|
1222
1368
|
if (this.hasLiveWorkflowStepProcesses(row.id))
|
|
1223
1369
|
continue;
|
|
1224
1370
|
const finished = now.toISOString();
|
|
1225
|
-
this.db.
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1371
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1372
|
+
try {
|
|
1373
|
+
const res = this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
|
|
1374
|
+
error='run lease expired before completion', updated_at=$updated
|
|
1375
|
+
WHERE id=$id AND status='running' AND lease_expires_at <= $now
|
|
1376
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1377
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1378
|
+
))`).run({
|
|
1379
|
+
$id: row.id,
|
|
1380
|
+
$finished: finished,
|
|
1381
|
+
$updated: finished,
|
|
1382
|
+
$now: finished,
|
|
1383
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1238
1384
|
});
|
|
1385
|
+
if (res.changes !== 1) {
|
|
1386
|
+
this.db.exec("COMMIT");
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
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);
|
|
1390
|
+
for (const workflowRow of workflowRows) {
|
|
1391
|
+
const workflowRes = this.db.query(`UPDATE workflow_runs
|
|
1392
|
+
SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1393
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1394
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1395
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1396
|
+
))`).run({
|
|
1397
|
+
$id: workflowRow.id,
|
|
1398
|
+
$finished: finished,
|
|
1399
|
+
$updated: finished,
|
|
1400
|
+
$now: finished,
|
|
1401
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1402
|
+
});
|
|
1403
|
+
if (workflowRes.changes !== 1)
|
|
1404
|
+
continue;
|
|
1405
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
1406
|
+
SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1407
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')
|
|
1408
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1409
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1410
|
+
))`).run({
|
|
1411
|
+
$workflowRunId: workflowRow.id,
|
|
1412
|
+
$finished: finished,
|
|
1413
|
+
$updated: finished,
|
|
1414
|
+
$now: finished,
|
|
1415
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1416
|
+
});
|
|
1417
|
+
this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
|
|
1418
|
+
error: "parent loop run lease expired before completion",
|
|
1419
|
+
loopRunId: row.id
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
this.db.exec("COMMIT");
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
try {
|
|
1425
|
+
this.db.exec("ROLLBACK");
|
|
1426
|
+
} catch {}
|
|
1427
|
+
throw error;
|
|
1239
1428
|
}
|
|
1240
1429
|
const run = this.getRun(row.id);
|
|
1241
1430
|
if (run)
|
|
@@ -1243,11 +1432,14 @@ class Store {
|
|
|
1243
1432
|
}
|
|
1244
1433
|
return recovered;
|
|
1245
1434
|
}
|
|
1246
|
-
expireLoops(now = new Date) {
|
|
1435
|
+
expireLoops(now = new Date, opts = {}) {
|
|
1247
1436
|
const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
|
|
1248
1437
|
const expired = [];
|
|
1249
|
-
for (const row of rows)
|
|
1250
|
-
|
|
1438
|
+
for (const row of rows) {
|
|
1439
|
+
const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
|
|
1440
|
+
if (updated.status === "expired")
|
|
1441
|
+
expired.push(updated);
|
|
1442
|
+
}
|
|
1251
1443
|
return expired;
|
|
1252
1444
|
}
|
|
1253
1445
|
countLoops(status) {
|
|
@@ -1290,7 +1482,7 @@ class Store {
|
|
|
1290
1482
|
}
|
|
1291
1483
|
heartbeatDaemonLease(id, ttlMs, now = new Date) {
|
|
1292
1484
|
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() });
|
|
1485
|
+
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
1486
|
if (res.changes !== 1)
|
|
1295
1487
|
return;
|
|
1296
1488
|
return this.getDaemonLease();
|