@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.
@@ -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);
@@ -1176,8 +1313,9 @@ import { hostname as hostname2 } from "os";
1176
1313
  import { spawn as spawn2 } from "child_process";
1177
1314
 
1178
1315
  // src/lib/executor.ts
1179
- import { spawn } from "child_process";
1316
+ import { spawn, spawnSync as spawnSync2 } from "child_process";
1180
1317
  import { once } from "events";
1318
+ import { existsSync as existsSync2 } from "fs";
1181
1319
 
1182
1320
  // src/lib/accounts.ts
1183
1321
  import { spawnSync } from "child_process";
@@ -1225,6 +1363,25 @@ function primaryAccountDir(output) {
1225
1363
  }
1226
1364
  return;
1227
1365
  }
1366
+ function accountDirEnvVar(tool) {
1367
+ switch (tool) {
1368
+ case "claude":
1369
+ return "CLAUDE_CONFIG_DIR";
1370
+ case "codex":
1371
+ case "codex-app":
1372
+ return "CODEX_HOME";
1373
+ case "cursor":
1374
+ return "CURSOR_CONFIG_DIR";
1375
+ case "opencode":
1376
+ return "OPENCODE_CONFIG_DIR";
1377
+ case "codewith":
1378
+ return "CODEWITH_HOME";
1379
+ case "aicopilot":
1380
+ return "AICOPILOT_CONFIG_DIR";
1381
+ default:
1382
+ return;
1383
+ }
1384
+ }
1228
1385
  function resolveAccountEnv(account, toolHint, env) {
1229
1386
  if (!account)
1230
1387
  return {};
@@ -1243,13 +1400,14 @@ function resolveAccountEnv(account, toolHint, env) {
1243
1400
  const stderr = result.stderr.trim();
1244
1401
  throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
1245
1402
  }
1246
- const profileDir = primaryAccountDir(result.stdout);
1403
+ const accountEnv = parseAccountExportLines(result.stdout);
1404
+ const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
1247
1405
  if (!profileDir)
1248
1406
  throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
1249
1407
  if (!existsSync(profileDir))
1250
1408
  throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
1251
1409
  return {
1252
- ...parseAccountExportLines(result.stdout),
1410
+ ...accountEnv,
1253
1411
  LOOPS_ACCOUNT_PROFILE: account.profile,
1254
1412
  LOOPS_ACCOUNT_TOOL: tool
1255
1413
  };
@@ -1438,6 +1596,27 @@ function executionEnv(spec, metadata, opts) {
1438
1596
  env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1439
1597
  return env;
1440
1598
  }
1599
+ function commandExists(command, env) {
1600
+ if (command.includes("/") && existsSync2(command))
1601
+ return true;
1602
+ const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], {
1603
+ env,
1604
+ stdio: "ignore"
1605
+ });
1606
+ return (result.status ?? 1) === 0;
1607
+ }
1608
+ function preflightTarget(target, metadata = {}, opts = {}) {
1609
+ const spec = commandSpec(target);
1610
+ const env = executionEnv(spec, metadata, opts);
1611
+ if (!spec.shell && !commandExists(spec.command, env)) {
1612
+ throw new Error(`Executable not found in PATH: ${spec.command}`);
1613
+ }
1614
+ return {
1615
+ command: spec.command,
1616
+ accountProfile: spec.account?.profile,
1617
+ accountTool: spec.accountTool
1618
+ };
1619
+ }
1441
1620
  async function executeTarget(target, metadata = {}, opts = {}) {
1442
1621
  const spec = commandSpec(target);
1443
1622
  const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
@@ -1448,6 +1627,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1448
1627
  let exitCode;
1449
1628
  let error;
1450
1629
  const env = executionEnv(spec, metadata, opts);
1630
+ if (!spec.shell && !commandExists(spec.command, env)) {
1631
+ return {
1632
+ status: "failed",
1633
+ stdout: "",
1634
+ stderr: "",
1635
+ error: `Executable not found in PATH: ${spec.command}`,
1636
+ startedAt,
1637
+ finishedAt: nowIso(),
1638
+ durationMs: 0
1639
+ };
1640
+ }
1451
1641
  const child = spawn(spec.command, spec.args, {
1452
1642
  cwd: spec.cwd,
1453
1643
  env,
@@ -1455,6 +1645,16 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1455
1645
  detached: true,
1456
1646
  stdio: ["ignore", "pipe", "pipe"]
1457
1647
  });
1648
+ if (child.pid)
1649
+ opts.onSpawn?.(child.pid);
1650
+ const abortHandler = () => {
1651
+ error = "cancelled";
1652
+ if (child.pid)
1653
+ killProcessGroup(child.pid);
1654
+ };
1655
+ if (opts.signal?.aborted)
1656
+ abortHandler();
1657
+ opts.signal?.addEventListener("abort", abortHandler, { once: true });
1458
1658
  child.stdout.on("data", (chunk) => {
1459
1659
  stdout = appendBounded(stdout, chunk, maxOutputBytes);
1460
1660
  });
@@ -1477,6 +1677,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1477
1677
  error = err instanceof Error ? err.message : String(err);
1478
1678
  } finally {
1479
1679
  clearTimeout(timer);
1680
+ opts.signal?.removeEventListener("abort", abortHandler);
1480
1681
  }
