@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/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 AND step_id=$stepId AND status IN ('pending', 'running', 'failed', 'timed_out')`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
843
- this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
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
- this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
866
- exitCode: patch.exitCode,
867
- error: patch.error
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
- this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
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: now.toISOString(), $updated: now.toISOString() });
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 existsSync3, readFileSync as readFileSync2 } from "fs";
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 profileDir = primaryAccountDir(result.stdout);
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
- ...parseAccountExportLines(result.stdout),
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
- if (!account)
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: status === "succeeded" ? 0 : 1,
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
- const result = await executeTarget(targetWithStepAccount(step), {
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
- }, opts);
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
- return executeWorkflow(store, workflow, {
1674
- ...opts,
1675
- loop,
1676
- loopRun: run,
1677
- scheduledFor: run.scheduledFor,
1678
- idempotencyKey: `${loop.id}:${run.scheduledFor}`
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 runSlot(deps, loop, scheduledFor) {
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(1000, Math.min(60000, Math.floor(claim.loop.leaseMs / 3)));
2017
+ const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
1722
2018
  heartbeat = setInterval(() => {
1723
- deps.store.heartbeatRunLease(claim.run.id, deps.runnerId, claim.loop.leaseMs);
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 ?? ((loop2, run) => executeLoopTarget(deps.store, loop2, run)))(claim.loop, claim.run);
1728
- const finalRun = deps.store.finalizeRun(claim.run.id, {
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?.(claim.loop, err);
2040
+ deps.onError?.(deps.loop, err);
1746
2041
  const finishedAt = new Date;
1747
- const finalRun = deps.store.finalizeRun(claim.run.id, {
2042
+ return deps.store.finalizeRun(deps.run.id, {
1748
2043
  status: "failed",
1749
2044
  finishedAt: finishedAt.toISOString(),
1750
- durationMs: finishedAt.getTime() - new Date(claim.run.startedAt ?? claim.run.createdAt).getTime(),
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 existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
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 (!existsSync2(path))
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
- stopFlag = true;
2289
+ requestStop("daemon lease lost");
1964
2290
  throw new Error("daemon lease lost");
1965
2291
  }
1966
2292
  };
1967
2293
  const onSignal = () => {
1968
- stopFlag = true;
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: `${hostname2()}:${process.pid}:${leaseId}`,
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 executeLoop(loop, run);
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.2.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
- print(value, `${run?.id ?? workflow.id} ${result.status}`);
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 claim = store.claimRun(loop, new Date().toISOString(), `manual:${process.pid}`);
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 result = await executeLoopTarget(store, loop, claim.run);
2420
- const run = store.finalizeRun(claim.run.id, {
2421
- status: result.status,
2422
- finishedAt: result.finishedAt,
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 (!existsSync3(path)) {
2981
+ if (!existsSync4(path)) {
2473
2982
  console.log("");
2474
2983
  return;
2475
2984
  }