diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml index 49381ddbd0..03f58b43de 100644 --- a/.github/workflows/release-fork.yml +++ b/.github/workflows/release-fork.yml @@ -89,6 +89,11 @@ jobs: # (LeXwDeX/opencode-dag-config) into a release asset. dev/main do not manage # these templates anymore — the config repo is the single source of truth. # Read-only: no commits, no pushes, so branch protection never blocks it. + # + # Validate-before-package: the releasing runtime commit runs its directory + # validator against the config repo HEAD BEFORE any copy/package step. Any + # invalid template — or an unavailable validator — fails the job (fail + # closed), so an unchecked archive can never be uploaded or embedded. package-templates: name: Package Reference Templates if: github.event_name == 'workflow_dispatch' @@ -96,24 +101,28 @@ jobs: permissions: contents: read steps: + - name: Checkout Runtime (releasing commit) + uses: actions/checkout@v4 + - name: Clone Config Repo uses: actions/checkout@v4 with: repository: LeXwDeX/opencode-dag-config path: dag-config - - name: Package Templates + - name: Setup Bun + uses: ./.github/actions/setup-bun + with: + save-cache: false + + - name: Install Runtime Dependencies + run: bun install + + - name: Validate and Package Templates (fail closed) + working-directory: packages/opencode run: | - mkdir -p dist - shopt -s nullglob - files=(dag-config/*.yaml) - if [ ${#files[@]} -gt 0 ]; then - cp "${files[@]}" dist/ - else - echo "::warning::No templates found in opencode-dag-config root; packaging empty archive" - fi - tar -czf dag-templates.tar.gz -C dist . - echo "Templates packaged: $(ls dist | wc -l) files" + echo "Packaging config commit $(git -C "$GITHUB_WORKSPACE/dag-config" rev-parse HEAD) with runtime commit $(git rev-parse HEAD)" + bun run script/package-dag-templates.ts "$GITHUB_WORKSPACE/dag-config" "$GITHUB_WORKSPACE/dag-templates.tar.gz" - name: Upload Templates Artifact uses: actions/upload-artifact@v4 @@ -224,15 +233,9 @@ jobs: for dir in opencode-*/; do base="${dir%/}" if [[ "$base" == *linux* ]]; then - tar -czf "${base}.tar.gz" -C "${base}/bin" . + bun run ../script/package-cli-artifact.ts "$base" "${base}.tar.gz" else - cd "${base}/bin" - if command -v zip &>/dev/null; then - zip -r "../../${base}.zip" . - else - pwsh -Command "Compress-Archive -Path '*' -DestinationPath '../../${base}.zip'" 2>/dev/null || 7z a "../../${base}.zip" . || true - fi - cd ../.. + bun run ../script/package-cli-artifact.ts "$base" "${base}.zip" fi done diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 7de0a6c619..d9e39d5cb2 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -5,7 +5,8 @@ Read the context documents relevant to the code or decision under review. Do not | Context | Domain document | Primary areas | | --- | --- | --- | | Session Runtime and Client Contract | [`CONTEXT.md`](CONTEXT.md) | `packages/opencode/src/session`, `packages/opencode/src/system-context`, `packages/protocol`, `packages/client`, `packages/sdk` | +| Workflow Orchestration | [`packages/opencode/src/dag/CONTEXT.md`](packages/opencode/src/dag/CONTEXT.md) | `packages/opencode/src/dag`, workflow tool, DAG template validation and packaging | ## Contexts created lazily -DAG orchestration does not yet have a dedicated `CONTEXT.md`. The full DAG review must establish terminology from implementation, tests, existing specifications, and accepted decisions before `/domain-modeling` creates one. Add future contexts to this map only when they have a stable document to reference. +Add future contexts to this map only when they have a stable document to reference. diff --git a/NOTICE b/NOTICE index 0f346d30fd..1d067dbb89 100644 --- a/NOTICE +++ b/NOTICE @@ -51,3 +51,16 @@ License boundaries When a file under an AGPL-covered directory imports MIT-licensed upstream modules, the upstream modules remain MIT; only the AGPL-covered files and their derivatives carry AGPL obligations. + +3. Engineering workflow methodologies + + Source: https://github.com/mattpocock/skills + Revision: 84fdeffd12f2ee307994d1eb6feb48173b6e0502 + License: MIT + Text: ./third_party/mattpocock-skills/LICENSE + Copyright (c) 2026 Matt Pocock + + Selected decision, evidence, debugging, test-first delivery, codebase + design, review, and synthesis methodologies are adapted into product-owned + workflow routing and block contracts. Source metadata and adaptation scope + are recorded in ./third_party/mattpocock-skills/SOURCE.md. diff --git a/packages/core/src/plugin/command/dag-flow.txt b/packages/core/src/plugin/command/dag-flow.txt index dbc4e08d6b..7de158df1f 100644 --- a/packages/core/src/plugin/command/dag-flow.txt +++ b/packages/core/src/plugin/command/dag-flow.txt @@ -4,17 +4,10 @@ $ARGUMENTS -If the task is empty or contains only whitespace, ask for it and do not start a workflow. Otherwise load -the `orchestration-router` skill and route the request through one consolidated -graph. `/dag-flow` explicitly selects DAG execution, but it does not waive a -material user decision. - -When the route requires a decision checkpoint or GRILL qualification, inspect -discoverable facts first, proactively write recommended answers, surface the -compact brief in the main conversation, and ask for one combined confirmation. -Do not call `workflow(action="start")` until that confirmation arrives. Do not -put the checkpoint in a child node. If the request is already bounded and -confirmed, start without manufacturing another question. +If the task is empty or contains only whitespace, ask for it; do not start a workflow. +Otherwise apply the resident Orchestration Router and route the +request through one consolidated graph. `/dag-flow` explicitly selects DAG +execution; the router still owns any material Decision Checkpoint. Prefer composable blocks for a fresh flow. Load `workflow(action="guide", topic="blocks")` only if the block contract is not diff --git a/packages/core/src/plugin/command/dag-template-update.txt b/packages/core/src/plugin/command/dag-template-update.txt index 2619f06659..87d1fc985d 100644 --- a/packages/core/src/plugin/command/dag-template-update.txt +++ b/packages/core/src/plugin/command/dag-template-update.txt @@ -33,7 +33,7 @@ https://codeload.github.com/LeXwDeX/opencode-dag-config/zip/refs/heads/main ``` Extract it into a temporary directory. The archive contains a top-level folder -(typically `opencode-dag-config-main/`) whose root holds the `*.yaml` +(typically `opencode-dag-config-main/`) whose root holds the `*.yaml` and `*.yml` templates. ## Dry-run preview (always show before applying) @@ -48,6 +48,28 @@ Compare the extracted templates against the current Show the user the three lists, or report that nothing needs updating. +## Validate downloaded templates (fail closed, before any replacement) + +Before any copy or overwrite, discover and validate EVERY extracted `*.yaml` and `*.yml` template with +the same validation authority `start` and `list` use — the workflow tool's +`validate` action. For each extracted template call: + +``` +workflow(action: "validate", spec_path: "", profile: "portable") +``` + +- Every template must come back `valid: true`. +- If both `.yaml` and `.yml` exist, abort before applying anything; + one logical workflow name cannot have two source files. +- If ANY template is invalid: keep the current global library exactly as it + is — copy nothing, overwrite nothing. Report a per-file diagnostic list + (code, path, message, hint) for every failing template plus the names that + passed, and stop. Treat validation failure like a download failure: never + partially apply. +- Use the portable profile: the global library doubles as the distributable + builtin source, so a template that only works inside one specific project + does not belong here. + ## Merge - If there are no `UPDATE` entries: merge directly — copy `NEW` templates in, @@ -94,6 +116,8 @@ not just the workflow library listing: - Download failure (network, 404, rate limit): report the actual error verbatim and stop — never invent success. - Extraction failure (corrupt archive): report and stop. +- Validation failure (any template invalid): report per-file diagnostics and + stop; the existing library stays untouched. - If `/workflows` does not exist, create it before applying. ## Notes diff --git a/packages/core/src/plugin/command/workflow-blocks.md b/packages/core/src/plugin/command/workflow-blocks.md index cb6ea49c3f..95feef7e21 100644 --- a/packages/core/src/plugin/command/workflow-blocks.md +++ b/packages/core/src/plugin/command/workflow-blocks.md @@ -17,31 +17,26 @@ config: - id: map kind: explore instruction: Locate the ownership and persistence seams. - - id: design + - id: codebase-design kind: plan depends_on: [map] - - id: implement + instruction: Define the owning seam, deep interface, migration path, and acceptance evidence. + - id: coding kind: coding - depends_on: [design] - skills: [tdd] - - id: checks + depends_on: [codebase-design] + instruction: Deliver the bounded design through observable tests and focused checks. + - id: verify kind: verify - depends_on: [implement] - - id: decision + depends_on: [coding] + - id: global-review kind: review - depends_on: [checks] - skills: [code-review] + depends_on: [verify] ``` -Each block accepts: - -- `id`: unique dependency address and the ID of its compiled exit node. -- `kind`: `explore`, `plan`, `prototype`, `debug`, `coding`, `verify`, - `review`, or `synthesize`. -- `depends_on`: upstream block IDs; omitted means a root block. -- `instruction`: target-specific text added to the built-in block contract. -- `skills`: relevant skill names the child loads lazily when available. -- `worker_type`, `required`, `report_to_parent`: optional overrides. +The parameter schema owns the exact block field shapes; the tool rejects +unknown or missing fields by name, and `workflow(action="validate")` reports +each field error with its path. This guide covers semantics and constraints +only — compose blocks against the schema, not against prose. `objective` is required and is injected into every generated node. Use blocks or nodes, never both. Block IDs use letters, numbers, underscores, and hyphens. @@ -51,7 +46,8 @@ or existing durable node IDs during **extend** and replan. ## Block contracts - `explore`: read-only repository mapping and evidence collection. -- `plan`: implementation-ready decomposition, seams, checks, and risks. +- `plan`: decision- or implementation-ready options/work packages, checks, + falsifiers, and risks. - `prototype`: the smallest throwaway experiment that resolves a runnable uncertainty; it does not silently become production code. It still publishes its changed-file list and fingerprint so later verification or review cannot @@ -65,6 +61,10 @@ or existing durable node IDs during **extend** and replan. fingerprint through both reviews into an `ACCEPT | REJECT` decision. - `synthesize`: resolves dependency outputs into the parent-facing result. +Block contracts are self-contained. `instruction` specializes a lifecycle kind +into a capability such as `codebase-design`, `domain-modeling`, or +`global-review`; it never delegates the method to an external Skill. + Judgment and acceptance gates (`plan`, debug diagnosis, `verify`, review decision, and `synthesize`) are required by default. Volume lanes (`explore`, `prototype`, `coding`, debug evidence, and independent review lanes) are @@ -74,41 +74,10 @@ stay quiet. A block immediately after a review gate is conditioned on its accepted verdict. Because the condition language handles one verdict reference, fan multiple review lanes into one review block before continuing. -## Composition routes - -Choose only blocks justified by current evidence: - -- Product or architecture decision: parallel `explore` lanes → `plan` options - → `review` or `synthesize`. -- Project feature: optional parallel `explore` or proposal lanes → `plan` → - ordered `coding`/assembly → `verify` → `review`. -- Hard bug: `debug` → `coding` → `verify` → `review`. -- Runnable design uncertainty: `prototype` → `plan`; keep the prototype - disposable unless the confirmed scope explicitly promotes it. -- Existing implementation review: `explore` scope lanes → `review`; add a - separate verification block first when test evidence is required. - -Do not add a phase merely because it exists. Skip exploration when repository -facts are already known and skip a prototype when ordinary inspection resolves -the question. All block workers share one workspace: the compiler serializes -otherwise-unordered `coding` and `prototype` writers, while read-only discovery -and proposal lanes remain parallel. Use `synthesize` only when multiple outputs -need reconciliation. - -## Parent decision checkpoint - -User qualification is not a DAG block. Before executable blocks start, the -parent gathers facts it can discover, creates recommended answers for every -material open decision, displays one compact decision brief, and asks for one -combined confirmation. The brief contains the recommended route, alternatives -only where they change the result, assumptions, risks, scope, and acceptance -evidence. A correction from the user updates the brief; unchanged confirmed -facts are not asked again. - -After confirmation, encode the decision in `objective` and block instructions. -If the request is already fully bounded and confirmed, do not manufacture a -redundant checkpoint. Child nodes never ask the user to make product or scope -decisions. +All block workers share one workspace. The compiler serializes +otherwise-unordered `coding` and `prototype` writers, while read-only lanes may +remain parallel. The resident Orchestration Router owns route selection and +phase pruning; this guide owns block fields, contracts, and graph mechanics. ## When to use low-level nodes diff --git a/packages/core/src/plugin/command/workflow-routing.md b/packages/core/src/plugin/command/workflow-routing.md index dc027c2567..92394309a4 100644 --- a/packages/core/src/plugin/command/workflow-routing.md +++ b/packages/core/src/plugin/command/workflow-routing.md @@ -1,63 +1,78 @@ -# Workflow Orchestration - -In the user-facing parent session, use this tool proactively when one user -objective needs staged, parallel, quality-gated, or adaptive execution. A slash -command is not required. A DAG child session executes its assigned block and -must not recursively route that assignment into another workflow. - -## Execution Mode Selection - -- Use direct execution for conversation, a small read-only lookup, or one or - two isolated utility scripts outside a project-level change. -- Use one `task` subagent for one independent non-trivial leaf assignment. -- Use one `workflow` DAG for project-level source or test changes (even when - only one project file is expected), work that crosses module boundaries, - product/architecture planning that needs repository exploration, or any - staged/parallel/gated/adaptive objective. - -Related work for one objective belongs in one live workflow. The parent -conversation owns user decisions, scope, checkpoints, workflow control, and -the final synthesis. Child nodes own executable leaf work. Explicit requests -for “single agent”, “do not use DAG”, or direct work disable proactive DAG -selection. Read-only scope changes what nodes may do; it does not by itself -disable a useful exploration or review DAG. - -For a project-level route, load the `orchestration-router` skill before -constructing the graph. It selects the smallest useful sequence of composable -blocks and defines the one-confirmation decision checkpoint. Do not place user -questioning inside a child node. +# Orchestration Router + +The user-facing parent owns workflow qualification and block composition. A +slash command or external Skill is not required. A DAG child executes its +assigned block directly and never creates a nested workflow. + +Do not discover, load, or apply an external Skill to select the workflow route +or compose its blocks. Installed routing Skills do not override this product +contract and must not change the selected graph or generated block prompts. + +## Execution mode + +- Direct execution: conversation, a small read-only lookup, or one or two + isolated utility scripts outside a project-level change. +- One `task` child: one independent non-trivial leaf assignment. +- One `workflow` DAG: project-level source or test changes, even one project + file; cross-module work; repository-backed product or architecture work; or + staged, parallel, quality-gated, or adaptive execution. + +An explicit request for one agent, direct work, or no DAG selects direct work. +Related work for one objective stays under one workflow ID; extend or replan +that workflow when evidence adds work. + +## Qualify before composing + +Inspect repository instructions, code, tests, history, and runtime evidence +before asking. Classify what remains as confirmed facts, safe inferences, +runnable uncertainties, user-owned decisions, and executable work. + +When a user-owned choice materially changes behavior, scope, acceptance, or an +irreversible boundary, present one **Decision Checkpoint** before executable +blocks start. Its **Workflow Brief** contains the recommended answer and why, +scope, acceptance evidence, assumptions, risks, and only materially different +alternatives. Ask for one combined confirmation. A request that already +contains an equivalent confirmed brief needs no checkpoint. Child nodes never +ask the user to make product or scope decisions. + +## Compose the smallest justified graph + +Use `workflow(action="guide", topic="blocks")` when block fields are not in +context. Choose blocks from evidence, not from a fixed all-phases pipeline: + +- feature: optional evidence → plan/design → coding packages → verify → review; +- bug without a proven cause: debug → coding → verify → review; +- runnable uncertainty: prototype → update the plan; +- product or architecture decision: evidence lanes → plan options → review or + synthesize; +- existing implementation review: scope evidence → verify when required → + review. + +Omit exploration when facts are already sufficient, omit prototype when +inspection resolves the question, and add synthesize only when outputs need +reconciliation. High-level block contracts are self-contained; block +instructions specialize the task and never name external Skills. + +When a saved route matches the topology, read it, retarget its objective and +block instructions, and prune or add justified blocks before starting the +edited inline spec. Start `spec_path` directly only when its target already +matches exactly. Use low-level nodes only for bindings, conditions, output +schemas, or lifecycle metadata blocks cannot express. + +Validate the composed or edited spec before start. Fix every diagnostic and +validate again; validation creates no workflow. A successful start returns the +exact workflow ID. The parent owns the brief, graph, user interaction, +checkpoints, controls, and final report; children own bounded executable work. +End after start and let the workflow wake the parent. Do not poll merely to +wait, and never claim an unstarted graph is running. ## Progressive guidance -Load details only when needed: - -- **guide** without `topic`: compact topic index. -- **guide** `topic=blocks`: composable block schema and examples. -- **guide** `topic=interface`: low-level node fields and tool semantics. -- **guide** `topic=policy`: admission, gates, recovery, and bounded repair. -- **guide** `topic=patterns`: larger domain playbooks. - -## Actions - -- **start** creates one workflow from exactly one inline `spec` or saved - `spec_path`. -- **extend** adds nodes or blocks to the same objective. -- **status** reads durable state when the user asks or before a control - decision; it is not a waiting mechanism. -- **result** reads one node's complete durable output in bounded pages when a - wake preview reports `truncated=true`. -- **control** pauses, resumes, cancels, replans, steps, or completes a workflow. -- **list** shows saved workflow specs and their resolution scope. -- **read** returns one saved spec so the parent can retarget it before start. - -Prefer high-level `blocks` for a fresh one-off flow. Use low-level `nodes` when -the task needs custom bindings, conditions, output schemas, or review metadata. -Never provide both. Reusable saved YAML remains valid and may use either form. -When a saved route is generic, call **read**, replace its objective and -block-specific instructions with the confirmed request, then pass the result -as an inline **start** spec. Start by `spec_path` only when the saved target -already matches exactly. - -The workflow runs asynchronously and wakes the parent at actionable reporting -nodes or terminal state. Do not poll, sleep, or loop merely to wait. Never -claim a workflow started unless **start** returned its exact workflow ID. +- `guide` without `topic`: compact index. +- `guide(topic="blocks")`: block shape and composition semantics. +- `guide(topic="interface")`: low-level node and tool fields. +- `guide(topic="policy")`: gates, recovery, and bounded repair. +- `guide(topic="patterns")`: larger domain playbooks. + +The tool parameter schema owns required fields and exclusivity; author calls +from that schema rather than reconstructed prose. diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index c027b73302..b2fdc51b4a 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -9,12 +9,10 @@ import { SkillV2 } from "../skill" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } import configureHooksContent from "./skill/configure-hooks.md" with { type: "text" } import createDagWorkflowContent from "./skill/create-dag-workflow.md" with { type: "text" } -import orchestrationRouterContent from "./skill/orchestration-router.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent export const ConfigureHooksContent = configureHooksContent export const CreateDagWorkflowContent = createDagWorkflowContent -export const OrchestrationRouterContent = orchestrationRouterContent export const CustomizeOpencodeDescription = "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." @@ -25,9 +23,6 @@ export const ConfigureHooksDescription = export const CreateDagWorkflowDescription = "Use when the user wants to create, save, or edit a reusable DAG workflow — a named multi-agent graph they can start again later — or asks where workflow specs live, how to make a workflow available in every project, or why a saved workflow name does not resolve. Covers project/global scopes, composable blocks, low-level nodes, and verification. Do not use to run an existing workflow or to design a one-off graph for the current task; the workflow tool handles those." -export const OrchestrationRouterDescription = - "Use proactively in the user-facing parent session, without waiting for /dag-flow, whenever one objective changes project source or tests (even one project file), crosses module boundaries, needs repository-backed product/architecture planning, or has staged, parallel, quality-gated, or adaptive execution. Routes work through a parent-owned decision checkpoint and composable DAG blocks. Do not use inside a DAG child session, for one or two isolated utility scripts, simple lookup/conversation, or when the user explicitly requests direct work, one agent, or no DAG." - export const Plugin = define({ id: "skill", effect: Effect.fn(function* (ctx) { @@ -65,17 +60,6 @@ export const Plugin = define({ }), }), ) - draft.source( - SkillV2.EmbeddedSource.make({ - type: "embedded", - skill: SkillV2.Info.make({ - name: "orchestration-router", - description: OrchestrationRouterDescription, - location: AbsolutePath.make("/builtin/orchestration-router.md"), - content: OrchestrationRouterContent, - }), - }), - ) }) }), }) diff --git a/packages/core/src/plugin/skill/orchestration-router.md b/packages/core/src/plugin/skill/orchestration-router.md deleted file mode 100644 index 9d264918a5..0000000000 --- a/packages/core/src/plugin/skill/orchestration-router.md +++ /dev/null @@ -1,92 +0,0 @@ - - -# Orchestration Router - -Turn one user objective into the smallest execution route that preserves user -control and produces verifiable evidence. The router decides; workflow blocks -execute. Do not copy the whole playbook into the parent response. - -This skill belongs to the user-facing parent session. If the current prompt -identifies this session as a DAG child or assigns one bounded block, execute -that assignment directly and do not create a nested workflow. - -## 1. Establish facts before asking - -Read repository instructions and inspect enough code, tests, history, or -runtime evidence to answer discoverable questions yourself. Separate: - -- confirmed facts; -- decisions only the user can make; -- runnable uncertainties best answered by a disposable prototype; -- implementation work suitable for child sessions. - -Do not ask the user for file locations, conventions, or current behavior that -the repository can reveal. - -## 2. Select the execution lane - -Use direct work for conversation, a bounded lookup, or one or two isolated -utility scripts outside a project-level change. Use one task child for one -independent non-trivial leaf. Use one workflow without waiting for `/dag-flow` -whenever the objective changes project source or tests—even when only one -project file is expected—spans modules, requires repository-backed product or -architecture planning, or has staged, parallel, gated, or adaptive work. - -Honor explicit “single agent”, “do not use DAG”, and direct-execution requests. -Keep all related work for one objective under one workflow ID; adapt it with -extend/replan rather than creating disconnected graphs. - -## 3. Run one parent-owned decision checkpoint when needed - -Use a decision checkpoint for material product choices, conflicting -constraints, high-blast-radius architecture, or an explicit GRILL request. It -must happen in the parent conversation before executable DAG blocks start. - -Generate recommended answers proactively. Present one compact brief containing: - -1. recommended route and why; -2. scope in/out and acceptance evidence; -3. assumptions and risks; -4. alternatives only where the choice materially changes the result; -5. one combined confirmation request. - -Wait for that confirmation. Do not hide the recommendation inside tool output, -start speculative implementation, or delegate the questions to a child. If the -user changes an answer, revise only affected fields and ask one new combined -confirmation. If the request already supplies an equivalent confirmed brief, -do not repeat the checkpoint. - -## 4. Compose blocks from the route - -Call `workflow(action="guide", topic="blocks")` when the block interface is -not already in context. Select only justified blocks: - -- product/design: evidence lanes → competing plans when useful → synthesis or - review decision; -- feature: optional explore → plan → independent coding packages → verify → - review; -- bug: debug → coding → verify → review; -- runnable uncertainty: prototype detour → update the plan; -- review-only: scope exploration → independent review and arbitration. - -When a reusable route matches the topology, call -`workflow(action="read", spec_path="")`, retarget the objective and -block instructions to the confirmed request, prune unjustified blocks, and -start the edited result as an inline spec. Start the saved `spec_path` directly -only when its target already matches exactly. - -Use a skill name on a block only when it appears in the available skill -catalog. Test-first implementation and standards/spec review belong in their -respective coding and review blocks, not in the always-on router prompt. - -## 5. Preserve ownership boundaries - -The parent owns the confirmed brief, graph shape, user interaction, workflow -controls, checkpoint disposal, and final report. Children own repository -exploration, implementation, checks, and bounded review artifacts. Do not have -the parent perform executable leaf work after choosing a workflow. - -Start only after required confirmation. Report the returned workflow ID and -end the turn; the runtime wakes the parent later. On wake, dispose of a -non-ACCEPT verdict by targeted extension/replan or a reasoned stop. Never poll -merely to wait, and never describe an unstarted graph as running. diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index e4213fce3d..264fe05098 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -49,25 +49,25 @@ describe("CommandPlugin.Plugin", () => { template: CommandPlugin.DagFlowContent, }) expect(CommandPlugin.DagFlowContent).toContain("$ARGUMENTS") - expect(CommandPlugin.DagFlowContent).toContain('workflow(action="start")') + expect(CommandPlugin.DagFlowContent).toContain("`action=start`") expect(CommandPlugin.DagFlowContent).toContain("exact Workflow ID") expect(CommandPlugin.DagFlowContent).toContain("run `/dag`") - expect(CommandPlugin.DagFlowContent).toContain("orchestration-router") - expect(CommandPlugin.DagFlowContent).toContain("one combined confirmation") + expect(CommandPlugin.DagFlowContent).toContain("resident Orchestration Router") + expect(CommandPlugin.DagFlowContent).toContain("Decision Checkpoint") }), ) it.effect("documents the smallest child execution mode", () => Effect.sync(() => { - expect(CommandPlugin.WorkflowContent).toContain("## Execution Mode Selection") - expect(CommandPlugin.WorkflowContent).toContain("Use direct execution for") - expect(CommandPlugin.WorkflowContent).toContain("one `task` subagent") + expect(CommandPlugin.WorkflowContent).toContain("## Execution mode") + expect(CommandPlugin.WorkflowContent).toContain("Direct execution:") + expect(CommandPlugin.WorkflowContent).toContain("One `task` child") expect(CommandPlugin.WorkflowContent).toContain("Related work for one objective") expect(CommandPlugin.WorkflowFactsContent).toContain("project-level source or test changes") expect(CommandPlugin.WorkflowFactsContent).toMatch(/even when only\s+one project file/) expect(CommandPlugin.WorkflowFactsContent).not.toContain("when ANY") expect(CommandPlugin.WorkflowFactsContent).not.toContain("- **Multi-model**:") - expect(CommandPlugin.DagFlowContent).toContain('workflow(action="start")') + expect(CommandPlugin.DagFlowContent).toContain("`action=start`") }), ) @@ -75,13 +75,22 @@ describe("CommandPlugin.Plugin", () => { Effect.sync(() => { expect(CommandPlugin.WorkflowContent.length).toBeLessThan(5_000) expect(CommandPlugin.WorkflowContent).toContain("project-level source or test changes") - expect(CommandPlugin.WorkflowContent).toContain("only one project file") + expect(CommandPlugin.WorkflowContent).toMatch(/even one project\s+file/) expect(CommandPlugin.WorkflowContent).toContain("isolated utility scripts") - expect(CommandPlugin.WorkflowContent).toContain("orchestration-router") - expect(CommandPlugin.WorkflowContent).toContain("**guide**") + expect(CommandPlugin.WorkflowContent).toContain("# Orchestration Router") + expect(CommandPlugin.WorkflowContent).toContain("Workflow Brief") + expect(CommandPlugin.WorkflowContent).toContain("smallest justified graph") + expect(CommandPlugin.WorkflowContent).not.toMatch(/load (?:the )?[`"']?orchestration-router/i) + expect(CommandPlugin.WorkflowContent).toContain( + "Do not discover, load, or apply an external Skill to select the workflow route", + ) + expect(CommandPlugin.WorkflowContent).toContain('guide(topic="blocks")') expect(CommandPlugin.WorkflowContent).not.toContain("# Orchestration Domains") expect(CommandPlugin.WorkflowBlocksContent).toContain("# Composable Workflow Blocks") - expect(CommandPlugin.WorkflowBlocksContent).toContain("combined confirmation") + expect(CommandPlugin.WorkflowContent).toContain("combined confirmation") + expect(CommandPlugin.WorkflowBlocksContent).not.toContain("combined confirmation") + expect(CommandPlugin.WorkflowContent).toContain("product or architecture decision") + expect(CommandPlugin.WorkflowBlocksContent).not.toContain("product or architecture decision") expect(CommandPlugin.WorkflowFactsContent.length).toBeGreaterThan(CommandPlugin.WorkflowContent.length) }), ) @@ -102,7 +111,11 @@ describe("CommandPlugin.Plugin", () => { Effect.sync(() => { expect(CommandPlugin.WorkflowFactsContent).toContain("For a one-off graph, pass `spec` inline") expect(CommandPlugin.WorkflowFactsContent).toContain("Use `spec_path` only") - expect(CommandPlugin.WorkflowContent).toContain("**read**") + // The resident description keeps tool selection and the progressive + // guide index only; per-action field semantics live in the parameter + // schema (change repair-workflow-authoring-validation). + expect(CommandPlugin.WorkflowContent).not.toContain("## Actions") + expect(CommandPlugin.WorkflowContent).toContain("parameter schema") expect(CommandPlugin.WorkflowFactsContent).toContain('{ action: "read", spec_path: "code-review" }') expect(CommandPlugin.WorkflowFactsContent).toContain("retarget its objective and block instructions") expect(CommandPlugin.WorkflowFactsContent).not.toContain("Never inline graph nodes") diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index c032e70dc3..d50a089422 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -60,23 +60,12 @@ describe("SkillPlugin.Plugin", () => { }), ) - it.effect("registers the proactive orchestration router as a lazy built-in skill", () => + it.effect("keeps workflow orchestration out of the Skill catalog", () => Effect.gen(function* () { const skill = yield* SkillV2.Service yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) - expect(yield* skill.list()).toContainEqual( - expect.objectContaining({ - name: "orchestration-router", - description: expect.stringContaining("without waiting for /dag-flow"), - content: expect.stringContaining("one combined confirmation"), - }), - ) - const router = (yield* skill.list()).find((item) => item.name === "orchestration-router") - expect(router?.description).toContain("even one project file") - expect(router?.description).toContain("isolated utility scripts") - expect(router?.content).toContain('workflow(action="read"') - expect(router?.content).toContain("retarget the objective") + expect((yield* skill.list()).some((item) => item.name === "orchestration-router")).toBe(false) }), ) diff --git a/packages/opencode/script/dag-template-files.ts b/packages/opencode/script/dag-template-files.ts new file mode 100644 index 0000000000..36a4bd0c84 --- /dev/null +++ b/packages/opencode/script/dag-template-files.ts @@ -0,0 +1,40 @@ +import path from "node:path" +import { Schema } from "effect" + +const RuntimeCompat = Schema.Struct({ + runtime_repo: Schema.String, + runtime_commit: Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}$/)), +}) + +const decodeRuntimeCompat = Schema.decodeUnknownSync(RuntimeCompat) + +/** One root-only discovery contract shared by validation, generation, and packaging. */ +export async function discoverDagTemplateFiles(directory: string) { + const files = await Promise.all( + ["*.yaml", "*.yml"].map((pattern) => Array.fromAsync(new Bun.Glob(pattern).scan(directory))), + ) + const discovered = [...new Set(files.flat())].sort() + const names = new Map() + for (const file of discovered) { + const name = path.basename(file, path.extname(file)) + const previous = names.get(name) + if (previous) { + throw new Error(`DAG template name is duplicated across .yaml/.yml files: ${name} (${previous}, ${file})`) + } + names.set(name, file) + } + return discovered +} + +/** A template directory is releasable only when it pins one exact runtime. */ +export async function readRuntimeCompat(directory: string) { + const filepath = path.join(directory, "runtime-compat.json") + if (!(await Bun.file(filepath).exists())) { + throw new Error(`runtime compatibility file is missing: ${filepath}`) + } + try { + return decodeRuntimeCompat(await Bun.file(filepath).json()) + } catch (error) { + throw new Error(`runtime compatibility file is invalid: ${filepath}: ${String(error)}`, { cause: error }) + } +} diff --git a/packages/opencode/script/dag-template-validation.ts b/packages/opencode/script/dag-template-validation.ts new file mode 100644 index 0000000000..df1b5b7e68 --- /dev/null +++ b/packages/opencode/script/dag-template-validation.ts @@ -0,0 +1,60 @@ +import path from "node:path" +import { Effect } from "effect" +import { WorkflowAuthoring } from "../src/dag/authoring" +import { discoverDagTemplateFiles, readRuntimeCompat } from "./dag-template-files" + +/** One directory-to-validation-result boundary shared by CI and generation. */ +export async function validateDagTemplateDirectory(directory: string) { + const compat = await readRuntimeCompat(directory).then( + (value) => ({ value, error: undefined }), + (error: unknown) => ({ + value: undefined, + error: error instanceof Error ? error.message : String(error), + }), + ) + const discovery = await discoverDagTemplateFiles(directory).then( + (files) => ({ files, error: undefined }), + (error: unknown) => ({ + files: [], + error: error instanceof Error ? error.message : String(error), + }), + ) + const authoring = WorkflowAuthoring.make() + const results = await Promise.all( + discovery.files.map(async (file) => { + const content = await Bun.file(path.join(directory, file)).text() + const result = await Effect.runPromise( + authoring.prepare({ + action: "start", + source: { kind: "yaml", source: file, content }, + profile: "portable", + }), + ) + return { name: file, content, valid: result.valid, errors: result.errors, warnings: result.warnings } + }), + ) + return { + compat: compat.value, + compat_error: compat.error, + discovery_error: discovery.error, + results, + } +} + +export function dagTemplateDirectoryFailure(result: Awaited>) { + if (result.compat_error) return result.compat_error + if (result.discovery_error) return result.discovery_error + const invalid = result.results.filter((entry) => !entry.valid) + if (invalid.length > 0) { + return `DAG template validation failed:\n${invalid + .flatMap((entry) => + entry.errors.map( + (diagnostic) => + `- ${entry.name} [${diagnostic.code}] ${diagnostic.path}: ${diagnostic.message}`, + ), + ) + .join("\n")}` + } + if (result.results.length === 0) return "no templates found in directory" + return undefined +} diff --git a/packages/opencode/script/evidence-schema-capture.ts b/packages/opencode/script/evidence-schema-capture.ts new file mode 100644 index 0000000000..4895d14a0e --- /dev/null +++ b/packages/opencode/script/evidence-schema-capture.ts @@ -0,0 +1,64 @@ +/* oxlint-disable typescript-eslint/no-unsafe-type-assertion -- This one-shot evidence script intentionally traverses provider-transformed recursive JSON Schema values. */ +// One-shot evidence capture for change repair-workflow-authoring-validation +// (task 1.4, recaptured after the review-remediation admission change): +// records the POST-change provider-facing workflow schema so provider-shape +// regressions surface as fixture diffs. The PRE-change fixture +// (workflow-parameters-pre-change.json) is immutable red evidence — never +// regenerate it; it documents the failure mode the switch fixed. +import path from "node:path" +import { Parameters } from "../src/tool/workflow" +import { ToolJsonSchema } from "../src/tool/json-schema" +import { ProviderTransform } from "../src/provider/transform" + +const schema = ToolJsonSchema.fromSchema(Parameters as never) as JsonSchemaNode +const flat = JSON.stringify(schema) + +const providers: Record = { + openai: { providerID: "openai", api: { id: "gpt-4.1", npm: "@ai-sdk/openai" } }, + azure: { providerID: "azure", api: { id: "gpt-4.1", npm: "@ai-sdk/azure" } }, + gemini: { providerID: "google", api: { id: "gemini-3-pro", npm: "@ai-sdk/google" } }, +} + +type JsonSchemaNode = { + anyOf?: JsonSchemaNode[] + required?: string[] + properties?: Record + items?: JsonSchemaNode + [key: string]: unknown +} + +const evidence: Record = { + captured_from: "packages/opencode/src/tool/workflow.ts (discriminated-union Parameters)", + schema_bytes: Buffer.byteLength(flat, "utf8"), + branch_count: (schema.anyOf ?? []).length, + session_id_exposed: flat.includes('"session_id"'), + project_id_exposed: flat.includes('"project_id"'), + transformed: {} as Record, +} + +for (const [name, model] of Object.entries(providers)) { + const transformed = ProviderTransform.schema(model as never, JSON.parse(flat)) as JsonSchemaNode + const branches = transformed.anyOf ?? [] + const startInline = branches.find( + (branch) => { + const actions = branch.properties?.action?.enum + return Array.isArray(actions) && actions.includes("start") && branch.properties?.["spec"] !== undefined + }, + ) + const config = startInline?.properties?.["spec"]?.properties?.["config"] + const configBranches = config?.anyOf ?? [] + const blocksBranch = configBranches.find((branch) => branch.properties?.["blocks"] !== undefined) + const nodesBranch = configBranches.find((branch) => branch.properties?.["nodes"] !== undefined) + ;(evidence.transformed as Record)[name] = { + bytes: Buffer.byteLength(JSON.stringify(transformed), "utf8"), + branch_count: branches.length, + start_inline_spec_config_present: config !== undefined, + blocks_branch_fields: Object.keys(blocksBranch?.properties ?? {}), + block_item_fields: Object.keys(blocksBranch?.properties?.["blocks"]?.items?.properties ?? {}), + node_item_fields: Object.keys(nodesBranch?.properties?.["nodes"]?.items?.properties ?? {}), + } +} + +const out = path.join(import.meta.dir, "..", "test", "tool", "fixtures", "workflow-parameters-post-change.json") +await Bun.file(out).write(JSON.stringify(evidence, null, 2) + "\n") +console.log(JSON.stringify(evidence, null, 2)) diff --git a/packages/opencode/script/generate.ts b/packages/opencode/script/generate.ts index aa4ffdd976..c6b938b150 100644 --- a/packages/opencode/script/generate.ts +++ b/packages/opencode/script/generate.ts @@ -1,6 +1,7 @@ import { existsSync } from "fs" import path from "path" import { fileURLToPath } from "url" +import { dagTemplateDirectoryFailure, validateDagTemplateDirectory } from "./dag-template-validation" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -57,12 +58,15 @@ async function loadDagTemplatesData() { `DAG_TEMPLATES_DIR points to a missing directory: ${templatesDir} — check the release workflow's Extract Templates step path conversion`, ) } - const templates: Record = {} - for (const file of await Array.fromAsync(new Bun.Glob("*.yaml").scan({ cwd: templatesDir }))) { - const name = file.replace(/\.ya?ml$/, "") - templates[name] = await Bun.file(path.join(templatesDir, file)).text() - } - console.log(`Loaded dag templates snapshot from ${templatesDir}: ${Object.keys(templates).length} templates`) + const validation = await validateDagTemplateDirectory(templatesDir) + const failure = dagTemplateDirectoryFailure(validation) + if (failure) throw new Error(failure) + const templates = Object.fromEntries( + validation.results.map((entry) => [entry.name.replace(/\.ya?ml$/, ""), entry.content]), + ) + console.log( + `Loaded dag templates snapshot from ${templatesDir}: ${Object.keys(templates).length} templates (all validated)`, + ) return JSON.stringify(templates) } diff --git a/packages/opencode/script/package-cli-artifact.ts b/packages/opencode/script/package-cli-artifact.ts new file mode 100644 index 0000000000..4bfc59b053 --- /dev/null +++ b/packages/opencode/script/package-cli-artifact.ts @@ -0,0 +1,69 @@ +import fs from "node:fs/promises" +import path from "node:path" + +const distDir = process.argv[2] +const outArchive = process.argv[3] +if (!distDir || !outArchive) { + console.error("usage: package-cli-artifact.ts ") + process.exit(2) +} + +const resolvedDist = path.resolve(distDir) +const resolvedArchive = path.resolve(outArchive) +const binDir = path.join(resolvedDist, "bin") +const repoRoot = path.resolve(import.meta.dir, "..", "..", "..") +const distributionFiles = [ + "NOTICE", + "LICENSE", + "packages/core/src/dag/LICENSE", + "packages/opencode/src/dag/LICENSE", + "third_party/mattpocock-skills/LICENSE", + "third_party/mattpocock-skills/SOURCE.md", +] as const + +for (const name of distributionFiles) { + await fs.mkdir(path.dirname(path.join(binDir, name)), { recursive: true }) + await fs.copyFile(path.join(repoRoot, name), path.join(binDir, name)) +} + +const archive = resolvedArchive.endsWith(".tar.gz") + ? Bun.spawnSync({ + cmd: ["tar", "-czf", resolvedArchive, "-C", binDir, "."], + stdout: "pipe", + stderr: "pipe", + }) + : packageZip(binDir, resolvedArchive) + +if (archive.exitCode !== 0) { + process.stderr.write(archive.stderr.toString()) + console.error(`CLI packaging failed: ${resolvedArchive}`) + process.exit(1) +} + +console.log( + JSON.stringify({ + packager: "opencode cli packager v1", + archive: resolvedArchive, + distribution_files: distributionFiles, + }), +) + +function packageZip(directory: string, archive: string) { + const zip = Bun.which("zip") + if (zip) { + return Bun.spawnSync({ cmd: [zip, "-r", archive, "."], cwd: directory, stdout: "pipe", stderr: "pipe" }) + } + const sevenZip = Bun.which("7z") + if (sevenZip) { + return Bun.spawnSync({ cmd: [sevenZip, "a", archive, "."], cwd: directory, stdout: "pipe", stderr: "pipe" }) + } + const powershell = Bun.which("pwsh") ?? Bun.which("powershell") + if (!powershell) throw new Error("CLI packaging requires zip, 7z, pwsh, or powershell") + const destination = archive.replaceAll("'", "''") + return Bun.spawnSync({ + cmd: [powershell, "-NoProfile", "-Command", `Compress-Archive -Path * -DestinationPath '${destination}' -Force`], + cwd: directory, + stdout: "pipe", + stderr: "pipe", + }) +} diff --git a/packages/opencode/script/package-dag-templates.ts b/packages/opencode/script/package-dag-templates.ts new file mode 100644 index 0000000000..a2264f7bf7 --- /dev/null +++ b/packages/opencode/script/package-dag-templates.ts @@ -0,0 +1,103 @@ +/** + * Release packaging gate (change repair-workflow-authoring-validation, §6). + * + * One executable shape for the release-fork package-templates job: validate + * (fail closed) → copy validated root YAML plus provenance/license files → + * tar.gz → manifest JSON on + * stdout. release-fork.yml and the packaging smoke test invoke this same + * script, so CI and the test can never drift apart on the copy/tar contract. + * + * Usage: bun run script/package-dag-templates.ts + */ + +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Schema } from "effect" + +const templatesDir = process.argv[2] +const outArchive = process.argv[3] +if (!templatesDir || !outArchive) { + console.error("usage: package-dag-templates.ts ") + process.exit(2) +} + +const resolvedDir = path.resolve(templatesDir) +const resolvedArchive = path.resolve(outArchive) +const distributionFiles = [ + "THIRD_PARTY_NOTICES.md", + "third_party/mattpocock-skills/LICENSE", + "third_party/mattpocock-skills/SOURCE.md", +] as const + +const PackagingReport = Schema.Struct({ + results: Schema.Array(Schema.Struct({ name: Schema.String, valid: Schema.Boolean })), + runtime_commit: Schema.optional(Schema.String), + template_commit: Schema.optional(Schema.String), + compat_runtime_sha: Schema.optional(Schema.String), +}) + +const validation = Bun.spawnSync({ + cmd: ["bun", path.join(import.meta.dir, "validate-dag-templates.ts"), resolvedDir], + cwd: path.resolve(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", +}) +process.stderr.write(validation.stderr.toString()) +if (validation.exitCode !== 0) { + console.error("Packaging aborted: template validation failed (nothing was archived).") + process.exit(validation.exitCode === 0 ? 1 : validation.exitCode) +} + +const report = Schema.decodeUnknownSync(PackagingReport)(JSON.parse(validation.stdout.toString())) +const files = report.results + .filter((entry) => entry.valid) + .map((entry) => entry.name) + .sort() +if (files.length === 0) { + console.error("Packaging aborted: no valid templates to archive.") + process.exit(1) +} + +const staging = await fs.mkdtemp(path.join(os.tmpdir(), "dag-template-dist-")) +try { + for (const name of files) { + await fs.copyFile(path.join(resolvedDir, name), path.join(staging, name)) + } + await fs.copyFile(path.join(resolvedDir, "runtime-compat.json"), path.join(staging, "runtime-compat.json")) + for (const name of distributionFiles) { + await fs.mkdir(path.dirname(path.join(staging, name)), { recursive: true }) + await fs.copyFile(path.join(resolvedDir, name), path.join(staging, name)) + } + const tar = Bun.spawnSync({ + cmd: ["tar", "-czf", resolvedArchive, "-C", staging, "."], + stdout: "pipe", + stderr: "pipe", + }) + if (tar.exitCode !== 0) { + process.stderr.write(tar.stderr.toString()) + console.error("Packaging aborted: tar failed.") + process.exit(1) + } +} finally { + await fs.rm(staging, { recursive: true, force: true }) +} + +console.log( + JSON.stringify( + { + packager: "opencode dag-template-packager v1", + archive: resolvedArchive, + files: [...files, "runtime-compat.json", ...distributionFiles].sort(), + template_files: files, + file_count: files.length + distributionFiles.length + 1, + template_count: files.length, + runtime_commit: report.runtime_commit, + template_commit: report.template_commit, + compat_runtime_sha: report.compat_runtime_sha, + }, + null, + 2, + ), +) +console.error(`Templates packaged: ${files.length} files (all validated) → ${resolvedArchive}`) diff --git a/packages/opencode/script/validate-dag-templates.ts b/packages/opencode/script/validate-dag-templates.ts new file mode 100644 index 0000000000..4eec643946 --- /dev/null +++ b/packages/opencode/script/validate-dag-templates.ts @@ -0,0 +1,64 @@ +/** + * Directory-level template validator (change repair-workflow-authoring-validation, §4.3). + * + * Reuses the runtime source-to-graph authority (WorkflowAuthoring) so + * config-repo CI, release packaging, and /dag-template-update all enforce the + * same portable contract. Emits machine-readable diagnostics plus the runtime, + * template, and compatibility commit identifiers, and exits non-zero when any + * template is invalid. + * + * Usage: bun run script/validate-dag-templates.ts + */ + +import path from "node:path" +import { dagTemplateDirectoryFailure, validateDagTemplateDirectory } from "./dag-template-validation" + +const templatesDir = process.argv[2] +if (!templatesDir) { + console.error("usage: validate-dag-templates.ts ") + process.exit(2) +} + +const resolvedDir = path.resolve(templatesDir) + +async function gitHead(cwd: string): Promise { + try { + const result = await Bun.$`git rev-parse HEAD`.cwd(cwd).quiet() + return result.text().trim() || undefined + } catch { + return undefined + } +} + +const validation = await validateDagTemplateDirectory(resolvedDir) +const invalid = validation.results.filter((entry) => !entry.valid) +const report = { + validator: "opencode WorkflowAuthoring.portable v1", + templates_dir: resolvedDir, + runtime_commit: await gitHead(path.resolve(import.meta.dir, "..", "..", "..")), + template_commit: await gitHead(resolvedDir), + compat_runtime_sha: validation.compat?.runtime_commit, + compat_error: validation.compat_error, + discovery_error: validation.discovery_error, + template_count: validation.results.length, + valid_count: validation.results.length - invalid.length, + invalid_count: invalid.length, + results: validation.results.map((entry) => ({ + name: entry.name, + valid: entry.valid, + errors: entry.errors, + warnings: entry.warnings, + })), +} + +// Machine-readable report goes to stdout; human summaries go to stderr so +// callers can `JSON.parse(stdout)` without stripping trailers. +console.log(JSON.stringify(report, null, 2)) +const failure = dagTemplateDirectoryFailure(validation) +if (failure) { + console.error(`Template validation failed: ${failure}`) + process.exit(1) +} +console.error( + `Template validation passed: ${validation.results.length} of ${validation.results.length} templates valid`, +) diff --git a/packages/opencode/src/dag/CONTEXT.md b/packages/opencode/src/dag/CONTEXT.md new file mode 100644 index 0000000000..063b7ade16 --- /dev/null +++ b/packages/opencode/src/dag/CONTEXT.md @@ -0,0 +1,44 @@ +# Workflow Orchestration Context + +Workflow Orchestration turns one user objective into one durable DAG. It supports saved or inline custom workflows and recommends heuristic composition from reusable Blocks. Low-level Nodes remain available when a Block route cannot express the objective. + +## Glossary + +| Term | Meaning | +| --- | --- | +| Workflow Source | An inline object or YAML document supplied to start, extend, replan, read, validate, or release tooling. | +| Workflow Authoring Check | The side-effect-free source-to-graph boundary that parses, normalizes file compatibility, decodes the action shape, compiles Blocks, applies the selected validation profile, and returns diagnostics or a Prepared Workflow Graph. | +| Prepared Workflow Graph | A strictly decoded and compiled graph that passed the requested authoring checks and is ready for a runtime mutation. | +| Workflow Route | A complete Block or Node composition selected for one objective. It may be custom, saved, or assembled heuristically. | +| Orchestration Router | The product-owned parent guidance that qualifies an objective and selects one Workflow Route without external Skill discovery. | +| Block Composer | The Orchestration Router decision that selects the smallest Block graph justified by current evidence. | +| Decision Checkpoint | One parent-owned confirmation for unresolved user choices that materially change behavior, scope, acceptance, or an irreversible boundary. | +| Workflow Brief | The recommended route, scope, acceptance evidence, assumptions, risks, and material alternatives presented at a Decision Checkpoint. | +| Block | A reusable high-level orchestration capability such as explore, plan, debug, coding, verify, or review. Blocks compile into Nodes. | +| Node | A low-level durable unit of child-agent work with dependencies, prompt input, policy, and output contract. | +| Validation Profile | `portable` checks source-contained structure without user environment state; `environment` additionally resolves live agents, prompt assets, and models. | +| Runtime Admission | The READY/WAIVED gate for a deep workflow. It is a lifecycle policy after authoring, not another name for Workflow Authoring Check. | + +## Invariants + +- One user objective has at most one live DAG; route expansion stays inside that DAG. +- Block composition is the recommended authoring path and is selected heuristically from the objective; custom Blocks/Nodes remain supported. +- Workflow Authoring Check is the only raw source-to-Prepared Workflow Graph authority used by tool actions, CLI, generation, and packaging. +- Parsing, file-only compatibility, strict action decoding, Block compilation, and profile diagnostics are not reimplemented by callers. +- `portable` validation does not load user environment catalogs. `environment` validation reads current catalogs and verifies actual model availability. +- No workflow event or durable mutation occurs before a valid Prepared Workflow Graph exists. +- The model-facing schema contains fields the model owns. Session/Project identity, admission audit state, model assignment, and other runtime-derived fields remain hidden. +- Legacy YAML may be adapted at the file boundary without making legacy fields valid inline input. +- Runtime Admission and Workflow Authoring Check have separate names, state, and responsibilities. + +## Boundaries + +- `WorkflowAuthoring` owns source interpretation and authoring diagnostics. +- `DagWorkflows` owns saved-source discovery, scope precedence, and presentation metadata; it does not decide startability. +- `Dag` owns durable lifecycle invariants, event publication, and runtime transitions for already prepared graphs. +- Provider/Agent/Skill/prompt catalogs own environment facts; the authoring boundary consumes current snapshots without becoming their source of truth. +- Release/config tooling invokes the same portable authoring boundary and adds repository compatibility and packaging gates. + +## Decisions + +- [ADR-0001: One Workflow Authoring Check authority](docs/adr/0001-workflow-authoring-check.md) diff --git a/packages/opencode/src/dag/authoring.ts b/packages/opencode/src/dag/authoring.ts new file mode 100644 index 0000000000..18fcceee85 --- /dev/null +++ b/packages/opencode/src/dag/authoring.ts @@ -0,0 +1,338 @@ +/** + * The only source-to-prepared-graph seam for workflow authoring. + * + * Callers authorize and read a source; this module owns every interpretation + * step after that boundary: YAML parsing, file-only legacy normalization, + * strict action decode, block compilation, profile validation, diagnostics, + * and content-addressed result caching. + */ +export * as WorkflowAuthoring from "./authoring" + +import { Effect, Schema } from "effect" +import type { NodeConfig, NodeDefaults, WorkflowConfig } from "./dag" +import type { AdmissionInput } from "./admission" +import { DagValidation } from "./validation" + +type Action = "start" | "extend" | "replan" + +type Source = { kind: "inline"; value: unknown; source?: string } | { kind: "yaml"; content: string; source: string } + +interface EnvironmentContext { + directory?: string + parent?: { id: string; providerID: string } +} + +type PreparedGraph = + | { + action: "start" + nodes: NodeConfig[] + title: string + config: Omit + admission?: AdmissionInput + } + | { action: "extend"; nodes: NodeConfig[] } + | { action: "replan"; nodes: NodeConfig[] } + +interface Result extends DagValidation.ValidationResult { + /** Strict decoded document. Boundary-owned legacy fields are not exposed. */ + document?: unknown + prepared?: PreparedGraph +} + +interface PrepareInput { + action: Action + source: Source + profile?: DagValidation.Profile + environment?: EnvironmentContext + known_dependencies?: string[] + node_defaults?: NodeDefaults +} + +interface Options { + loadEnvironment?: (context: EnvironmentContext) => Effect.Effect +} + +type DecodedAction = + | { action: "start"; spec: DagValidation.StartSpec } + | { action: "extend"; spec: DagValidation.ExtendGraph } + | { action: "replan"; spec: { fragment: DagValidation.StartGraph } } + +const VALIDATOR_VERSION = 1 +const ModelRef = Schema.Struct({ providerID: Schema.String, modelID: Schema.String }) +const decodeModelRef = Schema.decodeUnknownOption(ModelRef, DagValidation.STRICT_PARSE_OPTIONS) + +export function make(options: Options = {}) { + const cache = new Map() + + const prepare = (input: PrepareInput): Effect.Effect => + Effect.gen(function* () { + const profile = input.profile ?? "portable" + const sourceName = input.source.kind === "yaml" ? input.source.source : (input.source.source ?? "") + const key = cacheKey(input, profile) + // Environment catalogs are live state (models and agents may + // change during the tool instance), so only portable results are safe + // to cache by source content. + const cached = profile === "portable" ? cache.get(key) : undefined + if (cached) return cached + + const parsed = parseSource(input.source, input.action, profile) + if (!parsed.value) { + if (profile === "portable") cache.set(key, parsed.result) + return parsed.result + } + const decoded = decodeAction(input.action, parsed.value.value, sourceName, profile) + if (!decoded.value) { + const result = { ...decoded.result, document: parsed.value.value } + if (profile === "portable") cache.set(key, result) + return result + } + const compiled = compileAction(decoded.value, input.known_dependencies) + if (!compiled.nodes) { + const result = { ...invalidResult(sourceName, profile, compiled.diagnostics), document: decoded.value.spec } + if (profile === "portable") cache.set(key, result) + return result + } + if (profile === "environment" && !options.loadEnvironment) { + return { + ...invalidResult(sourceName, profile, [ + DagValidation.diagnostic({ + code: DagValidation.DIAGNOSTIC_CODES.environmentUnavailable, + path: "$environment", + message: "environment validation requires a live catalog loader", + hint: "Provide agents, prompt assets, and model resolution for environment validation", + }), + ]), + document: decoded.value.spec, + } + } + + const modeledNodes = applyNodeModels(compiled.nodes, parsed.value.nodes) + const nodes = + input.action === "replan" && parsed.value.defaultModel + ? modeledNodes.map((node) => (node.model ? node : { ...node, model: parsed.value.defaultModel })) + : modeledNodes + const baseDefaults = input.action === "start" ? compiled.node_defaults : input.node_defaults + const nodeDefaults = parsed.value.defaultModel + ? { ...baseDefaults, model: parsed.value.defaultModel } + : baseDefaults + const catalogs = + profile === "environment" && options.loadEnvironment + ? yield* options.loadEnvironment(input.environment ?? {}) + : undefined + const validation = yield* DagValidation.validatePostCompile({ + source: sourceName, + profile, + config: { + ...compiled.config, + ...(nodeDefaults ? { node_defaults: nodeDefaults } : {}), + }, + nodes, + blocks: compiled.blocks, + directory: input.environment?.directory, + catalogs, + structural: input.action === "start", + }) + const prepared = validation.valid ? prepareGraph(decoded.value, nodes, nodeDefaults) : undefined + const result = { + ...validation, + document: decoded.value.spec, + ...(prepared ? { prepared } : {}), + } satisfies Result + if (profile === "portable") cache.set(key, result) + return result + }) + + return { prepare } +} + +function prepareGraph(decoded: DecodedAction, nodes: NodeConfig[], nodeDefaults?: NodeDefaults): PreparedGraph { + if (decoded.action !== "start") return { action: decoded.action, nodes } + const spec = decoded.spec + return { + action: decoded.action, + nodes, + title: spec.title ?? spec.config.name, + config: { + name: spec.config.name, + mode: spec.mode ?? "standard", + ...(spec.config.max_concurrency !== undefined ? { max_concurrency: spec.config.max_concurrency } : {}), + ...(spec.config.max_node_replan_attempts !== undefined + ? { max_node_replan_attempts: spec.config.max_node_replan_attempts } + : {}), + ...(spec.config.max_total_nodes !== undefined ? { max_total_nodes: spec.config.max_total_nodes } : {}), + ...(nodeDefaults ? { node_defaults: nodeDefaults } : {}), + nodes, + }, + ...(spec.admission ? { admission: spec.admission } : {}), + } +} + +interface LegacyModels { + nodes: ReadonlyMap + defaultModel?: { modelID: string; providerID: string } +} + +interface ParsedValue extends LegacyModels { + value: unknown +} + +function parseSource( + source: Source, + action: Action, + profile: DagValidation.Profile, +): { value: ParsedValue; result?: never } | { value?: never; result: Result } { + if (source.kind === "inline") { + return { value: { value: source.value, nodes: new Map() } } + } + const parsed = DagValidation.parseYaml(source.content) + if (!parsed.parsed) { + return { result: invalidResult(source.source, profile, [parsed.diagnostic]) } + } + return { value: normalizeLegacyFile(action, parsed.value) } +} + +function decodeAction(action: Action, value: unknown, source: string, profile: DagValidation.Profile) { + const options = { + ...DagValidation.STRICT_PARSE_OPTIONS, + errors: "all", + } as const + if (action === "start") { + const decoded = Schema.decodeUnknownResult(DagValidation.StartSpec, options)(value) + if (decoded._tag === "Success") return { value: { action, spec: decoded.success } } as const + return { result: invalidResult(source, profile, DagValidation.schemaDiagnostics(decoded.failure)) } + } + if (action === "extend") { + const decoded = Schema.decodeUnknownResult(DagValidation.ExtendSpec, options)(value) + if (decoded._tag === "Success") return { value: { action, spec: decoded.success } } as const + return { result: invalidResult(source, profile, DagValidation.schemaDiagnostics(decoded.failure)) } + } + const decoded = Schema.decodeUnknownResult(DagValidation.ReplanSpec, options)(value) + if (decoded._tag === "Success") return { value: { action, spec: decoded.success } } as const + return { result: invalidResult(source, profile, DagValidation.schemaDiagnostics(decoded.failure)) } +} + +function compileAction( + decoded: DecodedAction, + knownDependencies?: string[], +): { + nodes?: NodeConfig[] + diagnostics: DagValidation.Diagnostic[] + blocks?: readonly import("./blocks").DagBlocks.WorkflowBlock[] + node_defaults?: NodeDefaults + config: { mode?: "standard" | "deep"; max_total_nodes?: number } +} { + if (decoded.action === "extend") { + const extend = decoded.spec + const compiled = DagValidation.compileBlockSource(extend, { known_dependencies: knownDependencies }) + return { + ...compiled, + blocks: "blocks" in extend ? extend.blocks : undefined, + config: {}, + } + } + const graph = decoded.action === "start" ? decoded.spec.config : decoded.spec.fragment + const compiled = DagValidation.compileGraphSource(graph, { known_dependencies: knownDependencies }) + return { + ...compiled, + blocks: "blocks" in graph ? graph.blocks : undefined, + node_defaults: graph.node_defaults, + config: decoded.action === "start" ? { ...graph, mode: decoded.spec.mode } : graph, + } +} + +function invalidResult( + source: string, + profile: DagValidation.Profile, + diagnostics: DagValidation.Diagnostic[], +): Result { + const errors = DagValidation.sortDiagnostics(diagnostics.filter((diagnostic) => diagnostic.severity === "error")) + return { + source, + profile, + valid: errors.length === 0, + errors, + warnings: DagValidation.sortDiagnostics(diagnostics.filter((diagnostic) => diagnostic.severity === "warning")), + nodes: [], + } +} + +function normalizeLegacyFile(action: Action, value: unknown): ParsedValue { + const stripped = action === "start" ? stripPersistedWorkflowFields(value) : value + if (!isRecord(stripped)) return { value: stripped, nodes: new Map() } + const graphKey = action === "start" ? "config" : action === "replan" ? "fragment" : undefined + const graph = graphKey ? stripped[graphKey] : stripped + const normalized = normalizeLegacyGraph(graph) + return { + value: graphKey ? { ...stripped, [graphKey]: normalized.graph } : normalized.graph, + nodes: normalized.nodes, + ...(normalized.defaultModel ? { defaultModel: normalized.defaultModel } : {}), + } +} + +function normalizeLegacyGraph(value: unknown): { + graph: unknown + nodes: Map + defaultModel?: { modelID: string; providerID: string } +} { + if (!isRecord(value)) return { graph: value, nodes: new Map() } + const nodeModels = new Map() + const nodes = Array.isArray(value.nodes) + ? value.nodes.map((node) => { + if (!isRecord(node)) return node + const model = decodeModelRef(node.model) + if (model._tag === "None" || typeof node.id !== "string") return node + nodeModels.set(node.id, model.value) + const normalized = { ...node } + delete normalized.model + return normalized + }) + : value.nodes + const defaults = isRecord(value.node_defaults) ? { ...value.node_defaults } : value.node_defaults + const defaultModel = isRecord(defaults) ? decodeModelRef(defaults.model) : undefined + if (isRecord(defaults) && defaultModel?._tag === "Some") delete defaults.model + return { + graph: { + ...value, + ...(nodes ? { nodes } : {}), + ...(defaults ? { node_defaults: defaults } : {}), + }, + nodes: nodeModels, + ...(defaultModel?._tag === "Some" ? { defaultModel: defaultModel.value } : {}), + } +} + +function stripPersistedWorkflowFields(value: unknown) { + if (!isRecord(value) || !isRecord(value.admission)) return value + const admission = { ...value.admission } + delete admission.protocol_version + delete admission.state + delete admission.fingerprint + return { ...value, admission } +} + +function applyNodeModels(nodes: NodeConfig[], models: LegacyModels["nodes"]) { + return nodes.map((node) => { + const model = models.get(node.id) + return model ? { ...node, model } : node + }) +} + +function cacheKey(input: PrepareInput, profile: DagValidation.Profile) { + const content = + input.source.kind === "yaml" ? input.source.content : (JSON.stringify(input.source.value) ?? "undefined") + const context = JSON.stringify({ + version: VALIDATOR_VERSION, + action: input.action, + profile, + source: input.source.kind === "yaml" ? input.source.source : (input.source.source ?? ""), + directory: input.environment?.directory, + parent: input.environment?.parent, + known_dependencies: input.known_dependencies, + node_defaults: input.node_defaults, + }) + return new Bun.CryptoHasher("sha256").update(`${context}\0${content}`).digest("hex") +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index b88415b434..ecc66d0775 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -25,9 +25,6 @@ export class WorkflowBlock extends Schema.Class("WorkflowBlock")( instruction: Schema.optional(Schema.String).annotate({ description: "Task-specific instruction added to the block's built-in execution contract", }), - skills: Schema.optional(Schema.Array(Schema.String)).annotate({ - description: "Relevant skills the child should load lazily before working", - }), worker_type: Schema.optional(Schema.String).annotate({ description: "Optional configured agent override; defaults from the block kind", }), @@ -99,20 +96,20 @@ const WRITER_KINDS = new Set(["coding", "prototype"]) const BLOCK_CONTRACTS: Record = { explore: - "Inspect the target read-only. Map relevant modules, constraints, existing conventions, and evidence with file references. Do not implement.", - plan: "Produce an implementation-ready plan from repository evidence and dependency outputs. Name seams, work packages, acceptance checks, and unresolved risks. Do not implement.", + "Inspect the target read-only and prefer primary repository or runtime evidence. Separate confirmed facts, inferences, and unknowns; map ownership, constraints, conventions, and file references. Return an evidence map that downstream blocks can cite. Do not implement or hide unresolved uncertainty.", + plan: "Produce a decision- or implementation-ready plan from repository evidence and dependency outputs. State the selected boundary, ordered options or work packages, dependencies, acceptance checks, falsifiers, and unresolved risks. Stop rather than inventing a user-owned product decision. Do not implement.", prototype: - "Build only the smallest throwaway experiment needed to answer the stated uncertainty. Separate observations from production recommendations and do not integrate it unless explicitly instructed. Submit its changed-file list and a stable fingerprint so downstream verification and review can bind to the exact experiment.", + "Answer one falsifiable uncertainty with the smallest disposable experiment. State the hypothesis and success signal first, separate observations from inference, and do not integrate prototype code unless explicitly promoted by confirmed scope. Submit its changed-file list and a stable fingerprint so downstream verification and review bind to the exact experiment.", debug: - "Diagnose the smallest falsifiable root-cause hypothesis from reproduced evidence. Distinguish cause from symptom and identify the narrowest safe repair plus a regression check.", + "Minimize the reproduced failure, rank falsifiable hypotheses, instrument the discriminating boundary, and identify the smallest causal explanation. Distinguish cause from symptom and correlated damage. Return the narrowest safe repair boundary and a regression check that would fail without that repair; stop if evidence does not establish a cause.", coding: - "Implement the bounded production change. Follow repository instructions, preserve unrelated work, add or update focused tests, and run relevant checks. Submit the aggregate changed-file list and a stable fingerprint of the actual implementation state so downstream verification and review can detect stale evidence.", + "Implement only the bounded production change and preserve unrelated work. When an observable automated seam exists, establish a failing check, make the smallest change that passes it, then refactor without breaking the check; otherwise record the evidence-backed reason before implementation. Run focused checks and stop on ownership or interface drift. Submit the aggregate changed-file list and a stable fingerprint of the actual implementation state.", verify: - "Verify the supplied work against acceptance criteria using deterministic checks where available. Submit commands and evidence with an explicit PASS or FAIL verdict. Do not hide failures.", + "Verify the supplied work against every acceptance criterion using deterministic checks where available. Bind evidence to the supplied implementation fingerprint and submit exact commands, results, and an explicit PASS or FAIL verdict. Missing evidence or any failed required check is FAIL; do not repair or hide failures in this block.", review: - "Review independently against repository standards and the confirmed intent. Cite concrete evidence, separate blockers from suggestions, and identify claims that still need verification.", + "Review independently against repository standards and the confirmed intent. Bind findings to the supplied implementation fingerprint, cite concrete evidence, separate required actions from suggestions, and reject stale, duplicated, or unsupported claims. Do not implement fixes inside the review lane.", synthesize: - "Combine dependency outputs into one decision-ready result. Resolve conflicts using evidence, preserve material uncertainty, and state the outcome, rationale, residual risks, and next action.", + "Combine dependency outputs into one decision-ready result. Resolve conflicts by evidence strength, preserve material uncertainty, and state the outcome, rationale, acceptance evidence, residual risks, and next action. Do not invent consensus or new facts absent from dependency evidence.", } export function compileWorkflowBlocks( @@ -150,7 +147,6 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB dependencies, objective, instruction: block.instruction, - skills: block.skills, contract: "Reproduce or characterize the failure read-only where possible. Capture exact symptoms, commands, logs, boundaries, and the smallest falsifiable observations. Do not patch the code.", required: false, @@ -164,7 +160,6 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB dependencies: [evidenceID], objective, instruction: block.instruction, - skills: block.skills, contract: BLOCK_CONTRACTS.debug, required: block.required ?? true, reportToParent: block.report_to_parent ?? false, @@ -192,7 +187,6 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB dependencies, objective, instruction: block.instruction, - skills: block.skills, contract: `${BLOCK_CONTRACTS.review} Focus on documented repository standards, architecture constraints, correctness, and verification evidence.`, required: false, reportToParent: false, @@ -206,7 +200,6 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB dependencies, objective, instruction: block.instruction, - skills: block.skills, contract: `${BLOCK_CONTRACTS.review} Focus on the confirmed goal, scope, acceptance criteria, and user-visible behavior.`, required: false, reportToParent: false, @@ -220,7 +213,6 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB dependencies: [standardsID, intentID, ...(route ? [route.verification.id] : [])], objective, instruction: block.instruction, - skills: block.skills, contract: [ "Arbitrate the two independent reviews finding by finding.", route @@ -258,7 +250,6 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB dependencies, objective, instruction: block.instruction, - skills: block.skills, contract: BLOCK_CONTRACTS[block.kind], required, reportToParent: block.report_to_parent ?? block.kind === "synthesize", @@ -279,7 +270,6 @@ function node(input: { dependencies: readonly string[] objective: string instruction?: string - skills?: readonly string[] contract: string required: boolean reportToParent: boolean @@ -288,9 +278,6 @@ function node(input: { review?: NodeConfig["review"] outputSchema?: Record }): NodeConfig { - const skillInstruction = input.skills?.length - ? `Before working, load these relevant skills with the skill tool when available: ${input.skills.join(", ")}. If one is unavailable, state that limitation and continue from repository evidence.` - : "" const instruction = input.instruction?.trim() ? "Block-specific instruction:\n{{instruction}}" : "" return { id: input.id, @@ -303,7 +290,6 @@ function node(input: { inline: [ "Workflow objective:\n{{objective}}", instruction, - skillInstruction, input.contract, "Use dependency outputs as evidence and return a concise artifact that downstream blocks can consume. Do not ask the user questions from this child session.", ] @@ -374,7 +360,6 @@ function serializeWorkspaceWriters(blocks: WorkflowBlock[]) { kind: block.kind, depends_on: [...(block.depends_on ?? []), previous], instruction: block.instruction, - skills: block.skills, worker_type: block.worker_type, required: block.required, report_to_parent: block.report_to_parent, diff --git a/packages/opencode/src/dag/dag.ts b/packages/opencode/src/dag/dag.ts index 1545a2d5bc..ab03a1915d 100644 --- a/packages/opencode/src/dag/dag.ts +++ b/packages/opencode/src/dag/dag.ts @@ -9,9 +9,7 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex" import { isRecord } from "@/util/record" -import { validateRequiredNodes } from "@opencode-ai/core/dag/core/required-validator" -import { buildGraph, WorkflowRuntime, toSchedulingNodes } from "@opencode-ai/core/dag/core/scheduling" -import { CycleError } from "@opencode-ai/core/dag/core/graph" +import { WorkflowRuntime, toSchedulingNodes } from "@opencode-ai/core/dag/core/scheduling" import { planReplan } from "@opencode-ai/core/dag/core/replan" import { getValidNextWorkflowStatuses, @@ -29,10 +27,10 @@ import { transitionAdmission, validateAdmission, } from "./admission" -import { unresolvedReviewOutcomes, validateReviewLifecycle } from "./review-lifecycle" -import { conditionReference } from "./runtime/eval" -import { unsupportedSchemaKeywords } from "./runtime/capture" -import { placeholderKeys } from "./templates/resolve" +import { unresolvedReviewOutcomes } from "./review-lifecycle" +import { DagValidation, StructuralValidationError } from "./validation" + +export { StructuralValidationError } from "./validation" // Re-export domain types export const ID = DagEvent.DagID @@ -63,7 +61,7 @@ export interface NodeConfig { name: string worker_type: string depends_on: string[] - required: boolean + required?: boolean prompt_template: { id?: string; inline?: string; input?: Record } worker_config?: { timeout_ms?: number } input_mapping?: Record @@ -87,6 +85,10 @@ export interface NodeDefaults { model?: { modelID: string; providerID: string } } +interface NormalizedNodeConfig extends NodeConfig { + required: boolean +} + export interface WorkflowConfig { name: string mode?: ExecutionMode @@ -142,7 +144,7 @@ function normalizeNodeDefaults(defaults: NodeDefaults | undefined): NodeDefaults } } -function normalizeNodeConfig(node: NodeConfig, defaults: NodeDefaults): NodeConfig { +function normalizeNodeConfig(node: NodeConfig, defaults: NodeDefaults): NormalizedNodeConfig { const model = normalizeModel(node.model ?? defaults.model) return { ...node, @@ -162,13 +164,17 @@ function normalizeNodeConfig(node: NodeConfig, defaults: NodeDefaults): NodeConf // back to 10min — implicit budget shortening). The replace bucket (definition // replaced, execution kept) preserves the existing node's timeout for the // merged config and the deadline recompute. -function normalizeFragmentNode(node: NodeConfig, existingTimeoutMs: number | undefined, defaults: NodeDefaults): NodeConfig { +function normalizeFragmentNode( + node: NodeConfig, + existingTimeoutMs: number | undefined, + defaults: NodeDefaults, +): NormalizedNodeConfig { const timeoutMs = node.worker_config?.timeout_ms ?? existingTimeoutMs const withTimeout = timeoutMs == null ? node : { ...node, worker_config: { ...node.worker_config, timeout_ms: timeoutMs } } return normalizeNodeConfig(withTimeout, defaults) } -function normalizeWorkflowConfig(config: WorkflowConfig): WorkflowConfig { +function normalizeWorkflowConfig(config: WorkflowConfig): WorkflowConfig & { nodes: NormalizedNodeConfig[] } { const defaults = normalizeNodeDefaults(config.node_defaults) return { ...config, @@ -232,62 +238,11 @@ export function parseWorkflowConfig(raw: string): WorkflowConfig | undefined { return parsed.value as WorkflowConfig } -/** - * A parseable condition may only reference the node's direct dependencies — - * anything else silently resolves to undefined and evaluates false at spawn - * time. Shared by create (all nodes) and replan (fragment nodes). - */ -function conditionReferenceErrors(nodes: readonly NodeConfig[]): string[] { - return nodes.flatMap((node) => { - const ref = conditionReference(node.condition) - if (!ref || node.depends_on.includes(ref)) return [] - return [ - `node "${node.id}" condition references "${ref}" which is not in its depends_on (condition inputs come from direct dependencies only; this would silently evaluate false)`, - ] - }) -} - -/** - * An inline prompt_template may only reference variables that have a binding - * source: static prompt_template.input keys, input_mapping target names, or — - * when input_mapping is omitted — the direct depends_on ids that feed the - * spawn-time input. Anything else is guaranteed to die at spawn (verdict_fail: - * Unresolved template placeholders), so rejecting at acceptance removes the - * "Added, then spawn-dead" silent window. `id` templates are read lazily from - * disk and cannot be binding-checked here; spawn-time enforcement still - * covers them. - */ -function templateBindingErrors(nodes: readonly NodeConfig[]): string[] { - return nodes.flatMap((node) => { - const template = node.prompt_template.inline - if (template === undefined) return [] - const bound = new Set([ - ...Object.keys(node.prompt_template.input ?? {}), - ...Object.keys(node.input_mapping ?? Object.fromEntries(node.depends_on.map((dep) => [dep, dep]))), - ]) - return placeholderKeys(template) - .filter((key) => !bound.has(key)) - .map((key) => - `node "${node.id}" prompt_template references unbound variable "{{${key}}}" (bind it via prompt_template.input, input_mapping, or depends_on)`, - ) - }) -} - -// The runtime validator enforces a JSON Schema subset; anything outside it is -// inert. Warn (not reject) at create/replan so authors learn their constraint -// won't fire before a payload silently sails past it. -function warnUnsupportedSchemaKeywords(nodes: readonly NodeConfig[]) { - return Effect.forEach( - nodes.flatMap((node) => { - if (!node.output_schema) return [] - const keywords = unsupportedSchemaKeywords(node.output_schema) - return keywords.length > 0 ? [{ nodeID: node.id, keywords }] : [] - }), - (hit) => - Effect.logWarning("output_schema uses keywords the subset validator does not enforce — they will be ignored at runtime", hit), - { discard: true }, - ) -} +// Structural validation (duplicate ids, dangling/condition references, +// template bindings, ceilings, review lifecycle, required-node and full-graph +// cycles) lives in the shared validation authority so create, replan, and the +// workflow validate action all enforce the same invariants with the same +// codes and field paths. export interface Interface { readonly create: (input: { @@ -383,37 +338,23 @@ export const layer = Layer.effect( config: WorkflowConfig }) { const config = normalizeWorkflowConfig(input.config) - // Structural validation first (mirrors planReplan's fragment checks so - // create and replan reject the same malformed shapes): duplicate ids - // would silently merge via the projector's upsert, and a dangling - // depends_on reference would silently drop the edge in buildGraph — - // turning a typo'd dependency into an immediately-runnable root node. - const ids = config.nodes.map((n) => n.id) - const idSet = new Set(ids) - if (idSet.size !== ids.length) { - const duplicates = [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))] - return yield* Effect.fail(new Error(`Invalid workflow config: duplicate node ids: ${duplicates.join(", ")}`)) - } - const danglingDeps = config.nodes.flatMap((n) => - n.depends_on.filter((dep) => !idSet.has(dep)).map((dep) => `node "${n.id}" depends on unknown node "${dep}"`), - ) - if (danglingDeps.length > 0) { - return yield* Effect.fail(new Error(`Invalid workflow config: ${danglingDeps.join("; ")}`)) - } - const conditionErrors = conditionReferenceErrors(config.nodes) - if (conditionErrors.length > 0) { - return yield* Effect.fail(new Error(`Invalid workflow config: ${conditionErrors.join("; ")}`)) - } - const bindingErrors = templateBindingErrors(config.nodes) - if (bindingErrors.length > 0) { - return yield* Effect.fail(new Error(`Invalid workflow config: ${bindingErrors.join("; ")}`)) + // Structural validation first, via the shared authority (the same one + // the workflow validate action runs): duplicate ids would silently + // merge via the projector's upsert, and a dangling depends_on reference + // would silently drop the edge in buildGraph — turning a typo'd + // dependency into an immediately-runnable root node. Rejection happens + // before any event publication. + const structural = DagValidation.structuralDiagnostics({ + nodes: config.nodes, + mode: config.mode, + max_total_nodes: config.max_total_nodes, + }) + const structuralErrors = DagValidation.sortLegacyStructural(structural.filter((d) => d.severity === "error")) + for (const warning of structural.filter((d) => d.severity === "warning")) { + yield* Effect.logWarning("DAG structural validation diagnostic", { diagnostic: warning }) } - yield* warnUnsupportedSchemaKeywords(config.nodes) - // Enforce the total node ceiling at creation, not only on replan — the - // ceiling is a lifetime cap and the initial graph counts toward it. - const maxTotalNodes = config.max_total_nodes ?? DEFAULT_WORKFLOW_CONFIG.maxTotalNodes - if (config.nodes.length > maxTotalNodes) { - return yield* Effect.fail(new Error(`Total node ceiling exceeded: ${config.nodes.length} nodes > ${maxTotalNodes} max`)) + if (structuralErrors.length > 0) { + return yield* Effect.fail(new StructuralValidationError({ diagnostics: structuralErrors })) } if (config.mode === "deep") { if (!config.admission) { @@ -445,37 +386,6 @@ export const layer = Layer.effect( }, } : config - const reviewLifecycle = validateReviewLifecycle(durableConfig) - if (!reviewLifecycle.valid) { - return yield* Effect.fail(new Error( - `Invalid review lifecycle: ${reviewLifecycle.errors.join("; ")}`, - )) - } - for (const warning of reviewLifecycle.warnings) { - yield* Effect.logWarning("DAG review lifecycle diagnostic", { warning }) - } - const validation = validateRequiredNodes({ - nodes: durableConfig.nodes.map((n) => ({ id: n.id, depends_on: n.depends_on, required: n.required })), - }) - if (!validation.valid) return yield* Effect.fail(new Error(`Invalid workflow config: ${validation.errors.join("; ")}`)) - - // Full-graph cycle detection — validates ALL nodes (not just required), - // so a cycle among optional nodes cannot silently create a zombie graph. - // buildGraph throws CycleError via addEdge's wouldCreateCycle pre-check. - const cyclePath: string[] | null = yield* Effect.sync(() => { - try { - const graph = buildGraph( - durableConfig.nodes.map((n) => ({ id: n.id, dependsOn: n.depends_on, status: "pending" as const, required: n.required })), - ) - return graph.hasCycle() ? (graph.findCycles()[0] ?? null) : null - } catch (e) { - if (e instanceof CycleError) return e.cycle - throw e - } - }) - if (cyclePath) { - return yield* Effect.fail(new Error(`Workflow config contains a dependency cycle: ${cyclePath.join(" -> ")}`)) - } const dagID = DagEvent.DagID.create() const ts = yield* DateTime.now @@ -627,38 +537,29 @@ export const layer = Layer.effect( const status = nodeStatusById.get(n.id) return status === undefined || !isNodeTerminalStatus(status as NodeStatus) }) - const conditionErrors = conditionReferenceErrors(rerunNodes) - if (conditionErrors.length > 0) { - return yield* Effect.fail(new Error(`Replan rejected: ${conditionErrors.join("; ")}`)) - } - const bindingErrors = templateBindingErrors(rerunNodes) - if (bindingErrors.length > 0) { - return yield* Effect.fail(new Error(`Replan rejected: ${bindingErrors.join("; ")}`)) - } - yield* warnUnsupportedSchemaKeywords(normalizedFragment.nodes) + // Structural validation through the SAME authority as create — condition, + // binding, dangling-dep, ceiling, review-lifecycle, and topology checks + // all run through DagValidation.replanStructuralDiagnostics (which reuses + // the exact same helper functions as structuralDiagnostics). This is the + // create/replan parity the spec requires: one authority, two entry points + // that differ only in scoping (fragment + rerun-only vs whole-graph). const maxReplanAttempts = wfConfig?.max_node_replan_attempts ?? DEFAULT_WORKFLOW_CONFIG.maxNodeReplanAttempts - const maxTotalNodes = wfConfig?.max_total_nodes ?? DEFAULT_WORKFLOW_CONFIG.maxTotalNodes - - // Enforce total node ceiling BEFORE any event publication so a rejected - // replan leaves no durable side effects. Count ALL nodes ever registered - // (cumulative lifetime) — terminal nodes still count toward the cap. - if (nodes.length + plan.add.length > maxTotalNodes) { - return yield* Effect.fail(new Error(`Total node ceiling exceeded: ${nodes.length} existing + ${plan.add.length} new > ${maxTotalNodes} max`)) + const replanDiagnostics = DagValidation.replanStructuralDiagnostics({ + fragmentNodes: normalizedFragment.nodes, + rerunNodes, + existingNodeIds: new Set(nodes.map((n) => n.id)), + existingNodeCount: nodes.length, + addCount: plan.add.length, + merged: wfConfig ? computeMergedConfig(wfConfig, normalizedFragment, plan) : { nodes: normalizedFragment.nodes }, + config: { mode: wfConfig?.mode, max_total_nodes: wfConfig?.max_total_nodes }, + }) + const replanErrors = DagValidation.sortLegacyStructural(replanDiagnostics.filter((d) => d.severity === "error")) + for (const warning of replanDiagnostics.filter((d) => d.severity === "warning")) { + yield* Effect.logWarning("DAG structural validation diagnostic", { diagnostic: warning }) } - - if (wfConfig) { - const reviewLifecycle = validateReviewLifecycle( - computeMergedConfig(wfConfig, normalizedFragment, plan), - ) - if (!reviewLifecycle.valid) { - return yield* Effect.fail(new Error( - `Invalid review lifecycle: ${reviewLifecycle.errors.join("; ")}`, - )) - } - for (const warning of reviewLifecycle.warnings) { - yield* Effect.logWarning("DAG review lifecycle diagnostic", { warning }) - } + if (replanErrors.length > 0) { + return yield* Effect.fail(new StructuralValidationError({ diagnostics: replanErrors })) } const nodeById = new Map(nodes.map((n) => [n.id, n])) diff --git a/packages/opencode/src/dag/docs/adr/0001-workflow-authoring-check.md b/packages/opencode/src/dag/docs/adr/0001-workflow-authoring-check.md new file mode 100644 index 0000000000..5c1779590b --- /dev/null +++ b/packages/opencode/src/dag/docs/adr/0001-workflow-authoring-check.md @@ -0,0 +1,33 @@ +# ADR-0001: One Workflow Authoring Check authority + +- Status: Accepted +- Date: 2026-08-11 + +## Context + +Workflow input was interpreted independently by the provider-facing tool schema, start, validate, list/read, replan, CLI, generation, and packaging. Hidden YAML authoring removed the model's accidental examples while leaving it unable to infer required fields. Later patches added validators at individual callers, so accepted shapes and diagnostics drifted and some paths reached durable DAG operations before equivalent checks had run. + +The product supports a single custom workflow, saved workflows, and heuristic Block composition. Those are source choices for one orchestration product, not separate validation systems. + +## Decision + +`WorkflowAuthoring` is the only raw Workflow Source to Prepared Workflow Graph boundary. It owns YAML parsing, file-only legacy normalization, action-specific strict decoding, Block compilation, portable/environment validation, stable diagnostics, and safe result caching. + +All tool graph actions and offline config/release consumers call this boundary. Callers may authorize and read files or perform durable DAG mutations, but they do not reinterpret source shape or decide graph validity. + +The provider schema exposes only author-owned fields. Runtime identity, model assignment, and persisted admission audit fields are derived or adapted behind the boundary. Portable checks are environment-free; environment checks resolve live catalogs and are not cached as content-only facts. + +## Consequences + +- A valid source has one compiled meaning across validate, start, extend, replan, read/list diagnostics, CI, generation, and packaging. +- Provider schema is sufficient for model authoring without exposing runtime-owned fields. +- Legacy YAML remains readable while new inline input stays strict. +- Environment changes are observed on the next environment check. +- Durable DAG methods retain lifecycle validation as defense in depth, but do not become a second raw-source validator. + +## Alternatives Considered + +- Keep validators per caller: rejected because fixes and diagnostics drift across runtime and release paths. +- Publish the persisted YAML shape directly to the model: rejected because compatibility/audit/runtime fields are discoverable but not model-owned. +- Make every check environment-aware: rejected because config CI and portable assets must not depend on user-global agents, skills, prompts, or models. +- Remove low-level custom Nodes: rejected because Blocks are the recommended composition interface, not the only expressible workflow form. diff --git a/packages/opencode/src/dag/templates/resolve.ts b/packages/opencode/src/dag/templates/resolve.ts index b0fb31c936..fc0b1c3fd4 100644 --- a/packages/opencode/src/dag/templates/resolve.ts +++ b/packages/opencode/src/dag/templates/resolve.ts @@ -50,13 +50,19 @@ export function resolveTemplate(ref: TemplateRef, projectDir: string): Effect.Ef return renderTemplate(ref, projectDir).pipe(Effect.map((result) => result.text)) } +/** Read a template asset by id without interpolation — validation needs the + * raw source to check placeholder bindings before any node spawn. */ +export function templateSourceById(id: string, projectDir: string): Effect.Effect { + return readById(id, projectDir) +} + export function renderTemplate( ref: TemplateRef, projectDir: string, dynamicInput: Record = {}, ) { return Effect.gen(function* () { - const input = sanitizeInput({ ...dynamicInput, ...(ref.input ?? {}) }) + const input = sanitizeInput({ ...dynamicInput, ...ref.input }) const raw = yield* readTemplateSource(ref, projectDir) return interpolate(raw, input) }) @@ -106,7 +112,10 @@ function interpolate(template: string, input: Record) { const text = template.replace(INTERPOLATION_RE, (match, key: string) => { const value = input[key] if (value !== null && value !== undefined) { - return typeof value === "object" ? JSON.stringify(value, null, 2) : String(value) + if (typeof value === "object") return JSON.stringify(value, null, 2) + if (typeof value === "symbol") return value.description ?? "" + if (typeof value === "function") return value.name + return value.toString() } unresolvedPlaceholders.push(key) return match diff --git a/packages/opencode/src/dag/validation.ts b/packages/opencode/src/dag/validation.ts new file mode 100644 index 0000000000..ace5701901 --- /dev/null +++ b/packages/opencode/src/dag/validation.ts @@ -0,0 +1,943 @@ +/** + * Workflow spec validation authority. + * + * Side-effect-free rule core shared by WorkflowAuthoring and Dag.create / + * Dag.replan. Raw source orchestration belongs exclusively to + * WorkflowAuthoring; this module never chooses or reads a source. + * + * Profiles: + * - portable — proves a spec can be distributed on its own (no dependency on + * one user's project prompts, models, or agents); + * - environment — portable plus resolution against the current project/global + * prompt directories and the agent/skill/model catalogs. + * + * Validation never creates workflows, publishes DAG events, registers nodes, + * spawns child sessions, or writes files. + */ + +export * as DagValidation from "./validation" + +import { Effect, Option, Schema } from "effect" +import { buildGraph } from "@opencode-ai/core/dag/core/scheduling" +import { CycleError } from "@opencode-ai/core/dag/core/graph" +import { validateRequiredNodes } from "@opencode-ai/core/dag/core/required-validator" +import type { NodeConfig } from "./dag" +import { DEFAULT_WORKFLOW_CONFIG } from "./dag" +import { DagBlocks } from "./blocks" +import { AdmissionInput, ExecutionMode } from "./admission" +import { validateReviewLifecycle } from "./review-lifecycle" +import { conditionReference } from "./runtime/eval" +import { unsupportedSchemaKeywords } from "./runtime/capture" +import { placeholderKeys, templateSourceById } from "./templates/resolve" + +// ============================================================================ +// Diagnostic contract +// ============================================================================ + +export const DIAGNOSTIC_CODES = { + schemaInvalid: "schema.invalid", + // Reserved vocabulary from the design: source exclusivity is enforced by the + // discriminated parameter schema before any diagnostic path runs. + graphSourceConflict: "graph.source_conflict", + blockCompileFailed: "block.compile_failed", + dagInvalid: "dag.invalid", + promptUnboundVariable: "prompt.unbound_variable", + promptMissingAsset: "prompt.missing_asset", + promptNonportableAsset: "prompt.nonportable_asset", + workerUnknown: "worker.unknown", + modelUnavailable: "model.unavailable", + environmentUnavailable: "environment.unavailable", + schemaKeywordWarning: "schema.keyword_warning", +} as const + +export type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[keyof typeof DIAGNOSTIC_CODES] + +export const DiagnosticCodeSchema = Schema.Literals(Object.values(DIAGNOSTIC_CODES)) + +export const DiagnosticSchema = Schema.Struct({ + severity: Schema.Literals(["error", "warning"]), + code: DiagnosticCodeSchema, + /** Field or asset path, e.g. `config.blocks` or `nodes[verify].prompt_template.id`. */ + path: Schema.String, + message: Schema.String, + hint: Schema.String, +}) +export type Diagnostic = typeof DiagnosticSchema.Type + +/** Structural validation errors carry the shared diagnostics so callers can + * compare validate/start/replan rejections code by code (spec: one authority). */ +export class StructuralValidationError extends Schema.TaggedErrorClass()( + "StructuralValidationError", + { diagnostics: Schema.mutable(Schema.Array(DiagnosticSchema)) }, +) { + /** The message text tools and tests have always seen, derived from the + * shared diagnostic messages. The legacy render format is decided at + * construction time via the legacy-class tag — never by re-parsing + * message text. */ + override get message() { + return this.diagnostics.map((d) => legacyValidationMessage(d)).join("; ") + } +} + +export type Profile = "portable" | "environment" + +export interface CompiledNodeSummary { + id: string + name: string + worker_type: string + depends_on: string[] + required: boolean + report_to_parent: boolean + has_output_schema: boolean + review_phase?: "design" | "diff" +} + +export interface ValidationResult { + source: string + profile: Profile + valid: boolean + errors: Diagnostic[] + warnings: Diagnostic[] + nodes: CompiledNodeSummary[] +} + +export function diagnostic(input: { + severity?: "error" | "warning" + code: DiagnosticCode + path: string + message: string + hint?: string +}): Diagnostic { + return { + severity: input.severity ?? "error", + code: input.code, + path: input.path, + message: input.message, + hint: input.hint ?? "", + } +} + +/** Stable ordering: field path, then code, then message. Same input always + * yields the same diagnostic order, so validate output is diffable. */ +export function sortDiagnostics(diagnostics: Diagnostic[]): Diagnostic[] { + return [...diagnostics].sort( + (a, b) => a.path.localeCompare(b.path) || a.code.localeCompare(b.code) || a.message.localeCompare(b.message), + ) +} + +export type YamlParseResult = { parsed: true; value: unknown } | { parsed: false; diagnostic: Diagnostic } + +/** YAML parsing belongs to the validation authority so the workflow tool, + * config CI, generation, and release packaging cannot drift on parse-error + * codes or paths. */ +export function parseYaml(content: string): YamlParseResult { + try { + return { parsed: true, value: Bun.YAML.parse(content) } + } catch { + return { + parsed: false, + diagnostic: diagnostic({ + code: DIAGNOSTIC_CODES.schemaInvalid, + path: "$", + message: "file is not parseable YAML", + hint: "Fix the YAML syntax before validation can run", + }), + } + } +} + +function summarizeNodes(nodes: readonly NodeConfig[]): CompiledNodeSummary[] { + return nodes.map((node) => ({ + id: node.id, + name: node.name, + worker_type: node.worker_type, + depends_on: [...node.depends_on], + required: node.required ?? false, + report_to_parent: node.report_to_parent ?? false, + has_output_schema: node.output_schema !== undefined, + ...(node.review ? { review_phase: node.review.phase } : {}), + })) +} + +// ============================================================================ +// Spec schemas — the single decode authority for inline and file-backed input +// ============================================================================ + +const PromptInput = Schema.optional(Schema.Record(Schema.String, Schema.Unknown)) +/** A prompt template selects exactly one source: inline text or an asset id. + * Both present is ambiguous; neither is a spawn-time guarantee the runtime + * cannot keep. */ +export const PromptTemplateSource = Schema.Union([ + Schema.Struct({ + inline: Schema.String.annotate({ + description: "Inline prompt text; bind {{placeholders}} via input or input_mapping", + }), + input: PromptInput, + }), + Schema.Struct({ + id: Schema.String.annotate({ + description: "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", + }), + input: PromptInput, + }), +]) + +export const NodeSchema = Schema.Struct({ + id: Schema.String.annotate({ description: "Unique node identifier, used in depends_on" }), + name: Schema.String.annotate({ description: "Human-readable node name" }), + worker_type: Schema.String.annotate({ description: "Agent type (explore, build, general, plan, or custom)" }), + depends_on: Schema.Array(Schema.String).annotate({ description: "Node IDs this node waits for ([] for root)" }), + required: Schema.optional(Schema.Boolean).annotate({ + description: + "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", + }), + prompt_template: PromptTemplateSource.annotate({ + description: + 'Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default', + }), + worker_config: Schema.optional( + Schema.Struct({ + timeout_ms: Schema.optional(Schema.Number), + }), + ).annotate({ description: "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config" }), + input_mapping: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ + description: + 'Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID', + }), + report_to_parent: Schema.optional(Schema.Boolean).annotate({ + description: + "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + }), + condition: Schema.optional(Schema.String).annotate({ + description: "Expression evaluated before spawn; node is skipped if false", + }), + restart: Schema.optional(Schema.Boolean).annotate({ + description: + "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", + }), + cancel: Schema.optional(Schema.Boolean).annotate({ description: "(replan only) Cancel this node" }), + output_schema: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ + description: "JSON Schema; child agent must call submit_result to submit structured output", + }), + review: Schema.optional( + Schema.Struct({ + phase: Schema.Literals(["design", "diff"]), + implementation_node_id: Schema.optional(Schema.String), + verification_node_id: Schema.optional(Schema.String), + }), + ).annotate({ + description: + "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", + }), +}) + +const NodeDefaults = Schema.Struct({ + required: Schema.optional(Schema.Boolean), + worker_config: Schema.optional( + Schema.Struct({ + timeout_ms: Schema.optional(Schema.Number), + }), + ), + report_to_parent: Schema.optional(Schema.Boolean), +}) + +const GraphBudgetFields = { + max_concurrency: Schema.optional(Schema.Number).annotate({ description: "Max parallel nodes. Default: 5" }), + max_node_replan_attempts: Schema.optional(Schema.Number).annotate({ + description: "Max replan restarts per node ID. Default: 5", + }), + max_total_nodes: Schema.optional(Schema.Number).annotate({ + description: "Cumulative node cap across the workflow lifetime. Default: 100", + }), +} as const + +/** High-level graph: objective + composable blocks compiled into nodes. */ +export const BlocksGraphSchema = Schema.Struct({ + name: Schema.String.annotate({ description: "Workflow name" }), + objective: Schema.String.annotate({ + description: "Injected into every generated child prompt; required for blocks", + }), + blocks: Schema.Array(DagBlocks.WorkflowBlock).annotate({ + description: "Composable blocks compiled into nodes by the runtime", + }), + node_defaults: Schema.optional(NodeDefaults).annotate({ + description: "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + }), + ...GraphBudgetFields, +}) + +/** Low-level graph: explicit node declarations. */ +export const NodesGraphSchema = Schema.Struct({ + name: Schema.String.annotate({ description: "Workflow name" }), + nodes: Schema.Array(NodeSchema).annotate({ description: "Low-level node declarations" }), + node_defaults: Schema.optional(NodeDefaults).annotate({ + description: "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + }), + ...GraphBudgetFields, +}) + +/** A start/replan graph carries exactly one source shape: blocks or nodes. */ +export const WorkflowGraphSchema = Schema.Union([BlocksGraphSchema, NodesGraphSchema]) + +export const StartSpec = Schema.Struct({ + title: Schema.optional(Schema.String), + mode: Schema.optional(ExecutionMode), + admission: Schema.optional(AdmissionInput), + config: WorkflowGraphSchema, +}) + +/** Extend adds exactly one graph source: objective+blocks or nodes. */ +export const ExtendSpec = Schema.Union([ + Schema.Struct({ + objective: Schema.String.annotate({ description: "Injected into every generated child prompt" }), + blocks: Schema.Array(DagBlocks.WorkflowBlock), + }), + Schema.Struct({ + nodes: Schema.Array(NodeSchema), + }), +]) + +export const ReplanSpec = Schema.Struct({ + fragment: WorkflowGraphSchema, +}) + +export type StartSpec = typeof StartSpec.Type +export type StartGraph = typeof WorkflowGraphSchema.Type +export type ExtendGraph = typeof ExtendSpec.Type +export type NodeSpec = typeof NodeSchema.Type + +/** The validator decodes untrusted model/YAML input; unknown keys are + * rejected so a foreign field can never be silently dropped or defaulted. */ +export const STRICT_PARSE_OPTIONS = { onExcessProperty: "error" as const } + +// ============================================================================ +// Schema-error → diagnostics +// ============================================================================ + +interface LeafIssue { + path: string + message: string +} + +function issuePathSegment(segment: unknown): string { + return typeof segment === "number" ? `[${segment}]` : `[${JSON.stringify(String(segment))}]` +} + +function collectLeafIssues(issue: unknown, path: readonly string[], out: LeafIssue[]) { + if (!isRecord(issue)) return + const nextPath = Array.isArray(issue.path) ? [...path, ...issue.path.map(issuePathSegment)] : path + const children: unknown[] = [] + if (Array.isArray(issue.issues)) children.push(...issue.issues) + if (issue.issue !== undefined) children.push(issue.issue) + const tag = typeof issue._tag === "string" ? issue._tag : "" + if (tag === "AnyOf" || tag === "UnionMember") { + for (const value of Object.values(issue)) { + if (value !== null && typeof value === "object" && "_tag" in value) children.push(value) + } + } + if (children.length > 0) { + for (const child of children) collectLeafIssues(child, nextPath, out) + return + } + const message = typeof issue.message === "string" ? issue.message : tag + if (message) out.push({ path: nextPath.join("") || "$", message }) +} + +export function schemaDiagnostics(error: unknown, basePath = ""): Diagnostic[] { + const leaves: LeafIssue[] = [] + collectLeafIssues(isRecord(error) && error.issue !== undefined ? error.issue : error, basePath ? [basePath] : [], leaves) + if (leaves.length === 0) { + return [diagnostic({ code: DIAGNOSTIC_CODES.schemaInvalid, path: basePath || "$", message: String(error) })] + } + return sortDiagnostics( + leaves.map((leaf) => + diagnostic({ + code: DIAGNOSTIC_CODES.schemaInvalid, + path: leaf.path, + message: leaf.message, + hint: "Fix the field shape; blocks graphs need name+objective+blocks, nodes graphs need name+nodes", + }), + ), + ) +} + +// ============================================================================ +// Graph compilation (blocks → nodes) as diagnostics +// ============================================================================ + +export type BlockSource = + | { objective: string; blocks: readonly DagBlocks.WorkflowBlock[] } + | { nodes: readonly NodeSpec[] } + +export function compileBlockSource( + source: BlockSource, + options: { known_dependencies?: string[] } = {}, +): { nodes?: NodeConfig[]; diagnostics: Diagnostic[] } { + if ("blocks" in source) { + try { + const nodes = DagBlocks.compileWorkflowBlocks( + { objective: source.objective, blocks: [...source.blocks] }, + { known_dependencies: options.known_dependencies }, + ) + return { nodes, diagnostics: [] } + } catch (error) { + return { + diagnostics: [ + diagnostic({ + code: DIAGNOSTIC_CODES.blockCompileFailed, + path: "config.blocks", + message: error instanceof Error ? error.message : String(error), + hint: "Blocks must satisfy the writer-serialization and review-route contracts; inline compiled nodes if you need a shape blocks cannot express", + }), + ], + } + } + } + return { nodes: source.nodes.map(materializeNode), diagnostics: [] } +} + +function materializeNode(node: NodeSpec): NodeConfig { + return { + ...node, + depends_on: [...node.depends_on], + prompt_template: { + ...node.prompt_template, + ...(node.prompt_template.input ? { input: { ...node.prompt_template.input } } : {}), + }, + ...(node.worker_config ? { worker_config: { ...node.worker_config } } : {}), + ...(node.input_mapping ? { input_mapping: { ...node.input_mapping } } : {}), + ...(node.output_schema ? { output_schema: { ...node.output_schema } } : {}), + ...(node.review ? { review: { ...node.review } } : {}), + } +} + +export function compileGraphSource( + graph: StartGraph, + options: { known_dependencies?: string[] } = {}, +): { nodes?: NodeConfig[]; diagnostics: Diagnostic[] } { + if ("blocks" in graph) { + return compileBlockSource({ objective: graph.objective, blocks: graph.blocks }, options) + } + return compileBlockSource({ nodes: graph.nodes }, options) +} + +// ============================================================================ +// Structural diagnostics — shared by validate, create, and replan +// ============================================================================ + +/** A parseable condition may only reference the node's direct dependencies — + * anything else silently resolves to undefined and evaluates false at spawn. */ +export function conditionReferenceErrors(nodes: readonly NodeConfig[]): string[] { + return nodes.flatMap((node) => { + const ref = conditionReference(node.condition) + if (!ref || node.depends_on.includes(ref)) return [] + return [ + `node "${node.id}" condition references "${ref}" which is not in its depends_on (condition inputs come from direct dependencies only; this would silently evaluate false)`, + ] + }) +} + +/** Inline prompt templates may only reference bound variables: static + * prompt_template.input keys, input_mapping target names, or (without + * input_mapping) the direct depends_on ids. Id templates are resolved from + * disk and are binding-checked by the environment profile. */ +export function templateBindingErrors(nodes: readonly NodeConfig[]): string[] { + return nodes.flatMap((node) => { + const template = node.prompt_template.inline + if (template === undefined) return [] + const bound = new Set([ + ...Object.keys(node.prompt_template.input ?? {}), + ...Object.keys(node.input_mapping ?? Object.fromEntries(node.depends_on.map((dep) => [dep, dep]))), + ]) + return placeholderKeys(template) + .filter((key) => !bound.has(key)) + .map( + (key) => + `node "${node.id}" prompt_template references unbound variable "{{${key}}}" (bind it via prompt_template.input, input_mapping, or depends_on)`, + ) + }) +} + +export interface StructuralInput { + nodes: readonly NodeConfig[] + mode?: ExecutionMode + max_total_nodes?: number + /** Nodes already registered in a live workflow; counts toward the ceiling. */ + existing_node_count?: number + /** Node ids already present in a live workflow; valid dependency targets for + * replan/extend fragments whose depends_on may reference them. */ + known_node_ids?: ReadonlySet +} + +// Legacy byte-compat: Dag.create has always reported one structural class at +// a time in a fixed sequence. Each helper tags its diagnostics with that +// class index so callers can restore the historical ordering without +// re-parsing message text — the ordering lives with the message authors. +const legacyClassByDiagnostic = new WeakMap() + +// Classes whose messages pass through as-is in the legacy render; every +// other structural class is prefixed with "Invalid workflow config:". +const RAW_LEGACY_CLASSES = new Set([4, 5, 7]) + +function tagLegacyClass(diagnostics: Diagnostic[], classIndex: number): Diagnostic[] { + for (const d of diagnostics) legacyClassByDiagnostic.set(d, classIndex) + return diagnostics +} + +export function sortLegacyStructural(diagnostics: readonly Diagnostic[]): Diagnostic[] { + return sortDiagnostics([...diagnostics]).sort( + (a, b) => (legacyClassByDiagnostic.get(a) ?? 8) - (legacyClassByDiagnostic.get(b) ?? 8), + ) +} + +/** Legacy message render driven by the structural class tag, never by + * re-parsing message text. Schema-decode diagnostics (untagged) render with + * the historical "Invalid workflow config:" wrapper. */ +function legacyValidationMessage(d: Diagnostic): string { + const cls = legacyClassByDiagnostic.get(d) + if (cls !== undefined && RAW_LEGACY_CLASSES.has(cls)) return d.message + return `Invalid workflow config: ${d.message}` +} + +function duplicateNodeIds(nodes: readonly NodeConfig[]): string[] { + const ids = nodes.map((node) => node.id) + return [...new Set(ids.filter((id, index) => ids.indexOf(id) !== index))] +} + +function duplicateIdDiagnostics(duplicates: string[]): Diagnostic[] { + if (duplicates.length === 0) return [] + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path: "nodes", + message: `duplicate node ids: ${duplicates.join(", ")}`, + hint: "Every node id must be unique; rename the colliding node", + }), + ] +} + +function danglingDependencyDiagnostics(nodes: readonly NodeConfig[], knownNodeIds?: ReadonlySet): Diagnostic[] { + const idSet = new Set(nodes.map((node) => node.id)) + if (knownNodeIds) for (const id of knownNodeIds) idSet.add(id) + const dangling = nodes.flatMap((node) => + node.depends_on.filter((dep) => !idSet.has(dep)).map((dep) => ({ node, dep })), + ) + if (dangling.length === 0) return [] + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path: "nodes", + message: dangling.map(({ node, dep }) => `node "${node.id}" depends on unknown node "${dep}"`).join("; "), + hint: "depends_on may only reference node ids declared in this graph", + }), + ] +} + +function conditionDiagnostics(nodes: readonly NodeConfig[]): Diagnostic[] { + const errors = conditionReferenceErrors(nodes) + if (errors.length === 0) return [] + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path: "nodes", + message: errors.join("; "), + hint: "A condition may only read outputs of the node's direct depends_on", + }), + ] +} + +function bindingDiagnostics(nodes: readonly NodeConfig[]): Diagnostic[] { + return templateBindingErrors(nodes).map((error) => + diagnostic({ + code: DIAGNOSTIC_CODES.promptUnboundVariable, + path: "nodes", + message: error, + hint: "Bind the variable via prompt_template.input, input_mapping, or depends_on", + }), + ) +} + +export function effectiveMaxTotalNodes(maxTotalNodes: number | undefined) { + return maxTotalNodes ?? DEFAULT_WORKFLOW_CONFIG.maxTotalNodes +} + +/** Reusable ceiling check: produces the historical diagnostic when the + * cumulative node count exceeds the configured maximum. Shared by create + * (existing_node_count + nodes.length) and replan (existing + add count). */ +function ceilingExceeded(totalNodes: number, maxTotalNodes: number | undefined): Diagnostic[] { + const max = effectiveMaxTotalNodes(maxTotalNodes) + if (totalNodes <= max) return [] + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path: "config.max_total_nodes", + message: `Total node ceiling exceeded: ${totalNodes} nodes > ${max} max`, + hint: "Reduce the graph or raise max_total_nodes deliberately", + }), + ] +} + +function ceilingDiagnostics(input: StructuralInput): Diagnostic[] { + return ceilingExceeded((input.existing_node_count ?? 0) + input.nodes.length, input.max_total_nodes) +} + +/** Review-lifecycle diagnostics for one config. Shared by the structural + * validator and Dag.replan's merged-config check. */ +export function reviewLifecycleDiagnostics(input: { + name?: string + mode?: ExecutionMode + nodes: readonly NodeConfig[] +}) { + const reviewLifecycle = validateReviewLifecycle({ + name: input.name ?? "validation", + mode: input.mode, + nodes: [...input.nodes], + }) + return { + errors: reviewLifecycle.errors.map((error) => + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path: "nodes", + message: `Invalid review lifecycle: ${error}`, + hint: "Diff reviews need implementation + verification wiring; deep review workers must declare review.phase", + }), + ), + warnings: reviewLifecycle.warnings.map((warning) => + diagnostic({ + severity: "warning", + code: DIAGNOSTIC_CODES.dagInvalid, + path: "nodes", + message: `Review lifecycle diagnostic: ${warning}`, + hint: "Standard mode records this without failing the workflow", + }), + ), + } +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +// Duplicate ids make topology checks ambiguous (projector would silently +// merge the rows), so callers run these only on id-unique graphs. +function topologyDiagnostics(nodes: readonly NodeConfig[]): Diagnostic[] { + const diagnostics: Diagnostic[] = [] + const required = validateRequiredNodes({ + nodes: nodes.map((node) => ({ + id: node.id, + depends_on: node.depends_on, + required: node.required ?? false, + })), + }) + if (!required.valid) { + diagnostics.push( + ...tagLegacyClass( + required.errors.map((error) => + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path: "nodes", + message: error, + hint: "Required nodes must be reachable without depending on optional work", + }), + ), + 6, + ), + ) + } + const cyclePath = findCycle(nodes) + if (cyclePath) { + diagnostics.push( + ...tagLegacyClass( + [ + diagnostic({ + code: DIAGNOSTIC_CODES.dagInvalid, + path: "nodes", + message: `Workflow config contains a dependency cycle: ${cyclePath.join(" -> ")}`, + hint: "Break the cycle by removing one depends_on edge", + }), + ], + 7, + ), + ) + } + return diagnostics +} + +function outputSchemaKeywordDiagnostics(nodes: readonly NodeConfig[]): Diagnostic[] { + return nodes.flatMap((node) => { + if (!node.output_schema) return [] + const keywords = unsupportedSchemaKeywords(node.output_schema) + if (keywords.length === 0) return [] + return [ + diagnostic({ + severity: "warning", + code: DIAGNOSTIC_CODES.schemaKeywordWarning, + path: `nodes[${node.id}].output_schema`, + message: `output_schema uses keywords the subset validator does not enforce: ${keywords.join(", ")}`, + hint: "They will be ignored at runtime; simplify the schema or accept the gap", + }), + ] + }) +} + +/** Pure structural validation. No events, no store, no logging — callers + * decide how to surface the diagnostics (tool output vs. create rejection). */ +export function structuralDiagnostics(input: StructuralInput): Diagnostic[] { + const duplicates = duplicateNodeIds(input.nodes) + const review = reviewLifecycleDiagnostics({ mode: input.mode, nodes: input.nodes }) + return sortDiagnostics([ + ...tagLegacyClass(duplicateIdDiagnostics(duplicates), 0), + ...tagLegacyClass(danglingDependencyDiagnostics(input.nodes, input.known_node_ids), 1), + ...tagLegacyClass(conditionDiagnostics(input.nodes), 2), + ...tagLegacyClass(bindingDiagnostics(input.nodes), 3), + ...tagLegacyClass(ceilingDiagnostics(input), 4), + ...tagLegacyClass(review.errors, 5), + ...tagLegacyClass(review.warnings, 5), + ...(duplicates.length === 0 ? topologyDiagnostics(input.nodes) : []), + ...tagLegacyClass(outputSchemaKeywordDiagnostics(input.nodes), 8), + ]) +} + +export interface ReplanStructuralInput { + /** Full fragment nodes — checked for duplicate ids within the fragment. */ + fragmentNodes: readonly NodeConfig[] + /** Fragment nodes that will actually (re)run (excludes cancel + terminal). + * Condition, binding, dangling-dep, topology, and output-schema checks + * run on these — a cancelled or terminal node never evaluates them. */ + rerunNodes: readonly NodeConfig[] + /** Existing workflow node ids — valid dependency targets and ceiling baseline. */ + existingNodeIds: ReadonlySet + existingNodeCount: number + /** New node ids being added by this replan (toward the lifetime ceiling). */ + addCount: number + /** Merged config (existing + fragment) for review-lifecycle validation. */ + merged: { name?: string; mode?: ExecutionMode; nodes: readonly NodeConfig[] } + config: { mode?: ExecutionMode; max_total_nodes?: number } +} + +/** Replan structural validation through the same helper functions as create — + * the authority lives here, not in Dag.replan. The scoping differs (fragment + * vs whole-graph, rerun-only condition/binding, merged-config review), but + * every check reuses the same underlying helper. */ +export function replanStructuralDiagnostics(input: ReplanStructuralInput): Diagnostic[] { + const knownIds = new Set([...input.existingNodeIds, ...input.fragmentNodes.map((n) => n.id)]) + const duplicates = duplicateNodeIds(input.fragmentNodes) + const review = reviewLifecycleDiagnostics({ + name: input.merged.name, + mode: input.merged.mode, + nodes: input.merged.nodes, + }) + return sortDiagnostics([ + ...tagLegacyClass(duplicateIdDiagnostics(duplicates), 0), + ...tagLegacyClass(danglingDependencyDiagnostics(input.rerunNodes, knownIds), 1), + ...tagLegacyClass(conditionDiagnostics(input.rerunNodes), 2), + ...tagLegacyClass(bindingDiagnostics(input.rerunNodes), 3), + ...tagLegacyClass(ceilingExceeded(input.existingNodeCount + input.addCount, input.config.max_total_nodes), 4), + ...tagLegacyClass(review.errors, 5), + ...tagLegacyClass(review.warnings, 5), + ...(duplicates.length === 0 ? topologyDiagnostics(input.rerunNodes) : []), + ...tagLegacyClass(outputSchemaKeywordDiagnostics(input.rerunNodes), 8), + ]) +} + +function findCycle(nodes: readonly NodeConfig[]): string[] | null { + try { + const graph = buildGraph( + nodes.map((node) => ({ + id: node.id, + dependsOn: node.depends_on, + status: "pending" as const, + required: node.required ?? false, + })), + ) + return graph.hasCycle() ? (graph.findCycles()[0] ?? null) : null + } catch (error) { + if (error instanceof CycleError) return error.cycle + throw error + } +} + +// ============================================================================ +// Portable + environment validation +// ============================================================================ + +export interface EnvironmentCatalogs { + /** Known worker types from the Agent catalog; undefined skips the check. */ + worker_types?: ReadonlySet + /** Resolves a node's model against this environment (dag.jsonc tiers, + * worker agent model, parent session). undefined skips the check. */ + resolveModel?: ( + node: { + id: string + worker_type: string + required: boolean + model?: { modelID: string; providerID: string } + }, + defaults?: { + required?: boolean + model?: { modelID: string; providerID: string } + }, + ) => Effect.Effect +} + +/** Environment-only diagnostics for an already-compiled node list: prompt-id + * resolution and bindings, worker catalog, and model + * resolution. Used by start/extend/replan before any durable side effect. */ +export function environmentDiagnostics(input: { + nodes: readonly NodeConfig[] + directory?: string + catalogs?: EnvironmentCatalogs + /** Graph-level required default, applied when a node does not declare one. */ + defaults?: { required?: boolean } +}): Effect.Effect { + return Effect.gen(function* () { + const diagnostics: Diagnostic[] = [] + for (const node of input.nodes) { + diagnostics.push(...(yield* promptIdDiagnostics(node, input.directory))) + if (input.catalogs?.worker_types && !input.catalogs.worker_types.has(node.worker_type)) { + diagnostics.push( + diagnostic({ + code: DIAGNOSTIC_CODES.workerUnknown, + path: `nodes[${node.id}].worker_type`, + message: `worker type "${node.worker_type}" is not in the current agent catalog`, + hint: "Use a builtin agent type or register the custom agent before start", + }), + ) + } + if (input.catalogs?.resolveModel) { + const required = node.required ?? input.defaults?.required ?? DEFAULT_WORKFLOW_CONFIG.nodeRequired + const resolves = yield* input.catalogs.resolveModel( + { id: node.id, worker_type: node.worker_type, required, model: node.model }, + input.defaults, + ) + if (!resolves) { + diagnostics.push( + diagnostic({ + code: DIAGNOSTIC_CODES.modelUnavailable, + path: `nodes[${node.id}]`, + message: `no model resolves for node "${node.id}"`, + hint: "Configure dag.jsonc tiers, the worker agent model, or a parent-session model", + }), + ) + } + } + } + return sortDiagnostics(diagnostics) + }) +} + +function nodeBoundVariables(node: NodeConfig): Set { + return new Set([ + ...Object.keys(node.prompt_template.input ?? {}), + ...Object.keys(node.input_mapping ?? Object.fromEntries(node.depends_on.map((dep) => [dep, dep]))), + ]) +} + +function nonportablePromptDiagnostics(nodes: readonly NodeConfig[]): Diagnostic[] { + return nodes.flatMap((node) => { + const prompt = node.prompt_template + if (prompt.id === undefined) return [] + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.promptNonportableAsset, + path: `nodes[${node.id}].prompt_template.id`, + message: `prompt id "${prompt.id}" is not shipped with the template`, + hint: "Inline the prompt content or ship the asset with the distributable package", + }), + ] + }) +} + +function promptIdDiagnostics(node: NodeConfig, directory: string | undefined): Effect.Effect { + return Effect.gen(function* () { + const prompt = node.prompt_template + if (prompt.id === undefined) return [] + if (!directory) { + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.promptMissingAsset, + path: `nodes[${node.id}].prompt_template.id`, + message: `prompt id "${prompt.id}" cannot be resolved without a project directory`, + hint: "Inline the prompt content or validate from a project with dag-prompts", + }), + ] + } + // readById rejects via a thrown error (defect channel) — sandbox it into + // a failure so a missing asset becomes a diagnostic, not a die. + const source = yield* templateSourceById(prompt.id, directory).pipe(Effect.sandbox, Effect.option) + if (Option.isNone(source)) { + return [ + diagnostic({ + code: DIAGNOSTIC_CODES.promptMissingAsset, + path: `nodes[${node.id}].prompt_template.id`, + message: `prompt id "${prompt.id}" does not resolve in project or global dag-prompts`, + hint: "Add .md to .opencode/dag-prompts (project or global) or switch to an inline template", + }), + ] + } + const bound = nodeBoundVariables(node) + return placeholderKeys(source.value) + .filter((key) => !bound.has(key)) + .map((key) => + diagnostic({ + code: DIAGNOSTIC_CODES.promptUnboundVariable, + path: `nodes[${node.id}].prompt_template.id`, + message: `prompt asset "${prompt.id}" references unbound variable "{{${key}}}"`, + hint: "Bind it via prompt_template.input, input_mapping, or depends_on", + }), + ) + }) +} + +/** Validate a decoded + compiled spec under the chosen profile. Shared by + * the raw-entry validateSpec and the workflow start path. */ +export function validatePostCompile(input: { + source: string + profile: Profile + config: { + mode?: ExecutionMode + max_total_nodes?: number + node_defaults?: { required?: boolean; model?: { modelID: string; providerID: string } } + } + nodes: readonly NodeConfig[] + /** The original blocks when the graph used the high-level interface. */ + blocks?: readonly DagBlocks.WorkflowBlock[] + directory?: string + catalogs?: EnvironmentCatalogs + /** Fragment actions are structurally validated by Dag.replan after merge; + * authoring still owns profile checks without pretending a fragment is a + * standalone graph. */ + structural?: boolean +}): Effect.Effect { + return Effect.gen(function* () { + const diagnostics = + input.structural === false + ? [] + : structuralDiagnostics({ + nodes: input.nodes, + mode: input.config.mode, + max_total_nodes: input.config.max_total_nodes, + }) + if (input.profile === "portable") diagnostics.push(...nonportablePromptDiagnostics(input.nodes)) + if (input.profile === "environment") { + diagnostics.push( + ...(yield* environmentDiagnostics({ + nodes: input.nodes, + directory: input.directory, + catalogs: input.catalogs, + defaults: input.config.node_defaults, + })), + ) + } + const errors = sortDiagnostics(diagnostics.filter((d) => d.severity === "error")) + const warnings = sortDiagnostics(diagnostics.filter((d) => d.severity === "warning")) + return { + source: input.source, + profile: input.profile, + valid: errors.length === 0, + errors, + warnings, + nodes: summarizeNodes(input.nodes), + } + }) +} diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 4a6803a961..06999e4556 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -50,13 +50,6 @@ const CREATE_DAG_WORKFLOW_SKILL_NAME = "create-dag-workflow" const CREATE_DAG_WORKFLOW_SKILL_DESCRIPTION = SkillPlugin.CreateDagWorkflowDescription const CREATE_DAG_WORKFLOW_SKILL_BODY = SkillPlugin.CreateDagWorkflowContent -// Built-in routing skill. Its compact catalog description makes project-level -// orchestration proactive; the full decision and block-composition playbook is -// loaded only when the model invokes the skill. -const ORCHESTRATION_ROUTER_SKILL_NAME = "orchestration-router" -const ORCHESTRATION_ROUTER_SKILL_DESCRIPTION = SkillPlugin.OrchestrationRouterDescription -const ORCHESTRATION_ROUTER_SKILL_BODY = SkillPlugin.OrchestrationRouterContent - export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -316,12 +309,6 @@ export const layer = Layer.effect( location: "", content: CREATE_DAG_WORKFLOW_SKILL_BODY, } - s.skills[ORCHESTRATION_ROUTER_SKILL_NAME] = { - name: ORCHESTRATION_ROUTER_SKILL_NAME, - description: ORCHESTRATION_ROUTER_SKILL_DESCRIPTION, - location: "", - content: ORCHESTRATION_ROUTER_SKILL_BODY, - } yield* loadSkills(s, yield* InstanceState.get(discovered), events) return s }), diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index ed2b64bc98..e0beb31913 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -2,8 +2,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Effect, Schema } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" import type { JSONSchema7 } from "@ai-sdk/provider" -import type { MessageV2 } from "../session/message-v2" -import type { Permission } from "../permission" import type { SessionID, MessageID } from "../session/schema" import * as Truncate from "./truncate" import { Agent } from "@/agent/agent" @@ -60,6 +58,15 @@ export interface Def< description: string parameters: Parameters jsonSchema?: JSONSchema7 + /** Parse options applied when decoding LLM-supplied arguments. Tools whose + * parameters are a discriminated union use `onExcessProperty: "error"` so a + * call carrying another action's fields is rejected instead of silently + * dropping them. */ + parseOptions?: { + errors?: "first" | "all" + onExcessProperty?: "ignore" | "error" | "preserve" + propertyOrder?: "none" | "original" + } execute(args: Schema.Schema.Type, ctx: Context): Effect.Effect> formatValidationError?(error: unknown): string } @@ -108,7 +115,7 @@ function wrap, Result extends Metadat // Compile the parser closure once per tool init; `decodeUnknownEffect` // allocates a new closure per call, so hoisting avoids re-closing it for // every LLM tool invocation. - const decode = Schema.decodeUnknownEffect(toolInfo.parameters) + const decode = Schema.decodeUnknownEffect(toolInfo.parameters, toolInfo.parseOptions) const execute = toolInfo.execute toolInfo.execute = (args, ctx) => { const attrs = { diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 31cc5b15d2..f1a87358b0 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -5,13 +5,14 @@ import { Dag } from "@/dag/dag" import { DagConfig } from "@/dag/config" import { DagWorkflows } from "@/dag/workflows" import { DagModel } from "@/dag/model" -import { DagBlocks } from "@/dag/blocks" +import { DagValidation, type Diagnostic } from "@/dag/validation" +import { WorkflowAuthoring } from "@/dag/authoring" import { Agent } from "@/agent/agent" import { Question } from "@/question" +import { Provider } from "@/provider/provider" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" -import type { NodeConfig, WorkflowConfig } from "@/dag/dag" -import { AdmissionInput, createAdmissionRecord, ExecutionMode } from "@/dag/admission" +import { createAdmissionRecord } from "@/dag/admission" import { TerminalViolationError } from "@opencode-ai/core/dag/core/types" import { FSUtil } from "@opencode-ai/core/fs-util" import { assertExternalDirectoryEffect } from "./external-directory" @@ -34,156 +35,126 @@ const ResultCursorToken = Schema.String.pipe(Schema.brand("WorkflowResultCursorT type ResultCursorToken = typeof ResultCursorToken.Type const decodeResultCursor = Schema.decodeUnknownOption(ResultCursorJSON) +// Exported so the committed workflow library can be validated in tests. +export const StartSpec = DagValidation.StartSpec +// Distinct re-export for test files that import multiple tools' Parameters +// without aliasing (the repo forbids import aliases). +export { Parameters as WorkflowParameters } + // ============================================================================ -// Action schemas remain the single validation authority for file and inline input. +// Parameters: one discriminated union, action-owned fields only. +// Runtime-derived identity (session/project) is never model-authored — start +// derives ownership from the calling session. // ============================================================================ -const NodeSchema = Schema.Struct({ - id: Schema.String.annotate({ description: "Unique node identifier, used in depends_on" }), - name: Schema.String.annotate({ description: "Human-readable node name" }), - worker_type: Schema.String.annotate({ description: "Agent type (explore, build, general, plan, or custom)" }), - depends_on: Schema.Array(Schema.String).annotate({ description: "Node IDs this node waits for ([] for root)" }), - required: Schema.optional(Schema.Boolean).annotate({ - description: - "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", - }), - prompt_template: Schema.Struct({ - id: Schema.optional(Schema.String), - inline: Schema.optional(Schema.String), - input: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), - }).annotate({ - description: - 'Template: { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default', - }), - worker_config: Schema.optional( - Schema.Struct({ - timeout_ms: Schema.optional(Schema.Number), - }), - ).annotate({ description: "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config" }), - input_mapping: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ - description: - 'Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID', - }), - report_to_parent: Schema.optional(Schema.Boolean).annotate({ - description: - "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", - }), - condition: Schema.optional(Schema.String).annotate({ - description: "Expression evaluated before spawn; node is skipped if false", - }), - restart: Schema.optional(Schema.Boolean).annotate({ - description: - "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", - }), - cancel: Schema.optional(Schema.Boolean).annotate({ description: "(replan only) Cancel this node" }), - output_schema: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ - description: "JSON Schema; child agent must call submit_result to submit structured output", - }), - review: Schema.optional( - Schema.Struct({ - phase: Schema.Literals(["design", "diff"]), - implementation_node_id: Schema.optional(Schema.String), - verification_node_id: Schema.optional(Schema.String), - }), - ).annotate({ - description: - "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", - }), -}) +const specDescription = "Inline structured spec for a one-off graph. Use this or spec_path, never both" +const specPathDescription = + '(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory' -const WorkflowGraphSchema = Schema.Struct({ - name: Schema.String.annotate({ description: "Workflow name" }), - node_defaults: Schema.optional( - Schema.Struct({ - required: Schema.optional(Schema.Boolean), - worker_config: Schema.optional( - Schema.Struct({ - timeout_ms: Schema.optional(Schema.Number), - }), - ), - report_to_parent: Schema.optional(Schema.Boolean), - }), - ).annotate({ - description: "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", - }), - max_concurrency: Schema.optional(Schema.Number).annotate({ description: "Max parallel nodes. Default: 5" }), - max_node_replan_attempts: Schema.optional(Schema.Number).annotate({ - description: "Max replan restarts per node ID. Default: 5", - }), - max_total_nodes: Schema.optional(Schema.Number).annotate({ - description: "Cumulative node cap across the workflow lifetime. Default: 100", - }), - objective: Schema.optional(Schema.String).annotate({ - description: "Required when using blocks; injected into every generated child prompt", - }), - blocks: Schema.optional(Schema.Array(DagBlocks.WorkflowBlock)).annotate({ - description: "High-level graph compiled into nodes. Use blocks or nodes, never both", - }), - nodes: Schema.optional(Schema.Array(NodeSchema)).annotate({ - description: "Low-level node declarations. Use nodes or blocks, never both", - }), +const StartInline = Schema.Struct({ + action: Schema.Literal("start").annotate({ description: "Create a workflow" }), + spec: DagValidation.StartSpec.annotate({ description: specDescription }), }) - -// Exported so the committed workflow library can be validated in tests. -export const StartSpec = Schema.Struct({ - title: Schema.optional(Schema.String), - mode: Schema.optional(ExecutionMode), - admission: Schema.optional(AdmissionInput), - config: WorkflowGraphSchema, +const StartPath = Schema.Struct({ + action: Schema.Literal("start").annotate({ description: "Create a workflow" }), + spec_path: Schema.String.annotate({ description: specPathDescription }), }) - -const ExtendSpec = Schema.Struct({ - objective: Schema.optional(Schema.String), - blocks: Schema.optional(Schema.Array(DagBlocks.WorkflowBlock)), - nodes: Schema.optional(Schema.Array(NodeSchema)), +const ExtendInline = Schema.Struct({ + action: Schema.Literal("extend").annotate({ description: "Add nodes or blocks to a live workflow" }), + workflow_id: Dag.ID.annotate({ description: "Target workflow ID" }), + spec: DagValidation.ExtendSpec.annotate({ description: specDescription }), }) - -const ReplanSpec = Schema.Struct({ - fragment: WorkflowGraphSchema, +const ExtendPath = Schema.Struct({ + action: Schema.Literal("extend").annotate({ description: "Add nodes or blocks to a live workflow" }), + workflow_id: Dag.ID.annotate({ description: "Target workflow ID" }), + spec_path: Schema.String.annotate({ description: specPathDescription }), }) - -const decodeStartSpec = Schema.decodeUnknownEffect(StartSpec) -const decodeExtendSpec = Schema.decodeUnknownEffect(ExtendSpec) -const decodeReplanSpec = Schema.decodeUnknownEffect(ReplanSpec) - -export const Parameters = Schema.Struct({ - action: Schema.Literals(["start", "extend", "control", "status", "result", "list", "read", "guide"]).annotate({ - description: - "start: create workflow; extend: add nodes or blocks; control: pause/resume/cancel/replan/step/complete; status: inspect durable state; result: read one durable node output in bounded pages; list: show saved specs; read: inspect one saved spec before retargeting it; guide: load detailed guidance only when needed", - }), - topic: Schema.optional(Schema.Literals(["blocks", "interface", "policy", "patterns"])).annotate({ - description: - "(guide) blocks: composable block schema; interface: low-level workflow API; policy: gates/admission/recovery; patterns: domain playbooks. Omit for the compact index", - }), - spec: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ - description: - "(start/extend/control replan) Inline structured spec for a one-off graph. Use this or spec_path, never both", - }), - spec_path: Schema.optional(Schema.String).annotate({ - description: - '(start/extend/control replan/read) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory', +const ControlReplanInline = Schema.Struct({ + action: Schema.Literal("control").annotate({ description: "Control a live workflow" }), + operation: Schema.Literal("replan").annotate({ description: "Apply a node fragment (add/cancel/restart/replace)" }), + workflow_id: Dag.ID.annotate({ description: "Target workflow ID" }), + spec: DagValidation.ReplanSpec.annotate({ description: specDescription }), +}) +const ControlReplanPath = Schema.Struct({ + action: Schema.Literal("control").annotate({ description: "Control a live workflow" }), + operation: Schema.Literal("replan").annotate({ description: "Apply a node fragment (add/cancel/restart/replace)" }), + workflow_id: Dag.ID.annotate({ description: "Target workflow ID" }), + spec_path: Schema.String.annotate({ description: specPathDescription }), +}) +const ControlOther = Schema.Struct({ + action: Schema.Literal("control").annotate({ description: "Control a live workflow" }), + operation: Schema.Literals(["pause", "resume", "cancel", "step", "complete"]).annotate({ + description: "pause/resume/cancel/step/complete", }), - session_id: Schema.optional(Schema.String).annotate({ - description: "(start) Parent session ID; when provided, it must match the calling session", + workflow_id: Dag.ID.annotate({ description: "Target workflow ID" }), +}) +const Status = Schema.Struct({ + action: Schema.Literal("status").annotate({ description: "Inspect durable workflow and node state" }), + workflow_id: Dag.ID.annotate({ description: "Target workflow ID" }), +}) +const Result = Schema.Struct({ + action: Schema.Literal("result").annotate({ description: "Read one durable node output in bounded pages" }), + workflow_id: Dag.ID.annotate({ description: "Target workflow ID" }), + node_id: Dag.NodeID.annotate({ description: "Target durable node ID" }), + cursor: Schema.optional(ResultCursorToken).annotate({ + description: "Opaque continuation cursor returned by the previous page", }), - project_id: Schema.optional(Schema.String).annotate({ - description: "(start) Optional Project ID; must match the parent session project", + limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_RESULT_PAGE_CHARS }))).annotate({ + description: `Maximum page characters; defaults to ${DEFAULT_RESULT_PAGE_CHARS}, max ${MAX_RESULT_PAGE_CHARS}`, }), - workflow_id: Schema.optional(Dag.ID).annotate({ - description: "(extend/control/status/result) Target workflow ID", +}) +const List = Schema.Struct({ + action: Schema.Literal("list").annotate({ + description: "Show saved workflow specs in the library with their validation status", }), - node_id: Schema.optional(Dag.NodeID).annotate({ description: "(result) Target durable node ID" }), - cursor: Schema.optional(ResultCursorToken).annotate({ - description: "(result) Opaque continuation cursor returned by the previous page", +}) +const Read = Schema.Struct({ + action: Schema.Literal("read").annotate({ description: "Inspect one saved spec before retargeting it" }), + spec_path: Schema.String.annotate({ description: specPathDescription }), +}) +const Guide = Schema.Struct({ + action: Schema.Literal("guide").annotate({ description: "Load detailed guidance only when needed" }), + topic: Schema.optional(Schema.Literals(["blocks", "interface", "policy", "patterns"])).annotate({ + description: + "blocks: composable block schema; interface: low-level workflow API; policy: gates/admission/recovery; patterns: domain playbooks. Omit for the compact index", }), - limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_RESULT_PAGE_CHARS }))).annotate({ - description: `(result) Maximum page characters; defaults to ${DEFAULT_RESULT_PAGE_CHARS}, max ${MAX_RESULT_PAGE_CHARS}`, +}) +const ValidationProfile = Schema.optional(Schema.Literals(["portable", "environment"])).annotate({ + description: + "portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, inline and project/global specs environment", +}) +const ValidateInline = Schema.Struct({ + action: Schema.Literal("validate").annotate({ + description: "Pre-flight a custom spec without creating a workflow; returns diagnostics, never a workflow ID", }), - operation: Schema.optional(Schema.Literals(["pause", "resume", "cancel", "replan", "step", "complete"])).annotate({ - description: "(control) Operation to perform", + spec: DagValidation.StartSpec.annotate({ description: specDescription }), + profile: ValidationProfile, +}) +const ValidatePath = Schema.Struct({ + action: Schema.Literal("validate").annotate({ + description: "Pre-flight a custom spec without creating a workflow; returns diagnostics, never a workflow ID", }), + spec_path: Schema.String.annotate({ description: specPathDescription }), + profile: ValidationProfile, }) +export const Parameters = Schema.Union([ + StartInline, + StartPath, + ExtendInline, + ExtendPath, + ControlReplanInline, + ControlReplanPath, + ControlOther, + Status, + Result, + List, + Read, + Guide, + ValidateInline, + ValidatePath, +]) + // ============================================================================ // Tool definition // ============================================================================ @@ -199,10 +170,12 @@ type Metadata = { replace?: string[] } +type AuthoringSource = Parameters["prepare"]>[0]["source"] + export const WorkflowTool = Tool.define< typeof Parameters, Metadata, - Dag.Service | Session.Service | Agent.Service | Question.Service + Dag.Service | Session.Service | Agent.Service | Question.Service | Provider.Service >( id, Effect.gen(function* () { @@ -210,6 +183,7 @@ export const WorkflowTool = Tool.define< const sessions = yield* Session.Service const agents = yield* Agent.Service const question = yield* Question.Service + const provider = yield* Provider.Service const requireOwnedWorkflow = Effect.fn("WorkflowTool.requireOwnedWorkflow")(function* ( workflowID: Dag.ID, @@ -222,9 +196,79 @@ export const WorkflowTool = Tool.define< return workflow }) + const rejectDiagnostics = (diagnostics: Diagnostic[], context: string) => + Effect.die( + new Error( + `${context} rejected by workflow validation:\n${diagnostics + .map((d) => `- [${d.code}] ${d.path}: ${d.message}${d.hint ? ` (${d.hint})` : ""}`) + .join("\n")}`, + ), + ) + + const authoring = WorkflowAuthoring.make({ + loadEnvironment: (context) => + Effect.gen(function* () { + if (!context.directory) return {} + const agentCatalog = yield* agents.list().pipe(Effect.orDie) + const providerCatalog = yield* provider.list() + const config = yield* DagConfig.load(context.directory) + const agentsByName = new Map(agentCatalog.map((agent) => [agent.name, agent])) + const availableModels = new Set( + Object.values(providerCatalog).flatMap((info) => + Object.values(info.models).map((model) => `${model.providerID}/${model.id}`), + ), + ) + const resolveModel: NonNullable = (node, defaults) => + Effect.sync(() => { + const resolved = DagModel.resolve({ + node: node.model ?? defaults?.model, + tier: DagConfig.tierModel(config, { + required: node.required ?? defaults?.required ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeRequired, + workerType: node.worker_type, + }), + agent: agentsByName.get(node.worker_type)?.model, + parent: context.parent + ? { modelID: context.parent.id, providerID: context.parent.providerID } + : undefined, + }) + return Boolean(resolved && availableModels.has(`${resolved.providerID}/${resolved.modelID}`)) + }) + return { + worker_types: new Set(agentCatalog.map((agent) => agent.name)), + resolveModel, + } + }), + }) + + const portableEntryCheck = (entry: DagWorkflows.Entry) => + Effect.gen(function* () { + const content = yield* Effect.promise(() => entryContent(entry)) + if (content === undefined) { + return { valid: false, summary: "[schema.invalid] spec content is unreadable" } + } + const result = yield* authoring.prepare({ + action: "start", + source: { kind: "yaml", source: entry.path, content }, + profile: "portable", + }) + const summary = result.valid + ? "" + : result.errors + .slice(0, 3) + .map((d) => `[${d.code}] ${d.path}: ${d.message}`) + .join("; ") + return { valid: result.valid, summary } + }) + return { description: CommandPlugin.WorkflowContent, parameters: Parameters, + parseOptions: { onExcessProperty: "error" }, + formatValidationError: (error) => + [ + `Workflow call rejected by the action schema: ${error instanceof Error ? error.message : String(error)}`, + "Each action owns only its own fields: start {spec | spec_path}; extend {workflow_id, spec | spec_path}; control {workflow_id, operation} plus spec/spec_path for replan; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; validate {spec | spec_path, profile?}. Graph-carrying actions take exactly one source (spec or spec_path), and session/project identity is never a parameter.", + ].join("\n"), execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { const callingSession = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) @@ -239,9 +283,9 @@ export const WorkflowTool = Tool.define< always: ["*"], metadata: { action: params.action, - ...(params.workflow_id ? { workflow_id: params.workflow_id } : {}), - ...(params.node_id ? { node_id: params.node_id } : {}), - ...(params.operation ? { operation: params.operation } : {}), + ...("workflow_id" in params ? { workflow_id: params.workflow_id } : {}), + ...("node_id" in params ? { node_id: params.node_id } : {}), + ...("operation" in params ? { operation: params.operation } : {}), }, }) switch (params.action) { @@ -272,52 +316,108 @@ export const WorkflowTool = Tool.define< } } case "list": { - const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) - const entries = yield* DagWorkflows.list(session.directory) + const entries = yield* DagWorkflows.list(callingSession.directory) if (entries.length === 0) { return { title: "No saved workflows", - output: `The workflow library is empty. Searched ${searchedScopes(session.directory)}. Save a spec as .yaml in one of those directories to start it later by name.`, + output: `The workflow library is empty. Searched ${searchedScopes(callingSession.directory)}. Save a spec as .yaml in one of those directories to start it later by name.`, metadata: {}, } } + const rows: string[] = [] + for (const entry of entries) { + const check = yield* portableEntryCheck(entry) + rows.push( + [ + `${entry.name} [${entry.scope}]${check.valid ? "" : " [invalid — not startable]"}`, + entry.title ? ` — ${entry.title}` : "", + entry.nodes !== undefined + ? ` (${entry.nodes} nodes)` + : entry.blocks !== undefined + ? ` (${entry.blocks} blocks)` + : "", + `\n ${entry.path}`, + check.valid ? "" : `\n ${check.summary}`, + ].join(""), + ) + } return { title: `${entries.length} saved workflow${entries.length > 1 ? "s" : ""}`, - output: entries - .map((entry) => - [ - `${entry.name} [${entry.scope}]`, - entry.title ? ` — ${entry.title}` : "", - entry.nodes !== undefined - ? ` (${entry.nodes} nodes)` - : entry.blocks !== undefined - ? ` (${entry.blocks} blocks)` - : "", - `\n ${entry.path}`, - ].join(""), - ) - .join("\n"), + output: rows.join("\n"), metadata: {}, } } case "read": { - if (!params.spec_path || params.spec) { - return yield* Effect.die( - new Error("read requires exactly one 'spec_path' and does not accept inline 'spec'"), - ) - } - const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) - const specFile = yield* readWorkflowSpec(undefined, params.spec_path, session.directory, ctx).pipe( - Effect.orDie, - ) + const specFile = yield* loadSpecFile(params.spec_path, callingSession.directory, ctx).pipe(Effect.orDie) + const validation = yield* authoring.prepare({ + action: "start", + source: { kind: "yaml", source: specFile.path, content: specFile.content }, + profile: "portable", + }) return { title: `Workflow spec: ${params.spec_path}`, - output: JSON.stringify(specFile.value, null, 2), + output: JSON.stringify( + { + spec: validation.document, + validation: { + valid: validation.valid, + errors: validation.errors, + warnings: validation.warnings, + }, + }, + null, + 2, + ), + metadata: {}, + } + } + case "validate": { + const loaded = + "spec" in params + ? { path: "", source: { kind: "inline" as const, value: params.spec } } + : yield* loadSpecFile(params.spec_path, callingSession.directory, ctx).pipe( + Effect.map((file) => ({ + path: file.path, + source: { kind: "yaml" as const, source: file.path, content: file.content }, + })), + Effect.catch((error: unknown) => + Effect.succeed({ + path: params.spec_path, + loadError: error instanceof Error ? error.message : String(error), + }), + ), + ) + const profile = params.profile ?? (DagWorkflows.isBuiltinPath(loaded.path) ? "portable" : "environment") + const result = + "loadError" in loaded + ? { + source: loaded.path, + profile, + valid: false, + errors: [ + DagValidation.diagnostic({ + code: DagValidation.DIAGNOSTIC_CODES.schemaInvalid, + path: loaded.path, + message: loaded.loadError, + hint: "Verify the workflow name or YAML file path", + }), + ], + warnings: [], + nodes: [], + } + : yield* authoring.prepare({ + action: "start", + source: loaded.source, + profile, + environment: { directory: callingSession.directory, parent: callingSession.model }, + }) + return { + title: `Workflow validation ${result.valid ? "passed" : "failed"}: ${loaded.path} (${profile})`, + output: JSON.stringify(validationOutput(result), null, 2), metadata: {}, } } case "status": { - if (!params.workflow_id) return yield* Effect.die(new Error("status requires 'workflow_id'")) const workflow = yield* requireOwnedWorkflow(params.workflow_id, ctx.sessionID) const nodes = yield* dag.store.getNodes(params.workflow_id).pipe(Effect.orDie) const config = Dag.parseWorkflowConfig(workflow.config) @@ -365,9 +465,6 @@ export const WorkflowTool = Tool.define< } } case "result": { - if (!params.workflow_id || !params.node_id) { - return yield* Effect.die(new Error("result requires 'workflow_id' and 'node_id'")) - } yield* requireOwnedWorkflow(params.workflow_id, ctx.sessionID) const node = yield* dag.store.getNode(params.workflow_id, params.node_id).pipe(Effect.orDie) if (!node) { @@ -439,29 +536,26 @@ export const WorkflowTool = Tool.define< } } case "start": { - if (params.session_id && params.session_id !== ctx.sessionID) { - return yield* Effect.die(new Error("session_id must match the calling session")) - } const sessionID = SessionID.make(ctx.sessionID) - const session = yield* sessions.get(sessionID).pipe(Effect.orDie) - if (params.project_id && params.project_id !== session.projectID) { - return yield* Effect.die(new Error("project_id must match the parent session project")) - } - const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe( - Effect.orDie, - ) - const spec = yield* decodeStartSpec(specFile.value).pipe( - Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), - Effect.orDie, - ) - const config = compileGraph(spec.config, specFile.path) - const missingModels = yield* findNodesWithoutModel({ - nodes: config.nodes, - defaults: config.node_defaults, - directory: session.directory, - parent: session.model, - agents, + const source = yield* loadAuthoringSource( + "spec" in params ? { inline: params.spec } : { specPath: params.spec_path }, + callingSession.directory, + ctx, + ).pipe(Effect.orDie) + const result = yield* authoring.prepare({ + action: "start", + source, + profile: "environment", + environment: { directory: callingSession.directory, parent: callingSession.model }, }) + const blocking = result.errors.filter( + (diagnostic) => diagnostic.code !== DagValidation.DIAGNOSTIC_CODES.modelUnavailable, + ) + if (blocking.length > 0) return yield* rejectDiagnostics(blocking, "Workflow start") + const missingModels = result.errors + .filter((diagnostic) => diagnostic.code === DagValidation.DIAGNOSTIC_CODES.modelUnavailable) + .map((diagnostic) => /^nodes\[([^\]]+)\]$/.exec(diagnostic.path)?.[1]) + .filter((node): node is string => node !== undefined) if (missingModels.length > 0) { yield* question .ask({ @@ -492,42 +586,48 @@ export const WorkflowTool = Tool.define< metadata: {}, } } + if (result.prepared?.action !== "start") return yield* rejectDiagnostics(result.errors, "Workflow start") + const prepared = result.prepared const dagID = yield* dag .create({ - projectID: session.projectID, + projectID: callingSession.projectID, sessionID, - title: spec.title ?? config.name, + title: prepared.title, config: { - ...config, - mode: spec.mode ?? "standard", - ...(spec.admission ? { admission: createAdmissionRecord(spec.admission) } : {}), - } as WorkflowConfig, + ...prepared.config, + ...(prepared.admission ? { admission: createAdmissionRecord(prepared.admission) } : {}), + }, }) .pipe(Effect.orDie) - const mode = spec.mode ?? "standard" + const mode = prepared.config.mode ?? "standard" return { - title: `Workflow started: ${config.name}`, - output: `\n${config.nodes.length} nodes registered.\nDo not poll this workflow. It runs asynchronously and will wake the parent session when attention is required.\n`, + title: `Workflow started: ${prepared.config.name}`, + output: `\n${result.prepared.nodes.length} nodes registered.\nDo not poll this workflow. It runs asynchronously and will wake the parent session when attention is required.\n`, metadata: { workflowId: dagID } as Metadata, } } case "extend": { - if (!params.workflow_id) return yield* Effect.die(new Error("extend requires 'workflow_id'")) - yield* requireOwnedWorkflow(params.workflow_id, ctx.sessionID) - const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) - const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe( - Effect.orDie, - ) - const spec = yield* decodeExtendSpec(specFile.value).pipe( - Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), - Effect.orDie, - ) + const workflow = yield* requireOwnedWorkflow(params.workflow_id, ctx.sessionID) + const workflowDefaults = Dag.parseWorkflowConfig(workflow.config)?.node_defaults const knownDependencies = (yield* dag.store.getNodes(params.workflow_id).pipe(Effect.orDie)).map( (node) => node.id, ) - const nodes = compileNodeSource(spec, specFile.path, knownDependencies) + const source = yield* loadAuthoringSource( + "spec" in params ? { inline: params.spec } : { specPath: params.spec_path }, + callingSession.directory, + ctx, + ).pipe(Effect.orDie) + const result = yield* authoring.prepare({ + action: "extend", + source, + profile: "environment", + environment: { directory: callingSession.directory, parent: callingSession.model }, + known_dependencies: knownDependencies, + node_defaults: workflowDefaults, + }) + if (!result.valid || !result.prepared) return yield* rejectDiagnostics(result.errors, "Workflow extend") const r = yield* withTerminalRecovery( - dag.extend(params.workflow_id, nodes), + dag.extend(params.workflow_id, result.prepared.nodes), "Terminal workflows are immutable except for the additive-extend reopen, which requires the workflow to have completed naturally at a wake-eligible reporting checkpoint (fragment adds new node ids; no early control(complete); no executed node beyond the checkpoint — condition-skipped dependents are fine). When the reopen does not apply, recover by starting a NEW workflow spec that reuses this workflow's completed outputs as static input.", ).pipe(Effect.orDie) return { @@ -537,15 +637,42 @@ export const WorkflowTool = Tool.define< } } case "control": { - if (!params.workflow_id || !params.operation) { - return yield* Effect.die( - new Error( - `control requires 'workflow_id' and 'operation' (got workflow_id=${params.workflow_id ?? ""}, operation=${params.operation ?? ""}). Example: { action: "control", workflow_id: "dag_...", operation: "pause" }. On a cancel/replan intent, issue pause FIRST — it needs no spec and freezes scheduling instantly while you compose the replan.`, - ), - ) - } const wfId = params.workflow_id - yield* requireOwnedWorkflow(wfId, ctx.sessionID) + const workflow = yield* requireOwnedWorkflow(wfId, ctx.sessionID) + if (params.operation === "replan") { + const workflowDefaults = Dag.parseWorkflowConfig(workflow.config)?.node_defaults + const knownDependencies = (yield* dag.store.getNodes(wfId).pipe(Effect.orDie)).map((node) => node.id) + const source = yield* loadAuthoringSource( + "spec" in params ? { inline: params.spec } : { specPath: params.spec_path }, + callingSession.directory, + ctx, + ).pipe(Effect.orDie) + const result = yield* authoring.prepare({ + action: "replan", + source, + profile: "environment", + environment: { directory: callingSession.directory, parent: callingSession.model }, + known_dependencies: knownDependencies, + node_defaults: workflowDefaults, + }) + if (!result.valid || !result.prepared) return yield* rejectDiagnostics(result.errors, "Workflow replan") + // The graph raced to terminal while the fragment was being + // composed (the pause-first protocol was skipped). Surface + // the recovery options instead of a bare iron-law rejection. + const r = yield* withTerminalRecovery( + dag.replan(wfId, { nodes: result.prepared.nodes }), + "The workflow reached a terminal status before the replan arrived — terminal workflows are immutable. Recover by starting a new workflow with the updated node definitions, or extend if a reporting leaf checkpoint naturally completed the graph. Next time issue control(pause) BEFORE composing the spec.", + ).pipe(Effect.orDie) + const ignored = + r.ignore.length > 0 + ? `\nIgnored (terminal, immutable — add replacements under new ids to retry): ${r.ignore.join(", ")}` + : "" + return { + title: `Workflow replanned: +${r.add.length} -${r.cancel.length} ↻${r.restart.length}`, + output: `\nAdded: ${r.add.join(", ")}\nCancelled: ${r.cancel.join(", ")}\nRestarted: ${r.restart.join(", ")}\nReplaced: ${r.replace.join(", ")}${ignored}\n`, + metadata: { workflowId: wfId, ...r } as Metadata, + } + } switch (params.operation) { case "pause": yield* dag.pause(wfId).pipe(Effect.orDie) @@ -575,34 +702,6 @@ export const WorkflowTool = Tool.define< output: ``, metadata: { workflowId: wfId } as Metadata, } - case "replan": { - const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) - const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe( - Effect.orDie, - ) - const spec = yield* decodeReplanSpec(specFile.value).pipe( - Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), - Effect.orDie, - ) - const knownDependencies = (yield* dag.store.getNodes(wfId).pipe(Effect.orDie)).map((node) => node.id) - const fragment = compileGraph(spec.fragment, specFile.path, knownDependencies) - // The graph raced to terminal while the fragment was being - // composed (the pause-first protocol was skipped). Surface - // the recovery options instead of a bare iron-law rejection. - const r = yield* withTerminalRecovery( - dag.replan(wfId, { nodes: fragment.nodes }), - "The workflow reached a terminal status before the replan arrived — terminal workflows are immutable. Recover by starting a new workflow with the updated node definitions, or extend if a reporting leaf checkpoint naturally completed the graph. Next time issue control(pause) BEFORE composing the spec.", - ).pipe(Effect.orDie) - const ignored = - r.ignore.length > 0 - ? `\nIgnored (terminal, immutable — add replacements under new ids to retry): ${r.ignore.join(", ")}` - : "" - return { - title: `Workflow replanned: +${r.add.length} -${r.cancel.length} ↻${r.restart.length}`, - output: `\nAdded: ${r.add.join(", ")}\nCancelled: ${r.cancel.join(", ")}\nRestarted: ${r.restart.join(", ")}\nReplaced: ${r.replace.join(", ")}${ignored}\n`, - metadata: { workflowId: wfId, ...r } as Metadata, - } - } case "step": { const r = yield* dag.step(wfId).pipe(Effect.orDie) if (r.status === "no_ready_nodes") { @@ -626,6 +725,10 @@ export const WorkflowTool = Tool.define< }), ) +// ============================================================================ +// Helpers +// ============================================================================ + function resultPageEnd(content: string, offset: number, limit: number) { const end = Math.min(content.length, offset + limit) if (end >= content.length) return end @@ -638,53 +741,19 @@ function resultPageEnd(content: string, offset: number, limit: number) { return end - offset === 1 ? end + 1 : end - 1 } -type WorkflowGraphInput = Schema.Schema.Type -type NodeSource = Pick - -function compileGraph(graph: WorkflowGraphInput, source: string, knownDependencies?: string[]) { - const nodes = compileNodeSource(graph, source, knownDependencies) - const { objective: _objective, blocks: _blocks, nodes: _nodes, ...config } = graph +function validationOutput(result: DagValidation.ValidationResult) { return { - ...config, - nodes, - } as WorkflowConfig + source: result.source, + profile: result.profile, + valid: result.valid, + errors: result.errors, + warnings: result.warnings, + nodes: result.nodes, + } } -function compileNodeSource(source: NodeSource, path: string, knownDependencies?: string[]) { - const hasNodes = source.nodes !== undefined - const hasBlocks = source.blocks !== undefined - if (hasNodes === hasBlocks) throw new Error(`Invalid workflow graph ${path}: use exactly one of nodes or blocks`) - if (source.nodes) return source.nodes as NodeConfig[] - if (!source.objective) throw new Error(`Invalid workflow graph ${path}: blocks require objective`) - return DagBlocks.compileWorkflowBlocks( - { - objective: source.objective, - blocks: source.blocks as DagBlocks.WorkflowBlock[], - }, - { known_dependencies: knownDependencies }, - ) -} - -function readWorkflowSpec( - spec: Record | undefined, - specPath: string | undefined, - directory: string, - ctx: Tool.Context, -) { +function loadSpecFile(specPath: string, directory: string, ctx: Tool.Context) { return Effect.gen(function* () { - if (spec && specPath) { - return yield* Effect.fail( - new Error("Workflow configuration accepts exactly one source: remove either 'spec' or 'spec_path'."), - ) - } - if (spec) return { path: "", value: spec } - if (!specPath) { - return yield* Effect.fail( - new Error( - `Workflow configuration requires exactly one of 'spec' or 'spec_path'. Pass an inline structured spec for a one-off graph, or a saved workflow name/path through 'spec_path'.`, - ), - ) - } const filepath = yield* resolveSpecPath(specPath, directory, ctx) // Builtin templates are compiled into the binary (no backing file). @@ -693,11 +762,7 @@ function readWorkflowSpec( if (content === undefined) { return yield* Effect.fail(new Error(`Workflow spec not found: ${filepath}`)) } - const value = yield* Effect.try({ - try: () => Bun.YAML.parse(content), - catch: (error) => workflowSpecParseError(filepath, error), - }) - return { path: filepath, value } + return { path: filepath, content } } const file = Bun.file(filepath) @@ -713,14 +778,21 @@ function readWorkflowSpec( try: () => file.text(), catch: (error) => new Error(`Failed to read workflow spec ${filepath}: ${String(error)}`), }) - const value = yield* Effect.try({ - try: () => Bun.YAML.parse(content), - catch: (error) => workflowSpecParseError(filepath, error), - }) - return { path: filepath, value } + return { path: filepath, content } }) } +function loadAuthoringSource( + input: { inline: unknown; specPath?: never } | { inline?: never; specPath: string }, + directory: string, + ctx: Tool.Context, +): Effect.Effect { + if ("inline" in input) return Effect.succeed({ kind: "inline" as const, value: input.inline }) + return loadSpecFile(input.specPath, directory, ctx).pipe( + Effect.map((file) => ({ kind: "yaml" as const, source: file.path, content: file.content })), + ) +} + /** Directories (and the builtin fallback, when the release ships templates) a * bare workflow name may resolve from — for "not found" / empty-library hints. */ function searchedScopes(directory: string) { @@ -758,10 +830,6 @@ function resolveSpecPath(specPath: string, directory: string, ctx: Tool.Context) }) } -function workflowSpecParseError(filepath: string, error: unknown) { - return new Error(`Invalid workflow YAML ${filepath}: ${error instanceof Error ? error.message : String(error)}`) -} - /** Terminal-workflow rejections surface as defects carrying recovery * guidance, not bare iron-law errors. Shared by the replan and extend paths. */ function withTerminalRecovery(effect: Effect.Effect, guidance: string) { @@ -773,36 +841,9 @@ function withTerminalRecovery(effect: Effect.Effect, guidance: stri ) } -function findNodesWithoutModel(input: { - nodes: ReadonlyArray> - defaults?: Schema.Schema.Type["node_defaults"] - directory: string - parent?: Session.Info["model"] - agents: Agent.Interface -}) { - if (input.nodes.length === 0) return Effect.succeed([]) - return Effect.gen(function* () { - const config = yield* DagConfig.load(input.directory) - return yield* Effect.filter( - input.nodes, - (node) => - Effect.gen(function* () { - const agent = yield* input.agents.get(node.worker_type).pipe( - Effect.map((info) => info as Agent.Info | undefined), - Effect.catchCause(() => Effect.succeed(undefined)), - ) - return ( - DagModel.resolve({ - tier: DagConfig.tierModel(config, { - required: node.required ?? input.defaults?.required ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeRequired, - workerType: node.worker_type, - }), - agent: agent?.model, - parent: input.parent ? { modelID: input.parent.id, providerID: input.parent.providerID } : undefined, - }) === undefined - ) - }), - { concurrency: "unbounded" }, - ).pipe(Effect.map((nodes) => nodes.map((node) => node.id))) - }) +async function entryContent(entry: DagWorkflows.Entry): Promise { + if (entry.content !== undefined) return entry.content + return Bun.file(entry.path) + .text() + .catch(() => undefined) } diff --git a/packages/opencode/test/command/command.test.ts b/packages/opencode/test/command/command.test.ts index 160937d0ac..958ee3129d 100644 --- a/packages/opencode/test/command/command.test.ts +++ b/packages/opencode/test/command/command.test.ts @@ -108,7 +108,7 @@ describe("legacy command registry", () => { "Use @security-reviewer to review this project. Do not modify files.", ) - expect(expanded).toContain("orchestration-router") + expect(expanded).toContain("resident Orchestration Router") expect(expanded).toMatch(/Preserve\s+the task, user constraints/) expect(expanded).toContain("worker types or model IDs") expect(expanded).toContain("configured capability or model") diff --git a/packages/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts index 773b72605e..038ccc4217 100644 --- a/packages/opencode/test/dag/blocks.test.ts +++ b/packages/opencode/test/dag/blocks.test.ts @@ -3,7 +3,7 @@ import { DagBlocks } from "@/dag/blocks" import { DagConfig } from "@/dag/config" describe("workflow blocks", () => { - it("compiles a staged route and carries objective, instructions, skills, and dependencies", () => { + it("compiles a staged route and carries objective, instructions, and dependencies", () => { const nodes = DagBlocks.compileWorkflowBlocks({ objective: "Add durable session recovery", blocks: [ @@ -16,7 +16,6 @@ describe("workflow blocks", () => { id: "build", kind: "coding", depends_on: ["map"], - skills: ["tdd"], }, { id: "verify", @@ -35,8 +34,8 @@ describe("workflow blocks", () => { objective: "Add durable session recovery", instruction: "Locate persistence ownership", }) - expect(nodes[1]?.prompt_template.inline).toContain("load these relevant skills") - expect(nodes[1]?.prompt_template.inline).toContain("tdd") + expect(nodes[1]?.prompt_template.inline).toContain("failing check") + expect(nodes[1]?.prompt_template.inline).not.toContain("Skill") expect(nodes.map((node) => ({ id: node.id, required: node.required }))).toEqual([ { id: "map", required: false }, { id: "build", required: false }, @@ -44,6 +43,48 @@ describe("workflow blocks", () => { ]) }) + it("composes configured design delivery capabilities without new lifecycle kinds", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Design, implement, and review project-owned memory", + blocks: [ + { + id: "codebase-design", + kind: "plan", + instruction: "Define the project identity seam and migration boundary.", + }, + { id: "coding", kind: "coding", depends_on: ["codebase-design"] }, + { id: "verify", kind: "verify", depends_on: ["coding"] }, + { id: "global-review", kind: "review", depends_on: ["verify"] }, + ], + }) + + expect(nodes.map((node) => node.id)).toEqual([ + "codebase-design", + "coding", + "verify", + "global-review--standards", + "global-review--intent", + "global-review", + ]) + expect(nodes[0]?.prompt_template.input).toMatchObject({ + instruction: "Define the project identity seam and migration boundary.", + }) + }) + + it("keeps a pruned design delivery route valid when evidence is already supplied", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Implement the confirmed design from supplied file-level evidence", + blocks: [ + { id: "coding", kind: "coding" }, + { id: "verify", kind: "verify", depends_on: ["coding"] }, + { id: "global-review", kind: "review", depends_on: ["verify"] }, + ], + }) + + expect(nodes.some((node) => node.worker_type === "explore")).toBe(false) + expect(nodes.find((node) => node.id === "global-review")?.required).toBe(true) + }) + it("expands debug into evidence and diagnosis nodes", () => { const nodes = DagBlocks.compileWorkflowBlocks({ objective: "Find the source of a timeout", diff --git a/packages/opencode/test/dag/dag-create-validation.test.ts b/packages/opencode/test/dag/dag-create-validation.test.ts index 2ab11022de..61b65d7102 100644 --- a/packages/opencode/test/dag/dag-create-validation.test.ts +++ b/packages/opencode/test/dag/dag-create-validation.test.ts @@ -235,10 +235,14 @@ describe("Dag prompt_template binding validation", () => { title: "binding-extend", config: { name: "binding-extend", nodes: [node("explore")] }, }).pipe(Effect.orDie) - const errorMessage = yield* dag.extend(dagID, [ + // extend internally routes through _replan, which now uses the same + // StructuralValidationError as create (shared authority). + const error = yield* dag.extend(dagID, [ { ...node("repair", ["explore"]), prompt_template: { inline: "Use {{path}}" } }, - ]).pipe(Effect.catch((e: Error) => Effect.succeed(e.message))) - expect(errorMessage).toContain('Replan rejected: node "repair" prompt_template references unbound variable "{{path}}"') + ]).pipe(Effect.catch((e: unknown) => Effect.succeed(e))) + expect(error).toBeInstanceOf(Dag.StructuralValidationError) + if (!(error instanceof Error)) throw error + expect(error.message).toContain('node "repair" prompt_template references unbound variable "{{path}}"') }).pipe(Effect.scoped, Effect.provide(dagLayer)), ) }) diff --git a/packages/opencode/test/dag/dag-templates-generation.test.ts b/packages/opencode/test/dag/dag-templates-generation.test.ts new file mode 100644 index 0000000000..f65ba88a36 --- /dev/null +++ b/packages/opencode/test/dag/dag-templates-generation.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "../fixture/fixture" + +// Generation embeds DAG_TEMPLATES_DIR content into the binary (script/generate.ts). +// Validate-before-embed: an invalid or unparseable template must abort +// generation instead of shipping a builtin template that fails at start/read +// time. Runs generate.ts as a subprocess to exercise the real build path. + +const pkgRoot = path.resolve(import.meta.dir, "..", "..") + +const VALID_TEMPLATE = `config: + name: valid-route + objective: Ship the bounded change + blocks: + - id: plan + kind: plan +` + +// Review fed by prototype writers without verification — the exact shape the +// block compiler rejects (and the pre-fix prototype-decision-route pinned). +const INVALID_TEMPLATE = `config: + name: invalid-route + objective: Ship the bounded change + blocks: + - id: proto + kind: prototype + - id: review + kind: review + depends_on: [proto] +` + +async function runGenerate(templatesDir: string, modelsSnapshot: string) { + const result = Bun.spawnSync({ + cmd: ["bun", path.join("script", "generate.ts")], + cwd: pkgRoot, + env: { ...process.env, DAG_TEMPLATES_DIR: templatesDir, MODELS_DEV_API_JSON: modelsSnapshot }, + stdout: "pipe", + stderr: "pipe", + }) + return { + exitCode: result.exitCode, + output: `${result.stdout.toString()}\n${result.stderr.toString()}`, + } +} + +// modelsSnapshot lives inside the scoped tmpdir so cleanup is guaranteed even +// when the test body throws — no separate rm that could leak on an exception path. +async function withTemplatesDir( + files: Record, + fn: (dir: string, modelsSnapshot: string) => Promise, +) { + await using tmp = await tmpdir({ + init: async (dir) => { + const templates = path.join(dir, "templates") + await fs.mkdir(templates, { recursive: true }) + for (const [name, content] of Object.entries(files)) { + await fs.writeFile(path.join(templates, name), content) + } + await fs.writeFile( + path.join(templates, "runtime-compat.json"), + JSON.stringify({ runtime_repo: "LeXwDeX/OpenCode-GraphAgent", runtime_commit: "0".repeat(40) }), + ) + await fs.writeFile(path.join(dir, "models-snapshot.json"), "{}") + }, + }) + await fn(path.join(tmp.path, "templates"), path.join(tmp.path, "models-snapshot.json")) +} + +describe("dag template generation validates before embedding", () => { + it( + "embeds a directory whose templates all pass portable validation", + async () => { + await withTemplatesDir({ "valid-route.yml": VALID_TEMPLATE }, async (dir, modelsSnapshot) => { + const result = await runGenerate(dir, modelsSnapshot) + expect(result.exitCode).toBe(0) + expect(result.output).toContain("1 templates (all validated)") + }) + }, + { timeout: 60_000 }, + ) + + it( + "aborts when a template fails portable validation", + async () => { + await withTemplatesDir( + { "valid-route.yaml": VALID_TEMPLATE, "invalid-route.yaml": INVALID_TEMPLATE }, + async (dir, modelsSnapshot) => { + const result = await runGenerate(dir, modelsSnapshot) + expect(result.exitCode).not.toBe(0) + expect(result.output).toContain("invalid-route.yaml [block.compile_failed]") + expect(result.output).toContain("block.compile_failed") + }, + ) + }, + { timeout: 60_000 }, + ) + + it( + "aborts when a template is not parseable YAML", + async () => { + await withTemplatesDir( + { "broken.yaml": "key: [unclosed", "valid-route.yaml": VALID_TEMPLATE }, + async (dir, modelsSnapshot) => { + const result = await runGenerate(dir, modelsSnapshot) + expect(result.exitCode).not.toBe(0) + }, + ) + }, + { timeout: 60_000 }, + ) + + it( + "aborts when runtime compatibility metadata is missing", + async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "valid-route.yml"), VALID_TEMPLATE) + await fs.writeFile(path.join(dir, "models-snapshot.json"), "{}") + }, + }) + const result = await runGenerate(tmp.path, path.join(tmp.path, "models-snapshot.json")) + expect(result.exitCode).not.toBe(0) + expect(result.output).toContain("runtime compatibility file is missing") + }, + { timeout: 60_000 }, + ) + + it( + "rejects duplicate logical names across yaml extensions", + async () => { + await withTemplatesDir( + { "duplicate.yaml": VALID_TEMPLATE, "duplicate.yml": VALID_TEMPLATE }, + async (dir, modelsSnapshot) => { + const result = await runGenerate(dir, modelsSnapshot) + expect(result.exitCode).not.toBe(0) + expect(result.output).toContain("duplicated across .yaml/.yml") + }, + ) + }, + { timeout: 60_000 }, + ) +}) diff --git a/packages/opencode/test/dag/dag-validation-parity.test.ts b/packages/opencode/test/dag/dag-validation-parity.test.ts new file mode 100644 index 0000000000..bba3378dc6 --- /dev/null +++ b/packages/opencode/test/dag/dag-validation-parity.test.ts @@ -0,0 +1,199 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { DagProjector } from "@opencode-ai/core/dag/projector" +import { DagStore } from "@opencode-ai/core/dag/store" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { EventV2Bridge } from "@/event-v2-bridge" +import { Dag, type NodeConfig } from "@/dag/dag" +import { DagValidation } from "@/dag/validation" +import { WorkflowAuthoring } from "@/dag/authoring" +import { testEffect } from "../lib/effect" + +const testLayer = Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + DagProjector.defaultLayer, + DagStore.defaultLayer, + EventV2Bridge.defaultLayer, +) + +const dagLayer = Layer.provideMerge(Dag.layer, testLayer) + +const it = testEffect(dagLayer) + +const validate = (value: unknown) => + WorkflowAuthoring.make().prepare({ + action: "start", + source: { kind: "inline", value }, + profile: "portable", + }) + +// The same bad spec must be rejected with the same diagnostic codes and +// field paths by the validate action (pure validator) and by start +// (Dag.create reusing the shared structural core) — before any event is +// published in either case. + +const badNodes = [ + { + id: "a", + name: "a", + worker_type: "general", + depends_on: ["missing"], + prompt_template: { inline: "Use {{gone}}" }, + }, + { + id: "b", + name: "b", + worker_type: "general", + depends_on: ["b"], + prompt_template: { inline: "Self loop" }, + }, +] + +describe("validate/start parity through the shared validator", () => { + it.effect("the same bad spec yields the same structural codes and paths", () => + Effect.gen(function* () { + const dag = yield* Dag.Service + const validation = yield* validate({ config: { name: "parity", nodes: badNodes } }) + expect(validation.valid).toBe(false) + + const error = yield* dag + .create({ + projectID: "project-1", + sessionID: "ses_parity", + title: "parity", + config: { name: "parity", nodes: badNodes as NodeConfig[] }, + }) + .pipe(Effect.catch((e: Error) => Effect.succeed(e))) + expect(error).toBeInstanceOf(Dag.StructuralValidationError) + const createErrors = (error as Dag.StructuralValidationError).diagnostics.filter( + (d) => d.severity === "error", + ) + const key = (d: { code: string; path: string; message: string }) => `${d.code}|${d.path}|${d.message}` + // Same authority ⇒ same codes and paths for the structural rules + // (order differs: create reports in legacy class order). + expect(createErrors.map(key).sort()).toEqual( + validation.errors + .filter( + (d) => + d.code === DagValidation.DIAGNOSTIC_CODES.dagInvalid || + d.code === DagValidation.DIAGNOSTIC_CODES.promptUnboundVariable, + ) + .map(key) + .sort(), + ) + }), + ) + + it.effect("create rejection publishes no events", () => + Effect.gen(function* () { + const dag = yield* Dag.Service + const store = dag.store + const error = yield* dag + .create({ + projectID: "project-1", + sessionID: "ses_parity_no_events", + title: "parity-no-events", + config: { name: "parity", nodes: badNodes as NodeConfig[] }, + }) + .pipe(Effect.catch((e: Error) => Effect.succeed(e))) + expect(error).toBeInstanceOf(Error) + expect(yield* store.getNodes("anything").pipe(Effect.orDie)).toEqual([]) + }), + ) + + it.effect("the same uncompilable block graph fails validate and start identically", () => + Effect.gen(function* () { + // prototype writers feeding a review without verification — the exact + // shape pinned from the pre-fix prototype-decision-route template. + const value = { + config: { + name: "review-without-verify", + objective: "Ship it", + blocks: [ + { id: "proto", kind: "prototype" }, + { id: "plan", kind: "plan", depends_on: ["proto"] }, + { id: "review", kind: "review", depends_on: ["plan"] }, + ], + }, + } as const + const validation = yield* validate(value) + expect(validation.valid).toBe(false) + expect(validation.errors[0]?.code).toBe(DagValidation.DIAGNOSTIC_CODES.blockCompileFailed) + // The start path compiles through the same shared function. + const compiled = DagValidation.compileGraphSource(value.config) + expect(compiled.nodes).toBeUndefined() + expect(compiled.diagnostics.map((d) => [d.code, d.path, d.message])).toEqual( + validation.errors.map((d) => [d.code, d.path, d.message]), + ) + }), + ) + + it.effect("replan rejects the same structural errors through the shared authority", () => + Effect.gen(function* () { + // FK setup so the valid workflow we replan against can persist events. + const { db } = yield* Database.Service + yield* db.insert(ProjectTable).values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }).run().pipe(Effect.orDie) + yield* db.insert(SessionTable).values({ id: "ses_replan_parity" as never, project_id: Project.ID.global, slug: "replan", directory: "/project", title: "replan", version: "test" }).run().pipe(Effect.orDie) + + const dag = yield* Dag.Service + const goodNode: NodeConfig = { + id: "good", + name: "good", + worker_type: "general", + depends_on: [], + required: true, + prompt_template: { inline: "Work" }, + } + const dagID = yield* dag.create({ + projectID: Project.ID.global, + sessionID: "ses_replan_parity", + title: "replan-parity", + config: { name: "replan-parity", nodes: [goodNode] }, + }).pipe(Effect.orDie) + + // Structural errors that planReplan does NOT pre-filter: a condition + // referencing a node outside depends_on, and an unbound prompt + // placeholder. Both are enforced only by the shared structural authority, + // so create and replan must produce the same diagnostic codes. + const badReplanFragment: NodeConfig[] = [ + { + id: "cond", + name: "cond", + worker_type: "general", + depends_on: [], + required: true, + condition: 'gate.output.verdict == "ACCEPT"', + prompt_template: { inline: "Work {{missing}}" }, + }, + ] + + const error = yield* dag.replan(dagID, { nodes: badReplanFragment }).pipe( + Effect.catch((e: unknown) => Effect.succeed(e)), + ) + expect(error).toBeInstanceOf(Dag.StructuralValidationError) + const replanErrors = (error as Dag.StructuralValidationError).diagnostics.filter( + (d) => d.severity === "error", + ) + + // Cross-check against the pure validator for the same fragment nodes. + const validation = yield* validate({ config: { name: "replan-parity", nodes: badReplanFragment } }) + const key = (d: { code: string; path: string; message: string }) => `${d.code}|${d.path}|${d.message}` + expect(replanErrors.map(key).sort()).toEqual( + validation.errors + .filter( + (d) => + d.code === DagValidation.DIAGNOSTIC_CODES.dagInvalid || + d.code === DagValidation.DIAGNOSTIC_CODES.promptUnboundVariable, + ) + .map(key) + .sort(), + ) + }), + ) +}) diff --git a/packages/opencode/test/dag/dag-validation.test.ts b/packages/opencode/test/dag/dag-validation.test.ts new file mode 100644 index 0000000000..7fa0184ac4 --- /dev/null +++ b/packages/opencode/test/dag/dag-validation.test.ts @@ -0,0 +1,542 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import fs from "node:fs/promises" +import path from "node:path" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { DagValidation } from "../../src/dag/validation" +import { WorkflowAuthoring } from "../../src/dag/authoring" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(CrossSpawnSpawner.defaultLayer) + +function validateSpec(input: { + value: unknown + source: string + profile?: DagValidation.Profile + directory?: string + catalogs?: DagValidation.EnvironmentCatalogs +}) { + return WorkflowAuthoring.make({ loadEnvironment: () => Effect.succeed(input.catalogs ?? {}) }).prepare({ + action: "start", + source: { kind: "inline", value: input.value, source: input.source }, + profile: input.profile, + environment: { directory: input.directory }, + }) +} + +function validateYaml(input: { content: string; source: string; profile?: DagValidation.Profile }) { + return WorkflowAuthoring.make().prepare({ + action: "start", + source: { kind: "yaml", content: input.content, source: input.source }, + profile: input.profile, + }) +} + +const validNodesSpec = { + title: "Two node chain", + config: { + name: "two-node-chain", + nodes: [ + { + id: "explore", + name: "explore", + worker_type: "explore", + depends_on: [], + prompt_template: { inline: "Inspect {{target}}", input: { target: "dag module" } }, + required: true, + }, + { + id: "summarize", + name: "summarize", + worker_type: "general", + depends_on: ["explore"], + prompt_template: { inline: "Summarize {{explore}}." }, + report_to_parent: true, + }, + ], + }, +} + +const validBlocksSpec = { + config: { + name: "plan-verify-review", + objective: "Ship the bounded change with evidence", + blocks: [ + { id: "plan", kind: "plan" }, + { id: "code", kind: "coding", depends_on: ["plan"] }, + { id: "verify", kind: "verify", depends_on: ["code"] }, + { id: "review", kind: "review", depends_on: ["verify"] }, + ], + }, +} + +function projectDir(files: Record) { + return tmpdirScoped({ + init: (directory) => + Effect.promise(async () => { + for (const [file, content] of Object.entries(files)) { + const target = path.join(directory, file) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, content, "utf-8") + } + }), + }) +} + +describe("workflow spec validator", () => { + describe("diagnostic contract", () => { + it.effect("returns stable machine-readable diagnostics with severity, code, path, message, hint", () => + Effect.gen(function* () { + const result = yield* validateSpec({ value: { config: {} }, source: "" }) + expect(result.valid).toBe(false) + expect(result.errors.length).toBeGreaterThan(0) + for (const diagnostic of result.errors) { + expect(diagnostic.severity).toBe("error") + expect(typeof diagnostic.code).toBe("string") + expect(typeof diagnostic.path).toBe("string") + expect(typeof diagnostic.message).toBe("string") + expect(typeof diagnostic.hint).toBe("string") + } + }), + ) + + it.effect("collects several independent errors instead of only the first", () => + Effect.gen(function* () { + const result = yield* validateSpec({ + value: { + config: { + name: "multi-error", + nodes: [ + { + id: "a", + name: "a", + worker_type: "general", + depends_on: ["missing"], + prompt_template: { inline: "Use {{gone}}" }, + }, + { + id: "a", + name: "dup", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "ok" }, + }, + ], + }, + }, + source: "", + }) + const codes = result.errors.map((d) => d.code) + expect(codes).toContain(DagValidation.DIAGNOSTIC_CODES.dagInvalid) + expect(codes).toContain(DagValidation.DIAGNOSTIC_CODES.promptUnboundVariable) + expect(result.errors.length).toBeGreaterThanOrEqual(3) + }), + ) + + it.effect("orders diagnostics stably for identical input", () => + Effect.gen(function* () { + const input = { + value: { + config: { + name: "ordering", + nodes: [ + { + id: "z", + name: "z", + worker_type: "general", + depends_on: ["missing"], + prompt_template: { inline: "{{gone}}" }, + }, + { + id: "a", + name: "a", + worker_type: "general", + depends_on: ["missing"], + prompt_template: { inline: "{{gone}}" }, + }, + ], + }, + }, + source: "", + } + const first = yield* validateSpec(input) + const second = yield* validateSpec(input) + expect(second.errors).toEqual(first.errors) + }), + ) + + it.effect("returns schema.invalid for malformed YAML through the shared parser", () => + Effect.gen(function* () { + const result = yield* validateYaml({ + content: "config: [unclosed", + source: "broken.yaml", + }) + expect(result).toMatchObject({ + source: "broken.yaml", + profile: "portable", + valid: false, + warnings: [], + nodes: [], + errors: [ + { + severity: "error", + code: DagValidation.DIAGNOSTIC_CODES.schemaInvalid, + path: "$", + message: "file is not parseable YAML", + }, + ], + }) + }), + ) + + it.effect("warning-only validation stays valid", () => + Effect.gen(function* () { + const result = yield* validateSpec({ + value: { + config: { + name: "warning-only", + nodes: [ + { + id: "gate", + name: "gate", + worker_type: "general", + depends_on: [], + required: true, + output_schema: { type: "object", format: "custom" }, + prompt_template: { inline: "Rule." }, + }, + ], + }, + }, + source: "", + }) + expect(result.valid).toBe(true) + expect(result.errors).toEqual([]) + expect(result.warnings.some((d) => d.code === DagValidation.DIAGNOSTIC_CODES.schemaKeywordWarning)).toBe(true) + }), + ) + + it.effect("validation has no side effects: no services, no files, no workflow id", () => + Effect.gen(function* () { + const tmp = yield* projectDir({}) + const before = yield* Effect.promise(() => fs.readdir(tmp)) + const result = yield* validateSpec({ + value: validNodesSpec, + source: "", + directory: tmp, + }) + expect(result.valid).toBe(true) + expect(JSON.stringify(result)).not.toContain("workflow_id") + expect(yield* Effect.promise(() => fs.readdir(tmp))).toEqual(before) + }), + ) + }) + + describe("portable profile", () => { + it.effect("valid block YAML passes without reading user directories", () => + Effect.gen(function* () { + const result = yield* validateSpec({ value: validBlocksSpec, source: "builtin://test" }) + expect(result.valid).toBe(true) + expect(result.nodes.map((node) => node.id)).toEqual( + expect.arrayContaining(["plan", "code", "verify", "review--standards", "review"]), + ) + expect(result.nodes.find((node) => node.id === "review")?.review_phase).toBe("diff") + }), + ) + + it.effect("valid low-level node YAML passes with compiled-node summary", () => + Effect.gen(function* () { + const result = yield* validateSpec({ value: validNodesSpec, source: "" }) + expect(result.valid).toBe(true) + expect(result.nodes.map((node) => node.id)).toEqual(["explore", "summarize"]) + expect(result.nodes[1]?.depends_on).toEqual(["explore"]) + }), + ) + + it.effect("rejects a spec carrying both graph sources", () => + Effect.gen(function* () { + const result = yield* validateSpec({ + value: { + config: { + name: "both-sources", + objective: "x", + blocks: [{ id: "plan", kind: "plan" }], + nodes: [{ id: "a", name: "a", worker_type: "general", depends_on: [], prompt_template: { inline: "x" } }], + }, + }, + source: "", + }) + expect(result.valid).toBe(false) + expect(result.errors[0]?.code).toBe(DagValidation.DIAGNOSTIC_CODES.schemaInvalid) + }), + ) + + it.effect("rejects a prompt template selecting both inline and id", () => + Effect.gen(function* () { + const result = yield* validateSpec({ + value: { + config: { + name: "ambiguous-source", + nodes: [ + { + id: "a", + name: "a", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "x", id: "code-explore" }, + }, + ], + }, + }, + source: "", + }) + expect(result.valid).toBe(false) + }), + ) + + it.effect("rejects a prompt template with no source", () => + Effect.gen(function* () { + const result = yield* validateSpec({ + value: { + config: { + name: "no-source", + nodes: [ + { + id: "a", + name: "a", + worker_type: "general", + depends_on: [], + prompt_template: { input: { target: "x" } }, + }, + ], + }, + }, + source: "", + }) + expect(result.valid).toBe(false) + }), + ) + + it.effect("flags id prompts as nonportable even when the project happens to own them", () => + Effect.gen(function* () { + const tmp = yield* projectDir({ ".opencode/dag-prompts/code-explore.md": "Explore {{target}}" }) + const result = yield* validateSpec({ + value: { + config: { + name: "id-prompt", + nodes: [ + { + id: "explore", + name: "explore", + worker_type: "explore", + depends_on: [], + prompt_template: { id: "code-explore", input: { target: "x" } }, + }, + ], + }, + }, + source: "builtin://dag-review", + profile: "portable", + directory: tmp, + }) + expect(result.errors.some((d) => d.code === DagValidation.DIAGNOSTIC_CODES.promptNonportableAsset)).toBe(true) + }), + ) + + it.effect("inline prompt with an unbound placeholder fails with node id and path", () => + Effect.gen(function* () { + const result = yield* validateSpec({ + value: { + config: { + name: "unbound", + nodes: [ + { + id: "a", + name: "a", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "Use {{gone}}" }, + }, + ], + }, + }, + source: "", + }) + const diagnostic = result.errors.find((d) => d.code === DagValidation.DIAGNOSTIC_CODES.promptUnboundVariable) + expect(diagnostic).toBeDefined() + expect(diagnostic?.message).toContain("{{gone}}") + }), + ) + }) + + describe("environment profile", () => { + it.effect("resolves a project prompt and validates its bindings", () => + Effect.gen(function* () { + const tmp = yield* projectDir({ + ".opencode/dag-prompts/code-explore.md": "Explore {{target}} and {{missing}}", + }) + const result = yield* validateSpec({ + value: { + config: { + name: "env-binding", + nodes: [ + { + id: "explore", + name: "explore", + worker_type: "explore", + depends_on: [], + prompt_template: { id: "code-explore", input: { target: "dag" } }, + }, + ], + }, + }, + source: "env.yaml", + profile: "environment", + directory: tmp, + }) + const diagnostic = result.errors.find((d) => d.code === DagValidation.DIAGNOSTIC_CODES.promptUnboundVariable) + expect(diagnostic?.message).toContain('prompt asset "code-explore"') + expect(diagnostic?.message).toContain("{{missing}}") + }), + ) + + it.effect("reports prompt.missing_asset when the id does not resolve", () => + Effect.gen(function* () { + const tmp = yield* projectDir({}) + const result = yield* validateSpec({ + value: { + config: { + name: "missing-asset", + nodes: [ + { + id: "explore", + name: "explore", + worker_type: "explore", + depends_on: [], + prompt_template: { id: "does-not-exist" }, + }, + ], + }, + }, + source: "env.yaml", + profile: "environment", + directory: tmp, + }) + expect(result.errors.some((d) => d.code === DagValidation.DIAGNOSTIC_CODES.promptMissingAsset)).toBe(true) + }), + ) + + it.effect("worker.unknown is an error and block validation is Skill-catalog independent", () => + Effect.gen(function* () { + const result = yield* validateSpec({ + value: validBlocksSpec, + source: "", + profile: "environment", + catalogs: { + worker_types: new Set(["plan", "general"]), + }, + }) + const unknown = result.errors.filter((d) => d.code === DagValidation.DIAGNOSTIC_CODES.workerUnknown) + expect(unknown.map((d) => d.message)).toEqual(expect.arrayContaining([expect.stringContaining('"build"')])) + expect(result.warnings).toEqual([]) + + const legacy = yield* validateSpec({ + value: { + config: { + ...validBlocksSpec.config, + blocks: [{ id: "plan", kind: "plan", skills: ["ghost-skill"] }], + }, + }, + source: "", + profile: "environment", + catalogs: { worker_types: new Set(["plan", "build", "general"]) }, + }) + expect(legacy.valid).toBe(false) + expect(legacy.errors).toContainEqual( + expect.objectContaining({ + code: DagValidation.DIAGNOSTIC_CODES.schemaInvalid, + path: expect.stringContaining("skills"), + }), + ) + }), + ) + + it.effect("model.unavailable is reported per unresolved node", () => + Effect.gen(function* () { + const result = yield* validateSpec({ + value: validNodesSpec, + source: "", + profile: "environment", + catalogs: { + resolveModel: (node) => Effect.succeed(node.id !== "summarize"), + }, + }) + const diagnostic = result.errors.find((d) => d.code === DagValidation.DIAGNOSTIC_CODES.modelUnavailable) + expect(diagnostic?.path).toBe("nodes[summarize]") + }), + ) + }) + + describe("config repository evidence", () => { + it.effect("pre-fix prototype-decision-route.yaml fails block compilation (pinned fixture)", () => + Effect.gen(function* () { + const source = yield* Effect.promise(() => + Bun.file( + new URL("./fixtures/config-templates-pre-fix/prototype-decision-route.yaml", import.meta.url), + ).text(), + ) + const result = yield* validateSpec({ + value: Bun.YAML.parse(source), + source: "prototype-decision-route.yaml", + }) + expect(result.valid).toBe(false) + const diagnostic = result.errors.find((d) => d.code === DagValidation.DIAGNOSTIC_CODES.blockCompileFailed) + expect(diagnostic).toBeDefined() + expect(diagnostic?.message).toContain("verification") + }), + ) + + it.effect("pre-fix dag-review.yaml prompts are not portable (pinned fixture)", () => + Effect.gen(function* () { + const source = yield* Effect.promise(() => + Bun.file(new URL("./fixtures/config-templates-pre-fix/dag-review.yaml", import.meta.url)).text(), + ) + const result = yield* validateSpec({ + value: Bun.YAML.parse(source), + source: "dag-review.yaml", + }) + expect(result.valid).toBe(false) + const nonportable = result.errors.filter( + (d) => d.code === DagValidation.DIAGNOSTIC_CODES.promptNonportableAsset, + ) + const ids = nonportable.map((d) => d.message) + for (const prompt of ["code-explore", "review-arch", "review-logic", "review-style"]) { + expect(ids.join("\n")).toContain(prompt) + } + }), + ) + + it.effect("every root YAML in the config repository passes portable validation", () => + Effect.gen(function* () { + // Live cross-repo gate: runs where an opencode-dag-config checkout sits + // next to the runtime repo (or OPENCODAG_CONFIG_REPO points at one). + const repoDir = + process.env.OPENCODAG_CONFIG_REPO ?? + path.resolve(import.meta.dir, "..", "..", "..", "..", "opencode-dag-config") + const yamlFiles = (yield* Effect.promise(() => fs.readdir(repoDir).catch(() => [] as string[]))).filter( + (name) => name.endsWith(".yaml") || name.endsWith(".yml"), + ) + // checkout not available — CI pins the evidence above instead + if (yamlFiles.length === 0) return + const failures: Array<{ name: string; errors: DagValidation.Diagnostic[] }> = [] + for (const file of yamlFiles.sort()) { + const text = yield* Effect.promise(() => Bun.file(path.join(repoDir, file)).text()) + const result = yield* validateYaml({ content: text, source: file }) + if (!result.valid) failures.push({ name: file, errors: result.errors }) + } + expect(failures).toEqual([]) + }), + ) + }) +}) diff --git a/packages/opencode/test/dag/fixtures/config-templates-pre-fix/dag-review.yaml b/packages/opencode/test/dag/fixtures/config-templates-pre-fix/dag-review.yaml new file mode 100644 index 0000000000..06c186c92d --- /dev/null +++ b/packages/opencode/test/dag/fixtures/config-templates-pre-fix/dag-review.yaml @@ -0,0 +1,166 @@ +title: "DAG Module Deep Review" +config: + name: dag-module-review + max_concurrency: 5 + max_node_replan_attempts: 3 + max_total_nodes: 20 + node_defaults: + required: false + report_to_parent: false + worker_config: + timeout_ms: 1800000 + nodes: + - id: explore-core + name: explore-core + worker_type: explore + depends_on: [] + prompt_template: + id: code-explore + input: + target: "packages/opencode/src/dag core lifecycle files: dag.ts, config.ts, model.ts, admission.ts, review-lifecycle.ts. Map workflow state machine transitions, locking strategy (withWorkflowLock), node lifecycle (spawn/complete/fail/cancel/pause/resume/step), config normalization, model resolution, and admission QA protocol. Identify state ownership, concurrency guards, and cross-module contracts." + + - id: explore-runtime + name: explore-runtime + worker_type: explore + depends_on: [] + prompt_template: + id: code-explore + input: + target: "packages/opencode/src/dag/runtime execution engine: loop.ts (scheduling loop, layer computation, concurrency control), spawn.ts (child session creation), recovery.ts (crash recovery, reconciliation), eval.ts (condition evaluation, input mapping), capture.ts (output schema validation, submit_result), summary-publisher.ts (event emission). Map the scheduling algorithm, session lifecycle, error propagation, and recovery invariants." + + - id: explore-templates + name: explore-templates + worker_type: explore + depends_on: [] + prompt_template: + id: code-explore + input: + target: "packages/opencode/src/dag/templates template system: resolve.ts (template resolution, rendering, interpolation) and sanitize.ts (input sanitization, injection prevention). Map template loading (by ID from .opencode/dag-prompts, inline), variable interpolation mechanics, and the sanitization boundary. Identify trust assumptions and injection vectors." + + - id: review-arch + name: review-arch + worker_type: review + depends_on: [explore-core, explore-runtime, explore-templates] + prompt_template: + id: review-arch + + - id: review-logic + name: review-logic + worker_type: review + depends_on: [explore-core, explore-runtime, explore-templates] + prompt_template: + id: review-logic + + - id: review-style + name: review-style + worker_type: review + depends_on: [explore-core, explore-runtime, explore-templates] + prompt_template: + id: review-style + + - id: verify-claims + name: verify-claims + worker_type: general + depends_on: [review-arch, review-logic, review-style] + required: true + prompt_template: + inline: | + You are a claim verifier. Three reviewers produced findings and unverified_claims about the DAG module (packages/opencode/src/dag/). + + Your job: take EVERY item listed under `unverified_claims` from all three reviews and check it against the actual source code. For each claim: + 1. Open the cited file(s) and read the relevant code. + 2. Determine: CONFIRMED (the claim is true, cite evidence), REFUTED (the claim is false, cite counter-evidence), or INCONCLUSIVE (cannot determine from code alone, state why). + 3. Also spot-check any finding marked CRITICAL/P0 that lacks a clear file:line citation. + + Output a structured verdict per claim. Never modify any file. + + ## Reviewer outputs to verify: + + ### Architecture Review + {{review-arch}} + + ### Logic Review + {{review-logic}} + + ### Style Review + {{review-style}} + + - id: arbitrate + name: arbitrate + worker_type: review + depends_on: [verify-claims] + required: true + report_to_parent: true + output_schema: + type: object + required: [verdict, summary, findings, required_actions, next_action] + properties: + verdict: + type: string + enum: [ACCEPT, REVISE, REJECT, BLOCKED] + summary: + type: string + findings: + type: array + items: + type: object + properties: + severity: { type: string } + title: { type: string } + evidence: { type: string } + status: { type: string, enum: [confirmed, refuted, inconclusive] } + required_actions: + type: array + items: { type: string } + next_action: + type: object + required: [operation, targets] + properties: + operation: + type: string + enum: [continue, extend, replan, complete, stop] + targets: + type: array + items: { type: string } + prompt_template: + inline: | + You are the arbiter for a deep review of the DAG workflow engine (packages/opencode/src/dag/). + + Three reviewers (architecture, logic, style) produced findings. A verification node then checked all unverified_claims against the actual code. + + Your task: + 1. Rule finding-by-finding: accept only findings with CONFIRMED evidence. Discard REFUTED claims. Flag INCONCLUSIVE items as residual risk. + 2. Deduplicate overlapping findings across reviewers. + 3. Rank confirmed findings by severity and blast radius. + 4. Emit a structured verdict: + - ACCEPT: no CRITICAL/P0 confirmed findings, module is sound. + - REVISE: confirmed findings exist but are addressable without redesign. + - REJECT: confirmed CRITICAL/P0 findings require structural rework. + - BLOCKED: verification was insufficient to rule. + 5. Provide required_actions (concrete, file-scoped) and next_action for the orchestrator. + + Never modify any file. Base your ruling ONLY on verified evidence from the verification node. + + ## Verification Results + {{verify-claims}} + + - id: deep-dive + name: deep-dive + worker_type: general + depends_on: [arbitrate] + condition: 'arbitrate.output.verdict != "ACCEPT"' + report_to_parent: true + prompt_template: + inline: | + The arbiter did not ACCEPT the DAG module review. Its findings and required actions are below. + + For each required_action: + 1. Open the cited file(s) and verify the problem still exists at the stated location. + 2. Produce a corrected, evidence-backed remediation plan: exact file, function, what to change, and why. + 3. Identify any dependencies between actions (ordering constraints). + 4. Flag any action that is infeasible or would cause a regression. + + Output a prioritized remediation plan. Never modify any file. + + ## Arbiter Verdict + {{arbitrate}} diff --git a/packages/opencode/test/dag/fixtures/config-templates-pre-fix/prototype-decision-route.yaml b/packages/opencode/test/dag/fixtures/config-templates-pre-fix/prototype-decision-route.yaml new file mode 100644 index 0000000000..6095253c94 --- /dev/null +++ b/packages/opencode/test/dag/fixtures/config-templates-pre-fix/prototype-decision-route.yaml @@ -0,0 +1,53 @@ +title: "Prototype detour: evidence → experiments → production plan" +config: + name: prototype-decision-route + max_concurrency: 3 + max_node_replan_attempts: 2 + max_total_nodes: 16 + node_defaults: + worker_config: + timeout_ms: 1800000 + objective: >- + Resolve a confirmed runnable design uncertainty with disposable experiments, + then convert only supported observations into a reviewed production plan. + blocks: + - id: uncertainty-map + kind: explore + instruction: >- + Define the exact unknown, current evidence, falsifiable success signal, + constraints, and what the experiment must not attempt to prove. + + - id: simplest-experiment + kind: prototype + depends_on: [uncertainty-map] + instruction: >- + Build the shortest disposable path that can falsify the leading design. + Keep it isolated from production wiring and record reproducible observations. + + - id: contrast-experiment + kind: prototype + depends_on: [uncertainty-map] + instruction: >- + Test the strongest contrasting mechanism or failure mode. Optimize for + information gained, not polish, and keep all artifacts disposable. + + - id: production-plan + kind: plan + depends_on: [simplest-experiment, contrast-experiment] + instruction: >- + Separate observations from inference, discard prototype shortcuts, and + propose production boundaries, migration steps, tests, and stop criteria. + + - id: plan-decision + kind: review + depends_on: [production-plan] + instruction: >- + Verify that the plan follows from experiment evidence, does not promote + throwaway code implicitly, and exposes unresolved risks and falsifiers. + + - id: decision-record + kind: synthesize + depends_on: [plan-decision] + instruction: >- + Record what was learned, what was disproven, the reviewed production path, + acceptance checks, and remaining uncertainty. diff --git a/packages/opencode/test/dag/release-packaging-smoke.test.ts b/packages/opencode/test/dag/release-packaging-smoke.test.ts new file mode 100644 index 0000000000..c9898cdcf9 --- /dev/null +++ b/packages/opencode/test/dag/release-packaging-smoke.test.ts @@ -0,0 +1,338 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import fs from "node:fs/promises" +import path from "node:path" +import { DagValidation } from "@/dag/validation" +import { WorkflowAuthoring } from "@/dag/authoring" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +// Release packaging smoke test (change repair-workflow-authoring-validation, +// §6.4): the package-templates job's contract is validate-before-copy, fail +// closed. This simulates the job locally: run the runtime validator CLI, +// package only when it passes, then prove the archive holds exactly the +// validated YAML, compatibility manifest, and required provenance/license +// files, and that all three commit identifiers are recorded. + +const it = testEffect(CrossSpawnSpawner.defaultLayer) + +const pkgRoot = path.resolve(import.meta.dir, "..", "..") + +const VALID_TEMPLATE_A = `config: + name: route-a + objective: Ship the bounded change + blocks: + - id: plan + kind: plan +` + +const VALID_TEMPLATE_B = `config: + name: route-b + nodes: + - id: work + name: work + worker_type: build + depends_on: [] + prompt_template: + inline: Do the work. +` + +const INVALID_TEMPLATE = `config: + name: route-broken + objective: Ship + blocks: + - id: proto + kind: prototype + - id: review + kind: review + depends_on: [proto] +` + +function runValidator(configDir: string) { + const result = Bun.spawnSync({ + cmd: ["bun", path.join("script", "validate-dag-templates.ts"), configDir], + cwd: pkgRoot, + env: { ...process.env }, + stdout: "pipe", + stderr: "pipe", + }) + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + } +} + +// The release job and this smoke test run the exact same script, so the +// copy/tar contract cannot drift between CI and the test. +function runPackager(configDir: string, archive: string) { + const result = Bun.spawnSync({ + cmd: ["bun", path.join("script", "package-dag-templates.ts"), configDir, archive], + cwd: pkgRoot, + env: { ...process.env }, + stdout: "pipe", + stderr: "pipe", + }) + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + } +} + +function runCliPackager(distDir: string, archive: string) { + const result = Bun.spawnSync({ + cmd: ["bun", path.join("script", "package-cli-artifact.ts"), distDir, archive], + cwd: pkgRoot, + env: { ...process.env }, + stdout: "pipe", + stderr: "pipe", + }) + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + } +} + +function configRepoScoped(templates: Record) { + return Effect.gen(function* () { + return yield* tmpdirScoped({ + init: (directory) => + Effect.promise(async () => { + for (const [name, content] of Object.entries(templates)) { + await fs.writeFile(path.join(directory, name), content) + } + // runtime-compat.json travels with the config repo and is read by the CLI. + await fs.writeFile( + path.join(directory, "runtime-compat.json"), + JSON.stringify({ runtime_repo: "LeXwDeX/OpenCode-GraphAgent", runtime_commit: "0".repeat(40) }), + ) + await fs.mkdir(path.join(directory, "third_party", "mattpocock-skills"), { recursive: true }) + await fs.writeFile(path.join(directory, "THIRD_PARTY_NOTICES.md"), "# Third-party notices\n") + await fs.writeFile(path.join(directory, "third_party", "mattpocock-skills", "LICENSE"), "MIT License\n") + await fs.writeFile( + path.join(directory, "third_party", "mattpocock-skills", "SOURCE.md"), + "# Source\n", + ) + for (const command of [ + ["git", "init", "-q"], + ["git", "config", "user.email", "test@example.com"], + ["git", "config", "user.name", "Test"], + ["git", "add", "."], + ["git", "commit", "-qm", "test templates"], + ]) { + const result = Bun.spawnSync({ cmd: command, cwd: directory, stdout: "pipe", stderr: "pipe" }) + if (result.exitCode !== 0) throw new Error(result.stderr.toString()) + } + }), + }) + }) +} + +describe("release packaging smoke test", () => { + it.effect("packages every license referenced by NOTICE into the real CLI archive", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const dist = path.join(directory, "opencode-test") + yield* Effect.promise(() => fs.mkdir(path.join(dist, "bin"), { recursive: true })) + yield* Effect.promise(() => fs.writeFile(path.join(dist, "bin", "opencode"), "test binary")) + const archive = path.join(directory, "opencode-test.tar.gz") + const packaged = runCliPackager(dist, archive) + expect(packaged.exitCode).toBe(0) + + const unpack = path.join(directory, "unpack") + yield* Effect.promise(() => fs.mkdir(unpack)) + const untar = Bun.spawnSync({ cmd: ["tar", "-xzf", archive, "-C", unpack], stdout: "pipe", stderr: "pipe" }) + expect(untar.exitCode).toBe(0) + const repoRoot = path.resolve(pkgRoot, "..", "..") + for (const name of [ + "NOTICE", + "LICENSE", + "packages/core/src/dag/LICENSE", + "packages/opencode/src/dag/LICENSE", + "third_party/mattpocock-skills/LICENSE", + "third_party/mattpocock-skills/SOURCE.md", + ]) { + expect(yield* Effect.promise(() => fs.readFile(path.join(unpack, name), "utf-8"))).toBe( + yield* Effect.promise(() => fs.readFile(path.join(repoRoot, name), "utf-8")), + ) + } + }), + ) + + it.effect( + "packages exactly the validated YAML through the release packager and records all SHAs", + () => + Effect.gen(function* () { + const configDir = yield* configRepoScoped({ + "route-a.yaml": VALID_TEMPLATE_A, + "route-b.yml": VALID_TEMPLATE_B, + }) + const archive = path.join(configDir, "dag-templates.tar.gz") + const packaged = runPackager(configDir, archive) + expect(packaged.exitCode).toBe(0) + + const manifest = JSON.parse(packaged.stdout) + expect(manifest.files).toEqual([ + "THIRD_PARTY_NOTICES.md", + "route-a.yaml", + "route-b.yml", + "runtime-compat.json", + "third_party/mattpocock-skills/LICENSE", + "third_party/mattpocock-skills/SOURCE.md", + ]) + expect(manifest.template_files).toEqual(["route-a.yaml", "route-b.yml"]) + expect(manifest.file_count).toBe(6) + expect(manifest.template_count).toBe(2) + // Runtime SHA comes from the releasing runtime checkout; compat SHA + // from the config repo's pinned runtime commit. + expect(manifest.runtime_commit).toMatch(/^[0-9a-f]{7,40}$/) + expect(manifest.compat_runtime_sha).toBe("0".repeat(40)) + expect(manifest.template_commit).toMatch(/^[0-9a-f]{40}$/) + + // Unpack the produced artifact and assert it holds exactly the + // validated YAML plus compatibility and provenance metadata, + // byte-identical and usable by the real generation path. + const unpack = path.join(configDir, "unpack") + yield* Effect.promise(() => fs.mkdir(unpack)) + const untar = Bun.spawnSync({ cmd: ["tar", "-xzf", archive, "-C", unpack], stdout: "pipe", stderr: "pipe" }) + expect(untar.exitCode).toBe(0) + + const archived = (yield* Effect.promise(() => fs.readdir(unpack))) + .filter((name) => name.endsWith(".yaml") || name.endsWith(".yml")) + .sort() + expect(archived).toEqual(["route-a.yaml", "route-b.yml"]) + expect((yield* Effect.promise(() => fs.readdir(unpack))).sort()).toEqual([ + "THIRD_PARTY_NOTICES.md", + "route-a.yaml", + "route-b.yml", + "runtime-compat.json", + "third_party", + ]) + expect(yield* Effect.promise(() => fs.readFile(path.join(unpack, "runtime-compat.json"), "utf-8"))).toBe( + yield* Effect.promise(() => fs.readFile(path.join(configDir, "runtime-compat.json"), "utf-8")), + ) + for (const name of archived) { + const content = yield* Effect.promise(() => fs.readFile(path.join(unpack, name), "utf-8")) + expect(content).toBe(yield* Effect.promise(() => fs.readFile(path.join(configDir, name), "utf-8"))) + const result = yield* WorkflowAuthoring.make().prepare({ + action: "start", + source: { kind: "yaml", content, source: name }, + profile: "portable", + }) + expect(result.valid).toBe(true) + } + for (const name of [ + "THIRD_PARTY_NOTICES.md", + "third_party/mattpocock-skills/LICENSE", + "third_party/mattpocock-skills/SOURCE.md", + ]) { + expect(yield* Effect.promise(() => fs.readFile(path.join(unpack, name), "utf-8"))).toBe( + yield* Effect.promise(() => fs.readFile(path.join(configDir, name), "utf-8")), + ) + } + + const modelsSnapshot = path.join(configDir, "models-snapshot.json") + yield* Effect.promise(() => fs.writeFile(modelsSnapshot, "{}")) + const generated = Bun.spawnSync({ + cmd: ["bun", path.join("script", "generate.ts")], + cwd: pkgRoot, + env: { ...process.env, DAG_TEMPLATES_DIR: unpack, MODELS_DEV_API_JSON: modelsSnapshot }, + stdout: "pipe", + stderr: "pipe", + }) + expect(`${generated.stdout.toString()}\n${generated.stderr.toString()}`).not.toContain( + "runtime compatibility file is missing", + ) + expect(generated.exitCode).toBe(0) + }), + { timeout: 60_000 }, + ) + + it.effect( + "fails closed on duplicate logical names before packaging", + () => + Effect.gen(function* () { + const configDir = yield* configRepoScoped({ + "duplicate.yaml": VALID_TEMPLATE_A, + "duplicate.yml": VALID_TEMPLATE_B, + }) + const archive = path.join(configDir, "dag-templates.tar.gz") + const packaged = runPackager(configDir, archive) + + expect(packaged.exitCode).toBe(1) + expect(packaged.stderr).toContain("duplicated across .yaml/.yml") + expect(yield* Effect.promise(() => Bun.file(archive).exists())).toBe(false) + const validation = runValidator(configDir) + expect(validation.exitCode).toBe(1) + expect(JSON.parse(validation.stdout).discovery_error).toContain("duplicated across .yaml/.yml") + }), + { timeout: 60_000 }, + ) + + it.effect( + "fails closed: an invalid template blocks packaging entirely", + () => + Effect.gen(function* () { + const configDir = yield* configRepoScoped({ + "route-a.yaml": VALID_TEMPLATE_A, + "route-broken.yaml": INVALID_TEMPLATE, + }) + const archive = path.join(configDir, "dag-templates.tar.gz") + const packaged = runPackager(configDir, archive) + expect(packaged.exitCode).toBe(1) + expect(packaged.stderr).toContain("Template validation failed") + expect(packaged.stderr).toContain("Packaging aborted") + // Nothing was archived — the release job aborts at the gate. + expect(yield* Effect.promise(() => Bun.file(archive).exists())).toBe(false) + + const gate = runValidator(configDir) + expect(gate.exitCode).toBe(1) + const report = JSON.parse(gate.stdout) + expect(report.invalid_count).toBe(1) + const broken = report.results.find((entry: { name: string }) => entry.name === "route-broken.yaml") + expect(broken.errors[0].code).toBe(DagValidation.DIAGNOSTIC_CODES.blockCompileFailed) + }), + { timeout: 60_000 }, + ) + + it.effect( + "reports an unparseable template inside the machine-readable JSON", + () => + Effect.gen(function* () { + const configDir = yield* configRepoScoped({ + "route-a.yaml": VALID_TEMPLATE_A, + "route-unparseable.yaml": "key: [unclosed", + }) + const gate = runValidator(configDir) + expect(gate.exitCode).toBe(1) + // stdout stays parseable JSON even when a file cannot be parsed. + const report = JSON.parse(gate.stdout) + const broken = report.results.find((entry: { name: string }) => entry.name === "route-unparseable.yaml") + expect(broken.valid).toBe(false) + expect(broken.errors[0].code).toBe(DagValidation.DIAGNOSTIC_CODES.schemaInvalid) + expect(broken.errors[0].message).toContain("not parseable YAML") + }), + { timeout: 60_000 }, + ) + + it.effect( + "fails closed when runtime compatibility metadata is missing or invalid", + () => + Effect.gen(function* () { + const configDir = yield* configRepoScoped({ "route-a.yaml": VALID_TEMPLATE_A }) + yield* Effect.promise(() => fs.writeFile(path.join(configDir, "runtime-compat.json"), "{broken")) + const invalid = runValidator(configDir) + expect(invalid.exitCode).toBe(1) + expect(JSON.parse(invalid.stdout).compat_error).toContain("runtime compatibility file is invalid") + + yield* Effect.promise(() => fs.rm(path.join(configDir, "runtime-compat.json"))) + const missing = runValidator(configDir) + expect(missing.exitCode).toBe(1) + expect(JSON.parse(missing.stdout).compat_error).toContain("runtime compatibility file is missing") + }), + { timeout: 60_000 }, + ) +}) diff --git a/packages/opencode/test/dag/workflow-authoring.test.ts b/packages/opencode/test/dag/workflow-authoring.test.ts new file mode 100644 index 0000000000..1df8901e03 --- /dev/null +++ b/packages/opencode/test/dag/workflow-authoring.test.ts @@ -0,0 +1,211 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { WorkflowAuthoring } from "../../src/dag/authoring" +import { DagValidation } from "../../src/dag/validation" +import { testEffect } from "../lib/effect" + +const it = testEffect(CrossSpawnSpawner.defaultLayer) + +const node = { + id: "work", + name: "work", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "Do the work" }, +} + +const start = { + config: { + name: "one-node", + nodes: [node], + }, +} + +describe("WorkflowAuthoring source-to-graph seam", () => { + it.effect("prepares start, extend, and replan through one action-aware interface", () => + Effect.gen(function* () { + const authoring = WorkflowAuthoring.make() + const inputs = [ + { action: "start" as const, value: start }, + { action: "extend" as const, value: { nodes: [{ ...node, id: "extend" }] } }, + { + action: "replan" as const, + value: { fragment: { name: "replacement", nodes: [{ ...node, id: "replacement" }] } }, + }, + ] + + for (const input of inputs) { + const result = yield* authoring.prepare({ + action: input.action, + source: { kind: "inline", value: input.value }, + profile: "portable", + }) + expect(result.valid).toBe(true) + expect(result.prepared?.action).toBe(input.action) + expect(result.prepared?.nodes).toHaveLength(1) + if (input.action === "start") { + expect(result.prepared).toMatchObject({ + action: "start", + title: "one-node", + config: { name: "one-node", mode: "standard", nodes: [{ id: "work" }] }, + }) + } + } + }), + ) + + it.effect("treats inline values strictly but adapts legacy model hints only at the YAML boundary", () => + Effect.gen(function* () { + const authoring = WorkflowAuthoring.make() + const inline = yield* authoring.prepare({ + action: "start", + source: { + kind: "inline", + value: { + config: { + name: "inline-model", + node_defaults: { model: { providerID: "openai", modelID: "gpt-4.1" } }, + nodes: [{ ...node, model: { providerID: "openai", modelID: "gpt-4.1" } }], + }, + }, + }, + profile: "portable", + }) + expect(inline.valid).toBe(false) + expect(inline.errors.map((error) => error.code)).toContain(DagValidation.DIAGNOSTIC_CODES.schemaInvalid) + + const yaml = yield* authoring.prepare({ + action: "start", + source: { + kind: "yaml", + source: "legacy.yaml", + content: [ + "config:", + " name: legacy-model", + " node_defaults:", + " model: { providerID: openai, modelID: gpt-4.1 }", + " nodes:", + " - id: work", + " name: work", + " worker_type: general", + " depends_on: []", + " model: { providerID: openai, modelID: gpt-4.1 }", + " prompt_template: { inline: Do the work }", + ].join("\n"), + }, + profile: "portable", + }) + expect(yaml.valid).toBe(true) + expect(yaml.prepared?.nodes[0]?.model).toEqual({ providerID: "openai", modelID: "gpt-4.1" }) + expect(yaml.prepared?.action === "start" ? yaml.prepared.config.node_defaults?.model : undefined).toEqual({ + providerID: "openai", + modelID: "gpt-4.1", + }) + + const replan = yield* authoring.prepare({ + action: "replan", + source: { + kind: "yaml", + source: "legacy-replan.yaml", + content: [ + "fragment:", + " name: legacy-replan", + " node_defaults:", + " model: { providerID: openai, modelID: gpt-4.1 }", + " nodes:", + " - id: work", + " name: work", + " worker_type: general", + " depends_on: []", + " prompt_template: { inline: Do the work }", + ].join("\n"), + }, + profile: "portable", + }) + expect(replan.prepared?.nodes[0]?.model).toEqual({ providerID: "openai", modelID: "gpt-4.1" }) + }), + ) + + it.effect("reports malformed YAML as stable diagnostics instead of throwing", () => + Effect.gen(function* () { + const result = yield* WorkflowAuthoring.make().prepare({ + action: "start", + source: { kind: "yaml", source: "broken.yaml", content: "config: [unclosed" }, + profile: "portable", + }) + expect(result).toMatchObject({ + source: "broken.yaml", + profile: "portable", + valid: false, + errors: [{ code: DagValidation.DIAGNOSTIC_CODES.schemaInvalid, path: "$" }], + warnings: [], + }) + expect(result.prepared).toBeUndefined() + }), + ) + + it.effect("keeps portable caching but refreshes live environment catalogs", () => + Effect.gen(function* () { + let loads = 0 + const authoring = WorkflowAuthoring.make({ + loadEnvironment: () => { + loads += 1 + return Effect.succeed({ + worker_types: new Set(loads === 1 ? ["general"] : []), + }) + }, + }) + const input = { + action: "start" as const, + source: { kind: "inline" as const, value: start }, + } + const portable = yield* authoring.prepare({ ...input, profile: "portable" }) + expect(portable.valid).toBe(true) + expect(loads).toBe(0) + + const first = yield* authoring.prepare({ ...input, profile: "environment" }) + const second = yield* authoring.prepare({ ...input, profile: "environment" }) + expect(first.valid).toBe(true) + expect(second.valid).toBe(false) + expect(second.prepared).toBeUndefined() + expect(loads).toBe(2) + }), + ) + + it.effect("fails closed when environment validation has no catalog loader", () => + Effect.gen(function* () { + const result = yield* WorkflowAuthoring.make().prepare({ + action: "start", + source: { kind: "inline", value: start }, + profile: "environment", + }) + + expect(result.valid).toBe(false) + expect(result.errors).toContainEqual( + expect.objectContaining({ + code: DagValidation.DIAGNOSTIC_CODES.environmentUnavailable, + path: "$environment", + }), + ) + expect(result.prepared).toBeUndefined() + }), + ) + + it.effect("keeps source identity distinct when equal content is cached", () => + Effect.gen(function* () { + const authoring = WorkflowAuthoring.make() + const content = Bun.YAML.stringify(start) + const first = yield* authoring.prepare({ + action: "start", + source: { kind: "yaml", source: "first.yaml", content }, + }) + const second = yield* authoring.prepare({ + action: "start", + source: { kind: "yaml", source: "second.yaml", content }, + }) + expect(first.source).toBe("first.yaml") + expect(second.source).toBe("second.yaml") + }), + ) +}) diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 3dbbd948d1..c00efb1d2e 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -4,7 +4,10 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { Dag } from "@/dag/dag" +import { DagValidation } from "@/dag/validation" import { Agent } from "@/agent/agent" +import { Skill } from "@/skill" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { DagStore } from "@opencode-ai/core/dag/store" import { DagEvent } from "@opencode-ai/schema/dag-event" import { EventV2Bridge } from "@/event-v2-bridge" @@ -19,7 +22,10 @@ import { fingerprintBrief, type State } from "@/dag/admission" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Provider } from "@/provider/provider" +import { ProviderTest } from "../fake/provider" import { makeNodeRow } from "./fixtures" +import { tmpdirScoped } from "../fixture/fixture" const projectID = ProjectV2.ID.make("project_test") let workflowSpecDirectory = "" @@ -48,16 +54,14 @@ const admissionBrief = { blocking_questions: [], } -function admissionFor( - verdict: "READY" | "NOT_READY" | "WAIVED", - state: State = verdict, -) { - const brief = verdict === "READY" - ? admissionBrief - : { - ...admissionBrief, - blocking_questions: ["Confirm the production rollout target"], - } +function admissionFor(verdict: "READY" | "NOT_READY" | "WAIVED", state: State = verdict) { + const brief = + verdict === "READY" + ? admissionBrief + : { + ...admissionBrief, + blocking_questions: ["Confirm the production rollout target"], + } return { protocol_version: 1, brief_revision: 1, @@ -309,10 +313,39 @@ const events = Layer.mock(EventV2Bridge.Service, { return { id: "event_test", type: definition.type, data } as never }), }) -const dag = Dag.layer.pipe( - Layer.provide(store), - Layer.provide(events), -) +const dag = Dag.layer.pipe(Layer.provide(store), Layer.provide(events)) +const testModel = ProviderTest.model({ + providerID: ProviderV2.ID.make("test"), + id: ModelV2.ID.make("test-model"), +}) +const localModel = ProviderTest.model({ + providerID: ProviderV2.ID.make("local-proxy-compatible"), + id: ModelV2.ID.make("local-proxy-compatible/glm-5.2"), +}) +const providerRows = { + [testModel.providerID]: ProviderTest.info({}, testModel), + [localModel.providerID]: ProviderTest.info({}, localModel), +} +let environmentProviderListCalls = 0 +let environmentProviderGetModelCalls = 0 +const providerCatalog = Layer.mock(Provider.Service, { + list: () => + Effect.sync(() => { + environmentProviderListCalls++ + return providerRows + }), + getModel: (providerID, modelID) => + Effect.gen(function* () { + yield* Effect.sync(() => { + environmentProviderGetModelCalls++ + }) + if (providerID === testModel.providerID && modelID === testModel.id) return testModel + if (providerID === localModel.providerID && modelID === localModel.id) return localModel + return yield* new Provider.ModelNotFoundError({ providerID, modelID }) + }), +}) +let environmentAgentListCalls = 0 +let environmentSkillListCalls = 0 const runtime = testEffect( Layer.mergeAll( Layer.mock(Agent.Service, { @@ -327,6 +360,18 @@ const runtime = testEffect( tools: {}, hooks: {}, }), + list: () => + Effect.sync(() => { + environmentAgentListCalls++ + return builtinAgentCatalog + }), + }), + Layer.mock(Skill.Service, { + all: () => + Effect.sync(() => { + environmentSkillListCalls++ + return [] + }), }), Layer.mock(Truncate.Service, { output: (content) => Effect.succeed({ content, truncated: false }), @@ -334,6 +379,7 @@ const runtime = testEffect( Layer.mock(Question.Service, { ask: () => Effect.succeed([["Configure first"]]), }), + providerCatalog, dag, Layer.mock(Session.Service, { get: (id: Parameters[0]) => @@ -342,8 +388,7 @@ const runtime = testEffect( slug: "workflow-test", projectID, directory: workflowSpecDirectory, - parentID: - id === SessionID.make("ses_workflow_child") ? SessionID.make("ses_workflow_parent") : undefined, + parentID: id === SessionID.make("ses_workflow_child") ? SessionID.make("ses_workflow_parent") : undefined, title: "Workflow test", version: "test", time: { created: 0, updated: 0 }, @@ -360,6 +405,7 @@ let missingModelDirectory = "" const questionsAsked: Question.Info[] = [] const missingModelRuntime = testEffect( Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, Layer.mock(Agent.Service, { get: () => Effect.succeed({ @@ -368,6 +414,10 @@ const missingModelRuntime = testEffect( permission: [], options: {}, }), + list: () => Effect.succeed(builtinAgentCatalog), + }), + Layer.mock(Skill.Service, { + all: () => Effect.succeed([]), }), Layer.mock(Truncate.Service, { output: (content) => Effect.succeed({ content, truncated: false }), @@ -379,6 +429,7 @@ const missingModelRuntime = testEffect( return [["Configure first"]] }), }), + providerCatalog, dag, Layer.mock(Session.Service, { get: (id: Parameters[0]) => @@ -395,11 +446,18 @@ const missingModelRuntime = testEffect( ), ) +// The builtin agent catalog the environment validation checks worker types +// against — mirrors the real build/plan/general/explore builtins. +const builtinAgentCatalog = ["build", "plan", "general", "explore"].map((name) => ({ + name, + mode: "all" as const, + permission: [], + options: {}, +})) as Agent.Info[] + function writeWorkflowSpec(name: string, value: unknown) { const filepath = path.join(workflowSpecDirectory, `${name}.yaml`) - return Effect.promise(() => Bun.write(filepath, JSON.stringify(value, null, 2))).pipe( - Effect.as(filepath), - ) + return Effect.promise(() => Bun.write(filepath, JSON.stringify(value, null, 2))).pipe(Effect.as(filepath)) } function toolContext() { @@ -414,6 +472,60 @@ function toolContext() { } satisfies Tool.Context } +function missingModelProject() { + return tmpdirScoped({ + init: (directory) => + Effect.promise(async () => { + await fs.mkdir(path.join(directory, ".opencode"), { recursive: true }) + await Bun.write(path.join(directory, ".opencode", "dag.jsonc"), '{ "model": {} }\n') + await Bun.write( + path.join(directory, "missing-model.yaml"), + JSON.stringify({ + config: { + name: "missing-model", + nodes: [ + { + id: "worker", + name: "Worker", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], + }, + }), + ) + }), + }) +} + +function missingCatalogModelProject() { + return tmpdirScoped({ + init: (directory) => + Effect.promise(async () => { + await fs.mkdir(path.join(directory, ".opencode"), { recursive: true }) + await Bun.write(path.join(directory, ".opencode", "dag.jsonc"), '{ "model": { "advanced": "ghost/ghost" } }\n') + await Bun.write( + path.join(directory, "missing-catalog-model.yaml"), + JSON.stringify({ + config: { + name: "missing-catalog-model", + nodes: [ + { + id: "worker", + name: "Worker", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], + }, + }), + ) + }), + }) +} + describe("workflow tool schema (negative tests)", () => { it("action field accepts start/extend/control/status/result/list/read/guide", () => { const decode = Schema.decodeUnknownSync(Parameters) @@ -466,13 +578,29 @@ describe("workflow tool schema (negative tests)", () => { expect(() => decode({ action: "logs" })).toThrow() }) - it("control operation accepts pause/resume/cancel/replan/step/complete", () => { + it("control operation accepts pause/resume/cancel/step/complete", () => { const decode = Schema.decodeUnknownSync(Parameters) - for (const op of ["pause", "resume", "cancel", "replan", "step", "complete"]) { + for (const op of ["pause", "resume", "cancel", "step", "complete"]) { expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: op })).not.toThrow() } }) + it("control replan requires exactly one graph source", () => { + const decode = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" }) + expect(() => + decode({ action: "control", workflow_id: "dag_wf_1", operation: "replan", spec_path: "fragment.yaml" }), + ).not.toThrow() + expect(() => + decode({ + action: "control", + workflow_id: "dag_wf_1", + operation: "replan", + spec: { fragment: { name: "fragment", nodes: [] } }, + }), + ).not.toThrow() + expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "replan" })).toThrow() + }) + it("control operation rejects unknown operations", () => { const decode = Schema.decodeUnknownSync(Parameters) expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "delete" })).toThrow() @@ -505,10 +633,9 @@ describe("workflow tool execution", () => { published.length = 0 const info = yield* WorkflowTool const workflow = yield* info.init() - const exit = yield* workflow.execute( - { action: "list" }, - { ...toolContext(), sessionID: SessionID.make("ses_workflow_child") }, - ).pipe(Effect.exit) + const exit = yield* workflow + .execute({ action: "list" }, { ...toolContext(), sessionID: SessionID.make("ses_workflow_child") }) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("main conversation") @@ -516,14 +643,18 @@ describe("workflow tool execution", () => { }), ) - runtime.effect("description retains the workflow action reference after guidance migration", () => + runtime.effect("description keeps tool selection and the guide index; action fields live in the schema", () => Effect.gen(function* () { const info = yield* WorkflowTool const workflow = yield* info.init() - for (const action of ["guide", "start", "extend", "status", "result", "control", "list", "read"]) { - expect(workflow.description).toContain(`**${action}**`) - } + // The resident description no longer carries the per-action field + // manual — the discriminated parameter schema owns those fields + // (change repair-workflow-authoring-validation, §7.1). + expect(workflow.description).toContain('guide(topic="blocks")') + expect(workflow.description).not.toContain("**start** creates") + expect(workflow.description).not.toContain("**result** reads") + expect(workflow.description).toContain("parameter schema") expect(workflow.description).toContain("Do not poll") expect(workflow.description).not.toContain("$ARGUMENTS") }), @@ -814,7 +945,7 @@ describe("workflow tool execution", () => { name: "block-start", objective: "Implement and review session recovery", blocks: [ - { id: "build", kind: "coding", skills: ["tdd"] }, + { id: "build", kind: "coding" }, { id: "verify", kind: "verify", depends_on: ["build"] }, { id: "review", kind: "review", depends_on: ["verify"] }, ], @@ -852,13 +983,15 @@ describe("workflow tool execution", () => { action: "extend", workflow_id: "dag_defaults", spec: { - nodes: [{ - id: "inline-added", - name: "Inline added", - worker_type: "general", - depends_on: [], - prompt_template: { inline: "work" }, - }], + nodes: [ + { + id: "inline-added", + name: "Inline added", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], }, }), toolContext(), @@ -908,13 +1041,15 @@ describe("workflow tool execution", () => { spec: { fragment: { name: "inline-replan", - nodes: [{ - id: "inline-replanned", - name: "Inline replanned", - worker_type: "general", - depends_on: [], - prompt_template: { inline: "work" }, - }], + nodes: [ + { + id: "inline-replanned", + name: "Inline replanned", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], }, }, }), @@ -939,25 +1074,29 @@ describe("workflow tool execution", () => { spec: { config: { name: "ambiguous", nodes: [] } }, spec_path: "saved-workflow", }, - message: "accepts exactly one source", }, { params: { action: "start" }, - message: "requires exactly one of 'spec' or 'spec_path'", }, ] for (const item of cases) { published.length = 0 - const exit = yield* workflow.execute( - Schema.decodeUnknownSync(Parameters)(item.params), - toolContext(), + // Source exclusivity is owned by the parameter schema: the real tool + // path strict-decodes before execute, so neither shape can reach the + // DAG service or publish an event. + const exit = yield* Effect.sync(() => + Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" })(item.params), ).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(item.message) expect(published).toHaveLength(0) } + + // The recovery guidance names both valid source variants. + const guidance = workflow.formatValidationError?.(new Error("no branch matched")) ?? "" + expect(guidance).toContain("exactly one source") + expect(guidance).toContain("spec or spec_path") }), ) @@ -982,18 +1121,20 @@ describe("workflow tool execution", () => { ) const output = JSON.parse(result.output) - expect(output).toEqual(expect.objectContaining({ - mode: "deep", - admission: { - verdict: "WAIVED", - state: "CONSUMED", - qa_mode: "STANDARD", - brief_revision: 1, - fingerprint: admissionFor("WAIVED").fingerprint, - waiver_reason: "Preview release only", - acknowledged_risks: ["Production rollout is unresolved"], - }, - })) + expect(output).toEqual( + expect.objectContaining({ + mode: "deep", + admission: { + verdict: "WAIVED", + state: "CONSUMED", + qa_mode: "STANDARD", + brief_revision: 1, + fingerprint: admissionFor("WAIVED").fingerprint, + waiver_reason: "Preview release only", + acknowledged_risks: ["Production rollout is unresolved"], + }, + }), + ) expect(output.admission).not.toHaveProperty("qa_transcript") }), ) @@ -1029,13 +1170,15 @@ config: metadata: () => Effect.void, ask: () => Effect.void, } satisfies Tool.Context - const invalid = yield* workflow.execute( - { - action: "start", - spec_path: "deep.yaml", - }, - context, - ).pipe(Effect.exit) + const invalid = yield* workflow + .execute( + { + action: "start", + spec_path: "deep.yaml", + }, + context, + ) + .pipe(Effect.exit) expect(Exit.isFailure(invalid)).toBe(true) if (Exit.isFailure(invalid)) { @@ -1087,15 +1230,17 @@ config: const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data as { config?: string } - expect(JSON.parse(created.config ?? "{}")).toEqual(expect.objectContaining({ - mode: "deep", - admission: expect.objectContaining({ - protocol_version: 1, - verdict: "READY", - state: "CONSUMED", - fingerprint: fingerprintBrief(admissionBrief), + expect(JSON.parse(created.config ?? "{}")).toEqual( + expect.objectContaining({ + mode: "deep", + admission: expect.objectContaining({ + protocol_version: 1, + verdict: "READY", + state: "CONSUMED", + fingerprint: fingerprintBrief(admissionBrief), + }), }), - })) + ) }), ) @@ -1106,25 +1251,27 @@ config: yield* Effect.promise(() => Bun.write(specPath, "config:\n nodes: [\n")) const info = yield* WorkflowTool const workflow = yield* info.init() - const exit = yield* workflow.execute( - { - action: "start", - spec_path: specPath, - }, - { - sessionID: SessionID.make("ses_workflow_parent"), - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - } satisfies Tool.Context, - ).pipe(Effect.exit) + const exit = yield* workflow + .execute( + { + action: "start", + spec_path: specPath, + }, + { + sessionID: SessionID.make("ses_workflow_parent"), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context, + ) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain(`Invalid workflow YAML ${specPath}:`) + expect(Cause.pretty(exit.cause)).toContain("[schema.invalid] $: file is not parseable YAML") } expect(published).toHaveLength(0) }), @@ -1176,34 +1323,7 @@ config: Effect.gen(function* () { published.length = 0 questionsAsked.length = 0 - missingModelDirectory = yield* Effect.acquireRelease( - Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "workflow-model-"))), - (directory) => Effect.promise(() => fs.rm(directory, { recursive: true, force: true })), - ) - yield* Effect.promise(() => fs.mkdir(path.join(missingModelDirectory, ".opencode"), { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(missingModelDirectory, ".opencode", "dag.jsonc"), - '{ "model": {} }\n', - ) - ) - yield* Effect.promise(() => - Bun.write( - path.join(missingModelDirectory, "missing-model.yaml"), - JSON.stringify({ - config: { - name: "missing-model", - nodes: [{ - id: "worker", - name: "Worker", - worker_type: "build", - depends_on: [], - prompt_template: { inline: "work" }, - }], - }, - }), - ) - ) + missingModelDirectory = yield* missingModelProject() const info = yield* WorkflowTool const workflow = yield* info.init() @@ -1231,6 +1351,149 @@ config: }), ) + missingModelRuntime.effect("validate(environment) reports the same missing model before start", () => + Effect.gen(function* () { + missingModelDirectory = yield* missingModelProject() + + const info = yield* WorkflowTool + const workflow = yield* info.init() + const result = yield* workflow.execute( + { + action: "validate", + spec_path: "missing-model.yaml", + profile: "environment", + }, + toolContext(), + ) + + const report = JSON.parse(result.output) + expect(report.valid).toBe(false) + expect(report.profile).toBe("environment") + const diagnostic = report.errors.find((d: { code: string }) => d.code === "model.unavailable") + expect(diagnostic?.path).toBe("nodes[worker]") + expect(result.title).toContain("failed") + }), + ) + + missingModelRuntime.effect("validate(environment) rejects a configured model absent from the provider catalog", () => + Effect.gen(function* () { + missingModelDirectory = yield* missingCatalogModelProject() + published.length = 0 + + const info = yield* WorkflowTool + const workflow = yield* info.init() + const result = yield* workflow.execute( + { + action: "validate", + spec_path: "missing-catalog-model.yaml", + profile: "environment", + }, + toolContext(), + ) + + const report = JSON.parse(result.output) + expect(report.valid).toBe(false) + expect(report.errors).toContainEqual( + expect.objectContaining({ code: "model.unavailable", path: "nodes[worker]" }), + ) + const started = yield* workflow.execute( + { action: "start", spec_path: "missing-catalog-model.yaml" }, + toolContext(), + ) + expect(started.title).toBe("Workflow not started: model required") + expect(started.metadata.workflowId).toBeUndefined() + expect(published).toHaveLength(0) + }), + ) + + missingModelRuntime.effect("extend and replan reject unresolved models before durable events", () => + Effect.gen(function* () { + missingModelDirectory = yield* missingModelProject() + published.length = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + const node = { + id: "unresolved", + name: "Unresolved", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "work" }, + } + + const extendExit = yield* workflow + .execute({ action: "extend", workflow_id: Dag.ID.make("dag_paused"), spec: { nodes: [node] } }, toolContext()) + .pipe(Effect.exit) + const replanExit = yield* workflow + .execute( + { + action: "control", + operation: "replan", + workflow_id: Dag.ID.make("dag_paused"), + spec: { fragment: { name: "unresolved-replan", nodes: [node] } }, + }, + toolContext(), + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(extendExit)).toBe(true) + expect(Exit.isFailure(replanExit)).toBe(true) + if (Exit.isFailure(extendExit)) expect(Cause.pretty(extendExit.cause)).toContain("model.unavailable") + if (Exit.isFailure(replanExit)) expect(Cause.pretty(replanExit.cause)).toContain("model.unavailable") + expect(published).toHaveLength(0) + }), + ) + + missingModelRuntime.effect("extend and replan honor persisted or explicit node models", () => + Effect.gen(function* () { + missingModelDirectory = yield* missingModelProject() + published.length = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + const node = { + id: "modeled", + name: "Modeled", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "work" }, + } + + const extended = yield* workflow.execute( + { action: "extend", workflow_id: Dag.ID.make("dag_defaults"), spec: { nodes: [node] } }, + toolContext(), + ) + const replanned = yield* workflow.execute( + { + action: "control", + operation: "replan", + workflow_id: Dag.ID.make("dag_paused"), + spec_path: yield* Effect.promise(() => + Bun.write( + path.join(missingModelDirectory, "modeled-replan.yaml"), + JSON.stringify({ + fragment: { + name: "modeled-replan", + nodes: [ + { + ...node, + model: { + providerID: "local-proxy-compatible", + modelID: "local-proxy-compatible/glm-5.2", + }, + }, + ], + }, + }), + ).then(() => path.join(missingModelDirectory, "modeled-replan.yaml")), + ), + }, + toolContext(), + ) + + expect(extended.title).toContain("Workflow extended") + expect(replanned.title).toContain("Workflow replanned") + }), + ) + runtime.effect("deep start consumes and retains an informed WAIVED admission", () => Effect.gen(function* () { published.length = 0 @@ -1263,12 +1526,62 @@ config: const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data as { config?: string } - expect(JSON.parse(created.config ?? "{}").admission).toEqual(expect.objectContaining({ - verdict: "WAIVED", - state: "CONSUMED", - waiver_reason: "Preview release only", - acknowledged_risks: ["Production rollout is unresolved"], - })) + expect(JSON.parse(created.config ?? "{}").admission).toEqual( + expect.objectContaining({ + verdict: "WAIVED", + state: "CONSUMED", + waiver_reason: "Preview release only", + acknowledged_risks: ["Production rollout is unresolved"], + }), + ) + }), + ) + + runtime.effect("start strips persisted admission audit fields read from disk and regenerates them", () => + Effect.gen(function* () { + published.length = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + // Legacy saved specs may embed the persisted record shape. The audit + // fields are boundary-owned: stripped at the file-read boundary and + // regenerated by createAdmissionRecord. + const specPath = yield* writeWorkflowSpec("deep-legacy-admission", { + mode: "deep", + admission: { + ...admissionInputFor("WAIVED"), + protocol_version: 9, + state: "CONSUMED", + fingerprint: "stale-fingerprint", + }, + config: { + name: "deep-legacy-admission", + nodes: [], + }, + }) + yield* workflow.execute( + { + action: "start", + spec_path: specPath, + }, + { + sessionID: SessionID.make("ses_workflow_parent"), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context, + ) + + const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data + if (!created || typeof created !== "object" || !("config" in created) || typeof created.config !== "string") { + throw new Error("workflow.created event did not include serialized config") + } + const admission = JSON.parse(created.config).admission + expect(admission).toEqual(expect.objectContaining({ protocol_version: 1, verdict: "WAIVED", state: "CONSUMED" })) + expect(admission.fingerprint).not.toBe("stale-fingerprint") + expect(admission.fingerprint).toBe(fingerprintBrief(admission.brief)) }), ) @@ -1318,21 +1631,23 @@ config: for (const item of cases) { published.length = 0 const specPath = yield* writeWorkflowSpec(`blocked-${item.name}`, item.value) - const exit = yield* workflow.execute( - { - action: "start", - spec_path: specPath, - }, - { - sessionID: SessionID.make("ses_workflow_parent"), - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - } satisfies Tool.Context, - ).pipe(Effect.exit) + const exit = yield* workflow + .execute( + { + action: "start", + spec_path: specPath, + }, + { + sessionID: SessionID.make("ses_workflow_parent"), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context, + ) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) expect(published).toHaveLength(0) @@ -1577,59 +1892,45 @@ config: }), ) - runtime.effect("start rejects a project ID outside the parent session project", () => + runtime.effect("start does not accept a model-authored project identity", () => Effect.gen(function* () { - published.length = 0 - const parentID = SessionID.make("ses_workflow_parent") - const info = yield* WorkflowTool - const workflow = yield* info.init() - const exit = yield* workflow - .execute( - { - action: "start", - project_id: "project_other", - spec_path: "project-id-mismatch.yaml", - }, - { - sessionID: parentID, - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - } satisfies Tool.Context, - ) - .pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - expect(published).toHaveLength(0) + // Runtime identity fields are derived from the authenticated tool + // context and the loaded session, never authored by the model: the + // strict parameter decode rejects a project_id supplied by the caller. + const decode = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" }) + expect(() => + decode({ + action: "start", + project_id: "project_other", + spec_path: "project-id-mismatch.yaml", + }), + ).toThrow() }), ) - runtime.effect("start rejects a parent session other than the calling session", () => + runtime.effect("start does not accept a model-authored session identity", () => Effect.gen(function* () { - published.length = 0 - const info = yield* WorkflowTool - const workflow = yield* info.init() - const exit = yield* workflow - .execute( - { - action: "start", - session_id: "ses_other_parent", - spec: { - config: { - name: "foreign-parent", - nodes: [], - }, + const decode = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" }) + expect(() => + decode({ + action: "start", + session_id: "ses_other_parent", + spec: { + config: { + name: "foreign-parent", + nodes: [ + { + id: "work", + name: "work", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], }, }, - toolContext(), - ) - .pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - expect(published).toHaveLength(0) + }), + ).toThrow() }), ) }) @@ -1654,8 +1955,7 @@ describe("workflow tool saved workflows", () => { }), ) - const savedSpec = (name: string) => - `title: ${name} title\nconfig:\n name: ${name}\n nodes: []\n` + const savedSpec = (name: string) => `title: ${name} title\nconfig:\n name: ${name}\n nodes: []\n` const contextWith = (asked: unknown[]) => ({ @@ -1694,19 +1994,18 @@ describe("workflow tool saved workflows", () => { const workflow = yield* info.init() const asked: unknown[] = [] - const result = yield* workflow.execute( - { action: "read", spec_path: "saved-readable" }, - contextWith(asked), - ) + const result = yield* workflow.execute({ action: "read", spec_path: "saved-readable" }, contextWith(asked)) expect(result.title).toBe("Workflow spec: saved-readable") - expect(JSON.parse(result.output)).toMatchObject({ + const payload = JSON.parse(result.output) + expect(payload.spec).toMatchObject({ title: "Saved readable route", config: { objective: "Replace this generic objective", blocks: [{ id: "map", kind: "explore" }], }, }) + expect(payload.validation.valid).toBe(true) expect(asked).toEqual([expect.objectContaining({ permission: "workflow", patterns: ["read"] })]) expect(published).toHaveLength(0) }), @@ -1827,4 +2126,267 @@ describe("workflow tool saved workflows", () => { }), ), ) + + runtime.effect("validate(portable) does not load agent or skill catalogs", () => + Effect.gen(function* () { + environmentAgentListCalls = 0 + environmentSkillListCalls = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + + const result = yield* workflow.execute( + { + action: "validate", + profile: "portable", + spec: { config: { name: "portable-inline", nodes: [] } }, + }, + toolContext(), + ) + + expect(JSON.parse(result.output).valid).toBe(true) + expect(environmentAgentListCalls).toBe(0) + expect(environmentSkillListCalls).toBe(0) + }), + ) + + runtime.effect("validate(environment) snapshots required catalogs once and never reads Skills", () => + Effect.gen(function* () { + environmentAgentListCalls = 0 + environmentSkillListCalls = 0 + environmentProviderListCalls = 0 + environmentProviderGetModelCalls = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + + const result = yield* workflow.execute( + { + action: "validate", + profile: "environment", + spec: { + config: { + name: "catalog-snapshot", + nodes: [ + { + id: "first", + name: "First", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "First" }, + }, + { + id: "second", + name: "Second", + worker_type: "build", + depends_on: ["first"], + prompt_template: { inline: "Second" }, + }, + ], + }, + }, + }, + toolContext(), + ) + + expect(JSON.parse(result.output).valid).toBe(true) + expect(environmentAgentListCalls).toBe(1) + expect(environmentSkillListCalls).toBe(0) + expect(environmentProviderListCalls).toBe(1) + expect(environmentProviderGetModelCalls).toBe(0) + }), + ) + + runtime.effect("validate and start resolve the same source content across all four sources", () => + withGlobalConfigDir((globalDir) => + Effect.gen(function* () { + published.length = 0 + // project scope shadows global for the same name; builtin fills the + // gap a file scope does not own; inline stays session-local. + const routeSpec = (name: string) => + `title: ${name} title\nconfig:\n name: ${name}\n objective: Route objective\n blocks:\n - id: plan\n kind: plan\n` + yield* Effect.promise(() => + Promise.all([ + Bun.write(path.join(globalDir, "workflows", "shared-route.yaml"), routeSpec("global-route")), + Bun.write(path.join(globalDir, "workflows", "builtin-shadowed.yaml"), routeSpec("file-route")), + Bun.write( + path.join(workflowSpecDirectory, ".opencode", "workflows", "shared-route.yaml"), + routeSpec("project-route"), + ), + ]), + ) + const previousBuiltin = (globalThis as Record).OPENCODE_DAG_TEMPLATES + ;(globalThis as Record).OPENCODE_DAG_TEMPLATES = { + "builtin-only-route": routeSpec("builtin-route"), + "builtin-shadowed": routeSpec("stale-builtin-route"), + } + try { + const info = yield* WorkflowTool + const workflow = yield* info.init() + + // project beats global + const projectRead = yield* workflow.execute({ action: "read", spec_path: "shared-route" }, contextWith([])) + expect(JSON.parse(projectRead.output).spec.title).toContain("project-route") + + // global fills names the project scope does not own + const globalRead = yield* workflow.execute({ action: "read", spec_path: "builtin-shadowed" }, contextWith([])) + expect(JSON.parse(globalRead.output).spec.title).toContain("file-route") + + // builtin fills names no file scope owns + const builtinValidate = yield* workflow.execute( + { action: "validate", spec_path: "builtin-only-route" }, + contextWith([]), + ) + const builtinResult = JSON.parse(builtinValidate.output) + expect(builtinResult.source).toBe("builtin://builtin-only-route") + expect(builtinResult.profile).toBe("portable") + expect(builtinResult.valid).toBe(true) + + // inline source validates under the environment profile by default + const inlineValidate = yield* workflow.execute( + { + action: "validate", + spec: { + config: { + name: "inline-route", + objective: "Inline objective", + blocks: [{ id: "plan", kind: "plan" }], + }, + }, + }, + contextWith([]), + ) + const inlineResult = JSON.parse(inlineValidate.output) + expect(inlineResult.source).toBe("") + expect(inlineResult.profile).toBe("environment") + expect(inlineResult.valid).toBe(true) + + // validate and start see the same resolved content: validate passes, + // start succeeds from the same name, and mutating the file changes + // both views consistently. + const beforeStart = yield* workflow.execute( + { action: "validate", spec_path: "shared-route" }, + contextWith([]), + ) + expect(JSON.parse(beforeStart.output).valid).toBe(true) + const started = yield* workflow.execute({ action: "start", spec_path: "shared-route" }, contextWith([])) + expect(started.title).toBe("Workflow started: project-route") + expect(published.some((event) => event.type === DagEvent.WorkflowCreated.type)).toBe(true) + } finally { + if (previousBuiltin === undefined) delete (globalThis as Record).OPENCODE_DAG_TEMPLATES + else (globalThis as Record).OPENCODE_DAG_TEMPLATES = previousBuiltin + } + }), + ), + ) + + runtime.effect("list marks invalid templates without hiding them", () => + withGlobalConfigDir((globalDir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + Bun.write( + path.join(globalDir, "workflows", "broken-route.yaml"), + "config:\n name: broken-route\n objective: Ship\n blocks:\n - id: proto\n kind: prototype\n - id: review\n kind: review\n depends_on: [proto]\n", + ), + Bun.write(path.join(globalDir, "workflows", "fine-route.yaml"), savedSpec("fine-route")), + ]), + ) + const info = yield* WorkflowTool + const workflow = yield* info.init() + + const result = yield* workflow.execute({ action: "list" }, contextWith([])) + + expect(result.output).toContain("broken-route [global] [invalid — not startable]") + expect(result.output).toContain("block.compile_failed") + expect(result.output).toContain("fine-route [global]") + expect(result.output).not.toContain("fine-route [global] [invalid") + }), + ), + ) + + runtime.effect("read keeps the editable raw spec for an invalid graph and reports diagnostics", () => + withGlobalConfigDir((globalDir) => + Effect.gen(function* () { + published.length = 0 + yield* Effect.promise(() => + Bun.write( + path.join(globalDir, "workflows", "uncompilable-route.yaml"), + "config:\n name: uncompilable-route\n objective: Ship\n blocks:\n - id: proto\n kind: prototype\n - id: review\n kind: review\n depends_on: [proto]\n", + ), + ) + const info = yield* WorkflowTool + const workflow = yield* info.init() + + const result = yield* workflow.execute({ action: "read", spec_path: "uncompilable-route" }, contextWith([])) + + const payload = JSON.parse(result.output) + // The editable source survives untouched so the parent can repair it. + expect(payload.spec.config.blocks.map((block: { id: string }) => block.id)).toEqual(["proto", "review"]) + expect(payload.validation.valid).toBe(false) + expect(payload.validation.errors.some((d: { code: string }) => d.code === "block.compile_failed")).toBe(true) + // Read never claims the route can be started. + expect(result.title).toBe("Workflow spec: uncompilable-route") + expect(published).toHaveLength(0) + }), + ), + ) + + runtime.effect("list keeps a syntax-broken template visible with a stable diagnostic", () => + withGlobalConfigDir((globalDir) => + Effect.gen(function* () { + published.length = 0 + yield* Effect.promise(() => + Bun.write(path.join(globalDir, "workflows", "broken-syntax.yaml"), "key: [unclosed"), + ) + yield* Effect.promise(() => + Bun.write( + path.join(globalDir, "workflows", "fine-route.yaml"), + "config:\n name: fine-route\n objective: Ship\n blocks:\n - id: plan\n kind: plan\n", + ), + ) + const info = yield* WorkflowTool + const workflow = yield* info.init() + + const result = yield* workflow.execute({ action: "list" }, contextWith([])) + + expect(result.output).toContain("broken-syntax [global] [invalid — not startable]") + expect(result.output).toContain("[schema.invalid]") + expect(result.output).toContain("fine-route [global]") + }), + ), + ) + + runtime.effect("validate returns structured diagnostics for syntax-broken YAML", () => + withGlobalConfigDir((globalDir) => + Effect.gen(function* () { + published.length = 0 + const filepath = path.join(globalDir, "workflows", "broken-validate.yaml") + yield* Effect.promise(() => Bun.write(filepath, "config: [unclosed")) + const info = yield* WorkflowTool + const workflow = yield* info.init() + + const result = yield* workflow.execute( + { action: "validate", spec_path: "broken-validate", profile: "portable" }, + contextWith([]), + ) + + const report = JSON.parse(result.output) + expect(report).toMatchObject({ + source: filepath, + profile: "portable", + valid: false, + errors: [ + { + code: DagValidation.DIAGNOSTIC_CODES.schemaInvalid, + path: "$", + message: "file is not parseable YAML", + }, + ], + warnings: [], + nodes: [], + }) + expect(result.metadata.workflowId).toBeUndefined() + expect(published).toHaveLength(0) + }), + ), + ) }) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 755ebfefeb..38636e9ea3 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -85,11 +85,8 @@ describe("skill", () => { expect(yield* skill.get("workflow")).toBeUndefined() expect( (yield* skill.all()).filter((item) => item.location === "").map((item) => item.name), - ).toEqual(["customize-opencode", "configure-hooks", "create-dag-workflow", "orchestration-router"]) - expect(yield* skill.get("orchestration-router")).toMatchObject({ - description: expect.stringContaining("without waiting for /dag-flow"), - location: "", - }) + ).toEqual(["customize-opencode", "configure-hooks", "create-dag-workflow"]) + expect(yield* skill.get("orchestration-router")).toBeUndefined() }), { git: true }, ), diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 51ff867ea4..772564d18e 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -444,6 +444,1732 @@ exports[`tool parameters JSON Schema (wire shape) websearch 1`] = ` } `; +exports[`tool parameters JSON Schema (wire shape) workflow 1`] = ` +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "properties": { + "action": { + "description": "Create a workflow", + "enum": [ + "start", + ], + "type": "string", + }, + "spec": { + "description": "Inline structured spec for a one-off graph. Use this or spec_path, never both", + "properties": { + "admission": { + "properties": { + "acknowledged_risks": { + "items": { + "type": "string", + }, + "type": "array", + }, + "brief": { + "properties": { + "acceptance_criteria": { + "items": { + "type": "string", + }, + "type": "array", + }, + "assumptions": { + "items": { + "type": "string", + }, + "type": "array", + }, + "blocking_questions": { + "items": { + "type": "string", + }, + "type": "array", + }, + "constraints": { + "items": { + "type": "string", + }, + "type": "array", + }, + "evidence_required": { + "items": { + "type": "string", + }, + "type": "array", + }, + "goal": { + "type": "string", + }, + "open_questions": { + "items": { + "type": "string", + }, + "type": "array", + }, + "review_plan": { + "items": { + "type": "string", + }, + "type": "array", + }, + "risks": { + "items": { + "type": "string", + }, + "type": "array", + }, + "scope": { + "properties": { + "in": { + "items": { + "type": "string", + }, + "type": "array", + }, + "out": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "in", + "out", + ], + "type": "object", + }, + }, + "required": [ + "goal", + "scope", + "constraints", + "assumptions", + "acceptance_criteria", + "evidence_required", + "risks", + "review_plan", + "open_questions", + "blocking_questions", + ], + "type": "object", + }, + "brief_revision": { + "type": "number", + }, + "qa_mode": { + "enum": [ + "LIGHT", + "STANDARD", + "GRILL", + ], + "type": "string", + }, + "verdict": { + "enum": [ + "READY", + "NOT_READY", + "WAIVED", + ], + "type": "string", + }, + "waiver_reason": { + "type": "string", + }, + }, + "required": [ + "brief_revision", + "qa_mode", + "verdict", + "brief", + ], + "type": "object", + }, + "config": { + "anyOf": [ + { + "properties": { + "blocks": { + "description": "Composable blocks compiled into nodes by the runtime", + "items": { + "properties": { + "depends_on": { + "description": "Block IDs this block waits for. Defaults to []", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique block identifier; dependencies target block IDs", + "type": "string", + }, + "instruction": { + "description": "Task-specific instruction added to the block's built-in execution contract", + "type": "string", + }, + "kind": { + "description": "Composable workflow block; debug and review expand into evidence-gathering subgraphs", + "enum": [ + "explore", + "plan", + "prototype", + "debug", + "coding", + "verify", + "review", + "synthesize", + ], + "type": "string", + }, + "report_to_parent": { + "description": "Override wake behavior. Review decisions and synthesis report by default", + "type": "boolean", + }, + "required": { + "description": "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", + "type": "boolean", + }, + "worker_type": { + "description": "Optional configured agent override; defaults from the block kind", + "type": "string", + }, + }, + "required": [ + "id", + "kind", + ], + "type": "object", + }, + "type": "array", + }, + "max_concurrency": { + "description": "Max parallel nodes. Default: 5", + "type": "number", + }, + "max_node_replan_attempts": { + "description": "Max replan restarts per node ID. Default: 5", + "type": "number", + }, + "max_total_nodes": { + "description": "Cumulative node cap across the workflow lifetime. Default: 100", + "type": "number", + }, + "name": { + "description": "Workflow name", + "type": "string", + }, + "node_defaults": { + "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + "properties": { + "report_to_parent": { + "type": "boolean", + }, + "required": { + "type": "boolean", + }, + "worker_config": { + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "objective": { + "description": "Injected into every generated child prompt; required for blocks", + "type": "string", + }, + }, + "required": [ + "name", + "objective", + "blocks", + ], + "type": "object", + }, + { + "properties": { + "max_concurrency": { + "description": "Max parallel nodes. Default: 5", + "type": "number", + }, + "max_node_replan_attempts": { + "description": "Max replan restarts per node ID. Default: 5", + "type": "number", + }, + "max_total_nodes": { + "description": "Cumulative node cap across the workflow lifetime. Default: 100", + "type": "number", + }, + "name": { + "description": "Workflow name", + "type": "string", + }, + "node_defaults": { + "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + "properties": { + "report_to_parent": { + "type": "boolean", + }, + "required": { + "type": "boolean", + }, + "worker_config": { + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "nodes": { + "description": "Low-level node declarations", + "items": { + "properties": { + "cancel": { + "description": "(replan only) Cancel this node", + "type": "boolean", + }, + "condition": { + "description": "Expression evaluated before spawn; node is skipped if false", + "type": "string", + }, + "depends_on": { + "description": "Node IDs this node waits for ([] for root)", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique node identifier, used in depends_on", + "type": "string", + }, + "input_mapping": { + "additionalProperties": { + "type": "string", + }, + "description": "Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID", + "type": "object", + }, + "name": { + "description": "Human-readable node name", + "type": "string", + }, + "output_schema": { + "description": "JSON Schema; child agent must call submit_result to submit structured output", + "type": "object", + }, + "prompt_template": { + "anyOf": [ + { + "properties": { + "inline": { + "description": "Inline prompt text; bind {{placeholders}} via input or input_mapping", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "inline", + ], + "type": "object", + }, + { + "properties": { + "id": { + "description": "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + ], + "description": "Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default", + }, + "report_to_parent": { + "description": "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + "type": "boolean", + }, + "required": { + "description": "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", + "type": "boolean", + }, + "restart": { + "description": "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", + "type": "boolean", + }, + "review": { + "description": "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", + "properties": { + "implementation_node_id": { + "type": "string", + }, + "phase": { + "enum": [ + "design", + "diff", + ], + "type": "string", + }, + "verification_node_id": { + "type": "string", + }, + }, + "required": [ + "phase", + ], + "type": "object", + }, + "worker_config": { + "description": "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config", + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + "worker_type": { + "description": "Agent type (explore, build, general, plan, or custom)", + "type": "string", + }, + }, + "required": [ + "id", + "name", + "worker_type", + "depends_on", + "prompt_template", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "name", + "nodes", + ], + "type": "object", + }, + ], + }, + "mode": { + "enum": [ + "standard", + "deep", + ], + "type": "string", + }, + "title": { + "type": "string", + }, + }, + "required": [ + "config", + ], + "type": "object", + }, + }, + "required": [ + "action", + "spec", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Create a workflow", + "enum": [ + "start", + ], + "type": "string", + }, + "spec_path": { + "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory", + "type": "string", + }, + }, + "required": [ + "action", + "spec_path", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Add nodes or blocks to a live workflow", + "enum": [ + "extend", + ], + "type": "string", + }, + "spec": { + "anyOf": [ + { + "properties": { + "blocks": { + "items": { + "properties": { + "depends_on": { + "description": "Block IDs this block waits for. Defaults to []", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique block identifier; dependencies target block IDs", + "type": "string", + }, + "instruction": { + "description": "Task-specific instruction added to the block's built-in execution contract", + "type": "string", + }, + "kind": { + "description": "Composable workflow block; debug and review expand into evidence-gathering subgraphs", + "enum": [ + "explore", + "plan", + "prototype", + "debug", + "coding", + "verify", + "review", + "synthesize", + ], + "type": "string", + }, + "report_to_parent": { + "description": "Override wake behavior. Review decisions and synthesis report by default", + "type": "boolean", + }, + "required": { + "description": "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", + "type": "boolean", + }, + "worker_type": { + "description": "Optional configured agent override; defaults from the block kind", + "type": "string", + }, + }, + "required": [ + "id", + "kind", + ], + "type": "object", + }, + "type": "array", + }, + "objective": { + "description": "Injected into every generated child prompt", + "type": "string", + }, + }, + "required": [ + "objective", + "blocks", + ], + "type": "object", + }, + { + "properties": { + "nodes": { + "items": { + "properties": { + "cancel": { + "description": "(replan only) Cancel this node", + "type": "boolean", + }, + "condition": { + "description": "Expression evaluated before spawn; node is skipped if false", + "type": "string", + }, + "depends_on": { + "description": "Node IDs this node waits for ([] for root)", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique node identifier, used in depends_on", + "type": "string", + }, + "input_mapping": { + "additionalProperties": { + "type": "string", + }, + "description": "Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID", + "type": "object", + }, + "name": { + "description": "Human-readable node name", + "type": "string", + }, + "output_schema": { + "description": "JSON Schema; child agent must call submit_result to submit structured output", + "type": "object", + }, + "prompt_template": { + "anyOf": [ + { + "properties": { + "inline": { + "description": "Inline prompt text; bind {{placeholders}} via input or input_mapping", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "inline", + ], + "type": "object", + }, + { + "properties": { + "id": { + "description": "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + ], + "description": "Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default", + }, + "report_to_parent": { + "description": "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + "type": "boolean", + }, + "required": { + "description": "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", + "type": "boolean", + }, + "restart": { + "description": "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", + "type": "boolean", + }, + "review": { + "description": "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", + "properties": { + "implementation_node_id": { + "type": "string", + }, + "phase": { + "enum": [ + "design", + "diff", + ], + "type": "string", + }, + "verification_node_id": { + "type": "string", + }, + }, + "required": [ + "phase", + ], + "type": "object", + }, + "worker_config": { + "description": "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config", + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + "worker_type": { + "description": "Agent type (explore, build, general, plan, or custom)", + "type": "string", + }, + }, + "required": [ + "id", + "name", + "worker_type", + "depends_on", + "prompt_template", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "nodes", + ], + "type": "object", + }, + ], + "description": "Inline structured spec for a one-off graph. Use this or spec_path, never both", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "workflow_id", + "spec", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Add nodes or blocks to a live workflow", + "enum": [ + "extend", + ], + "type": "string", + }, + "spec_path": { + "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory", + "type": "string", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "workflow_id", + "spec_path", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Control a live workflow", + "enum": [ + "control", + ], + "type": "string", + }, + "operation": { + "description": "Apply a node fragment (add/cancel/restart/replace)", + "enum": [ + "replan", + ], + "type": "string", + }, + "spec": { + "description": "Inline structured spec for a one-off graph. Use this or spec_path, never both", + "properties": { + "fragment": { + "anyOf": [ + { + "properties": { + "blocks": { + "description": "Composable blocks compiled into nodes by the runtime", + "items": { + "properties": { + "depends_on": { + "description": "Block IDs this block waits for. Defaults to []", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique block identifier; dependencies target block IDs", + "type": "string", + }, + "instruction": { + "description": "Task-specific instruction added to the block's built-in execution contract", + "type": "string", + }, + "kind": { + "description": "Composable workflow block; debug and review expand into evidence-gathering subgraphs", + "enum": [ + "explore", + "plan", + "prototype", + "debug", + "coding", + "verify", + "review", + "synthesize", + ], + "type": "string", + }, + "report_to_parent": { + "description": "Override wake behavior. Review decisions and synthesis report by default", + "type": "boolean", + }, + "required": { + "description": "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", + "type": "boolean", + }, + "worker_type": { + "description": "Optional configured agent override; defaults from the block kind", + "type": "string", + }, + }, + "required": [ + "id", + "kind", + ], + "type": "object", + }, + "type": "array", + }, + "max_concurrency": { + "description": "Max parallel nodes. Default: 5", + "type": "number", + }, + "max_node_replan_attempts": { + "description": "Max replan restarts per node ID. Default: 5", + "type": "number", + }, + "max_total_nodes": { + "description": "Cumulative node cap across the workflow lifetime. Default: 100", + "type": "number", + }, + "name": { + "description": "Workflow name", + "type": "string", + }, + "node_defaults": { + "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + "properties": { + "report_to_parent": { + "type": "boolean", + }, + "required": { + "type": "boolean", + }, + "worker_config": { + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "objective": { + "description": "Injected into every generated child prompt; required for blocks", + "type": "string", + }, + }, + "required": [ + "name", + "objective", + "blocks", + ], + "type": "object", + }, + { + "properties": { + "max_concurrency": { + "description": "Max parallel nodes. Default: 5", + "type": "number", + }, + "max_node_replan_attempts": { + "description": "Max replan restarts per node ID. Default: 5", + "type": "number", + }, + "max_total_nodes": { + "description": "Cumulative node cap across the workflow lifetime. Default: 100", + "type": "number", + }, + "name": { + "description": "Workflow name", + "type": "string", + }, + "node_defaults": { + "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + "properties": { + "report_to_parent": { + "type": "boolean", + }, + "required": { + "type": "boolean", + }, + "worker_config": { + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "nodes": { + "description": "Low-level node declarations", + "items": { + "properties": { + "cancel": { + "description": "(replan only) Cancel this node", + "type": "boolean", + }, + "condition": { + "description": "Expression evaluated before spawn; node is skipped if false", + "type": "string", + }, + "depends_on": { + "description": "Node IDs this node waits for ([] for root)", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique node identifier, used in depends_on", + "type": "string", + }, + "input_mapping": { + "additionalProperties": { + "type": "string", + }, + "description": "Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID", + "type": "object", + }, + "name": { + "description": "Human-readable node name", + "type": "string", + }, + "output_schema": { + "description": "JSON Schema; child agent must call submit_result to submit structured output", + "type": "object", + }, + "prompt_template": { + "anyOf": [ + { + "properties": { + "inline": { + "description": "Inline prompt text; bind {{placeholders}} via input or input_mapping", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "inline", + ], + "type": "object", + }, + { + "properties": { + "id": { + "description": "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + ], + "description": "Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default", + }, + "report_to_parent": { + "description": "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + "type": "boolean", + }, + "required": { + "description": "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", + "type": "boolean", + }, + "restart": { + "description": "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", + "type": "boolean", + }, + "review": { + "description": "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", + "properties": { + "implementation_node_id": { + "type": "string", + }, + "phase": { + "enum": [ + "design", + "diff", + ], + "type": "string", + }, + "verification_node_id": { + "type": "string", + }, + }, + "required": [ + "phase", + ], + "type": "object", + }, + "worker_config": { + "description": "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config", + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + "worker_type": { + "description": "Agent type (explore, build, general, plan, or custom)", + "type": "string", + }, + }, + "required": [ + "id", + "name", + "worker_type", + "depends_on", + "prompt_template", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "name", + "nodes", + ], + "type": "object", + }, + ], + }, + }, + "required": [ + "fragment", + ], + "type": "object", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "operation", + "workflow_id", + "spec", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Control a live workflow", + "enum": [ + "control", + ], + "type": "string", + }, + "operation": { + "description": "Apply a node fragment (add/cancel/restart/replace)", + "enum": [ + "replan", + ], + "type": "string", + }, + "spec_path": { + "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory", + "type": "string", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "operation", + "workflow_id", + "spec_path", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Control a live workflow", + "enum": [ + "control", + ], + "type": "string", + }, + "operation": { + "description": "pause/resume/cancel/step/complete", + "enum": [ + "pause", + "resume", + "cancel", + "step", + "complete", + ], + "type": "string", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "operation", + "workflow_id", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Inspect durable workflow and node state", + "enum": [ + "status", + ], + "type": "string", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "workflow_id", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Read one durable node output in bounded pages", + "enum": [ + "result", + ], + "type": "string", + }, + "cursor": { + "description": "Opaque continuation cursor returned by the previous page", + "type": "string", + }, + "limit": { + "description": "Maximum page characters; defaults to 8000, max 12000", + "maximum": 12000, + "minimum": 1, + "type": "integer", + }, + "node_id": { + "description": "Target durable node ID", + "type": "string", + }, + "workflow_id": { + "description": "Target workflow ID", + "pattern": "^dag", + "type": "string", + }, + }, + "required": [ + "action", + "workflow_id", + "node_id", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Show saved workflow specs in the library with their validation status", + "enum": [ + "list", + ], + "type": "string", + }, + }, + "required": [ + "action", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Inspect one saved spec before retargeting it", + "enum": [ + "read", + ], + "type": "string", + }, + "spec_path": { + "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory", + "type": "string", + }, + }, + "required": [ + "action", + "spec_path", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Load detailed guidance only when needed", + "enum": [ + "guide", + ], + "type": "string", + }, + "topic": { + "description": "blocks: composable block schema; interface: low-level workflow API; policy: gates/admission/recovery; patterns: domain playbooks. Omit for the compact index", + "enum": [ + "blocks", + "interface", + "policy", + "patterns", + ], + "type": "string", + }, + }, + "required": [ + "action", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Pre-flight a custom spec without creating a workflow; returns diagnostics, never a workflow ID", + "enum": [ + "validate", + ], + "type": "string", + }, + "profile": { + "description": "portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, inline and project/global specs environment", + "enum": [ + "portable", + "environment", + ], + "type": "string", + }, + "spec": { + "description": "Inline structured spec for a one-off graph. Use this or spec_path, never both", + "properties": { + "admission": { + "properties": { + "acknowledged_risks": { + "items": { + "type": "string", + }, + "type": "array", + }, + "brief": { + "properties": { + "acceptance_criteria": { + "items": { + "type": "string", + }, + "type": "array", + }, + "assumptions": { + "items": { + "type": "string", + }, + "type": "array", + }, + "blocking_questions": { + "items": { + "type": "string", + }, + "type": "array", + }, + "constraints": { + "items": { + "type": "string", + }, + "type": "array", + }, + "evidence_required": { + "items": { + "type": "string", + }, + "type": "array", + }, + "goal": { + "type": "string", + }, + "open_questions": { + "items": { + "type": "string", + }, + "type": "array", + }, + "review_plan": { + "items": { + "type": "string", + }, + "type": "array", + }, + "risks": { + "items": { + "type": "string", + }, + "type": "array", + }, + "scope": { + "properties": { + "in": { + "items": { + "type": "string", + }, + "type": "array", + }, + "out": { + "items": { + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "in", + "out", + ], + "type": "object", + }, + }, + "required": [ + "goal", + "scope", + "constraints", + "assumptions", + "acceptance_criteria", + "evidence_required", + "risks", + "review_plan", + "open_questions", + "blocking_questions", + ], + "type": "object", + }, + "brief_revision": { + "type": "number", + }, + "qa_mode": { + "enum": [ + "LIGHT", + "STANDARD", + "GRILL", + ], + "type": "string", + }, + "verdict": { + "enum": [ + "READY", + "NOT_READY", + "WAIVED", + ], + "type": "string", + }, + "waiver_reason": { + "type": "string", + }, + }, + "required": [ + "brief_revision", + "qa_mode", + "verdict", + "brief", + ], + "type": "object", + }, + "config": { + "anyOf": [ + { + "properties": { + "blocks": { + "description": "Composable blocks compiled into nodes by the runtime", + "items": { + "properties": { + "depends_on": { + "description": "Block IDs this block waits for. Defaults to []", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique block identifier; dependencies target block IDs", + "type": "string", + }, + "instruction": { + "description": "Task-specific instruction added to the block's built-in execution contract", + "type": "string", + }, + "kind": { + "description": "Composable workflow block; debug and review expand into evidence-gathering subgraphs", + "enum": [ + "explore", + "plan", + "prototype", + "debug", + "coding", + "verify", + "review", + "synthesize", + ], + "type": "string", + }, + "report_to_parent": { + "description": "Override wake behavior. Review decisions and synthesis report by default", + "type": "boolean", + }, + "required": { + "description": "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", + "type": "boolean", + }, + "worker_type": { + "description": "Optional configured agent override; defaults from the block kind", + "type": "string", + }, + }, + "required": [ + "id", + "kind", + ], + "type": "object", + }, + "type": "array", + }, + "max_concurrency": { + "description": "Max parallel nodes. Default: 5", + "type": "number", + }, + "max_node_replan_attempts": { + "description": "Max replan restarts per node ID. Default: 5", + "type": "number", + }, + "max_total_nodes": { + "description": "Cumulative node cap across the workflow lifetime. Default: 100", + "type": "number", + }, + "name": { + "description": "Workflow name", + "type": "string", + }, + "node_defaults": { + "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + "properties": { + "report_to_parent": { + "type": "boolean", + }, + "required": { + "type": "boolean", + }, + "worker_config": { + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "objective": { + "description": "Injected into every generated child prompt; required for blocks", + "type": "string", + }, + }, + "required": [ + "name", + "objective", + "blocks", + ], + "type": "object", + }, + { + "properties": { + "max_concurrency": { + "description": "Max parallel nodes. Default: 5", + "type": "number", + }, + "max_node_replan_attempts": { + "description": "Max replan restarts per node ID. Default: 5", + "type": "number", + }, + "max_total_nodes": { + "description": "Cumulative node cap across the workflow lifetime. Default: 100", + "type": "number", + }, + "name": { + "description": "Workflow name", + "type": "string", + }, + "node_defaults": { + "description": "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", + "properties": { + "report_to_parent": { + "type": "boolean", + }, + "required": { + "type": "boolean", + }, + "worker_config": { + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + }, + "type": "object", + }, + "nodes": { + "description": "Low-level node declarations", + "items": { + "properties": { + "cancel": { + "description": "(replan only) Cancel this node", + "type": "boolean", + }, + "condition": { + "description": "Expression evaluated before spawn; node is skipped if false", + "type": "string", + }, + "depends_on": { + "description": "Node IDs this node waits for ([] for root)", + "items": { + "type": "string", + }, + "type": "array", + }, + "id": { + "description": "Unique node identifier, used in depends_on", + "type": "string", + }, + "input_mapping": { + "additionalProperties": { + "type": "string", + }, + "description": "Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID", + "type": "object", + }, + "name": { + "description": "Human-readable node name", + "type": "string", + }, + "output_schema": { + "description": "JSON Schema; child agent must call submit_result to submit structured output", + "type": "object", + }, + "prompt_template": { + "anyOf": [ + { + "properties": { + "inline": { + "description": "Inline prompt text; bind {{placeholders}} via input or input_mapping", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "inline", + ], + "type": "object", + }, + { + "properties": { + "id": { + "description": "Prompt asset id resolved from .opencode/dag-prompts (project, then global)", + "type": "string", + }, + "input": { + "type": "object", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, + ], + "description": "Template: exactly one of { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default", + }, + "report_to_parent": { + "description": "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + "type": "boolean", + }, + "required": { + "description": "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", + "type": "boolean", + }, + "restart": { + "description": "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", + "type": "boolean", + }, + "review": { + "description": "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", + "properties": { + "implementation_node_id": { + "type": "string", + }, + "phase": { + "enum": [ + "design", + "diff", + ], + "type": "string", + }, + "verification_node_id": { + "type": "string", + }, + }, + "required": [ + "phase", + ], + "type": "object", + }, + "worker_config": { + "description": "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config", + "properties": { + "timeout_ms": { + "type": "number", + }, + }, + "type": "object", + }, + "worker_type": { + "description": "Agent type (explore, build, general, plan, or custom)", + "type": "string", + }, + }, + "required": [ + "id", + "name", + "worker_type", + "depends_on", + "prompt_template", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "name", + "nodes", + ], + "type": "object", + }, + ], + }, + "mode": { + "enum": [ + "standard", + "deep", + ], + "type": "string", + }, + "title": { + "type": "string", + }, + }, + "required": [ + "config", + ], + "type": "object", + }, + }, + "required": [ + "action", + "spec", + ], + "type": "object", + }, + { + "properties": { + "action": { + "description": "Pre-flight a custom spec without creating a workflow; returns diagnostics, never a workflow ID", + "enum": [ + "validate", + ], + "type": "string", + }, + "profile": { + "description": "portable: distributable-template checks; environment: additionally resolves prompts, workers, and models in this project. Defaults: builtin specs portable, inline and project/global specs environment", + "enum": [ + "portable", + "environment", + ], + "type": "string", + }, + "spec_path": { + "description": "(start/extend/control replan/read/validate) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory", + "type": "string", + }, + }, + "required": [ + "action", + "spec_path", + ], + "type": "object", + }, + ], +} +`; + exports[`tool parameters JSON Schema (wire shape) write 1`] = ` { "$schema": "https://json-schema.org/draft/2020-12/schema", diff --git a/packages/opencode/test/tool/fixtures/workflow-block-skills-pre-internalization.json b/packages/opencode/test/tool/fixtures/workflow-block-skills-pre-internalization.json new file mode 100644 index 0000000000..c957950dff --- /dev/null +++ b/packages/opencode/test/tool/fixtures/workflow-block-skills-pre-internalization.json @@ -0,0 +1,15 @@ +{ + "captured_from": "workflow-parameters-post-change.json before internalize-dag-block-capabilities", + "schema_bytes": 29768, + "provider_block_item_fields": [ + "id", + "kind", + "depends_on", + "instruction", + "skills", + "worker_type", + "required", + "report_to_parent" + ], + "compiled_prompt_fragment": "Before working, load these relevant skills with the skill tool when available" +} diff --git a/packages/opencode/test/tool/fixtures/workflow-parameters-post-change.json b/packages/opencode/test/tool/fixtures/workflow-parameters-post-change.json new file mode 100644 index 0000000000..7a73cf124e --- /dev/null +++ b/packages/opencode/test/tool/fixtures/workflow-parameters-post-change.json @@ -0,0 +1,126 @@ +{ + "captured_from": "packages/opencode/src/tool/workflow.ts (discriminated-union Parameters)", + "schema_bytes": 29254, + "branch_count": 14, + "session_id_exposed": false, + "project_id_exposed": false, + "transformed": { + "openai": { + "bytes": 29306, + "branch_count": 14, + "start_inline_spec_config_present": true, + "blocks_branch_fields": [ + "name", + "objective", + "blocks", + "node_defaults", + "max_concurrency", + "max_node_replan_attempts", + "max_total_nodes" + ], + "block_item_fields": [ + "id", + "kind", + "depends_on", + "instruction", + "worker_type", + "required", + "report_to_parent" + ], + "node_item_fields": [ + "id", + "name", + "worker_type", + "depends_on", + "required", + "prompt_template", + "worker_config", + "input_mapping", + "report_to_parent", + "condition", + "restart", + "cancel", + "output_schema", + "review" + ] + }, + "azure": { + "bytes": 29306, + "branch_count": 14, + "start_inline_spec_config_present": true, + "blocks_branch_fields": [ + "name", + "objective", + "blocks", + "node_defaults", + "max_concurrency", + "max_node_replan_attempts", + "max_total_nodes" + ], + "block_item_fields": [ + "id", + "kind", + "depends_on", + "instruction", + "worker_type", + "required", + "report_to_parent" + ], + "node_item_fields": [ + "id", + "name", + "worker_type", + "depends_on", + "required", + "prompt_template", + "worker_config", + "input_mapping", + "report_to_parent", + "condition", + "restart", + "cancel", + "output_schema", + "review" + ] + }, + "gemini": { + "bytes": 29254, + "branch_count": 14, + "start_inline_spec_config_present": true, + "blocks_branch_fields": [ + "name", + "objective", + "blocks", + "node_defaults", + "max_concurrency", + "max_node_replan_attempts", + "max_total_nodes" + ], + "block_item_fields": [ + "id", + "kind", + "depends_on", + "instruction", + "worker_type", + "required", + "report_to_parent" + ], + "node_item_fields": [ + "id", + "name", + "worker_type", + "depends_on", + "required", + "prompt_template", + "worker_config", + "input_mapping", + "report_to_parent", + "condition", + "restart", + "cancel", + "output_schema", + "review" + ] + } + } +} diff --git a/packages/opencode/test/tool/fixtures/workflow-parameters-pre-change.json b/packages/opencode/test/tool/fixtures/workflow-parameters-pre-change.json new file mode 100644 index 0000000000..67d7c5a3b8 --- /dev/null +++ b/packages/opencode/test/tool/fixtures/workflow-parameters-pre-change.json @@ -0,0 +1,32 @@ +{ + "note": "Immutable red evidence captured before the discriminated-union switch (task 1.1). Never regenerate: the pre-change flat Parameters no longer exist.", + "captured_from": "packages/opencode/src/tool/workflow.ts (flat Parameters, 11 optional fields)", + "schema_bytes": 1987, + "spec_wire_shape": { + "type": "object", + "description": "(start/extend/control replan) Inline structured spec for a one-off graph. Use this or spec_path, never both" + }, + "session_id_exposed": true, + "project_id_exposed": true, + "field_count": 11, + "transformed": { + "openai": { + "bytes": 1901, + "spec_properties": {}, + "spec_property_keys": [], + "spec_description_present": true + }, + "azure": { + "bytes": 1901, + "spec_properties": {}, + "spec_property_keys": [], + "spec_description_present": true + }, + "gemini": { + "bytes": 1987, + "spec_properties": null, + "spec_property_keys": [], + "spec_description_present": true + } + } +} diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 9c540daad0..ee47064544 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -24,6 +24,7 @@ import { Parameters as Task } from "../../src/tool/task" import { Parameters as Todo } from "../../src/tool/todo" import { Parameters as WebFetch } from "../../src/tool/webfetch" import { Parameters as WebSearch } from "../../src/tool/websearch" +import { WorkflowParameters } from "../../src/tool/workflow" import { Parameters as Write } from "../../src/tool/write" const parse = >(schema: S, input: unknown): S["Type"] => @@ -51,8 +52,23 @@ describe("tool parameters", () => { test("todo", () => expect(toJsonSchema(Todo)).toMatchSnapshot()) test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot()) test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot()) + test("workflow", () => expect(toJsonSchema(WorkflowParameters)).toMatchSnapshot()) test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot()) + // Regression fixture from change repair-workflow-authoring-validation: + // the pre-change flat Parameters left the inline spec opaque after + // provider transformation and exposed runtime-derived identity fields. + // The capture lives in fixtures/ so the red evidence survives the fix. + test("workflow pre-change evidence recorded the opaque inline spec", async () => { + const evidence = await Bun.file(new URL("./fixtures/workflow-parameters-pre-change.json", import.meta.url)).json() + expect(evidence.field_count).toBe(11) + expect(evidence.session_id_exposed).toBe(true) + expect(evidence.project_id_exposed).toBe(true) + expect(evidence.transformed.openai.spec_properties).toEqual({}) + expect(evidence.transformed.azure.spec_properties).toEqual({}) + expect(evidence.transformed.gemini.spec_property_keys).toEqual([]) + }) + test("inlines named child schemas for provider compatibility", () => { const schema = toJsonSchema(Question) expect(schema).not.toHaveProperty("$defs") diff --git a/packages/opencode/test/tool/workflow-authoring.test.ts b/packages/opencode/test/tool/workflow-authoring.test.ts new file mode 100644 index 0000000000..8330690420 --- /dev/null +++ b/packages/opencode/test/tool/workflow-authoring.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from "bun:test" +import { Result, Schema } from "effect" +import { Parameters } from "../../src/tool/workflow" +import { DagBlocks } from "../../src/dag/blocks" + +// Regression fixtures for change repair-workflow-authoring-validation. +// +// The worktree-lifecycle task had already produced a complete decision brief +// and correctly selected `plan → coding(2 packages) → verify → review` with +// an explicit explore skip. The route choice was right; every start call then +// failed at the tool boundary: +// +// 1. start carrying an empty workflow_id (Dag.ID entry validation), +// 2. start carrying other actions' fields (operation=complete, node_id, +// limit), +// 3. start whose inline spec stayed `{}` because the provider-facing +// schema declared no spec structure. +// +// These fixtures pin the decision brief's route and the two polluted call +// shapes so the discriminated-union schema is measured against them. + +const WORKTREE_LIFECYCLE_BRIEF = { + objective: + "Repair worktree lifecycle handling: fix bootstrap cleanup races and cover both tiers with regression tests", + route: ["plan", "coding(worktree-core)", "coding(callers-and-fixture)", "verify", "review"], + skips: ["explore — the confirmed brief already supplies file references, failure mechanism, package split, risks, and acceptance checks"], + packages: { + "worktree-core": "worktree bootstrap/cleanup ownership in the core lifecycle", + "callers-and-fixture": "call-site updates plus the isolated memory fixture in cli tests", + }, +} as const + +const block = (input: { + id: string + kind: (typeof DagBlocks.WORKFLOW_BLOCK_KINDS)[number] + depends_on?: string[] + instruction?: string +}) => new DagBlocks.WorkflowBlock(input) + +// The accepted start fixture: only start-owned fields, complete config.blocks. +const worktreeLifecycleStartInput = { + action: "start", + spec: { + title: "Worktree lifecycle repair", + config: { + name: "worktree-lifecycle-repair", + objective: WORKTREE_LIFECYCLE_BRIEF.objective, + blocks: [ + block({ + id: "plan", + kind: "plan", + instruction: "Use the confirmed brief; do not repeat discovery.", + }), + block({ + id: "coding-worktree-core", + kind: "coding", + depends_on: ["plan"], + instruction: WORKTREE_LIFECYCLE_BRIEF.packages["worktree-core"], + }), + block({ + id: "coding-callers-and-fixture", + kind: "coding", + depends_on: ["plan"], + instruction: WORKTREE_LIFECYCLE_BRIEF.packages["callers-and-fixture"], + }), + block({ + id: "verify", + kind: "verify", + depends_on: ["coding-worktree-core", "coding-callers-and-fixture"], + instruction: "Run the two packages' acceptance commands and record evidence", + }), + block({ + id: "review", + kind: "review", + depends_on: ["verify"], + }), + ], + }, + }, +} as const + +// The previously observed polluted calls: a start that carries another +// action's identifiers and control fields. The empty-workflow_id shape +// already fails the Dag.ID brand today; the plausible-id shape passes the +// flat schema and must be rejected once fields become action-owned. +const pollutedStartWithForeignFields = { + action: "start", + workflow_id: "dag_2x9k4m", + operation: "complete", + node_id: "verify", + cursor: "", + limit: 8000, + spec: worktreeLifecycleStartInput.spec, +} + +const pollutedStartWithEmptySpec = { + action: "start", + spec: {}, +} + +// The tool admits parameters with strict parsing (foreign fields are an +// error, not a silent drop), so the fixtures decode the same way. +const decode = (input: unknown) => + Result.isSuccess(Schema.decodeUnknownResult(Parameters, { onExcessProperty: "error" })(input)) + +describe("worktree-lifecycle regression fixtures", () => { + test("decision brief route compiles under the block compiler", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: WORKTREE_LIFECYCLE_BRIEF.objective, + blocks: [...worktreeLifecycleStartInput.spec.config.blocks], + }) + const byID = new Map(nodes.map((node) => [node.id, node])) + // Workspace-writer serialization: the second coding writer waits for the + // first even though both only declared the plan dependency. + const writerOrder = nodes.filter((node) => node.worker_type === "build").map((node) => node.id) + expect(writerOrder).toEqual(["coding-worktree-core", "coding-callers-and-fixture"]) + expect(byID.get("coding-callers-and-fixture")?.depends_on).toContain("coding-worktree-core") + // Verification depends on every writer; the review binds to the + // canonical implementation fingerprint. + expect(byID.get("verify")?.depends_on).toEqual( + expect.arrayContaining(["coding-worktree-core", "coding-callers-and-fixture"]), + ) + const reviewDecision = byID.get("review") + expect(reviewDecision?.review?.phase).toBe("diff") + // Canonical writer is the serialized one that transitively depends on + // every other writer — the second package after serialization. + expect(reviewDecision?.review?.implementation_node_id).toBe("coding-callers-and-fixture") + expect(reviewDecision?.review?.verification_node_id).toBe("verify") + expect(reviewDecision?.input_mapping?.["implementation_fingerprint"]).toBe( + "coding-callers-and-fixture.output.fingerprint", + ) + }) + + test("accepted start fixture carries only start-owned fields and a complete config.blocks", () => { + expect(Object.keys(worktreeLifecycleStartInput)).toEqual(["action", "spec"]) + expect(worktreeLifecycleStartInput.spec.config.blocks.length).toBe(5) + expect(decode(worktreeLifecycleStartInput)).toBe(true) + }) + + test("replay: the complete audit adds no explore block and strict decode keeps a clean start", () => { + // The confirmed brief already supplies repository evidence, so the route + // starts at plan — no explore lane is added back. + const blockIDs = worktreeLifecycleStartInput.spec.config.blocks.map((block) => block.id) + expect(blockIDs.some((id) => id.includes("explore"))).toBe(false) + expect(blockIDs).toEqual(["plan", "coding-worktree-core", "coding-callers-and-fixture", "verify", "review"]) + // Strict decoding admits exactly the start-owned fields. + const decoded = Schema.decodeUnknownSync(Parameters, { onExcessProperty: "error" })(worktreeLifecycleStartInput) + expect(decoded.action).toBe("start") + expect("spec" in decoded).toBe(true) + expect("workflow_id" in decoded).toBe(false) + expect("operation" in decoded).toBe(false) + expect("node_id" in decoded).toBe(false) + }) + + test("start polluted with empty workflow/control/result fields is rejected", () => { + expect(decode(pollutedStartWithForeignFields)).toBe(false) + }) + + test("start with an empty inline spec is rejected", () => { + expect(decode(pollutedStartWithEmptySpec)).toBe(false) + }) + + test("start without any graph source is rejected", () => { + expect(decode({ action: "start" })).toBe(false) + }) + + test("validate rejects control and result fields it does not own", () => { + const spec = worktreeLifecycleStartInput.spec + expect(decode({ action: "validate", spec, workflow_id: "dag_2x9k4m" })).toBe(false) + expect(decode({ action: "validate", spec, node_id: "verify" })).toBe(false) + expect(decode({ action: "validate", spec, operation: "cancel" })).toBe(false) + expect(decode({ action: "validate", spec, cursor: "", limit: 500 })).toBe(false) + // The validate action itself stays clean with exactly one source. + expect(decode({ action: "validate", spec, profile: "portable" })).toBe(true) + expect(decode({ action: "validate", spec_path: "saved-route", profile: "environment" })).toBe(true) + }) + + test("inline admission rejects boundary-owned audit fields; file reads strip them instead", () => { + const brief = { + goal: "Ship the change", + scope: { in: ["dag"], out: [] }, + constraints: [], + assumptions: [], + acceptance_criteria: [], + evidence_required: [], + risks: ["unresolved rollout"], + review_plan: [], + open_questions: [], + blocking_questions: [], + } + const cleanAdmission = { + brief_revision: 1, + qa_mode: "STANDARD", + verdict: "WAIVED", + brief, + waiver_reason: "Preview release only", + acknowledged_risks: ["unresolved rollout"], + } + const spec = { ...worktreeLifecycleStartInput.spec, mode: "deep", admission: cleanAdmission } + expect(decode({ action: "start", spec })).toBe(true) + // System-generated fields never belong in the model-facing schema — the + // file-read boundary strips them for legacy YAML compatibility instead. + for (const field of ["protocol_version", "state", "fingerprint"]) { + expect(decode({ action: "start", spec: { ...spec, admission: { ...cleanAdmission, [field]: "x" } } })).toBe( + false, + ) + } + }) +}) diff --git a/packages/opencode/test/tool/workflow-provider-schema.test.ts b/packages/opencode/test/tool/workflow-provider-schema.test.ts new file mode 100644 index 0000000000..0aa59f8448 --- /dev/null +++ b/packages/opencode/test/tool/workflow-provider-schema.test.ts @@ -0,0 +1,159 @@ +/* oxlint-disable typescript-eslint/no-unsafe-type-assertion -- These wire-shape tests intentionally traverse provider-owned recursive JSON Schema values and pinned JSON evidence. */ +import { describe, expect, test } from "bun:test" +import { Parameters } from "../../src/tool/workflow" +import { ToolJsonSchema } from "../../src/tool/json-schema" +import { ProviderTransform } from "../../src/provider/transform" + +// Wire-shape regression for change repair-workflow-authoring-validation: +// the discriminated union must survive provider transformation — every action +// keeps its discriminator and required fields, and nested block/node fields +// stay visible to the model (the pre-change Record spec collapsed to +// `properties: {}` on OpenAI — see fixtures/workflow-parameters-pre-change.json). + +const openaiModel = { providerID: "openai", api: { id: "gpt-4.1", npm: "@ai-sdk/openai" } } as never +const azureModel = { providerID: "azure", api: { id: "gpt-4.1", npm: "@ai-sdk/azure" } } as never +const geminiModel = { providerID: "google", api: { id: "gemini-3-pro", npm: "@ai-sdk/google" } } as never + +type JsonSchemaNode = { + anyOf?: JsonSchemaNode[] + required?: string[] + properties?: Record + items?: JsonSchemaNode + enum?: unknown[] + [key: string]: unknown +} + +function branches(transformed: JsonSchemaNode): JsonSchemaNode[] { + expect(Array.isArray(transformed.anyOf)).toBe(true) + return transformed.anyOf ?? [] +} + +function branchByAction(transformed: JsonSchemaNode, action: string, withField?: string): JsonSchemaNode[] { + return branches(transformed).filter((branch) => { + const actionEnum = branch?.properties?.action?.enum + if (!Array.isArray(actionEnum) || !actionEnum.includes(action)) return false + return withField === undefined || branch?.properties?.[withField] !== undefined + }) +} + +// Asserts the node carries properties and returns them for traversal. +function record(node: JsonSchemaNode | undefined): Record { + expect(node?.properties).toBeDefined() + return node?.properties ?? {} +} + +describe("workflow provider-facing schema", () => { + test("base wire shape is the 14-branch discriminated union", async () => { + const schema = ToolJsonSchema.fromSchema(Parameters as never) as JsonSchemaNode + const evidence = (await Bun.file( + new URL("./fixtures/workflow-parameters-post-change.json", import.meta.url), + ).json()) as JsonSchemaNode + expect(schema.anyOf?.length).toBe(14) + const flat = JSON.stringify(schema) + expect(flat).not.toContain('"session_id"') + expect(flat).not.toContain('"project_id"') + expect(flat).not.toContain('"skills"') + expect(Buffer.byteLength(flat, "utf8")).toBe(evidence.schema_bytes as number) + }) + + test("every action stays representable after OpenAI transformation", () => { + const transformed = ProviderTransform.schema( + openaiModel, + ToolJsonSchema.fromSchema(Parameters as never), + ) as JsonSchemaNode + for (const action of ["start", "extend", "control", "status", "result", "list", "read", "guide", "validate"]) { + expect(branchByAction(transformed, action).length).toBeGreaterThan(0) + } + // Action fields do not bleed across branches: the status branch carries + // no spec/operation/cursor fields. + const status = branchByAction(transformed, "status")[0] + expect(status.required).toContain("workflow_id") + expect(Object.keys(status.properties ?? {})).toEqual(["action", "workflow_id"]) + }) + + test("OpenAI transformation exposes the nested blocks spec instead of properties: {}", () => { + const transformed = ProviderTransform.schema( + openaiModel, + ToolJsonSchema.fromSchema(Parameters as never), + ) as JsonSchemaNode + const startInline = branchByAction(transformed, "start", "spec")[0] + const config = record(startInline)["spec"] + expect(record(config)["config"]).toBeDefined() + const configUnion = record(config)["config"].anyOf ?? [] + const blocksBranch = configUnion.find((branch) => branch.properties?.blocks !== undefined) + const nodesBranch = configUnion.find((branch) => branch.properties?.nodes !== undefined) + expect(blocksBranch).toBeDefined() + expect(nodesBranch).toBeDefined() + expect(record(blocksBranch)["objective"]).toBeDefined() + expect(blocksBranch?.required).toEqual(expect.arrayContaining(["name", "objective", "blocks"])) + expect(nodesBranch?.required).toEqual(expect.arrayContaining(["name", "nodes"])) + const blockItem = record(blocksBranch)["blocks"]?.items + expect(Object.keys(record(blockItem))).toEqual(expect.arrayContaining(["id", "kind", "depends_on", "instruction"])) + expect(Object.keys(record(blockItem))).not.toContain("skills") + const nodeItem = record(nodesBranch)["nodes"]?.items + expect(Object.keys(record(nodeItem))).toEqual( + expect.arrayContaining(["id", "name", "worker_type", "depends_on", "prompt_template"]), + ) + expect(Object.keys(record(nodeItem))).not.toContain("model") + expect(Object.keys(record(record(nodesBranch)["node_defaults"]))).not.toContain("model") + // Exactly-one-source prompt_template: both variants declared. + const promptTemplate = record(nodeItem)["prompt_template"] + const promptVariants = promptTemplate?.anyOf ?? [] + expect(promptVariants.some((variant) => variant.properties?.inline !== undefined)).toBe(true) + expect(promptVariants.some((variant) => variant.properties?.id !== undefined)).toBe(true) + }) + + test("pins the removed Skill-dependent block surface as red evidence", async () => { + const before = (await Bun.file( + new URL("./fixtures/workflow-block-skills-pre-internalization.json", import.meta.url), + ).json()) as { provider_block_item_fields: string[]; compiled_prompt_fragment: string } + expect(before.provider_block_item_fields).toContain("skills") + expect(before.compiled_prompt_fragment).toContain("load these relevant skills") + + const schema = JSON.stringify(ToolJsonSchema.fromSchema(Parameters as never)) + expect(schema).not.toContain('"skills"') + }) + + test("Azure transformation keeps the same discriminated union", () => { + const transformed = ProviderTransform.schema( + azureModel, + ToolJsonSchema.fromSchema(Parameters as never), + ) as JsonSchemaNode + expect(transformed.anyOf?.length).toBe(14) + expect(branchByAction(transformed, "start", "spec").length).toBeGreaterThan(0) + expect(branchByAction(transformed, "validate", "spec_path").length).toBeGreaterThan(0) + }) + + test("Gemini transformation keeps every branch and nested fields", () => { + const transformed = ProviderTransform.schema( + geminiModel, + ToolJsonSchema.fromSchema(Parameters as never), + ) as JsonSchemaNode + expect(transformed.anyOf?.length).toBe(14) + const startInline = branchByAction(transformed, "start", "spec")[0] + expect(record(record(startInline)["spec"])["config"]).toBeDefined() + const resultBranch = branchByAction(transformed, "result")[0] + expect(resultBranch.required).toEqual(expect.arrayContaining(["workflow_id", "node_id"])) + expect(Object.keys(record(resultBranch))).toEqual(expect.arrayContaining(["cursor", "limit"])) + }) + + test("post-change byte sizes stay at the recorded evidence", async () => { + const evidence = (await Bun.file( + new URL("./fixtures/workflow-parameters-post-change.json", import.meta.url), + ).json()) as { + schema_bytes: number + transformed: { openai: { bytes: number }; azure: { bytes: number }; gemini: { bytes: number } } + } + const base = JSON.stringify(ToolJsonSchema.fromSchema(Parameters as never)) + expect(Buffer.byteLength(base, "utf8")).toBe(evidence.schema_bytes) + expect(Buffer.byteLength(JSON.stringify(ProviderTransform.schema(openaiModel, JSON.parse(base))), "utf8")).toBe( + evidence.transformed.openai.bytes, + ) + expect(Buffer.byteLength(JSON.stringify(ProviderTransform.schema(azureModel, JSON.parse(base))), "utf8")).toBe( + evidence.transformed.azure.bytes, + ) + expect(Buffer.byteLength(JSON.stringify(ProviderTransform.schema(geminiModel, JSON.parse(base))), "utf8")).toBe( + evidence.transformed.gemini.bytes, + ) + }) +}) diff --git a/third_party/mattpocock-skills/LICENSE b/third_party/mattpocock-skills/LICENSE new file mode 100644 index 0000000000..f1dd2c0910 --- /dev/null +++ b/third_party/mattpocock-skills/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Matt Pocock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/mattpocock-skills/SOURCE.md b/third_party/mattpocock-skills/SOURCE.md new file mode 100644 index 0000000000..a928c2fd07 --- /dev/null +++ b/third_party/mattpocock-skills/SOURCE.md @@ -0,0 +1,12 @@ +# Adapted methodology source + +- Source: https://github.com/mattpocock/skills +- License: MIT +- Copyright: Matt Pocock +- Pinned revision: 84fdeffd12f2ee307994d1eb6feb48173b6e0502 +- Adaptation: engineering decision, evidence, debugging, test-first delivery, + codebase design, review, and synthesis disciplines were adapted into + product-owned workflow routing and block contracts. + +The upstream project and feature names are provenance only. They are not +runtime workflow fields, product labels, or required installed extensions.