@hasna/loops 0.2.0 → 0.3.1

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