@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/dist/lib/store.js CHANGED
@@ -345,6 +345,7 @@ function rowToLoop(row) {
345
345
  status: row.status,
346
346
  schedule: JSON.parse(row.schedule_json),
347
347
  target: JSON.parse(row.target_json),
348
+ machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
348
349
  nextRunAt: row.next_run_at ?? undefined,
349
350
  retryScheduledFor: row.retry_scheduled_for ?? undefined,
350
351
  catchUp: row.catch_up,
@@ -487,6 +488,7 @@ class Store {
487
488
  status TEXT NOT NULL,
488
489
  schedule_json TEXT NOT NULL,
489
490
  target_json TEXT NOT NULL,
491
+ machine_json TEXT,
490
492
  next_run_at TEXT,
491
493
  retry_scheduled_for TEXT,
492
494
  catch_up TEXT NOT NULL,
@@ -608,10 +610,21 @@ class Store {
608
610
  );
609
611
  CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
610
612
  `);
613
+ try {
614
+ this.db.query("ALTER TABLE loops ADD COLUMN machine_json TEXT").run();
615
+ } catch {}
611
616
  try {
612
617
  this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
613
618
  } catch {}
614
619
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
620
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
621
+ }
622
+ assertDaemonLeaseFence(opts = {}, now = nowIso()) {
623
+ if (!opts.daemonLeaseId)
624
+ return;
625
+ const row = this.db.query("SELECT id FROM daemon_lease WHERE id = ? AND expires_at > ?").get(opts.daemonLeaseId, now);
626
+ if (!row)
627
+ throw new Error("daemon lease lost");
615
628
  }
616
629
  createLoop(input, from = new Date) {
617
630
  const now = nowIso();
@@ -622,6 +635,7 @@ class Store {
622
635
  status: "active",
623
636
  schedule: input.schedule,
624
637
  target: input.target,
638
+ machine: input.machine,
625
639
  nextRunAt: initialNextRun(input.schedule, from),
626
640
  catchUp: input.catchUp ?? "latest",
627
641
  catchUpLimit: input.catchUpLimit ?? 50,
@@ -633,9 +647,9 @@ class Store {
633
647
  createdAt: now,
634
648
  updatedAt: now
635
649
  };
636
- this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, next_run_at, retry_scheduled_for,
650
+ this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
637
651
  catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
638
- VALUES ($id, $name, $description, $status, $schedule, $target, $nextRun, NULL, $catchUp, $catchUpLimit,
652
+ VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $catchUp, $catchUpLimit,
639
653
  $overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
640
654
  $id: loop.id,
641
655
  $name: loop.name,
@@ -643,6 +657,7 @@ class Store {
643
657
  $status: loop.status,
644
658
  $schedule: JSON.stringify(loop.schedule),
645
659
  $target: JSON.stringify(loop.target),
660
+ $machine: loop.machine ? JSON.stringify(loop.machine) : null,
646
661
  $nextRun: loop.nextRunAt ?? null,
647
662
  $catchUp: loop.catchUp,
648
663
  $catchUpLimit: loop.catchUpLimit,
@@ -682,21 +697,31 @@ class Store {
682
697
  ORDER BY next_run_at ASC`).all(now.toISOString());
683
698
  return rows.map(rowToLoop);
684
699
  }
685
- updateLoop(id, patch) {
700
+ updateLoop(id, patch, opts = {}) {
686
701
  const current = this.getLoop(id);
687
702
  if (!current)
688
703
  throw new Error(`loop not found: ${id}`);
689
- const merged = { ...current, ...patch, updatedAt: nowIso() };
704
+ const updated = (opts.now ?? new Date).toISOString();
705
+ const merged = { ...current, ...patch, updatedAt: updated };
690
706
  this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
691
- expires_at=$expiresAt, updated_at=$updated WHERE id=$id`).run({
707
+ expires_at=$expiresAt, updated_at=$updated
708
+ WHERE id=$id
709
+ AND ($daemonLeaseId IS NULL OR EXISTS (
710
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
711
+ ))`).run({
692
712
  $id: id,
693
713
  $status: merged.status,
694
714
  $nextRun: merged.nextRunAt ?? null,
695
715
  $retrySlot: merged.retryScheduledFor ?? null,
696
716
  $expiresAt: merged.expiresAt ?? null,
697
- $updated: merged.updatedAt
717
+ $updated: merged.updatedAt,
718
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
719
+ $now: updated
698
720
  });
699
- return merged;
721
+ const after = this.getLoop(id);
722
+ if (!after)
723
+ throw new Error(`loop not found after update: ${id}`);
724
+ return after;
700
725
  }
