@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/daemon/index.js
CHANGED
|
@@ -249,6 +249,9 @@ function validateTarget(value, label) {
|
|
|
249
249
|
assertObject(value, label);
|
|
250
250
|
if (value.type === "command") {
|
|
251
251
|
assertString(value.command, `${label}.command`);
|
|
252
|
+
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
253
|
+
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
254
|
+
}
|
|
252
255
|
return value;
|
|
253
256
|
}
|
|
254
257
|
if (value.type === "agent") {
|
|
@@ -414,6 +417,7 @@ function rowToWorkflowStepRun(row) {
|
|
|
414
417
|
startedAt: row.started_at ?? undefined,
|
|
415
418
|
finishedAt: row.finished_at ?? undefined,
|
|
416
419
|
exitCode: row.exit_code ?? undefined,
|
|
420
|
+
pid: row.pid ?? undefined,
|
|
417
421
|
durationMs: row.duration_ms ?? undefined,
|
|
418
422
|
stdout: row.stdout ?? undefined,
|
|
419
423
|
stderr: row.stderr ?? undefined,
|
|
@@ -435,6 +439,14 @@ function rowToWorkflowEvent(row) {
|
|
|
435
439
|
createdAt: row.created_at
|
|
436
440
|
};
|
|
437
441
|
}
|
|
442
|
+
function isProcessAlive(pid) {
|
|
443
|
+
try {
|
|
444
|
+
process.kill(pid, 0);
|
|
445
|
+
return true;
|
|
446
|
+
} catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
438
450
|
function rowToLease(row) {
|
|
439
451
|
return {
|
|
440
452
|
id: row.id,
|
|
@@ -567,6 +579,7 @@ class Store {
|
|
|
567
579
|
started_at TEXT,
|
|
568
580
|
finished_at TEXT,
|
|
569
581
|
exit_code INTEGER,
|
|
582
|
+
pid INTEGER,
|
|
570
583
|
duration_ms INTEGER,
|
|
571
584
|
stdout TEXT,
|
|
572
585
|
stderr TEXT,
|
|
@@ -592,6 +605,9 @@ class Store {
|
|
|
592
605
|
);
|
|
593
606
|
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
|
|
594
607
|
`);
|
|
608
|
+
try {
|
|
609
|
+
this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
|
|
610
|
+
} catch {}
|
|
595
611
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
596
612
|
}
|
|
597
613
|
createLoop(input, from = new Date) {
|
|
@@ -772,8 +788,8 @@ class Store {
|
|
|
772
788
|
input.workflow.steps.forEach((step, sequence) => {
|
|
773
789
|
const account = step.account ?? step.target.account;
|
|
774
790
|
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
775
|
-
exit_code, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
|
|
776
|
-
VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
791
|
+
exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
|
|
792
|
+
VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
777
793
|
$accountProfile, $accountTool, $created, $updated)`).run({
|
|
778
794
|
$id: genId(),
|
|
779
795
|
$workflowRunId: runId,
|
|
@@ -814,6 +830,12 @@ class Store {
|
|
|
814
830
|
const row = this.db.query("SELECT * FROM workflow_runs WHERE id = ?").get(id);
|
|
815
831
|
return row ? rowToWorkflowRun(row) : undefined;
|
|
816
832
|
}
|
|
833
|
+
requireWorkflowRun(id) {
|
|
834
|
+
const run = this.getWorkflowRun(id);
|
|
835
|
+
if (!run)
|
|
836
|
+
throw new Error(`workflow run not found: ${id}`);
|
|
837
|
+
return run;
|
|
838
|
+
}
|
|
817
839
|
listWorkflowRuns(opts = {}) {
|
|
818
840
|
const limit = opts.limit ?? 100;
|
|
819
841
|
let rows;
|
|
@@ -834,23 +856,67 @@ class Store {
|
|
|
834
856
|
const row = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? AND step_id = ?").get(workflowRunId, stepId);
|
|
835
857
|
return row ? rowToWorkflowStepRun(row) : undefined;
|
|
836
858
|
}
|
|
859
|
+
isWorkflowRunTerminal(workflowRunId) {
|
|
860
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
861
|
+
return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
|
|
862
|
+
}
|
|
837
863
|
startWorkflowStepRun(workflowRunId, stepId) {
|
|
838
864
|
const now = nowIso();
|
|
839
|
-
this.db.query(`UPDATE workflow_step_runs
|
|
865
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
840
866
|
SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
|
|
841
|
-
stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
842
|
-
WHERE workflow_run_id=$workflowRunId
|
|
843
|
-
|
|
867
|
+
pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
868
|
+
WHERE workflow_run_id=$workflowRunId
|
|
869
|
+
AND step_id=$stepId
|
|
870
|
+
AND status IN ('pending', 'failed', 'timed_out')
|
|
871
|
+
AND EXISTS (
|
|
872
|
+
SELECT 1 FROM workflow_runs
|
|
873
|
+
WHERE id=$workflowRunId AND status='running'
|
|
874
|
+
)`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
|
|
844
875
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
845
876
|
if (!run)
|
|
846
877
|
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
878
|
+
if (res.changes !== 1) {
|
|
879
|
+
throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
|
|
880
|
+
}
|
|
881
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
882
|
+
return run;
|
|
883
|
+
}
|
|
884
|
+
markWorkflowStepPid(workflowRunId, stepId, pid) {
|
|
885
|
+
const now = nowIso();
|
|
886
|
+
this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
|
|
887
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $pid: pid, $updated: now });
|
|
888
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
889
|
+
if (!run)
|
|
890
|
+
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
847
891
|
return run;
|
|
848
892
|
}
|
|
893
|
+
recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
|
|
894
|
+
const now = nowIso();
|
|
895
|
+
const before = this.listWorkflowStepRuns(workflowRunId).filter((step) => step.status === "running");
|
|
896
|
+
const live = before.filter((step) => step.pid !== undefined && isProcessAlive(step.pid));
|
|
897
|
+
if (live.length > 0) {
|
|
898
|
+
throw new Error(`cannot recover workflow run while step processes are still alive: ${live.map((step) => `${step.stepId} pid=${step.pid}`).join(", ")}`);
|
|
899
|
+
}
|
|
900
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
901
|
+
SET status='pending', started_at=NULL, finished_at=NULL, exit_code=NULL, pid=NULL, duration_ms=NULL,
|
|
902
|
+
stdout=NULL, stderr=NULL, error=$reason, updated_at=$updated
|
|
903
|
+
WHERE workflow_run_id=$workflowRunId AND status='running'`).run({ $workflowRunId: workflowRunId, $reason: reason, $updated: now });
|
|
904
|
+
if (before.length > 0) {
|
|
905
|
+
this.appendWorkflowEvent(workflowRunId, "recovered", undefined, {
|
|
906
|
+
reason,
|
|
907
|
+
recoveredSteps: before.map((step) => step.stepId)
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
run: this.requireWorkflowRun(workflowRunId),
|
|
912
|
+
recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
|
|
913
|
+
};
|
|
914
|
+
}
|
|
849
915
|
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
850
916
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
851
|
-
this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
852
|
-
stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
853
|
-
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({
|
|
917
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
918
|
+
pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
919
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({
|
|
854
920
|
$workflowRunId: workflowRunId,
|
|
855
921
|
$stepId: stepId,
|
|
856
922
|
$status: patch.status,
|
|
@@ -862,10 +928,12 @@ class Store {
|
|
|
862
928
|
$error: patch.error ?? null,
|
|
863
929
|
$updated: finishedAt
|
|
864
930
|
});
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
931
|
+
if (res.changes === 1) {
|
|
932
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
933
|
+
exitCode: patch.exitCode,
|
|
934
|
+
error: patch.error
|
|
935
|
+
});
|
|
936
|
+
}
|
|
869
937
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
870
938
|
if (!run)
|
|
871
939
|
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
@@ -873,9 +941,10 @@ class Store {
|
|
|
873
941
|
}
|
|
874
942
|
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
875
943
|
const now = nowIso();
|
|
876
|
-
this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, error=$error, updated_at=$updated
|
|
877
|
-
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $finished: now, $error: reason, $updated: now });
|
|
878
|
-
|
|
944
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
|
|
945
|
+
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 });
|
|
946
|
+
if (res.changes === 1)
|
|
947
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
879
948
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
880
949
|
if (!run)
|
|
881
950
|
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
@@ -883,8 +952,8 @@ class Store {
|
|
|
883
952
|
}
|
|
884
953
|
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
885
954
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
886
|
-
this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
887
|
-
WHERE id=$id`).run({
|
|
955
|
+
const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
956
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({
|
|
888
957
|
$id: workflowRunId,
|
|
889
958
|
$status: status,
|
|
890
959
|
$finished: finishedAt,
|
|
@@ -892,12 +961,36 @@ class Store {
|
|
|
892
961
|
$error: patch.error ?? null,
|
|
893
962
|
$updated: finishedAt
|
|
894
963
|
});
|
|
895
|
-
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
896
964
|
const run = this.getWorkflowRun(workflowRunId);
|
|
897
965
|
if (!run)
|
|
898
966
|
throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
|
|
967
|
+
if (res.changes === 1)
|
|
968
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
899
969
|
return run;
|
|
900
970
|
}
|
|
971
|
+
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
972
|
+
const now = nowIso();
|
|
973
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
974
|
+
try {
|
|
975
|
+
const run = this.requireWorkflowRun(workflowRunId);
|
|
976
|
+
if (!["succeeded", "failed", "timed_out", "cancelled"].includes(run.status)) {
|
|
977
|
+
this.db.query(`UPDATE workflow_runs
|
|
978
|
+
SET status='cancelled', finished_at=$finished, error=$reason, updated_at=$updated
|
|
979
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
980
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
981
|
+
SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
|
|
982
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
983
|
+
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
984
|
+
}
|
|
985
|
+
this.db.exec("COMMIT");
|
|
986
|
+
return this.requireWorkflowRun(workflowRunId);
|
|
987
|
+
} catch (error) {
|
|
988
|
+
try {
|
|
989
|
+
this.db.exec("ROLLBACK");
|
|
990
|
+
} catch {}
|
|
991
|
+
throw error;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
901
994
|
appendWorkflowEvent(workflowRunId, eventType, stepId, payload) {
|
|
902
995
|
const now = nowIso();
|
|
903
996
|
const current = this.db.query("SELECT MAX(sequence) AS sequence FROM workflow_events WHERE workflow_run_id = ?").get(workflowRunId);
|
|
@@ -926,6 +1019,24 @@ class Store {
|
|
|
926
1019
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
927
1020
|
return (row?.count ?? 0) > 0;
|
|
928
1021
|
}
|
|
1022
|
+
markRunPid(id, pid, claimedBy) {
|
|
1023
|
+
const now = nowIso();
|
|
1024
|
+
const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1025
|
+
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 });
|
|
1026
|
+
if (res.changes !== 1)
|
|
1027
|
+
return;
|
|
1028
|
+
return this.getRun(id);
|
|
1029
|
+
}
|
|
1030
|
+
hasLiveWorkflowStepProcesses(loopRunId) {
|
|
1031
|
+
const liveWorkflowSteps = this.db.query(`SELECT wr.id AS workflow_run_id, wsr.step_id AS step_id, wsr.pid AS pid
|
|
1032
|
+
FROM workflow_runs wr
|
|
1033
|
+
JOIN workflow_step_runs wsr ON wsr.workflow_run_id = wr.id
|
|
1034
|
+
WHERE wr.loop_run_id = ?
|
|
1035
|
+
AND wr.status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1036
|
+
AND wsr.status = 'running'
|
|
1037
|
+
AND wsr.pid IS NOT NULL`).all(loopRunId);
|
|
1038
|
+
return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
|
|
1039
|
+
}
|
|
929
1040
|
createSkippedRun(loop, scheduledFor, reason) {
|
|
930
1041
|
const now = nowIso();
|
|
931
1042
|
const run = {
|
|
@@ -973,6 +1084,14 @@ class Store {
|
|
|
973
1084
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
974
1085
|
if (existing) {
|
|
975
1086
|
if (existing.status === "running") {
|
|
1087
|
+
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && existing.pid && isProcessAlive(existing.pid)) {
|
|
1088
|
+
this.db.exec("COMMIT");
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && this.hasLiveWorkflowStepProcesses(existing.id)) {
|
|
1092
|
+
this.db.exec("COMMIT");
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
976
1095
|
const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
|
|
977
1096
|
claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, exit_code=NULL,
|
|
978
1097
|
duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
@@ -1095,8 +1214,26 @@ class Store {
|
|
|
1095
1214
|
const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
|
|
1096
1215
|
const recovered = [];
|
|
1097
1216
|
for (const row of rows) {
|
|
1217
|
+
if (row.pid && isProcessAlive(row.pid))
|
|
1218
|
+
continue;
|
|
1219
|
+
if (this.hasLiveWorkflowStepProcesses(row.id))
|
|
1220
|
+
continue;
|
|
1221
|
+
const finished = now.toISOString();
|
|
1098
1222
|
this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
|
|
1099
|
-
error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished:
|
|
1223
|
+
error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: finished, $updated: finished });
|
|
1224
|
+
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);
|
|
1225
|
+
for (const workflowRow of workflowRows) {
|
|
1226
|
+
this.db.query(`UPDATE workflow_runs
|
|
1227
|
+
SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1228
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRow.id, $finished: finished, $updated: finished });
|
|
1229
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
1230
|
+
SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1231
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRow.id, $finished: finished, $updated: finished });
|
|
1232
|
+
this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
|
|
1233
|
+
error: "parent loop run lease expired before completion",
|
|
1234
|
+
loopRunId: row.id
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1100
1237
|
const run = this.getRun(row.id);
|
|
1101
1238
|
if (run)
|
|
1102
1239
|
recovered.push(run);
|
|
@@ -1176,8 +1313,9 @@ import { hostname as hostname2 } from "os";
|
|
|
1176
1313
|
import { spawn as spawn2 } from "child_process";
|
|
1177
1314
|
|
|
1178
1315
|
// src/lib/executor.ts
|
|
1179
|
-
import { spawn } from "child_process";
|
|
1316
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
1180
1317
|
import { once } from "events";
|
|
1318
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1181
1319
|
|
|
1182
1320
|
// src/lib/accounts.ts
|
|
1183
1321
|
import { spawnSync } from "child_process";
|
|
@@ -1225,6 +1363,25 @@ function primaryAccountDir(output) {
|
|
|
1225
1363
|
}
|
|
1226
1364
|
return;
|
|
1227
1365
|
}
|
|
1366
|
+
function accountDirEnvVar(tool) {
|
|
1367
|
+
switch (tool) {
|
|
1368
|
+
case "claude":
|
|
1369
|
+
return "CLAUDE_CONFIG_DIR";
|
|
1370
|
+
case "codex":
|
|
1371
|
+
case "codex-app":
|
|
1372
|
+
return "CODEX_HOME";
|
|
1373
|
+
case "cursor":
|
|
1374
|
+
return "CURSOR_CONFIG_DIR";
|
|
1375
|
+
case "opencode":
|
|
1376
|
+
return "OPENCODE_CONFIG_DIR";
|
|
1377
|
+
case "codewith":
|
|
1378
|
+
return "CODEWITH_HOME";
|
|
1379
|
+
case "aicopilot":
|
|
1380
|
+
return "AICOPILOT_CONFIG_DIR";
|
|
1381
|
+
default:
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1228
1385
|
function resolveAccountEnv(account, toolHint, env) {
|
|
1229
1386
|
if (!account)
|
|
1230
1387
|
return {};
|
|
@@ -1243,13 +1400,14 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
1243
1400
|
const stderr = result.stderr.trim();
|
|
1244
1401
|
throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
|
|
1245
1402
|
}
|
|
1246
|
-
const
|
|
1403
|
+
const accountEnv = parseAccountExportLines(result.stdout);
|
|
1404
|
+
const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
|
|
1247
1405
|
if (!profileDir)
|
|
1248
1406
|
throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
|
|
1249
1407
|
if (!existsSync(profileDir))
|
|
1250
1408
|
throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
|
|
1251
1409
|
return {
|
|
1252
|
-
...
|
|
1410
|
+
...accountEnv,
|
|
1253
1411
|
LOOPS_ACCOUNT_PROFILE: account.profile,
|
|
1254
1412
|
LOOPS_ACCOUNT_TOOL: tool
|
|
1255
1413
|
};
|
|
@@ -1438,6 +1596,27 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1438
1596
|
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1439
1597
|
return env;
|
|
1440
1598
|
}
|
|
1599
|
+
function commandExists(command, env) {
|
|
1600
|
+
if (command.includes("/") && existsSync2(command))
|
|
1601
|
+
return true;
|
|
1602
|
+
const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], {
|
|
1603
|
+
env,
|
|
1604
|
+
stdio: "ignore"
|
|
1605
|
+
});
|
|
1606
|
+
return (result.status ?? 1) === 0;
|
|
1607
|
+
}
|
|
1608
|
+
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1609
|
+
const spec = commandSpec(target);
|
|
1610
|
+
const env = executionEnv(spec, metadata, opts);
|
|
1611
|
+
if (!spec.shell && !commandExists(spec.command, env)) {
|
|
1612
|
+
throw new Error(`Executable not found in PATH: ${spec.command}`);
|
|
1613
|
+
}
|
|
1614
|
+
return {
|
|
1615
|
+
command: spec.command,
|
|
1616
|
+
accountProfile: spec.account?.profile,
|
|
1617
|
+
accountTool: spec.accountTool
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1441
1620
|
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1442
1621
|
const spec = commandSpec(target);
|
|
1443
1622
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
@@ -1448,6 +1627,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1448
1627
|
let exitCode;
|
|
1449
1628
|
let error;
|
|
1450
1629
|
const env = executionEnv(spec, metadata, opts);
|
|
1630
|
+
if (!spec.shell && !commandExists(spec.command, env)) {
|
|
1631
|
+
return {
|
|
1632
|
+
status: "failed",
|
|
1633
|
+
stdout: "",
|
|
1634
|
+
stderr: "",
|
|
1635
|
+
error: `Executable not found in PATH: ${spec.command}`,
|
|
1636
|
+
startedAt,
|
|
1637
|
+
finishedAt: nowIso(),
|
|
1638
|
+
durationMs: 0
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1451
1641
|
const child = spawn(spec.command, spec.args, {
|
|
1452
1642
|
cwd: spec.cwd,
|
|
1453
1643
|
env,
|
|
@@ -1455,6 +1645,16 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1455
1645
|
detached: true,
|
|
1456
1646
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1457
1647
|
});
|
|
1648
|
+
if (child.pid)
|
|
1649
|
+
opts.onSpawn?.(child.pid);
|
|
1650
|
+
const abortHandler = () => {
|
|
1651
|
+
error = "cancelled";
|
|
1652
|
+
if (child.pid)
|
|
1653
|
+
killProcessGroup(child.pid);
|
|
1654
|
+
};
|
|
1655
|
+
if (opts.signal?.aborted)
|
|
1656
|
+
abortHandler();
|
|
1657
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1458
1658
|
child.stdout.on("data", (chunk) => {
|
|
1459
1659
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
1460
1660
|
});
|
|
@@ -1477,6 +1677,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1477
1677
|
error = err instanceof Error ? err.message : String(err);
|
|
1478
1678
|
} finally {
|
|
1479
1679
|
clearTimeout(timer);
|
|
1680
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
1480
1681
|
}
|
|
1481
1682
|
const finishedAt = nowIso();
|
|
1482
1683
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
@@ -1532,14 +1733,16 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
1532
1733
|
// src/lib/workflow-runner.ts
|
|
1533
1734
|
function targetWithStepAccount(step) {
|
|
1534
1735
|
const account = step.account ?? step.target.account;
|
|
1535
|
-
|
|
1736
|
+
const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
|
|
1737
|
+
if (!account && timeoutMs === step.target.timeoutMs)
|
|
1536
1738
|
return step.target;
|
|
1537
|
-
return { ...step.target, account };
|
|
1739
|
+
return { ...step.target, account, timeoutMs };
|
|
1538
1740
|
}
|
|
1539
1741
|
function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
|
|
1742
|
+
const executorStatus = status === "succeeded" ? "succeeded" : status === "timed_out" ? "timed_out" : "failed";
|
|
1540
1743
|
return {
|
|
1541
|
-
status,
|
|
1542
|
-
exitCode:
|
|
1744
|
+
status: executorStatus,
|
|
1745
|
+
exitCode: executorStatus === "succeeded" ? 0 : 1,
|
|
1543
1746
|
stdout,
|
|
1544
1747
|
stderr: "",
|
|
1545
1748
|
error,
|
|
@@ -1557,7 +1760,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1557
1760
|
idempotencyKey: opts.idempotencyKey
|
|
1558
1761
|
});
|
|
1559
1762
|
const startedAt = run.startedAt ?? nowIso();
|
|
1560
|
-
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out") {
|
|
1763
|
+
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
1561
1764
|
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1562
1765
|
return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
|
|
1563
1766
|
}
|
|
@@ -1566,8 +1769,19 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1566
1769
|
let blockingError;
|
|
1567
1770
|
let terminalStatus = "succeeded";
|
|
1568
1771
|
for (const step of ordered) {
|
|
1772
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1773
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1774
|
+
blockingError = "workflow run was cancelled";
|
|
1775
|
+
break;
|
|
1776
|
+
}
|
|
1777
|
+
const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1778
|
+
if (pendingTimeout) {
|
|
1779
|
+
terminalStatus = "timed_out";
|
|
1780
|
+
blockingError = pendingTimeout;
|
|
1781
|
+
break;
|
|
1782
|
+
}
|
|
1569
1783
|
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1570
|
-
if (existing?.status === "succeeded" || existing?.status === "skipped")
|
|
1784
|
+
if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
|
|
1571
1785
|
continue;
|
|
1572
1786
|
const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
|
|
1573
1787
|
const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
|
|
@@ -1582,8 +1796,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1582
1796
|
terminalStatus = "failed";
|
|
1583
1797
|
continue;
|
|
1584
1798
|
}
|
|
1585
|
-
store.startWorkflowStepRun(run.id, step.id);
|
|
1586
|
-
|
|
1799
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id);
|
|
1800
|
+
if (startedStep.status !== "running") {
|
|
1801
|
+
terminalStatus = "failed";
|
|
1802
|
+
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
1803
|
+
break;
|
|
1804
|
+
}
|
|
1805
|
+
const metadata = {
|
|
1587
1806
|
loopId: opts.loop?.id,
|
|
1588
1807
|
loopName: opts.loop?.name,
|
|
1589
1808
|
runId: opts.loopRun?.id,
|
|
@@ -1592,7 +1811,51 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1592
1811
|
workflowName: workflow.name,
|
|
1593
1812
|
workflowRunId: run.id,
|
|
1594
1813
|
workflowStepId: step.id
|
|
1595
|
-
}
|
|
1814
|
+
};
|
|
1815
|
+
let result;
|
|
1816
|
+
const controller = new AbortController;
|
|
1817
|
+
const externalAbort = () => controller.abort();
|
|
1818
|
+
if (opts.signal?.aborted)
|
|
1819
|
+
controller.abort();
|
|
1820
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1821
|
+
const cancelTimer = setInterval(() => {
|
|
1822
|
+
if (store.getWorkflowRun(run.id)?.status === "cancelled")
|
|
1823
|
+
controller.abort();
|
|
1824
|
+
}, opts.cancelPollMs ?? 500);
|
|
1825
|
+
cancelTimer.unref();
|
|
1826
|
+
try {
|
|
1827
|
+
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
1828
|
+
...opts,
|
|
1829
|
+
signal: controller.signal,
|
|
1830
|
+
onSpawn: (pid) => {
|
|
1831
|
+
store.markWorkflowStepPid(run.id, step.id, pid);
|
|
1832
|
+
opts.onSpawn?.(pid);
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
} catch (error) {
|
|
1836
|
+
const finishedAt2 = nowIso();
|
|
1837
|
+
result = {
|
|
1838
|
+
status: "failed",
|
|
1839
|
+
stdout: "",
|
|
1840
|
+
stderr: "",
|
|
1841
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1842
|
+
startedAt: startedStep.startedAt ?? finishedAt2,
|
|
1843
|
+
finishedAt: finishedAt2,
|
|
1844
|
+
durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
|
|
1845
|
+
};
|
|
1846
|
+
} finally {
|
|
1847
|
+
clearInterval(cancelTimer);
|
|
1848
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1849
|
+
}
|
|
1850
|
+
const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1851
|
+
if (timeoutMessage && result.status === "failed") {
|
|
1852
|
+
result = { ...result, status: "timed_out", error: timeoutMessage };
|
|
1853
|
+
}
|
|
1854
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1855
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1856
|
+
blockingError = "workflow run was cancelled";
|
|
1857
|
+
break;
|
|
1858
|
+
}
|
|
1596
1859
|
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1597
1860
|
status: result.status,
|
|
1598
1861
|
finishedAt: result.finishedAt,
|
|
@@ -1617,6 +1880,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1617
1880
|
}
|
|
1618
1881
|
}
|
|
1619
1882
|
const finishedAt = nowIso();
|
|
1883
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1884
|
+
const terminalRun = store.requireWorkflowRun(run.id);
|
|
1885
|
+
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1886
|
+
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
1887
|
+
}
|
|
1620
1888
|
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
1621
1889
|
finishedAt,
|
|
1622
1890
|
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
@@ -1625,20 +1893,59 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1625
1893
|
const steps = store.listWorkflowStepRuns(run.id);
|
|
1626
1894
|
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
1627
1895
|
}
|
|
1896
|
+
function preflightWorkflow(workflow, opts = {}) {
|
|
1897
|
+
return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
|
|
1898
|
+
workflowId: workflow.id,
|
|
1899
|
+
workflowName: workflow.name,
|
|
1900
|
+
workflowStepId: step.id
|
|
1901
|
+
}, opts));
|
|
1902
|
+
}
|
|
1628
1903
|
async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
1629
1904
|
if (loop.target.type !== "workflow")
|
|
1630
1905
|
return executeLoop(loop, run, opts);
|
|
1631
1906
|
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1907
|
+
const controller = loop.target.timeoutMs ? new AbortController : undefined;
|
|
1908
|
+
let workflowTimedOut = false;
|
|
1909
|
+
const externalAbort = () => controller?.abort();
|
|
1910
|
+
if (controller && opts.signal?.aborted)
|
|
1911
|
+
controller.abort();
|
|
1912
|
+
if (controller)
|
|
1913
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1914
|
+
const timer = controller ? setTimeout(() => {
|
|
1915
|
+
workflowTimedOut = true;
|
|
1916
|
+
controller.abort();
|
|
1917
|
+
}, loop.target.timeoutMs) : undefined;
|
|
1918
|
+
timer?.unref();
|
|
1919
|
+
try {
|
|
1920
|
+
return await executeWorkflow(store, workflow, {
|
|
1921
|
+
...opts,
|
|
1922
|
+
signal: controller?.signal ?? opts.signal,
|
|
1923
|
+
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
|
|
1924
|
+
loop,
|
|
1925
|
+
loopRun: run,
|
|
1926
|
+
scheduledFor: run.scheduledFor,
|
|
1927
|
+
idempotencyKey: `${loop.id}:${run.scheduledFor}:attempt:${run.attempt}`
|
|
1928
|
+
});
|
|
1929
|
+
} finally {
|
|
1930
|
+
if (timer)
|
|
1931
|
+
clearTimeout(timer);
|
|
1932
|
+
if (controller)
|
|
1933
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1934
|
+
}
|
|
1639
1935
|
}
|
|
1640
1936
|
|
|
1641
1937
|
// src/lib/scheduler.ts
|
|
1938
|
+
function manualRunScheduledFor(loop, now = new Date) {
|
|
1939
|
+
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
1940
|
+
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
1941
|
+
}
|
|
1942
|
+
return now.toISOString();
|
|
1943
|
+
}
|
|
1944
|
+
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
1945
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
1946
|
+
return false;
|
|
1947
|
+
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
1948
|
+
}
|
|
1642
1949
|
function nextAfterRetry(loop, now) {
|
|
1643
1950
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
1644
1951
|
}
|
|
@@ -1664,27 +1971,18 @@ function advanceLoop(store, loop, run, finishedAt, succeeded) {
|
|
|
1664
1971
|
retryScheduledFor: undefined
|
|
1665
1972
|
});
|
|
1666
1973
|
}
|
|
1667
|
-
async function
|
|
1668
|
-
const now = deps.now?.() ?? new Date;
|
|
1669
|
-
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
1670
|
-
const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
|
|
1671
|
-
advanceLoop(deps.store, loop, skipped, now, true);
|
|
1672
|
-
deps.onRun?.(skipped);
|
|
1673
|
-
return skipped;
|
|
1674
|
-
}
|
|
1675
|
-
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
1676
|
-
if (!claim)
|
|
1677
|
-
return;
|
|
1678
|
-
deps.onRun?.(claim.run);
|
|
1974
|
+
async function executeClaimedRun(deps) {
|
|
1679
1975
|
let heartbeat;
|
|
1680
|
-
const heartbeatEveryMs = Math.max(
|
|
1976
|
+
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
1681
1977
|
heartbeat = setInterval(() => {
|
|
1682
|
-
deps.store.heartbeatRunLease(
|
|
1978
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
|
|
1683
1979
|
}, heartbeatEveryMs);
|
|
1684
1980
|
heartbeat.unref();
|
|
1685
1981
|
try {
|
|
1686
|
-
const result = await (deps.execute ?? ((
|
|
1687
|
-
|
|
1982
|
+
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
1983
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
|
|
1984
|
+
})))(deps.loop, deps.run);
|
|
1985
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
1688
1986
|
status: result.status,
|
|
1689
1987
|
finishedAt: result.finishedAt,
|
|
1690
1988
|
durationMs: result.durationMs,
|
|
@@ -1697,16 +1995,13 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
1697
1995
|
claimedBy: deps.runnerId,
|
|
1698
1996
|
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
1699
1997
|
});
|
|
1700
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), finalRun.status === "succeeded");
|
|
1701
|
-
deps.onRun?.(finalRun);
|
|
1702
|
-
return finalRun;
|
|
1703
1998
|
} catch (err) {
|
|
1704
|
-
deps.onError?.(
|
|
1999
|
+
deps.onError?.(deps.loop, err);
|
|
1705
2000
|
const finishedAt = new Date;
|
|
1706
|
-
|
|
2001
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
1707
2002
|
status: "failed",
|
|
1708
2003
|
finishedAt: finishedAt.toISOString(),
|
|
1709
|
-
durationMs: finishedAt.getTime() - new Date(
|
|
2004
|
+
durationMs: finishedAt.getTime() - new Date(deps.run.startedAt ?? deps.run.createdAt).getTime(),
|
|
1710
2005
|
stdout: "",
|
|
1711
2006
|
stderr: "",
|
|
1712
2007
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -1714,14 +2009,36 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
1714
2009
|
claimedBy: deps.runnerId,
|
|
1715
2010
|
now: deps.now?.() ?? finishedAt
|
|
1716
2011
|
});
|
|
1717
|
-
advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
|
|
1718
|
-
deps.onRun?.(finalRun);
|
|
1719
|
-
return finalRun;
|
|
1720
2012
|
} finally {
|
|
1721
2013
|
if (heartbeat)
|
|
1722
2014
|
clearInterval(heartbeat);
|
|
1723
2015
|
}
|
|
1724
2016
|
}
|
|
2017
|
+
async function runSlot(deps, loop, scheduledFor) {
|
|
2018
|
+
const now = deps.now?.() ?? new Date;
|
|
2019
|
+
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2020
|
+
const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
|
|
2021
|
+
advanceLoop(deps.store, loop, skipped, now, true);
|
|
2022
|
+
deps.onRun?.(skipped);
|
|
2023
|
+
return skipped;
|
|
2024
|
+
}
|
|
2025
|
+
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
2026
|
+
if (!claim)
|
|
2027
|
+
return;
|
|
2028
|
+
deps.onRun?.(claim.run);
|
|
2029
|
+
const finalRun = await executeClaimedRun({
|
|
2030
|
+
store: deps.store,
|
|
2031
|
+
runnerId: deps.runnerId,
|
|
2032
|
+
loop: claim.loop,
|
|
2033
|
+
run: claim.run,
|
|
2034
|
+
now: deps.now,
|
|
2035
|
+
execute: deps.execute,
|
|
2036
|
+
onError: deps.onError
|
|
2037
|
+
});
|
|
2038
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2039
|
+
deps.onRun?.(finalRun);
|
|
2040
|
+
return finalRun;
|
|
2041
|
+
}
|
|
1725
2042
|
async function tick(deps) {
|
|
1726
2043
|
const now = deps.now?.() ?? new Date;
|
|
1727
2044
|
const recovered = deps.store.recoverExpiredRunLeases(now);
|
|
@@ -1754,7 +2071,7 @@ async function tick(deps) {
|
|
|
1754
2071
|
}
|
|
1755
2072
|
|
|
1756
2073
|
// src/daemon/control.ts
|
|
1757
|
-
import { existsSync as
|
|
2074
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
|
|
1758
2075
|
import { hostname } from "os";
|
|
1759
2076
|
import { dirname as dirname2 } from "path";
|
|
1760
2077
|
|
|
@@ -1782,7 +2099,7 @@ async function runLoop(opts) {
|
|
|
1782
2099
|
|
|
1783
2100
|
// src/daemon/control.ts
|
|
1784
2101
|
function readPid(path = pidFilePath()) {
|
|
1785
|
-
if (!
|
|
2102
|
+
if (!existsSync3(path))
|
|
1786
2103
|
return;
|
|
1787
2104
|
try {
|
|
1788
2105
|
const pid = Number(readFileSync(path, "utf8").trim());
|
|
@@ -1897,6 +2214,7 @@ async function runDaemon(opts = {}) {
|
|
|
1897
2214
|
const ownStore = !opts.store;
|
|
1898
2215
|
const store = opts.store ?? new Store;
|
|
1899
2216
|
const leaseId = genId();
|
|
2217
|
+
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
1900
2218
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
1901
2219
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
1902
2220
|
const log = opts.log ?? ((message) => console.error(`[loops-daemon] ${message}`));
|
|
@@ -1912,18 +2230,28 @@ async function runDaemon(opts = {}) {
|
|
|
1912
2230
|
log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
|
|
1913
2231
|
let stopFlag = false;
|
|
1914
2232
|
let leaseLost = false;
|
|
2233
|
+
const runAbort = new AbortController;
|
|
2234
|
+
const requestStop = (message) => {
|
|
2235
|
+
stopFlag = true;
|
|
2236
|
+
if (!runAbort.signal.aborted)
|
|
2237
|
+
runAbort.abort();
|
|
2238
|
+
if (message)
|
|
2239
|
+
log(message);
|
|
2240
|
+
};
|
|
1915
2241
|
const ensureLease = () => {
|
|
1916
2242
|
const current = store.heartbeatDaemonLease(leaseId, leaseTtlMs);
|
|
1917
2243
|
if (!current || current.id !== leaseId) {
|
|
1918
2244
|
leaseLost = true;
|
|
1919
|
-
|
|
2245
|
+
requestStop("daemon lease lost");
|
|
1920
2246
|
throw new Error("daemon lease lost");
|
|
1921
2247
|
}
|
|
1922
2248
|
};
|
|
1923
2249
|
const onSignal = () => {
|
|
1924
|
-
|
|
1925
|
-
log("stop signal received");
|
|
2250
|
+
requestStop("stop signal received");
|
|
1926
2251
|
};
|
|
2252
|
+
if (opts.signal?.aborted)
|
|
2253
|
+
onSignal();
|
|
2254
|
+
opts.signal?.addEventListener("abort", onSignal, { once: true });
|
|
1927
2255
|
process.on("SIGINT", onSignal);
|
|
1928
2256
|
process.on("SIGTERM", onSignal);
|
|
1929
2257
|
try {
|
|
@@ -1936,7 +2264,7 @@ async function runDaemon(opts = {}) {
|
|
|
1936
2264
|
ensureLease();
|
|
1937
2265
|
const result = await tick({
|
|
1938
2266
|
store,
|
|
1939
|
-
runnerId
|
|
2267
|
+
runnerId,
|
|
1940
2268
|
execute: async (loop, run) => {
|
|
1941
2269
|
const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs / 3));
|
|
1942
2270
|
const timer = setInterval(() => {
|
|
@@ -1948,7 +2276,10 @@ async function runDaemon(opts = {}) {
|
|
|
1948
2276
|
}, heartbeatMs);
|
|
1949
2277
|
timer.unref();
|
|
1950
2278
|
try {
|
|
1951
|
-
const result2 = await
|
|
2279
|
+
const result2 = await executeLoopTarget(store, loop, run, {
|
|
2280
|
+
signal: runAbort.signal,
|
|
2281
|
+
onSpawn: (pid) => store.markRunPid(run.id, pid, runnerId)
|
|
2282
|
+
});
|
|
1952
2283
|
if (leaseLost)
|
|
1953
2284
|
throw new Error("daemon lease lost during run");
|
|
1954
2285
|
return result2;
|
|
@@ -1965,6 +2296,7 @@ async function runDaemon(opts = {}) {
|
|
|
1965
2296
|
}
|
|
1966
2297
|
});
|
|
1967
2298
|
} finally {
|
|
2299
|
+
opts.signal?.removeEventListener("abort", onSignal);
|
|
1968
2300
|
process.off("SIGINT", onSignal);
|
|
1969
2301
|
process.off("SIGTERM", onSignal);
|
|
1970
2302
|
store.releaseDaemonLease(leaseId);
|
|
@@ -2000,6 +2332,7 @@ async function startDaemon(opts) {
|
|
|
2000
2332
|
|
|
2001
2333
|
// src/daemon/install.ts
|
|
2002
2334
|
import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
2335
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2003
2336
|
import { dirname as dirname3 } from "path";
|
|
2004
2337
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
2005
2338
|
const command = [execPath, cliEntry, ...args].join(" ");
|
|
@@ -2061,10 +2394,25 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2061
2394
|
}
|
|
2062
2395
|
throw new Error(`startup install is not implemented for ${process.platform}`);
|
|
2063
2396
|
}
|
|
2397
|
+
function enableStartup(result) {
|
|
2398
|
+
const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
|
|
2399
|
+
return commands.map((command) => {
|
|
2400
|
+
const run = spawnSync3("sh", ["-c", command], {
|
|
2401
|
+
encoding: "utf8",
|
|
2402
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2403
|
+
});
|
|
2404
|
+
return {
|
|
2405
|
+
command,
|
|
2406
|
+
status: run.status,
|
|
2407
|
+
stdout: run.stdout.trim(),
|
|
2408
|
+
stderr: run.stderr.trim()
|
|
2409
|
+
};
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2064
2412
|
|
|
2065
2413
|
// src/daemon/index.ts
|
|
2066
2414
|
var program = new Command;
|
|
2067
|
-
program.name("loops-daemon").description("OpenLoops daemon helper").version("0.
|
|
2415
|
+
program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.0");
|
|
2068
2416
|
program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
|
|
2069
2417
|
program.command("start").action(async () => {
|
|
2070
2418
|
const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
|