fix(workflow): run documented scripts and harden cancellation#4325
Conversation
The VM wraps workflow sources in an async IIFE, where the module-only
`export` keyword is a syntax error. The declarative shape
(`export default workflow({...})`) is lowered before it reaches the VM,
but the documented imperative shape
(`export default async function (args) { ... }`) fell through to raw
eval and failed to parse - which made every checked-in #4131 dogfood
fixture unrunnable via /workflow run (found live in the WF-A1 dogfood
pass, where the model then tried to write a "fixed" copy of the
fixture).
Desugar a line-leading `export default` into a captured value: invoke
it with the `args` global when it is a function and use its result as
the run result; return a non-function default export as-is. Plain
scripts are untouched.
Verified: cargo test -p codewhale-workflow-js --locked -> 39 passed
(4 new: async-function args round-trip, sync function, non-function
export, and a no-touch guard for plain scripts).
Agent-assisted: Claude Code (Fable 5) under Hunter's direction.
|
Claude finished @Hmbown's task in 3m 23s —— View job Review: fix(workflow-js): run export-default imperative workflow scripts
SummaryThe fix addresses a real, well-diagnosed bug: sources wrapped in an async IIFE choke on the module-only Findings1. Correctness — naive line-prefix scan can misfire inside multi-line template literals/comments (
const example = `
export default async function (args) { ... }
`;will have that inner line falsely matched and rewritten, corrupting the template literal's content. This is plausible in this crate's domain (workflow scripts that generate, document, or reference other The new The codebase already has a more careful precedent for exactly this class of problem: 2. Minor/non-blocking:
Other notes
|
| fn desugar_export_default(source: &str) -> String { | ||
| let Some(line_idx) = source | ||
| .lines() | ||
| .position(|line| line.trim_start().starts_with("export default")) | ||
| else { | ||
| return source.to_string(); | ||
| }; |
There was a problem hiding this comment.
Correctness: naive line-prefix scan can misfire inside multi-line template literals
source.lines().position(|line| line.trim_start().starts_with("export default")) only checks each line's leading text — it has no notion of "am I inside a string/template literal or comment". A script like:
const example = `
export default async function (args) { ... }
`;
return example;will have its second line matched and rewritten (globalThis.__workflow_default = async function (args) { ... }), corrupting the template literal's textual content, even though there is no real top-level export default in this script. Given workflow-js scripts can legitimately embed example/documentation snippets (e.g. a meta-workflow that generates or explains other .workflow.js files) this isn't a purely theoretical case.
The new plain_scripts_are_untouched_by_export_desugaring test (vm_tests.rs) only covers a single-line string ("const note = \"export default docs\";"), whose line doesn't start with export default in the first place, so it doesn't exercise this hazard at all.
The codebase already has a more careful precedent for this exact problem: extract_workflow_object in crates/workflow/src/js_authoring.rs char-scans while tracking in_string/backtick state to avoid treating string/template contents as syntax. Worth borrowing that discipline here — e.g. only desugar when export default is the first non-whitespace token of the entire trimmed source (matching the documented single-default-export shape), or track quote/backtick/comment state while scanning for the line.
Scan only line-leading export-default syntax outside JavaScript strings, template literals, and comments. This prevents the workflow VM from corrupting embedded authoring examples while preserving the documented imperative module shape.\n\nVerified: cargo test -p codewhale-workflow-js --locked (40 passed).\n\nAgent-assisted: Codex under Hunter Bown\x27s direction. Signed-off-by: Hunter B <hmbown@gmail.com>
|
Claude finished @Hmbown's task in 2m 7s —— View job Review: fix(workflow-js): run export-default imperative workflow scripts
SummaryCommit I traced the state machine by hand against the tricky cases:
Test coverage
Combined with the four tests from the original commit (async function + args round-trip, sync function, non-function export, single-line string guard), coverage now includes the specific failure mode that was found. Remaining non-blocking notes (unchanged from prior review, not addressed here, low priority)
VerificationI was not able to run VerdictThis resolves the correctness finding from my previous pass. No new blocking issues found in |
Signed-off-by: Hunter B <hmbown@gmail.com>
…export-default Signed-off-by: Hunter B <hmbown@gmail.com>
Signed-off-by: Hunter B <hmbown@gmail.com>
Signed-off-by: Hunter B <hmbown@gmail.com>
|
Claude finished @Hmbown's task in 3m 44s —— View job Review: fix(workflow-js): run export-default imperative workflow scripts
SummaryThis push adds three commits beyond what was previously reviewed ( Findings1. Correctness (plausible, non-blocking) —
let completion = tokio::select! {
_ = cancel.cancelled() => return Err("task(): run cancelled".to_string()),
completion = completion_rx => completion...,
};has no In practice this is masked for the default ( 2. Minor — The controller is removed from 3. Verified correct:
VerificationCould not run |
Signed-off-by: Hunter B <hmbown@gmail.com>
|
Claude finished @Hmbown's task in 3m 1s —— View job Review: fix(workflow): run documented scripts and harden cancellation
SummaryThis push adds one commit beyond what was previously reviewed ( Findings1. Correctness (plausible, non-blocking) — narrowing finalization to explicit
This is plausible-but-unverified — I didn't reproduce it, and it depends on a script leaving a spawned task unawaited at teardown, which the documented Previously flagged, still open (not touched by this commit)
VerificationCould not run |
What
The #4131 live dogfood pass found that every checked-in imperative Workflow fixture was unrunnable: the VM accepted plain scripts and declarative
export default workflow({...}), but not the documentedexport default async function (args) {...}shape.This PR now closes the full live-dogfood chain:
export defaultwithout matching examples inside strings, templates, or comments;descriptionand the actualprompt(promptwins);parallel()slot;run_cancelledstate after racing completion events;docs/DOGFOOD_AUTOMATIC_WORKFLOWS.md;Live evidence
workflow_dd5de6d0: 4/4 children, two phases, read-only audit plus synthesis.workflow_97ae14dc: isolated implementer then parent verifier, 2/2, isolation confirmed.workflow_2f590eec: one visible budget failure, one null slot, synthesis from two survivors.workflow_45629ac6: one completed + two cancelled children, lifecyclecancelled, no unreachable phase, no result.Verification
cargo test -p codewhale-workflow-js --locked— 42 VM integration tests + 8 unit tests passed.cargo test -p codewhale-tui --locked dogfood_— 5 dogfood tests passed.cargo test -p codewhale-tui --locked workflow_cancel_interrupts_vm_and_blocks_further_spawns— passed twice after replacing a fixed sleep with a bounded first-child wait.cargo clippy -p codewhale-tui -p codewhale-workflow-js --all-targets --locked -- -D warnings— passed on Rust stable 1.97 after merging currentmain/ fix(tui): clear clippy 1.97 lints in test targets #4322.cargo fmt --all -- --checkandgit diff --check— passed.Blocks completion of #4131 and supplies release evidence for #4110, #4175, #4177, and #4179. Part of the v0.8.68 milestone.