701
726
  deleteLoop(idOrName) {
702
727
  const loop = this.requireLoop(idOrName);
@@ -760,11 +785,14 @@ class Store {
760
785
  const now = nowIso();
761
786
  if (input.idempotencyKey) {
762
787
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
763
- if (existing)
788
+ if (existing) {
789
+ this.assertDaemonLeaseFence(input);
764
790
  return rowToWorkflowRun(existing);
791
+ }
765
792
  }
766
793
  this.db.exec("BEGIN IMMEDIATE");
767
794
  try {
795
+ this.assertDaemonLeaseFence(input, now);
768
796
  if (input.idempotencyKey) {
769
797
  const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
770
798
  if (existing) {
@@ -863,31 +891,60 @@ class Store {
863
891
  const run = this.getWorkflowRun(workflowRunId);
864
892
  return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
865
893
  }
866
- startWorkflowStepRun(workflowRunId, stepId) {
867
- const now = nowIso();
868
- const res = this.db.query(`UPDATE workflow_step_runs
869
- SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
870
- pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
871
- WHERE workflow_run_id=$workflowRunId
872
- AND step_id=$stepId
873
- AND status IN ('pending', 'failed', 'timed_out')
874
- AND EXISTS (
875
- SELECT 1 FROM workflow_runs
876
- WHERE id=$workflowRunId AND status='running'
877
- )`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
878
- const run = this.getWorkflowStepRun(workflowRunId, stepId);
879
- if (!run)
880
- throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
881
- if (res.changes !== 1) {
882
- throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
894
+ startWorkflowStepRun(workflowRunId, stepId, opts = {}) {
895
+ const now = (opts.now ?? new Date).toISOString();
896
+ this.db.exec("BEGIN IMMEDIATE");
897
+ try {
898
+ const res = this.db.query(`UPDATE workflow_step_runs
899
+ SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
900
+ pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
901
+ WHERE workflow_run_id=$workflowRunId
902
+ AND step_id=$stepId
903
+ AND status IN ('pending', 'failed', 'timed_out')
904
+ AND EXISTS (
905
+ SELECT 1 FROM workflow_runs
906
+ WHERE id=$workflowRunId AND status='running'
907
+ )
908
+ AND ($daemonLeaseId IS NULL OR EXISTS (
909
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
910
+ ))`).run({
911
+ $workflowRunId: workflowRunId,
912
+ $stepId: stepId,
913
+ $started: now,
914
+ $updated: now,
915
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
916
+ $now: now
917
+ });
918
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
919
+ if (!run)
920
+ throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
921
+ if (res.changes !== 1) {
922
+ throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
923
+ }
924
+ this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
925
+ this.db.exec("COMMIT");
926
+ return run;
927
+ } catch (error) {
928
+ try {
929
+ this.db.exec("ROLLBACK");
930
+ } catch {}
931
+ throw error;
883
932
  }
884
- this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
885
- return run;
886
933
  }
887
- markWorkflowStepPid(workflowRunId, stepId, pid) {
888
- const now = nowIso();
934
+ markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
935
+ const now = (opts.now ?? new Date).toISOString();
889
936
  this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
890
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $pid: pid, $updated: now });
937
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
938
+ AND ($daemonLeaseId IS NULL OR EXISTS (
939
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
940
+ ))`).run({
941
+ $workflowRunId: workflowRunId,
942
+ $stepId: stepId,
943
+ $pid: pid,
944
+ $updated: now,
945
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
946
+ $now: now
947
+ });
891
948
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
892
949
  if (!run)
893
950
  throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
@@ -915,60 +972,110 @@ class Store {
915
972
  recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
916
973
  };
917
974
  }
918
- finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
975
+ finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
919
976
  const finishedAt = patch.finishedAt ?? nowIso();
920
- const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
921
- pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
922
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({
923
- $workflowRunId: workflowRunId,
924
- $stepId: stepId,
925
- $status: patch.status,
926
- $finished: finishedAt,
927
- $exitCode: patch.exitCode ?? null,
928
- $durationMs: patch.durationMs ?? null,
929
- $stdout: patch.stdout ?? null,
930
- $stderr: patch.stderr ?? null,
931
- $error: patch.error ?? null,
932
- $updated: finishedAt
933
- });
934
- if (res.changes === 1) {
935
- this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
936
- exitCode: patch.exitCode,
937
- error: patch.error
977
+ this.db.exec("BEGIN IMMEDIATE");
978
+ try {
979
+ const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
980
+ pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
981
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
982
+ AND ($daemonLeaseId IS NULL OR EXISTS (
983
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
984
+ ))`).run({
985
+ $workflowRunId: workflowRunId,
986
+ $stepId: stepId,
987
+ $status: patch.status,
988
+ $finished: finishedAt,
989
+ $exitCode: patch.exitCode ?? null,
990
+ $durationMs: patch.durationMs ?? null,
991
+ $stdout: patch.stdout ?? null,
992
+ $stderr: patch.stderr ?? null,
993
+ $error: patch.error ?? null,
994
+ $updated: finishedAt,
995
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
996
+ $now: (opts.now ?? new Date(finishedAt)).toISOString()
938
997
  });
998
+ if (res.changes === 1) {
999
+ this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
1000
+ exitCode: patch.exitCode,
1001
+ error: patch.error
1002
+ });
1003
+ }
1004
+ this.db.exec("COMMIT");
1005
+ } catch (error) {
1006
+ try {
1007
+ this.db.exec("ROLLBACK");
1008
+ } catch {}
1009
+ throw error;
939
1010
  }
940
1011
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
941
1012
  if (!run)
942
1013
  throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
943
1014
  return run;
944
1015
  }
945
- skipWorkflowStepRun(workflowRunId, stepId, reason) {
946
- const now = nowIso();
947
- const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
948
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $finished: now, $error: reason, $updated: now });
949
- if (res.changes === 1)
950
- this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
1016
+ skipWorkflowStepRun(workflowRunId, stepId, reason, opts = {}) {
1017
+ const now = (opts.now ?? new Date).toISOString();
1018
+ this.db.exec("BEGIN IMMEDIATE");
1019
+ try {
1020
+ const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
1021
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')
1022
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1023
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1024
+ ))`).run({
1025
+ $workflowRunId: workflowRunId,
1026
+ $stepId: stepId,
1027
+ $finished: now,
1028
+ $error: reason,
1029
+ $updated: now,
1030
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1031
+ $now: now
1032
+ });
1033
+ if (res.changes === 1)
1034
+ this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
1035
+ this.db.exec("COMMIT");
1036
+ } catch (error) {
1037
+ try {
1038
+ this.db.exec("ROLLBACK");
1039
+ } catch {}
1040
+ throw error;
1041
+ }
951
1042
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
952
1043
  if (!run)
953
1044
  throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
954
1045
  return run;
955
1046
  }
956
- finalizeWorkflowRun(workflowRunId, status, patch = {}) {
1047
+ finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
957
1048
  const finishedAt = patch.finishedAt ?? nowIso();
958
- const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
959
- WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({
960
- $id: workflowRunId,
961
- $status: status,
962
- $finished: finishedAt,
963
- $durationMs: patch.durationMs ?? null,
964
- $error: patch.error ?? null,
965
- $updated: finishedAt
966
- });
1049
+ let changed = false;
1050
+ this.db.exec("BEGIN IMMEDIATE");
1051
+ try {
1052
+ const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
1053
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
1054
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1055
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1056
+ ))`).run({
1057
+ $id: workflowRunId,
1058
+ $status: status,
1059
+ $finished: finishedAt,
1060
+ $durationMs: patch.durationMs ?? null,
1061
+ $error: patch.error ?? null,
1062
+ $updated: finishedAt,
1063
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1064
+ $now: (opts.now ?? new Date(finishedAt)).toISOString()
1065
+ });
1066
+ changed = res.changes === 1;
1067
+ if (changed)
1068
+ this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
1069
+ this.db.exec("COMMIT");
1070
+ } catch (error) {
1071
+ try {
1072
+ this.db.exec("ROLLBACK");
1073
+ } catch {}
1074
+ throw error;
1075
+ }
967
1076
  const run = this.getWorkflowRun(workflowRunId);