1481
1682
  const finishedAt = nowIso();
1482
1683
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
@@ -1532,14 +1733,16 @@ async function executeLoop(loop, run, opts = {}) {
1532
1733
  // src/lib/workflow-runner.ts
1533
1734
  function targetWithStepAccount(step) {
1534
1735
  const account = step.account ?? step.target.account;
1535
- if (!account)
1736
+ const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
1737
+ if (!account && timeoutMs === step.target.timeoutMs)
1536
1738
  return step.target;
1537
- return { ...step.target, account };
1739
+ return { ...step.target, account, timeoutMs };
1538
1740
  }
1539
1741
  function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
1742
+ const executorStatus = status === "succeeded" ? "succeeded" : status === "timed_out" ? "timed_out" : "failed";
1540
1743
  return {
1541
- status,
1542
- exitCode: status === "succeeded" ? 0 : 1,
1744
+ status: executorStatus,
1745
+ exitCode: executorStatus === "succeeded" ? 0 : 1,
1543
1746
  stdout,
1544
1747
  stderr: "",
1545
1748
  error,
@@ -1557,7 +1760,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
1557
1760
  idempotencyKey: opts.idempotencyKey
1558
1761
  });
1559
1762
  const startedAt = run.startedAt ?? nowIso();
1560
- if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out") {
1763
+ if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
1561
1764
  const steps2 = store.listWorkflowStepRuns(run.id);
1562
1765
  return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
1563
1766
  }
