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