968
1077
  if (!run)
969
1078
  throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
970
- if (res.changes === 1)
971
- this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
972
1079
  return run;
973
1080
  }
974
1081
  cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
@@ -1022,10 +1129,24 @@ class Store {
1022
1129
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
1023
1130
  return (row?.count ?? 0) > 0;
1024
1131
  }
1025
- markRunPid(id, pid, claimedBy) {
1026
- const now = nowIso();
1132
+ markRunPid(id, pid, claimedBy, opts = {}) {
1133
+ const now = (opts.now ?? new Date).toISOString();
1027
1134
  const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
1028
- WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $pid: pid, $updated: now, $claimedBy: claimedBy }) : this.db.query("UPDATE loop_runs SET pid=$pid, updated_at=$updated WHERE id=$id AND status='running'").run({ $id: id, $pid: pid, $updated: now });
1135
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy
1136
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1137
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1138
+ ))`).run({
1139
+ $id: id,
1140
+ $pid: pid,
1141
+ $updated: now,
1142
+ $claimedBy: claimedBy,
1143
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1144
+ $now: now
1145
+ }) : this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
1146
+ WHERE id=$id AND status='running'
1147
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1148
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1149
+ ))`).run({ $id: id, $pid: pid, $updated: now, $daemonLeaseId: opts.daemonLeaseId ?? null, $now: now });
1029
1150
  if (res.changes !== 1)
