@hasna/loops 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -4
- package/dist/cli/index.js +686 -88
- package/dist/daemon/daemon.d.ts +1 -0
- package/dist/daemon/index.js +474 -71
- package/dist/daemon/install.d.ts +8 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +644 -79
- package/dist/lib/doctor.d.ts +13 -0
- package/dist/lib/env.d.ts +4 -0
- package/dist/lib/executor.d.ts +8 -0
- package/dist/lib/format.d.ts +3 -0
- package/dist/lib/scheduler.d.ts +12 -0
- package/dist/lib/store.d.ts +10 -0
- package/dist/lib/store.js +157 -20
- package/dist/lib/workflow-runner.d.ts +4 -1
- package/dist/sdk/index.js +440 -79
- package/dist/types.d.ts +3 -2
- package/docs/USAGE.md +25 -4
- package/package.json +1 -1
package/dist/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);
|
|
847
882
|
return run;
|
|
848
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}`);
|
|
891
|
+
return run;
|
|
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);
|
|
@@ -1225,6 +1362,25 @@ function primaryAccountDir(output) {
|
|
|
1225
1362
|
}
|
|
1226
1363
|
return;
|
|
1227
1364
|
}
|
|
1365
|
+
function accountDirEnvVar(tool) {
|
|
1366
|
+
switch (tool) {
|
|
1367
|
+
case "claude":
|
|
1368
|
+
return "CLAUDE_CONFIG_DIR";
|
|
1369
|
+
case "codex":
|
|
1370
|
+
case "codex-app":
|
|
1371
|
+
return "CODEX_HOME";
|
|
1372
|
+
case "cursor":
|
|
1373
|
+
return "CURSOR_CONFIG_DIR";
|
|
1374
|
+
case "opencode":
|
|
1375
|
+
return "OPENCODE_CONFIG_DIR";
|
|
1376
|
+
case "codewith":
|
|
1377
|
+
return "CODEWITH_HOME";
|
|
1378
|
+
case "aicopilot":
|
|
1379
|
+
return "AICOPILOT_CONFIG_DIR";
|
|
1380
|
+
default:
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1228
1384
|
function resolveAccountEnv(account, toolHint, env) {
|
|
1229
1385
|
if (!account)
|
|
1230
1386
|
return {};
|
|
@@ -1243,18 +1399,78 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
1243
1399
|
const stderr = result.stderr.trim();
|
|
1244
1400
|
throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
|
|
1245
1401
|
}
|
|
1246
|
-
const
|
|
1402
|
+
const accountEnv = parseAccountExportLines(result.stdout);
|
|
1403
|
+
const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
|
|
1247
1404
|
if (!profileDir)
|
|
1248
1405
|
throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
|
|
1249
1406
|
if (!existsSync(profileDir))
|
|
1250
1407
|
throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
|
|
1251
1408
|
return {
|
|
1252
|
-
...
|
|
1409
|
+
...accountEnv,
|
|
1253
1410
|
LOOPS_ACCOUNT_PROFILE: account.profile,
|
|
1254
1411
|
LOOPS_ACCOUNT_TOOL: tool
|
|
1255
1412
|
};
|
|
1256
1413
|
}
|
|
1257
1414
|
|
|
1415
|
+
// src/lib/env.ts
|
|
1416
|
+
import { accessSync, constants } from "fs";
|
|
1417
|
+
import { homedir as homedir2 } from "os";
|
|
1418
|
+
import { delimiter, join as join2 } from "path";
|
|
1419
|
+
function compactPathParts(parts) {
|
|
1420
|
+
const seen = new Set;
|
|
1421
|
+
const result = [];
|
|
1422
|
+
for (const part of parts) {
|
|
1423
|
+
const value = part?.trim();
|
|
1424
|
+
if (!value || seen.has(value))
|
|
1425
|
+
continue;
|
|
1426
|
+
seen.add(value);
|
|
1427
|
+
result.push(value);
|
|
1428
|
+
}
|
|
1429
|
+
return result;
|
|
1430
|
+
}
|
|
1431
|
+
function commonExecutableDirs(env = process.env) {
|
|
1432
|
+
const home = env.HOME || homedir2();
|
|
1433
|
+
return compactPathParts([
|
|
1434
|
+
join2(home, ".local", "bin"),
|
|
1435
|
+
join2(home, ".bun", "bin"),
|
|
1436
|
+
join2(home, ".cargo", "bin"),
|
|
1437
|
+
join2(home, ".npm-global", "bin"),
|
|
1438
|
+
join2(home, "bin"),
|
|
1439
|
+
env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
|
|
1440
|
+
env.PNPM_HOME,
|
|
1441
|
+
env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
1442
|
+
"/opt/homebrew/bin",
|
|
1443
|
+
"/usr/local/bin",
|
|
1444
|
+
"/usr/bin",
|
|
1445
|
+
"/bin",
|
|
1446
|
+
"/usr/sbin",
|
|
1447
|
+
"/sbin"
|
|
1448
|
+
]);
|
|
1449
|
+
}
|
|
1450
|
+
function normalizeExecutionPath(env = process.env) {
|
|
1451
|
+
return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
|
|
1452
|
+
}
|
|
1453
|
+
function isExecutable(path) {
|
|
1454
|
+
try {
|
|
1455
|
+
accessSync(path, constants.X_OK);
|
|
1456
|
+
return true;
|
|
1457
|
+
} catch {
|
|
1458
|
+
return false;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
function executableExists(command, env = process.env) {
|
|
1462
|
+
if (command.includes("/"))
|
|
1463
|
+
return isExecutable(command);
|
|
1464
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
1465
|
+
if (dir && isExecutable(join2(dir, command)))
|
|
1466
|
+
return true;
|
|
1467
|
+
}
|
|
1468
|
+
return false;
|
|
1469
|
+
}
|
|
1470
|
+
function commandNotFoundMessage(command, env = process.env) {
|
|
1471
|
+
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1258
1474
|
// src/lib/executor.ts
|
|
1259
1475
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
1260
1476
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
@@ -1340,7 +1556,7 @@ function agentArgs(target) {
|
|
|
1340
1556
|
args.push(...target.extraArgs ?? [], target.prompt);
|
|
1341
1557
|
return args;
|
|
1342
1558
|
case "codewith":
|
|
1343
|
-
args.push("
|
|
1559
|
+
args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
|
|
1344
1560
|
if (isolation === "safe")
|
|
1345
1561
|
args.push("--ignore-rules");
|
|
1346
1562
|
if (target.cwd)
|
|
@@ -1420,6 +1636,7 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1420
1636
|
Object.assign(env, accountEnv);
|
|
1421
1637
|
}
|
|
1422
1638
|
Object.assign(env, spec.env ?? {});
|
|
1639
|
+
env.PATH = normalizeExecutionPath(env);
|
|
1423
1640
|
if (metadata.loopId)
|
|
1424
1641
|
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1425
1642
|
if (metadata.loopName)
|
|
@@ -1438,6 +1655,18 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1438
1655
|
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1439
1656
|
return env;
|
|
1440
1657
|
}
|
|
1658
|
+
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1659
|
+
const spec = commandSpec(target);
|
|
1660
|
+
const env = executionEnv(spec, metadata, opts);
|
|
1661
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1662
|
+
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
1663
|
+
}
|
|
1664
|
+
return {
|
|
1665
|
+
command: spec.command,
|
|
1666
|
+
accountProfile: spec.account?.profile,
|
|
1667
|
+
accountTool: spec.accountTool
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1441
1670
|
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1442
1671
|
const spec = commandSpec(target);
|
|
1443
1672
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
@@ -1448,6 +1677,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1448
1677
|
let exitCode;
|
|
1449
1678
|
let error;
|
|
1450
1679
|
const env = executionEnv(spec, metadata, opts);
|
|
1680
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1681
|
+
return {
|
|
1682
|
+
status: "failed",
|
|
1683
|
+
stdout: "",
|
|
1684
|
+
stderr: "",
|
|
1685
|
+
error: commandNotFoundMessage(spec.command, env),
|
|
1686
|
+
startedAt,
|
|
1687
|
+
finishedAt: nowIso(),
|
|
1688
|
+
durationMs: 0
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1451
1691
|
const child = spawn(spec.command, spec.args, {
|
|
1452
1692
|
cwd: spec.cwd,
|
|
1453
1693
|
env,
|
|
@@ -1455,6 +1695,16 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1455
1695
|
detached: true,
|
|
1456
1696
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1457
1697
|
});
|
|
1698
|
+
if (child.pid)
|
|
1699
|
+
opts.onSpawn?.(child.pid);
|
|
1700
|
+
const abortHandler = () => {
|
|
1701
|
+
error = "cancelled";
|
|
1702
|
+
if (child.pid)
|
|
1703
|
+
killProcessGroup(child.pid);
|
|
1704
|
+
};
|
|
1705
|
+
if (opts.signal?.aborted)
|
|
1706
|
+
abortHandler();
|
|
1707
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1458
1708
|
child.stdout.on("data", (chunk) => {
|
|
1459
1709
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
1460
1710
|
});
|
|
@@ -1477,6 +1727,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1477
1727
|
error = err instanceof Error ? err.message : String(err);
|
|
1478
1728
|
} finally {
|
|
1479
1729
|
clearTimeout(timer);
|
|
1730
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
1480
1731
|
}
|
|
1481
1732
|
const finishedAt = nowIso();
|
|
1482
1733
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
@@ -1532,14 +1783,16 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
1532
1783
|
// src/lib/workflow-runner.ts
|
|
1533
1784
|
function targetWithStepAccount(step) {
|
|
1534
1785
|
const account = step.account ?? step.target.account;
|
|
1535
|
-
|
|
1786
|
+
const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
|
|
1787
|
+
if (!account && timeoutMs === step.target.timeoutMs)
|
|
1536
1788
|
return step.target;
|
|
1537
|
-
return { ...step.target, account };
|
|
1789
|
+
return { ...step.target, account, timeoutMs };
|
|
1538
1790
|
}
|
|
1539
1791
|
function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
|
|
1792
|
+
const executorStatus = status === "succeeded" ? "succeeded" : status === "timed_out" ? "timed_out" : "failed";
|
|
1540
1793
|
return {
|
|
1541
|
-
status,
|
|
1542
|
-
exitCode:
|
|
1794
|
+
status: executorStatus,
|
|
1795
|
+
exitCode: executorStatus === "succeeded" ? 0 : 1,
|
|
1543
1796
|
stdout,
|
|
1544
1797
|
stderr: "",
|
|
1545
1798
|
error,
|
|
@@ -1557,7 +1810,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1557
1810
|
idempotencyKey: opts.idempotencyKey
|
|
1558
1811
|
});
|
|
1559
1812
|
const startedAt = run.startedAt ?? nowIso();
|
|
1560
|
-
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out") {
|
|
1813
|
+
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
1561
1814
|
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1562
1815
|
return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
|
|
1563
1816
|
}
|
|
@@ -1566,8 +1819,19 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1566
1819
|
let blockingError;
|
|
1567
1820
|
let terminalStatus = "succeeded";
|
|
1568
1821
|
for (const step of ordered) {
|
|
1822
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1823
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1824
|
+
blockingError = "workflow run was cancelled";
|
|
1825
|
+
break;
|
|
1826
|
+
}
|
|
1827
|
+
const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1828
|
+
if (pendingTimeout) {
|
|
1829
|
+
terminalStatus = "timed_out";
|
|
1830
|
+
blockingError = pendingTimeout;
|
|
1831
|
+
break;
|
|
1832
|
+
}
|
|
1569
1833
|
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1570
|
-
if (existing?.status === "succeeded" || existing?.status === "skipped")
|
|
1834
|
+
if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
|
|
1571
1835
|
continue;
|
|
1572
1836
|
const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
|
|
1573
1837
|
const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
|
|
@@ -1582,8 +1846,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1582
1846
|
terminalStatus = "failed";
|
|
1583
1847
|
continue;
|
|
1584
1848
|
}
|
|
1585
|
-
store.startWorkflowStepRun(run.id, step.id);
|
|
1586
|
-
|
|
1849
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id);
|
|
1850
|
+
if (startedStep.status !== "running") {
|
|
1851
|
+
terminalStatus = "failed";
|
|
1852
|
+
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
1853
|
+
break;
|
|
1854
|
+
}
|
|
1855
|
+
const metadata = {
|
|
1587
1856
|
loopId: opts.loop?.id,
|
|
1588
1857
|
loopName: opts.loop?.name,
|
|
1589
1858
|
runId: opts.loopRun?.id,
|
|
@@ -1592,7 +1861,51 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1592
1861
|
workflowName: workflow.name,
|
|
1593
1862
|
workflowRunId: run.id,
|
|
1594
1863
|
workflowStepId: step.id
|
|
1595
|
-
}
|
|
1864
|
+
};
|
|
1865
|
+
let result;
|
|
1866
|
+
const controller = new AbortController;
|
|
1867
|
+
const externalAbort = () => controller.abort();
|
|
1868
|
+
if (opts.signal?.aborted)
|
|
1869
|
+
controller.abort();
|
|
1870
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1871
|
+
const cancelTimer = setInterval(() => {
|
|
1872
|
+
if (store.getWorkflowRun(run.id)?.status === "cancelled")
|
|
1873
|
+
controller.abort();
|
|
1874
|
+
}, opts.cancelPollMs ?? 500);
|
|
1875
|
+
cancelTimer.unref();
|
|
1876
|
+
try {
|
|
1877
|
+
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
1878
|
+
...opts,
|
|
1879
|
+
signal: controller.signal,
|
|
1880
|
+
onSpawn: (pid) => {
|
|
1881
|
+
store.markWorkflowStepPid(run.id, step.id, pid);
|
|
1882
|
+
opts.onSpawn?.(pid);
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1885
|
+
} catch (error) {
|
|
1886
|
+
const finishedAt2 = nowIso();
|
|
1887
|
+
result = {
|
|
1888
|
+
status: "failed",
|
|
1889
|
+
stdout: "",
|
|
1890
|
+
stderr: "",
|
|
1891
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1892
|
+
startedAt: startedStep.startedAt ?? finishedAt2,
|
|
1893
|
+
finishedAt: finishedAt2,
|
|
1894
|
+
durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
|
|
1895
|
+
};
|
|
1896
|
+
} finally {
|
|
1897
|
+
clearInterval(cancelTimer);
|
|
1898
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1899
|
+
}
|
|
1900
|
+
const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1901
|
+
if (timeoutMessage && result.status === "failed") {
|
|
1902
|
+
result = { ...result, status: "timed_out", error: timeoutMessage };
|
|
1903
|
+
}
|
|
1904
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1905
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1906
|
+
blockingError = "workflow run was cancelled";
|
|
1907
|
+
break;
|
|
1908
|
+
}
|
|
1596
1909
|
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1597
1910
|
status: result.status,
|
|
1598
1911
|
finishedAt: result.finishedAt,
|
|
@@ -1617,6 +1930,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1617
1930
|
}
|
|
1618
1931
|
}
|
|
1619
1932
|
const finishedAt = nowIso();
|
|
1933
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1934
|
+
const terminalRun = store.requireWorkflowRun(run.id);
|
|
1935
|
+
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1936
|
+
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
1937
|
+
}
|
|
1620
1938
|
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
1621
1939
|
finishedAt,
|
|
1622
1940
|
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
@@ -1625,20 +1943,59 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1625
1943
|
const steps = store.listWorkflowStepRuns(run.id);
|
|
1626
1944
|
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
1627
1945
|
}
|
|
1946
|
+
function preflightWorkflow(workflow, opts = {}) {
|
|
1947
|
+
return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
|
|
1948
|
+
workflowId: workflow.id,
|
|
1949
|
+
workflowName: workflow.name,
|
|
1950
|
+
workflowStepId: step.id
|
|
1951
|
+
}, opts));
|
|
1952
|
+
}
|
|
1628
1953
|
async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
1629
1954
|
if (loop.target.type !== "workflow")
|
|
1630
1955
|
return executeLoop(loop, run, opts);
|
|
1631
1956
|
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1957
|
+
const controller = loop.target.timeoutMs ? new AbortController : undefined;
|
|
1958
|
+
let workflowTimedOut = false;
|
|
1959
|
+
const externalAbort = () => controller?.abort();
|
|
1960
|
+
if (controller && opts.signal?.aborted)
|
|
1961
|
+
controller.abort();
|
|
1962
|
+
if (controller)
|
|
1963
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1964
|
+
const timer = controller ? setTimeout(() => {
|
|
1965
|
+
workflowTimedOut = true;
|
|
1966
|
+
controller.abort();
|
|
1967
|
+
}, loop.target.timeoutMs) : undefined;
|
|
1968
|
+
timer?.unref();
|
|
1969
|
+
try {
|
|
1970
|
+
return await executeWorkflow(store, workflow, {
|
|
1971
|
+
...opts,
|
|
1972
|
+
signal: controller?.signal ?? opts.signal,
|
|
1973
|
+
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
|
|
1974
|
+
loop,
|
|
1975
|
+
loopRun: run,
|
|
1976
|
+
scheduledFor: run.scheduledFor,
|
|
1977
|
+
idempotencyKey: `${loop.id}:${run.scheduledFor}:attempt:${run.attempt}`
|
|
1978
|
+
});
|
|
1979
|
+
} finally {
|
|
1980
|
+
if (timer)
|
|
1981
|
+
clearTimeout(timer);
|
|
1982
|
+
if (controller)
|
|
1983
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1984
|
+
}
|
|
1639
1985
|
}
|
|
1640
1986
|
|
|
1641
1987
|
// src/lib/scheduler.ts
|
|
1988
|
+
function manualRunScheduledFor(loop, now = new Date) {
|
|
1989
|
+
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
1990
|
+
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
1991
|
+
}
|
|
1992
|
+
return now.toISOString();
|
|
1993
|
+
}
|
|
1994
|
+
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
1995
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
1996
|
+
return false;
|
|
1997
|
+
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
1998
|
+
}
|
|
1642
1999
|
function nextAfterRetry(loop, now) {
|
|
1643
2000
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
1644
2001
|
}
|
|
@@ -1664,27 +2021,18 @@ function advanceLoop(store, loop, run, finishedAt, succeeded) {
|
|
|
1664
2021
|
retryScheduledFor: undefined
|
|
1665
2022
|
});
|
|
1666
2023
|
}
|
|
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);
|
|
2024
|
+
async function executeClaimedRun(deps) {
|
|
1679
2025
|
let heartbeat;
|
|
1680
|
-
const heartbeatEveryMs = Math.max(
|
|
2026
|
+
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
1681
2027
|
heartbeat = setInterval(() => {
|
|
1682
|
-
deps.store.heartbeatRunLease(
|
|
2028
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
|
|
1683
2029
|
}, heartbeatEveryMs);
|
|
1684
2030
|
heartbeat.unref();
|
|
1685
2031
|
try {
|
|
1686
|
-
const result = await (deps.execute ?? ((
|
|
1687
|
-
|
|
2032
|
+
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
2033
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
|
|
2034
|
+
})))(deps.loop, deps.run);
|
|
2035
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
1688
2036
|
status: result.status,
|
|
1689
2037
|
finishedAt: result.finishedAt,
|
|
1690
2038
|
durationMs: result.durationMs,
|
|
@@ -1697,16 +2045,13 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
1697
2045
|
claimedBy: deps.runnerId,
|
|
1698
2046
|
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
1699
2047
|
});
|
|
1700
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), finalRun.status === "succeeded");
|
|
1701
|
-
deps.onRun?.(finalRun);
|
|
1702
|
-
return finalRun;
|
|
1703
2048
|
} catch (err) {
|
|
1704
|
-
deps.onError?.(
|
|
2049
|
+
deps.onError?.(deps.loop, err);
|
|
1705
2050
|
const finishedAt = new Date;
|
|
1706
|
-
|
|
2051
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
1707
2052
|
status: "failed",
|
|
1708
2053
|
finishedAt: finishedAt.toISOString(),
|
|
1709
|
-
durationMs: finishedAt.getTime() - new Date(
|
|
2054
|
+
durationMs: finishedAt.getTime() - new Date(deps.run.startedAt ?? deps.run.createdAt).getTime(),
|
|
1710
2055
|
stdout: "",
|
|
1711
2056
|
stderr: "",
|
|
1712
2057
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -1714,14 +2059,36 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
1714
2059
|
claimedBy: deps.runnerId,
|
|
1715
2060
|
now: deps.now?.() ?? finishedAt
|
|
1716
2061
|
});
|
|
1717
|
-
advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
|
|
1718
|
-
deps.onRun?.(finalRun);
|
|
1719
|
-
return finalRun;
|
|
1720
2062
|
} finally {
|
|
1721
2063
|
if (heartbeat)
|
|
1722
2064
|
clearInterval(heartbeat);
|
|
1723
2065
|
}
|
|
1724
2066
|
}
|
|
2067
|
+
async function runSlot(deps, loop, scheduledFor) {
|
|
2068
|
+
const now = deps.now?.() ?? new Date;
|
|
2069
|
+
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2070
|
+
const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
|
|
2071
|
+
advanceLoop(deps.store, loop, skipped, now, true);
|
|
2072
|
+
deps.onRun?.(skipped);
|
|
2073
|
+
return skipped;
|
|
2074
|
+
}
|
|
2075
|
+
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
2076
|
+
if (!claim)
|
|
2077
|
+
return;
|
|
2078
|
+
deps.onRun?.(claim.run);
|
|
2079
|
+
const finalRun = await executeClaimedRun({
|
|
2080
|
+
store: deps.store,
|
|
2081
|
+
runnerId: deps.runnerId,
|
|
2082
|
+
loop: claim.loop,
|
|
2083
|
+
run: claim.run,
|
|
2084
|
+
now: deps.now,
|
|
2085
|
+
execute: deps.execute,
|
|
2086
|
+
onError: deps.onError
|
|
2087
|
+
});
|
|
2088
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2089
|
+
deps.onRun?.(finalRun);
|
|
2090
|
+
return finalRun;
|
|
2091
|
+
}
|
|
1725
2092
|
async function tick(deps) {
|
|
1726
2093
|
const now = deps.now?.() ?? new Date;
|
|
1727
2094
|
const recovered = deps.store.recoverExpiredRunLeases(now);
|
|
@@ -1897,6 +2264,7 @@ async function runDaemon(opts = {}) {
|
|
|
1897
2264
|
const ownStore = !opts.store;
|
|
1898
2265
|
const store = opts.store ?? new Store;
|
|
1899
2266
|
const leaseId = genId();
|
|
2267
|
+
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
1900
2268
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
1901
2269
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
1902
2270
|
const log = opts.log ?? ((message) => console.error(`[loops-daemon] ${message}`));
|
|
@@ -1912,18 +2280,28 @@ async function runDaemon(opts = {}) {
|
|
|
1912
2280
|
log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
|
|
1913
2281
|
let stopFlag = false;
|
|
1914
2282
|
let leaseLost = false;
|
|
2283
|
+
const runAbort = new AbortController;
|
|
2284
|
+
const requestStop = (message) => {
|
|
2285
|
+
stopFlag = true;
|
|
2286
|
+
if (!runAbort.signal.aborted)
|
|
2287
|
+
runAbort.abort();
|
|
2288
|
+
if (message)
|
|
2289
|
+
log(message);
|
|
2290
|
+
};
|
|
1915
2291
|
const ensureLease = () => {
|
|
1916
2292
|
const current = store.heartbeatDaemonLease(leaseId, leaseTtlMs);
|
|
1917
2293
|
if (!current || current.id !== leaseId) {
|
|
1918
2294
|
leaseLost = true;
|
|
1919
|
-
|
|
2295
|
+
requestStop("daemon lease lost");
|
|
1920
2296
|
throw new Error("daemon lease lost");
|
|
1921
2297
|
}
|
|
1922
2298
|
};
|
|
1923
2299
|
const onSignal = () => {
|
|
1924
|
-
|
|
1925
|
-
log("stop signal received");
|
|
2300
|
+
requestStop("stop signal received");
|
|
1926
2301
|
};
|
|
2302
|
+
if (opts.signal?.aborted)
|
|
2303
|
+
onSignal();
|
|
2304
|
+
opts.signal?.addEventListener("abort", onSignal, { once: true });
|
|
1927
2305
|
process.on("SIGINT", onSignal);
|
|
1928
2306
|
process.on("SIGTERM", onSignal);
|
|
1929
2307
|
try {
|
|
@@ -1936,7 +2314,7 @@ async function runDaemon(opts = {}) {
|
|
|
1936
2314
|
ensureLease();
|
|
1937
2315
|
const result = await tick({
|
|
1938
2316
|
store,
|
|
1939
|
-
runnerId
|
|
2317
|
+
runnerId,
|
|
1940
2318
|
execute: async (loop, run) => {
|
|
1941
2319
|
const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs / 3));
|
|
1942
2320
|
const timer = setInterval(() => {
|
|
@@ -1948,7 +2326,10 @@ async function runDaemon(opts = {}) {
|
|
|
1948
2326
|
}, heartbeatMs);
|
|
1949
2327
|
timer.unref();
|
|
1950
2328
|
try {
|
|
1951
|
-
const result2 = await
|
|
2329
|
+
const result2 = await executeLoopTarget(store, loop, run, {
|
|
2330
|
+
signal: runAbort.signal,
|
|
2331
|
+
onSpawn: (pid) => store.markRunPid(run.id, pid, runnerId)
|
|
2332
|
+
});
|
|
1952
2333
|
if (leaseLost)
|
|
1953
2334
|
throw new Error("daemon lease lost during run");
|
|
1954
2335
|
return result2;
|
|
@@ -1965,6 +2346,7 @@ async function runDaemon(opts = {}) {
|
|
|
1965
2346
|
}
|
|
1966
2347
|
});
|
|
1967
2348
|
} finally {
|
|
2349
|
+
opts.signal?.removeEventListener("abort", onSignal);
|
|
1968
2350
|
process.off("SIGINT", onSignal);
|
|
1969
2351
|
process.off("SIGTERM", onSignal);
|
|
1970
2352
|
store.releaseDaemonLease(leaseId);
|
|
@@ -2000,9 +2382,11 @@ async function startDaemon(opts) {
|
|
|
2000
2382
|
|
|
2001
2383
|
// src/daemon/install.ts
|
|
2002
2384
|
import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
2385
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2003
2386
|
import { dirname as dirname3 } from "path";
|
|
2004
2387
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
2005
2388
|
const command = [execPath, cliEntry, ...args].join(" ");
|
|
2389
|
+
const pathEnv = normalizeExecutionPath(process.env);
|
|
2006
2390
|
if (process.platform === "linux") {
|
|
2007
2391
|
const path = systemdServicePath();
|
|
2008
2392
|
mkdirSync4(dirname3(path), { recursive: true, mode: 448 });
|
|
@@ -2015,7 +2399,7 @@ Type=simple
|
|
|
2015
2399
|
ExecStart=${command}
|
|
2016
2400
|
Restart=always
|
|
2017
2401
|
RestartSec=5
|
|
2018
|
-
Environment=PATH=${
|
|
2402
|
+
Environment=PATH=${pathEnv}
|
|
2019
2403
|
|
|
2020
2404
|
[Install]
|
|
2021
2405
|
WantedBy=default.target
|
|
@@ -2047,6 +2431,10 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2047
2431
|
</array>
|
|
2048
2432
|
<key>RunAtLoad</key><true/>
|
|
2049
2433
|
<key>KeepAlive</key><true/>
|
|
2434
|
+
<key>EnvironmentVariables</key>
|
|
2435
|
+
<dict>
|
|
2436
|
+
<key>PATH</key><string>${pathEnv}</string>
|
|
2437
|
+
</dict>
|
|
2050
2438
|
<key>StandardOutPath</key><string>${daemonLogPath()}</string>
|
|
2051
2439
|
<key>StandardErrorPath</key><string>${daemonLogPath()}</string>
|
|
2052
2440
|
</dict>
|
|
@@ -2061,10 +2449,25 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2061
2449
|
}
|
|
2062
2450
|
throw new Error(`startup install is not implemented for ${process.platform}`);
|
|
2063
2451
|
}
|
|
2452
|
+
function enableStartup(result) {
|
|
2453
|
+
const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
|
|
2454
|
+
return commands.map((command) => {
|
|
2455
|
+
const run = spawnSync2("sh", ["-c", command], {
|
|
2456
|
+
encoding: "utf8",
|
|
2457
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2458
|
+
});
|
|
2459
|
+
return {
|
|
2460
|
+
command,
|
|
2461
|
+
status: run.status,
|
|
2462
|
+
stdout: run.stdout.trim(),
|
|
2463
|
+
stderr: run.stderr.trim()
|
|
2464
|
+
};
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2064
2467
|
|
|
2065
2468
|
// src/daemon/index.ts
|
|
2066
2469
|
var program = new Command;
|
|
2067
|
-
program.name("loops-daemon").description("OpenLoops daemon helper").version("0.1
|
|
2470
|
+
program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.1");
|
|
2068
2471
|
program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
|
|
2069
2472
|
program.command("start").action(async () => {
|
|
2070
2473
|
const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });
|