@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/cli/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);
|
|
@@ -1168,7 +1305,7 @@ class Store {
|
|
|
1168
1305
|
}
|
|
1169
1306
|
|
|
1170
1307
|
// src/cli/index.ts
|
|
1171
|
-
import { existsSync as
|
|
1308
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
1172
1309
|
import { Command } from "commander";
|
|
1173
1310
|
|
|
1174
1311
|
// src/lib/format.ts
|
|
@@ -1217,8 +1354,9 @@ function publicWorkflowEvent(event) {
|
|
|
1217
1354
|
}
|
|
1218
1355
|
|
|
1219
1356
|
// src/lib/executor.ts
|
|
1220
|
-
import { spawn } from "child_process";
|
|
1357
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
1221
1358
|
import { once } from "events";
|
|
1359
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1222
1360
|
|
|
1223
1361
|
// src/lib/accounts.ts
|
|
1224
1362
|
import { spawnSync } from "child_process";
|
|
@@ -1266,6 +1404,25 @@ function primaryAccountDir(output) {
|
|
|
1266
1404
|
}
|
|
1267
1405
|
return;
|
|
1268
1406
|
}
|
|
1407
|
+
function accountDirEnvVar(tool) {
|
|
1408
|
+
switch (tool) {
|
|
1409
|
+
case "claude":
|
|
1410
|
+
return "CLAUDE_CONFIG_DIR";
|
|
1411
|
+
case "codex":
|
|
1412
|
+
case "codex-app":
|
|
1413
|
+
return "CODEX_HOME";
|
|
1414
|
+
case "cursor":
|
|
1415
|
+
return "CURSOR_CONFIG_DIR";
|
|
1416
|
+
case "opencode":
|
|
1417
|
+
return "OPENCODE_CONFIG_DIR";
|
|
1418
|
+
case "codewith":
|
|
1419
|
+
return "CODEWITH_HOME";
|
|
1420
|
+
case "aicopilot":
|
|
1421
|
+
return "AICOPILOT_CONFIG_DIR";
|
|
1422
|
+
default:
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1269
1426
|
function resolveAccountEnv(account, toolHint, env) {
|
|
1270
1427
|
if (!account)
|
|
1271
1428
|
return {};
|
|
@@ -1284,13 +1441,14 @@ function resolveAccountEnv(account, toolHint, env) {
|
|
|
1284
1441
|
const stderr = result.stderr.trim();
|
|
1285
1442
|
throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
|
|
1286
1443
|
}
|
|
1287
|
-
const
|
|
1444
|
+
const accountEnv = parseAccountExportLines(result.stdout);
|
|
1445
|
+
const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
|
|
1288
1446
|
if (!profileDir)
|
|
1289
1447
|
throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
|
|
1290
1448
|
if (!existsSync(profileDir))
|
|
1291
1449
|
throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
|
|
1292
1450
|
return {
|
|
1293
|
-
...
|
|
1451
|
+
...accountEnv,
|
|
1294
1452
|
LOOPS_ACCOUNT_PROFILE: account.profile,
|
|
1295
1453
|
LOOPS_ACCOUNT_TOOL: tool
|
|
1296
1454
|
};
|
|
@@ -1479,6 +1637,27 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1479
1637
|
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1480
1638
|
return env;
|
|
1481
1639
|
}
|
|
1640
|
+
function commandExists(command, env) {
|
|
1641
|
+
if (command.includes("/") && existsSync2(command))
|
|
1642
|
+
return true;
|
|
1643
|
+
const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], {
|
|
1644
|
+
env,
|
|
1645
|
+
stdio: "ignore"
|
|
1646
|
+
});
|
|
1647
|
+
return (result.status ?? 1) === 0;
|
|
1648
|
+
}
|
|
1649
|
+
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1650
|
+
const spec = commandSpec(target);
|
|
1651
|
+
const env = executionEnv(spec, metadata, opts);
|
|
1652
|
+
if (!spec.shell && !commandExists(spec.command, env)) {
|
|
1653
|
+
throw new Error(`Executable not found in PATH: ${spec.command}`);
|
|
1654
|
+
}
|
|
1655
|
+
return {
|
|
1656
|
+
command: spec.command,
|
|
1657
|
+
accountProfile: spec.account?.profile,
|
|
1658
|
+
accountTool: spec.accountTool
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1482
1661
|
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1483
1662
|
const spec = commandSpec(target);
|
|
1484
1663
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
@@ -1489,6 +1668,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1489
1668
|
let exitCode;
|
|
1490
1669
|
let error;
|
|
1491
1670
|
const env = executionEnv(spec, metadata, opts);
|
|
1671
|
+
if (!spec.shell && !commandExists(spec.command, env)) {
|
|
1672
|
+
return {
|
|
1673
|
+
status: "failed",
|
|
1674
|
+
stdout: "",
|
|
1675
|
+
stderr: "",
|
|
1676
|
+
error: `Executable not found in PATH: ${spec.command}`,
|
|
1677
|
+
startedAt,
|
|
1678
|
+
finishedAt: nowIso(),
|
|
1679
|
+
durationMs: 0
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1492
1682
|
const child = spawn(spec.command, spec.args, {
|
|
1493
1683
|
cwd: spec.cwd,
|
|
1494
1684
|
env,
|
|
@@ -1496,6 +1686,16 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1496
1686
|
detached: true,
|
|
1497
1687
|
stdio: ["ignore", "pipe", "pipe"]
|
|
1498
1688
|
});
|
|
1689
|
+
if (child.pid)
|
|
1690
|
+
opts.onSpawn?.(child.pid);
|
|
1691
|
+
const abortHandler = () => {
|
|
1692
|
+
error = "cancelled";
|
|
1693
|
+
if (child.pid)
|
|
1694
|
+
killProcessGroup(child.pid);
|
|
1695
|
+
};
|
|
1696
|
+
if (opts.signal?.aborted)
|
|
1697
|
+
abortHandler();
|
|
1698
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1499
1699
|
child.stdout.on("data", (chunk) => {
|
|
1500
1700
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
1501
1701
|
});
|
|
@@ -1518,6 +1718,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1518
1718
|
error = err instanceof Error ? err.message : String(err);
|
|
1519
1719
|
} finally {
|
|
1520
1720
|
clearTimeout(timer);
|
|
1721
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
1521
1722
|
}
|
|
1522
1723
|
const finishedAt = nowIso();
|
|
1523
1724
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
@@ -1573,14 +1774,16 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
1573
1774
|
// src/lib/workflow-runner.ts
|
|
1574
1775
|
function targetWithStepAccount(step) {
|
|
1575
1776
|
const account = step.account ?? step.target.account;
|
|
1576
|
-
|
|
1777
|
+
const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
|
|
1778
|
+
if (!account && timeoutMs === step.target.timeoutMs)
|
|
1577
1779
|
return step.target;
|
|
1578
|
-
return { ...step.target, account };
|
|
1780
|
+
return { ...step.target, account, timeoutMs };
|
|
1579
1781
|
}
|
|
1580
1782
|
function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
|
|
1783
|
+
const executorStatus = status === "succeeded" ? "succeeded" : status === "timed_out" ? "timed_out" : "failed";
|
|
1581
1784
|
return {
|
|
1582
|
-
status,
|
|
1583
|
-
exitCode:
|
|
1785
|
+
status: executorStatus,
|
|
1786
|
+
exitCode: executorStatus === "succeeded" ? 0 : 1,
|
|
1584
1787
|
stdout,
|
|
1585
1788
|
stderr: "",
|
|
1586
1789
|
error,
|
|
@@ -1598,7 +1801,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1598
1801
|
idempotencyKey: opts.idempotencyKey
|
|
1599
1802
|
});
|
|
1600
1803
|
const startedAt = run.startedAt ?? nowIso();
|
|
1601
|
-
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out") {
|
|
1804
|
+
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
1602
1805
|
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1603
1806
|
return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
|
|
1604
1807
|
}
|
|
@@ -1607,8 +1810,19 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1607
1810
|
let blockingError;
|
|
1608
1811
|
let terminalStatus = "succeeded";
|
|
1609
1812
|
for (const step of ordered) {
|
|
1813
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1814
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1815
|
+
blockingError = "workflow run was cancelled";
|
|
1816
|
+
break;
|
|
1817
|
+
}
|
|
1818
|
+
const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1819
|
+
if (pendingTimeout) {
|
|
1820
|
+
terminalStatus = "timed_out";
|
|
1821
|
+
blockingError = pendingTimeout;
|
|
1822
|
+
break;
|
|
1823
|
+
}
|
|
1610
1824
|
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1611
|
-
if (existing?.status === "succeeded" || existing?.status === "skipped")
|
|
1825
|
+
if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
|
|
1612
1826
|
continue;
|
|
1613
1827
|
const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
|
|
1614
1828
|
const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
|
|
@@ -1623,8 +1837,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1623
1837
|
terminalStatus = "failed";
|
|
1624
1838
|
continue;
|
|
1625
1839
|
}
|
|
1626
|
-
store.startWorkflowStepRun(run.id, step.id);
|
|
1627
|
-
|
|
1840
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id);
|
|
1841
|
+
if (startedStep.status !== "running") {
|
|
1842
|
+
terminalStatus = "failed";
|
|
1843
|
+
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
1844
|
+
break;
|
|
1845
|
+
}
|
|
1846
|
+
const metadata = {
|
|
1628
1847
|
loopId: opts.loop?.id,
|
|
1629
1848
|
loopName: opts.loop?.name,
|
|
1630
1849
|
runId: opts.loopRun?.id,
|
|
@@ -1633,7 +1852,51 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1633
1852
|
workflowName: workflow.name,
|
|
1634
1853
|
workflowRunId: run.id,
|
|
1635
1854
|
workflowStepId: step.id
|
|
1636
|
-
}
|
|
1855
|
+
};
|
|
1856
|
+
let result;
|
|
1857
|
+
const controller = new AbortController;
|
|
1858
|
+
const externalAbort = () => controller.abort();
|
|
1859
|
+
if (opts.signal?.aborted)
|
|
1860
|
+
controller.abort();
|
|
1861
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1862
|
+
const cancelTimer = setInterval(() => {
|
|
1863
|
+
if (store.getWorkflowRun(run.id)?.status === "cancelled")
|
|
1864
|
+
controller.abort();
|
|
1865
|
+
}, opts.cancelPollMs ?? 500);
|
|
1866
|
+
cancelTimer.unref();
|
|
1867
|
+
try {
|
|
1868
|
+
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
1869
|
+
...opts,
|
|
1870
|
+
signal: controller.signal,
|
|
1871
|
+
onSpawn: (pid) => {
|
|
1872
|
+
store.markWorkflowStepPid(run.id, step.id, pid);
|
|
1873
|
+
opts.onSpawn?.(pid);
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
const finishedAt2 = nowIso();
|
|
1878
|
+
result = {
|
|
1879
|
+
status: "failed",
|
|
1880
|
+
stdout: "",
|
|
1881
|
+
stderr: "",
|
|
1882
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1883
|
+
startedAt: startedStep.startedAt ?? finishedAt2,
|
|
1884
|
+
finishedAt: finishedAt2,
|
|
1885
|
+
durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
|
|
1886
|
+
};
|
|
1887
|
+
} finally {
|
|
1888
|
+
clearInterval(cancelTimer);
|
|
1889
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1890
|
+
}
|
|
1891
|
+
const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1892
|
+
if (timeoutMessage && result.status === "failed") {
|
|
1893
|
+
result = { ...result, status: "timed_out", error: timeoutMessage };
|
|
1894
|
+
}
|
|
1895
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1896
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1897
|
+
blockingError = "workflow run was cancelled";
|
|
1898
|
+
break;
|
|
1899
|
+
}
|
|
1637
1900
|
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1638
1901
|
status: result.status,
|
|
1639
1902
|
finishedAt: result.finishedAt,
|
|
@@ -1658,6 +1921,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1658
1921
|
}
|
|
1659
1922
|
}
|
|
1660
1923
|
const finishedAt = nowIso();
|
|
1924
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1925
|
+
const terminalRun = store.requireWorkflowRun(run.id);
|
|
1926
|
+
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1927
|
+
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
1928
|
+
}
|
|
1661
1929
|
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
1662
1930
|
finishedAt,
|
|
1663
1931
|
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
@@ -1666,20 +1934,59 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1666
1934
|
const steps = store.listWorkflowStepRuns(run.id);
|
|
1667
1935
|
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
1668
1936
|
}
|
|
1937
|
+
function preflightWorkflow(workflow, opts = {}) {
|
|
1938
|
+
return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
|
|
1939
|
+
workflowId: workflow.id,
|
|
1940
|
+
workflowName: workflow.name,
|
|
1941
|
+
workflowStepId: step.id
|
|
1942
|
+
}, opts));
|
|
1943
|
+
}
|
|
1669
1944
|
async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
1670
1945
|
if (loop.target.type !== "workflow")
|
|
1671
1946
|
return executeLoop(loop, run, opts);
|
|
1672
1947
|
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1948
|
+
const controller = loop.target.timeoutMs ? new AbortController : undefined;
|
|
1949
|
+
let workflowTimedOut = false;
|
|
1950
|
+
const externalAbort = () => controller?.abort();
|
|
1951
|
+
if (controller && opts.signal?.aborted)
|
|
1952
|
+
controller.abort();
|
|
1953
|
+
if (controller)
|
|
1954
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1955
|
+
const timer = controller ? setTimeout(() => {
|
|
1956
|
+
workflowTimedOut = true;
|
|
1957
|
+
controller.abort();
|
|
1958
|
+
}, loop.target.timeoutMs) : undefined;
|
|
1959
|
+
timer?.unref();
|
|
1960
|
+
try {
|
|
1961
|
+
return await executeWorkflow(store, workflow, {
|
|
1962
|
+
...opts,
|
|
1963
|
+
signal: controller?.signal ?? opts.signal,
|
|
1964
|
+
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
|
|
1965
|
+
loop,
|
|
1966
|
+
loopRun: run,
|
|
1967
|
+
scheduledFor: run.scheduledFor,
|
|
1968
|
+
idempotencyKey: `${loop.id}:${run.scheduledFor}:attempt:${run.attempt}`
|
|
1969
|
+
});
|
|
1970
|
+
} finally {
|
|
1971
|
+
if (timer)
|
|
1972
|
+
clearTimeout(timer);
|
|
1973
|
+
if (controller)
|
|
1974
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1975
|
+
}
|
|
1680
1976
|
}
|
|
1681
1977
|
|
|
1682
1978
|
// src/lib/scheduler.ts
|
|
1979
|
+
function manualRunScheduledFor(loop, now = new Date) {
|
|
1980
|
+
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
1981
|
+
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
1982
|
+
}
|
|
1983
|
+
return now.toISOString();
|
|
1984
|
+
}
|
|
1985
|
+
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
1986
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
1987
|
+
return false;
|
|
1988
|
+
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
1989
|
+
}
|
|
1683
1990
|
function nextAfterRetry(loop, now) {
|
|
1684
1991
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
1685
1992
|
}
|
|
@@ -1705,27 +2012,18 @@ function advanceLoop(store, loop, run, finishedAt, succeeded) {
|
|
|
1705
2012
|
retryScheduledFor: undefined
|
|
1706
2013
|
});
|
|
1707
2014
|
}
|
|
1708
|
-
async function
|
|
1709
|
-
const now = deps.now?.() ?? new Date;
|
|
1710
|
-
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
1711
|
-
const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
|
|
1712
|
-
advanceLoop(deps.store, loop, skipped, now, true);
|
|
1713
|
-
deps.onRun?.(skipped);
|
|
1714
|
-
return skipped;
|
|
1715
|
-
}
|
|
1716
|
-
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
1717
|
-
if (!claim)
|
|
1718
|
-
return;
|
|
1719
|
-
deps.onRun?.(claim.run);
|
|
2015
|
+
async function executeClaimedRun(deps) {
|
|
1720
2016
|
let heartbeat;
|
|
1721
|
-
const heartbeatEveryMs = Math.max(
|
|
2017
|
+
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
1722
2018
|
heartbeat = setInterval(() => {
|
|
1723
|
-
deps.store.heartbeatRunLease(
|
|
2019
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
|
|
1724
2020
|
}, heartbeatEveryMs);
|
|
1725
2021
|
heartbeat.unref();
|
|
1726
2022
|
try {
|
|
1727
|
-
const result = await (deps.execute ?? ((
|
|
1728
|
-
|
|
2023
|
+
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
2024
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
|
|
2025
|
+
})))(deps.loop, deps.run);
|
|
2026
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
1729
2027
|
status: result.status,
|
|
1730
2028
|
finishedAt: result.finishedAt,
|
|
1731
2029
|
durationMs: result.durationMs,
|
|
@@ -1738,16 +2036,13 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
1738
2036
|
claimedBy: deps.runnerId,
|
|
1739
2037
|
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
1740
2038
|
});
|
|
1741
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), finalRun.status === "succeeded");
|
|
1742
|
-
deps.onRun?.(finalRun);
|
|
1743
|
-
return finalRun;
|
|
1744
2039
|
} catch (err) {
|
|
1745
|
-
deps.onError?.(
|
|
2040
|
+
deps.onError?.(deps.loop, err);
|
|
1746
2041
|
const finishedAt = new Date;
|
|
1747
|
-
|
|
2042
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
1748
2043
|
status: "failed",
|
|
1749
2044
|
finishedAt: finishedAt.toISOString(),
|
|
1750
|
-
durationMs: finishedAt.getTime() - new Date(
|
|
2045
|
+
durationMs: finishedAt.getTime() - new Date(deps.run.startedAt ?? deps.run.createdAt).getTime(),
|
|
1751
2046
|
stdout: "",
|
|
1752
2047
|
stderr: "",
|
|
1753
2048
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -1755,14 +2050,36 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
1755
2050
|
claimedBy: deps.runnerId,
|
|
1756
2051
|
now: deps.now?.() ?? finishedAt
|
|
1757
2052
|
});
|
|
1758
|
-
advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
|
|
1759
|
-
deps.onRun?.(finalRun);
|
|
1760
|
-
return finalRun;
|
|
1761
2053
|
} finally {
|
|
1762
2054
|
if (heartbeat)
|
|
1763
2055
|
clearInterval(heartbeat);
|
|
1764
2056
|
}
|
|
1765
2057
|
}
|
|
2058
|
+
async function runSlot(deps, loop, scheduledFor) {
|
|
2059
|
+
const now = deps.now?.() ?? new Date;
|
|
2060
|
+
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2061
|
+
const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
|
|
2062
|
+
advanceLoop(deps.store, loop, skipped, now, true);
|
|
2063
|
+
deps.onRun?.(skipped);
|
|
2064
|
+
return skipped;
|
|
2065
|
+
}
|
|
2066
|
+
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
2067
|
+
if (!claim)
|
|
2068
|
+
return;
|
|
2069
|
+
deps.onRun?.(claim.run);
|
|
2070
|
+
const finalRun = await executeClaimedRun({
|
|
2071
|
+
store: deps.store,
|
|
2072
|
+
runnerId: deps.runnerId,
|
|
2073
|
+
loop: claim.loop,
|
|
2074
|
+
run: claim.run,
|
|
2075
|
+
now: deps.now,
|
|
2076
|
+
execute: deps.execute,
|
|
2077
|
+
onError: deps.onError
|
|
2078
|
+
});
|
|
2079
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2080
|
+
deps.onRun?.(finalRun);
|
|
2081
|
+
return finalRun;
|
|
2082
|
+
}
|
|
1766
2083
|
async function tick(deps) {
|
|
1767
2084
|
const now = deps.now?.() ?? new Date;
|
|
1768
2085
|
const recovered = deps.store.recoverExpiredRunLeases(now);
|
|
@@ -1795,7 +2112,7 @@ async function tick(deps) {
|
|
|
1795
2112
|
}
|
|
1796
2113
|
|
|
1797
2114
|
// src/daemon/control.ts
|
|
1798
|
-
import { existsSync as
|
|
2115
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
|
|
1799
2116
|
import { hostname } from "os";
|
|
1800
2117
|
import { dirname as dirname2 } from "path";
|
|
1801
2118
|
|
|
@@ -1823,7 +2140,7 @@ async function runLoop(opts) {
|
|
|
1823
2140
|
|
|
1824
2141
|
// src/daemon/control.ts
|
|
1825
2142
|
function readPid(path = pidFilePath()) {
|
|
1826
|
-
if (!
|
|
2143
|
+
if (!existsSync3(path))
|
|
1827
2144
|
return;
|
|
1828
2145
|
try {
|
|
1829
2146
|
const pid = Number(readFileSync(path, "utf8").trim());
|
|
@@ -1941,6 +2258,7 @@ async function runDaemon(opts = {}) {
|
|
|
1941
2258
|
const ownStore = !opts.store;
|
|
1942
2259
|
const store = opts.store ?? new Store;
|
|
1943
2260
|
const leaseId = genId();
|
|
2261
|
+
const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
|
|
1944
2262
|
const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
|
|
1945
2263
|
const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
|
|
1946
2264
|
const log = opts.log ?? ((message) => console.error(`[loops-daemon] ${message}`));
|
|
@@ -1956,18 +2274,28 @@ async function runDaemon(opts = {}) {
|
|
|
1956
2274
|
log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
|
|
1957
2275
|
let stopFlag = false;
|
|
1958
2276
|
let leaseLost = false;
|
|
2277
|
+
const runAbort = new AbortController;
|
|
2278
|
+
const requestStop = (message) => {
|
|
2279
|
+
stopFlag = true;
|
|
2280
|
+
if (!runAbort.signal.aborted)
|
|
2281
|
+
runAbort.abort();
|
|
2282
|
+
if (message)
|
|
2283
|
+
log(message);
|
|
2284
|
+
};
|
|
1959
2285
|
const ensureLease = () => {
|
|
1960
2286
|
const current = store.heartbeatDaemonLease(leaseId, leaseTtlMs);
|
|
1961
2287
|
if (!current || current.id !== leaseId) {
|
|
1962
2288
|
leaseLost = true;
|
|
1963
|
-
|
|
2289
|
+
requestStop("daemon lease lost");
|
|
1964
2290
|
throw new Error("daemon lease lost");
|
|
1965
2291
|
}
|
|
1966
2292
|
};
|
|
1967
2293
|
const onSignal = () => {
|
|
1968
|
-
|
|
1969
|
-
log("stop signal received");
|
|
2294
|
+
requestStop("stop signal received");
|
|
1970
2295
|
};
|
|
2296
|
+
if (opts.signal?.aborted)
|
|
2297
|
+
onSignal();
|
|
2298
|
+
opts.signal?.addEventListener("abort", onSignal, { once: true });
|
|
1971
2299
|
process.on("SIGINT", onSignal);
|
|
1972
2300
|
process.on("SIGTERM", onSignal);
|
|
1973
2301
|
try {
|
|
@@ -1980,7 +2308,7 @@ async function runDaemon(opts = {}) {
|
|
|
1980
2308
|
ensureLease();
|
|
1981
2309
|
const result = await tick({
|
|
1982
2310
|
store,
|
|
1983
|
-
runnerId
|
|
2311
|
+
runnerId,
|
|
1984
2312
|
execute: async (loop, run) => {
|
|
1985
2313
|
const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs / 3));
|
|
1986
2314
|
const timer = setInterval(() => {
|
|
@@ -1992,7 +2320,10 @@ async function runDaemon(opts = {}) {
|
|
|
1992
2320
|
}, heartbeatMs);
|
|
1993
2321
|
timer.unref();
|
|
1994
2322
|
try {
|
|
1995
|
-
const result2 = await
|
|
2323
|
+
const result2 = await executeLoopTarget(store, loop, run, {
|
|
2324
|
+
signal: runAbort.signal,
|
|
2325
|
+
onSpawn: (pid) => store.markRunPid(run.id, pid, runnerId)
|
|
2326
|
+
});
|
|
1996
2327
|
if (leaseLost)
|
|
1997
2328
|
throw new Error("daemon lease lost during run");
|
|
1998
2329
|
return result2;
|
|
@@ -2009,6 +2340,7 @@ async function runDaemon(opts = {}) {
|
|
|
2009
2340
|
}
|
|
2010
2341
|
});
|
|
2011
2342
|
} finally {
|
|
2343
|
+
opts.signal?.removeEventListener("abort", onSignal);
|
|
2012
2344
|
process.off("SIGINT", onSignal);
|
|
2013
2345
|
process.off("SIGTERM", onSignal);
|
|
2014
2346
|
store.releaseDaemonLease(leaseId);
|
|
@@ -2044,6 +2376,7 @@ async function startDaemon(opts) {
|
|
|
2044
2376
|
|
|
2045
2377
|
// src/daemon/install.ts
|
|
2046
2378
|
import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
2379
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2047
2380
|
import { dirname as dirname3 } from "path";
|
|
2048
2381
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
2049
2382
|
const command = [execPath, cliEntry, ...args].join(" ");
|
|
@@ -2105,10 +2438,100 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2105
2438
|
}
|
|
2106
2439
|
throw new Error(`startup install is not implemented for ${process.platform}`);
|
|
2107
2440
|
}
|
|
2441
|
+
function enableStartup(result) {
|
|
2442
|
+
const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
|
|
2443
|
+
return commands.map((command) => {
|
|
2444
|
+
const run = spawnSync3("sh", ["-c", command], {
|
|
2445
|
+
encoding: "utf8",
|
|
2446
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2447
|
+
});
|
|
2448
|
+
return {
|
|
2449
|
+
command,
|
|
2450
|
+
status: run.status,
|
|
2451
|
+
stdout: run.stdout.trim(),
|
|
2452
|
+
stderr: run.stderr.trim()
|
|
2453
|
+
};
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
// src/lib/doctor.ts
|
|
2458
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
2459
|
+
import { accessSync, constants } from "fs";
|
|
2460
|
+
var PROVIDER_COMMANDS = [
|
|
2461
|
+
"claude",
|
|
2462
|
+
"cursor-agent",
|
|
2463
|
+
"codewith",
|
|
2464
|
+
"aicopilot",
|
|
2465
|
+
"opencode",
|
|
2466
|
+
"codex"
|
|
2467
|
+
];
|
|
2468
|
+
function hasCommand(command) {
|
|
2469
|
+
const result = spawnSync4("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
|
|
2470
|
+
return (result.status ?? 1) === 0;
|
|
2471
|
+
}
|
|
2472
|
+
function commandVersion(command) {
|
|
2473
|
+
const result = spawnSync4(command, ["--version"], {
|
|
2474
|
+
encoding: "utf8",
|
|
2475
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2476
|
+
});
|
|
2477
|
+
if ((result.status ?? 1) !== 0)
|
|
2478
|
+
return;
|
|
2479
|
+
return (result.stdout || result.stderr).trim().split(/\r?\n/)[0];
|
|
2480
|
+
}
|
|
2481
|
+
function runDoctor(store) {
|
|
2482
|
+
const checks = [];
|
|
2483
|
+
try {
|
|
2484
|
+
const dir = ensureDataDir();
|
|
2485
|
+
accessSync(dir, constants.R_OK | constants.W_OK);
|
|
2486
|
+
checks.push({ id: "data-dir", status: "ok", message: "data directory is writable", detail: dir });
|
|
2487
|
+
} catch (error) {
|
|
2488
|
+
checks.push({
|
|
2489
|
+
id: "data-dir",
|
|
2490
|
+
status: "fail",
|
|
2491
|
+
message: "data directory is not writable",
|
|
2492
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
const bunVersion = commandVersion("bun");
|
|
2496
|
+
checks.push(bunVersion ? { id: "bun", status: "ok", message: "bun is available", detail: bunVersion } : { id: "bun", status: "fail", message: "bun is not available on PATH" });
|
|
2497
|
+
const accountsVersion = commandVersion("accounts");
|
|
2498
|
+
checks.push(accountsVersion ? { id: "accounts", status: "ok", message: "accounts is available", detail: accountsVersion } : { id: "accounts", status: "warn", message: "accounts CLI is not available; account-routed steps will fail" });
|
|
2499
|
+
for (const command of PROVIDER_COMMANDS) {
|
|
2500
|
+
checks.push(hasCommand(command) ? { id: `provider:${command}`, status: "ok", message: `${command} is available` } : { id: `provider:${command}`, status: "warn", message: `${command} is not on PATH` });
|
|
2501
|
+
}
|
|
2502
|
+
const status = daemonStatus(store);
|
|
2503
|
+
checks.push(status.running ? { id: "daemon", status: "ok", message: `daemon is running pid=${status.pid}` } : { id: "daemon", status: status.stale ? "warn" : "ok", message: status.stale ? "daemon pid file is stale" : "daemon is not running" });
|
|
2504
|
+
const failedRuns = store.countRuns("failed");
|
|
2505
|
+
checks.push(failedRuns === 0 ? { id: "loop-runs", status: "ok", message: "no failed loop runs recorded" } : { id: "loop-runs", status: "warn", message: `${failedRuns} failed loop run(s) recorded` });
|
|
2506
|
+
for (const loop of store.listLoops({ status: "active" })) {
|
|
2507
|
+
try {
|
|
2508
|
+
if (loop.target.type === "workflow") {
|
|
2509
|
+
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
2510
|
+
for (const step of workflowExecutionOrder(workflow)) {
|
|
2511
|
+
preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id });
|
|
2512
|
+
}
|
|
2513
|
+
} else {
|
|
2514
|
+
preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name });
|
|
2515
|
+
}
|
|
2516
|
+
checks.push({ id: `loop:${loop.id}:preflight`, status: "ok", message: `active loop target is ready: ${loop.name}` });
|
|
2517
|
+
} catch (error) {
|
|
2518
|
+
checks.push({
|
|
2519
|
+
id: `loop:${loop.id}:preflight`,
|
|
2520
|
+
status: "warn",
|
|
2521
|
+
message: `active loop target preflight failed: ${loop.name}`,
|
|
2522
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
return {
|
|
2527
|
+
ok: checks.every((check) => check.status !== "fail"),
|
|
2528
|
+
checks
|
|
2529
|
+
};
|
|
2530
|
+
}
|
|
2108
2531
|
|
|
2109
2532
|
// src/cli/index.ts
|
|
2110
2533
|
var program = new Command;
|
|
2111
|
-
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.
|
|
2534
|
+
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.0");
|
|
2112
2535
|
program.option("-j, --json", "print JSON");
|
|
2113
2536
|
function isJson() {
|
|
2114
2537
|
return Boolean(program.opts().json);
|
|
@@ -2258,6 +2681,22 @@ addScheduleOptions(create.command("workflow <name>").description("schedule a sto
|
|
|
2258
2681
|
}
|
|
2259
2682
|
});
|
|
2260
2683
|
var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
|
|
2684
|
+
workflows.command("validate <file>").description("validate a workflow JSON file without storing or running it").option("--name <name>", "override workflow name from the file").option("--preflight", "also check account env and target executables").action((file, opts) => {
|
|
2685
|
+
const body = workflowBodyFromJson(JSON.parse(readFileSync2(file, "utf8")), opts.name);
|
|
2686
|
+
const now = new Date().toISOString();
|
|
2687
|
+
const workflow = {
|
|
2688
|
+
id: "validation",
|
|
2689
|
+
name: body.name,
|
|
2690
|
+
description: body.description,
|
|
2691
|
+
version: body.version ?? 1,
|
|
2692
|
+
status: "active",
|
|
2693
|
+
steps: body.steps,
|
|
2694
|
+
createdAt: now,
|
|
2695
|
+
updatedAt: now
|
|
2696
|
+
};
|
|
2697
|
+
const preflight = opts.preflight ? preflightWorkflow(workflow) : undefined;
|
|
2698
|
+
print({ valid: true, workflow: publicWorkflow(workflow), preflight }, `valid workflow ${workflow.name} steps=${workflow.steps.length}`);
|
|
2699
|
+
});
|
|
2261
2700
|
workflows.command("create <file>").description("validate and store a workflow JSON file").option("--name <name>", "override workflow name from the file").action((file, opts) => {
|
|
2262
2701
|
const store = new Store;
|
|
2263
2702
|
try {
|
|
@@ -2291,6 +2730,30 @@ workflows.command("show <idOrName>").action((idOrName) => {
|
|
|
2291
2730
|
store.close();
|
|
2292
2731
|
}
|
|
2293
2732
|
});
|
|
2733
|
+
workflows.command("inspect <runId>").description("show a workflow run with steps and events").action((runId) => {
|
|
2734
|
+
const store = new Store;
|
|
2735
|
+
try {
|
|
2736
|
+
const run = store.requireWorkflowRun(runId);
|
|
2737
|
+
const steps = store.listWorkflowStepRuns(run.id);
|
|
2738
|
+
const events = store.listWorkflowEvents(run.id);
|
|
2739
|
+
const value = {
|
|
2740
|
+
workflowRun: publicWorkflowRun(run),
|
|
2741
|
+
steps: steps.map((step) => publicWorkflowStepRun(step, isJson())),
|
|
2742
|
+
events: events.map(publicWorkflowEvent)
|
|
2743
|
+
};
|
|
2744
|
+
if (isJson())
|
|
2745
|
+
print(value);
|
|
2746
|
+
else {
|
|
2747
|
+
console.log(`${run.id} ${run.status} ${run.workflowName}`);
|
|
2748
|
+
for (const step of steps) {
|
|
2749
|
+
console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${step.error ?? ""}`);
|
|
2750
|
+
}
|
|
2751
|
+
console.log(` events=${events.length}`);
|
|
2752
|
+
}
|
|
2753
|
+
} finally {
|
|
2754
|
+
store.close();
|
|
2755
|
+
}
|
|
2756
|
+
});
|
|
2294
2757
|
workflows.command("run <idOrName>").option("--show-output", "show step stdout/stderr").action(async (idOrName, opts) => {
|
|
2295
2758
|
const store = new Store;
|
|
2296
2759
|
try {
|
|
@@ -2303,7 +2766,14 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
|
|
|
2303
2766
|
workflowRun: run ? publicWorkflowRun(run) : undefined,
|
|
2304
2767
|
steps: steps.map((step) => publicWorkflowStepRun(step, opts.showOutput))
|
|
2305
2768
|
};
|
|
2306
|
-
|
|
2769
|
+
if (isJson())
|
|
2770
|
+
print(value);
|
|
2771
|
+
else {
|
|
2772
|
+
console.log(`${run?.id ?? workflow.id} ${result.status}`);
|
|
2773
|
+
for (const step of steps) {
|
|
2774
|
+
console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${step.error ?? ""}`);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2307
2777
|
} finally {
|
|
2308
2778
|
store.close();
|
|
2309
2779
|
}
|
|
@@ -2339,6 +2809,27 @@ workflows.command("events <runId>").option("--limit <n>", "limit", "200").action
|
|
|
2339
2809
|
store.close();
|
|
2340
2810
|
}
|
|
2341
2811
|
});
|
|
2812
|
+
workflows.command("cancel <runId>").description("mark a workflow run cancelled and cancel pending/running steps").option("--reason <reason>", "cancellation reason", "cancelled by user").action((runId, opts) => {
|
|
2813
|
+
const store = new Store;
|
|
2814
|
+
try {
|
|
2815
|
+
const run = store.cancelWorkflowRun(runId, opts.reason);
|
|
2816
|
+
print(publicWorkflowRun(run), `${run.id} ${run.status}`);
|
|
2817
|
+
} finally {
|
|
2818
|
+
store.close();
|
|
2819
|
+
}
|
|
2820
|
+
});
|
|
2821
|
+
workflows.command("recover <runId>").description("reset interrupted running workflow steps to pending").option("--reason <reason>", "recovery reason", "manual recovery").action((runId, opts) => {
|
|
2822
|
+
const store = new Store;
|
|
2823
|
+
try {
|
|
2824
|
+
const result = store.recoverWorkflowRun(runId, opts.reason);
|
|
2825
|
+
print({
|
|
2826
|
+
workflowRun: publicWorkflowRun(result.run),
|
|
2827
|
+
recoveredSteps: result.recoveredSteps.map((step) => publicWorkflowStepRun(step))
|
|
2828
|
+
}, `${result.run.id} recovered=${result.recoveredSteps.length}`);
|
|
2829
|
+
} finally {
|
|
2830
|
+
store.close();
|
|
2831
|
+
}
|
|
2832
|
+
});
|
|
2342
2833
|
workflows.command("archive <idOrName>").action((idOrName) => {
|
|
2343
2834
|
const store = new Store;
|
|
2344
2835
|
try {
|
|
@@ -2413,22 +2904,17 @@ program.command("run-now <idOrName>").option("--show-output", "show stdout/stder
|
|
|
2413
2904
|
const store = new Store;
|
|
2414
2905
|
try {
|
|
2415
2906
|
const loop = store.requireLoop(idOrName);
|
|
2416
|
-
const
|
|
2907
|
+
const runnerId = `manual:${process.pid}`;
|
|
2908
|
+
const now = new Date;
|
|
2909
|
+
const scheduledFor = manualRunScheduledFor(loop, now);
|
|
2910
|
+
const shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
|
|
2911
|
+
const claim = store.claimRun(loop, scheduledFor, runnerId, now);
|
|
2417
2912
|
if (!claim)
|
|
2418
2913
|
throw new Error("could not claim manual run");
|
|
2419
|
-
const
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
durationMs: result.durationMs,
|
|
2424
|
-
stdout: result.stdout,
|
|
2425
|
-
stderr: result.stderr,
|
|
2426
|
-
exitCode: result.exitCode,
|
|
2427
|
-
error: result.error,
|
|
2428
|
-
pid: result.pid
|
|
2429
|
-
}, {
|
|
2430
|
-
claimedBy: claim.run.claimedBy
|
|
2431
|
-
});
|
|
2914
|
+
const run = await executeClaimedRun({ store, runnerId, loop: claim.loop, run: claim.run });
|
|
2915
|
+
if (shouldAdvance) {
|
|
2916
|
+
advanceLoop(store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
|
|
2917
|
+
}
|
|
2432
2918
|
print(publicRun(run, opts.showOutput), `${run.id} ${run.status}`);
|
|
2433
2919
|
} finally {
|
|
2434
2920
|
store.close();
|
|
@@ -2443,6 +2929,24 @@ program.command("tick").description("run one scheduler tick").action(async () =>
|
|
|
2443
2929
|
store.close();
|
|
2444
2930
|
}
|
|
2445
2931
|
});
|
|
2932
|
+
program.command("doctor").description("check local OpenLoops runtime dependencies and state").action(() => {
|
|
2933
|
+
const store = new Store;
|
|
2934
|
+
try {
|
|
2935
|
+
const report = runDoctor(store);
|
|
2936
|
+
if (isJson())
|
|
2937
|
+
print(report);
|
|
2938
|
+
else {
|
|
2939
|
+
for (const check of report.checks) {
|
|
2940
|
+
const marker = check.status === "ok" ? "ok" : check.status === "warn" ? "warn" : "fail";
|
|
2941
|
+
console.log(`${marker.padEnd(4)} ${check.id.padEnd(22)} ${check.message}${check.detail ? ` (${check.detail})` : ""}`);
|
|
2942
|
+
}
|
|
2943
|
+
if (!report.ok)
|
|
2944
|
+
process.exitCode = 1;
|
|
2945
|
+
}
|
|
2946
|
+
} finally {
|
|
2947
|
+
store.close();
|
|
2948
|
+
}
|
|
2949
|
+
});
|
|
2446
2950
|
var daemon = program.command("daemon").description("manage the local daemon");
|
|
2447
2951
|
daemon.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
|
|
2448
2952
|
daemon.command("start").action(async () => {
|
|
@@ -2461,15 +2965,20 @@ daemon.command("status").action(() => {
|
|
|
2461
2965
|
store.close();
|
|
2462
2966
|
}
|
|
2463
2967
|
});
|
|
2464
|
-
daemon.command("install").description("write a systemd user service or launchd plist").action(() => {
|
|
2968
|
+
daemon.command("install").description("write a systemd user service or launchd plist").option("--enable", "also enable/start the user service when supported").action((opts) => {
|
|
2465
2969
|
const result = installStartup(process.argv[1] ?? "loops");
|
|
2970
|
+
if (opts.enable)
|
|
2971
|
+
result.enableResults = enableStartup(result);
|
|
2972
|
+
const enableText = result.enableResults ? `
|
|
2973
|
+
${result.enableResults.map((item) => `${item.command} -> ${item.status === 0 ? "ok" : `exit ${item.status}`}`).join(`
|
|
2974
|
+
`)}` : "";
|
|
2466
2975
|
print(result, `wrote ${result.path}
|
|
2467
2976
|
${result.instructions.join(`
|
|
2468
|
-
`)}`);
|
|
2977
|
+
`)}${enableText}`);
|
|
2469
2978
|
});
|
|
2470
2979
|
daemon.command("logs").option("-n, --lines <n>", "lines", "80").action((opts) => {
|
|
2471
2980
|
const path = daemonLogPath();
|
|
2472
|
-
if (!
|
|
2981
|
+
if (!existsSync4(path)) {
|
|
2473
2982
|
console.log("");
|
|
2474
2983
|
return;
|
|
2475
2984
|
}
|