1030
1151
  return;
1031
1152
  return this.getRun(id);
@@ -1040,7 +1161,7 @@ class Store {
1040
1161
  AND wsr.pid IS NOT NULL`).all(loopRunId);
1041
1162
  return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
1042
1163
  }
1043
- createSkippedRun(loop, scheduledFor, reason) {
1164
+ createSkippedRun(loop, scheduledFor, reason, opts = {}) {
1044
1165
  const now = nowIso();
1045
1166
  const run = {
1046
1167
  id: genId(),
@@ -1054,21 +1175,31 @@ class Store {
1054
1175
  createdAt: now,
1055
1176
  updatedAt: now
1056
1177
  };
1057
- this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
1058
- claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
1059
- VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
1060
- NULL, NULL, $error, $created, $updated)`).run({
1061
- $id: run.id,
1062
- $loopId: run.loopId,
1063
- $loopName: run.loopName,
1064
- $scheduledFor: run.scheduledFor,
1065
- $attempt: run.attempt,
1066
- $status: run.status,
1067
- $finished: run.finishedAt ?? null,
1068
- $error: run.error ?? null,
1069
- $created: run.createdAt,
1070
- $updated: run.updatedAt
1071
- });
1178
+ this.db.exec("BEGIN IMMEDIATE");
1179
+ try {
1180
+ this.assertDaemonLeaseFence(opts, now);
1181
+ this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
1182
+ claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
1183
+ VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
1184
+ NULL, NULL, $error, $created, $updated)`).run({
1185
+ $id: run.id,
1186
+ $loopId: run.loopId,
1187
+ $loopName: run.loopName,
1188
+ $scheduledFor: run.scheduledFor,
1189
+ $attempt: run.attempt,
1190
+ $status: run.status,
1191
+ $finished: run.finishedAt ?? null,
1192
+ $error: run.error ?? null,
1193
+ $created: run.createdAt,
1194
+ $updated: run.updatedAt
1195
+ });
1196
+ this.db.exec("COMMIT");
1197
+ } catch (error) {
1198
+ try {
1199
+ this.db.exec("ROLLBACK");
1200
+ } catch {}
1201
+ throw error;
1202
+ }
1072
1203
  return this.getRunBySlot(loop.id, scheduledFor) ?? run;
1073
1204
  }
1074
1205
  getRun(id) {
@@ -1079,11 +1210,20 @@ class Store {
1079
1210
  const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
1080
1211
  return row ? rowToRun(row) : undefined;
1081
1212
  }
1082
- claimRun(loop, scheduledFor, runnerId, now = new Date) {
1213
+ nextRetryableRun(loopId, maxAttempts, afterScheduledFor) {
1214
+ const row = afterScheduledFor ? this.db.query(`SELECT * FROM loop_runs
1215
+ WHERE loop_id = ? AND scheduled_for > ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
1216
+ ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, afterScheduledFor, maxAttempts) : this.db.query(`SELECT * FROM loop_runs
1217
+ WHERE loop_id = ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
1218
+ ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, maxAttempts);
1219
+ return row ? rowToRun(row) : undefined;
1220
+ }
1221
+ claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
1083
1222
  const startedAt = now.toISOString();
1084
1223
  const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
1085
1224
  this.db.exec("BEGIN IMMEDIATE");
1086
1225
  try {
1226
+ this.assertDaemonLeaseFence(opts, startedAt);
1087
1227
  const existing = this.getRunBySlot(loop.id, scheduledFor);
1088
1228
  if (existing) {
1089
1229
  if (existing.status === "running") {
@@ -1178,11 +1318,15 @@ class Store {
1178
1318
  $error: patch.error ?? null,
1179
1319
  $updated: finishedAt,
1180
1320
  $claimedBy: opts.claimedBy ?? null,
1181
- $now: (opts.now ?? new Date).toISOString()
1321
+ $now: (opts.now ?? new Date).toISOString(),
1322
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1182
1323
  };
1183
1324
  const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
1184
1325
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
1185
- WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
1326
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
1327
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1328
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1329
+ ))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
1186
1330
  duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
1187
1331
  const run = this.getRun(id);
1188
1332
  if (!run)
@@ -1191,10 +1335,20 @@ class Store {
1191
1335
  return run;
1192
1336
  return run;
1193
1337
  }
1194
- heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
1338
+ heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
1195
1339
  const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
1196
1340
  const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
1197
- WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $claimedBy: claimedBy, $expires: expiresAt, $updated: now.toISOString() });
1341
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
1342
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1343
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1344
+ ))`).run({
1345
+ $id: id,
1346
+ $claimedBy: claimedBy,
1347
+ $expires: expiresAt,
1348
+ $updated: now.toISOString(),
1349
+ $now: now.toISOString(),
1350
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1351
+ });
1198
1352
  if (res.changes !== 1)