@@ -1566,8 +1769,19 @@ async function executeWorkflow(store, workflow, opts = {}) {
1566
1769
  let blockingError;
1567
1770
  let terminalStatus = "succeeded";
1568
1771
  for (const step of ordered) {
1772
+ if (store.isWorkflowRunTerminal(run.id)) {
1773
+ terminalStatus = store.requireWorkflowRun(run.id).status;
1774
+ blockingError = "workflow run was cancelled";
1775
+ break;
1776
+ }
1777
+ const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
1778
+ if (pendingTimeout) {
1779
+ terminalStatus = "timed_out";
1780
+ blockingError = pendingTimeout;
1781
+ break;
1782
+ }
1569
1783
  const existing = store.getWorkflowStepRun(run.id, step.id);
1570
- if (existing?.status === "succeeded" || existing?.status === "skipped")
1784
+ if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
1571
1785
  continue;
1572
1786
  const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
1573
1787
  const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
@@ -1582,8 +1796,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
1582
1796
  terminalStatus = "failed";
1583
1797
  continue;
1584
1798
  }
1585
- store.startWorkflowStepRun(run.id, step.id);
1586
- const result = await executeTarget(targetWithStepAccount(step), {
1799
+ const startedStep = store.startWorkflowStepRun(run.id, step.id);
1800
+ if (startedStep.status !== "running") {
1801
+ terminalStatus = "failed";
1802
+ blockingError = `step ${step.id} could not start because workflow is no longer running`;
1803
+ break;
1804
+ }
1805
+ const metadata = {
1587
1806
  loopId: opts.loop?.id,
1588
1807
  loopName: opts.loop?.name,
1589
1808
  runId: opts.loopRun?.id,
@@ -1592,7 +1811,51 @@ async function executeWorkflow(store, workflow, opts = {}) {
1592
1811
  workflowName: workflow.name,
1593
1812
  workflowRunId: run.id,
1594
1813
  workflowStepId: step.id
1595
- }, opts);
1814
+ };
1815
+ let result;
1816
+ const controller = new AbortController;
1817
+ const externalAbort = () => controller.abort();
1818
+ if (opts.signal?.aborted)
1819
+ controller.abort();
1820
+ opts.signal?.addEventListener("abort", externalAbort, { once: true });
1821
+ const cancelTimer = setInterval(() => {
1822
+ if (store.getWorkflowRun(run.id)?.status === "cancelled")
1823
+ controller.abort();
1824
+ }, opts.cancelPollMs ?? 500);
1825
+ cancelTimer.unref();
1826
+ try {
1827
+ result = await executeTarget(targetWithStepAccount(step), metadata, {
1828
+ ...opts,
1829
+ signal: controller.signal,
1830
+ onSpawn: (pid) => {
1831
+ store.markWorkflowStepPid(run.id, step.id, pid);
1832
+ opts.onSpawn?.(pid);
1833
+ }
1834
+ });
1835
+ } catch (error) {
1836
+ const finishedAt2 = nowIso();
1837
+ result = {
1838
+ status: "failed",
1839
+ stdout: "",
1840
+ stderr: "",
1841
+ error: error instanceof Error ? error.message : String(error),
1842
+ startedAt: startedStep.startedAt ?? finishedAt2,
1843
+ finishedAt: finishedAt2,
1844
+ durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
1845
+ };
1846
+ } finally {
1847
+ clearInterval(cancelTimer);
1848
+ opts.signal?.removeEventListener("abort", externalAbort);
1849
+ }
1850
+ const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
1851
+ if (timeoutMessage && result.status === "failed") {
1852
+ result = { ...result, status: "timed_out", error: timeoutMessage };
1853
+ }
1854
+ if (store.isWorkflowRunTerminal(run.id)) {
1855
+ terminalStatus = store.requireWorkflowRun(run.id).status;
1856
+ blockingError = "workflow run was cancelled";
1857
+ break;
1858
+ }
1596
1859
  store.finalizeWorkflowStepRun(run.id, step.id, {
1597
1860
  status: result.status,
1598
1861
  finishedAt: result.finishedAt,
@@ -1617,6 +1880,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
1617
1880
  }
1618
1881
  }
1619
1882
  const finishedAt = nowIso();
1883
+ if (store.isWorkflowRunTerminal(run.id)) {
1884
+ const terminalRun = store.requireWorkflowRun(run.id);
1885
+ const steps2 = store.listWorkflowStepRuns(run.id);
1886
+ return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
1887
+ }
1620
1888
  const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
1621
1889
  finishedAt,
1622
1890
  durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
@@ -1625,20 +1893,59 @@ async function executeWorkflow(store, workflow, opts = {}) {
1625
1893
  const steps = store.listWorkflowStepRuns(run.id);
1626
1894
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
1627
1895
  }
1896
+ function preflightWorkflow(workflow, opts = {}) {
1897
+ return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
1898
+ workflowId: workflow.id,
1899
+ workflowName: workflow.name,
1900
+ workflowStepId: step.id
1901
+ }, opts));
1902
+ }
1628
1903
  async function executeLoopTarget(store, loop, run, opts = {}) {
1629
1904
  if (loop.target.type !== "workflow")
1630
1905
  return executeLoop(loop, run, opts);
1631
1906
  const workflow = store.requireWorkflow(loop.target.workflowId);
1632
- return executeWorkflow(store, workflow, {
1633
- ...opts,
1634
- loop,
1635
- loopRun: run,
1636
- scheduledFor: run.scheduledFor,
1637
- idempotencyKey: `${loop.id}:${run.scheduledFor}`
1638
- });
1907
+ const controller = loop.target.timeoutMs ? new AbortController : undefined;
1908
+ let workflowTimedOut = false;
1909
+ const externalAbort = () => controller?.abort();
1910
+ if (controller && opts.signal?.aborted)
1911
+ controller.abort();
1912
+ if (controller)
1913
+ opts.signal?.addEventListener("abort", externalAbort, { once: true });
1914
+ const timer = controller ? setTimeout(() => {
1915
+ workflowTimedOut = true;
1916
+ controller.abort();
1917
+ }, loop.target.timeoutMs) : undefined;
1918
+ timer?.unref();
1919
+ try {
1920
+ return await executeWorkflow(store, workflow, {
1921
+ ...opts,
1922
+ signal: controller?.signal ?? opts.signal,
1923
+ signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
1924
+ loop,
1925
+ loopRun: run,
1926
+ scheduledFor: run.scheduledFor,
1927
+ idempotencyKey: `${loop.id}:${run.scheduledFor}:attempt:${run.attempt}`
1928
+ });
1929
+ } finally {
1930
+ if (timer)
1931
+ clearTimeout(timer);
1932
+ if (controller)
1933
+ opts.signal?.removeEventListener("abort", externalAbort);
1934
+ }
1639
1935
  }
