@hasna/loops 0.3.2 → 0.3.4
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 +8 -0
- package/dist/cli/index.js +857 -191
- package/dist/daemon/index.js +756 -167
- package/dist/index.d.ts +1 -0
- package/dist/index.js +779 -171
- package/dist/lib/executor.d.ts +11 -2
- package/dist/lib/format.d.ts +2 -1
- package/dist/lib/machines.d.ts +16 -0
- package/dist/lib/scheduler.d.ts +10 -1
- package/dist/lib/store.d.ts +21 -12
- package/dist/lib/store.js +309 -109
- package/dist/sdk/index.js +753 -165
- package/dist/types.d.ts +19 -0
- package/docs/USAGE.md +8 -0
- package/package.json +2 -1
package/dist/cli/index.js
CHANGED
|
@@ -347,6 +347,7 @@ function rowToLoop(row) {
|
|
|
347
347
|
status: row.status,
|
|
348
348
|
schedule: JSON.parse(row.schedule_json),
|
|
349
349
|
target: JSON.parse(row.target_json),
|
|
350
|
+
machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
|
|
350
351
|
nextRunAt: row.next_run_at ?? undefined,
|
|
351
352
|
retryScheduledFor: row.retry_scheduled_for ?? undefined,
|
|
352
353
|
catchUp: row.catch_up,
|
|
@@ -489,6 +490,7 @@ class Store {
|
|
|
489
490
|
status TEXT NOT NULL,
|
|
490
491
|
schedule_json TEXT NOT NULL,
|
|
491
492
|
target_json TEXT NOT NULL,
|
|
493
|
+
machine_json TEXT,
|
|
492
494
|
next_run_at TEXT,
|
|
493
495
|
retry_scheduled_for TEXT,
|
|
494
496
|
catch_up TEXT NOT NULL,
|
|
@@ -610,10 +612,21 @@ class Store {
|
|
|
610
612
|
);
|
|
611
613
|
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
|
|
612
614
|
`);
|
|
615
|
+
try {
|
|
616
|
+
this.db.query("ALTER TABLE loops ADD COLUMN machine_json TEXT").run();
|
|
617
|
+
} catch {}
|
|
613
618
|
try {
|
|
614
619
|
this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
|
|
615
620
|
} catch {}
|
|
616
621
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
622
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
|
|
623
|
+
}
|
|
624
|
+
assertDaemonLeaseFence(opts = {}, now = nowIso()) {
|
|
625
|
+
if (!opts.daemonLeaseId)
|
|
626
|
+
return;
|
|
627
|
+
const row = this.db.query("SELECT id FROM daemon_lease WHERE id = ? AND expires_at > ?").get(opts.daemonLeaseId, now);
|
|
628
|
+
if (!row)
|
|
629
|
+
throw new Error("daemon lease lost");
|
|
617
630
|
}
|
|
618
631
|
createLoop(input, from = new Date) {
|
|
619
632
|
const now = nowIso();
|
|
@@ -624,6 +637,7 @@ class Store {
|
|
|
624
637
|
status: "active",
|
|
625
638
|
schedule: input.schedule,
|
|
626
639
|
target: input.target,
|
|
640
|
+
machine: input.machine,
|
|
627
641
|
nextRunAt: initialNextRun(input.schedule, from),
|
|
628
642
|
catchUp: input.catchUp ?? "latest",
|
|
629
643
|
catchUpLimit: input.catchUpLimit ?? 50,
|
|
@@ -635,9 +649,9 @@ class Store {
|
|
|
635
649
|
createdAt: now,
|
|
636
650
|
updatedAt: now
|
|
637
651
|
};
|
|
638
|
-
this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, next_run_at, retry_scheduled_for,
|
|
652
|
+
this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
|
|
639
653
|
catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
|
|
640
|
-
VALUES ($id, $name, $description, $status, $schedule, $target, $nextRun, NULL, $catchUp, $catchUpLimit,
|
|
654
|
+
VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $catchUp, $catchUpLimit,
|
|
641
655
|
$overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
|
|
642
656
|
$id: loop.id,
|
|
643
657
|
$name: loop.name,
|
|
@@ -645,6 +659,7 @@ class Store {
|
|
|
645
659
|
$status: loop.status,
|
|
646
660
|
$schedule: JSON.stringify(loop.schedule),
|
|
647
661
|
$target: JSON.stringify(loop.target),
|
|
662
|
+
$machine: loop.machine ? JSON.stringify(loop.machine) : null,
|
|
648
663
|
$nextRun: loop.nextRunAt ?? null,
|
|
649
664
|
$catchUp: loop.catchUp,
|
|
650
665
|
$catchUpLimit: loop.catchUpLimit,
|
|
@@ -684,21 +699,31 @@ class Store {
|
|
|
684
699
|
ORDER BY next_run_at ASC`).all(now.toISOString());
|
|
685
700
|
return rows.map(rowToLoop);
|
|
686
701
|
}
|
|
687
|
-
updateLoop(id, patch) {
|
|
702
|
+
updateLoop(id, patch, opts = {}) {
|
|
688
703
|
const current = this.getLoop(id);
|
|
689
704
|
if (!current)
|
|
690
705
|
throw new Error(`loop not found: ${id}`);
|
|
691
|
-
const
|
|
706
|
+
const updated = (opts.now ?? new Date).toISOString();
|
|
707
|
+
const merged = { ...current, ...patch, updatedAt: updated };
|
|
692
708
|
this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
|
|
693
|
-
expires_at=$expiresAt, updated_at=$updated
|
|
709
|
+
expires_at=$expiresAt, updated_at=$updated
|
|
710
|
+
WHERE id=$id
|
|
711
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
712
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
713
|
+
))`).run({
|
|
694
714
|
$id: id,
|
|
695
715
|
$status: merged.status,
|
|
696
716
|
$nextRun: merged.nextRunAt ?? null,
|
|
697
717
|
$retrySlot: merged.retryScheduledFor ?? null,
|
|
698
718
|
$expiresAt: merged.expiresAt ?? null,
|
|
699
|
-
$updated: merged.updatedAt
|
|
719
|
+
$updated: merged.updatedAt,
|
|
720
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
721
|
+
$now: updated
|
|
700
722
|
});
|
|
701
|
-
|
|
723
|
+
const after = this.getLoop(id);
|
|
724
|
+
if (!after)
|
|
725
|
+
throw new Error(`loop not found after update: ${id}`);
|
|
726
|
+
return after;
|
|
702
727
|
}
|
|
703
728
|
deleteLoop(idOrName) {
|
|
704
729
|
const loop = this.requireLoop(idOrName);
|
|
@@ -762,11 +787,14 @@ class Store {
|
|
|
762
787
|
const now = nowIso();
|
|
763
788
|
if (input.idempotencyKey) {
|
|
764
789
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
765
|
-
if (existing)
|
|
790
|
+
if (existing) {
|
|
791
|
+
this.assertDaemonLeaseFence(input);
|
|
766
792
|
return rowToWorkflowRun(existing);
|
|
793
|
+
}
|
|
767
794
|
}
|
|
768
795
|
this.db.exec("BEGIN IMMEDIATE");
|
|
769
796
|
try {
|
|
797
|
+
this.assertDaemonLeaseFence(input, now);
|
|
770
798
|
if (input.idempotencyKey) {
|
|
771
799
|
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
772
800
|
if (existing) {
|
|
@@ -865,31 +893,60 @@ class Store {
|
|
|
865
893
|
const run = this.getWorkflowRun(workflowRunId);
|
|
866
894
|
return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
|
|
867
895
|
}
|
|
868
|
-
startWorkflowStepRun(workflowRunId, stepId) {
|
|
869
|
-
const now =
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
896
|
+
startWorkflowStepRun(workflowRunId, stepId, opts = {}) {
|
|
897
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
898
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
899
|
+
try {
|
|
900
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
901
|
+
SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
|
|
902
|
+
pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
903
|
+
WHERE workflow_run_id=$workflowRunId
|
|
904
|
+
AND step_id=$stepId
|
|
905
|
+
AND status IN ('pending', 'failed', 'timed_out')
|
|
906
|
+
AND EXISTS (
|
|
907
|
+
SELECT 1 FROM workflow_runs
|
|
908
|
+
WHERE id=$workflowRunId AND status='running'
|
|
909
|
+
)
|
|
910
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
911
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
912
|
+
))`).run({
|
|
913
|
+
$workflowRunId: workflowRunId,
|
|
914
|
+
$stepId: stepId,
|
|
915
|
+
$started: now,
|
|
916
|
+
$updated: now,
|
|
917
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
918
|
+
$now: now
|
|
919
|
+
});
|
|
920
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
921
|
+
if (!run)
|
|
922
|
+
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
923
|
+
if (res.changes !== 1) {
|
|
924
|
+
throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
|
|
925
|
+
}
|
|
926
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
927
|
+
this.db.exec("COMMIT");
|
|
928
|
+
return run;
|
|
929
|
+
} catch (error) {
|
|
930
|
+
try {
|
|
931
|
+
this.db.exec("ROLLBACK");
|
|
932
|
+
} catch {}
|
|
933
|
+
throw error;
|
|
885
934
|
}
|
|
886
|
-
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
887
|
-
return run;
|
|
888
935
|
}
|
|
889
|
-
markWorkflowStepPid(workflowRunId, stepId, pid) {
|
|
890
|
-
const now =
|
|
936
|
+
markWorkflowStepPid(workflowRunId, stepId, pid, opts = {}) {
|
|
937
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
891
938
|
this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
|
|
892
|
-
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
939
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
940
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
941
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
942
|
+
))`).run({
|
|
943
|
+
$workflowRunId: workflowRunId,
|
|
944
|
+
$stepId: stepId,
|
|
945
|
+
$pid: pid,
|
|
946
|
+
$updated: now,
|
|
947
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
948
|
+
$now: now
|
|
949
|
+
});
|
|
893
950
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
894
951
|
if (!run)
|
|
895
952
|
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
@@ -917,60 +974,110 @@ class Store {
|
|
|
917
974
|
recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
|
|
918
975
|
};
|
|
919
976
|
}
|
|
920
|
-
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
977
|
+
finalizeWorkflowStepRun(workflowRunId, stepId, patch, opts = {}) {
|
|
921
978
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
979
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
980
|
+
try {
|
|
981
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
982
|
+
pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
983
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'
|
|
984
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
985
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
986
|
+
))`).run({
|
|
987
|
+
$workflowRunId: workflowRunId,
|
|
988
|
+
$stepId: stepId,
|
|
989
|
+
$status: patch.status,
|
|
990
|
+
$finished: finishedAt,
|
|
991
|
+
$exitCode: patch.exitCode ?? null,
|
|
992
|
+
$durationMs: patch.durationMs ?? null,
|
|
993
|
+
$stdout: patch.stdout ?? null,
|
|
994
|
+
$stderr: patch.stderr ?? null,
|
|
995
|
+
$error: patch.error ?? null,
|
|
996
|
+
$updated: finishedAt,
|
|
997
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
998
|
+
$now: (opts.now ?? new Date(finishedAt)).toISOString()
|
|
940
999
|
});
|
|
1000
|
+
if (res.changes === 1) {
|
|
1001
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
1002
|
+
exitCode: patch.exitCode,
|
|
1003
|
+
error: patch.error
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
this.db.exec("COMMIT");
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
try {
|
|
1009
|
+
this.db.exec("ROLLBACK");
|
|
1010
|
+
} catch {}
|
|
1011
|
+
throw error;
|
|
941
1012
|
}
|
|
942
1013
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
943
1014
|
if (!run)
|
|
944
1015
|
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
945
1016
|
return run;
|
|
946
1017
|
}
|
|
947
|
-
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
948
|
-
const now =
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1018
|
+
skipWorkflowStepRun(workflowRunId, stepId, reason, opts = {}) {
|
|
1019
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1020
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1021
|
+
try {
|
|
1022
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
|
|
1023
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')
|
|
1024
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1025
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1026
|
+
))`).run({
|
|
1027
|
+
$workflowRunId: workflowRunId,
|
|
1028
|
+
$stepId: stepId,
|
|
1029
|
+
$finished: now,
|
|
1030
|
+
$error: reason,
|
|
1031
|
+
$updated: now,
|
|
1032
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1033
|
+
$now: now
|
|
1034
|
+
});
|
|
1035
|
+
if (res.changes === 1)
|
|
1036
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
1037
|
+
this.db.exec("COMMIT");
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
try {
|
|
1040
|
+
this.db.exec("ROLLBACK");
|
|
1041
|
+
} catch {}
|
|
1042
|
+
throw error;
|
|
1043
|
+
}
|
|
953
1044
|
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
954
1045
|
if (!run)
|
|
955
1046
|
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
956
1047
|
return run;
|
|
957
1048
|
}
|
|
958
|
-
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
1049
|
+
finalizeWorkflowRun(workflowRunId, status, patch = {}, opts = {}) {
|
|
959
1050
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1051
|
+
let changed = false;
|
|
1052
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1053
|
+
try {
|
|
1054
|
+
const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
1055
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1056
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1057
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1058
|
+
))`).run({
|
|
1059
|
+
$id: workflowRunId,
|
|
1060
|
+
$status: status,
|
|
1061
|
+
$finished: finishedAt,
|
|
1062
|
+
$durationMs: patch.durationMs ?? null,
|
|
1063
|
+
$error: patch.error ?? null,
|
|
1064
|
+
$updated: finishedAt,
|
|
1065
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1066
|
+
$now: (opts.now ?? new Date(finishedAt)).toISOString()
|
|
1067
|
+
});
|
|
1068
|
+
changed = res.changes === 1;
|
|
1069
|
+
if (changed)
|
|
1070
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
1071
|
+
this.db.exec("COMMIT");
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
try {
|
|
1074
|
+
this.db.exec("ROLLBACK");
|
|
1075
|
+
} catch {}
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
969
1078
|
const run = this.getWorkflowRun(workflowRunId);
|
|
970
1079
|
if (!run)
|
|
971
1080
|
throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
|
|
972
|
-
if (res.changes === 1)
|
|
973
|
-
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
974
1081
|
return run;
|
|
975
1082
|
}
|
|
976
1083
|
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
@@ -1024,10 +1131,24 @@ class Store {
|
|
|
1024
1131
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
1025
1132
|
return (row?.count ?? 0) > 0;
|
|
1026
1133
|
}
|
|
1027
|
-
markRunPid(id, pid, claimedBy) {
|
|
1028
|
-
const now =
|
|
1134
|
+
markRunPid(id, pid, claimedBy, opts = {}) {
|
|
1135
|
+
const now = (opts.now ?? new Date).toISOString();
|
|
1029
1136
|
const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1030
|
-
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1137
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1138
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1139
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1140
|
+
))`).run({
|
|
1141
|
+
$id: id,
|
|
1142
|
+
$pid: pid,
|
|
1143
|
+
$updated: now,
|
|
1144
|
+
$claimedBy: claimedBy,
|
|
1145
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
1146
|
+
$now: now
|
|
1147
|
+
}) : this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1148
|
+
WHERE id=$id AND status='running'
|
|
1149
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1150
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1151
|
+
))`).run({ $id: id, $pid: pid, $updated: now, $daemonLeaseId: opts.daemonLeaseId ?? null, $now: now });
|
|
1031
1152
|
if (res.changes !== 1)
|
|
1032
1153
|
return;
|
|
1033
1154
|
return this.getRun(id);
|
|
@@ -1042,7 +1163,7 @@ class Store {
|
|
|
1042
1163
|
AND wsr.pid IS NOT NULL`).all(loopRunId);
|
|
1043
1164
|
return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
|
|
1044
1165
|
}
|
|
1045
|
-
createSkippedRun(loop, scheduledFor, reason) {
|
|
1166
|
+
createSkippedRun(loop, scheduledFor, reason, opts = {}) {
|
|
1046
1167
|
const now = nowIso();
|
|
1047
1168
|
const run = {
|
|
1048
1169
|
id: genId(),
|
|
@@ -1056,21 +1177,31 @@ class Store {
|
|
|
1056
1177
|
createdAt: now,
|
|
1057
1178
|
updatedAt: now
|
|
1058
1179
|
};
|
|
1059
|
-
this.db.
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1180
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1181
|
+
try {
|
|
1182
|
+
this.assertDaemonLeaseFence(opts, now);
|
|
1183
|
+
this.db.query(`INSERT OR IGNORE INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
|
|
1184
|
+
claimed_by, lease_expires_at, pid, exit_code, duration_ms, stdout, stderr, error, created_at, updated_at)
|
|
1185
|
+
VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, NULL, $finished, NULL, NULL, NULL, NULL, NULL,
|
|
1186
|
+
NULL, NULL, $error, $created, $updated)`).run({
|
|
1187
|
+
$id: run.id,
|
|
1188
|
+
$loopId: run.loopId,
|
|
1189
|
+
$loopName: run.loopName,
|
|
1190
|
+
$scheduledFor: run.scheduledFor,
|
|
1191
|
+
$attempt: run.attempt,
|
|
1192
|
+
$status: run.status,
|
|
1193
|
+
$finished: run.finishedAt ?? null,
|
|
1194
|
+
$error: run.error ?? null,
|
|
1195
|
+
$created: run.createdAt,
|
|
1196
|
+
$updated: run.updatedAt
|
|
1197
|
+
});
|
|
1198
|
+
this.db.exec("COMMIT");
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
try {
|
|
1201
|
+
this.db.exec("ROLLBACK");
|
|
1202
|
+
} catch {}
|
|
1203
|
+
throw error;
|
|
1204
|
+
}
|
|
1074
1205
|
return this.getRunBySlot(loop.id, scheduledFor) ?? run;
|
|
1075
1206
|
}
|
|
1076
1207
|
getRun(id) {
|
|
@@ -1081,11 +1212,20 @@ class Store {
|
|
|
1081
1212
|
const row = this.db.query("SELECT * FROM loop_runs WHERE loop_id = ? AND scheduled_for = ?").get(loopId, scheduledFor);
|
|
1082
1213
|
return row ? rowToRun(row) : undefined;
|
|
1083
1214
|
}
|
|
1084
|
-
|
|
1215
|
+
nextRetryableRun(loopId, maxAttempts, afterScheduledFor) {
|
|
1216
|
+
const row = afterScheduledFor ? this.db.query(`SELECT * FROM loop_runs
|
|
1217
|
+
WHERE loop_id = ? AND scheduled_for > ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
|
|
1218
|
+
ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, afterScheduledFor, maxAttempts) : this.db.query(`SELECT * FROM loop_runs
|
|
1219
|
+
WHERE loop_id = ? AND status IN ('failed', 'timed_out', 'abandoned') AND attempt < ?
|
|
1220
|
+
ORDER BY scheduled_for ASC LIMIT 1`).get(loopId, maxAttempts);
|
|
1221
|
+
return row ? rowToRun(row) : undefined;
|
|
1222
|
+
}
|
|
1223
|
+
claimRun(loop, scheduledFor, runnerId, now = new Date, opts = {}) {
|
|
1085
1224
|
const startedAt = now.toISOString();
|
|
1086
1225
|
const leaseExpiresAt = new Date(now.getTime() + loop.leaseMs).toISOString();
|
|
1087
1226
|
this.db.exec("BEGIN IMMEDIATE");
|
|
1088
1227
|
try {
|
|
1228
|
+
this.assertDaemonLeaseFence(opts, startedAt);
|
|
1089
1229
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
1090
1230
|
if (existing) {
|
|
1091
1231
|
if (existing.status === "running") {
|
|
@@ -1180,11 +1320,15 @@ class Store {
|
|
|
1180
1320
|
$error: patch.error ?? null,
|
|
1181
1321
|
$updated: finishedAt,
|
|
1182
1322
|
$claimedBy: opts.claimedBy ?? null,
|
|
1183
|
-
$now: (opts.now ?? new Date).toISOString()
|
|
1323
|
+
$now: (opts.now ?? new Date).toISOString(),
|
|
1324
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1184
1325
|
};
|
|
1185
1326
|
const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1186
1327
|
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
1187
|
-
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1328
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1329
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1330
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1331
|
+
))`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1188
1332
|
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
|
|
1189
1333
|
const run = this.getRun(id);
|
|
1190
1334
|
if (!run)
|
|
@@ -1193,10 +1337,20 @@ class Store {
|
|
|
1193
1337
|
return run;
|
|
1194
1338
|
return run;
|
|
1195
1339
|
}
|
|
1196
|
-
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
|
|
1340
|
+
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
|
|
1197
1341
|
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
1198
1342
|
const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
|
|
1199
|
-
WHERE id=$id AND status='running' AND claimed_by=$claimedBy
|
|
1343
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now
|
|
1344
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1345
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1346
|
+
))`).run({
|
|
1347
|
+
$id: id,
|
|
1348
|
+
$claimedBy: claimedBy,
|
|
1349
|
+
$expires: expiresAt,
|
|
1350
|
+
$updated: now.toISOString(),
|
|
1351
|
+
$now: now.toISOString(),
|
|
1352
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1353
|
+
});
|
|
1200
1354
|
if (res.changes !== 1)
|
|
1201
1355
|
return;
|
|
1202
1356
|
return this.getRun(id);
|
|
@@ -1215,7 +1369,7 @@ class Store {
|
|
|
1215
1369
|
}
|
|
1216
1370
|
return rows.map(rowToRun);
|
|
1217
1371
|
}
|
|
1218
|
-
recoverExpiredRunLeases(now = new Date) {
|
|
1372
|
+
recoverExpiredRunLeases(now = new Date, opts = {}) {
|
|
1219
1373
|
const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
|
|
1220
1374
|
const recovered = [];
|
|
1221
1375
|
for (const row of rows) {
|
|
@@ -1224,20 +1378,63 @@ class Store {
|
|
|
1224
1378
|
if (this.hasLiveWorkflowStepProcesses(row.id))
|
|
1225
1379
|
continue;
|
|
1226
1380
|
const finished = now.toISOString();
|
|
1227
|
-
this.db.
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1381
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
1382
|
+
try {
|
|
1383
|
+
const res = this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
|
|
1384
|
+
error='run lease expired before completion', updated_at=$updated
|
|
1385
|
+
WHERE id=$id AND status='running' AND lease_expires_at <= $now
|
|
1386
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1387
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1388
|
+
))`).run({
|
|
1389
|
+
$id: row.id,
|
|
1390
|
+
$finished: finished,
|
|
1391
|
+
$updated: finished,
|
|
1392
|
+
$now: finished,
|
|
1393
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1240
1394
|
});
|
|
1395
|
+
if (res.changes !== 1) {
|
|
1396
|
+
this.db.exec("COMMIT");
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
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);
|
|
1400
|
+
for (const workflowRow of workflowRows) {
|
|
1401
|
+
const workflowRes = this.db.query(`UPDATE workflow_runs
|
|
1402
|
+
SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1403
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1404
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1405
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1406
|
+
))`).run({
|
|
1407
|
+
$id: workflowRow.id,
|
|
1408
|
+
$finished: finished,
|
|
1409
|
+
$updated: finished,
|
|
1410
|
+
$now: finished,
|
|
1411
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1412
|
+
});
|
|
1413
|
+
if (workflowRes.changes !== 1)
|
|
1414
|
+
continue;
|
|
1415
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
1416
|
+
SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1417
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')
|
|
1418
|
+
AND ($daemonLeaseId IS NULL OR EXISTS (
|
|
1419
|
+
SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
|
|
1420
|
+
))`).run({
|
|
1421
|
+
$workflowRunId: workflowRow.id,
|
|
1422
|
+
$finished: finished,
|
|
1423
|
+
$updated: finished,
|
|
1424
|
+
$now: finished,
|
|
1425
|
+
$daemonLeaseId: opts.daemonLeaseId ?? null
|
|
1426
|
+
});
|
|
1427
|
+
this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
|
|
1428
|
+
error: "parent loop run lease expired before completion",
|
|
1429
|
+
loopRunId: row.id
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
this.db.exec("COMMIT");
|
|
1433
|
+
} catch (error) {
|
|
1434
|
+
try {
|
|
1435
|
+
this.db.exec("ROLLBACK");
|
|
1436
|
+
} catch {}
|
|
1437
|
+
throw error;
|
|
1241
1438
|
}
|
|
1242
1439
|
const run = this.getRun(row.id);
|
|
1243
1440
|
if (run)
|
|
@@ -1245,11 +1442,14 @@ class Store {
|
|
|
1245
1442
|
}
|
|
1246
1443
|
return recovered;
|
|
1247
1444
|
}
|
|
1248
|
-
expireLoops(now = new Date) {
|
|
1445
|
+
expireLoops(now = new Date, opts = {}) {
|
|
1249
1446
|
const rows = this.db.query("SELECT * FROM loops WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= ?").all(now.toISOString());
|
|
1250
1447
|
const expired = [];
|
|
1251
|
-
for (const row of rows)
|
|
1252
|
-
|
|
1448
|
+
for (const row of rows) {
|
|
1449
|
+
const updated = this.updateLoop(row.id, { status: "expired", nextRunAt: undefined }, opts);
|
|
1450
|
+
if (updated.status === "expired")
|
|
1451
|
+
expired.push(updated);
|
|
1452
|
+
}
|
|
1253
1453
|
return expired;
|
|
1254
1454
|
}
|
|
1255
1455
|
countLoops(status) {
|
|
@@ -1292,7 +1492,7 @@ class Store {
|
|
|
1292
1492
|
}
|
|
1293
1493
|
heartbeatDaemonLease(id, ttlMs, now = new Date) {
|
|
1294
1494
|
const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
|
|
1295
|
-
const res = this.db.query(`UPDATE daemon_lease SET heartbeat_at=$heartbeat, expires_at=$expires, updated_at=$updated WHERE id=$id`).run({ $id: id, $heartbeat: now.toISOString(), $expires: expiresAt, $updated: now.toISOString() });
|
|
1495
|
+
const res = this.db.query(`UPDATE daemon_lease SET heartbeat_at=$heartbeat, expires_at=$expires, updated_at=$updated WHERE id=$id AND expires_at > $now`).run({ $id: id, $heartbeat: now.toISOString(), $expires: expiresAt, $updated: now.toISOString(), $now: now.toISOString() });
|
|
1296
1496
|
if (res.changes !== 1)
|
|
1297
1497
|
return;
|
|
1298
1498
|
return this.getDaemonLease();
|
|
@@ -1315,11 +1515,14 @@ import { Command } from "commander";
|
|
|
1315
1515
|
|
|
1316
1516
|
// src/lib/format.ts
|
|
1317
1517
|
var TEXT_OUTPUT_LIMIT = 32 * 1024;
|
|
1318
|
-
|
|
1518
|
+
var SENSITIVE_PAYLOAD_KEYS = new Set(["env", "error", "prompt", "reason", "stderr", "stdout"]);
|
|
1519
|
+
function redact(value, visible = 0) {
|
|
1319
1520
|
if (!value)
|
|
1320
1521
|
return value;
|
|
1321
1522
|
if (value.length <= visible)
|
|
1322
1523
|
return value;
|
|
1524
|
+
if (visible <= 0)
|
|
1525
|
+
return `[redacted ${value.length} chars]`;
|
|
1323
1526
|
return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
|
|
1324
1527
|
}
|
|
1325
1528
|
function truncateTextOutput(value) {
|
|
@@ -1328,6 +1531,21 @@ function truncateTextOutput(value) {
|
|
|
1328
1531
|
return `${value.slice(0, TEXT_OUTPUT_LIMIT)}
|
|
1329
1532
|
[truncated ${value.length - TEXT_OUTPUT_LIMIT} chars]`;
|
|
1330
1533
|
}
|
|
1534
|
+
function redactSensitivePayload(value, key) {
|
|
1535
|
+
if (key && SENSITIVE_PAYLOAD_KEYS.has(key)) {
|
|
1536
|
+
if (typeof value === "string")
|
|
1537
|
+
return redact(value);
|
|
1538
|
+
if (value === undefined || value === null)
|
|
1539
|
+
return value;
|
|
1540
|
+
return "[redacted]";
|
|
1541
|
+
}
|
|
1542
|
+
if (Array.isArray(value))
|
|
1543
|
+
return value.map((item) => redactSensitivePayload(item));
|
|
1544
|
+
if (value && typeof value === "object") {
|
|
1545
|
+
return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactSensitivePayload(entryValue, entryKey)]));
|
|
1546
|
+
}
|
|
1547
|
+
return value;
|
|
1548
|
+
}
|
|
1331
1549
|
function textOutputBlocks(value, opts = {}) {
|
|
1332
1550
|
const indent = opts.indent ?? "";
|
|
1333
1551
|
const nested = `${indent} `;
|
|
@@ -1359,6 +1577,14 @@ function publicRun(run, showOutput = false) {
|
|
|
1359
1577
|
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
|
|
1360
1578
|
};
|
|
1361
1579
|
}
|
|
1580
|
+
function publicExecutorResult(result, showOutput = false) {
|
|
1581
|
+
return {
|
|
1582
|
+
...result,
|
|
1583
|
+
stdout: showOutput ? result.stdout : result.stdout ? `[redacted ${result.stdout.length} chars]` : undefined,
|
|
1584
|
+
stderr: showOutput ? result.stderr : result.stderr ? `[redacted ${result.stderr.length} chars]` : undefined,
|
|
1585
|
+
error: redact(result.error)
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1362
1588
|
function publicWorkflow(workflow) {
|
|
1363
1589
|
return {
|
|
1364
1590
|
...workflow,
|
|
@@ -1369,22 +1595,24 @@ function publicWorkflow(workflow) {
|
|
|
1369
1595
|
};
|
|
1370
1596
|
}
|
|
1371
1597
|
function publicWorkflowRun(run) {
|
|
1372
|
-
return { ...run };
|
|
1598
|
+
return { ...run, error: redact(run.error) };
|
|
1373
1599
|
}
|
|
1374
1600
|
function publicWorkflowStepRun(run, showOutput = false) {
|
|
1375
1601
|
return {
|
|
1376
1602
|
...run,
|
|
1377
1603
|
stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
|
|
1378
|
-
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined
|
|
1604
|
+
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
|
|
1605
|
+
error: redact(run.error)
|
|
1379
1606
|
};
|
|
1380
1607
|
}
|
|
1381
1608
|
function publicWorkflowEvent(event) {
|
|
1382
|
-
return { ...event };
|
|
1609
|
+
return { ...event, payload: redactSensitivePayload(event.payload) };
|
|
1383
1610
|
}
|
|
1384
1611
|
|
|
1385
1612
|
// src/lib/executor.ts
|
|
1386
|
-
import { spawn } from "child_process";
|
|
1613
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
1387
1614
|
import { once } from "events";
|
|
1615
|
+
import { resolveMachineCommand } from "@hasna/machines/consumer";
|
|
1388
1616
|
|
|
1389
1617
|
// src/lib/accounts.ts
|
|
1390
1618
|
import { spawnSync } from "child_process";
|
|
@@ -1541,6 +1769,59 @@ function commandNotFoundMessage(command, env = process.env) {
|
|
|
1541
1769
|
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
1542
1770
|
}
|
|
1543
1771
|
|
|
1772
|
+
// src/lib/machines.ts
|
|
1773
|
+
import {
|
|
1774
|
+
discoverMachineTopology,
|
|
1775
|
+
resolveMachineRoute
|
|
1776
|
+
} from "@hasna/machines/consumer";
|
|
1777
|
+
function compact(value) {
|
|
1778
|
+
const text = value?.trim();
|
|
1779
|
+
return text ? text : undefined;
|
|
1780
|
+
}
|
|
1781
|
+
function entryToSummary(entry, topology) {
|
|
1782
|
+
return {
|
|
1783
|
+
id: entry.machine_id,
|
|
1784
|
+
hostname: compact(entry.hostname),
|
|
1785
|
+
platform: compact(entry.platform),
|
|
1786
|
+
user: compact(entry.user),
|
|
1787
|
+
workspacePath: compact(entry.workspace_path),
|
|
1788
|
+
route: entry.ssh.route,
|
|
1789
|
+
local: entry.machine_id === topology.local_machine_id || entry.ssh.route === "local",
|
|
1790
|
+
heartbeatStatus: entry.heartbeat_status,
|
|
1791
|
+
tailscaleOnline: entry.tailscale.online,
|
|
1792
|
+
tags: entry.tags
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
function machineFromRoute(route, topology) {
|
|
1796
|
+
if (!route.ok || !route.machine_id) {
|
|
1797
|
+
throw new Error(`OpenMachines route not found for machine: ${route.requested_machine_id}`);
|
|
1798
|
+
}
|
|
1799
|
+
const entry = topology.machines.find((machine) => machine.machine_id === route.machine_id);
|
|
1800
|
+
return {
|
|
1801
|
+
id: route.machine_id,
|
|
1802
|
+
requestedId: route.requested_machine_id !== route.machine_id ? route.requested_machine_id : undefined,
|
|
1803
|
+
route: route.route,
|
|
1804
|
+
local: route.local,
|
|
1805
|
+
confidence: route.confidence,
|
|
1806
|
+
workspacePath: compact(entry?.workspace_path),
|
|
1807
|
+
resolvedAt: route.generated_at,
|
|
1808
|
+
packageVersion: route.package.version,
|
|
1809
|
+
warnings: route.warnings.length ? route.warnings : undefined
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
function listOpenMachines() {
|
|
1813
|
+
const topology = discoverMachineTopology();
|
|
1814
|
+
return topology.machines.map((entry) => entryToSummary(entry, topology));
|
|
1815
|
+
}
|
|
1816
|
+
function resolveLoopMachine(machineId) {
|
|
1817
|
+
const topology = discoverMachineTopology();
|
|
1818
|
+
const route = resolveMachineRoute(machineId, { topology });
|
|
1819
|
+
return machineFromRoute(route, topology);
|
|
1820
|
+
}
|
|
1821
|
+
function refreshLoopMachine(machine) {
|
|
1822
|
+
return resolveLoopMachine(machine.id);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1544
1825
|
// src/lib/executor.ts
|
|
1545
1826
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
1546
1827
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
@@ -1561,6 +1842,23 @@ var AUTH_ENV_KEYS = [
|
|
|
1561
1842
|
"XDG_STATE_HOME",
|
|
1562
1843
|
"XDG_CACHE_HOME"
|
|
1563
1844
|
];
|
|
1845
|
+
var TRANSPORT_ENV_KEYS = new Set([
|
|
1846
|
+
"BUN_INSTALL",
|
|
1847
|
+
"HOME",
|
|
1848
|
+
"LANG",
|
|
1849
|
+
"LANGUAGE",
|
|
1850
|
+
"LOGNAME",
|
|
1851
|
+
"PATH",
|
|
1852
|
+
"SHELL",
|
|
1853
|
+
"SSH_AGENT_PID",
|
|
1854
|
+
"SSH_AUTH_SOCK",
|
|
1855
|
+
"TERM",
|
|
1856
|
+
"TMP",
|
|
1857
|
+
"TMPDIR",
|
|
1858
|
+
"TEMP",
|
|
1859
|
+
"USER",
|
|
1860
|
+
"XDG_RUNTIME_DIR"
|
|
1861
|
+
]);
|
|
1564
1862
|
function appendBounded(current, chunk, maxBytes) {
|
|
1565
1863
|
const next = current + chunk.toString("utf8");
|
|
1566
1864
|
if (Buffer.byteLength(next, "utf8") <= maxBytes)
|
|
@@ -1587,6 +1885,29 @@ function killProcessGroup(pid) {
|
|
|
1587
1885
|
}
|
|
1588
1886
|
}, 2000).unref();
|
|
1589
1887
|
}
|
|
1888
|
+
function shellQuote(value) {
|
|
1889
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1890
|
+
}
|
|
1891
|
+
function metadataEnv(metadata) {
|
|
1892
|
+
const env = {};
|
|
1893
|
+
if (metadata.loopId)
|
|
1894
|
+
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1895
|
+
if (metadata.loopName)
|
|
1896
|
+
env.LOOPS_LOOP_NAME = metadata.loopName;
|
|
1897
|
+
if (metadata.runId)
|
|
1898
|
+
env.LOOPS_RUN_ID = metadata.runId;
|
|
1899
|
+
if (metadata.scheduledFor)
|
|
1900
|
+
env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
|
|
1901
|
+
if (metadata.workflowId)
|
|
1902
|
+
env.LOOPS_WORKFLOW_ID = metadata.workflowId;
|
|
1903
|
+
if (metadata.workflowName)
|
|
1904
|
+
env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
|
|
1905
|
+
if (metadata.workflowRunId)
|
|
1906
|
+
env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
|
|
1907
|
+
if (metadata.workflowStepId)
|
|
1908
|
+
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1909
|
+
return env;
|
|
1910
|
+
}
|
|
1590
1911
|
function providerCommand(provider) {
|
|
1591
1912
|
switch (provider) {
|
|
1592
1913
|
case "claude":
|
|
@@ -1615,7 +1936,7 @@ function agentArgs(target) {
|
|
|
1615
1936
|
args.push("--model", target.model);
|
|
1616
1937
|
if (target.agent)
|
|
1617
1938
|
args.push("--agent", target.agent);
|
|
1618
|
-
args.push(...target.extraArgs ?? []
|
|
1939
|
+
args.push(...target.extraArgs ?? []);
|
|
1619
1940
|
return args;
|
|
1620
1941
|
case "cursor":
|
|
1621
1942
|
args.push("-p");
|
|
@@ -1623,7 +1944,7 @@ function agentArgs(target) {
|
|
|
1623
1944
|
args.push("--model", target.model);
|
|
1624
1945
|
if (target.agent)
|
|
1625
1946
|
args.push("--agent", target.agent);
|
|
1626
|
-
args.push(...target.extraArgs ?? []
|
|
1947
|
+
args.push(...target.extraArgs ?? []);
|
|
1627
1948
|
return args;
|
|
1628
1949
|
case "codewith":
|
|
1629
1950
|
args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : [], "--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
|
|
@@ -1635,7 +1956,7 @@ function agentArgs(target) {
|
|
|
1635
1956
|
args.push("--model", target.model);
|
|
1636
1957
|
if (target.agent)
|
|
1637
1958
|
args.push("--agent", target.agent);
|
|
1638
|
-
args.push(...target.extraArgs ?? []
|
|
1959
|
+
args.push(...target.extraArgs ?? []);
|
|
1639
1960
|
return args;
|
|
1640
1961
|
case "codex":
|
|
1641
1962
|
args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
|
|
@@ -1645,7 +1966,7 @@ function agentArgs(target) {
|
|
|
1645
1966
|
args.push("--cd", target.cwd);
|
|
1646
1967
|
if (target.model)
|
|
1647
1968
|
args.push("--model", target.model);
|
|
1648
|
-
args.push(...target.extraArgs ?? []
|
|
1969
|
+
args.push(...target.extraArgs ?? []);
|
|
1649
1970
|
return args;
|
|
1650
1971
|
case "aicopilot":
|
|
1651
1972
|
args.push("run", "--format", "json");
|
|
@@ -1657,7 +1978,7 @@ function agentArgs(target) {
|
|
|
1657
1978
|
args.push("--model", target.model);
|
|
1658
1979
|
if (target.agent)
|
|
1659
1980
|
args.push("--agent", target.agent);
|
|
1660
|
-
args.push(...target.extraArgs ?? []
|
|
1981
|
+
args.push(...target.extraArgs ?? []);
|
|
1661
1982
|
return args;
|
|
1662
1983
|
case "opencode":
|
|
1663
1984
|
args.push("run", "--format", "json");
|
|
@@ -1669,7 +1990,7 @@ function agentArgs(target) {
|
|
|
1669
1990
|
args.push("--model", target.model);
|
|
1670
1991
|
if (target.agent)
|
|
1671
1992
|
args.push("--agent", target.agent);
|
|
1672
|
-
args.push(...target.extraArgs ?? []
|
|
1993
|
+
args.push(...target.extraArgs ?? []);
|
|
1673
1994
|
return args;
|
|
1674
1995
|
}
|
|
1675
1996
|
}
|
|
@@ -1694,7 +2015,8 @@ function commandSpec(target) {
|
|
|
1694
2015
|
cwd: agentTarget.cwd,
|
|
1695
2016
|
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
1696
2017
|
account: agentTarget.account,
|
|
1697
|
-
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
|
|
2018
|
+
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
2019
|
+
stdin: agentTarget.prompt
|
|
1698
2020
|
};
|
|
1699
2021
|
}
|
|
1700
2022
|
function executionEnv(spec, metadata, opts) {
|
|
@@ -1707,26 +2029,213 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1707
2029
|
}
|
|
1708
2030
|
Object.assign(env, spec.env ?? {});
|
|
1709
2031
|
env.PATH = normalizeExecutionPath(env);
|
|
1710
|
-
|
|
1711
|
-
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1712
|
-
if (metadata.loopName)
|
|
1713
|
-
env.LOOPS_LOOP_NAME = metadata.loopName;
|
|
1714
|
-
if (metadata.runId)
|
|
1715
|
-
env.LOOPS_RUN_ID = metadata.runId;
|
|
1716
|
-
if (metadata.scheduledFor)
|
|
1717
|
-
env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
|
|
1718
|
-
if (metadata.workflowId)
|
|
1719
|
-
env.LOOPS_WORKFLOW_ID = metadata.workflowId;
|
|
1720
|
-
if (metadata.workflowName)
|
|
1721
|
-
env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
|
|
1722
|
-
if (metadata.workflowRunId)
|
|
1723
|
-
env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
|
|
1724
|
-
if (metadata.workflowStepId)
|
|
1725
|
-
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
2032
|
+
Object.assign(env, metadataEnv(metadata));
|
|
1726
2033
|
return env;
|
|
1727
2034
|
}
|
|
2035
|
+
function resolvedMachine(opts) {
|
|
2036
|
+
if (!opts.machine)
|
|
2037
|
+
return;
|
|
2038
|
+
return (opts.machineResolver ?? refreshLoopMachine)(opts.machine);
|
|
2039
|
+
}
|
|
2040
|
+
function commandForShell(spec) {
|
|
2041
|
+
if (!spec.args.length)
|
|
2042
|
+
return spec.command;
|
|
2043
|
+
return [spec.command, ...spec.args.map(shellQuote)].join(" ");
|
|
2044
|
+
}
|
|
2045
|
+
function hereDoc(value) {
|
|
2046
|
+
let delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
|
|
2047
|
+
while (value.split(/\r?\n/).includes(delimiter2)) {
|
|
2048
|
+
delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
|
|
2049
|
+
}
|
|
2050
|
+
return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
|
|
2051
|
+
}
|
|
2052
|
+
function remoteBootstrapLines(spec, metadata) {
|
|
2053
|
+
const lines = [
|
|
2054
|
+
"set -e",
|
|
2055
|
+
'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$HOME/.cargo/bin:$HOME/.npm-global/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin${PATH:+:$PATH}"'
|
|
2056
|
+
];
|
|
2057
|
+
if (spec.cwd)
|
|
2058
|
+
lines.push(`cd ${shellQuote(spec.cwd)}`);
|
|
2059
|
+
if (spec.account) {
|
|
2060
|
+
if (!spec.accountTool)
|
|
2061
|
+
throw new Error("account.tool is required when no provider tool can be inferred");
|
|
2062
|
+
lines.push("if ! command -v accounts >/dev/null 2>&1; then echo 'accounts CLI is not available on remote machine' >&2; exit 127; fi", `unset ${AUTH_ENV_KEYS.join(" ")}`, `eval "$(accounts env ${shellQuote(spec.account.profile)} --tool ${shellQuote(spec.accountTool)})"`, `export LOOPS_ACCOUNT_PROFILE=${shellQuote(spec.account.profile)}`, `export LOOPS_ACCOUNT_TOOL=${shellQuote(spec.accountTool)}`);
|
|
2063
|
+
}
|
|
2064
|
+
for (const [key, value] of Object.entries({ ...metadataEnv(metadata), ...spec.env ?? {} })) {
|
|
2065
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
2066
|
+
continue;
|
|
2067
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
2068
|
+
}
|
|
2069
|
+
return lines;
|
|
2070
|
+
}
|
|
2071
|
+
function remoteScript(spec, metadata) {
|
|
2072
|
+
const lines = remoteBootstrapLines(spec, metadata);
|
|
2073
|
+
let stdinRedirect = "";
|
|
2074
|
+
if (spec.stdin !== undefined) {
|
|
2075
|
+
lines.push('__OPENLOOPS_STDIN="$(mktemp -t openloops-stdin.XXXXXX)"', `trap 'rm -f "$__OPENLOOPS_STDIN"' EXIT`);
|
|
2076
|
+
lines.push(...hereDoc(spec.stdin));
|
|
2077
|
+
stdinRedirect = ' < "$__OPENLOOPS_STDIN"';
|
|
2078
|
+
}
|
|
2079
|
+
const invocation = spec.shell ? `sh -lc ${shellQuote(commandForShell(spec))}${stdinRedirect}` : `${[spec.command, ...spec.args].map(shellQuote).join(" ")}${stdinRedirect}`;
|
|
2080
|
+
lines.push(invocation);
|
|
2081
|
+
return `${lines.join(`
|
|
2082
|
+
`)}
|
|
2083
|
+
`;
|
|
2084
|
+
}
|
|
2085
|
+
function remotePreflightScript(spec, metadata) {
|
|
2086
|
+
return [
|
|
2087
|
+
...remoteBootstrapLines(spec, metadata),
|
|
2088
|
+
"command -v bash >/dev/null 2>&1",
|
|
2089
|
+
`command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
|
|
2090
|
+
].join(`
|
|
2091
|
+
`);
|
|
2092
|
+
}
|
|
2093
|
+
function transportEnv(opts) {
|
|
2094
|
+
const source = opts.env ?? process.env;
|
|
2095
|
+
const env = {};
|
|
2096
|
+
for (const [key, value] of Object.entries(source)) {
|
|
2097
|
+
if (value === undefined)
|
|
2098
|
+
continue;
|
|
2099
|
+
if (TRANSPORT_ENV_KEYS.has(key) || key.startsWith("LC_"))
|
|
2100
|
+
env[key] = value;
|
|
2101
|
+
}
|
|
2102
|
+
env.PATH = normalizeExecutionPath(env);
|
|
2103
|
+
return env;
|
|
2104
|
+
}
|
|
2105
|
+
function preflightRemoteSpec(spec, machine, metadata, opts) {
|
|
2106
|
+
const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
|
|
2107
|
+
const result = spawnSync2(plan.command, plan.args, {
|
|
2108
|
+
encoding: "utf8",
|
|
2109
|
+
env: transportEnv(opts),
|
|
2110
|
+
input: remotePreflightScript(spec, metadata),
|
|
2111
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2112
|
+
timeout: 15000
|
|
2113
|
+
});
|
|
2114
|
+
if (result.error)
|
|
2115
|
+
throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
|
|
2116
|
+
if ((result.status ?? 1) !== 0) {
|
|
2117
|
+
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
2118
|
+
throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
2122
|
+
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
2123
|
+
const startedAt = nowIso();
|
|
2124
|
+
let stdout = "";
|
|
2125
|
+
let stderr = "";
|
|
2126
|
+
let timedOut = false;
|
|
2127
|
+
let exitCode;
|
|
2128
|
+
let error;
|
|
2129
|
+
let plan;
|
|
2130
|
+
let script;
|
|
2131
|
+
try {
|
|
2132
|
+
plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
|
|
2133
|
+
script = remoteScript(spec, metadata);
|
|
2134
|
+
} catch (err) {
|
|
2135
|
+
return {
|
|
2136
|
+
status: "failed",
|
|
2137
|
+
stdout: "",
|
|
2138
|
+
stderr: "",
|
|
2139
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2140
|
+
startedAt,
|
|
2141
|
+
finishedAt: nowIso(),
|
|
2142
|
+
durationMs: 0
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
const child = spawn(plan.command, plan.args, {
|
|
2146
|
+
env: transportEnv(opts),
|
|
2147
|
+
detached: true,
|
|
2148
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2149
|
+
});
|
|
2150
|
+
if (child.pid)
|
|
2151
|
+
opts.onSpawn?.(child.pid);
|
|
2152
|
+
child.stdin?.on("error", (err) => {
|
|
2153
|
+
if (err.code !== "EPIPE")
|
|
2154
|
+
error = err.message;
|
|
2155
|
+
});
|
|
2156
|
+
child.stdin?.end(script);
|
|
2157
|
+
const abortHandler = () => {
|
|
2158
|
+
error = "cancelled";
|
|
2159
|
+
if (child.pid)
|
|
2160
|
+
killProcessGroup(child.pid);
|
|
2161
|
+
};
|
|
2162
|
+
if (opts.signal?.aborted)
|
|
2163
|
+
abortHandler();
|
|
2164
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
2165
|
+
child.stdout?.on("data", (chunk) => {
|
|
2166
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
2167
|
+
});
|
|
2168
|
+
child.stderr?.on("data", (chunk) => {
|
|
2169
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
2170
|
+
});
|
|
2171
|
+
const timer = setTimeout(() => {
|
|
2172
|
+
timedOut = true;
|
|
2173
|
+
if (child.pid)
|
|
2174
|
+
killProcessGroup(child.pid);
|
|
2175
|
+
}, spec.timeoutMs);
|
|
2176
|
+
timer.unref();
|
|
2177
|
+
try {
|
|
2178
|
+
const [code, signal] = await once(child, "exit");
|
|
2179
|
+
if (typeof code === "number")
|
|
2180
|
+
exitCode = code;
|
|
2181
|
+
if (signal)
|
|
2182
|
+
error = `terminated by ${signal}`;
|
|
2183
|
+
} catch (err) {
|
|
2184
|
+
error = err instanceof Error ? err.message : String(err);
|
|
2185
|
+
} finally {
|
|
2186
|
+
clearTimeout(timer);
|
|
2187
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
2188
|
+
}
|
|
2189
|
+
const finishedAt = nowIso();
|
|
2190
|
+
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
2191
|
+
if (timedOut) {
|
|
2192
|
+
return {
|
|
2193
|
+
status: "timed_out",
|
|
2194
|
+
exitCode,
|
|
2195
|
+
stdout,
|
|
2196
|
+
stderr,
|
|
2197
|
+
error: `timed out after ${spec.timeoutMs}ms`,
|
|
2198
|
+
pid: child.pid,
|
|
2199
|
+
startedAt,
|
|
2200
|
+
finishedAt,
|
|
2201
|
+
durationMs
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
if (error || exitCode !== 0) {
|
|
2205
|
+
return {
|
|
2206
|
+
status: "failed",
|
|
2207
|
+
exitCode,
|
|
2208
|
+
stdout,
|
|
2209
|
+
stderr,
|
|
2210
|
+
error: error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`,
|
|
2211
|
+
pid: child.pid,
|
|
2212
|
+
startedAt,
|
|
2213
|
+
finishedAt,
|
|
2214
|
+
durationMs
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
return {
|
|
2218
|
+
status: "succeeded",
|
|
2219
|
+
exitCode,
|
|
2220
|
+
stdout,
|
|
2221
|
+
stderr,
|
|
2222
|
+
pid: child.pid,
|
|
2223
|
+
startedAt,
|
|
2224
|
+
finishedAt,
|
|
2225
|
+
durationMs
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
1728
2228
|
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1729
2229
|
const spec = commandSpec(target);
|
|
2230
|
+
const machine = resolvedMachine(opts);
|
|
2231
|
+
if (machine && !machine.local) {
|
|
2232
|
+
preflightRemoteSpec(spec, machine, metadata, opts);
|
|
2233
|
+
return {
|
|
2234
|
+
command: spec.command,
|
|
2235
|
+
accountProfile: spec.account?.profile,
|
|
2236
|
+
accountTool: spec.accountTool
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
1730
2239
|
const env = executionEnv(spec, metadata, opts);
|
|
1731
2240
|
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1732
2241
|
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
@@ -1739,6 +2248,9 @@ function preflightTarget(target, metadata = {}, opts = {}) {
|
|
|
1739
2248
|
}
|
|
1740
2249
|
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1741
2250
|
const spec = commandSpec(target);
|
|
2251
|
+
const machine = resolvedMachine(opts);
|
|
2252
|
+
if (machine && !machine.local)
|
|
2253
|
+
return executeRemoteSpec(spec, machine, metadata, opts);
|
|
1742
2254
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
1743
2255
|
const startedAt = nowIso();
|
|
1744
2256
|
let stdout = "";
|
|
@@ -1763,10 +2275,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1763
2275
|
env,
|
|
1764
2276
|
shell: spec.shell ?? false,
|
|
1765
2277
|
detached: true,
|
|
1766
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
2278
|
+
stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
|
|
1767
2279
|
});
|
|
1768
2280
|
if (child.pid)
|
|
1769
2281
|
opts.onSpawn?.(child.pid);
|
|
2282
|
+
if (spec.stdin !== undefined && child.stdin) {
|
|
2283
|
+
child.stdin.on("error", (err) => {
|
|
2284
|
+
if (err.code !== "EPIPE")
|
|
2285
|
+
error = err.message;
|
|
2286
|
+
});
|
|
2287
|
+
child.stdin.end(spec.stdin);
|
|
2288
|
+
}
|
|
1770
2289
|
const abortHandler = () => {
|
|
1771
2290
|
error = "cancelled";
|
|
1772
2291
|
if (child.pid)
|
|
@@ -1775,10 +2294,10 @@ async function executeTarget(target, metadata = {}, opts = {}) {
|
|
|
1775
2294
|
if (opts.signal?.aborted)
|
|
1776
2295
|
abortHandler();
|
|
1777
2296
|
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1778
|
-
child.stdout
|
|
2297
|
+
child.stdout?.on("data", (chunk) => {
|
|
1779
2298
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
1780
2299
|
});
|
|
1781
|
-
child.stderr
|
|
2300
|
+
child.stderr?.on("data", (chunk) => {
|
|
1782
2301
|
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
1783
2302
|
});
|
|
1784
2303
|
const timer = setTimeout(() => {
|
|
@@ -1847,7 +2366,7 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
1847
2366
|
loopName: loop.name,
|
|
1848
2367
|
runId: run.id,
|
|
1849
2368
|
scheduledFor: run.scheduledFor
|
|
1850
|
-
}, opts);
|
|
2369
|
+
}, { ...opts, machine: opts.machine ?? loop.machine });
|
|
1851
2370
|
}
|
|
1852
2371
|
|
|
1853
2372
|
// src/lib/workflow-runner.ts
|
|
@@ -1877,7 +2396,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1877
2396
|
loop: opts.loop,
|
|
1878
2397
|
loopRun: opts.loopRun,
|
|
1879
2398
|
scheduledFor: opts.scheduledFor,
|
|
1880
|
-
idempotencyKey: opts.idempotencyKey
|
|
2399
|
+
idempotencyKey: opts.idempotencyKey,
|
|
2400
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
1881
2401
|
});
|
|
1882
2402
|
const startedAt = run.startedAt ?? nowIso();
|
|
1883
2403
|
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
@@ -1911,12 +2431,14 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1911
2431
|
return !dependencyStep?.continueOnFailure;
|
|
1912
2432
|
});
|
|
1913
2433
|
if (blockedBy) {
|
|
1914
|
-
|
|
2434
|
+
opts.beforePersist?.();
|
|
2435
|
+
store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`, { daemonLeaseId: opts.daemonLeaseId });
|
|
1915
2436
|
blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
|
|
1916
2437
|
terminalStatus = "failed";
|
|
1917
2438
|
continue;
|
|
1918
2439
|
}
|
|
1919
|
-
|
|
2440
|
+
opts.beforePersist?.();
|
|
2441
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id, { daemonLeaseId: opts.daemonLeaseId });
|
|
1920
2442
|
if (startedStep.status !== "running") {
|
|
1921
2443
|
terminalStatus = "failed";
|
|
1922
2444
|
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
@@ -1946,9 +2468,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1946
2468
|
try {
|
|
1947
2469
|
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
1948
2470
|
...opts,
|
|
2471
|
+
machine: opts.machine ?? opts.loop?.machine,
|
|
1949
2472
|
signal: controller.signal,
|
|
1950
2473
|
onSpawn: (pid) => {
|
|
1951
|
-
|
|
2474
|
+
opts.beforePersist?.();
|
|
2475
|
+
store.markWorkflowStepPid(run.id, step.id, pid, { daemonLeaseId: opts.daemonLeaseId });
|
|
1952
2476
|
opts.onSpawn?.(pid);
|
|
1953
2477
|
}
|
|
1954
2478
|
});
|
|
@@ -1976,6 +2500,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1976
2500
|
blockingError = "workflow run was cancelled";
|
|
1977
2501
|
break;
|
|
1978
2502
|
}
|
|
2503
|
+
opts.beforePersist?.();
|
|
1979
2504
|
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1980
2505
|
status: result.status,
|
|
1981
2506
|
finishedAt: result.finishedAt,
|
|
@@ -1984,6 +2509,8 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1984
2509
|
stderr: result.stderr,
|
|
1985
2510
|
exitCode: result.exitCode,
|
|
1986
2511
|
error: result.error
|
|
2512
|
+
}, {
|
|
2513
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
1987
2514
|
});
|
|
1988
2515
|
if (result.status !== "succeeded" && !step.continueOnFailure) {
|
|
1989
2516
|
terminalStatus = result.status;
|
|
@@ -1995,7 +2522,9 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
1995
2522
|
for (const step of ordered) {
|
|
1996
2523
|
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1997
2524
|
if (existing?.status === "pending" || existing?.status === "running") {
|
|
1998
|
-
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run"
|
|
2525
|
+
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run", {
|
|
2526
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
2527
|
+
});
|
|
1999
2528
|
}
|
|
2000
2529
|
}
|
|
2001
2530
|
}
|
|
@@ -2005,10 +2534,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
2005
2534
|
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
2006
2535
|
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
2007
2536
|
}
|
|
2537
|
+
opts.beforePersist?.();
|
|
2008
2538
|
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
2009
2539
|
finishedAt,
|
|
2010
2540
|
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
2011
2541
|
error: blockingError
|
|
2542
|
+
}, {
|
|
2543
|
+
daemonLeaseId: opts.daemonLeaseId
|
|
2012
2544
|
});
|
|
2013
2545
|
const steps = store.listWorkflowStepRuns(run.id);
|
|
2014
2546
|
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
@@ -2056,52 +2588,81 @@ async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
|
2056
2588
|
|
|
2057
2589
|
// src/lib/scheduler.ts
|
|
2058
2590
|
function manualRunScheduledFor(loop, now = new Date) {
|
|
2059
|
-
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
2591
|
+
if (loop.status === "active" && loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
2060
2592
|
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
2061
2593
|
}
|
|
2062
2594
|
return now.toISOString();
|
|
2063
2595
|
}
|
|
2064
2596
|
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
2597
|
+
if (loop.status !== "active")
|
|
2598
|
+
return false;
|
|
2065
2599
|
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
2066
2600
|
return false;
|
|
2067
2601
|
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
2068
2602
|
}
|
|
2603
|
+
function manualRunSource(loop, scheduledFor, now = new Date) {
|
|
2604
|
+
if (loop.status !== "active")
|
|
2605
|
+
return "ad_hoc";
|
|
2606
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
2607
|
+
return "ad_hoc";
|
|
2608
|
+
if (loop.retryScheduledFor && scheduledFor === loop.retryScheduledFor)
|
|
2609
|
+
return "retry_slot";
|
|
2610
|
+
return "due_slot";
|
|
2611
|
+
}
|
|
2069
2612
|
function nextAfterRetry(loop, now) {
|
|
2070
2613
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
2071
2614
|
}
|
|
2072
|
-
function
|
|
2615
|
+
function isDaemonLeaseLost(error) {
|
|
2616
|
+
return error instanceof Error && error.message === "daemon lease lost";
|
|
2617
|
+
}
|
|
2618
|
+
function advanceLoop(store, loop, run, finishedAt, succeeded, opts = {}) {
|
|
2073
2619
|
if (run.status === "running")
|
|
2074
2620
|
return;
|
|
2075
2621
|
const current = store.getLoop(loop.id);
|
|
2076
2622
|
if (!current || current.status !== "active")
|
|
2077
2623
|
return;
|
|
2078
|
-
|
|
2624
|
+
if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
|
|
2625
|
+
return;
|
|
2626
|
+
const shouldRetry = !succeeded && run.attempt < current.maxAttempts;
|
|
2079
2627
|
if (shouldRetry) {
|
|
2080
|
-
store.updateLoop(
|
|
2628
|
+
store.updateLoop(current.id, {
|
|
2081
2629
|
status: "active",
|
|
2082
|
-
nextRunAt: nextAfterRetry(
|
|
2630
|
+
nextRunAt: nextAfterRetry(current, finishedAt),
|
|
2083
2631
|
retryScheduledFor: run.scheduledFor
|
|
2084
|
-
});
|
|
2632
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2085
2633
|
return;
|
|
2086
2634
|
}
|
|
2087
|
-
const
|
|
2088
|
-
|
|
2635
|
+
const deferredRetry = store.nextRetryableRun(current.id, current.maxAttempts, run.scheduledFor);
|
|
2636
|
+
if (deferredRetry) {
|
|
2637
|
+
store.updateLoop(current.id, {
|
|
2638
|
+
status: "active",
|
|
2639
|
+
nextRunAt: nextAfterRetry(current, finishedAt),
|
|
2640
|
+
retryScheduledFor: deferredRetry.scheduledFor
|
|
2641
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
const nextRunAt = computeNextAfter(current.schedule, new Date(run.scheduledFor), finishedAt);
|
|
2645
|
+
store.updateLoop(current.id, {
|
|
2089
2646
|
status: nextRunAt ? "active" : "stopped",
|
|
2090
2647
|
nextRunAt,
|
|
2091
2648
|
retryScheduledFor: undefined
|
|
2092
|
-
});
|
|
2649
|
+
}, { daemonLeaseId: opts.daemonLeaseId });
|
|
2093
2650
|
}
|
|
2094
2651
|
async function executeClaimedRun(deps) {
|
|
2095
2652
|
let heartbeat;
|
|
2096
2653
|
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
2097
2654
|
heartbeat = setInterval(() => {
|
|
2098
|
-
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs
|
|
2655
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs, new Date, {
|
|
2656
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2657
|
+
});
|
|
2099
2658
|
}, heartbeatEveryMs);
|
|
2100
2659
|
heartbeat.unref();
|
|
2101
2660
|
try {
|
|
2102
2661
|
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
2103
|
-
|
|
2662
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2663
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId, { daemonLeaseId: deps.daemonLeaseId })
|
|
2104
2664
|
})))(deps.loop, deps.run);
|
|
2665
|
+
deps.beforeFinalize?.(deps.loop, deps.run);
|
|
2105
2666
|
return deps.store.finalizeRun(deps.run.id, {
|
|
2106
2667
|
status: result.status,
|
|
2107
2668
|
finishedAt: result.finishedAt,
|
|
@@ -2113,10 +2674,16 @@ async function executeClaimedRun(deps) {
|
|
|
2113
2674
|
pid: result.pid
|
|
2114
2675
|
}, {
|
|
2115
2676
|
claimedBy: deps.runnerId,
|
|
2677
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2116
2678
|
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
2117
2679
|
});
|
|
2118
2680
|
} catch (err) {
|
|
2119
2681
|
deps.onError?.(deps.loop, err);
|
|
2682
|
+
try {
|
|
2683
|
+
deps.beforeFinalize?.(deps.loop, deps.run);
|
|
2684
|
+
} catch {
|
|
2685
|
+
return deps.store.getRun(deps.run.id) ?? deps.run;
|
|
2686
|
+
}
|
|
2120
2687
|
const finishedAt = new Date;
|
|
2121
2688
|
return deps.store.finalizeRun(deps.run.id, {
|
|
2122
2689
|
status: "failed",
|
|
@@ -2127,6 +2694,7 @@ async function executeClaimedRun(deps) {
|
|
|
2127
2694
|
error: err instanceof Error ? err.message : String(err)
|
|
2128
2695
|
}, {
|
|
2129
2696
|
claimedBy: deps.runnerId,
|
|
2697
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2130
2698
|
now: deps.now?.() ?? finishedAt
|
|
2131
2699
|
});
|
|
2132
2700
|
} finally {
|
|
@@ -2136,15 +2704,33 @@ async function executeClaimedRun(deps) {
|
|
|
2136
2704
|
}
|
|
2137
2705
|
async function runSlot(deps, loop, scheduledFor) {
|
|
2138
2706
|
const now = deps.now?.() ?? new Date;
|
|
2707
|
+
deps.beforeRun?.(loop, scheduledFor);
|
|
2139
2708
|
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2140
|
-
|
|
2141
|
-
|
|
2709
|
+
let skipped;
|
|
2710
|
+
try {
|
|
2711
|
+
skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active", {
|
|
2712
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2713
|
+
});
|
|
2714
|
+
} catch (error) {
|
|
2715
|
+
if (deps.daemonLeaseId && isDaemonLeaseLost(error))
|
|
2716
|
+
return;
|
|
2717
|
+
throw error;
|
|
2718
|
+
}
|
|
2719
|
+
advanceLoop(deps.store, loop, skipped, now, true, { daemonLeaseId: deps.daemonLeaseId });
|
|
2142
2720
|
deps.onRun?.(skipped);
|
|
2143
2721
|
return skipped;
|
|
2144
2722
|
}
|
|
2145
|
-
|
|
2723
|
+
let claim;
|
|
2724
|
+
try {
|
|
2725
|
+
claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2726
|
+
} catch (error) {
|
|
2727
|
+
if (deps.daemonLeaseId && isDaemonLeaseLost(error))
|
|
2728
|
+
return;
|
|
2729
|
+
throw error;
|
|
2730
|
+
}
|
|
2146
2731
|
if (!claim)
|
|
2147
2732
|
return;
|
|
2733
|
+
deps.beforeRun?.(claim.loop, claim.run.scheduledFor);
|
|
2148
2734
|
deps.onRun?.(claim.run);
|
|
2149
2735
|
const finalRun = await executeClaimedRun({
|
|
2150
2736
|
store: deps.store,
|
|
@@ -2153,21 +2739,42 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
2153
2739
|
run: claim.run,
|
|
2154
2740
|
now: deps.now,
|
|
2155
2741
|
execute: deps.execute,
|
|
2742
|
+
beforeFinalize: deps.beforeFinalize,
|
|
2743
|
+
daemonLeaseId: deps.daemonLeaseId,
|
|
2156
2744
|
onError: deps.onError
|
|
2157
2745
|
});
|
|
2158
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2746
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded", { daemonLeaseId: deps.daemonLeaseId });
|
|
2159
2747
|
deps.onRun?.(finalRun);
|
|
2160
2748
|
return finalRun;
|
|
2161
2749
|
}
|
|
2162
2750
|
async function tick(deps) {
|
|
2163
2751
|
const now = deps.now?.() ?? new Date;
|
|
2164
|
-
const recovered = deps.store.recoverExpiredRunLeases(now);
|
|
2752
|
+
const recovered = deps.store.recoverExpiredRunLeases(now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2753
|
+
const recoveredByLoop = new Map;
|
|
2165
2754
|
for (const run of recovered) {
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2755
|
+
recoveredByLoop.set(run.loopId, [...recoveredByLoop.get(run.loopId) ?? [], run]);
|
|
2756
|
+
}
|
|
2757
|
+
for (const runs of recoveredByLoop.values()) {
|
|
2758
|
+
const loop = deps.store.getLoop(runs[0].loopId);
|
|
2759
|
+
if (!loop)
|
|
2760
|
+
continue;
|
|
2761
|
+
const retryable = runs.filter((run) => run.attempt < loop.maxAttempts).sort((a, b) => new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime())[0];
|
|
2762
|
+
if (retryable) {
|
|
2763
|
+
advanceLoop(deps.store, loop, retryable, new Date(retryable.finishedAt ?? now), false, {
|
|
2764
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2765
|
+
});
|
|
2766
|
+
continue;
|
|
2767
|
+
}
|
|
2768
|
+
for (const run of runs) {
|
|
2769
|
+
const current = deps.store.getLoop(run.loopId);
|
|
2770
|
+
if (current) {
|
|
2771
|
+
advanceLoop(deps.store, current, run, new Date(run.finishedAt ?? now), false, {
|
|
2772
|
+
daemonLeaseId: deps.daemonLeaseId
|
|
2773
|
+
});
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2169
2776
|
}
|
|
2170
|
-
const expired = deps.store.expireLoops(now);
|
|
2777
|
+
const expired = deps.store.expireLoops(now, { daemonLeaseId: deps.daemonLeaseId });
|
|
2171
2778
|
const claimed = [];
|
|
2172
2779
|
const completed = [];
|
|
2173
2780
|
const skipped = [];
|
|
@@ -2388,8 +2995,10 @@ async function runDaemon(opts = {}) {
|
|
|
2388
2995
|
const result = await tick({
|
|
2389
2996
|
store,
|
|
2390
2997
|
runnerId,
|
|
2998
|
+
daemonLeaseId: leaseId,
|
|
2999
|
+
beforeRun: () => ensureLease(),
|
|
2391
3000
|
execute: async (loop, run) => {
|
|
2392
|
-
const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs /
|
|
3001
|
+
const heartbeatMs = Math.max(25, Math.min(1000, intervalMs, Math.floor(leaseTtlMs / 10)));
|
|
2393
3002
|
const timer = setInterval(() => {
|
|
2394
3003
|
try {
|
|
2395
3004
|
ensureLease();
|
|
@@ -2401,8 +3010,14 @@ async function runDaemon(opts = {}) {
|
|
|
2401
3010
|
try {
|
|
2402
3011
|
const result2 = await executeLoopTarget(store, loop, run, {
|
|
2403
3012
|
signal: runAbort.signal,
|
|
2404
|
-
|
|
3013
|
+
beforePersist: () => ensureLease(),
|
|
3014
|
+
daemonLeaseId: leaseId,
|
|
3015
|
+
onSpawn: (pid) => {
|
|
3016
|
+
ensureLease();
|
|
3017
|
+
store.markRunPid(run.id, pid, runnerId, { daemonLeaseId: leaseId });
|
|
3018
|
+
}
|
|
2405
3019
|
});
|
|
3020
|
+
ensureLease();
|
|
2406
3021
|
if (leaseLost)
|
|
2407
3022
|
throw new Error("daemon lease lost during run");
|
|
2408
3023
|
return result2;
|
|
@@ -2410,6 +3025,7 @@ async function runDaemon(opts = {}) {
|
|
|
2410
3025
|
clearInterval(timer);
|
|
2411
3026
|
}
|
|
2412
3027
|
},
|
|
3028
|
+
beforeFinalize: () => ensureLease(),
|
|
2413
3029
|
onError: (loop, err) => log(`loop ${loop.id} failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
2414
3030
|
});
|
|
2415
3031
|
const changed = result.completed.length + result.skipped.length + result.recovered.length + result.expired.length;
|
|
@@ -2455,7 +3071,7 @@ async function startDaemon(opts) {
|
|
|
2455
3071
|
|
|
2456
3072
|
// src/daemon/install.ts
|
|
2457
3073
|
import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
2458
|
-
import { spawnSync as
|
|
3074
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2459
3075
|
import { dirname as dirname3 } from "path";
|
|
2460
3076
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
2461
3077
|
const command = [execPath, cliEntry, ...args].join(" ");
|
|
@@ -2525,7 +3141,7 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2525
3141
|
function enableStartup(result) {
|
|
2526
3142
|
const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
|
|
2527
3143
|
return commands.map((command) => {
|
|
2528
|
-
const run =
|
|
3144
|
+
const run = spawnSync3("sh", ["-c", command], {
|
|
2529
3145
|
encoding: "utf8",
|
|
2530
3146
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2531
3147
|
});
|
|
@@ -2539,7 +3155,7 @@ function enableStartup(result) {
|
|
|
2539
3155
|
}
|
|
2540
3156
|
|
|
2541
3157
|
// src/lib/doctor.ts
|
|
2542
|
-
import { spawnSync as
|
|
3158
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
2543
3159
|
import { accessSync as accessSync2, constants as constants2 } from "fs";
|
|
2544
3160
|
var PROVIDER_COMMANDS = [
|
|
2545
3161
|
"claude",
|
|
@@ -2550,11 +3166,11 @@ var PROVIDER_COMMANDS = [
|
|
|
2550
3166
|
"codex"
|
|
2551
3167
|
];
|
|
2552
3168
|
function hasCommand(command) {
|
|
2553
|
-
const result =
|
|
3169
|
+
const result = spawnSync4("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
|
|
2554
3170
|
return (result.status ?? 1) === 0;
|
|
2555
3171
|
}
|
|
2556
3172
|
function commandVersion(command) {
|
|
2557
|
-
const result =
|
|
3173
|
+
const result = spawnSync4(command, ["--version"], {
|
|
2558
3174
|
encoding: "utf8",
|
|
2559
3175
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2560
3176
|
});
|
|
@@ -2580,6 +3196,23 @@ function runDoctor(store) {
|
|
|
2580
3196
|
checks.push(bunVersion ? { id: "bun", status: "ok", message: "bun is available", detail: bunVersion } : { id: "bun", status: "fail", message: "bun is not available on PATH" });
|
|
2581
3197
|
const accountsVersion = commandVersion("accounts");
|
|
2582
3198
|
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" });
|
|
3199
|
+
try {
|
|
3200
|
+
const machines = listOpenMachines();
|
|
3201
|
+
const local = machines.find((machine) => machine.local);
|
|
3202
|
+
checks.push({
|
|
3203
|
+
id: "machines",
|
|
3204
|
+
status: "ok",
|
|
3205
|
+
message: `OpenMachines topology available (${machines.length} machine(s))`,
|
|
3206
|
+
detail: local ? `local=${local.id}` : undefined
|
|
3207
|
+
});
|
|
3208
|
+
} catch (error) {
|
|
3209
|
+
checks.push({
|
|
3210
|
+
id: "machines",
|
|
3211
|
+
status: "warn",
|
|
3212
|
+
message: "OpenMachines topology is not available; machine-assigned loops will fail",
|
|
3213
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
2583
3216
|
for (const command of PROVIDER_COMMANDS) {
|
|
2584
3217
|
checks.push(hasCommand(command) ? { id: `provider:${command}`, status: "ok", message: `${command} is available` } : { id: `provider:${command}`, status: "warn", message: `${command} is not on PATH` });
|
|
2585
3218
|
}
|
|
@@ -2592,16 +3225,16 @@ function runDoctor(store) {
|
|
|
2592
3225
|
if (loop.target.type === "workflow") {
|
|
2593
3226
|
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
2594
3227
|
for (const step of workflowExecutionOrder(workflow)) {
|
|
2595
|
-
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 });
|
|
3228
|
+
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 }, { machine: loop.machine });
|
|
2596
3229
|
}
|
|
2597
3230
|
} else {
|
|
2598
|
-
preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name });
|
|
3231
|
+
preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name }, { machine: loop.machine });
|
|
2599
3232
|
}
|
|
2600
3233
|
checks.push({ id: `loop:${loop.id}:preflight`, status: "ok", message: `active loop target is ready: ${loop.name}` });
|
|
2601
3234
|
} catch (error) {
|
|
2602
3235
|
checks.push({
|
|
2603
3236
|
id: `loop:${loop.id}:preflight`,
|
|
2604
|
-
status: "
|
|
3237
|
+
status: "fail",
|
|
2605
3238
|
message: `active loop target preflight failed: ${loop.name}`,
|
|
2606
3239
|
detail: error instanceof Error ? error.message : String(error)
|
|
2607
3240
|
});
|
|
@@ -2615,7 +3248,7 @@ function runDoctor(store) {
|
|
|
2615
3248
|
|
|
2616
3249
|
// src/cli/index.ts
|
|
2617
3250
|
var program = new Command;
|
|
2618
|
-
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.
|
|
3251
|
+
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.4");
|
|
2619
3252
|
program.option("-j, --json", "print JSON");
|
|
2620
3253
|
function isJson() {
|
|
2621
3254
|
return Boolean(program.opts().json);
|
|
@@ -2694,6 +3327,7 @@ function baseCreateInput(name, opts, target) {
|
|
|
2694
3327
|
description: typeof opts.description === "string" ? opts.description : undefined,
|
|
2695
3328
|
schedule,
|
|
2696
3329
|
target,
|
|
3330
|
+
machine: typeof opts.machine === "string" ? resolveLoopMachine(opts.machine) : undefined,
|
|
2697
3331
|
...policy,
|
|
2698
3332
|
expiresAt: typeof opts.expiresAt === "string" ? new Date(opts.expiresAt).toISOString() : undefined
|
|
2699
3333
|
};
|
|
@@ -2704,6 +3338,9 @@ function addScheduleOptions(command) {
|
|
|
2704
3338
|
function addAccountOptions(command) {
|
|
2705
3339
|
return command.option("--account <profile>", "OpenAccounts profile name for this target").option("--account-tool <tool>", "OpenAccounts tool id; defaults from provider for agents");
|
|
2706
3340
|
}
|
|
3341
|
+
function addMachineOptions(command) {
|
|
3342
|
+
return command.option("--machine <id>", "OpenMachines machine id to assign this loop to");
|
|
3343
|
+
}
|
|
2707
3344
|
function accountFromOpts(opts) {
|
|
2708
3345
|
if (!opts.account && opts.accountTool)
|
|
2709
3346
|
throw new Error("--account-tool requires --account");
|
|
@@ -2717,7 +3354,7 @@ function providerAuthProfileFromOpts(opts, provider) {
|
|
|
2717
3354
|
return opts.authProfile;
|
|
2718
3355
|
}
|
|
2719
3356
|
var create = program.command("create").description("create loops");
|
|
2720
|
-
addAccountOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell"))).action((name, opts) => {
|
|
3357
|
+
addAccountOptions(addMachineOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell")))).action((name, opts) => {
|
|
2721
3358
|
const store = new Store;
|
|
2722
3359
|
try {
|
|
2723
3360
|
const target = {
|
|
@@ -2734,7 +3371,7 @@ addAccountOptions(addScheduleOptions(create.command("command <name>").descriptio
|
|
|
2734
3371
|
store.close();
|
|
2735
3372
|
}
|
|
2736
3373
|
});
|
|
2737
|
-
addAccountOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe"))).action((name, opts) => {
|
|
3374
|
+
addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe")))).action((name, opts) => {
|
|
2738
3375
|
const provider = opts.provider;
|
|
2739
3376
|
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
|
|
2740
3377
|
throw new Error("unsupported provider");
|
|
@@ -2762,7 +3399,7 @@ addAccountOptions(addScheduleOptions(create.command("agent <name>").description(
|
|
|
2762
3399
|
store.close();
|
|
2763
3400
|
}
|
|
2764
3401
|
});
|
|
2765
|
-
addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name")).action((name, opts) => {
|
|
3402
|
+
addMachineOptions(addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name"))).action((name, opts) => {
|
|
2766
3403
|
const store = new Store;
|
|
2767
3404
|
try {
|
|
2768
3405
|
const workflow = store.requireWorkflow(opts.workflow);
|
|
@@ -2777,6 +3414,21 @@ addScheduleOptions(create.command("workflow <name>").description("schedule a sto
|
|
|
2777
3414
|
}
|
|
2778
3415
|
});
|
|
2779
3416
|
var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
|
|
3417
|
+
var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
|
|
3418
|
+
machines.command("list").alias("ls").description("list known machines").action(() => {
|
|
3419
|
+
const values = listOpenMachines();
|
|
3420
|
+
if (isJson())
|
|
3421
|
+
print(values);
|
|
3422
|
+
else {
|
|
3423
|
+
for (const machine of values) {
|
|
3424
|
+
const route = machine.local ? "local" : machine.route ?? "-";
|
|
3425
|
+
console.log(`${machine.id.padEnd(12)} ${route.padEnd(10)} workspace=${machine.workspacePath ?? "-"} host=${machine.hostname ?? "-"}`);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
});
|
|
3429
|
+
machines.command("show <id>").description("resolve a machine assignment").action((id) => {
|
|
3430
|
+
print(resolveLoopMachine(id));
|
|
3431
|
+
});
|
|
2780
3432
|
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) => {
|
|
2781
3433
|
const body = workflowBodyFromJson(JSON.parse(readFileSync2(file, "utf8")), opts.name);
|
|
2782
3434
|
const now = new Date().toISOString();
|
|
@@ -2834,7 +3486,7 @@ workflows.command("inspect <runId>").description("show a workflow run with steps
|
|
|
2834
3486
|
const events = store.listWorkflowEvents(run.id);
|
|
2835
3487
|
const value = {
|
|
2836
3488
|
workflowRun: publicWorkflowRun(run),
|
|
2837
|
-
steps: steps.map((step) => publicWorkflowStepRun(step
|
|
3489
|
+
steps: steps.map((step) => publicWorkflowStepRun(step)),
|
|
2838
3490
|
events: events.map(publicWorkflowEvent)
|
|
2839
3491
|
};
|
|
2840
3492
|
if (isJson())
|
|
@@ -2842,7 +3494,8 @@ workflows.command("inspect <runId>").description("show a workflow run with steps
|
|
|
2842
3494
|
else {
|
|
2843
3495
|
console.log(`${run.id} ${run.status} ${run.workflowName}`);
|
|
2844
3496
|
for (const step of steps) {
|
|
2845
|
-
|
|
3497
|
+
const publicStep = publicWorkflowStepRun(step);
|
|
3498
|
+
console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${publicStep.error ?? ""}`);
|
|
2846
3499
|
}
|
|
2847
3500
|
console.log(` events=${events.length}`);
|
|
2848
3501
|
}
|
|
@@ -2858,7 +3511,7 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
|
|
|
2858
3511
|
const run = store.listWorkflowRuns({ workflowId: workflow.id, limit: 1 })[0];
|
|
2859
3512
|
const steps = run ? store.listWorkflowStepRuns(run.id) : [];
|
|
2860
3513
|
const value = {
|
|
2861
|
-
result,
|
|
3514
|
+
result: publicExecutorResult(result),
|
|
2862
3515
|
workflowRun: run ? publicWorkflowRun(run) : undefined,
|
|
2863
3516
|
steps: steps.map((step) => publicWorkflowStepRun(step, opts.showOutput))
|
|
2864
3517
|
};
|
|
@@ -2867,7 +3520,8 @@ workflows.command("run <idOrName>").option("--show-output", "show step stdout/st
|
|
|
2867
3520
|
else {
|
|
2868
3521
|
console.log(`${run?.id ?? workflow.id} ${result.status}`);
|
|
2869
3522
|
for (const step of steps) {
|
|
2870
|
-
|
|
3523
|
+
const publicStep = publicWorkflowStepRun(step, opts.showOutput);
|
|
3524
|
+
console.log(` ${String(step.sequence).padStart(2, "0")} ${step.status.padEnd(10)} ${step.stepId} ${publicStep.error ?? ""}`);
|
|
2871
3525
|
if (opts.showOutput)
|
|
2872
3526
|
printTextOutput(step);
|
|
2873
3527
|
}
|
|
@@ -2945,7 +3599,8 @@ program.command("list").alias("ls").option("--status <status>", "filter by statu
|
|
|
2945
3599
|
print(loops.map(publicLoop));
|
|
2946
3600
|
else {
|
|
2947
3601
|
for (const loop of loops) {
|
|
2948
|
-
|
|
3602
|
+
const machine = loop.machine ? ` machine=${loop.machine.id}` : "";
|
|
3603
|
+
console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}`);
|
|
2949
3604
|
}
|
|
2950
3605
|
}
|
|
2951
3606
|
} finally {
|
|
@@ -3006,16 +3661,27 @@ program.command("run-now <idOrName>").option("--show-output", "show stdout/stder
|
|
|
3006
3661
|
const loop = store.requireLoop(idOrName);
|
|
3007
3662
|
const runnerId = `manual:${process.pid}`;
|
|
3008
3663
|
const now = new Date;
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3664
|
+
let scheduledFor = manualRunScheduledFor(loop, now);
|
|
3665
|
+
let source = manualRunSource(loop, scheduledFor, now);
|
|
3666
|
+
let shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
|
|
3667
|
+
let claim = store.claimRun(loop, scheduledFor, runnerId, now);
|
|
3668
|
+
if (!claim && shouldAdvance) {
|
|
3669
|
+
const existing = store.getRunBySlot(loop.id, scheduledFor);
|
|
3670
|
+
if (existing && existing.status !== "running") {
|
|
3671
|
+
scheduledFor = now.toISOString();
|
|
3672
|
+
source = "ad_hoc";
|
|
3673
|
+
shouldAdvance = false;
|
|
3674
|
+
claim = store.claimRun(loop, scheduledFor, runnerId, now);
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3012
3677
|
if (!claim)
|
|
3013
3678
|
throw new Error("could not claim manual run");
|
|
3014
3679
|
const run = await executeClaimedRun({ store, runnerId, loop: claim.loop, run: claim.run });
|
|
3015
3680
|
if (shouldAdvance) {
|
|
3016
3681
|
advanceLoop(store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
|
|
3017
3682
|
}
|
|
3018
|
-
|
|
3683
|
+
const value = { ...publicRun(run, opts.showOutput), runNow: { source, advancesLoop: shouldAdvance } };
|
|
3684
|
+
print(value, `${run.id} ${run.status} source=${source} slot=${run.scheduledFor}`);
|
|
3019
3685
|
if (!isJson() && opts.showOutput)
|
|
3020
3686
|
printTextOutput(run);
|
|
3021
3687
|
if (run.status !== "succeeded")
|
|
@@ -3044,9 +3710,9 @@ program.command("doctor").description("check local OpenLoops runtime dependencie
|
|
|
3044
3710
|
const marker = check.status === "ok" ? "ok" : check.status === "warn" ? "warn" : "fail";
|
|
3045
3711
|
console.log(`${marker.padEnd(4)} ${check.id.padEnd(22)} ${check.message}${check.detail ? ` (${check.detail})` : ""}`);
|
|
3046
3712
|
}
|
|
3047
|
-
if (!report.ok)
|
|
3048
|
-
process.exitCode = 1;
|
|
3049
3713
|
}
|
|
3714
|
+
if (!report.ok)
|
|
3715
|
+
process.exitCode = 1;
|
|
3050
3716
|
} finally {
|
|
3051
3717
|
store.close();
|
|
3052
3718
|
}
|