@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.
package/dist/sdk/index.js CHANGED
@@ -247,6 +247,9 @@ function validateTarget(value, label) {
247
247
  assertObject(value, label);
248
248
  if (value.type === "command") {
249
249
  assertString(value.command, `${label}.command`);
250
+ if (value.shell !== true && /\s/.test(value.command.trim())) {
251
+ throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
252
+ }
250
253
  return value;
251
254
  }
252
255
  if (value.type === "agent") {
@@ -412,6 +415,7 @@ function rowToWorkflowStepRun(row) {
412
415
  startedAt: row.started_at ?? undefined,
413
416
  finishedAt: row.finished_at ?? undefined,
414
417
  exitCode: row.exit_code ?? undefined,
418
+ pid: row.pid ?? undefined,
415
419
  durationMs: row.duration_ms ?? undefined,
416
420
  stdout: row.stdout ?? undefined,
417
421
  stderr: row.stderr ?? undefined,
@@ -433,6 +437,14 @@ function rowToWorkflowEvent(row) {
433
437
  createdAt: row.created_at
434
438
  };
435
439
  }
440
+ function isProcessAlive(pid) {
441
+ try {
442
+ process.kill(pid, 0);
443
+ return true;
444
+ } catch {
445
+ return false;
446
+ }
447
+ }
436
448
  function rowToLease(row) {
437
449
  return {
438
450
  id: row.id,
@@ -565,6 +577,7 @@ class Store {
565
577
  started_at TEXT,
566
578
  finished_at TEXT,
567
579
  exit_code INTEGER,
580
+ pid INTEGER,
568
581
  duration_ms INTEGER,
569
582
  stdout TEXT,
570
583
  stderr TEXT,
@@ -590,6 +603,9 @@ class Store {
590
603
  );
591
604
  CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
592
605
  `);
606
+ try {
607
+ this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
608
+ } catch {}
593
609
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
594
610
  }
595
611
  createLoop(input, from = new Date) {
@@ -770,8 +786,8 @@ class Store {
770
786
  input.workflow.steps.forEach((step, sequence) => {
771
787
  const account = step.account ?? step.target.account;
772
788
  this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
773
- exit_code, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
774
- VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL,
789
+ exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
790
+ VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
775
791
  $accountProfile, $accountTool, $created, $updated)`).run({
776
792
  $id: genId(),
777
793
  $workflowRunId: runId,
@@ -812,6 +828,12 @@ class Store {
812
828
  const row = this.db.query("SELECT * FROM workflow_runs WHERE id = ?").get(id);
813
829
  return row ? rowToWorkflowRun(row) : undefined;
814
830
  }
831
+ requireWorkflowRun(id) {
832
+ const run = this.getWorkflowRun(id);
833
+ if (!run)
834
+ throw new Error(`workflow run not found: ${id}`);
835
+ return run;
836
+ }
815
837
  listWorkflowRuns(opts = {}) {
816
838
  const limit = opts.limit ?? 100;
817
839
  let rows;
@@ -832,23 +854,67 @@ class Store {
832
854
  const row = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? AND step_id = ?").get(workflowRunId, stepId);
833
855
  return row ? rowToWorkflowStepRun(row) : undefined;
834
856
  }
857
+ isWorkflowRunTerminal(workflowRunId) {
858
+ const run = this.getWorkflowRun(workflowRunId);
859
+ return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
860
+ }
835
861
  startWorkflowStepRun(workflowRunId, stepId) {
836
862
  const now = nowIso();
837
- this.db.query(`UPDATE workflow_step_runs
863
+ const res = this.db.query(`UPDATE workflow_step_runs
838
864
  SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
839
- stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
840
- 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 });
841
- this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
865
+ pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
866
+ WHERE workflow_run_id=$workflowRunId
867
+ AND step_id=$stepId
868
+ AND status IN ('pending', 'failed', 'timed_out')
869
+ AND EXISTS (
870
+ SELECT 1 FROM workflow_runs
871
+ WHERE id=$workflowRunId AND status='running'
872
+ )`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
842
873
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
843
874
  if (!run)
844
875
  throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
876
+ if (res.changes !== 1) {
877
+ throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
878
+ }
879
+ this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
880
+ return run;
881
+ }
882
+ markWorkflowStepPid(workflowRunId, stepId, pid) {
883
+ const now = nowIso();
884
+ this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
885
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $pid: pid, $updated: now });
886
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
887
+ if (!run)
888
+ throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
845
889
  return run;
846
890
  }
891
+ recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
892
+ const now = nowIso();
893
+ const before = this.listWorkflowStepRuns(workflowRunId).filter((step) => step.status === "running");
894
+ const live = before.filter((step) => step.pid !== undefined && isProcessAlive(step.pid));
895
+ if (live.length > 0) {
896
+ throw new Error(`cannot recover workflow run while step processes are still alive: ${live.map((step) => `${step.stepId} pid=${step.pid}`).join(", ")}`);
897
+ }
898
+ this.db.query(`UPDATE workflow_step_runs
899
+ SET status='pending', started_at=NULL, finished_at=NULL, exit_code=NULL, pid=NULL, duration_ms=NULL,
900
+ stdout=NULL, stderr=NULL, error=$reason, updated_at=$updated
901
+ WHERE workflow_run_id=$workflowRunId AND status='running'`).run({ $workflowRunId: workflowRunId, $reason: reason, $updated: now });
902
+ if (before.length > 0) {
903
+ this.appendWorkflowEvent(workflowRunId, "recovered", undefined, {
904
+ reason,
905
+ recoveredSteps: before.map((step) => step.stepId)
906
+ });
907
+ }
908
+ return {
909
+ run: this.requireWorkflowRun(workflowRunId),
910
+ recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
911
+ };
912
+ }
847
913
  finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
848
914
  const finishedAt = patch.finishedAt ?? nowIso();
849
- this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
850
- stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
851
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({
915
+ const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
916
+ pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
917
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({
852
918
  $workflowRunId: workflowRunId,
853
919
  $stepId: stepId,
854
920
  $status: patch.status,
@@ -860,10 +926,12 @@ class Store {
860
926
  $error: patch.error ?? null,
861
927
  $updated: finishedAt
862
928
  });
863
- this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
864
- exitCode: patch.exitCode,
865
- error: patch.error
866
- });
929
+ if (res.changes === 1) {
930
+ this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
931
+ exitCode: patch.exitCode,
932
+ error: patch.error
933
+ });
934
+ }
867
935
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
868
936
  if (!run)
869
937
  throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
@@ -871,9 +939,10 @@ class Store {
871
939
  }
872
940
  skipWorkflowStepRun(workflowRunId, stepId, reason) {
873
941
  const now = nowIso();
874
- this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, error=$error, updated_at=$updated
875
- WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $finished: now, $error: reason, $updated: now });
876
- this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
942
+ const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
943
+ 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 });
944
+ if (res.changes === 1)
945
+ this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
877
946
  const run = this.getWorkflowStepRun(workflowRunId, stepId);
878
947
  if (!run)
879
948
  throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
@@ -881,8 +950,8 @@ class Store {
881
950
  }
882
951
  finalizeWorkflowRun(workflowRunId, status, patch = {}) {
883
952
  const finishedAt = patch.finishedAt ?? nowIso();
884
- this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
885
- WHERE id=$id`).run({
953
+ const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
954
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({
886
955
  $id: workflowRunId,
887
956
  $status: status,
888
957
  $finished: finishedAt,
@@ -890,12 +959,36 @@ class Store {
890
959
  $error: patch.error ?? null,
891
960
  $updated: finishedAt
892
961
  });
893
- this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
894
962
  const run = this.getWorkflowRun(workflowRunId);
895
963
  if (!run)
896
964
  throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
965
+ if (res.changes === 1)
966
+ this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
897
967
  return run;
898
968
  }
969
+ cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
970
+ const now = nowIso();
971
+ this.db.exec("BEGIN IMMEDIATE");
972
+ try {
973
+ const run = this.requireWorkflowRun(workflowRunId);
974
+ if (!["succeeded", "failed", "timed_out", "cancelled"].includes(run.status)) {
975
+ this.db.query(`UPDATE workflow_runs
976
+ SET status='cancelled', finished_at=$finished, error=$reason, updated_at=$updated
977
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRunId, $finished: now, $reason: reason, $updated: now });
978
+ this.db.query(`UPDATE workflow_step_runs
979
+ SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
980
+ WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
981
+ this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
982
+ }
983
+ this.db.exec("COMMIT");
984
+ return this.requireWorkflowRun(workflowRunId);
985
+ } catch (error) {
986
+ try {
987
+ this.db.exec("ROLLBACK");
988
+ } catch {}
989
+ throw error;
990
+ }
991
+ }
899
992
  appendWorkflowEvent(workflowRunId, eventType, stepId, payload) {
900
993
  const now = nowIso();
901
994
  const current = this.db.query("SELECT MAX(sequence) AS sequence FROM workflow_events WHERE workflow_run_id = ?").get(workflowRunId);
@@ -924,6 +1017,24 @@ class Store {
924
1017
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
925
1018
  return (row?.count ?? 0) > 0;
926
1019
  }
1020
+ markRunPid(id, pid, claimedBy) {
1021
+ const now = nowIso();
1022
+ const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
1023
+ 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 });
1024
+ if (res.changes !== 1)
1025
+ return;
1026
+ return this.getRun(id);
1027
+ }
1028
+ hasLiveWorkflowStepProcesses(loopRunId) {
1029
+ const liveWorkflowSteps = this.db.query(`SELECT wr.id AS workflow_run_id, wsr.step_id AS step_id, wsr.pid AS pid
1030
+ FROM workflow_runs wr
1031
+ JOIN workflow_step_runs wsr ON wsr.workflow_run_id = wr.id
1032
+ WHERE wr.loop_run_id = ?
1033
+ AND wr.status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
1034
+ AND wsr.status = 'running'
1035
+ AND wsr.pid IS NOT NULL`).all(loopRunId);
1036
+ return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
1037
+ }
927
1038
  createSkippedRun(loop, scheduledFor, reason) {
928
1039
  const now = nowIso();
929
1040
  const run = {
@@ -971,6 +1082,14 @@ class Store {
971
1082
  const existing = this.getRunBySlot(loop.id, scheduledFor);
972
1083
  if (existing) {
973
1084
  if (existing.status === "running") {
1085
+ if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && existing.pid && isProcessAlive(existing.pid)) {
1086
+ this.db.exec("COMMIT");
1087
+ return;
1088
+ }
1089
+ if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && this.hasLiveWorkflowStepProcesses(existing.id)) {
1090
+ this.db.exec("COMMIT");
1091
+ return;
1092
+ }
974
1093
  const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
975
1094
  claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, exit_code=NULL,
976
1095
  duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
@@ -1093,8 +1212,26 @@ class Store {
1093
1212
  const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
1094
1213
  const recovered = [];
1095
1214
  for (const row of rows) {
1215
+ if (row.pid && isProcessAlive(row.pid))
1216
+ continue;
1217
+ if (this.hasLiveWorkflowStepProcesses(row.id))
1218
+ continue;
1219
+ const finished = now.toISOString();
1096
1220
  this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
1097
- error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: now.toISOString(), $updated: now.toISOString() });
1221
+ error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: finished, $updated: finished });
1222
+ 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);
1223
+ for (const workflowRow of workflowRows) {
1224
+ this.db.query(`UPDATE workflow_runs
1225
+ SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
1226
+ WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRow.id, $finished: finished, $updated: finished });
1227
+ this.db.query(`UPDATE workflow_step_runs
1228
+ SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
1229
+ WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRow.id, $finished: finished, $updated: finished });
1230
+ this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
1231
+ error: "parent loop run lease expired before completion",
1232
+ loopRunId: row.id
1233
+ });
1234
+ }
1098
1235
  const run = this.getRun(row.id);
1099
1236
  if (run)
1100
1237
  recovered.push(run);
@@ -1215,6 +1352,25 @@ function primaryAccountDir(output) {
1215
1352
  }
1216
1353
  return;
1217
1354
  }
1355
+ function accountDirEnvVar(tool) {
1356
+ switch (tool) {
1357
+ case "claude":
1358
+ return "CLAUDE_CONFIG_DIR";
1359
+ case "codex":
1360
+ case "codex-app":
1361
+ return "CODEX_HOME";
1362
+ case "cursor":
1363
+ return "CURSOR_CONFIG_DIR";
1364
+ case "opencode":
1365
+ return "OPENCODE_CONFIG_DIR";
1366
+ case "codewith":
1367
+ return "CODEWITH_HOME";
1368
+ case "aicopilot":
1369
+ return "AICOPILOT_CONFIG_DIR";
1370
+ default:
1371
+ return;
1372
+ }
1373
+ }
1218
1374
  function resolveAccountEnv(account, toolHint, env) {
1219
1375
  if (!account)
1220
1376
  return {};
@@ -1233,18 +1389,78 @@ function resolveAccountEnv(account, toolHint, env) {
1233
1389
  const stderr = result.stderr.trim();
1234
1390
  throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
1235
1391
  }
1236
- const profileDir = primaryAccountDir(result.stdout);
1392
+ const accountEnv = parseAccountExportLines(result.stdout);
1393
+ const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
1237
1394
  if (!profileDir)
1238
1395
  throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
1239
1396
  if (!existsSync(profileDir))
1240
1397
  throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
1241
1398
  return {
1242
- ...parseAccountExportLines(result.stdout),
1399
+ ...accountEnv,
1243
1400
  LOOPS_ACCOUNT_PROFILE: account.profile,
1244
1401
  LOOPS_ACCOUNT_TOOL: tool
1245
1402
  };
1246
1403
  }
1247
1404
 
1405
+ // src/lib/env.ts
1406
+ import { accessSync, constants } from "fs";
1407
+ import { homedir as homedir2 } from "os";
1408
+ import { delimiter, join as join2 } from "path";
1409
+ function compactPathParts(parts) {
1410
+ const seen = new Set;
1411
+ const result = [];
1412
+ for (const part of parts) {
1413
+ const value = part?.trim();
1414
+ if (!value || seen.has(value))
1415
+ continue;
1416
+ seen.add(value);
1417
+ result.push(value);
1418
+ }
1419
+ return result;
1420
+ }
1421
+ function commonExecutableDirs(env = process.env) {
1422
+ const home = env.HOME || homedir2();
1423
+ return compactPathParts([
1424
+ join2(home, ".local", "bin"),
1425
+ join2(home, ".bun", "bin"),
1426
+ join2(home, ".cargo", "bin"),
1427
+ join2(home, ".npm-global", "bin"),
1428
+ join2(home, "bin"),
1429
+ env.BUN_INSTALL ? join2(env.BUN_INSTALL, "bin") : undefined,
1430
+ env.PNPM_HOME,
1431
+ env.NPM_CONFIG_PREFIX ? join2(env.NPM_CONFIG_PREFIX, "bin") : undefined,
1432
+ "/opt/homebrew/bin",
1433
+ "/usr/local/bin",
1434
+ "/usr/bin",
1435
+ "/bin",
1436
+ "/usr/sbin",
1437
+ "/sbin"
1438
+ ]);
1439
+ }
1440
+ function normalizeExecutionPath(env = process.env) {
1441
+ return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
1442
+ }
1443
+ function isExecutable(path) {
1444
+ try {
1445
+ accessSync(path, constants.X_OK);
1446
+ return true;
1447
+ } catch {
1448
+ return false;
1449
+ }
1450
+ }
1451
+ function executableExists(command, env = process.env) {
1452
+ if (command.includes("/"))
1453
+ return isExecutable(command);
1454
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
1455
+ if (dir && isExecutable(join2(dir, command)))
1456
+ return true;
1457
+ }
1458
+ return false;
1459
+ }
1460
+ function commandNotFoundMessage(command, env = process.env) {
1461
+ return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
1462
+ }
1463
+
1248
1464
  // src/lib/executor.ts
1249
1465
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
1250
1466
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
@@ -1330,7 +1546,7 @@ function agentArgs(target) {
1330
1546
  args.push(...target.extraArgs ?? [], target.prompt);
1331
1547
  return args;
1332
1548
  case "codewith":
1333
- args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
1549
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", "workspace-write", "--skip-git-repo-check");
1334
1550
  if (isolation === "safe")
1335
1551
  args.push("--ignore-rules");
1336
1552
  if (target.cwd)
@@ -1410,6 +1626,7 @@ function executionEnv(spec, metadata, opts) {
1410
1626
  Object.assign(env, accountEnv);
1411
1627
  }
1412
1628
  Object.assign(env, spec.env ?? {});
1629
+ env.PATH = normalizeExecutionPath(env);
1413
1630
  if (metadata.loopId)
1414
1631
  env.LOOPS_LOOP_ID = metadata.loopId;
1415
1632
  if (metadata.loopName)
@@ -1428,6 +1645,18 @@ function executionEnv(spec, metadata, opts) {
1428
1645
  env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1429
1646
  return env;
1430
1647
  }
1648
+ function preflightTarget(target, metadata = {}, opts = {}) {
1649
+ const spec = commandSpec(target);
1650
+ const env = executionEnv(spec, metadata, opts);
1651
+ if (!spec.shell && !executableExists(spec.command, env)) {
1652
+ throw new Error(commandNotFoundMessage(spec.command, env));
1653
+ }
1654
+ return {
1655
+ command: spec.command,
1656
+ accountProfile: spec.account?.profile,
1657
+ accountTool: spec.accountTool
1658
+ };
1659
+ }
1431
1660
  async function executeTarget(target, metadata = {}, opts = {}) {
1432
1661
  const spec = commandSpec(target);
1433
1662
  const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
@@ -1438,6 +1667,17 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1438
1667
  let exitCode;
1439
1668
  let error;
1440
1669
  const env = executionEnv(spec, metadata, opts);
1670
+ if (!spec.shell && !executableExists(spec.command, env)) {
1671
+ return {
1672
+ status: "failed",
1673
+ stdout: "",
1674
+ stderr: "",
1675
+ error: commandNotFoundMessage(spec.command, env),
1676
+ startedAt,
1677
+ finishedAt: nowIso(),
1678
+ durationMs: 0
1679
+ };
1680
+ }
1441
1681
  const child = spawn(spec.command, spec.args, {
1442
1682
  cwd: spec.cwd,
1443
1683
  env,
@@ -1445,6 +1685,16 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1445
1685
  detached: true,
1446
1686
  stdio: ["ignore", "pipe", "pipe"]
1447
1687
  });
1688
+ if (child.pid)
1689
+ opts.onSpawn?.(child.pid);
1690
+ const abortHandler = () => {
1691
+ error = "cancelled";
1692
+ if (child.pid)
1693
+ killProcessGroup(child.pid);
1694
+ };
1695
+ if (opts.signal?.aborted)
1696
+ abortHandler();
1697
+ opts.signal?.addEventListener("abort", abortHandler, { once: true });
1448
1698
  child.stdout.on("data", (chunk) => {
1449
1699
  stdout = appendBounded(stdout, chunk, maxOutputBytes);
1450
1700
  });
@@ -1467,6 +1717,7 @@ async function executeTarget(target, metadata = {}, opts = {}) {
1467
1717
  error = err instanceof Error ? err.message : String(err);
1468
1718
  } finally {
1469
1719
  clearTimeout(timer);
1720
+ opts.signal?.removeEventListener("abort", abortHandler);
1470
1721
  }
1471
1722
  const finishedAt = nowIso();
1472
1723
  const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
@@ -1522,14 +1773,16 @@ async function executeLoop(loop, run, opts = {}) {
1522
1773
  // src/lib/workflow-runner.ts
1523
1774
  function targetWithStepAccount(step) {
1524
1775
  const account = step.account ?? step.target.account;
1525
- if (!account)
1776
+ const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
1777
+ if (!account && timeoutMs === step.target.timeoutMs)
1526
1778
  return step.target;
1527
- return { ...step.target, account };
1779
+ return { ...step.target, account, timeoutMs };
1528
1780
  }
1529
1781
  function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
1782
+ const executorStatus = status === "succeeded" ? "succeeded" : status === "timed_out" ? "timed_out" : "failed";
1530
1783
  return {
1531
- status,
1532
- exitCode: status === "succeeded" ? 0 : 1,
1784
+ status: executorStatus,
1785
+ exitCode: executorStatus === "succeeded" ? 0 : 1,
1533
1786
  stdout,
1534
1787
  stderr: "",
1535
1788
  error,
@@ -1547,7 +1800,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
1547
1800
  idempotencyKey: opts.idempotencyKey
1548
1801
  });
1549
1802
  const startedAt = run.startedAt ?? nowIso();
1550
- if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out") {
1803
+ if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
1551
1804
  const steps2 = store.listWorkflowStepRuns(run.id);
1552
1805
  return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
1553
1806
  }
@@ -1556,8 +1809,19 @@ async function executeWorkflow(store, workflow, opts = {}) {
1556
1809
  let blockingError;
1557
1810
  let terminalStatus = "succeeded";
1558
1811
  for (const step of ordered) {
1812
+ if (store.isWorkflowRunTerminal(run.id)) {
1813
+ terminalStatus = store.requireWorkflowRun(run.id).status;
1814
+ blockingError = "workflow run was cancelled";
1815
+ break;
1816
+ }
1817
+ const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
1818
+ if (pendingTimeout) {
1819
+ terminalStatus = "timed_out";
1820
+ blockingError = pendingTimeout;
1821
+ break;
1822
+ }
1559
1823
  const existing = store.getWorkflowStepRun(run.id, step.id);
1560
- if (existing?.status === "succeeded" || existing?.status === "skipped")
1824
+ if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
1561
1825
  continue;
1562
1826
  const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
1563
1827
  const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
@@ -1572,8 +1836,13 @@ async function executeWorkflow(store, workflow, opts = {}) {
1572
1836
  terminalStatus = "failed";
1573
1837
  continue;
1574
1838
  }
1575
- store.startWorkflowStepRun(run.id, step.id);
1576
- const result = await executeTarget(targetWithStepAccount(step), {
1839
+ const startedStep = store.startWorkflowStepRun(run.id, step.id);
1840
+ if (startedStep.status !== "running") {
1841
+ terminalStatus = "failed";
1842
+ blockingError = `step ${step.id} could not start because workflow is no longer running`;
1843
+ break;
1844
+ }
1845
+ const metadata = {
1577
1846
  loopId: opts.loop?.id,
1578
1847
  loopName: opts.loop?.name,
1579
1848
  runId: opts.loopRun?.id,
@@ -1582,7 +1851,51 @@ async function executeWorkflow(store, workflow, opts = {}) {
1582
1851
  workflowName: workflow.name,
1583
1852
  workflowRunId: run.id,
1584
1853
  workflowStepId: step.id
1585
- }, opts);
1854
+ };
1855
+ let result;
1856
+ const controller = new AbortController;
1857
+ const externalAbort = () => controller.abort();
1858
+ if (opts.signal?.aborted)
1859
+ controller.abort();
1860
+ opts.signal?.addEventListener("abort", externalAbort, { once: true });
1861
+ const cancelTimer = setInterval(() => {
1862
+ if (store.getWorkflowRun(run.id)?.status === "cancelled")
1863
+ controller.abort();
1864
+ }, opts.cancelPollMs ?? 500);
1865
+ cancelTimer.unref();
1866
+ try {
1867
+ result = await executeTarget(targetWithStepAccount(step), metadata, {
1868
+ ...opts,
1869
+ signal: controller.signal,
1870
+ onSpawn: (pid) => {
1871
+ store.markWorkflowStepPid(run.id, step.id, pid);
1872
+ opts.onSpawn?.(pid);
1873
+ }
1874
+ });
1875
+ } catch (error) {
1876
+ const finishedAt2 = nowIso();
1877
+ result = {
1878
+ status: "failed",
1879
+ stdout: "",
1880
+ stderr: "",
1881
+ error: error instanceof Error ? error.message : String(error),
1882
+ startedAt: startedStep.startedAt ?? finishedAt2,
1883
+ finishedAt: finishedAt2,
1884
+ durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
1885
+ };
1886
+ } finally {
1887
+ clearInterval(cancelTimer);
1888
+ opts.signal?.removeEventListener("abort", externalAbort);
1889
+ }
1890
+ const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
1891
+ if (timeoutMessage && result.status === "failed") {
1892
+ result = { ...result, status: "timed_out", error: timeoutMessage };
1893
+ }
1894
+ if (store.isWorkflowRunTerminal(run.id)) {
1895
+ terminalStatus = store.requireWorkflowRun(run.id).status;
1896
+ blockingError = "workflow run was cancelled";
1897
+ break;
1898
+ }
1586
1899
  store.finalizeWorkflowStepRun(run.id, step.id, {
1587
1900
  status: result.status,
1588
1901
  finishedAt: result.finishedAt,
@@ -1607,6 +1920,11 @@ async function executeWorkflow(store, workflow, opts = {}) {
1607
1920
  }
1608
1921
  }
1609
1922
  const finishedAt = nowIso();
1923
+ if (store.isWorkflowRunTerminal(run.id)) {
1924
+ const terminalRun = store.requireWorkflowRun(run.id);
1925
+ const steps2 = store.listWorkflowStepRuns(run.id);
1926
+ return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
1927
+ }
1610
1928
  const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
1611
1929
  finishedAt,
1612
1930
  durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
@@ -1615,20 +1933,59 @@ async function executeWorkflow(store, workflow, opts = {}) {
1615
1933
  const steps = store.listWorkflowStepRuns(run.id);
1616
1934
  return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
1617
1935
  }
1936
+ function preflightWorkflow(workflow, opts = {}) {
1937
+ return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
1938
+ workflowId: workflow.id,
1939
+ workflowName: workflow.name,
1940
+ workflowStepId: step.id
1941
+ }, opts));
1942
+ }
1618
1943
  async function executeLoopTarget(store, loop, run, opts = {}) {
1619
1944
  if (loop.target.type !== "workflow")
1620
1945
  return executeLoop(loop, run, opts);
1621
1946
  const workflow = store.requireWorkflow(loop.target.workflowId);
1622
- return executeWorkflow(store, workflow, {
1623
- ...opts,
1624
- loop,
1625
- loopRun: run,
1626
- scheduledFor: run.scheduledFor,
1627
- idempotencyKey: `${loop.id}:${run.scheduledFor}`
1628
- });
1947
+ const controller = loop.target.timeoutMs ? new AbortController : undefined;
1948
+ let workflowTimedOut = false;
1949
+ const externalAbort = () => controller?.abort();
1950
+ if (controller && opts.signal?.aborted)
1951
+ controller.abort();
1952
+ if (controller)
1953
+ opts.signal?.addEventListener("abort", externalAbort, { once: true });
1954
+ const timer = controller ? setTimeout(() => {
1955
+ workflowTimedOut = true;
1956
+ controller.abort();
1957
+ }, loop.target.timeoutMs) : undefined;
1958
+ timer?.unref();
1959
+ try {
1960
+ return await executeWorkflow(store, workflow, {
1961
+ ...opts,
1962
+ signal: controller?.signal ?? opts.signal,
1963
+ signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
1964
+ loop,
1965
+ loopRun: run,
1966
+ scheduledFor: run.scheduledFor,
1967
+ idempotencyKey: `${loop.id}:${run.scheduledFor}:attempt:${run.attempt}`
1968
+ });
1969
+ } finally {
1970
+ if (timer)
1971
+ clearTimeout(timer);
1972
+ if (controller)
1973
+ opts.signal?.removeEventListener("abort", externalAbort);
1974
+ }
1629
1975
  }
1630
1976
 
1631
1977
  // src/lib/scheduler.ts
1978
+ function manualRunScheduledFor(loop, now = new Date) {
1979
+ if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
1980
+ return loop.retryScheduledFor ?? loop.nextRunAt;
1981
+ }
1982
+ return now.toISOString();
1983
+ }
1984
+ function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
1985
+ if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
1986
+ return false;
1987
+ return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
1988
+ }
1632
1989
  function nextAfterRetry(loop, now) {
1633
1990
  return new Date(now.getTime() + loop.retryDelayMs).toISOString();
1634
1991
  }
@@ -1654,27 +2011,18 @@ function advanceLoop(store, loop, run, finishedAt, succeeded) {
1654
2011
  retryScheduledFor: undefined
1655
2012
  });
1656
2013
  }
