| 1 | //! Dynamic Workflow runtime for CodeWhale. |
| 2 | //! |
| 3 | //! This crate is the imperative half of Workflow: a sandboxed QuickJS |
| 4 | //! (rquickjs) runtime that executes a model-authored JS program which |
| 5 | //! dispatches fleet-routed subagents via `task()`, fans out with |
| 6 | //! `parallel()`/`pipeline()`, reports progress with `log()`/`phase()`, and |
| 7 | //! scales itself to a token pool via the `budget` global. The static, |
| 8 | //! declarative IR (record/replay, model policy) stays in `codewhale-workflow`; |
| 9 | //! this crate only speaks to the outside world through the |
| 10 | //! [`WorkflowDriver`] seam, so it is fully testable without spawning a real |
| 11 | //! subagent (see [`testing::FakeDriver`]). |
| 12 | //! |
| 13 | //! # Script surface |
| 14 | //! |
| 15 | //! Every script runs inside an async function with these globals: |
| 16 | //! |
| 17 | //! * `args` — the invocation input, verbatim. |
| 18 | //! * `await task(opts)` — dispatch one subagent; resolves to the full result |
| 19 | //! text, or to a parsed + schema-validated object when `opts.responseSchema` |
| 20 | //! is set. Throws on rejection, failure, cancellation, budget exhaustion, |
| 21 | //! or once [`WORKFLOW_LIFETIME_CAP`] spawn attempts have been made. A reply |
| 22 | //! that fails `responseSchema` gets up to |
| 23 | //! [`SCHEMA_REPAIR_MAX_ATTEMPTS`] bounded repairs (#5583) before it throws; |
| 24 | //! the failed attempts are reported as driver receipts either way. |
| 25 | //! * `parallel(thunks, opts)` — fan-out, at most [`PARALLEL_MAX_ITEMS`] items. |
| 26 | //! `opts.mode` is `settled` (default), `fail-fast`, or `partial`; an |
| 27 | //! unrecognized mode throws. `settled` keeps today's ergonomics — an |
| 28 | //! ordinary failed slot resolves to `null` — while schema-contract failures |
| 29 | //! and run cancellation still fail the run. `fail-fast` rejects the fan-out |
| 30 | //! with the first non-fatal slot error instead of nulling it. `partial` |
| 31 | //! resolves every non-cancellation failure to |
| 32 | //! `{ __taskError: { index, kind, message } }`. |
| 33 | //! * `pipeline(items, ...stages)` — per-item stage chains with no barrier |
| 34 | //! between stages; same cap and the same three modes via the |
| 35 | //! `pipeline(items, { stages, mode })` overload. |
| 36 | //! |
| 37 | //! Both fan-outs attach a non-enumerable `errors` array to the resolved |
| 38 | //! result — `[{ index, kind, message }]`, ordered by index, empty when |
| 39 | //! nothing failed. The array's own contents, length, and JSON encoding are |
| 40 | //! unchanged, so a dropped slot is now inspectable instead of erased. |
| 41 | //! |
| 42 | //! `kind` is a [`TaskErrorKind`] wire string (`admission`, `budget`, |
| 43 | //! `cancelled`, `agent`, `schema`, `driver`) assigned by the host where the |
| 44 | //! failure happened, or `script` for an error the script threw itself. It is |
| 45 | //! read from the thrown `Error`'s `.kind`, never inferred from message text. |
| 46 | //! * `log(msg)` / `phase(title)` — progress events forwarded to the driver. |
| 47 | //! * `budget.total` / `budget.spent()` / `budget.remaining()` — live driver |
| 48 | //! snapshots (`total` is `null` and `remaining()` is `Infinity` when no |
| 49 | //! ceiling is configured). |
| 50 | //! |
| 51 | //! `Date.now()`, `new Date()`, `Date.parse/UTC`, and `Math.random()` throw: |
| 52 | //! runs must be deterministic so recorded traces can be replayed. |
| 53 | //! |
| 54 | //! # Ownership boundaries |
| 55 | //! |
| 56 | //! Token accounting and admission belong to the driver; the VM only reads |
| 57 | //! snapshots and fast-fails a spawn when the shared pool is already exhausted. |
| 58 | //! Already-running parallel children can reconcile above the hint because |
| 59 | //! provider usage arrives at response boundaries, not token-by-token. Fleet |
| 60 | //! roster resolution for `profile` also happens driver-side; this crate |
| 61 | //! normalizes and token-validates the profile string, nothing more. |
| 62 | |
| 63 | mod driver; |
| 64 | mod error; |
| 65 | mod schema; |
| 66 | pub mod testing; |
| 67 | mod vm; |
| 68 | |
| 69 | pub use driver::{ |
| 70 | BudgetSnapshot, ProgressEvent, SpawnedTask, TaskCompletion, TaskRequest, ToolCallRequest, |
| 71 | ToolCallResponse, ToolInvoker, WorkflowDriver, normalize_profile, |
| 72 | }; |
| 73 | pub use error::{DriverError, TaskErrorKind, WorkflowJsError}; |
| 74 | pub use schema::{SCHEMA_RAW_CARRY_CHARS, SCHEMA_RAW_PREVIEW_CHARS, SCHEMA_REPAIR_MAX_ATTEMPTS}; |
| 75 | pub use vm::{VmLimits, WorkflowRunCancel, WorkflowVm}; |
| 76 | |
| 77 | /// Maximum `task()` spawn attempts per run (design §4.3). Counted in the VM |
| 78 | /// before the driver is consulted, so a runaway `loop-until-dry` terminates |
| 79 | /// even if the driver would keep admitting work. |
| 80 | /// |
| 81 | /// Product scale: up to 1_000 agents per Workflow run. |
| 82 | pub const WORKFLOW_LIFETIME_CAP: u64 = 1000; |
| 83 | |
| 84 | /// Maximum concurrently executing agents within one Workflow run. |
| 85 | /// |
| 86 | /// Fan-out may *declare* more work via `parallel()` / `pipeline()`, but the |
| 87 | /// host admits at most this many live `task()` children at once; additional |
| 88 | /// spawns wait for a slot. |
| 89 | pub const WORKFLOW_MAX_CONCURRENT: usize = 16; |
| 90 | |
| 91 | /// Maximum items per `parallel()` or `pipeline()` call (design §4.2). |
| 92 | /// Kept at the per-run agent ceiling so a single fan-out cannot declare more |
| 93 | /// work than the lifetime cap can ever complete. |
| 94 | pub const PARALLEL_MAX_ITEMS: usize = 1000; |
| 95 | |
| 96 | /// Maximum `tools.call()` invocations per code-mode run. |
| 97 | /// |
| 98 | /// Counted in the VM before the invoker is consulted, so a runaway loop |
| 99 | /// terminates even if the invoker would keep admitting calls. Concurrency, |
| 100 | /// deadlines, and result sizes are enforced host-side by the invoker. |
| 101 | pub const CODEMODE_MAX_TOOL_CALLS: u64 = 50; |
| 102 |