@lchase/yak 0.4.0 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/dist/index.js +173 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,7 +42,9 @@ bug) for a guided walkthrough — also live at
42
42
  rather than failing it. `yak pending` lists every run across the repo
43
43
  awaiting a human answer; `yak resume <run-id>` replays and continues;
44
44
  `--interactive` prompts through open gates inline instead of exiting to
45
- resume later.
45
+ resume later. `yak cancel <run-id>` terminates a live run's process tree
46
+ and journals a clean `failed`/`cancelled` terminal state, so any
47
+ out-of-process supervisor has a supported kill.
46
48
  - **Worktree isolation:** `yak run --isolation worktree` runs the whole
47
49
  workflow inside a fresh git worktree; `map` steps can additionally
48
50
  isolate each item into its own sibling worktree.
package/dist/index.js CHANGED
@@ -488,6 +488,7 @@ async function validateWorkflow(workflow, cwd = process.cwd(), runIsolation = "n
488
488
  checkReservedArtifactNames(flatSteps);
489
489
  checkNeedsSatisfied(workflow.steps, producerOf);
490
490
  checkNoCycles(workflow.steps, producerOf);
491
+ checkExprArtifactIdentifiers(workflow.steps, producerOf);
491
492
  checkExitCodeReads(workflow.steps, producerOf);
492
493
  checkAgentToolNames(flatSteps);
493
494
  await checkAgentSchemaKeys(flatSteps, cwd);
@@ -605,22 +606,58 @@ function checkNeedsSatisfied(steps, producerOf) {
605
606
  function checkNoCycles(steps, producerOf) {
606
607
  const stepsById = new Map(steps.map((s) => [s.id, s]));
607
608
  const state = /* @__PURE__ */ new Map();
608
- function visit(step, path21) {
609
+ function visit(step, path22) {
609
610
  const mark = state.get(step.id);
610
611
  if (mark === "done") return;
611
612
  if (mark === "visiting") {
612
- const cycle = [...path21, step.id].join(" -> ");
613
+ const cycle = [...path22, step.id].join(" -> ");
613
614
  throw new WorkflowValidationError(`cycle detected: ${cycle}`);
614
615
  }
615
616
  state.set(step.id, "visiting");
616
617
  for (const depId of dependenciesOf(step, producerOf)) {
617
618
  const dep = stepsById.get(depId);
618
- if (dep) visit(dep, [...path21, step.id]);
619
+ if (dep) visit(dep, [...path22, step.id]);
619
620
  }
620
621
  state.set(step.id, "done");
621
622
  }
622
623
  for (const step of steps) visit(step, []);
623
624
  }
625
+ var JEXL_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
626
+ function checkExprArtifactIdentifiers(steps, producerOf) {
627
+ for (const step of steps) {
628
+ if (step.skipIf !== void 0) checkExprRefs(step.id, "skipIf", step.skipIf, producerOf);
629
+ if (step.kind === "loop") {
630
+ const localProducerOf = new Map([...producerOf, ...buildProducerMap(step.body)]);
631
+ checkExprRefs(step.id, "until", step.until, localProducerOf);
632
+ if (step.budget.noProgress) {
633
+ checkExprRefs(step.id, "noProgress.signal", step.budget.noProgress.signal, localProducerOf);
634
+ }
635
+ checkExprArtifactIdentifiers(step.body, localProducerOf);
636
+ }
637
+ if (step.kind === "map") checkExprArtifactIdentifiers([step.step], producerOf);
638
+ }
639
+ }
640
+ function checkExprRefs(stepId, field, expr, producerOf) {
641
+ if (typeof expr !== "string") return;
642
+ for (const name of producerOf.keys()) {
643
+ if (JEXL_IDENTIFIER.test(name)) continue;
644
+ const bounded = new RegExp(`(?<![A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`);
645
+ if (!bounded.test(expr)) continue;
646
+ throw new WorkflowValidationError(
647
+ `step "${stepId}": ${field} references artifact "${name}", but "${name}" is not a valid jexl identifier \u2014 jexl reads it as an expression ("a-b" parses as "a minus b"), not a name, so the artifact is invisible to ${field}. Rename it to an identifier (e.g. "${toIdentifier(name)}").`
648
+ );
649
+ }
650
+ }
651
+ function escapeRegExp(s) {
652
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
653
+ }
654
+ function toIdentifier(name) {
655
+ const camel = name.replace(
656
+ /[^A-Za-z0-9]+([A-Za-z0-9])?/g,
657
+ (_, c) => c ? c.toUpperCase() : ""
658
+ );
659
+ return JEXL_IDENTIFIER.test(camel) ? camel : `a${camel}`;
660
+ }
624
661
  function checkExitCodeReads(steps, producerOf) {
625
662
  const stepsById = new Map(steps.map((s) => [s.id, s]));
626
663
  for (const step of steps) {
@@ -1615,11 +1652,28 @@ async function runLoopStep(step, ctx) {
1615
1652
  return "failed";
1616
1653
  }
1617
1654
  const resumeFrom = ctx.loopContinuation?.action === "continue" ? await buildResumeFrom(step, ctx) : void 0;
1618
- const status = await runLoopBody(step, ctx, resumeFrom);
1619
- if (status === "ok") {
1620
- await appendJournalEvent(ctx.runDir, ctx.runId, { t: "step.completed", stepId: step.id, cached: false });
1655
+ const result = await runLoopBody(step, ctx, resumeFrom);
1656
+ if (result.status === "ok") {
1657
+ let artifact;
1658
+ if (step.produces) {
1659
+ const written = await writeArtifact(ctx.runDir, step.produces, result.untilContext, z6.unknown());
1660
+ artifact = { name: step.produces, hash: written.hash };
1661
+ ctx.outerArtifactHashes.set(step.produces, written.hash);
1662
+ await appendJournalEvent(ctx.runDir, ctx.runId, {
1663
+ t: "artifact.written",
1664
+ name: step.produces,
1665
+ hash: written.hash,
1666
+ bytes: written.bytes
1667
+ });
1668
+ }
1669
+ await appendJournalEvent(ctx.runDir, ctx.runId, {
1670
+ t: "step.completed",
1671
+ stepId: step.id,
1672
+ ...artifact ? { artifact: artifact.name, artifactHash: artifact.hash } : {},
1673
+ cached: false
1674
+ });
1621
1675
  }
1622
- return status;
1676
+ return result.status;
1623
1677
  }
1624
1678
  async function buildResumeFrom(step, ctx) {
1625
1679
  const events = await readJournal(ctx.runDir);
@@ -1682,12 +1736,12 @@ async function runLoopBody(step, ctx, resumeFrom) {
1682
1736
  recoverable: false
1683
1737
  }
1684
1738
  });
1685
- return "failed";
1739
+ return { status: "failed" };
1686
1740
  }
1687
1741
  throw err;
1688
1742
  }
1689
1743
  const untilContext = Object.fromEntries(thisValues);
1690
- if (await evalExpr(step.until, untilContext, ctx.cwd)) return "ok";
1744
+ if (await evalExpr(step.until, untilContext, ctx.cwd)) return { status: "ok", untilContext };
1691
1745
  let tripped;
1692
1746
  if (step.budget.noProgress) {
1693
1747
  lastSignal = await evalExpr(step.budget.noProgress.signal, untilContext, ctx.cwd);
@@ -1706,7 +1760,10 @@ async function runLoopBody(step, ctx, resumeFrom) {
1706
1760
  const tokensTotal = await sumBodyTokens(ctx.runDir, bodyStepIds);
1707
1761
  if (tokensTotal >= step.budget.maxTokens) tripped = "maxTokens";
1708
1762
  }
1709
- if (tripped) return await handleExhausted(step, ctx, iteration, tripped);
1763
+ if (tripped) {
1764
+ const outcome = await handleExhausted(step, ctx, iteration, tripped);
1765
+ return outcome === "ok" ? { status: "ok", untilContext } : { status: outcome };
1766
+ }
1710
1767
  priorValues = thisValues;
1711
1768
  priorHashes = thisHashes;
1712
1769
  }
@@ -2361,6 +2418,7 @@ async function executeWorkflowFile(workflowPath, opts = {}) {
2361
2418
  inputHash: inputWritten?.hash ?? sha256(JSON.stringify(workflow)),
2362
2419
  adapter,
2363
2420
  isolation,
2421
+ pid: process.pid,
2364
2422
  ...opts.tag !== void 0 ? { tag: opts.tag } : {}
2365
2423
  });
2366
2424
  if (inputWritten) {
@@ -2449,6 +2507,7 @@ async function resumeRun(runId, opts = {}) {
2449
2507
  const { runsDir, cwd, cacheDir } = resolveDirs(opts.runsDir, opts.cwd, opts.cacheDir);
2450
2508
  const runDir = path15.join(runsDir, runId);
2451
2509
  const workflow = await readRunWorkflow(runDir);
2510
+ await appendJournalEvent(runDir, runId, { t: "run.resumed", pid: process.pid });
2452
2511
  let events = await readJournal(runDir);
2453
2512
  const loopContinuations = await resolveOpenRequests(runId, runDir, workflow, cwd, events);
2454
2513
  events = await readJournal(runDir);
@@ -2512,6 +2571,99 @@ async function artifactsCommand(runId, opts = {}) {
2512
2571
  return 0;
2513
2572
  }
2514
2573
 
2574
+ // src/engine/cancel.ts
2575
+ import path17 from "path";
2576
+ var SIGKILL_GRACE_MS = 5e3;
2577
+ var LIVENESS_POLL_MS = 100;
2578
+ var LIVENESS_POLLS = Math.ceil(SIGKILL_GRACE_MS / LIVENESS_POLL_MS);
2579
+ var realSignal = (pid, signal) => {
2580
+ process.kill(pid, signal);
2581
+ };
2582
+ function livePidFromJournal(events) {
2583
+ for (let i = events.length - 1; i >= 0; i--) {
2584
+ const event = events[i];
2585
+ if (event?.t === "run.resumed") return event.pid;
2586
+ }
2587
+ const started = events.find((e) => e.t === "run.started");
2588
+ return started?.t === "run.started" ? started.pid : void 0;
2589
+ }
2590
+ function isAlive(signal, pid) {
2591
+ try {
2592
+ signal(pid, 0);
2593
+ return true;
2594
+ } catch (err) {
2595
+ return err.code === "EPERM";
2596
+ }
2597
+ }
2598
+ async function terminateProcessTree(signal, sleep, pid) {
2599
+ if (!isAlive(signal, pid)) return "not-running";
2600
+ try {
2601
+ signal(-pid, "SIGTERM");
2602
+ } catch {
2603
+ try {
2604
+ signal(pid, "SIGTERM");
2605
+ } catch {
2606
+ return "sigterm";
2607
+ }
2608
+ }
2609
+ for (let poll = 0; poll < LIVENESS_POLLS; poll++) {
2610
+ if (!isAlive(signal, pid)) return "sigterm";
2611
+ await sleep(LIVENESS_POLL_MS);
2612
+ }
2613
+ if (!isAlive(signal, pid)) return "sigterm";
2614
+ try {
2615
+ signal(-pid, "SIGKILL");
2616
+ } catch {
2617
+ try {
2618
+ signal(pid, "SIGKILL");
2619
+ } catch {
2620
+ }
2621
+ }
2622
+ return "sigkill";
2623
+ }
2624
+ async function cancelRun(runId, opts = {}) {
2625
+ const runsDir = opts.runsDir ?? defaultRunsDir();
2626
+ const signal = opts.signal ?? realSignal;
2627
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2628
+ const runDir = path17.join(runsDir, runId);
2629
+ const events = await readJournal(runDir);
2630
+ if (!events.some((e) => e.t === "run.started")) return { status: "not-found", runId };
2631
+ const last = events.at(-1);
2632
+ if (last?.t === "run.finished") {
2633
+ return { status: "already-terminal", runId, finishedStatus: last.status };
2634
+ }
2635
+ const pid = livePidFromJournal(events);
2636
+ const signalled = pid === void 0 ? "no-pid" : await terminateProcessTree(signal, sleep, pid);
2637
+ const settled = (await readJournal(runDir)).at(-1);
2638
+ if (settled?.t === "run.finished") {
2639
+ return { status: "already-terminal", runId, finishedStatus: settled.status };
2640
+ }
2641
+ await appendJournalEvent(runDir, runId, {
2642
+ t: "run.finished",
2643
+ status: "failed",
2644
+ reason: "cancelled"
2645
+ });
2646
+ return { status: "cancelled", runId, signalled };
2647
+ }
2648
+
2649
+ // src/cli/commands/cancel.ts
2650
+ async function cancelCommand(runId, opts = {}) {
2651
+ const outcome = await cancelRun(runId, { runsDir: opts.runsDir });
2652
+ switch (outcome.status) {
2653
+ case "not-found":
2654
+ console.error(`run ${runId} not found`);
2655
+ return 1;
2656
+ case "already-terminal":
2657
+ console.log(`run ${runId} already finished: ${outcome.finishedStatus} \u2014 nothing to cancel`);
2658
+ return 0;
2659
+ case "cancelled": {
2660
+ const note = outcome.signalled === "no-pid" ? " (no pid recorded \u2014 marked cancelled without signalling)" : outcome.signalled === "not-running" ? " (process already gone \u2014 marked cancelled)" : outcome.signalled === "sigkill" ? " (SIGKILL after grace period)" : "";
2661
+ console.log(`run ${runId} cancelled${note}`);
2662
+ return 0;
2663
+ }
2664
+ }
2665
+ }
2666
+
2515
2667
  // src/cli/commands/graph.ts
2516
2668
  async function graphCommand(workflowPath) {
2517
2669
  const raw = await loadWorkflowYaml(workflowPath);
@@ -2522,13 +2674,13 @@ async function graphCommand(workflowPath) {
2522
2674
 
2523
2675
  // src/cli/commands/pending.ts
2524
2676
  import { readdir as readdir3 } from "fs/promises";
2525
- import path17 from "path";
2677
+ import path18 from "path";
2526
2678
  async function pendingCommand(opts = {}) {
2527
2679
  const runsDir = opts.runsDir ?? defaultRunsDir();
2528
2680
  const runIds = (await readdir3(runsDir).catch(() => [])).sort();
2529
2681
  let anyPending = false;
2530
2682
  for (const runId of runIds) {
2531
- const runDir = path17.join(runsDir, runId);
2683
+ const runDir = path18.join(runsDir, runId);
2532
2684
  const events = await readJournal(runDir);
2533
2685
  const last = events.at(-1);
2534
2686
  if (!last || last.t !== "run.finished" || last.status !== "suspended") continue;
@@ -2632,7 +2784,7 @@ async function resumeCommand(runId, adapter) {
2632
2784
  }
2633
2785
 
2634
2786
  // src/cli/commands/run.ts
2635
- import path18 from "path";
2787
+ import path19 from "path";
2636
2788
  import { cancel, intro, log, outro } from "@clack/prompts";
2637
2789
 
2638
2790
  // src/cli/interactive.ts
@@ -2762,7 +2914,7 @@ ${text2}
2762
2914
  async function runCommand(workflowPath, opts = {}) {
2763
2915
  let result;
2764
2916
  try {
2765
- result = await executeWorkflowFile(path18.resolve(workflowPath), {
2917
+ result = await executeWorkflowFile(path19.resolve(workflowPath), {
2766
2918
  adapter: opts.adapter,
2767
2919
  isolation: opts.isolation,
2768
2920
  input: opts.input,
@@ -2802,7 +2954,7 @@ async function runCommand(workflowPath, opts = {}) {
2802
2954
  }
2803
2955
 
2804
2956
  // src/cli/commands/status.ts
2805
- import path19 from "path";
2957
+ import path20 from "path";
2806
2958
  async function statusCommand(runId, opts = {}) {
2807
2959
  const runsDir = opts.runsDir ?? defaultRunsDir();
2808
2960
  const resolvedRunId = runId ?? await findLatestRunId(runsDir);
@@ -2810,7 +2962,7 @@ async function statusCommand(runId, opts = {}) {
2810
2962
  console.error("no runs found");
2811
2963
  return 1;
2812
2964
  }
2813
- const runDir = path19.join(runsDir, resolvedRunId);
2965
+ const runDir = path20.join(runsDir, resolvedRunId);
2814
2966
  const workflow = await readRunWorkflow(runDir).catch(() => void 0);
2815
2967
  if (!workflow) {
2816
2968
  console.error(`run ${resolvedRunId} not found`);
@@ -2836,7 +2988,7 @@ async function statusCommand(runId, opts = {}) {
2836
2988
  }
2837
2989
 
2838
2990
  // src/cli/commands/watch.ts
2839
- import path20 from "path";
2991
+ import path21 from "path";
2840
2992
  import { render } from "ink";
2841
2993
  import React2 from "react";
2842
2994
 
@@ -2896,7 +3048,7 @@ async function watchCommand(runId, opts = {}) {
2896
3048
  console.error("no runs found");
2897
3049
  return 1;
2898
3050
  }
2899
- const runDir = path20.join(runsDir, resolvedRunId);
3051
+ const runDir = path21.join(runsDir, resolvedRunId);
2900
3052
  const workflow = await readRunWorkflow(runDir).catch(() => void 0);
2901
3053
  if (!workflow) {
2902
3054
  console.error(`run ${resolvedRunId} not found`);
@@ -2973,6 +3125,9 @@ program.command("run").description("Run a workflow YAML file").argument("<workfl
2973
3125
  program.command("resume").description("Resume an interrupted run").argument("<run-id>", "id of the run to resume").option("--adapter <adapter>", "must match the adapter the run started with, if given").action(async (runId, options) => {
2974
3126
  process.exitCode = await resumeCommand(runId, options.adapter ? requireAdapterId(options.adapter) : void 0);
2975
3127
  });
3128
+ program.command("cancel").description("Terminate a live run and journal a clean 'cancelled' terminal state").argument("<run-id>", "id of the run to cancel").action(async (runId) => {
3129
+ process.exitCode = await cancelCommand(runId);
3130
+ });
2976
3131
  program.command("status").description("Report a run's per-step status").argument("[run-id]", "id of the run to report on (default: most recent)").action(async (runId) => {
2977
3132
  process.exitCode = await statusCommand(runId);
2978
3133
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/yak",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "An agentic workflow engine",
5
5
  "license": "MIT",
6
6
  "type": "module",