@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/cli/index.js
CHANGED
|
@@ -615,6 +615,13 @@ class Store {
|
|
|
615
615
|
} catch {}
|
|
616
616
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
617
617
|
}
|
|
618
|
+
assertDaemonLeaseFence(opts = {}, now = nowIso()) {
|
|
619
|
+
if (!opts.daemonLeaseId)
|
|
620
|
+
return;
|
|
621
|
+
const row = this.db.query("SELECT id FROM daemon_lease WHERE id = ? AND expires_at > ?").get(opts.daemonLeaseId, now);
|
|
622
|
+
if (!row)
|
|
623
|
+
throw new Error("daemon lease lost");
|
|
624
|
+
}
|
|
618
625
|
createLoop(input, from = new Date) {
|
|
619
626
|
const now = nowIso();
|
|
620
627
|
const loop = {
|
|
@@ -684,21 +691,31 @@ class Store {
|
|
|
684
691
|
ORDER BY next_run_at ASC`).all(now.toISOString());
|
|
685
692
|
return rows.map(rowToLoop);
|
|
686
693
|
}
|
|
687
|
-
updateLoop(id, patch) {
|
|
694
|
+
updateLoop(id, patch, opts = {}) {
|
|
688
695
|
const current = this.getLoop(id);
|
|
689
696
|
if (!current)
|
|
690
697
|
throw new Error(`loop not found: ${id}`);
|
|
691
|
-
const
|
|
698
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
699
|
+
const merged = { ...current, ...patch, updatedAt: updated };
|
|
692
700
|
this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
|
|
693
|
-
expires_at=$expiresAt, updated_at=$updated
|
|
701
|
+
expires_at=$expiresAt, updated_at=$updated
|
|
702
|
+
WHERE id=$id
|
|
703
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
704
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
705
|
+
))`).run({
|
|
694
706
|
$id: id,
|
|
695
707
|
$status: merged.status,
|
|
696
708
|
$nextRun: merged.nextRunAt ?? null,
|
|
697
709
|
$retrySlot: merged.retryScheduledFor ?? null,
|
|
698
710
|
$expiresAt: merged.expiresAt ?? null,
|
|
699
|
-
$updated: merged.updatedAt
|
|
711
|
+
$updated: merged.updatedAt,
|
|
712
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
713
|
+
$now: updated
|
|
700
714
|
});
|
|
701
|
-
|
|
715
|
+
const after = this.getLoop(id);
|
|
716
|
+
if (!after)
|
|
717
|
+
throw new Error(`loop not found after update: ${id}`);
|
|
718
|
+
return after;
|
|
702
719
|
}
|
|
703
720
|
deleteLoop(idOrName) {
|
|
704
721
|
const loop = this.requireLoop(idOrName);
|
|
@@ -762,11 +779,14 @@ class Store {
|
|
|
762
779
|
const now = nowIso();
|
|
763
780
|
if (input.idempotencyKey) {
|
|
764
781
|
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)
|
|
782
|
+
if (existing) {
|
|
783
|
+
this.assertDaemonLeaseFence(input);
|
|
766
784
|
return rowToWorkflowRun(existing);
|
|
785
|
+
}
|
|
767
786
|
}
|
|
768
787
|
this.db.exec("BEGIN IMMEDIATE");
|
|
769
788
|
try {
|
|
789
|
+
this.assertDaemonLeaseFence(input, now);
|
|
770
790
|
if (input.idempotencyKey) {
|
|
771
791
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
772
792
|
if (existing) {
|
|
@@ -865,31 +885,60 @@ class Store {
|
|
|
865
885
|
const run = this.getWorkflowRun(workflowRunId);
|
|
866
886
|
return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
|
|
867
887
|
}
|
|
868
|
-
startWorkflowStepRun(workflowRunId, stepId) {
|
|
869
|
-
const now =
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
888
|
+
startWorkflowStepRun(workflowRunId, stepId, opts = {}) {
|
|
889
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
890
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
891
|
+
try {
|
|
892
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
893
|
+
SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
|
|
894
|
+
pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
895
|
+
WHERE workflow_run_id=$workflowRunId
|
|
896
|
+
AND step_id=$stepId
|
|
897
|
+
AND status IN ('pending', 'failed', 'timed_out')
|
|
898
|
+
AND EXISTS (
|
|
899
|
+
SELECT 1 FROM workflow_runs
|
|
900
|
+
WHERE id=$workflowRunId AND status='running'
|
|
901
|
+
)
|
|
902
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
903
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
904
|
+
))`).run({
|
|
905
|
+
$workflowRunId: workflowRunId,
|
|
906
|
+
$stepId: stepId,
|
|
907
|
+
$started: now,
|
|
908
|
+
$updated: now,
|
|
909
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
910
|
+
$now: now
|
|
911
|
+
});
|
|
912
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
913
|
+
if (!run)
|
|
914
|
+
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
915
|
+
if (res.changes !== 1) {
|
|
916
|
+
throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
|
|
917
|
+
}
|
|
918
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
919
|
+
this.db.exec("COMMIT");
|
|
920
|
+
return run;
|
|
921
|
+
} catch (error) {
|
|
922
|
+
try {
|
|
923
|
+
this.db.exec("ROLLBACK");
|
|
924
|
+
} catch {}
|
|
925
|
+
throw error;
|
|
885
926
|
}
|
|
886
|
-
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
887
|
-
return run;
|
|
888
927
|
}
|
|
889
|
-
markWorkflowStepPid(workflowRunId, stepId, pid) {
|
|
890
|
-
const now =
|
|
928
|
+
markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
|
|
929
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
891
930
|
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'
|
|
931
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
932
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
933
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
934
|
+
))`).run({
|
|
935
|
+
$workflowRunId: workflowRunId,
|
|
936
|
+
$stepId: stepId,
|
|
937
|
+
$pid: pid,
|
|
938
|
+
$updated: now,
|
|
939
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
940
|
+
$now: now
|
|
941
|
+
});
|
|
893
942
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
894
943
|
if (!run)
|
|
895
944
|
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
@@ -917,60 +966,110 @@ class Store {
|
|
|
917
966
|
recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
|
|
918
967
|
};
|
|
919
968
|
}
|
|
920
|
-
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
969
|
+
finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
|
|
921
970
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
971
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
972
|
+
try {
|
|
973
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
974
|
+
pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
975
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
976
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
977
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
978
|
+
))`).run({
|
|
979
|
+
$workflowRunId: workflowRunId,
|
|
980
|
+
$stepId: stepId,
|
|
981
|
+
$status: patch.status,
|
|
982
|
+
$finished: finishedAt,
|
|
983
|
+
$exitCode: patch.exitCode ?? null,
|
|
984
|
+
$durationMs: patch.durationMs ?? null,
|
|
985
|
+
$stdout: patch.stdout ?? null,
|
|
986
|
+
$stderr: patch.stderr ?? null,
|
|
987
|
+
$error: patch.error ?? null,
|
|
988
|
+
$updated: finishedAt,
|
|
989
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
990
|
+
$now: (opts.now ?? new Date(finishedAt)).toISOString()
|
|
940
991
|
});
|
|
992
|
+
if (res.changes === 1) {
|
|
993
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
994
|
+
exitCode: patch.exitCode,
|
|
995
|
+
error: patch.error
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
this.db.exec("COMMIT");
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
try {
|
|
1001
|
+
this.db.exec("ROLLBACK");
|
|
1002
|
+
} catch {}
|
|
1003
|
+
throw error;
|
|
941
1004
|
}
|
|
942
1005
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
943
1006
|
if (!run)
|
|
944
1007
|
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
945
1008
|
return run;
|
|
946
1009
|
}
|
|
947
|
-
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
948
|
-
const now =
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1010
|
+
skipWorkflowStepRun(workflowRunId, stepId, reason, opts = {}) {
|
|
1011
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1012
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1013
|
+
try {
|
|
1014
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
|
|
1015
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')
|
|
1016
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1017
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1018
|
+
))`).run({
|
|
1019
|
+
$workflowRunId: workflowRunId,
|
|
1020
|
+
$stepId: stepId,
|
|
1021
|
+
$finished: now,
|
|
1022
|
+
$error: reason,
|
|
1023
|
+
$updated: now,
|
|
1024
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1025
|
+
$now: now
|
|
1026
|
+
});
|
|
1027
|
+
if (res.changes === 1)
|
|
1028
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
1029
|
+
this.db.exec("COMMIT");
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
try {
|
|
1032
|
+
this.db.exec("ROLLBACK");
|
|
1033
|
+
} catch {}
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
953
1036
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
954
1037
|
if (!run)
|
|
955
1038
|
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
956
1039
|
return run;
|
|
957
1040
|
}
|
|
958
|
-
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
1041
|
+
finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
|
|
959
1042
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1043
|
+
let changed = false;
|
|
1044
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1045
|
+
try {
|
|
1046
|
+
const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
1047
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1048
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1049
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1050
|
+
))`).run({
|
|
1051
|
+
$id: workflowRunId,
|
|
1052
|
+
$status: status,
|
|
1053
|
+
$finished: finishedAt,
|
|
1054
|
+
$durationMs: patch.durationMs ?? null,
|
|
1055
|
+
$error: patch.error ?? null,
|
|
1056
|
+
$updated: finishedAt,
|
|
1057
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1058
|
+
$now: (opts.now ?? new Date(finishedAt)).toISOString()
|
|
1059
|
+
});
|
|
1060
|
+
changed = res.changes === 1;
|
|
1061
|
+
if (changed)
|
|
1062
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
1063
|
+
this.db.exec("COMMIT");
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
try {
|
|
1066
|
+
this.db.exec("ROLLBACK");
|
|
1067
|
+
} catch {}
|
|
1068
|
+
throw error;
|
|
1069
|
+
}
|
|
969
1070
|
const run = this.getWorkflowRun(workflowRunId);
|
|
970
1071
|
if (!run)
|
|
971
1072
|
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
1073
|
return run;
|
|
975
1074
|
}
|
|
976
1075
|
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
@@ -1024,10 +1123,24 @@ class Store {
|
|
|
1024
1123
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
1025
1124
|
return (row?.count ?? 0) > 0;
|
|
1026
1125
|
}
|
|
1027
|
-
markRunPid(id, pid, claimedBy) {
|
|
1028
|
-
const now =
|
|
1126
|
+
markRunPid(id, pid, claimedBy, opts = {}) {
|
|
1127
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1029
1128
|
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
|
|
1129
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1130
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1131
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1132
|
+
))`).run({
|
|
1133
|
+
$id: id,
|
|
1134
|
+
$pid: pid,
|
|
1135
|
+
$updated: now,
|
|
1136
|
+
$claimedBy: claimedBy,
|
|
1137
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1138
|
+
$now: now
|
|
1139
|
+
}) : this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1140
|
+
WHERE id=$id AND status='running'
|
|
1141
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1142
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1143
|
+
))`).run({ $id: id, $pid: pid, $updated: now, $daemonLeaseId: opts.daemonLeaseId ?? null, $now: now });
|
|
1031
1144
|
if (res.changes !== 1)
|
|
1032
1145
|
return;
|
|
1033
1146
|
return this.getRun(id);
|
|
@@ -1042,7 +1155,7 @@ class Store {
|
|
|
1042
1155
|
AND wsr.pid IS NOT NULL`).all(loopRunId);
|
|
1043
1156
|
return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
|
|
1044
1157
|
}
|
|
1045
|
-
createSkippedRun(loop, scheduledFor, reason) {
|
|
1158
|
+
createSkippedRun(loop, scheduledFor, reason, opts = {}) {
|
|
1046
1159
|
const now = nowIso();
|
|
1047
1160
|
const run = {
|
|
1048
1161
|
id: genId(),
|
|
@@ -1056,21 +1169,31 @@ class Store {
|
|
|
1056
1169
|
createdAt: now,
|
|
1057
1170
|
updatedAt: now
|
|
1058
1171
|
};
|
|
1059
|
-
this.db.
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1172
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1173
|
+
try {
|
|
1174
|
+
this.assertDaemonLeaseFence(opts, now);
|
|
1175
|
+
this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
|
|
1176
|
+
claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
|
|
1177
|
+
VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
|
|
1178
|
+
NULL, NULL, $error, $created, $updated)`).run({
|
|
1179
|
+
$id: run.id,
|
|
1180
|
+
$loopId: run.loopId,
|
|
1181
|
+
$loopName: run.loopName,
|
|
1182
|
+
$scheduledFor: run.scheduledFor,
|
|
1183
|
+
$attempt: run.attempt,
|
|
1184
|
+
$status: run.status,
|
|
1185
|
+
$finished: run.finishedAt ?? null,
|
|
1186
|
+
$error: run.error ?? null,
|
|
1187
|
+
$created: run.createdAt,
|
|
1188
|
+
$updated: run.updatedAt
|
|
1189
|
+
});
|
|
1190
|
+
this.db.exec("COMMIT");
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
try {
|
|
1193
|
+
this.db.exec("ROLLBACK");
|
|
1194
|
+
} catch {}
|
|
1195
|
+
throw error;
|
|
1196
|
+
}
|
|
1074
1197
|
return this.getRunBySlot(loop.id, scheduledFor) ?? run;
|
|
1075
1198
|
}
|
|
1076
1199
|
getRun(id) {
|
|
@@ -1081,11 +1204,20 @@ class Store {
|
|
|
1081
1204
|
const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
|
|
1082
1205
|
return row ? rowToRun(row) : undefined;
|
|
1083
1206
|
}
|
|
1084
|
-
|
|
1207
|
+
nextRetryableRun(loopId, maxAttempts, afterScheduledFor) {
|
|
1208
|
+
const row = afterScheduledFor ? this.db.query(`SELECT * FROM loop_runs
|
|
1209
|
+
WHERE loop_id = ? AND scheduled_for > ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
|
|
1210
|
+
ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, afterScheduledFor, maxAttempts) : this.db.query(`SELECT * FROM loop_runs
|
|
1211
|
+
WHERE loop_id = ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
|
|
1212
|
+
ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, maxAttempts);
|
|
1213
|
+
return row ? rowToRun(row) : undefined;
|
|
1214
|
+
}
|
|
1215
|
+
claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
|
|
1085
1216
|
const startedAt = now.toISOString();
|
|
1086
1217
|
const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
|
|
1087
1218
|
this.db.exec("BEGIN IMMEDIATE");
|
|
1088
1219
|
try {
|
|
1220
|
+
this.assertDaemonLeaseFence(opts, startedAt);
|
|
1089
1221
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
1090
1222
|
if (existing) {
|
|
1091
1223
|
if (existing.status === "running") {
|
|
@@ -1180,11 +1312,15 @@ class Store {
|
|
|
1180
1312
|
$error: patch.error ?? null,
|
|
1181
1313
|
$updated: finishedAt,
|
|
1182
1314
|
$claimedBy: opts.claimedBy ?? null,
|
|
1183
|
-
$now: (opts.now ?? new Date).toISOString()
|
|
1315
|
+
$now: (opts.now ?? new Date).toISOString(),
|
|
1316
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1184
1317
|
};
|
|
1185
1318
|
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
1319
|
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
|
|
1320
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1321
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1322
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1323
|
+
))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1188
1324
|
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
|
|
1189
1325
|
const run = this.getRun(id);
|
|
1190
1326
|
if (!run)
|
|
@@ -1193,10 +1329,20 @@ class Store {
|
|
|
1193
1329
|
return run;
|
|
1194
1330
|
return run;
|
|
1195
1331
|
}
|
|
1196
|
-
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
|
|
1332
|
+
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
1197
1333
|
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
1198
1334
|
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
|
|
1335
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1336
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1337
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1338
|
+
))`).run({
|
|
1339
|
+
$id: id,
|
|
1340
|
+
$claimedBy: claimedBy,
|
|
1341
|
+
$expires: expiresAt,
|
|
1342
|
+
$updated: now.toISOString(),
|
|
1343
|
+
$now: now.toISOString(),
|
|
1344
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1345
|
+
});
|
|
1200
1346
|
if (res.changes !== 1)
|
|
1201
1347
|
return;
|
|
1202
1348
|
return this.getRun(id);
|
|
@@ -1215,7 +1361,7 @@ class Store {
|
|
|
1215
1361
|
}
|
|
1216
1362
|
return rows.map(rowToRun);
|
|
1217
1363
|
}
|
|
1218
|
-
recoverExpiredRunLeases(now = new Date) {
|
|
1364
|
+
recoverExpiredRunLeases(now = new Date, opts = {}) {
|
|
1219
1365
|
const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
|
|
1220
1366
|
const recovered = [];
|
|
1221
1367
|
for (const row of rows) {
|
|
@@ -1224,20 +1370,63 @@ class Store {
|
|
|
1224
1370
|
if (this.hasLiveWorkflowStepProcesses(row.id))
|
|
1225
1371
|
continue;
|
|
1226
1372
|
const finished = now.toISOString();
|
|
1227
|
-
this.db.
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1373
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1374
|
+
try {
|
|
1375
|
+
const res = this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
|
|
1376
|
+
error='run lease expired before completion', updated_at=$updated
|
|
1377
|
+
WHERE id=$id AND status='running' AND lease_expires_at <= $now
|
|
1378
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1379
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1380
|
+
))`).run({
|
|
1381
|
+
$id: row.id,
|
|
1382
|
+
$finished: finished,
|
|
1383
|
+
$updated: finished,
|
|
1384
|
+
$now: finished,
|
|
1385
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1240
1386
|
});
|
|
1387
|
+
if (res.changes !== 1) {
|
|
1388
|
+
this.db.exec("COMMIT");
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
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);
|
|
1392
|
+
for (const workflowRow of workflowRows) {
|
|
1393
|
+
const workflowRes = this.db.query(`UPDATE workflow_runs
|
|
1394
|
+
SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1395
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1396
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1397
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1398
|
+
))`).run({
|
|
1399
|
+
$id: workflowRow.id,
|
|
1400
|
+
$finished: finished,
|
|
1401
|
+
$updated: finished,
|
|
1402
|
+
$now: finished,
|
|
1403
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1404
|
+
});
|
|
1405
|
+
if (workflowRes.changes !== 1)
|
|
1406
|
+
continue;
|
|
1407
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
1408
|
+
SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1409
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')
|
|
1410
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1411
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1412
|
+
))`).run({
|
|
1413
|
+
$workflowRunId: workflowRow.id,
|
|
1414
|
+
$finished: finished,
|
|
1415
|
+
$updated: finished,
|
|
1416
|
+
$now: finished,
|
|
1417
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1418
|
+
});
|
|
1419
|
+
this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
|
|
1420
|
+
error: "parent loop run lease expired before completion",
|
|
1421
|
+
loopRunId: row.id
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
this.db.exec("COMMIT");
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
try {
|
|
1427
|
+
this.db.exec("ROLLBACK");
|
|
1428
|
+
} catch {}
|
|
1429
|
+
throw error;
|
|
1241
1430
|
}
|
|
1242
1431
|
const run = this.getRun(row.id);
|
|
1243
1432
|
if (run)
|
|
@@ -1245,11 +1434,14 @@ class Store {
|
|
|
1245
1434
|
}
|
|
1246
1435
|
return recovered;
|
|
1247
1436
|
}
|
|
1248
|
-
expireLoops(now = new Date) {
|
|
1437
|
+
expireLoops(now = new Date, opts = {}) {
|
|
1249
1438
|
const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
|
|
1250
1439
|
const expired = [];
|
|
1251
|
-
for (const row of rows)
|
|
1252
|
-
|
|
1440
|
+
for (const row of rows) {
|
|
1441
|
+
const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
|
|
1442
|
+
if (updated.status === "expired")
|
|
1443
|
+
expired.push(updated);
|
|
1444
|
+
}
|
|
1253
1445
|
return expired;
|
|
1254
1446
|
}
|
|
1255
1447
|
countLoops(status) {
|
|
@@ -1292,7 +1484,7 @@ class Store {
|
|
|
1292
1484
|
}
|
|
1293
1485
|
heartbeatDaemonLease(id, ttlMs, now = new Date) {
|
|
1294
1486
|
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() });
|
|
1487
|
+
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
1488
|
if (res.changes !== 1)
|
|
1297
1489
|
return;
|
|
1298
1490
|
return this.getDaemonLease();
|
|
@@ -1315,11 +1507,14 @@ import { Command } from "commander";
|
|
|
1315
1507
|
|
|
1316
1508
|
// src/lib/format.ts
|
|
1317
1509
|
var TEXT_OUTPUT_LIMIT = 32 * 1024;
|
|
1318
|
-
|
|
1510
|
+
var SENSITIVE_PAYLOAD_KEYS = new Set(["env", "error", "prompt", "reason", "stderr", "stdout"]);
|
|
1511
|
+
function redact(value, visible = 0) {
|
|
1319
1512
|
if (!value)
|
|
1320
1513
|
return value;
|
|
1321
1514
|
if (value.length <= visible)
|
|
1322
1515
|
return value;
|
|
1516
|
+
if (visible <= 0)
|
|
1517
|
+
return `[redacted ${value.length} chars]`;
|
|
1323
1518
|
return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
|
|
1324
1519
|
}
|
|
1325
1520
|
function truncateTextOutput(value) {
|
|
@@ -1328,6 +1523,21 @@ function truncateTextOutput(value) {
|
|
|
1328
1523
|
return `${value.slice(0, TEXT_OUTPUT_LIMIT)}
|
|
1329
1524
|
[truncated ${value.length - TEXT_OUTPUT_LIMIT} chars]`;
|
|
1330
1525
|
}
|
|
1526
|
+
function redactSensitivePayload(value, key) {
|
|
1527
|
+
if (key && SENSITIVE_PAYLOAD_KEYS.has(key)) {
|
|
1528
|
+
if (typeof value === "string")
|
|
1529
|
+
return redact(value);
|
|
1530
|
+
if (value === undefined || value === null)
|
|
1531
|
+
return value;
|
|
1532
|
+
return "[redacted]";
|
|
1533
|
+
}
|
|
1534
|
+
if (Array.isArray(value))
|
|
1535
|
+
return value.map((item) => redactSensitivePayload(item));
|
|
1536
|
+
if (value && typeof value === "object") {
|
|
1537
|
+
return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactSensitivePayload(entryValue, entryKey)]));
|
|
1538
|
+
}
|
|
1539
|
+
return value;
|
|
1540
|
+
}
|
|
1331
1541
|
function textOutputBlocks(value, opts = {}) {
|
|
1332
1542
|
const indent = opts.indent ?? "";
|
|
1333
1543
|
const nested = `${indent} `;
|
|
@@ -1359,6 +1569,14 @@ function publicRun(run, showOutput = false) {
|
|
|
1359
1569
|
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
|
|
1360
1570
|
};
|
|
1361
1571
|
}
|
|
1572
|
+
function publicExecutorResult(result, showOutput = false) {
|
|
1573
|
+
return {
|
|
1574
|
+
...result,
|
|
1575
|
+
stdout: showOutput ? result.stdout : result.stdout ? `[redacted ${result.stdout.length} chars]` : undefined,
|
|
1576
|
+
stderr: showOutput ? result.stderr : result.stderr ? `[redacted ${result.stderr.length} chars]` : undefined,
|
|
1577
|
+
error: redact(result.error)
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1362
1580
|
function publicWorkflow(workflow) {
|
|
1363
1581
|
return {
|
|
1364
1582
|
...workflow,
|
|
@@ -1369,17 +1587,18 @@ function publicWorkflow(workflow) {
|
|
|
1369
1587
|
};
|
|
1370
1588
|
}
|
|
1371
1589
|
function publicWorkflowRun(run) {
|
|
1372
|
-
return { ...run };
|
|
1590
|
+
return { ...run, error: redact(run.error) };
|
|
1373
1591
|
}
|
|
1374
1592
|
function publicWorkflowStepRun(run, showOutput = false) {
|
|
1375
1593
|
return {
|
|
1376
1594
|
...run,
|
|
1377
1595
|
stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
|
|
1378
|
-
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
|
|
1596
|
+
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
|
|
1597
|
+
error: redact(run.error)
|
|
1379
1598
|
};
|
|
1380
1599
|
}
|
|
1381
1600
|
function publicWorkflowEvent(event) {
|
|
1382
|
-
return { ...event };
|
|
1601
|
+
return { ...event, payload: redactSensitivePayload(event.payload) };
|
|
1383
1602
|
}
|
|
1384
1603
|
|
|
1385
1604
|
// src/lib/executor.ts
|
|
@@ -1615,7 +1834,7 @@ function agentArgs(target) {
|
|
|
1615
1834
|
args.push("--model", target.model);
|
|
1616
1835
|
if (target.agent)
|
|
1617
1836
|
args.push("--agent", target.agent);
|
|
1618
|
-
args.push(...target.extraArgs ?? []
|
|
1837
|
+
args.push(...target.extraArgs ?? []);
|
|
1619
1838
|
return args;
|
|
1620
1839
|
case "cursor":
|
|
1621
1840
|
args.push("-p");
|
|
@@ -1623,7 +1842,7 @@ function agentArgs(target) {
|
|
|
1623
1842
|
args.push("--model", target.model);
|
|
1624
1843
|
if (target.agent)
|
|
1625
1844
|
args.push("--agent", target.agent);
|
|
1626
|
-
args.push(...target.extraArgs ?? []
|
|
1845
|
+
args.push(...target.extraArgs ?? []);
|
|
1627
1846
|
return args;
|
|
1628
1847
|
case "codewith":
|
|
1629
1848
|
args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
|
|
@@ -1635,7 +1854,7 @@ function agentArgs(target) {
|
|
|
1635
1854
|
args.push("--model", target.model);
|
|
1636
1855
|
if (target.agent)
|
|
1637
1856
|
args.push("--agent", target.agent);
|
|
1638
|
-
args.push(...target.extraArgs ?? []
|
|
1857
|
+
args.push(...target.extraArgs ?? []);
|
|
1639
1858
|
return args;
|
|
1640
1859
|
case "codex":
|
|
1641
1860
|
args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
|
|
@@ -1645,7 +1864,7 @@ function agentArgs(target) {
|
|
|
1645
1864
|
args.push("--cd", target.cwd);
|
|
1646
1865
|
if (target.model)
|
|
1647
1866
|
args.push("--model", target.model);
|
|
1648
|
-
args.push(...target.extraArgs ?? []
|
|
1867
|
+
args.push(...target.extraArgs ?? []);
|
|
1649
1868
|
return args;
|
|
1650
1869
|
case "aicopilot":
|
|
1651
1870
|
args.push("run", "--format", "json");
|
|
@@ -1657,7 +1876,7 @@ function agentArgs(target) {
|
|
|
1657
1876
|
args.push("--model", target.model);
|
|
1658
1877
|
if (target.agent)
|
|
1659
1878
|
args.push("--agent", target.agent);
|
|
1660
|
-
args.push(...target.extraArgs ?? []
|
|
1879
|
+
args.push(...target.extraArgs ?? []);
|
|
1661
1880
|
return args;
|
|
1662
1881
|
case "opencode":
|
|
1663
1882
|
args.push("run", "--format", "json");
|
|
@@ -1669,7 +1888,7 @@ function agentArgs(target) {
|
|
|
1669
1888
|
args.push("--model", target.model);
|
|
1670
1889
|
if (target.agent)
|
|
1671
1890
|
args.push("--agent", target.agent);
|
|
1672
|
-
args.push(...target.extraArgs ?? []
|
|
1891
|
+
args.push(...target.extraArgs ?? []);
|
|
1673
1892
|
return args;
|
|
1674
1893
|
}
|
|
1675
1894
|
}
|
|
@@ -1694,7 +1913,8 @@ function commandSpec(target) {
|
|
|
1694
1913
|
cwd: agentTarget.cwd,
|
|
1695
1914
|
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
1696
1915
|
account: agentTarget.account,
|
|
1697
|
-
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
|
|
1916
|
+
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
1917
|
+
stdin: agentTarget.prompt
|
|
1698
1918
|
};
|
|
1699
1919
|
}
|
|
1700
1920
|
function executionEnv(spec, metadata, opts) {
|
|
@@ -1763,10 +1983,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1763
1983
|
env,
|
|
1764
1984
|
shell: spec.shell ?? false,
|
|
1765
1985
|
detached: true,
|
|
1766
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
1986
|
+
stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
|
|
1767
1987
|
});
|
|
1768
1988
|
if (child.pid)
|
|
1769
1989
|
opts.onSpawn?.(child.pid);
|
|
1990
|
+
if (spec.stdin !== undefined && child.stdin) {
|
|
1991
|
+
child.stdin.on("error", (err) => {
|
|
1992
|
+
if (err.code !== "EPIPE")
|
|
1993
|
+
error = err.message;
|
|
1994
|
+
});
|
|
1995
|
+
child.stdin.end(spec.stdin);
|
|
1996
|
+
}
|
|
1770
1997
|
const abortHandler = () => {
|
|
1771
1998
|
error = "cancelled";
|
|
1772
1999
|
if (child.pid)
|
|
@@ -1775,10 +2002,10 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1775
2002
|
if (opts.signal?.aborted)
|
|
1776
2003
|
abortHandler();
|
|
1777
2004
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1778
|
-
child.stdout
|
|
2005
|
+
child.stdout?.on("data", (chunk) => {
|
|
1779
2006
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
1780
2007
|
});
|
|
1781
|
-
child.stderr
|
|
2008
|
+
child.stderr?.on("data", (chunk) => {
|
|
1782
2009
|
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
1783
2010
|
});
|
|
1784
2011
|
const timer = setTimeout(() => {
|
|
@@ -1877,7 +2104,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1877
2104
|
loop: opts.loop,
|
|
1878
2105
|
loopRun: opts.loopRun,
|
|
1879
2106
|
scheduledFor: opts.scheduledFor,
|
|
1880
|
-
idempotencyKey: opts.idempotencyKey
|
|
2107
|
+
idempotencyKey: opts.idempotencyKey,
|
|
2108
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
1881
2109
|
});
|
|
1882
2110
|
const startedAt = run.startedAt ?? nowIso();
|
|
1883
2111
|
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
@@ -1911,12 +2139,14 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1911
2139
|
return !dependencyStep?.continueOnFailure;
|
|
1912
2140
|
});
|
|
1913
2141
|
if (blockedBy) {
|
|
1914
|
-
|
|
2142
|
+
opts.beforePersist?.();
|
|
2143
|
+
store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
|
|
1915
2144
|
blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
|
|
1916
2145
|
terminalStatus = "failed";
|
|
1917
2146
|
continue;
|
|
1918
2147
|
}
|
|
1919
|
-
|
|
2148
|
+
opts.beforePersist?.();
|
|
2149
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
|
|
1920
2150
|
if (startedStep.status !== "running") {
|
|
1921
2151
|
terminalStatus = "failed";
|
|
1922
2152
|
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
@@ -1948,7 +2178,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1948
2178
|
...opts,
|
|
1949
2179
|
signal: controller.signal,
|
|
1950
2180
|
onSpawn: (pid) => {
|
|
1951
|
-
|
|
2181
|
+
opts.beforePersist?.();
|
|
2182
|
+
store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
|
|
1952
2183
|
opts.onSpawn?.(pid);
|
|
1953
2184
|
}
|
|
1954
2185
|
});
|
|
@@ -1976,6 +2207,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1976
2207
|
blockingError = "workflow run was cancelled";
|
|
1977
2208
|
break;
|
|
1978
2209
|
}
|
|
2210
|
+
opts.beforePersist?.();
|
|
1979
2211
|
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1980
2212
|
status: result.status,
|
|
1981
2213
|
finishedAt: result.finishedAt,
|
|
@@ -1984,6 +2216,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1984
2216
|
stderr: result.stderr,
|
|
1985
2217
|
exitCode: result.exitCode,
|
|
1986
2218
|
error: result.error
|
|
2219
|
+
}, {
|
|
2220
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
1987
2221
|
});
|
|
1988
2222
|
if (result.status !== "succeeded" && !step.continueOnFailure) {
|
|
1989
2223
|
terminalStatus = result.status;
|
|
@@ -1995,7 +2229,9 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1995
2229
|
for (const step of ordered) {
|
|
1996
2230
|
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1997
2231
|
if (existing?.status === "pending" || existing?.status === "running") {
|
|
1998
|
-
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run"
|
|
2232
|
+
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
|
|
2233
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
2234
|
+
});
|
|
1999
2235
|
}
|
|
2000
2236
|
}
|
|
2001
2237
|
}
|
|
@@ -2005,10 +2241,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
2005
2241
|
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
2006
2242
|
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
2007
2243
|
}
|
|
2244
|
+
opts.beforePersist?.();
|
|
2008
2245
|
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
2009
2246
|
finishedAt,
|
|
2010
2247
|
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
2011
2248
|
error: blockingError
|
|
2249
|
+
}, {
|
|
2250
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
2012
2251
|
});
|
|
2013
2252
|
const steps = store.listWorkflowStepRuns(run.id);
|
|
2014
2253
|
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
@@ -2056,52 +2295,81 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
2056
2295
|
|
|
2057
2296
|
// src/lib/scheduler.ts
|
|
2058
2297
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
2059
|
-
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
2298
|
+
if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
2060
2299
|
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
2061
2300
|
}
|
|
2062
2301
|
return now.toISOString();
|
|
2063
2302
|
}
|
|
2064
2303
|
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
2304
|
+
if (loop.status !== "active")
|
|
2305
|
+
return false;
|
|
2065
2306
|
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
2066
2307
|
return false;
|
|
2067
2308
|
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
2068
2309
|
}
|
|
2310
|
+
function manualRunSource(loop, scheduledFor, now = new Date) {
|
|
2311
|
+
if (loop.status !== "active")
|
|
2312
|
+
return "ad_hoc";
|
|
2313
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
2314
|
+
return "ad_hoc";
|
|
2315
|
+
if (loop.retryScheduledFor && scheduledFor === loop.retryScheduledFor)
|
|
2316
|
+
return "retry_slot";
|
|
2317
|
+
return "due_slot";
|
|
2318
|
+
}
|
|
2069
2319
|
function nextAfterRetry(loop, now) {
|
|
2070
2320
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
2071
2321
|
}
|
|
2072
|
-
function
|
|
2322
|
+
function isDaemonLeaseLost(error) {
|
|
2323
|
+
return error instanceof Error && error.message === "daemon lease lost";
|
|
2324
|
+
}
|
|
2325
|
+
function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
|
|
2073
2326
|
if (run.status === "running")
|
|
2074
2327
|
return;
|
|
2075
2328
|
const current = store.getLoop(loop.id);
|
|
2076
2329
|
if (!current || current.status !== "active")
|
|
2077
2330
|
return;
|
|
2078
|
-
|
|
2331
|
+
if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
|
|
2332
|
+
return;
|
|
2333
|
+
const shouldRetry = !succeeded && run.attempt < current.maxAttempts;
|
|
2079
2334
|
if (shouldRetry) {
|
|
2080
|
-
store.updateLoop(
|
|
2335
|
+
store.updateLoop(current.id, {
|
|
2081
2336
|
status: "active",
|
|
2082
|
-
nextRunAt: nextAfterRetry(
|
|
2337
|
+
nextRunAt: nextAfterRetry(current, finishedAt),
|
|
2083
2338
|
retryScheduledFor: run.scheduledFor
|
|
2084
|
-
});
|
|
2339
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2085
2340
|
return;
|
|
2086
2341
|
}
|
|
2087
|
-
const
|
|
2088
|
-
|
|
2342
|
+
const deferredRetry = store.nextRetryableRun(current.id, current.maxAttempts, run.scheduledFor);
|
|
2343
|
+
if (deferredRetry) {
|
|
2344
|
+
store.updateLoop(current.id, {
|
|
2345
|
+
status: "active",
|
|
2346
|
+
nextRunAt: nextAfterRetry(current, finishedAt),
|
|
2347
|
+
retryScheduledFor: deferredRetry.scheduledFor
|
|
2348
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
const nextRunAt = computeNextAfter(current.schedule, new Date(run.scheduledFor), finishedAt);
|
|
2352
|
+
store.updateLoop(current.id, {
|
|
2089
2353
|
status: nextRunAt ? "active" : "stopped",
|
|
2090
2354
|
nextRunAt,
|
|
2091
2355
|
retryScheduledFor: undefined
|
|
2092
|
-
});
|
|
2356
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2093
2357
|
}
|
|
2094
2358
|
async function executeClaimedRun(deps) {
|
|
2095
2359
|
let heartbeat;
|
|
2096
2360
|
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
2097
2361
|
heartbeat = setInterval(() => {
|
|
2098
|
-
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs
|
|
2362
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs, new Date, {
|
|
2363
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2364
|
+
});
|
|
2099
2365
|
}, heartbeatEveryMs);
|
|
2100
2366
|
heartbeat.unref();
|
|
2101
2367
|
try {
|
|
2102
2368
|
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
2103
|
-
|
|
2369
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2370
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId, { daemonLeaseId: deps.daemonLeaseId })
|
|
2104
2371
|
})))(deps.loop, deps.run);
|
|
2372
|
+
deps.beforeFinalize?.(deps.loop, deps.run);
|
|
2105
2373
|
return deps.store.finalizeRun(deps.run.id, {
|
|
2106
2374
|
status: result.status,
|
|
2107
2375
|
finishedAt: result.finishedAt,
|
|
@@ -2113,10 +2381,16 @@ async function executeClaimedRun(deps) {
|
|
|
2113
2381
|
pid: result.pid
|
|
2114
2382
|
}, {
|
|
2115
2383
|
claimedBy: deps.runnerId,
|
|
2384
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2116
2385
|
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
2117
2386
|
});
|
|
2118
2387
|
} catch (err) {
|
|
2119
2388
|
deps.onError?.(deps.loop, err);
|
|
2389
|
+
try {
|
|
2390
|
+
deps.beforeFinalize?.(deps.loop, deps.run);
|
|
2391
|
+
} catch {
|
|
2392
|
+
return deps.store.getRun(deps.run.id) ?? deps.run;
|
|
2393
|
+
}
|
|
2120
2394
|
const finishedAt = new Date;
|
|
2121
2395
|
return deps.store.finalizeRun(deps.run.id, {
|
|
2122
2396
|
status: "failed",
|
|
@@ -2127,6 +2401,7 @@ async function executeClaimedRun(deps) {
|
|
|
2127
2401
|
error: err instanceof Error ? err.message : String(err)
|
|
2128
2402
|
}, {
|
|
2129
2403
|
claimedBy: deps.runnerId,
|
|
2404
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2130
2405
|
now: deps.now?.() ?? finishedAt
|
|
2131
2406
|
});
|
|
2132
2407
|
} finally {
|
|
@@ -2136,15 +2411,33 @@ async function executeClaimedRun(deps) {
|
|
|
2136
2411
|
}
|
|
2137
2412
|
async function runSlot(deps, loop, scheduledFor) {
|
|
2138
2413
|
const now = deps.now?.() ?? new Date;
|
|
2414
|
+
deps.beforeRun?.(loop, scheduledFor);
|
|
2139
2415
|
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2140
|
-
|
|
2141
|
-
|
|
2416
|
+
let skipped;
|
|
2417
|
+
try {
|
|
2418
|
+
skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
|
|
2419
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2420
|
+
});
|
|
2421
|
+
} catch (error) {
|
|
2422
|
+
if (deps.daemonLeaseId && isDaemonLeaseLost(error))
|
|
2423
|
+
return;
|
|
2424
|
+
throw error;
|
|
2425
|
+
}
|
|
2426
|
+
advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
|
|
2142
2427
|
deps.onRun?.(skipped);
|
|
2143
2428
|
return skipped;
|
|
2144
2429
|
}
|
|
2145
|
-
|
|
2430
|
+
let claim;
|
|
2431
|
+
try {
|
|
2432
|
+
claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2433
|
+
} catch (error) {
|
|
2434
|
+
if (deps.daemonLeaseId && isDaemonLeaseLost(error))
|
|
2435
|
+
return;
|
|
2436
|
+
throw error;
|
|
2437
|
+
}
|
|
2146
2438
|
if (!claim)
|
|
2147
2439
|
return;
|
|
2440
|
+
deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
|
|
2148
2441
|
deps.onRun?.(claim.run);
|
|
2149
2442
|
const finalRun = await executeClaimedRun({
|
|
2150
2443
|
store: deps.store,
|
|
@@ -2153,21 +2446,42 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
2153
2446
|
run: claim.run,
|
|
2154
2447
|
now: deps.now,
|
|
2155
2448
|
execute: deps.execute,
|
|
2449
|
+
beforeFinalize: deps.beforeFinalize,
|
|
2450
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2156
2451
|
onError: deps.onError
|
|
2157
2452
|
});
|
|
2158
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2453
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded", { daemonLeaseId: deps.daemonLeaseId });
|
|
2159
2454
|
deps.onRun?.(finalRun);
|
|
2160
2455
|
return finalRun;
|
|
2161
2456
|
}
|
|
2162
2457
|
async function tick(deps) {
|
|
2163
2458
|
const now = deps.now?.() ?? new Date;
|
|
2164
|
-
const recovered = deps.store.recoverExpiredRunLeases(now);
|
|
2459
|
+
const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2460
|
+
const recoveredByLoop = new Map;
|
|
2165
2461
|
for (const run of recovered) {
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2462
|
+
recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
|
|
2463
|
+
}
|
|
2464
|
+
for (const runs of recoveredByLoop.values()) {
|
|
2465
|
+
const loop = deps.store.getLoop(runs[0].loopId);
|
|
2466
|
+
if (!loop)
|
|
2467
|
+
continue;
|
|
2468
|
+
const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
|
|
2469
|
+
if (retryable) {
|
|
2470
|
+
advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
|
|
2471
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2472
|
+
});
|
|
2473
|
+
continue;
|
|
2474
|
+
}
|
|
2475
|
+
for (const run of runs) {
|
|
2476
|
+
const current = deps.store.getLoop(run.loopId);
|
|
2477
|
+
if (current) {
|
|
2478
|
+
advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
|
|
2479
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2169
2483
|
}
|
|
2170
|
-
const expired = deps.store.expireLoops(now);
|
|
2484
|
+
const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2171
2485
|
const claimed = [];
|
|
2172
2486
|
const completed = [];
|
|
2173
2487
|
const skipped = [];
|
|
@@ -2388,8 +2702,10 @@ async function runDaemon(opts = {}) {
|
|
|
2388
2702
|
const result = await tick({
|
|
2389
2703
|
store,
|
|
2390
2704
|
runnerId,
|
|
2705
|
+
daemonLeaseId: leaseId,
|
|
2706
|
+
beforeRun: () => ensureLease(),
|
|
2391
2707
|
execute: async (loop, run) => {
|
|
2392
|
-
const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs /
|
|
2708
|
+
const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
|
|
2393
2709
|
const timer = setInterval(() => {
|
|
2394
2710
|
try {
|
|
2395
2711
|
ensureLease();
|
|
@@ -2401,8 +2717,14 @@ async function runDaemon(opts = {}) {
|
|
|
2401
2717
|
try {
|
|
2402
2718
|
const result2 = await executeLoopTarget(store, loop, run, {
|
|
2403
2719
|
signal: runAbort.signal,
|
|
2404
|
-
|
|
2720
|
+
beforePersist: () => ensureLease(),
|
|
2721
|
+
daemonLeaseId: leaseId,
|
|
2722
|
+
onSpawn: (pid) => {
|
|
2723
|
+
ensureLease();
|
|
2724
|
+
store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
|
|
2725
|
+
}
|
|
2405
2726
|
});
|
|
2727
|
+
ensureLease();
|
|
2406
2728
|
if (leaseLost)
|
|
2407
2729
|
throw new Error("daemon lease lost during run");
|
|
2408
2730
|
return result2;
|
|
@@ -2410,6 +2732,7 @@ async function runDaemon(opts = {}) {
|
|
|
2410
2732
|
clearInterval(timer);
|
|
2411
2733
|
}
|
|
2412
2734
|
},
|
|
2735
|
+
beforeFinalize: () => ensureLease(),
|
|
2413
2736
|
onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
2414
2737
|
});
|
|
2415
2738
|
const changed = result.completed.length + result.skipped.length + result.recovered.length + result.expired.length;
|
|
@@ -2615,7 +2938,7 @@ function runDoctor(store) {
|
|
|
2615
2938
|
|
|
2616
2939
|
// src/cli/index.ts
|
|
2617
2940
|
var program = new Command;
|
|
2618
|
-
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.
|
|
2941
|
+
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.3");
|
|
2619
2942
|
program.option("-j, --json", "print JSON");
|
|
2620
2943
|
function isJson() {
|
|
2621
2944
|
return Boolean(program.opts().json);
|
|
@@ -2834,7 +3157,7 @@ workflows.command("inspect <runId>").description("show a workflow run with steps
|
|
|
2834
3157
|
const events = store.listWorkflowEvents(run.id);
|
|
2835
3158
|
const value = {
|
|
2836
3159
|
workflowRun: publicWorkflowRun(run),
|
|
2837
|
-
steps: steps.map((step) => publicWorkflowStepRun(step
|
|
3160
|
+
steps: steps.map((step) => publicWorkflowStepRun(step)),
|
|
2838
3161
|
events: events.map(publicWorkflowEvent)
|
|
2839
3162
|
};
|
|
2840
3163
|
if (isJson())
|
|
@@ -2842,7 +3165,8 @@ workflows.command("inspect <runId>").description("show a workflow run with steps
|
|
|
2842
3165
|
else {
|
|
2843
3166
|
console.log(`${run.id} ${run.status} ${run.workflowName}`);
|
|
2844
3167
|
for (const step of steps) {
|
|
2845
|
-
|
|
3168
|
+
const publicStep = publicWorkflowStepRun(step);
|
|
3169
|
+
console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${publicStep.error ?? ""}`);
|
|
2846
3170
|
}
|
|
2847
3171
|
console.log(` events=${events.length}`);
|
|
2848
3172
|
}
|
|
@@ -2858,7 +3182,7 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
|
|
|
2858
3182
|
const run = store.listWorkflowRuns({ workflowId: workflow.id, limit: 1 })[0];
|
|
2859
3183
|
const steps = run ? store.listWorkflowStepRuns(run.id) : [];
|
|
2860
3184
|
const value = {
|
|
2861
|
-
result,
|
|
3185
|
+
result: publicExecutorResult(result),
|
|
2862
3186
|
workflowRun: run ? publicWorkflowRun(run) : undefined,
|
|
2863
3187
|
steps: steps.map((step) => publicWorkflowStepRun(step, opts.showOutput))
|
|
2864
3188
|
};
|
|
@@ -2867,7 +3191,8 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
|
|
|
2867
3191
|
else {
|
|
2868
3192
|
console.log(`${run?.id ?? workflow.id} ${result.status}`);
|
|
2869
3193
|
for (const step of steps) {
|
|
2870
|
-
|
|
3194
|
+
const publicStep = publicWorkflowStepRun(step, opts.showOutput);
|
|
3195
|
+
console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${publicStep.error ?? ""}`);
|
|
2871
3196
|
if (opts.showOutput)
|
|
2872
3197
|
printTextOutput(step);
|
|
2873
3198
|
}
|
|
@@ -3006,16 +3331,27 @@ program.command("run-now <idOrName>").option("--show-output", "show stdout/stder
|
|
|
3006
3331
|
const loop = store.requireLoop(idOrName);
|
|
3007
3332
|
const runnerId = `manual:${process.pid}`;
|
|
3008
3333
|
const now = new Date;
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3334
|
+
let scheduledFor = manualRunScheduledFor(loop, now);
|
|
3335
|
+
let source = manualRunSource(loop, scheduledFor, now);
|
|
3336
|
+
let shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
|
|
3337
|
+
let claim = store.claimRun(loop, scheduledFor, runnerId, now);
|
|
3338
|
+
if (!claim && shouldAdvance) {
|
|
3339
|
+
const existing = store.getRunBySlot(loop.id, scheduledFor);
|
|
3340
|
+
if (existing && existing.status !== "running") {
|
|
3341
|
+
scheduledFor = now.toISOString();
|
|
3342
|
+
source = "ad_hoc";
|
|
3343
|
+
shouldAdvance = false;
|
|
3344
|
+
claim = store.claimRun(loop, scheduledFor, runnerId, now);
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3012
3347
|
if (!claim)
|
|
3013
3348
|
throw new Error("could not claim manual run");
|
|
3014
3349
|
const run = await executeClaimedRun({ store, runnerId, loop: claim.loop, run: claim.run });
|
|
3015
3350
|
if (shouldAdvance) {
|
|
3016
3351
|
advanceLoop(store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
|
|
3017
3352
|
}
|
|
3018
|
-
|
|
3353
|
+
const value = { ...publicRun(run, opts.showOutput), runNow: { source, advancesLoop: shouldAdvance } };
|
|
3354
|
+
print(value, `${run.id} ${run.status} source=${source} slot=${run.scheduledFor}`);
|
|
3019
3355
|
if (!isJson() && opts.showOutput)
|
|
3020
3356
|
printTextOutput(run);
|
|
3021
3357
|
if (run.status !== "succeeded")
|