@hasna/loops 0.2.0 → 0.3.1
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 +25 -4
- package/dist/cli/index.js +686 -88
- package/dist/daemon/daemon.d.ts +1 -0
- package/dist/daemon/index.js +474 -71
- package/dist/daemon/install.d.ts +8 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +644 -79
- package/dist/lib/doctor.d.ts +13 -0
- package/dist/lib/env.d.ts +4 -0
- package/dist/lib/executor.d.ts +8 -0
- package/dist/lib/format.d.ts +3 -0
- package/dist/lib/scheduler.d.ts +12 -0
- package/dist/lib/store.d.ts +10 -0
- package/dist/lib/store.js +157 -20
- package/dist/lib/workflow-runner.d.ts +4 -1
- package/dist/sdk/index.js +440 -79
- package/dist/types.d.ts +3 -2
- package/docs/USAGE.md +25 -4
- package/package.json +1 -1
package/dist/sdk/index.js
CHANGED
|
@@ -247,6 +247,9 @@ function validateTarget(value, label) {
|
|
|
247
247
|
assertObject(value, label);
|
|
248
248
|
if (value.type === "command") {
|
|
249
249
|
assertString(value.command, `${label}.command`);
|
|
250
|
+
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
251
|
+
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
252
|
+
}
|
|
250
253
|
return value;
|
|
251
254
|
}
|
|
252
255
|
if (value.type === "agent") {
|
|
@@ -412,6 +415,7 @@ function rowToWorkflowStepRun(row) {
|
|
|
412
415
|
startedAt: row.started_at ?? undefined,
|
|
413
416
|
finishedAt: row.finished_at ?? undefined,
|
|
414
417
|
exitCode: row.exit_code ?? undefined,
|
|
418
|
+
pid: row.pid ?? undefined,
|
|
415
419
|
durationMs: row.duration_ms ?? undefined,
|
|
416
420
|
stdout: row.stdout ?? undefined,
|
|
417
421
|
stderr: row.stderr ?? undefined,
|
|
@@ -433,6 +437,14 @@ function rowToWorkflowEvent(row) {
|
|
|
433
437
|
createdAt: row.created_at
|
|
434
438
|
};
|
|
435
439
|
}
|
|
440
|
+
function isProcessAlive(pid) {
|
|
441
|
+
try {
|
|
442
|
+
process.kill(pid, 0);
|
|
443
|
+
return true;
|
|
444
|
+
} catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
436
448
|
function rowToLease(row) {
|
|
437
449
|
return {
|
|
438
450
|
id: row.id,
|
|
@@ -565,6 +577,7 @@ class Store {
|
|
|
565
577
|
started_at TEXT,
|
|
566
578
|
finished_at TEXT,
|
|
567
579
|
exit_code INTEGER,
|
|
580
|
+
pid INTEGER,
|
|
568
581
|
duration_ms INTEGER,
|
|
569
582
|
stdout TEXT,
|
|
570
583
|
stderr TEXT,
|
|
@@ -590,6 +603,9 @@ class Store {
|
|
|
590
603
|
);
|
|
591
604
|
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
|
|
592
605
|
`);
|
|
606
|
+
try {
|
|
607
|
+
this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
|
|
608
|
+
} catch {}
|
|
593
609
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
594
610
|
}
|
|
595
611
|
createLoop(input, from = new Date) {
|
|
@@ -770,8 +786,8 @@ class Store {
|
|
|
770
786
|
input.workflow.steps.forEach((step, sequence) => {
|
|
771
787
|
const account = step.account ?? step.target.account;
|
|
772
788
|
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
773
|
-
exit_code, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
|
|
774
|
-
VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
789
|
+
exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
|
|
790
|
+
VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
775
791
|
$accountProfile, $accountTool, $created, $updated)`).run({
|
|
776
792
|
$id: genId(),
|
|
777
793
|
$workflowRunId: runId,
|
|
@@ -812,6 +828,12 @@ class Store {
|
|
|
812
828
|
const row = this.db.query("SELECT * FROM workflow_runs WHERE id = ?").get(id);
|
|
813
829
|
return row ? rowToWorkflowRun(row) : undefined;
|
|
814
830
|
}
|
|
831
|
+
requireWorkflowRun(id) {
|
|
832
|
+
const run = this.getWorkflowRun(id);
|
|
833
|
+
if (!run)
|
|
834
|
+
throw new Error(`workflow run not found: ${id}`);
|
|
835
|
+
return run;
|
|
836
|
+
}
|
|
815
837
|
listWorkflowRuns(opts = {}) {
|
|
816
838
|
const limit = opts.limit ?? 100;
|
|
817
839
|
let rows;
|
|
@@ -832,23 +854,67 @@ class Store {
|
|
|
832
854
|
const row = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? AND step_id = ?").get(workflowRunId, stepId);
|
|
833
855
|
return row ? rowToWorkflowStepRun(row) : undefined;
|
|
834
856
|
}
|
|
857
|
+
isWorkflowRunTerminal(workflowRunId) {
|
|
858
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
859
|
+
return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
|
|
860
|
+
}
|
|
835
861
|
startWorkflowStepRun(workflowRunId, stepId) {
|
|
836
862
|
const now = nowIso();
|
|
837
|
-
this.db.query(`UPDATE workflow_step_runs
|
|
863
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
838
864
|
SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
|
|
839
|
-
stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
840
|
-
WHERE workflow_run_id=$workflowRunId
|
|
841
|
-
|
|
865
|
+
pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
866
|
+
WHERE workflow_run_id=$workflowRunId
|
|
867
|
+
AND step_id=$stepId
|
|
868
|
+
AND status IN ('pending', 'failed', 'timed_out')
|
|
869
|
+
AND EXISTS (
|
|
870
|
+
SELECT 1 FROM workflow_runs
|
|
871
|
+
WHERE id=$workflowRunId AND status='running'
|
|
872
|
+
)`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
|
|
842
873
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
843
874
|
if (!run)
|
|
844
875
|
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
876
|
+
if (res.changes !== 1) {
|
|
877
|
+
throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
|
|
878
|
+
}
|
|
879
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
880
|
+
return run;
|
|
881
|
+
}
|
|
882
|
+
markWorkflowStepPid(workflowRunId, stepId, pid) {
|
|
883
|
+
const now = nowIso();
|
|
884
|
+
this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
|
|
885
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $pid: pid, $updated: now });
|
|
886
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
887
|
+
if (!run)
|
|
888
|
+
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
845
889
|
return run;
|
|
846
890
|
}
|
|
891
|
+
recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
|
|
892
|
+
const now = nowIso();
|
|
893
|
+
const before = this.listWorkflowStepRuns(workflowRunId).filter((step) => step.status === "running");
|
|
894
|
+
const live = before.filter((step) => step.pid !== undefined && isProcessAlive(step.pid));
|
|
895
|
+
if (live.length > 0) {
|
|
896
|
+
throw new Error(`cannot recover workflow run while step processes are still alive: ${live.map((step) => `${step.stepId} pid=${step.pid}`).join(", ")}`);
|
|
897
|
+
}
|
|
898
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
899
|
+
SET status='pending', started_at=NULL, finished_at=NULL, exit_code=NULL, pid=NULL, duration_ms=NULL,
|
|
900
|
+
stdout=NULL, stderr=NULL, error=$reason, updated_at=$updated
|
|
901
|
+
WHERE workflow_run_id=$workflowRunId AND status='running'`).run({ $workflowRunId: workflowRunId, $reason: reason, $updated: now });
|
|
902
|
+
if (before.length > 0) {
|
|
903
|
+
this.appendWorkflowEvent(workflowRunId, "recovered", undefined, {
|
|
904
|
+
reason,
|
|
905
|
+
recoveredSteps: before.map((step) => step.stepId)
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
run: this.requireWorkflowRun(workflowRunId),
|
|
910
|
+
recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
|
|
911
|
+
};
|
|
912
|
+
}
|
|
847
913
|
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
848
914
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
849
|
-
this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
850
|
-
stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
851
|
-
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({
|
|
915
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
916
|
+
pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
917
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({
|
|
852
918
|
$workflowRunId: workflowRunId,
|
|
853
919
|
$stepId: stepId,
|
|
854
920
|
$status: patch.status,
|
|
@@ -860,10 +926,12 @@ class Store {
|
|
|
860
926
|
$error: patch.error ?? null,
|
|
861
927
|
$updated: finishedAt
|
|
862
928
|
});
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
929
|
+
if (res.changes === 1) {
|
|
930
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
931
|
+
exitCode: patch.exitCode,
|
|
932
|
+
error: patch.error
|
|
933
|
+
});
|
|
934
|
+
}
|
|
867
935
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
868
936
|
if (!run)
|
|
869
937
|
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
@@ -871,9 +939,10 @@ class Store {
|
|
|
871
939
|
}
|
|
872
940
|
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
873
941
|
const now = nowIso();
|
|
874
|
-
this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, error=$error, updated_at=$updated
|
|
875
|
-
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $finished: now, $error: reason, $updated: now });
|
|
876
|
-
|
|
942
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
|
|
943
|
+
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 });
|
|
944
|
+
if (res.changes === 1)
|
|
945
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
877
946
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
878
947
|
if (!run)
|
|
879
948
|
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
@@ -881,8 +950,8 @@ class Store {
|
|
|
881
950
|
}
|
|
882
951
|
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
883
952
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
884
|
-
this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
885
|
-
WHERE id=$id`).run({
|
|
953
|
+
const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
954
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({
|
|
886
955
|
$id: workflowRunId,
|
|
887
956
|
$status: status,
|
|
888
957
|
$finished: finishedAt,
|
|
@@ -890,12 +959,36 @@ class Store {
|
|
|
890
959
|
$error: patch.error ?? null,
|
|
891
960
|
$updated: finishedAt
|
|
892
961
|
});
|
|
893
|
-
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
894
962
|
const run = this.getWorkflowRun(workflowRunId);
|
|
895
963
|
if (!run)
|
|
896
964
|
throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
|
|
965
|
+
if (res.changes === 1)
|
|
966
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
897
967
|
return run;
|
|
898
968
|
}
|
|
969
|
+
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
970
|
+
const now = nowIso();
|
|
971
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
972
|
+
try {
|
|
973
|
+
const run = this.requireWorkflowRun(workflowRunId);
|
|
974
|
+
if (!["succeeded", "failed", "timed_out", "cancelled"].includes(run.status)) {
|
|
975
|
+
this.db.query(`UPDATE workflow_runs
|
|
976
|
+
SET status='cancelled', finished_at=$finished, error=$reason, updated_at=$updated
|
|
977
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
978
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
979
|
+
SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
|
|
980
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
981
|
+
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
982
|
+
}
|
|
983
|
+
this.db.exec("COMMIT");
|
|
984
|
+
return this.requireWorkflowRun(workflowRunId);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
try {
|
|
987
|
+
this.db.exec("ROLLBACK");
|
|
988
|
+
} catch {}
|
|
989
|
+
throw error;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
899
992
|
appendWorkflowEvent(workflowRunId, eventType, stepId, payload) {
|
|
900
993
|
const now = nowIso();
|
|
901
994
|
const current = this.db.query("SELECT MAX(sequence) AS sequence FROM workflow_events WHERE workflow_run_id = ?").get(workflowRunId);
|
|
@@ -924,6 +1017,24 @@ class Store {
|
|
|
924
1017
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
925
1018
|
return (row?.count ?? 0) > 0;
|
|
926
1019
|
}
|
|
1020
|
+
markRunPid(id, pid, claimedBy) {
|
|
1021
|
+
const now = nowIso();
|
|
1022
|
+
const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1023
|
+
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 });
|
|
1024
|
+
if (res.changes !== 1)
|
|
1025
|
+
return;
|
|
1026
|
+
return this.getRun(id);
|
|
1027
|
+
}
|
|
1028
|
+
hasLiveWorkflowStepProcesses(loopRunId) {
|
|
1029
|
+
const liveWorkflowSteps = this.db.query(`SELECT wr.id AS workflow_run_id, wsr.step_id AS step_id, wsr.pid AS pid
|
|
1030
|
+
FROM workflow_runs wr
|
|
1031
|
+
JOIN workflow_step_runs wsr ON wsr.workflow_run_id = wr.id
|
|
1032
|
+
WHERE wr.loop_run_id = ?
|
|
1033
|
+
AND wr.status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1034
|
+
AND wsr.status = 'running'
|
|
1035
|
+
AND wsr.pid IS NOT NULL`).all(loopRunId);
|
|
1036
|
+
return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
|
|
1037
|
+
}
|
|
927
1038
|
createSkippedRun(loop, scheduledFor, reason) {
|
|
928
1039
|
const now = nowIso();
|
|
929
1040
|
const run = {
|
|
@@ -971,6 +1082,14 @@ class Store {
|
|
|
971
1082
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
972
1083
|
if (existing) {
|
|
973
1084
|
if (existing.status === "running") {
|
|
1085
|
+
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && existing.pid && isProcessAlive(existing.pid)) {
|
|
1086
|
+
this.db.exec("COMMIT");
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && this.hasLiveWorkflowStepProcesses(existing.id)) {
|
|
1090
|
+
this.db.exec("COMMIT");
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
974
1093
|
const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
|
|
975
1094
|
claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, exit_code=NULL,
|
|
976
1095
|
duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
@@ -1093,8 +1212,26 @@ class Store {
|
|
|
1093
1212
|
const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
|
|
1094
1213
|
const recovered = [];
|
|
1095
1214
|
for (const row of rows) {
|
|
1215
|
+
if (row.pid && isProcessAlive(row.pid))
|
|
1216
|
+
continue;
|
|
1217
|
+
if (this.hasLiveWorkflowStepProcesses(row.id))
|
|
1218
|
+
continue;
|
|
1219
|
+
const finished = now.toISOString();
|
|
1096
1220
|
this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
|
|
1097
|
-
error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished:
|
|
1221
|
+
error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: finished, $updated: finished });
|
|
1222
|
+
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);
|
|
1223
|
+
for (const workflowRow of workflowRows) {
|
|
1224
|
+
this.db.query(`UPDATE workflow_runs
|
|
1225
|
+
SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1226
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRow.id, $finished: finished, $updated: finished });
|
|
1227
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
1228
|
+
SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1229
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRow.id, $finished: finished, $updated: finished });
|
|
1230
|
+
this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
|
|
1231
|
+
error: "parent loop run lease expired before completion",
|
|
1232
|
+
loopRunId: row.id
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1098
1235
|
const run = this.getRun(row.id);
|
|
1099
1236
|
if (run)
|
|
1100
1237
|
recovered.push(run);
|
|
@@ -1215,6 +1352,25 @@ function primaryAccountDir(output) {
|
|
|
1215
1352
|
}
|
|
1216
1353
|
return;
|
|
1217
1354
|
}
|
|
1355
|
+
function accountDirEnvVar(tool) {
|
|
1356
|
+
switch (tool) {
|
|
1357
|
+
case "claude":
|
|
1358
|
+
return "CLAUDE_CONFIG_DIR";
|
|
1359
|
+
case "codex":
|
|
1360
|
+
case "codex-app":
|
|
1361
|
+
return "CODEX_HOME";
|
|
1362
|
+
case "cursor":
|
|
1363
|
+
return "CURSOR_CONFIG_DIR";
|
|
1364
|
+
case "opencode":
|
|
1365
|
+
return "OPENCODE_CONFIG_DIR";
|
|
1366
|
+
case "codewith":
|
|
1367
|
+
return "CODEWITH_HOME";
|
|
1368
|
+
case "aicopilot":
|
|
1369
|
+
return "AICOPILOT_CONFIG_DIR";
|
|
1370
|
+
default:
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1218
1374
|
function resolveAccountEnv(account, toolHint, env) {
|
|
1219
1375
|
if (!account)
|
|
1220
1376
|
return {};
|
|
@@ -1233,18 +1389,78 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
1233
1389
|
const stderr = result.stderr.trim();
|
|
1234
1390
|
throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
|
|
1235
1391
|
}
|
|
1236
|
-
const
|
|
1392
|
+
const accountEnv = parseAccountExportLines(result.stdout);
|
|
1393
|
+
const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
|
|
1237
1394
|
if (!profileDir)
|
|
1238
1395
|
throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
|
|
1239
1396
|
if (!existsSync(profileDir))
|
|
1240
1397
|
throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
|
|
1241
1398
|
return {
|
|
1242
|
-
...
|
|
1399
|
+
...accountEnv,
|
|
1243
1400
|
LOOPS_ACCOUNT_PROFILE: account.profile,
|
|
1244
1401
|
LOOPS_ACCOUNT_TOOL: tool
|
|
1245
1402
|
};
|
|
1246
1403
|
}
|
|
1247
1404
|
|
|
1405
|
+
// src/lib/env.ts
|
|
1406
|
+
import { accessSync, constants } from "fs";
|
|
1407
|
+
import { homedir as homedir2 } from "os";
|
|
1408
|
+
import { delimiter, join as join2 } from "path";
|
|
1409
|
+
function compactPathParts(parts) {
|
|
1410
|
+
const seen = new Set;
|
|
1411
|
+
const result = [];
|
|
1412
|
+
for (const part of parts) {
|
|
1413
|
+
const value = part?.trim();
|
|
1414
|
+
if (!value || seen.has(value))
|
|
1415
|
+
continue;
|
|
1416
|
+
seen.add(value);
|
|
1417
|
+
result.push(value);
|
|
1418
|
+
}
|
|
1419
|
+
return result;
|
|
1420
|
+
}
|
|
1421
|
+
function commonExecutableDirs(env = process.env) {
|
|
1422
|
+
const home = env.HOME || homedir2();
|
|
1423
|
+
return compactPathParts([
|
|
1424
|
+
join2(home, ".local", "bin"),
|
|
1425
|
+
join2(home, ".bun", "bin"),
|
|
1426
|
+
join2(home, ".cargo", "bin"),
|
|
1427
|
+
join2(home, ".npm-global", "bin"),
|
|
1428
|
+
join2(home, "bin"),
|
|
1429
|
+
env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
|
|
1430
|
+
env.PNPM_HOME,
|
|
1431
|
+
env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
1432
|
+
"/opt/homebrew/bin",
|
|
1433
|
+
"/usr/local/bin",
|
|
1434
|
+
"/usr/bin",
|
|
1435
|
+
"/bin",
|
|
1436
|
+
"/usr/sbin",
|
|
1437
|
+
"/sbin"
|
|
1438
|
+
]);
|
|
1439
|
+
}
|
|
1440
|
+
function normalizeExecutionPath(env = process.env) {
|
|
1441
|
+
return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
|
|
1442
|
+
}
|
|
1443
|
+
function isExecutable(path) {
|
|
1444
|
+
try {
|
|
1445
|
+
accessSync(path, constants.X_OK);
|
|
1446
|
+
return true;
|
|
1447
|
+
} catch {
|
|
1448
|
+
return false;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
function executableExists(command, env = process.env) {
|
|
1452
|
+
if (command.includes("/"))
|
|
1453
|
+
return isExecutable(command);
|
|
1454
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
1455
|
+
if (dir && isExecutable(join2(dir, command)))
|
|
1456
|
+
return true;
|
|
1457
|
+
}
|
|
1458
|
+
return false;
|
|
1459
|
+
}
|
|
1460
|
+
function commandNotFoundMessage(command, env = process.env) {
|
|
1461
|
+
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1248
1464
|
// src/lib/executor.ts
|
|
1249
1465
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
1250
1466
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
@@ -1330,7 +1546,7 @@ function agentArgs(target) {
|
|
|
1330
1546
|
args.push(...target.extraArgs ?? [], target.prompt);
|
|
1331
1547
|
return args;
|
|
1332
1548
|
case "codewith":
|
|
1333
|
-
args.push("
|
|
1549
|
+
args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
|
|
1334
1550
|
if (isolation === "safe")
|
|
1335
1551
|
args.push("--ignore-rules");
|
|
1336
1552
|
if (target.cwd)
|
|
@@ -1410,6 +1626,7 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1410
1626
|
Object.assign(env, accountEnv);
|
|
1411
1627
|
}
|
|
1412
1628
|
Object.assign(env, spec.env ?? {});
|
|
1629
|
+
env.PATH = normalizeExecutionPath(env);
|
|
1413
1630
|
if (metadata.loopId)
|
|
1414
1631
|
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1415
1632
|
if (metadata.loopName)
|
|
@@ -1428,6 +1645,18 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1428
1645
|
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1429
1646
|
return env;
|
|
1430
1647
|
}
|
|
1648
|
+
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1649
|
+
const spec = commandSpec(target);
|
|
1650
|
+
const env = executionEnv(spec, metadata, opts);
|
|
1651
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1652
|
+
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
1653
|
+
}
|
|
1654
|
+
return {
|
|
1655
|
+
command: spec.command,
|
|
1656
|
+
accountProfile: spec.account?.profile,
|
|
1657
|
+
accountTool: spec.accountTool
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1431
1660
|
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1432
1661
|
const spec = commandSpec(target);
|
|
1433
1662
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
@@ -1438,6 +1667,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1438
1667
|
let exitCode;
|
|
1439
1668
|
let error;
|
|
1440
1669
|
const env = executionEnv(spec, metadata, opts);
|
|
1670
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1671
|
+
return {
|
|
1672
|
+
status: "failed",
|
|
1673
|
+
stdout: "",
|
|
1674
|
+
stderr: "",
|
|
1675
|
+
error: commandNotFoundMessage(spec.command, env),
|
|
1676
|
+
startedAt,
|
|
1677
|
+
finishedAt: nowIso(),
|
|
1678
|
+
durationMs: 0
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1441
1681
|
const child = spawn(spec.command, spec.args, {
|
|
1442
1682
|
cwd: spec.cwd,
|
|
1443
1683
|
env,
|
|
@@ -1445,6 +1685,16 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1445
1685
|
detached: true,
|
|
1446
1686
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1447
1687
|
});
|
|
1688
|
+
if (child.pid)
|
|
1689
|
+
opts.onSpawn?.(child.pid);
|
|
1690
|
+
const abortHandler = () => {
|
|
1691
|
+
error = "cancelled";
|
|
1692
|
+
if (child.pid)
|
|
1693
|
+
killProcessGroup(child.pid);
|
|
1694
|
+
};
|
|
1695
|
+
if (opts.signal?.aborted)
|
|
1696
|
+
abortHandler();
|
|
1697
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1448
1698
|
child.stdout.on("data", (chunk) => {
|
|
1449
1699
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
1450
1700
|
});
|
|
@@ -1467,6 +1717,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1467
1717
|
error = err instanceof Error ? err.message : String(err);
|
|
1468
1718
|
} finally {
|
|
1469
1719
|
clearTimeout(timer);
|
|
1720
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
1470
1721
|
}
|
|
1471
1722
|
const finishedAt = nowIso();
|
|
1472
1723
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
@@ -1522,14 +1773,16 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
1522
1773
|
// src/lib/workflow-runner.ts
|
|
1523
1774
|
function targetWithStepAccount(step) {
|
|
1524
1775
|
const account = step.account ?? step.target.account;
|
|
1525
|
-
|
|
1776
|
+
const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
|
|
1777
|
+
if (!account && timeoutMs === step.target.timeoutMs)
|
|
1526
1778
|
return step.target;
|
|
1527
|
-
return { ...step.target, account };
|
|
1779
|
+
return { ...step.target, account, timeoutMs };
|
|
1528
1780
|
}
|
|
1529
1781
|
function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
|
|
1782
|
+
const executorStatus = status === "succeeded" ? "succeeded" : status === "timed_out" ? "timed_out" : "failed";
|
|
1530
1783
|
return {
|
|
1531
|
-
status,
|
|
1532
|
-
exitCode:
|
|
1784
|
+
status: executorStatus,
|
|
1785
|
+
exitCode: executorStatus === "succeeded" ? 0 : 1,
|
|
1533
1786
|
stdout,
|
|
1534
1787
|
stderr: "",
|
|
1535
1788
|
error,
|
|
@@ -1547,7 +1800,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1547
1800
|
idempotencyKey: opts.idempotencyKey
|
|
1548
1801
|
});
|
|
1549
1802
|
const startedAt = run.startedAt ?? nowIso();
|
|
1550
|
-
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out") {
|
|
1803
|
+
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
1551
1804
|
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1552
1805
|
return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
|
|
1553
1806
|
}
|
|
@@ -1556,8 +1809,19 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1556
1809
|
let blockingError;
|
|
1557
1810
|
let terminalStatus = "succeeded";
|
|
1558
1811
|
for (const step of ordered) {
|
|
1812
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1813
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1814
|
+
blockingError = "workflow run was cancelled";
|
|
1815
|
+
break;
|
|
1816
|
+
}
|
|
1817
|
+
const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1818
|
+
if (pendingTimeout) {
|
|
1819
|
+
terminalStatus = "timed_out";
|
|
1820
|
+
blockingError = pendingTimeout;
|
|
1821
|
+
break;
|
|
1822
|
+
}
|
|
1559
1823
|
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1560
|
-
if (existing?.status === "succeeded" || existing?.status === "skipped")
|
|
1824
|
+
if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
|
|
1561
1825
|
continue;
|
|
1562
1826
|
const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
|
|
1563
1827
|
const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
|
|
@@ -1572,8 +1836,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1572
1836
|
terminalStatus = "failed";
|
|
1573
1837
|
continue;
|
|
1574
1838
|
}
|
|
1575
|
-
store.startWorkflowStepRun(run.id, step.id);
|
|
1576
|
-
|
|
1839
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id);
|
|
1840
|
+
if (startedStep.status !== "running") {
|
|
1841
|
+
terminalStatus = "failed";
|
|
1842
|
+
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
1843
|
+
break;
|
|
1844
|
+
}
|
|
1845
|
+
const metadata = {
|
|
1577
1846
|
loopId: opts.loop?.id,
|
|
1578
1847
|
loopName: opts.loop?.name,
|
|
1579
1848
|
runId: opts.loopRun?.id,
|
|
@@ -1582,7 +1851,51 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1582
1851
|
workflowName: workflow.name,
|
|
1583
1852
|
workflowRunId: run.id,
|
|
1584
1853
|
workflowStepId: step.id
|
|
1585
|
-
}
|
|
1854
|
+
};
|
|
1855
|
+
let result;
|
|
1856
|
+
const controller = new AbortController;
|
|
1857
|
+
const externalAbort = () => controller.abort();
|
|
1858
|
+
if (opts.signal?.aborted)
|
|
1859
|
+
controller.abort();
|
|
1860
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1861
|
+
const cancelTimer = setInterval(() => {
|
|
1862
|
+
if (store.getWorkflowRun(run.id)?.status === "cancelled")
|
|
1863
|
+
controller.abort();
|
|
1864
|
+
}, opts.cancelPollMs ?? 500);
|
|
1865
|
+
cancelTimer.unref();
|
|
1866
|
+
try {
|
|
1867
|
+
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
1868
|
+
...opts,
|
|
1869
|
+
signal: controller.signal,
|
|
1870
|
+
onSpawn: (pid) => {
|
|
1871
|
+
store.markWorkflowStepPid(run.id, step.id, pid);
|
|
1872
|
+
opts.onSpawn?.(pid);
|
|
1873
|
+
}
|
|
1874
|
+
});
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
const finishedAt2 = nowIso();
|
|
1877
|
+
result = {
|
|
1878
|
+
status: "failed",
|
|
1879
|
+
stdout: "",
|
|
1880
|
+
stderr: "",
|
|
1881
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1882
|
+
startedAt: startedStep.startedAt ?? finishedAt2,
|
|
1883
|
+
finishedAt: finishedAt2,
|
|
1884
|
+
durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
|
|
1885
|
+
};
|
|
1886
|
+
} finally {
|
|
1887
|
+
clearInterval(cancelTimer);
|
|
1888
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1889
|
+
}
|
|
1890
|
+
const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1891
|
+
if (timeoutMessage && result.status === "failed") {
|
|
1892
|
+
result = { ...result, status: "timed_out", error: timeoutMessage };
|
|
1893
|
+
}
|
|
1894
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1895
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1896
|
+
blockingError = "workflow run was cancelled";
|
|
1897
|
+
break;
|
|
1898
|
+
}
|
|
1586
1899
|
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1587
1900
|
status: result.status,
|
|
1588
1901
|
finishedAt: result.finishedAt,
|
|
@@ -1607,6 +1920,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1607
1920
|
}
|
|
1608
1921
|
}
|
|
1609
1922
|
const finishedAt = nowIso();
|
|
1923
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1924
|
+
const terminalRun = store.requireWorkflowRun(run.id);
|
|
1925
|
+
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1926
|
+
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
1927
|
+
}
|
|
1610
1928
|
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
1611
1929
|
finishedAt,
|
|
1612
1930
|
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
@@ -1615,20 +1933,59 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1615
1933
|
const steps = store.listWorkflowStepRuns(run.id);
|
|
1616
1934
|
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
1617
1935
|
}
|
|
1936
|
+
function preflightWorkflow(workflow, opts = {}) {
|
|
1937
|
+
return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
|
|
1938
|
+
workflowId: workflow.id,
|
|
1939
|
+
workflowName: workflow.name,
|
|
1940
|
+
workflowStepId: step.id
|
|
1941
|
+
}, opts));
|
|
1942
|
+
}
|
|
1618
1943
|
async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
1619
1944
|
if (loop.target.type !== "workflow")
|
|
1620
1945
|
return executeLoop(loop, run, opts);
|
|
1621
1946
|
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1947
|
+
const controller = loop.target.timeoutMs ? new AbortController : undefined;
|
|
1948
|
+
let workflowTimedOut = false;
|
|
1949
|
+
const externalAbort = () => controller?.abort();
|
|
1950
|
+
if (controller && opts.signal?.aborted)
|
|
1951
|
+
controller.abort();
|
|
1952
|
+
if (controller)
|
|
1953
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1954
|
+
const timer = controller ? setTimeout(() => {
|
|
1955
|
+
workflowTimedOut = true;
|
|
1956
|
+
controller.abort();
|
|
1957
|
+
}, loop.target.timeoutMs) : undefined;
|
|
1958
|
+
timer?.unref();
|
|
1959
|
+
try {
|
|
1960
|
+
return await executeWorkflow(store, workflow, {
|
|
1961
|
+
...opts,
|
|
1962
|
+
signal: controller?.signal ?? opts.signal,
|
|
1963
|
+
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
|
|
1964
|
+
loop,
|
|
1965
|
+
loopRun: run,
|
|
1966
|
+
scheduledFor: run.scheduledFor,
|
|
1967
|
+
idempotencyKey: `${loop.id}:${run.scheduledFor}:attempt:${run.attempt}`
|
|
1968
|
+
});
|
|
1969
|
+
} finally {
|
|
1970
|
+
if (timer)
|
|
1971
|
+
clearTimeout(timer);
|
|
1972
|
+
if (controller)
|
|
1973
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1974
|
+
}
|
|
1629
1975
|
}
|
|
1630
1976
|
|
|
1631
1977
|
// src/lib/scheduler.ts
|
|
1978
|
+
function manualRunScheduledFor(loop, now = new Date) {
|
|
1979
|
+
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
1980
|
+
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
1981
|
+
}
|
|
1982
|
+
return now.toISOString();
|
|
1983
|
+
}
|
|
1984
|
+
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
1985
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
1986
|
+
return false;
|
|
1987
|
+
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
1988
|
+
}
|
|
1632
1989
|
function nextAfterRetry(loop, now) {
|
|
1633
1990
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
1634
1991
|
}
|
|
@@ -1654,27 +2011,18 @@ function advanceLoop(store, loop, run, finishedAt, succeeded) {
|
|
|
1654
2011
|
retryScheduledFor: undefined
|
|
1655
2012
|
});
|
|
1656
2013
|
}
|
|
1657
|
-
async function
|
|
1658
|
-
const now = deps.now?.() ?? new Date;
|
|
1659
|
-
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
1660
|
-
const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
|
|
1661
|
-
advanceLoop(deps.store, loop, skipped, now, true);
|
|
1662
|
-
deps.onRun?.(skipped);
|
|
1663
|
-
return skipped;
|
|
1664
|
-
}
|
|
1665
|
-
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
1666
|
-
if (!claim)
|
|
1667
|
-
return;
|
|
1668
|
-
deps.onRun?.(claim.run);
|
|
2014
|
+
async function executeClaimedRun(deps) {
|
|
1669
2015
|
let heartbeat;
|
|
1670
|
-
const heartbeatEveryMs = Math.max(
|
|
2016
|
+
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
1671
2017
|
heartbeat = setInterval(() => {
|
|
1672
|
-
deps.store.heartbeatRunLease(
|
|
2018
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
|
|
1673
2019
|
}, heartbeatEveryMs);
|
|
1674
2020
|
heartbeat.unref();
|
|
1675
2021
|
try {
|
|
1676
|
-
const result = await (deps.execute ?? ((
|
|
1677
|
-
|
|
2022
|
+
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
2023
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
|
|
2024
|
+
})))(deps.loop, deps.run);
|
|
2025
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
1678
2026
|
status: result.status,
|
|
1679
2027
|
finishedAt: result.finishedAt,
|
|
1680
2028
|
durationMs: result.durationMs,
|
|
@@ -1687,16 +2035,13 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
1687
2035
|
claimedBy: deps.runnerId,
|
|
1688
2036
|
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
1689
2037
|
});
|
|
1690
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), finalRun.status === "succeeded");
|
|
1691
|
-
deps.onRun?.(finalRun);
|
|
1692
|
-
return finalRun;
|
|
1693
2038
|
} catch (err) {
|
|
1694
|
-
deps.onError?.(
|
|
2039
|
+
deps.onError?.(deps.loop, err);
|
|
1695
2040
|
const finishedAt = new Date;
|
|
1696
|
-
|
|
2041
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
1697
2042
|
status: "failed",
|
|
1698
2043
|
finishedAt: finishedAt.toISOString(),
|
|
1699
|
-
durationMs: finishedAt.getTime() - new Date(
|
|
2044
|
+
durationMs: finishedAt.getTime() - new Date(deps.run.startedAt ?? deps.run.createdAt).getTime(),
|
|
1700
2045
|
stdout: "",
|
|
1701
2046
|
stderr: "",
|
|
1702
2047
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -1704,14 +2049,36 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
1704
2049
|
claimedBy: deps.runnerId,
|
|
1705
2050
|
now: deps.now?.() ?? finishedAt
|
|
1706
2051
|
});
|
|
1707
|
-
advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
|
|
1708
|
-
deps.onRun?.(finalRun);
|
|
1709
|
-
return finalRun;
|
|
1710
2052
|
} finally {
|
|
1711
2053
|
if (heartbeat)
|
|
1712
2054
|
clearInterval(heartbeat);
|
|
1713
2055
|
}
|
|
1714
2056
|
}
|
|
2057
|
+
async function runSlot(deps, loop, scheduledFor) {
|
|
2058
|
+
const now = deps.now?.() ?? new Date;
|
|
2059
|
+
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2060
|
+
const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
|
|
2061
|
+
advanceLoop(deps.store, loop, skipped, now, true);
|
|
2062
|
+
deps.onRun?.(skipped);
|
|
2063
|
+
return skipped;
|
|
2064
|
+
}
|
|
2065
|
+
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
2066
|
+
if (!claim)
|
|
2067
|
+
return;
|
|
2068
|
+
deps.onRun?.(claim.run);
|
|
2069
|
+
const finalRun = await executeClaimedRun({
|
|
2070
|
+
store: deps.store,
|
|
2071
|
+
runnerId: deps.runnerId,
|
|
2072
|
+
loop: claim.loop,
|
|
2073
|
+
run: claim.run,
|
|
2074
|
+
now: deps.now,
|
|
2075
|
+
execute: deps.execute,
|
|
2076
|
+
onError: deps.onError
|
|
2077
|
+
});
|
|
2078
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2079
|
+
deps.onRun?.(finalRun);
|
|
2080
|
+
return finalRun;
|
|
2081
|
+
}
|
|
1715
2082
|
async function tick(deps) {
|
|
1716
2083
|
const now = deps.now?.() ?? new Date;
|
|
1717
2084
|
const recovered = deps.store.recoverExpiredRunLeases(now);
|
|
@@ -1785,23 +2152,17 @@ class LoopsClient {
|
|
|
1785
2152
|
}
|
|
1786
2153
|
async runNow(idOrName) {
|
|
1787
2154
|
const loop = this.get(idOrName);
|
|
1788
|
-
const
|
|
1789
|
-
const
|
|
2155
|
+
const now = new Date;
|
|
2156
|
+
const scheduledFor = manualRunScheduledFor(loop, now);
|
|
2157
|
+
const shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
|
|
2158
|
+
const claim = this.store.claimRun(loop, scheduledFor, this.runnerId, now);
|
|
1790
2159
|
if (!claim)
|
|
1791
2160
|
throw new Error(`could not claim manual run for ${idOrName}`);
|
|
1792
|
-
const
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
stdout: result.stdout,
|
|
1798
|
-
stderr: result.stderr,
|
|
1799
|
-
exitCode: result.exitCode,
|
|
1800
|
-
error: result.error,
|
|
1801
|
-
pid: result.pid
|
|
1802
|
-
}, {
|
|
1803
|
-
claimedBy: claim.run.claimedBy
|
|
1804
|
-
});
|
|
2161
|
+
const run = await executeClaimedRun({ store: this.store, runnerId: this.runnerId, loop: claim.loop, run: claim.run });
|
|
2162
|
+
if (shouldAdvance) {
|
|
2163
|
+
advanceLoop(this.store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
|
|
2164
|
+
}
|
|
2165
|
+
return run;
|
|
1805
2166
|
}
|
|
1806
2167
|
close() {
|
|
1807
2168
|
if (this.ownStore)
|