1640
1936
 
1641
1937
  // src/lib/scheduler.ts
1938
+ function manualRunScheduledFor(loop, now = new Date) {
1939
+ if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
1940
+ return loop.retryScheduledFor ?? loop.nextRunAt;
1941
+ }
1942
+ return now.toISOString();
1943
+ }
1944
+ function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
1945
+ if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
1946
+ return false;
1947
+ return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
1948
+ }
1642
1949
  function nextAfterRetry(loop, now) {
1643
1950
  return new Date(now.getTime() + loop.retryDelayMs).toISOString();
1644
1951
  }
@@ -1664,27 +1971,18 @@ function advanceLoop(store, loop, run, finishedAt, succeeded) {
1664
1971
  retryScheduledFor: undefined
1665
1972
  });
1666
1973
  }
1667
- async function runSlot(deps, loop, scheduledFor) {
1668
- const now = deps.now?.() ?? new Date;
1669
- if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
1670
- const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
1671
- advanceLoop(deps.store, loop, skipped, now, true);
1672
- deps.onRun?.(skipped);
1673
- return skipped;
1674
- }
1675
- const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
1676
- if (!claim)
1677
- return;
1678
- deps.onRun?.(claim.run);
1974
+ async function executeClaimedRun(deps) {
1679
1975
  let heartbeat;
1680
- const heartbeatEveryMs = Math.max(1000, Math.min(60000, Math.floor(claim.loop.leaseMs / 3)));
1976
+ const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
1681
1977
  heartbeat = setInterval(() => {
1682
- deps.store.heartbeatRunLease(claim.run.id, deps.runnerId, claim.loop.leaseMs);
1978
+ deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
1683
1979
  }, heartbeatEveryMs);
1684
1980
  heartbeat.unref();
1685
1981
  try {
1686
- const result = await (deps.execute ?? ((loop2, run) => executeLoopTarget(deps.store, loop2, run)))(claim.loop, claim.run);
1687
- const finalRun = deps.store.finalizeRun(claim.run.id, {
1982
+ const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
1983
+ onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
1984
+ })))(deps.loop, deps.run);
1985
+ return deps.store.finalizeRun(deps.run.id, {
1688
1986
  status: result.status,
1689
1987
  finishedAt: result.finishedAt,
1690
1988
  durationMs: result.durationMs,
@@ -1697,16 +1995,13 @@ async function runSlot(deps, loop, scheduledFor) {
1697
1995
  claimedBy: deps.runnerId,
1698
1996
  now: deps.now?.() ?? new Date(result.finishedAt)
1699
1997
  });
1700
- advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), finalRun.status === "succeeded");
1701
- deps.onRun?.(finalRun);
1702
- return finalRun;
1703
1998
  } catch (err) {
1704
- deps.onError?.(claim.loop, err);
1999
+ deps.onError?.(deps.loop, err);
1705
2000
  const finishedAt = new Date;
1706
- const finalRun = deps.store.finalizeRun(claim.run.id, {
2001
+ return deps.store.finalizeRun(deps.run.id, {
1707
2002
  status: "failed",
1708
2003
  finishedAt: finishedAt.toISOString(),
1709
- durationMs: finishedAt.getTime() - new Date(claim.run.startedAt ?? claim.run.createdAt).getTime(),
2004
+ durationMs: finishedAt.getTime() - new Date(deps.run.startedAt ?? deps.run.createdAt).getTime(),
1710
2005
  stdout: "",
1711
2006
  stderr: "",
1712
2007
  error: err instanceof Error ? err.message : String(err)
@@ -1714,14 +2009,36 @@ async function runSlot(deps, loop, scheduledFor) {
1714
2009
  claimedBy: deps.runnerId,
1715
2010
  now: deps.now?.() ?? finishedAt
1716
2011
  });
1717
- advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
1718
- deps.onRun?.(finalRun);
1719
- return finalRun;
1720
2012
  } finally {
1721
2013
  if (heartbeat)
1722
2014
  clearInterval(heartbeat);
1723
2015
  }
1724
2016
  }
2017
+ async function runSlot(deps, loop, scheduledFor) {
2018
+ const now = deps.now?.() ?? new Date;
2019
+ if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
2020
+ const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
2021
+ advanceLoop(deps.store, loop, skipped, now, true);
2022
+ deps.onRun?.(skipped);
2023
+ return skipped;
2024
+ }
2025
+ const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
2026
+ if (!claim)
2027
+ return;
2028
+ deps.onRun?.(claim.run);
2029
+ const finalRun = await executeClaimedRun({
2030
+ store: deps.store,
2031
+ runnerId: deps.runnerId,
2032
+ loop: claim.loop,
2033
+ run: claim.run,
2034
+ now: deps.now,
2035
+ execute: deps.execute,
2036
+ onError: deps.onError
2037
+ });
2038
+ advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
2039
+ deps.onRun?.(finalRun);
2040
+ return finalRun;
2041
+ }
1725
2042
  async function tick(deps) {
1726
2043
  const now = deps.now?.() ?? new Date;
1727
2044
  const recovered = deps.store.recoverExpiredRunLeases(now);
@@ -1754,7 +2071,7 @@ async function tick(deps) {
1754
2071
  }
1755
2072
 
1756
2073
  // src/daemon/control.ts
1757
- import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
2074
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
1758
2075
  import { hostname } from "os";
1759
2076
  import { dirname as dirname2 } from "path";
1760
2077
 
@@ -1782,7 +2099,7 @@ async function runLoop(opts) {
1782
2099
 
1783
2100
  // src/daemon/control.ts
1784
2101
  function readPid(path = pidFilePath()) {
1785
- if (!existsSync2(path))
2102
+ if (!existsSync3(path))
1786
2103
  return;
1787
2104
  try {
1788
2105
  const pid = Number(readFileSync(path, "utf8").trim());
@@ -1897,6 +2214,7 @@ async function runDaemon(opts = {}) {
1897
2214
  const ownStore = !opts.store;
1898
2215
  const store = opts.store ?? new Store;
1899
2216
  const leaseId = genId();
2217
+ const runnerId = `${hostname2()}:${process.pid}:${leaseId}`;
1900
2218
  const intervalMs = opts.intervalMs ?? intervalFromEnv() ?? 1000;
1901
2219
  const leaseTtlMs = opts.leaseTtlMs ?? Math.max(60000, intervalMs * 10);
1902
2220
  const log = opts.log ?? ((message) => console.error(`[loops-daemon] ${message}`));
@@ -1912,18 +2230,28 @@ async function runDaemon(opts = {}) {
1912
2230
  log(`started pid=${process.pid} interval=${intervalMs}ms lease=${leaseId}`);
1913
2231
  let stopFlag = false;
1914
2232
  let leaseLost = false;
2233
+ const runAbort = new AbortController;
2234
+ const requestStop = (message) => {
2235
+ stopFlag = true;
2236
+ if (!runAbort.signal.aborted)
2237
+ runAbort.abort();
2238
+ if (message)
2239
+ log(message);
2240
+ };
1915
2241
  const ensureLease = () => {
1916
2242
  const current = store.heartbeatDaemonLease(leaseId, leaseTtlMs);
1917
2243
  if (!current || current.id !== leaseId) {
1918
2244
  leaseLost = true;
1919
- stopFlag = true;
2245
+ requestStop("daemon lease lost");
1920
2246
  throw new Error("daemon lease lost");
1921
2247
  }
1922
2248
  };
1923
2249
  const onSignal = () => {
1924
- stopFlag = true;
1925
- log("stop signal received");
2250
+ requestStop("stop signal received");
1926
2251
  };
2252
+ if (opts.signal?.aborted)
2253
+ onSignal();
2254
+ opts.signal?.addEventListener("abort", onSignal, { once: true });
1927
2255
  process.on("SIGINT", onSignal);
1928
2256
  process.on("SIGTERM", onSignal);
1929
2257
  try {
@@ -1936,7 +2264,7 @@ async function runDaemon(opts = {}) {
1936
2264
  ensureLease();
1937
2265
  const result = await tick({
1938
2266
  store,
1939
- runnerId: `${hostname2()}:${process.pid}:${leaseId}`,
2267
+ runnerId,
1940
2268
  execute: async (loop, run) => {
1941
2269
  const heartbeatMs = Math.max(1000, Math.floor(leaseTtlMs / 3));
1942
2270
  const timer = setInterval(() => {
@@ -1948,7 +2276,10 @@ async function runDaemon(opts = {}) {
1948
2276
  }, heartbeatMs);
1949
2277
  timer.unref();
1950
2278
  try {
1951
- const result2 = await executeLoop(loop, run);
2279
+ const result2 = await executeLoopTarget(store, loop, run, {
2280
+ signal: runAbort.signal,
2281
+ onSpawn: (pid) => store.markRunPid(run.id, pid, runnerId)
2282
+ });
1952
2283
  if (leaseLost)
1953
2284
  throw new Error("daemon lease lost during run");
1954
2285
  return result2;
@@ -1965,6 +2296,7 @@ async function runDaemon(opts = {}) {
1965
2296
  }
1966
2297
  });
1967
2298
  } finally {
2299
+ opts.signal?.removeEventListener("abort", onSignal);
1968
2300
  process.off("SIGINT", onSignal);
1969
2301
  process.off("SIGTERM", onSignal);
1970
2302
  store.releaseDaemonLease(leaseId);
@@ -2000,6 +2332,7 @@ async function startDaemon(opts) {
2000
2332
 
2001
2333
  // src/daemon/install.ts
2002
2334
  import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
2335
+ import { spawnSync as spawnSync3 } from "child_process";
2003
2336
  import { dirname as dirname3 } from "path";
2004
2337
  function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
2005
2338
  const command = [execPath, cliEntry, ...args].join(" ");
@@ -2061,10 +2394,25 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
2061
2394
  }
2062
2395
  throw new Error(`startup install is not implemented for ${process.platform}`);
2063
2396
  }
2397
+ function enableStartup(result) {
2398
+ const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
2399
+ return commands.map((command) => {
2400
+ const run = spawnSync3("sh", ["-c", command], {
2401
+ encoding: "utf8",
2402
+ stdio: ["ignore", "pipe", "pipe"]
2403
+ });
2404
+ return {
2405
+ command,
2406
+ status: run.status,
2407
+ stdout: run.stdout.trim(),
2408
+ stderr: run.stderr.trim()
2409
+ };
2410
+ });
2411
+ }
2064
2412
 
2065
2413
  // src/daemon/index.ts
2066
2414
  var program = new Command;
2067
- program.name("loops-daemon").description("OpenLoops daemon helper").version("0.1.0");
2415
+ program.name("loops-daemon").description("OpenLoops daemon helper").version("0.3.0");
2068
2416
  program.command("run").option("--interval-ms <ms>", "tick interval", (value) => Number(value)).action(async (opts) => runDaemon({ intervalMs: opts.intervalMs }));
2069
2417
  program.command("start").action(async () => {
2070
2418
  const result = await startDaemon({ cliEntry: process.argv[1] ?? "loops-daemon", args: ["run"] });