@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.
- package/README.md +3 -1
- package/dist/index.js +173 -18
- 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,
|
|
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 = [...
|
|
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, [...
|
|
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
|
|
1619
|
-
if (status === "ok") {
|
|
1620
|
-
|
|
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)
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
});
|