@hasna/loops 0.2.0 → 0.3.0
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 +23 -3
- package/dist/cli/index.js +600 -91
- package/dist/daemon/daemon.d.ts +1 -0
- package/dist/daemon/index.js +420 -72
- package/dist/daemon/install.d.ts +8 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +594 -79
- package/dist/lib/doctor.d.ts +13 -0
- package/dist/lib/executor.d.ts +8 -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 +390 -79
- package/dist/types.d.ts +3 -2
- package/docs/USAGE.md +23 -3
- package/package.json +1 -1
package/dist/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);
|
|
845
880
|
return run;
|
|
846
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}`);
|
|
889
|
+
return run;
|
|
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);
|
|
@@ -1166,8 +1303,9 @@ class Store {
|
|
|
1166
1303
|
}
|
|
1167
1304
|
|
|
1168
1305
|
// src/lib/executor.ts
|
|
1169
|
-
import { spawn } from "child_process";
|
|
1306
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
1170
1307
|
import { once } from "events";
|
|
1308
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1171
1309
|
|
|
1172
1310
|
// src/lib/accounts.ts
|
|
1173
1311
|
import { spawnSync } from "child_process";
|
|
@@ -1215,6 +1353,25 @@ function primaryAccountDir(output) {
|
|
|
1215
1353
|
}
|
|
1216
1354
|
return;
|
|
1217
1355
|
}
|
|
1356
|
+
function accountDirEnvVar(tool) {
|
|
1357
|
+
switch (tool) {
|
|
1358
|
+
case "claude":
|
|
1359
|
+
return "CLAUDE_CONFIG_DIR";
|
|
1360
|
+
case "codex":
|
|
1361
|
+
case "codex-app":
|
|
1362
|
+
return "CODEX_HOME";
|
|
1363
|
+
case "cursor":
|
|
1364
|
+
return "CURSOR_CONFIG_DIR";
|
|
1365
|
+
case "opencode":
|
|
1366
|
+
return "OPENCODE_CONFIG_DIR";
|
|
1367
|
+
case "codewith":
|
|
1368
|
+
return "CODEWITH_HOME";
|
|
1369
|
+
case "aicopilot":
|
|
1370
|
+
return "AICOPILOT_CONFIG_DIR";
|
|
1371
|
+
default:
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1218
1375
|
function resolveAccountEnv(account, toolHint, env) {
|
|
1219
1376
|
if (!account)
|
|
1220
1377
|
return {};
|
|
@@ -1233,13 +1390,14 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
1233
1390
|
const stderr = result.stderr.trim();
|
|
1234
1391
|
throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
|
|
1235
1392
|
}
|
|
1236
|
-
const
|
|
1393
|
+
const accountEnv = parseAccountExportLines(result.stdout);
|
|
1394
|
+
const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
|
|
1237
1395
|
if (!profileDir)
|
|
1238
1396
|
throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
|
|
1239
1397
|
if (!existsSync(profileDir))
|
|
1240
1398
|
throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
|
|
1241
1399
|
return {
|
|
1242
|
-
...
|
|
1400
|
+
...accountEnv,
|
|
1243
1401
|
LOOPS_ACCOUNT_PROFILE: account.profile,
|
|
1244
1402
|
LOOPS_ACCOUNT_TOOL: tool
|
|
1245
1403
|
};
|
|
@@ -1428,6 +1586,27 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1428
1586
|
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1429
1587
|
return env;
|
|
1430
1588
|
}
|
|
1589
|
+
function commandExists(command, env) {
|
|
1590
|
+
if (command.includes("/") && existsSync2(command))
|
|
1591
|
+
return true;
|
|
1592
|
+
const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], {
|
|
1593
|
+
env,
|
|
1594
|
+
stdio: "ignore"
|
|
1595
|
+
});
|
|
1596
|
+
return (result.status ?? 1) === 0;
|
|
1597
|
+
}
|
|
1598
|
+
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1599
|
+
const spec = commandSpec(target);
|
|
1600
|
+
const env = executionEnv(spec, metadata, opts);
|
|
1601
|
+
if (!spec.shell && !commandExists(spec.command, env)) {
|
|
1602
|
+
throw new Error(`Executable not found in PATH: ${spec.command}`);
|
|
1603
|
+
}
|
|
1604
|
+
return {
|
|
1605
|
+
command: spec.command,
|
|
1606
|
+
accountProfile: spec.account?.profile,
|
|
1607
|
+
accountTool: spec.accountTool
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1431
1610
|
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1432
1611
|
const spec = commandSpec(target);
|
|
1433
1612
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
@@ -1438,6 +1617,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1438
1617
|
let exitCode;
|
|
1439
1618
|
let error;
|
|
1440
1619
|
const env = executionEnv(spec, metadata, opts);
|
|
1620
|
+
if (!spec.shell && !commandExists(spec.command, env)) {
|
|
1621
|
+
return {
|
|
1622
|
+
status: "failed",
|
|
1623
|
+
stdout: "",
|
|
1624
|
+
stderr: "",
|
|
1625
|
+
error: `Executable not found in PATH: ${spec.command}`,
|
|
1626
|
+
startedAt,
|
|
1627
|
+
finishedAt: nowIso(),
|
|
1628
|
+
durationMs: 0
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1441
1631
|
const child = spawn(spec.command, spec.args, {
|
|
1442
1632
|
cwd: spec.cwd,
|
|
1443
1633
|
env,
|
|
@@ -1445,6 +1635,16 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1445
1635
|
detached: true,
|
|
1446
1636
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1447
1637
|
});
|
|
1638
|
+
if (child.pid)
|
|
1639
|
+
opts.onSpawn?.(child.pid);
|
|
1640
|
+
const abortHandler = () => {
|
|
1641
|
+
error = "cancelled";
|
|
1642
|
+
if (child.pid)
|
|
1643
|
+
killProcessGroup(child.pid);
|
|
1644
|
+
};
|
|
1645
|
+
if (opts.signal?.aborted)
|
|
1646
|
+
abortHandler();
|
|
1647
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1448
1648
|
child.stdout.on("data", (chunk) => {
|
|
1449
1649
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
1450
1650
|
});
|
|
@@ -1467,6 +1667,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1467
1667
|
error = err instanceof Error ? err.message : String(err);
|
|
1468
1668
|
} finally {
|
|
1469
1669
|
clearTimeout(timer);
|
|
1670
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
1470
1671
|
}
|
|
1471
1672
|
const finishedAt = nowIso();
|
|
1472
1673
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
@@ -1522,14 +1723,16 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
1522
1723
|
// src/lib/workflow-runner.ts
|
|
1523
1724
|
function targetWithStepAccount(step) {
|
|
1524
1725
|
const account = step.account ?? step.target.account;
|
|
1525
|
-
|
|
1726
|
+
const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
|
|
1727
|
+
if (!account && timeoutMs === step.target.timeoutMs)
|
|
1526
1728
|
return step.target;
|
|
1527
|
-
return { ...step.target, account };
|
|
1729
|
+
return { ...step.target, account, timeoutMs };
|
|
1528
1730
|
}
|
|
1529
1731
|
function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
|
|
1732
|
+
const executorStatus = status === "succeeded" ? "succeeded" : status === "timed_out" ? "timed_out" : "failed";
|
|
1530
1733
|
return {
|
|
1531
|
-
status,
|
|
1532
|
-
exitCode:
|
|
1734
|
+
status: executorStatus,
|
|
1735
|
+
exitCode: executorStatus === "succeeded" ? 0 : 1,
|
|
1533
1736
|
stdout,
|
|
1534
1737
|
stderr: "",
|
|
1535
1738
|
error,
|
|
@@ -1547,7 +1750,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1547
1750
|
idempotencyKey: opts.idempotencyKey
|
|
1548
1751
|
});
|
|
1549
1752
|
const startedAt = run.startedAt ?? nowIso();
|
|
1550
|
-
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out") {
|
|
1753
|
+
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
1551
1754
|
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1552
1755
|
return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
|
|
1553
1756
|
}
|
|
@@ -1556,8 +1759,19 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1556
1759
|
let blockingError;
|
|
1557
1760
|
let terminalStatus = "succeeded";
|
|
1558
1761
|
for (const step of ordered) {
|
|
1762
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1763
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1764
|
+
blockingError = "workflow run was cancelled";
|
|
1765
|
+
break;
|
|
1766
|
+
}
|
|
1767
|
+
const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1768
|
+
if (pendingTimeout) {
|
|
1769
|
+
terminalStatus = "timed_out";
|
|
1770
|
+
blockingError = pendingTimeout;
|
|
1771
|
+
break;
|
|
1772
|
+
}
|
|
1559
1773
|
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1560
|
-
if (existing?.status === "succeeded" || existing?.status === "skipped")
|
|
1774
|
+
if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
|
|
1561
1775
|
continue;
|
|
1562
1776
|
const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
|
|
1563
1777
|
const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
|
|
@@ -1572,8 +1786,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1572
1786
|
terminalStatus = "failed";
|
|
1573
1787
|
continue;
|
|
1574
1788
|
}
|
|
1575
|
-
store.startWorkflowStepRun(run.id, step.id);
|
|
1576
|
-
|
|
1789
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id);
|
|
1790
|
+
if (startedStep.status !== "running") {
|
|
1791
|
+
terminalStatus = "failed";
|
|
1792
|
+
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
1793
|
+
break;
|
|
1794
|
+
}
|
|
1795
|
+
const metadata = {
|
|
1577
1796
|
loopId: opts.loop?.id,
|
|
1578
1797
|
loopName: opts.loop?.name,
|
|
1579
1798
|
runId: opts.loopRun?.id,
|
|
@@ -1582,7 +1801,51 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1582
1801
|
workflowName: workflow.name,
|
|
1583
1802
|
workflowRunId: run.id,
|
|
1584
1803
|
workflowStepId: step.id
|
|
1585
|
-
}
|
|
1804
|
+
};
|
|
1805
|
+
let result;
|
|
1806
|
+
const controller = new AbortController;
|
|
1807
|
+
const externalAbort = () => controller.abort();
|
|
1808
|
+
if (opts.signal?.aborted)
|
|
1809
|
+
controller.abort();
|
|
1810
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1811
|
+
const cancelTimer = setInterval(() => {
|
|
1812
|
+
if (store.getWorkflowRun(run.id)?.status === "cancelled")
|
|
1813
|
+
controller.abort();
|
|
1814
|
+
}, opts.cancelPollMs ?? 500);
|
|
1815
|
+
cancelTimer.unref();
|
|
1816
|
+
try {
|
|
1817
|
+
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
1818
|
+
...opts,
|
|
1819
|
+
signal: controller.signal,
|
|
1820
|
+
onSpawn: (pid) => {
|
|
1821
|
+
store.markWorkflowStepPid(run.id, step.id, pid);
|
|
1822
|
+
opts.onSpawn?.(pid);
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
const finishedAt2 = nowIso();
|
|
1827
|
+
result = {
|
|
1828
|
+
status: "failed",
|
|
1829
|
+
stdout: "",
|
|
1830
|
+
stderr: "",
|
|
1831
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1832
|
+
startedAt: startedStep.startedAt ?? finishedAt2,
|
|
1833
|
+
finishedAt: finishedAt2,
|
|
1834
|
+
durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
|
|
1835
|
+
};
|
|
1836
|
+
} finally {
|
|
1837
|
+
clearInterval(cancelTimer);
|
|
1838
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1839
|
+
}
|
|
1840
|
+
const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1841
|
+
if (timeoutMessage && result.status === "failed") {
|
|
1842
|
+
result = { ...result, status: "timed_out", error: timeoutMessage };
|
|
1843
|
+
}
|
|
1844
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1845
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1846
|
+
blockingError = "workflow run was cancelled";
|
|
1847
|
+
break;
|
|
1848
|
+
}
|
|
1586
1849
|
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1587
1850
|
status: result.status,
|
|
1588
1851
|
finishedAt: result.finishedAt,
|
|
@@ -1607,6 +1870,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1607
1870
|
}
|
|
1608
1871
|
}
|
|
1609
1872
|
const finishedAt = nowIso();
|
|
1873
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1874
|
+
const terminalRun = store.requireWorkflowRun(run.id);
|
|
1875
|
+
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1876
|
+
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
1877
|
+
}
|
|
1610
1878
|
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
1611
1879
|
finishedAt,
|
|
1612
1880
|
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
@@ -1615,20 +1883,59 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1615
1883
|
const steps = store.listWorkflowStepRuns(run.id);
|
|
1616
1884
|
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
1617
1885
|
}
|
|
1886
|
+
function preflightWorkflow(workflow, opts = {}) {
|
|
1887
|
+
return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
|
|
1888
|
+
workflowId: workflow.id,
|
|
1889
|
+
workflowName: workflow.name,
|
|
1890
|
+
workflowStepId: step.id
|
|
1891
|
+
}, opts));
|
|
1892
|
+
}
|
|
1618
1893
|
async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
1619
1894
|
if (loop.target.type !== "workflow")
|
|
1620
1895
|
return executeLoop(loop, run, opts);
|
|
1621
1896
|
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1897
|
+
const controller = loop.target.timeoutMs ? new AbortController : undefined;
|
|
1898
|
+
let workflowTimedOut = false;
|
|
1899
|
+
const externalAbort = () => controller?.abort();
|
|
1900
|
+
if (controller && opts.signal?.aborted)
|
|
1901
|
+
controller.abort();
|
|
1902
|
+
if (controller)
|
|
1903
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1904
|
+
const timer = controller ? setTimeout(() => {
|
|
1905
|
+
workflowTimedOut = true;
|
|
1906
|
+
controller.abort();
|
|
1907
|
+
}, loop.target.timeoutMs) : undefined;
|
|
1908
|
+
timer?.unref();
|
|
1909
|
+
try {
|
|
1910
|
+
return await executeWorkflow(store, workflow, {
|
|
1911
|
+
...opts,
|
|
1912
|
+
signal: controller?.signal ?? opts.signal,
|
|
1913
|
+
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
|
|
1914
|
+
loop,
|
|
1915
|
+
loopRun: run,
|
|
1916
|
+
scheduledFor: run.scheduledFor,
|
|
1917
|
+
idempotencyKey: `${loop.id}:${run.scheduledFor}:attempt:${run.attempt}`
|
|
1918
|
+
});
|
|
1919
|
+
} finally {
|
|
1920
|
+
if (timer)
|
|
1921
|
+
clearTimeout(timer);
|
|
1922
|
+
if (controller)
|
|
1923
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1924
|
+
}
|
|
1629
1925
|
}
|
|
1630
1926
|
|
|
1631
1927
|
// src/lib/scheduler.ts
|
|
1928
|
+
function manualRunScheduledFor(loop, now = new Date) {
|
|
1929
|
+
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
1930
|
+
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
1931
|
+
}
|
|
1932
|
+
return now.toISOString();
|
|
1933
|
+
}
|
|
1934
|
+
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
1935
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
1936
|
+
return false;
|
|
1937
|
+
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
1938
|
+
}
|
|
1632
1939
|
function nextAfterRetry(loop, now) {
|
|
1633
1940
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
1634
1941
|
}
|
|
@@ -1654,27 +1961,18 @@ function advanceLoop(store, loop, run, finishedAt, succeeded) {
|
|
|
1654
1961
|
retryScheduledFor: undefined
|
|
1655
1962
|
});
|
|
1656
1963
|
}
|
|
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);
|
|
1964
|
+
async function executeClaimedRun(deps) {
|
|
1669
1965
|
let heartbeat;
|
|
1670
|
-
const heartbeatEveryMs = Math.max(
|
|
1966
|
+
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
1671
1967
|
heartbeat = setInterval(() => {
|
|
1672
|
-
deps.store.heartbeatRunLease(
|
|
1968
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
|
|
1673
1969
|
}, heartbeatEveryMs);
|
|
1674
1970
|
heartbeat.unref();
|
|
1675
1971
|
try {
|
|
1676
|
-
const result = await (deps.execute ?? ((
|
|
1677
|
-
|
|
1972
|
+
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
1973
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
|
|
1974
|
+
})))(deps.loop, deps.run);
|
|
1975
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
1678
1976
|
status: result.status,
|
|
1679
1977
|
finishedAt: result.finishedAt,
|
|
1680
1978
|
durationMs: result.durationMs,
|
|
@@ -1687,16 +1985,13 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
1687
1985
|
claimedBy: deps.runnerId,
|
|
1688
1986
|
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
1689
1987
|
});
|
|
1690
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), finalRun.status === "succeeded");
|
|
1691
|
-
deps.onRun?.(finalRun);
|
|
1692
|
-
return finalRun;
|
|
1693
1988
|
} catch (err) {
|
|
1694
|
-
deps.onError?.(
|
|
1989
|
+
deps.onError?.(deps.loop, err);
|
|
1695
1990
|
const finishedAt = new Date;
|
|
1696
|
-
|
|
1991
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
1697
1992
|
status: "failed",
|
|
1698
1993
|
finishedAt: finishedAt.toISOString(),
|
|
1699
|
-
durationMs: finishedAt.getTime() - new Date(
|
|
1994
|
+
durationMs: finishedAt.getTime() - new Date(deps.run.startedAt ?? deps.run.createdAt).getTime(),
|
|
1700
1995
|
stdout: "",
|
|
1701
1996
|
stderr: "",
|
|
1702
1997
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -1704,14 +1999,36 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
1704
1999
|
claimedBy: deps.runnerId,
|
|
1705
2000
|
now: deps.now?.() ?? finishedAt
|
|
1706
2001
|
});
|
|
1707
|
-
advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
|
|
1708
|
-
deps.onRun?.(finalRun);
|
|
1709
|
-
return finalRun;
|
|
1710
2002
|
} finally {
|
|
1711
2003
|
if (heartbeat)
|
|
1712
2004
|
clearInterval(heartbeat);
|
|
1713
2005
|
}
|
|
1714
2006
|
}
|
|
2007
|
+
async function runSlot(deps, loop, scheduledFor) {
|
|
2008
|
+
const now = deps.now?.() ?? new Date;
|
|
2009
|
+
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2010
|
+
const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
|
|
2011
|
+
advanceLoop(deps.store, loop, skipped, now, true);
|
|
2012
|
+
deps.onRun?.(skipped);
|
|
2013
|
+
return skipped;
|
|
2014
|
+
}
|
|
2015
|
+
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
2016
|
+
if (!claim)
|
|
2017
|
+
return;
|
|
2018
|
+
deps.onRun?.(claim.run);
|
|
2019
|
+
const finalRun = await executeClaimedRun({
|
|
2020
|
+
store: deps.store,
|
|
2021
|
+
runnerId: deps.runnerId,
|
|
2022
|
+
loop: claim.loop,
|
|
2023
|
+
run: claim.run,
|
|
2024
|
+
now: deps.now,
|
|
2025
|
+
execute: deps.execute,
|
|
2026
|
+
onError: deps.onError
|
|
2027
|
+
});
|
|
2028
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2029
|
+
deps.onRun?.(finalRun);
|
|
2030
|
+
return finalRun;
|
|
2031
|
+
}
|
|
1715
2032
|
async function tick(deps) {
|
|
1716
2033
|
const now = deps.now?.() ?? new Date;
|
|
1717
2034
|
const recovered = deps.store.recoverExpiredRunLeases(now);
|
|
@@ -1785,23 +2102,17 @@ class LoopsClient {
|
|
|
1785
2102
|
}
|
|
1786
2103
|
async runNow(idOrName) {
|
|
1787
2104
|
const loop = this.get(idOrName);
|
|
1788
|
-
const
|
|
1789
|
-
const
|
|
2105
|
+
const now = new Date;
|
|
2106
|
+
const scheduledFor = manualRunScheduledFor(loop, now);
|
|
2107
|
+
const shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
|
|
2108
|
+
const claim = this.store.claimRun(loop, scheduledFor, this.runnerId, now);
|
|
1790
2109
|
if (!claim)
|
|
1791
2110
|
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
|
-
});
|
|
2111
|
+
const run = await executeClaimedRun({ store: this.store, runnerId: this.runnerId, loop: claim.loop, run: claim.run });
|
|
2112
|
+
if (shouldAdvance) {
|
|
2113
|
+
advanceLoop(this.store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
|
|
2114
|
+
}
|
|
2115
|
+
return run;
|
|
1805
2116
|
}
|
|
1806
2117
|
close() {
|
|
1807
2118
|
if (this.ownStore)
|
|
@@ -1811,10 +2122,214 @@ class LoopsClient {
|
|
|
1811
2122
|
function loops(opts = {}) {
|
|
1812
2123
|
return new LoopsClient(opts);
|
|
1813
2124
|
}
|
|
2125
|
+
// src/lib/doctor.ts
|
|
2126
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2127
|
+
import { accessSync, constants } from "fs";
|
|
2128
|
+
|
|
2129
|
+
// src/daemon/control.ts
|
|
2130
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
|
|
2131
|
+
import { hostname } from "os";
|
|
2132
|
+
import { dirname as dirname2 } from "path";
|
|
2133
|
+
|
|
2134
|
+
// src/daemon/loop.ts
|
|
2135
|
+
function realSleep(ms) {
|
|
2136
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2137
|
+
}
|
|
2138
|
+
async function runLoop(opts) {
|
|
2139
|
+
const sleep = opts.sleep ?? realSleep;
|
|
2140
|
+
const sliceMs = opts.sliceMs ?? 200;
|
|
2141
|
+
while (!opts.shouldStop()) {
|
|
2142
|
+
try {
|
|
2143
|
+
await opts.tickFn();
|
|
2144
|
+
} catch (err) {
|
|
2145
|
+
opts.onTickError?.(err);
|
|
2146
|
+
}
|
|
2147
|
+
let waited = 0;
|
|
2148
|
+
while (waited < opts.intervalMs && !opts.shouldStop()) {
|
|
2149
|
+
const chunk = Math.min(sliceMs, opts.intervalMs - waited);
|
|
2150
|
+
await sleep(chunk);
|
|
2151
|
+
waited += chunk;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// src/daemon/control.ts
|
|
2157
|
+
function readPid(path = pidFilePath()) {
|
|
2158
|
+
if (!existsSync3(path))
|
|
2159
|
+
return;
|
|
2160
|
+
try {
|
|
2161
|
+
const pid = Number(readFileSync(path, "utf8").trim());
|
|
2162
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
2163
|
+
} catch {
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
function writePid(pid = process.pid, path = pidFilePath()) {
|
|
2168
|
+
mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
|
|
2169
|
+
writeFileSync(path, String(pid));
|
|
2170
|
+
}
|
|
2171
|
+
function removePid(path = pidFilePath()) {
|
|
2172
|
+
rmSync(path, { force: true });
|
|
2173
|
+
}
|
|
2174
|
+
function isAlive(pid) {
|
|
2175
|
+
try {
|
|
2176
|
+
process.kill(pid, 0);
|
|
2177
|
+
return true;
|
|
2178
|
+
} catch (err) {
|
|
2179
|
+
return err.code === "EPERM";
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
function isDaemonRunning(path = pidFilePath()) {
|
|
2183
|
+
const pid = readPid(path);
|
|
2184
|
+
if (!pid)
|
|
2185
|
+
return { running: false, stale: false };
|
|
2186
|
+
if (isAlive(pid))
|
|
2187
|
+
return { running: true, stale: false, pid };
|
|
2188
|
+
return { running: false, stale: true, pid };
|
|
2189
|
+
}
|
|
2190
|
+
function daemonStatus(store, path = pidFilePath()) {
|
|
2191
|
+
return {
|
|
2192
|
+
...isDaemonRunning(path),
|
|
2193
|
+
lease: store.getDaemonLease(),
|
|
2194
|
+
host: hostname(),
|
|
2195
|
+
loops: {
|
|
2196
|
+
total: store.countLoops(),
|
|
2197
|
+
active: store.countLoops("active"),
|
|
2198
|
+
paused: store.countLoops("paused"),
|
|
2199
|
+
stopped: store.countLoops("stopped"),
|
|
2200
|
+
expired: store.countLoops("expired")
|
|
2201
|
+
},
|
|
2202
|
+
runs: {
|
|
2203
|
+
total: store.countRuns(),
|
|
2204
|
+
running: store.countRuns("running"),
|
|
2205
|
+
failed: store.countRuns("failed"),
|
|
2206
|
+
succeeded: store.countRuns("succeeded"),
|
|
2207
|
+
abandoned: store.countRuns("abandoned")
|
|
2208
|
+
},
|
|
2209
|
+
logPath: daemonLogPath()
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
async function stopDaemon(opts = {}) {
|
|
2213
|
+
const path = opts.path ?? pidFilePath();
|
|
2214
|
+
const state = isDaemonRunning(path);
|
|
2215
|
+
if (state.stale) {
|
|
2216
|
+
removePid(path);
|
|
2217
|
+
return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
|
|
2218
|
+
}
|
|
2219
|
+
if (!state.running || !state.pid)
|
|
2220
|
+
return { wasRunning: false, stopped: false, forced: false };
|
|
2221
|
+
const store = new Store;
|
|
2222
|
+
try {
|
|
2223
|
+
const lease = store.getDaemonLease();
|
|
2224
|
+
if (!lease || lease.pid !== state.pid || new Date(lease.expiresAt).getTime() <= Date.now()) {
|
|
2225
|
+
removePid(path);
|
|
2226
|
+
return { wasRunning: false, stopped: false, forced: false, pid: state.pid };
|
|
2227
|
+
}
|
|
2228
|
+
} finally {
|
|
2229
|
+
store.close();
|
|
2230
|
+
}
|
|
2231
|
+
const sleep = opts.sleep ?? realSleep;
|
|
2232
|
+
try {
|
|
2233
|
+
process.kill(state.pid, "SIGTERM");
|
|
2234
|
+
} catch {
|
|
2235
|
+
removePid(path);
|
|
2236
|
+
return { wasRunning: true, stopped: true, forced: false, pid: state.pid };
|
|
2237
|
+
}
|
|
2238
|
+
const steps = Math.max(1, Math.ceil((opts.timeoutMs ?? 6000) / 100));
|
|
2239
|
+
for (let i = 0;i < steps; i++) {
|
|
2240
|
+
await sleep(100);
|
|
2241
|
+
if (!isAlive(state.pid)) {
|
|
2242
|
+
removePid(path);
|
|
2243
|
+
return { wasRunning: true, stopped: true, forced: false, pid: state.pid };
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
try {
|
|
2247
|
+
process.kill(state.pid, "SIGKILL");
|
|
2248
|
+
} catch {}
|
|
2249
|
+
await sleep(150);
|
|
2250
|
+
removePid(path);
|
|
2251
|
+
return { wasRunning: true, stopped: !isAlive(state.pid), forced: true, pid: state.pid };
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// src/lib/doctor.ts
|
|
2255
|
+
var PROVIDER_COMMANDS = [
|
|
2256
|
+
"claude",
|
|
2257
|
+
"cursor-agent",
|
|
2258
|
+
"codewith",
|
|
2259
|
+
"aicopilot",
|
|
2260
|
+
"opencode",
|
|
2261
|
+
"codex"
|
|
2262
|
+
];
|
|
2263
|
+
function hasCommand(command) {
|
|
2264
|
+
const result = spawnSync3("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
|
|
2265
|
+
return (result.status ?? 1) === 0;
|
|
2266
|
+
}
|
|
2267
|
+
function commandVersion(command) {
|
|
2268
|
+
const result = spawnSync3(command, ["--version"], {
|
|
2269
|
+
encoding: "utf8",
|
|
2270
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2271
|
+
});
|
|
2272
|
+
if ((result.status ?? 1) !== 0)
|
|
2273
|
+
return;
|
|
2274
|
+
return (result.stdout || result.stderr).trim().split(/\r?\n/)[0];
|
|
2275
|
+
}
|
|
2276
|
+
function runDoctor(store) {
|
|
2277
|
+
const checks = [];
|
|
2278
|
+
try {
|
|
2279
|
+
const dir = ensureDataDir();
|
|
2280
|
+
accessSync(dir, constants.R_OK | constants.W_OK);
|
|
2281
|
+
checks.push({ id: "data-dir", status: "ok", message: "data directory is writable", detail: dir });
|
|
2282
|
+
} catch (error) {
|
|
2283
|
+
checks.push({
|
|
2284
|
+
id: "data-dir",
|
|
2285
|
+
status: "fail",
|
|
2286
|
+
message: "data directory is not writable",
|
|
2287
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
const bunVersion = commandVersion("bun");
|
|
2291
|
+
checks.push(bunVersion ? { id: "bun", status: "ok", message: "bun is available", detail: bunVersion } : { id: "bun", status: "fail", message: "bun is not available on PATH" });
|
|
2292
|
+
const accountsVersion = commandVersion("accounts");
|
|
2293
|
+
checks.push(accountsVersion ? { id: "accounts", status: "ok", message: "accounts is available", detail: accountsVersion } : { id: "accounts", status: "warn", message: "accounts CLI is not available; account-routed steps will fail" });
|
|
2294
|
+
for (const command of PROVIDER_COMMANDS) {
|
|
2295
|
+
checks.push(hasCommand(command) ? { id: `provider:${command}`, status: "ok", message: `${command} is available` } : { id: `provider:${command}`, status: "warn", message: `${command} is not on PATH` });
|
|
2296
|
+
}
|
|
2297
|
+
const status = daemonStatus(store);
|
|
2298
|
+
checks.push(status.running ? { id: "daemon", status: "ok", message: `daemon is running pid=${status.pid}` } : { id: "daemon", status: status.stale ? "warn" : "ok", message: status.stale ? "daemon pid file is stale" : "daemon is not running" });
|
|
2299
|
+
const failedRuns = store.countRuns("failed");
|
|
2300
|
+
checks.push(failedRuns === 0 ? { id: "loop-runs", status: "ok", message: "no failed loop runs recorded" } : { id: "loop-runs", status: "warn", message: `${failedRuns} failed loop run(s) recorded` });
|
|
2301
|
+
for (const loop of store.listLoops({ status: "active" })) {
|
|
2302
|
+
try {
|
|
2303
|
+
if (loop.target.type === "workflow") {
|
|
2304
|
+
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
2305
|
+
for (const step of workflowExecutionOrder(workflow)) {
|
|
2306
|
+
preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id });
|
|
2307
|
+
}
|
|
2308
|
+
} else {
|
|
2309
|
+
preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name });
|
|
2310
|
+
}
|
|
2311
|
+
checks.push({ id: `loop:${loop.id}:preflight`, status: "ok", message: `active loop target is ready: ${loop.name}` });
|
|
2312
|
+
} catch (error) {
|
|
2313
|
+
checks.push({
|
|
2314
|
+
id: `loop:${loop.id}:preflight`,
|
|
2315
|
+
status: "warn",
|
|
2316
|
+
message: `active loop target preflight failed: ${loop.name}`,
|
|
2317
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
return {
|
|
2322
|
+
ok: checks.every((check) => check.status !== "fail"),
|
|
2323
|
+
checks
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
1814
2326
|
export {
|
|
1815
2327
|
workflowExecutionOrder,
|
|
1816
2328
|
workflowBodyFromJson,
|
|
1817
2329
|
tick,
|
|
2330
|
+
runDoctor,
|
|
2331
|
+
preflightWorkflow,
|
|
2332
|
+
preflightTarget,
|
|
1818
2333
|
parseDuration,
|
|
1819
2334
|
parseCron,
|
|
1820
2335
|
nextCronRun,
|