1657
- async function runSlot(deps, loop, scheduledFor) {
1658
- const now = deps.now?.() ?? new Date;
1659
- if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
1660
- const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
1661
- advanceLoop(deps.store, loop, skipped, now, true);
1662
- deps.onRun?.(skipped);
1663
- return skipped;
1664
- }
1665
- const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
1666
- if (!claim)
1667
- return;
1668
- deps.onRun?.(claim.run);
2014
+ async function executeClaimedRun(deps) {
1669
2015
  let heartbeat;
1670
- const heartbeatEveryMs = Math.max(1000, Math.min(60000, Math.floor(claim.loop.leaseMs / 3)));
2016
+ const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
1671
2017
  heartbeat = setInterval(() => {
1672
- deps.store.heartbeatRunLease(claim.run.id, deps.runnerId, claim.loop.leaseMs);
2018
+ deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
1673
2019
  }, heartbeatEveryMs);
1674
2020
  heartbeat.unref();
1675
2021
  try {
1676
- const result = await (deps.execute ?? ((loop2, run) => executeLoopTarget(deps.store, loop2, run)))(claim.loop, claim.run);
1677
- const finalRun = deps.store.finalizeRun(claim.run.id, {
2022
+ const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
2023
+ onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
2024
+ })))(deps.loop, deps.run);
2025
+ return deps.store.finalizeRun(deps.run.id, {
1678
2026
  status: result.status,
1679
2027
  finishedAt: result.finishedAt,
1680
2028
  durationMs: result.durationMs,
@@ -1687,16 +2035,13 @@ async function runSlot(deps, loop, scheduledFor) {
1687
2035
  claimedBy: deps.runnerId,
1688
2036
  now: deps.now?.() ?? new Date(result.finishedAt)
1689
2037
  });
1690
- advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), finalRun.status === "succeeded");
1691
- deps.onRun?.(finalRun);
1692
- return finalRun;
1693
2038
  } catch (err) {
1694
- deps.onError?.(claim.loop, err);
2039
+ deps.onError?.(deps.loop, err);
1695
2040
  const finishedAt = new Date;
1696
- const finalRun = deps.store.finalizeRun(claim.run.id, {
2041
+ return deps.store.finalizeRun(deps.run.id, {
1697
2042
  status: "failed",
1698
2043
  finishedAt: finishedAt.toISOString(),
1699
- durationMs: finishedAt.getTime() - new Date(claim.run.startedAt ?? claim.run.createdAt).getTime(),
2044
+ durationMs: finishedAt.getTime() - new Date(deps.run.startedAt ?? deps.run.createdAt).getTime(),
1700
2045
  stdout: "",
1701
2046
  stderr: "",
1702
2047
  error: err instanceof Error ? err.message : String(err)
@@ -1704,14 +2049,36 @@ async function runSlot(deps, loop, scheduledFor) {
1704
2049
  claimedBy: deps.runnerId,
1705
2050
  now: deps.now?.() ?? finishedAt
1706
2051
  });
1707
- advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
1708
- deps.onRun?.(finalRun);
1709
- return finalRun;
1710
2052
  } finally {
1711
2053
  if (heartbeat)
1712
2054
  clearInterval(heartbeat);
1713
2055
  }
1714
2056
  }
2057
+ async function runSlot(deps, loop, scheduledFor) {
2058
+ const now = deps.now?.() ?? new Date;
2059
+ if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
2060
+ const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
2061
+ advanceLoop(deps.store, loop, skipped, now, true);
2062
+ deps.onRun?.(skipped);
2063
+ return skipped;
2064
+ }
2065
+ const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
2066
+ if (!claim)
2067
+ return;
2068
+ deps.onRun?.(claim.run);
2069
+ const finalRun = await executeClaimedRun({
2070
+ store: deps.store,
2071
+ runnerId: deps.runnerId,
2072
+ loop: claim.loop,
2073
+ run: claim.run,
2074
+ now: deps.now,
2075
+ execute: deps.execute,
2076
+ onError: deps.onError
2077
+ });
2078
+ advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
2079
+ deps.onRun?.(finalRun);
2080
+ return finalRun;
2081
+ }
1715
2082
  async function tick(deps) {
1716
2083
  const now = deps.now?.() ?? new Date;
1717
2084
  const recovered = deps.store.recoverExpiredRunLeases(now);
@@ -1785,23 +2152,17 @@ class LoopsClient {
1785
2152
  }
1786
2153
  async runNow(idOrName) {
1787
2154
  const loop = this.get(idOrName);
1788
- const scheduledFor = new Date().toISOString();
1789
- const claim = this.store.claimRun(loop, scheduledFor, this.runnerId);
2155
+ const now = new Date;
2156
+ const scheduledFor = manualRunScheduledFor(loop, now);
2157
+ const shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
2158
+ const claim = this.store.claimRun(loop, scheduledFor, this.runnerId, now);
1790
2159
  if (!claim)
1791
2160
  throw new Error(`could not claim manual run for ${idOrName}`);
1792
- const result = await executeLoopTarget(this.store, loop, claim.run);
1793
- return this.store.finalizeRun(claim.run.id, {
1794
- status: result.status,
1795
- finishedAt: result.finishedAt,
1796
- durationMs: result.durationMs,
1797
- stdout: result.stdout,
1798
- stderr: result.stderr,
1799
- exitCode: result.exitCode,
1800
- error: result.error,
1801
- pid: result.pid
1802
- }, {
1803
- claimedBy: claim.run.claimedBy
1804
- });
2161
+ const run = await executeClaimedRun({ store: this.store, runnerId: this.runnerId, loop: claim.loop, run: claim.run });
2162
+ if (shouldAdvance) {
2163
+ advanceLoop(this.store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
2164
+ }
2165
+ return run;
1805
2166
  }
1806
2167
  close() {
1807
2168
  if (this.ownStore)