1199
1353
  return;
1200
1354
  return this.getRun(id);
@@ -1213,7 +1367,7 @@ class Store {
1213
1367
  }
1214
1368
  return rows.map(rowToRun);
1215
1369
  }
1216
- recoverExpiredRunLeases(now = new Date) {
1370
+ recoverExpiredRunLeases(now = new Date, opts = {}) {
1217
1371
  const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
1218
1372
  const recovered = [];
1219
1373
  for (const row of rows) {
@@ -1222,20 +1376,63 @@ class Store {
1222
1376
  if (this.hasLiveWorkflowStepProcesses(row.id))
1223
1377
  continue;
1224
1378
  const finished = now.toISOString();
1225
- this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
1226
- error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: finished, $updated: finished });
1227
- 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);
1228
- for (const workflowRow of workflowRows) {
1229
- this.db.query(`UPDATE workflow_runs
1230
- SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
1231
- WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRow.id, $finished: finished, $updated: finished });
1232
- this.db.query(`UPDATE workflow_step_runs
1233
- SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
1234
- WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRow.id, $finished: finished, $updated: finished });
1235
- this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
1236
- error: "parent loop run lease expired before completion",
1237
- loopRunId: row.id
1379
+ this.db.exec("BEGIN IMMEDIATE");
1380
+ try {
1381
+ const res = this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
1382
+ error='run lease expired before completion', updated_at=$updated
1383
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
1384
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1385
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1386
+ ))`).run({
1387
+ $id: row.id,
1388
+ $finished: finished,
1389
+ $updated: finished,
1390
+ $now: finished,
1391
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1238
1392
  });
1393
+ if (res.changes !== 1) {
1394
+ this.db.exec("COMMIT");
1395
+ continue;
1396
+ }
1397
+ const workflowRows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')").all(row.id);
1398
+ for (const workflowRow of workflowRows) {
1399
+ const workflowRes = this.db.query(`UPDATE workflow_runs
1400
+ SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
1401
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
1402
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1403
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1404
+ ))`).run({
1405
+ $id: workflowRow.id,
1406
+ $finished: finished,
1407
+ $updated: finished,
1408
+ $now: finished,
1409
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1410
+ });
1411
+ if (workflowRes.changes !== 1)
1412
+ continue;
1413
+ this.db.query(`UPDATE workflow_step_runs
1414
+ SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
1415
+ WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')
1416
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1417
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1418
+ ))`).run({
1419
+ $workflowRunId: workflowRow.id,
1420
+ $finished: finished,
1421
+ $updated: finished,
1422
+ $now: finished,
1423
+ $daemonLeaseId: opts.daemonLeaseId ?? null
1424
+ });
1425
+ this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
1426
+ error: "parent loop run lease expired before completion",
1427
+ loopRunId: row.id
1428
+ });
1429
+ }
1430
+ this.db.exec("COMMIT");
1431
+ } catch (error) {
1432
+ try {
1433
+ this.db.exec("ROLLBACK");
1434
+ } catch {}
1435
+ throw error;
1239
1436
  }
1240
1437
  const run = this.getRun(row.id);
1241
1438
  if (run)
@@ -1243,11 +1440,14 @@ class Store {
1243
1440
  }
1244
1441
  return recovered;
1245
1442
  }
1246
- expireLoops(now = new Date) {
1443
+ expireLoops(now = new Date, opts = {}) {
1247
1444
  const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
1248
1445
  const expired = [];
1249
- for (const row of rows)
1250
- expired.push(this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }));
1446
+ for (const row of rows) {
1447
+ const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
1448
+ if (updated.status === "expired")
1449
+ expired.push(updated);
1450
+ }
1251
1451
  return expired;
1252
1452
  }
1253
1453
  countLoops(status) {
@@ -1290,7 +1490,7 @@ class Store {
1290
1490
  }
1291
1491
  heartbeatDaemonLease(id, ttlMs, now = new Date) {
1292
1492
  const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
1293
- const res = this.db.query(`UPDATE daemon_lease SET heartbeat_at=$heartbeat, expires_at=$expires, updated_at=$updated WHERE id=$id`).run({ $id: id, $heartbeat: now.toISOString(), $expires: expiresAt, $updated: now.toISOString() });
1493
+ const res = this.db.query(`UPDATE daemon_lease SET heartbeat_at=$heartbeat, expires_at=$expires, updated_at=$updated WHERE id=$id AND expires_at > $now`).run({ $id: id, $heartbeat: now.toISOString(), $expires: expiresAt, $updated: now.toISOString(), $now: now.toISOString() });
1294
1494
  if (res.changes !== 1)
1295
1495
  return;
1296
1496
  return this.getDaemonLease();