diff --git a/.opencode/workflows/change-review.yaml b/.opencode/workflows/change-review.yaml new file mode 100644 index 0000000000..3d09772a35 --- /dev/null +++ b/.opencode/workflows/change-review.yaml @@ -0,0 +1,72 @@ +title: Change review +config: + name: change-review + max_concurrency: 3 + node_defaults: + required: false + report_to_parent: false + worker_config: + timeout_ms: 900000 + nodes: + - id: survey + name: survey + worker_type: explore + depends_on: [] + required: true + prompt_template: + id: code-explore + input: + target: "the uncommitted changes in this repository (git status, git diff) and the modules they touch" + + - id: review-logic + name: review-logic + worker_type: general + depends_on: [survey] + prompt_template: { id: review-logic } + + - id: review-arch + name: review-arch + worker_type: general + depends_on: [survey] + prompt_template: { id: review-arch } + + - id: verify + name: verify + worker_type: build + depends_on: [survey] + prompt_template: + inline: | + Run the repository's commit gates against the changed packages and report the raw results. + From each affected package directory (never the repo root): `bun typecheck`, then `bun test` for the + test files covering the changed code. Report failures verbatim; do not fix anything. + + - id: arbitrate + name: arbitrate + worker_type: general + depends_on: [review-logic, review-arch, verify] + required: true + report_to_parent: true + output_schema: + type: object + required: [verdict, summary, findings] + properties: + verdict: + type: string + enum: [ACCEPT, REVISE, REJECT, BLOCKED] + summary: { type: string } + findings: + type: array + items: + type: object + required: [severity, location, problem] + properties: + severity: + type: string + enum: [blocker, major, minor, nit] + location: { type: string } + problem: { type: string } + prompt_template: + inline: | + Two reviewers and one verification run examined the same change. Submit a single deduplicated + verdict. A failing gate in {{verify}} is a blocker regardless of review opinion. Drop any reviewer + claim you cannot tie to a specific file and line. diff --git a/README.md b/README.md index 21d7095e2a..d9681e21e2 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,15 @@ 简体中文

-# OpenCode-GraphAgent +# GraphAgent -> A fork of [opencode](https://github.com/anomalyco/opencode) that adds a DAG workflow engine: the coding agent decomposes a task into a dependency graph of child agents and drives it to completion. State is durable, crashes are recoverable, and the whole thing can be inspected and controlled from the terminal. +> A coding agent that decomposes a task into a dependency graph of child agents and drives it to completion. State is durable, crashes are recoverable, and the whole graph can be inspected and controlled from the terminal. -Built on top of the MIT-licensed [opencode](https://github.com/anomalyco/opencode) terminal AI agent. **Not affiliated with or endorsed by the OpenCode team.** +GraphAgent is the product name for this project; the repository is published as +**OpenCode-GraphAgent**, a fork of the MIT-licensed +[opencode](https://github.com/anomalyco/opencode) terminal AI agent that adds +the DAG workflow engine. **Not affiliated with or endorsed by the OpenCode +team.** --- @@ -20,6 +24,91 @@ A single agent loop struggles once a task has staged dependencies, parallelizabl 3. **Gate verdicts need a follow-up.** When a checkpoint returns `REVISE` / `REJECT` / `BLOCKED`, the parent agent has to dispose of it in the same wake turn: extend, replan, start a new workflow, or stop with stated reasons. Summarizing the verdict and ending the turn counts as an orchestration failure under the contract. 4. **Recover from evidence, not guesses.** Every state change is a durable event, transitions go through a declared state machine's guards, terminal states are irreversible (one exception, written into the spec), and the read model is a CQRS projection. After a crash, recovery reconciles from durable evidence and never fabricates provider work. +## Using workflows + +Nothing has to be configured to try it: ask for work that has stages, parallel +parts, or a review gate in the middle, and the agent designs a graph and runs +it. Three things turn that into a repeatable setup of your own. + +### 1. Choose the model tiers — `.opencode/dag.jsonc` + +Nodes never name their own model. The graph declares *which nodes are critical* +and configuration decides *what runs them*: + +```jsonc +{ + // Critical nodes: required: true, plus review/arbiter workers. + "model": { + "advanced": "anthropic/claude-sonnet-4-5", + "standard": "anthropic/claude-haiku-4-5" + }, + // Reasoning variant for DAG child sessions, when the model defines one. + "thinking_depth": "medium" +} +``` + +A project `.opencode/dag.jsonc` overrides the global one in the opencode config +dir; a commented default is seeded there on first use. With only one tier +configured, it serves as the unified default. Resolution order per node is +tier → worker agent model → parent session model; when none of them yields a +model, the workflow is not created and you are asked to configure one. + +### 2. Save the workflows you rerun — `.opencode/workflows/` + +A workflow spec is a YAML file describing the graph. Put it in a workflow +directory and it gains a **name**: + +| Scope | Path | Availability | +|---|---|---| +| Project | `.opencode/workflows/.yaml` | This repo, committed with it — the whole team gets the same procedure | +| Global | `/workflows/.yaml` | Every project on the machine | + +The filename stem is the name, and a project file shadows a global one with the +same name. A minimal spec: + +```yaml +title: Dependency audit +config: + name: dependency-audit + nodes: + - id: inventory + name: inventory + worker_type: explore + depends_on: [] + required: true + prompt_template: + id: config-explore + input: + target: "package.json files and lockfiles" + + - id: report + name: report + worker_type: general + depends_on: [inventory] + report_to_parent: true + prompt_template: + inline: "Flag outdated or duplicated dependencies in {{inventory}} and propose an upgrade order." +``` + +Then just say "run the dependency-audit workflow" — the agent starts it by +name, no path needed. Ad-hoc specs still work exactly as before: a `spec_path` +that looks like a path (has a separator or a `.yaml`/`.yml` extension) is read +relative to the session directory instead of the library. + +### 3. Let the agent author it + +The built-in **`create-dag-workflow`** skill covers spec authoring: the two +scopes, the file shape, the rules a saved spec must respect (no pinned models, +`worker_type` must exist, required template variables must be supplied), and +how to verify it. Ask to "save this as a reusable workflow" and the agent +establishes the phases and gates with you, writes the file into the scope you +pick, and proves it by starting it once. + +Node prompts come from `.opencode/dag-prompts/*.md` — 12 templates ship +in-repo, referenced by `prompt_template.id`. Add your own `.md` file there to +make a new template available; a global workflow should prefer `inline` prompts +so it does not depend on a repo-local template. + ## DAG workflow engine The engine lives in [`packages/core/src/dag`](./packages/core/src/dag) (state machine, dependency graph, scheduling, event projection, SQLite read model) and [`packages/opencode/src/dag`](./packages/opencode/src/dag) (workflow service, execution loop, node spawn, admission, review lifecycle, crash recovery, templates). Agents drive it through a single `workflow` tool; humans watch and control it through the TUI or HTTP API. @@ -77,9 +166,20 @@ Workflow-level knobs: `max_concurrency` (default 5), `max_node_replan_attempts` POST /dag/:dagID/control pause/resume/cancel/replan/extend/step/complete ``` -### Configuration +### Project configuration files + +Everything DAG-specific lives under `.opencode/`, with a global counterpart in +the opencode config dir (`OPENCODE_CONFIG_DIR`, else `~/.config/opencode`). +Everything else inherits the main opencode configuration. + +| Path | Purpose | Global counterpart | +|---|---|---| +| `.opencode/dag.jsonc` | Model tiers (`advanced` / `standard`) and `thinking_depth` for child sessions | `/dag.jsonc`, seeded with comments on first use | +| `.opencode/workflows/*.yaml` | Saved workflow specs, startable by name | `/workflows/*.yaml` | +| `.opencode/dag-prompts/*.md` | Node prompt templates referenced by `prompt_template.id` | — (project-scoped) | -`dag.jsonc` (project `.opencode/` overrides global config dir; a commented default is seeded on first use) sets two model tiers: `advanced` for critical nodes (`required: true`, review workers), `standard` for everything else, plus a `thinking_depth` reasoning variant for child sessions. Everything else inherits the main opencode configuration. +Both `dag.jsonc` and the workflow library are read lazily, so an edit applies to +the next workflow start without a restart. --- @@ -133,6 +233,8 @@ Exact file boundaries are listed in [`NOTICE`](./NOTICE). The AGPL covers the DA ## Docs +- [Saved workflow authoring guide](./packages/core/src/plugin/skill/create-dag-workflow.md) — the `create-dag-workflow` skill body +- [`.opencode/workflows/change-review.yaml`](./.opencode/workflows/change-review.yaml) — a working saved workflow, startable as `change-review` - [`docs/harness-dag.md`](./docs/harness-dag.md) — deep-mode admission & review lifecycle - [`.opencode/dag-prompts`](./.opencode/dag-prompts) — built-in node prompt templates - [`AGENTS.md`](./AGENTS.md) — contribution & development guide diff --git a/README.zh.md b/README.zh.md index 0d092e37e7..e337d2f6b4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -3,11 +3,12 @@ 简体中文

-# OpenCode-GraphAgent +# GraphAgent -> [opencode](https://github.com/anomalyco/opencode) 的 fork,加了一个 DAG 工作流引擎:编码智能体把任务拆成一张子智能体依赖图,然后驱动它跑完。状态持久化,崩溃能恢复,在终端里就能看图、控图。 +> 一个把任务拆成子智能体依赖图、并驱动它跑完的编码智能体。状态持久化,崩溃能恢复,整张图在终端里就能看、能控。 -基于 MIT 许可的 [opencode](https://github.com/anomalyco/opencode) 终端 AI 智能体构建。**与 OpenCode 团队无任何隶属或背书关系。** +GraphAgent 是本项目对外的产品名;仓库以 **OpenCode-GraphAgent** 发布,是 MIT 许可的 +[opencode](https://github.com/anomalyco/opencode) 终端 AI 智能体的 fork,在其之上加了 DAG 工作流引擎。**与 OpenCode 团队无任何隶属或背书关系。** --- @@ -20,6 +21,71 @@ 3. **门禁结论必须有下文。** 检查点返回 `REVISE` / `REJECT` / `BLOCKED` 时,父智能体要在同一个唤醒回合里处置它:extend、replan、开新工作流,或者说明理由后停下。只复述结论就结束回合,按契约算编排失败。 4. **恢复靠证据,不靠猜。** 所有状态变更都是持久化事件,状态转换要过声明式状态机的守卫,终态不可逆(只有一个写进规范的例外),读模型是 CQRS 投影。崩溃后只依据持久化证据和解现场,不会凭空重放模型调用。 +## 工作流怎么用 + +不配置也能直接试:给它一件有阶段、有可并行部分、或者中间需要一道审查门禁的活,智能体自己会建图并跑起来。想把它变成你自己的一套固定流程,有三件事: + +### 1. 选定模型分层 —— `.opencode/dag.jsonc` + +节点从不指定自己的模型。图只声明*哪些节点是关键的*,由配置决定*用什么跑*: + +```jsonc +{ + // 关键节点:required: true,以及审查/仲裁类 worker。 + "model": { + "advanced": "anthropic/claude-sonnet-4-5", + "standard": "anthropic/claude-haiku-4-5" + }, + // DAG 子会话的推理深度变体,仅当模型定义了同名变体时生效。 + "thinking_depth": "medium" +} +``` + +项目的 `.opencode/dag.jsonc` 优先于 opencode 配置目录下的全局文件;首次使用时会在全局目录生成一份带注释的默认文件。只配一层时,它就是统一默认值。每个节点的解析顺序是:分层 → worker agent 模型 → 父会话模型;三者都给不出模型时,工作流不会被创建,而是回来问你去配一个。 + +### 2. 把要复用的工作流存起来 —— `.opencode/workflows/` + +工作流 spec 就是一个描述图的 YAML 文件。把它放进工作流目录,它就有了**名字**: + +| 作用域 | 路径 | 可用范围 | +|---|---|---| +| 项目级 | `.opencode/workflows/.yaml` | 本仓库,随仓库提交 —— 团队拿到的是同一套流程 | +| 全局级 | `/workflows/.yaml` | 本机所有项目 | + +文件名(去掉扩展名)就是名字,同名时项目级遮蔽全局级。一个最小的 spec: + +```yaml +title: Dependency audit +config: + name: dependency-audit + nodes: + - id: inventory + name: inventory + worker_type: explore + depends_on: [] + required: true + prompt_template: + id: config-explore + input: + target: "package.json files and lockfiles" + + - id: report + name: report + worker_type: general + depends_on: [inventory] + report_to_parent: true + prompt_template: + inline: "Flag outdated or duplicated dependencies in {{inventory}} and propose an upgrade order." +``` + +之后直接说「跑 dependency-audit 工作流」就行 —— 智能体按名字启动,不需要路径。临时 spec 的用法完全不变:看起来像路径的 `spec_path`(含路径分隔符,或带 `.yaml`/`.yml` 扩展名)仍按会话目录相对解析,不走工作流库。 + +### 3. 让智能体替你写 + +内置的 **`create-dag-workflow`** skill 覆盖 spec 编写:两个作用域、文件结构、存盘 spec 必须守的规矩(不能钉死模型、`worker_type` 必须存在、模板必需变量必须给全),以及怎么验证。说一句「把这个存成可复用的工作流」,智能体会先跟你确认阶段和门禁,把文件写进你选的作用域,再真跑一次证明它能用。 + +节点 prompt 来自 `.opencode/dag-prompts/*.md` —— 随仓库附带 12 个,通过 `prompt_template.id` 引用。往那儿加一个 `.md` 就多一个模板;全局工作流建议用 `inline` prompt,否则会依赖某个仓库本地的模板。 + ## DAG 工作流引擎 引擎位于 [`packages/core/src/dag`](./packages/core/src/dag)(状态机、依赖图、调度、事件投影、SQLite 读模型)和 [`packages/opencode/src/dag`](./packages/opencode/src/dag)(工作流服务、执行循环、节点生成、准入、审查生命周期、崩溃恢复、模板)。智能体通过单个 `workflow` 工具驱动它;人通过 TUI 或 HTTP API 观察和控制它。 @@ -77,9 +143,17 @@ POST /dag/:dagID/control pause/resume/cancel/replan/extend/step/complete ``` -### 配置 +### 项目配置文件 + +DAG 相关的东西都放在 `.opencode/` 下,在 opencode 配置目录(`OPENCODE_CONFIG_DIR`,否则 `~/.config/opencode`)里有对应的全局版本。其余全部继承 opencode 主配置。 + +| 路径 | 用途 | 全局对应 | +|---|---|---| +| `.opencode/dag.jsonc` | 模型分层(`advanced` / `standard`)与子会话 `thinking_depth` | `<配置目录>/dag.jsonc`,首次使用时生成带注释的默认文件 | +| `.opencode/workflows/*.yaml` | 存盘的工作流 spec,可按名字启动 | `<配置目录>/workflows/*.yaml` | +| `.opencode/dag-prompts/*.md` | 由 `prompt_template.id` 引用的节点 prompt 模板 | ——(仅项目级) | -`dag.jsonc`(项目 `.opencode/` 优先于全局配置目录,首次使用时自动生成带注释的默认文件)里设置两个模型层:`advanced` 给关键节点(`required: true` 和审查类 worker),`standard` 给其余节点,另外还有子会话的 `thinking_depth` 推理深度。其余全部继承 opencode 主配置。 +`dag.jsonc` 和工作流库都是惰性读取,改完下一次启动工作流就生效,不用重启。 --- @@ -133,6 +207,8 @@ bun dev serve # headless API 服务(端口 4096) ## 文档 +- [存盘工作流编写指南](./packages/core/src/plugin/skill/create-dag-workflow.md) —— `create-dag-workflow` skill 正文 +- [`.opencode/workflows/change-review.yaml`](./.opencode/workflows/change-review.yaml) —— 一个能用的存盘工作流,按 `change-review` 启动 - [`docs/harness-dag.md`](./docs/harness-dag.md) —— deep 模式准入与审查生命周期 - [`.opencode/dag-prompts`](./.opencode/dag-prompts) —— 内置节点 prompt 模板 - [`AGENTS.md`](./AGENTS.md) —— 贡献与开发指南 diff --git a/packages/core/src/plugin/command/workflow.md b/packages/core/src/plugin/command/workflow.md index 8c1f25b9ab..9d3042acd3 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -76,6 +76,26 @@ config: nodes: [] ``` +## Saved workflows + +A `spec_path` with no path separator and no `.yaml`/`.yml` extension is a +**name** resolved against the workflow library instead of the filesystem: + +1. `.opencode/workflows/.yaml` — project scope, committed with the repo +2. `/workflows/.yaml` — global scope, available in every project + +The project scope wins when both hold the name. `workflow(action: "list")` +reports the saved names with their scope, title, and node count; a name that +resolves nowhere fails with the directories that were searched. + +Prefer a saved workflow when the user names a recurring procedure ("run the +code review workflow"): starting it is one call, and its graph has already been +reviewed. Compose a fresh spec file when the task is one-off or when the saved +graph does not fit — a path-shaped `spec_path` keeps the original +session-relative behavior. To turn a working one-off spec into a saved +workflow, move the file into one of the two directories under a descriptive +name. + ## Orchestration Lifecycle Heavy tasks follow a meta-workflow: multiple workflows chained together, each producing a decision that shapes the next. The lifecycle is the two accuracy axes applied in sequence — breadth to cover the surface, depth to earn the verdict: @@ -453,10 +473,15 @@ All nodes share the same workspace. Write conflicts are an orchestration concern **start** — Create a workflow from a YAML spec with `config` and optional `title`, `mode`, and admission input at the file root. Write the file first, -then call `{ action: "start", spec_path: ".opencode/workflows/name.yaml" }`. +then call `{ action: "start", spec_path: ".opencode/workflows/name.yaml" }`, or +pass a saved workflow name (`{ action: "start", spec_path: "code-review" }`). Returns the workflow ID. Nodes declare `depends_on` (node IDs); layers and execution order are computed automatically. +**list** — Show the saved workflow specs in the library (project and global +scope) with their names, titles, and node counts. This lists reusable specs, +not running workflows; use `status` for a workflow's live state. + **extend** — Add nodes to a running workflow. Existing nodes are unaffected; new nodes are immediately eligible for scheduling if their dependencies are met. It also accepts a genuinely additive wave after a reporting leaf @@ -496,5 +521,5 @@ file-root `nodes` array, then call ### What NOT to expect - No `node_complete` action — completion is automatic -- No `list` / `history` actions — inspect a known workflow with `status`; broader browsing remains TUI-only +- No `history` action — inspect a known workflow with `status`; browsing running workflows remains TUI-only (`list` shows saved specs, not running workflows) - No topology templates — templates are prompt fragments only; you design the graph diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 5a8bc85760..43b76800ea 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -8,9 +8,11 @@ import { AbsolutePath } from "../schema" 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" } export const CustomizeOpencodeContent = customizeOpencodeContent export const ConfigureHooksContent = configureHooksContent +export const CreateDagWorkflowContent = createDagWorkflowContent 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." @@ -18,6 +20,9 @@ export const CustomizeOpencodeDescription = export const ConfigureHooksDescription = "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks." +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 the project (.opencode/workflows/) and global (config dir) scopes, the spec file shape, and how to verify a new workflow. 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 Plugin = define({ id: "skill", effect: Effect.fn(function* (ctx) { @@ -44,6 +49,17 @@ export const Plugin = define({ }), }), ) + draft.source( + SkillV2.EmbeddedSource.make({ + type: "embedded", + skill: SkillV2.Info.make({ + name: "create-dag-workflow", + description: CreateDagWorkflowDescription, + location: AbsolutePath.make("/builtin/create-dag-workflow.md"), + content: CreateDagWorkflowContent, + }), + }), + ) }) }), }) diff --git a/packages/core/src/plugin/skill/create-dag-workflow.md b/packages/core/src/plugin/skill/create-dag-workflow.md new file mode 100644 index 0000000000..f0091531fc --- /dev/null +++ b/packages/core/src/plugin/skill/create-dag-workflow.md @@ -0,0 +1,120 @@ + + +# Creating a saved DAG workflow + +A saved workflow is a YAML spec that lives on disk under a name, so a recurring +multi-agent procedure can be started with one call instead of being redesigned +every time. This skill covers authoring one. The `workflow` tool's own +documentation covers graph semantics — read it for node fields, collaboration +patterns, and replanning. + +## Where the file goes + +| Scope | Path | Use when | +|---|---|---| +| Project | `.opencode/workflows/.yaml` | The procedure depends on this repo (its packages, its test commands, its review rules). Committed, so the whole team gets it. | +| Global | `/workflows/.yaml` | The procedure is repo-agnostic (a generic review or research pattern). Available in every project. | + +The opencode config dir is `~/.config/opencode` on Linux/macOS unless +`OPENCODE_CONFIG_DIR` redirects it. Ask the user which scope they want when the +answer is not obvious from the request; a repo-specific graph in the global +scope will break in other projects. + +**The name is the filename stem.** `.opencode/workflows/code-review.yaml` +starts as `workflow(action: "start", spec_path: "code-review")`. Use kebab-case +and no path separators. A project file shadows a global one with the same name. + +## Before writing the file + +Do not invent the graph. Establish these with the user first — a saved workflow +gets reused, so a wrong assumption gets repeated: + +1. **The trigger.** What does the user say to run this? That phrasing should be recognizable in the workflow's `title`. +2. **The phases.** Which steps genuinely depend on an earlier step's output, and which are independent? Only real data dependencies become `depends_on` edges; everything else runs in parallel. +3. **The gate.** Is there a point where downstream work must not start until quality is confirmed? That becomes a node with `output_schema` returning a verdict plus a `condition` on its dependents. +4. **The inputs.** Does the graph need per-run values (a target module, a diff range)? A saved spec is static, so express them as static `prompt_template.input` defaults and state in the node prompt that the parent may narrow the target — or keep the node prompt broad enough to work unchanged. +5. **The finish.** What does a successful run produce, and which node reports it? Give that node `report_to_parent: true`. + +## File shape + +```yaml +title: Code review workflow +config: + name: code-review + max_concurrency: 5 + node_defaults: + required: false + report_to_parent: false + worker_config: + timeout_ms: 600000 + nodes: + - id: explore + name: explore + worker_type: explore + depends_on: [] + required: true + prompt_template: + id: code-explore + input: + target: "the packages changed in the working tree" + + - id: review-logic + name: review-logic + worker_type: general + depends_on: [explore] + prompt_template: { id: review-logic } + + - id: review-arch + name: review-arch + worker_type: general + depends_on: [explore] + prompt_template: { id: review-arch } + + - id: arbitrate + name: arbitrate + worker_type: general + depends_on: [review-logic, review-arch] + required: true + report_to_parent: true + output_schema: + type: object + required: [verdict, summary, findings] + properties: + verdict: + type: string + enum: [ACCEPT, REVISE, REJECT, BLOCKED] + summary: { type: string } + findings: { type: array } + prompt_template: + inline: "Two reviewers produced findings. Submit one deduplicated verdict with evidence-backed findings." +``` + +`title` and `config` sit at the file root. A deep workflow adds `mode: deep` +and an `admission` block at the same level — but admission answers are +per-request, so a saved spec is usually `standard`; let the parent run the +admission Q&A and write a one-off deep spec when depth is needed. + +## Rules that a saved spec must respect + +- **Never write `model` on a node or in `node_defaults`.** Model choice is configuration-owned: `dag.jsonc` supplies the `advanced` tier for `required: true` and review nodes, `standard` for the rest, then the agent model, then the parent session model. A saved spec that pins a model breaks on machines without it. +- **Every `worker_type` must exist** as a built-in (`explore`, `build`, `general`, `plan`) or a configured agent. A custom agent name makes the workflow project-scoped in practice, even if the file sits in the global directory. +- **Referenced `prompt_template.id` must exist** under `.opencode/dag-prompts/`. A global workflow referencing a repo-local template will fail at spawn in other projects — use `inline` prompts there. +- **Supply every required template variable.** An unresolved `{{var}}` fails the node loudly at spawn, so a missing input turns into a broken run, not a degraded one. +- **No cycles, no dangling `depends_on` ids.** Both are rejected at creation. +- **Terminal nodes are immutable at runtime.** Design retries as new nodes added by a replan, not as in-place restarts of finished ones. + +## Verify it + +A spec is only proven by a real start. After writing the file: + +1. `workflow(action: "list")` — confirm the name resolves and the reported node count matches the file. A file missing from the listing is in the wrong directory or has the wrong extension (`.yaml`/`.yml` only). +2. `workflow(action: "start", spec_path: "")` on a small, real target. Schema and graph validation happen here: an invalid spec fails the start with the offending field, and no workflow is created. +3. Read the wake report when it arrives. A graph that "succeeded" while its fan-in node produced an empty synthesis is not working — check that the reporting node's output actually contains the comparison or decision the procedure exists to produce. + +Fix the file and start again; do not patch a running workflow to compensate for +a spec bug. Tell the user the workflow is saved, where it lives, and the exact +phrase that starts it. diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 6c783e8d2d..070752d3a0 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -45,6 +45,20 @@ describe("SkillPlugin.Plugin", () => { }), ) + it.effect("registers the built-in create-dag-workflow skill", () => + 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: "create-dag-workflow", + description: expect.stringContaining("reusable DAG workflow"), + }), + ) + }), + ) + it.effect("does not register workflow as a built-in skill", () => Effect.gen(function* () { const skill = yield* SkillV2.Service diff --git a/packages/opencode/src/dag/workflows.ts b/packages/opencode/src/dag/workflows.ts new file mode 100644 index 0000000000..7b3cee3c23 --- /dev/null +++ b/packages/opencode/src/dag/workflows.ts @@ -0,0 +1,129 @@ +/** + * Workflow library — reusable start specs discovered by name. + * + * Named workflows let a spec live as a durable project asset instead of a + * throwaway file the agent must rewrite per run. `workflow(spec_path: "x")` + * resolves through this module; a path-shaped `spec_path` bypasses it and keeps + * the original session-relative behavior. + * + * Lookup order (project overrides global, first match wins): + * - project: `.opencode/workflows/.yaml` / `.yml` + * - global: `/workflows/.yaml` / `.yml` + * + * Mirrors config.ts: same two-level scope, same OPENCODE_CONFIG_DIR redirect, + * read lazily so edits apply on the next call and startup stays untouched. + */ + +export * as DagWorkflows from "./workflows" + +import { Effect } from "effect" +import * as path from "node:path" +import * as fs from "node:fs/promises" +import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" +import { isRecord } from "@/util/record" + +const DIRECTORY = "workflows" +const EXTENSIONS = [".yaml", ".yml"] + +export type Scope = "project" | "global" + +export interface Entry { + readonly name: string + readonly scope: Scope + readonly path: string + /** Workflow title from the spec, when the file declares one. */ + readonly title?: string + /** Node count, for a one-glance sense of the graph's size. */ + readonly nodes?: number +} + +/** A name has no path separators and no YAML extension — those mean a path. */ +export function isName(value: string) { + if (!value) return false + if (value.includes("/") || value.includes("\\")) return false + if (EXTENSIONS.includes(path.extname(value).toLowerCase())) return false + // Control characters are never valid in a filename, and a NUL makes the fs + // calls in resolve() reject — which surfaces as a raw TypeError instead of + // the actionable "not found" message. Let the path branch reject it. + if (/[\u0000-\u001f]/.test(value)) return false + // Leave `.` and `..` to the path branch; they are directories, not names. + return !value.startsWith(".") +} + +/** + * Resolve a workflow name to its file. Returns undefined when no scope holds + * it, so callers can report the searched locations instead of a bare ENOENT. + */ +export function resolve(name: string, projectDir: string): Effect.Effect { + return Effect.promise(async () => { + for (const scope of scopes(projectDir)) { + for (const extension of EXTENSIONS) { + const file = path.join(scope.dir, `${name}${extension}`) + if (!(await Bun.file(file).exists())) continue + return { name, scope: scope.scope, path: file, ...(await describe(file)) } + } + } + return undefined + }) +} + +/** Every directory a named workflow may live in, in resolution order. */ +export function searchPaths(projectDir: string) { + return scopes(projectDir).map((scope) => scope.dir) +} + +/** + * List available workflows. A project entry shadows a global one with the same + * name, matching resolve()'s precedence so the listing never advertises a file + * that resolve() would not pick. + */ +export function list(projectDir: string): Effect.Effect { + return Effect.promise(async () => { + const seen = new Map() + for (const scope of scopes(projectDir)) { + const entries = await fs.readdir(scope.dir, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (!entry.isFile()) continue + const extension = path.extname(entry.name).toLowerCase() + if (!EXTENSIONS.includes(extension)) continue + const name = path.basename(entry.name, extension) + if (seen.has(name)) continue + const file = path.join(scope.dir, entry.name) + seen.set(name, { name, scope: scope.scope, path: file, ...(await describe(file)) }) + } + } + return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name)) + }) +} + +// Resolution order, project first. The global directory applies the same +// OPENCODE_CONFIG_DIR redirect the Global service applies in make(), so managed +// setups pointing the config dir elsewhere are honored here too. +function scopes(projectDir: string) { + return [ + { scope: "project" as const, dir: path.join(projectDir, ".opencode", DIRECTORY) }, + { scope: "global" as const, dir: path.join(Flag.OPENCODE_CONFIG_DIR ?? Global.Path.config, DIRECTORY) }, + ] +} + +/** + * Best-effort listing metadata. A malformed or unreadable spec still lists — + * hiding it would make a typo look like a missing file; the start path reports + * the real parse error. + */ +async function describe(file: string): Promise<{ title?: string; nodes?: number }> { + const parsed = await Bun.file(file) + .text() + .then((text) => Bun.YAML.parse(text)) + .catch(() => undefined) + if (!isRecord(parsed)) return {} + const config = isRecord(parsed["config"]) ? parsed["config"] : undefined + const title = typeof parsed["title"] === "string" ? parsed["title"] : undefined + const name = config && typeof config["name"] === "string" ? config["name"] : undefined + const nodes = config && Array.isArray(config["nodes"]) ? config["nodes"].length : undefined + return { + ...(title ?? name ? { title: title ?? name } : {}), + ...(nodes === undefined ? {} : { nodes }), + } +} diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 64aba839eb..06999e4556 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -42,6 +42,14 @@ const CONFIGURE_HOOKS_SKILL_NAME = "configure-hooks" const CONFIGURE_HOOKS_SKILL_DESCRIPTION = SkillPlugin.ConfigureHooksDescription const CONFIGURE_HOOKS_SKILL_BODY = SkillPlugin.ConfigureHooksContent +// Built-in skill. The workflow tool documents how to design a graph but not +// where a reusable one is stored, so without this skill a well-formed spec ends +// up as a throwaway file the next session cannot find. The description is what +// makes the model realize a workflow can be saved at all. +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 + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -295,6 +303,12 @@ export const layer = Layer.effect( location: "", content: CONFIGURE_HOOKS_SKILL_BODY, } + s.skills[CREATE_DAG_WORKFLOW_SKILL_NAME] = { + name: CREATE_DAG_WORKFLOW_SKILL_NAME, + description: CREATE_DAG_WORKFLOW_SKILL_DESCRIPTION, + location: "", + content: CREATE_DAG_WORKFLOW_SKILL_BODY, + } yield* loadSkills(s, yield* InstanceState.get(discovered), events) return s }), diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index e43fa63a63..7c293f94de 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -3,6 +3,7 @@ import { CommandPlugin } from "@opencode-ai/core/plugin/command" import { Effect, Schema } from "effect" import { Dag } from "@/dag/dag" import { DagConfig } from "@/dag/config" +import { DagWorkflows } from "@/dag/workflows" import { DagModel } from "@/dag/model" import { Agent } from "@/agent/agent" import { Question } from "@/question" @@ -82,7 +83,8 @@ const WorkflowGraphSchema = Schema.Struct({ nodes: Schema.Array(NodeSchema).annotate({ description: "Node declarations" }), }) -const StartSpec = Schema.Struct({ +// 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), @@ -102,8 +104,8 @@ const decodeExtendSpec = Schema.decodeUnknownEffect(ExtendSpec) const decodeReplanSpec = Schema.decodeUnknownEffect(ReplanSpec) export const Parameters = Schema.Struct({ - action: Schema.Literals(["start", "extend", "control", "status"]).annotate({ description: "start: create workflow; extend: add nodes; control: pause/resume/cancel/replan/step/complete; status: inspect durable workflow and node state" }), - spec_path: Schema.optional(Schema.String).annotate({ description: "(start/extend/control replan) Path to a YAML workflow spec. Relative paths resolve from the session directory" }), + action: Schema.Literals(["start", "extend", "control", "status", "list"]).annotate({ description: "start: create workflow; extend: add nodes; control: pause/resume/cancel/replan/step/complete; status: inspect durable workflow and node state; list: show saved workflow specs in the library (not running workflows)" }), + spec_path: Schema.optional(Schema.String).annotate({ description: '(start/extend/control replan) 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' }), session_id: Schema.optional(Schema.String).annotate({ description: "(start) Parent session ID" }), project_id: Schema.optional(Schema.String).annotate({ description: "(start) Optional Project ID; must match the parent session project" }), workflow_id: Schema.optional(Schema.String).annotate({ description: "(extend/control/status) Target workflow ID" }), @@ -134,6 +136,31 @@ export const WorkflowTool = Tool.define< execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { switch (params.action) { + case "list": { + const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) + const entries = yield* DagWorkflows.list(session.directory) + if (entries.length === 0) { + return { + title: "No saved workflows", + output: `The workflow library is empty. Searched ${DagWorkflows.searchPaths(session.directory).join(" and ")}. Save a spec as .yaml in one of those directories to start it later by name.`, + metadata: {}, + } + } + 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)`, + `\n ${entry.path}`, + ].join(""), + ) + .join("\n"), + metadata: {}, + } + } case "status": { if (!params.workflow_id) return yield* Effect.die(new Error("status requires 'workflow_id'")) const workflow = yield* dag.store.getWorkflow(params.workflow_id).pipe(Effect.orDie) @@ -324,18 +351,10 @@ function readWorkflowSpec(specPath: string | undefined, directory: string, ctx: return Effect.gen(function* () { if (!specPath) { return yield* Effect.fail(new Error( - "Workflow configuration requires 'spec_path'. Write the YAML spec to a file, then retry with its path.", + `Workflow configuration requires 'spec_path'. Pass a saved workflow name (workflow(action: "list") shows them) or write the YAML spec to a file and retry with its path.`, )) } - const filepath = path.isAbsolute(specPath) ? path.normalize(specPath) : path.resolve(directory, specPath) - if (![".yaml", ".yml"].includes(path.extname(filepath).toLowerCase())) { - return yield* Effect.fail(new Error(`Workflow spec must be a .yaml or .yml file: ${filepath}`)) - } - if (!FSUtil.contains(directory, filepath)) { - yield* assertExternalDirectoryEffect(ctx, filepath, { - bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), - }) - } + const filepath = yield* resolveSpecPath(specPath, directory, ctx) const file = Bun.file(filepath) if (!(yield* Effect.promise(() => file.exists()))) { @@ -358,6 +377,33 @@ function readWorkflowSpec(specPath: string | undefined, directory: string, ctx: }) } +function resolveSpecPath(specPath: string, directory: string, ctx: Tool.Context) { + return Effect.gen(function* () { + // A bare name addresses the workflow library. Its two scopes are curated + // assets the user placed under `.opencode/` or the config dir — the same + // trust level as dag.jsonc — so a resolved name needs no + // external-directory prompt even when the global scope lands outside the + // session directory. Arbitrary paths below keep the prompt. + if (DagWorkflows.isName(specPath)) { + const entry = yield* DagWorkflows.resolve(specPath, directory) + if (entry) return entry.path + return yield* Effect.fail(new Error( + `Saved workflow not found: "${specPath}". Searched ${DagWorkflows.searchPaths(directory).join(" and ")}. Run workflow(action: "list") to see what is available, or pass a path to a .yaml spec file.`, + )) + } + const filepath = path.isAbsolute(specPath) ? path.normalize(specPath) : path.resolve(directory, specPath) + if (![".yaml", ".yml"].includes(path.extname(filepath).toLowerCase())) { + return yield* Effect.fail(new Error(`Workflow spec must be a .yaml or .yml file: ${filepath}`)) + } + if (!FSUtil.contains(directory, filepath)) { + yield* assertExternalDirectoryEffect(ctx, filepath, { + bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), + }) + } + return filepath + }) +} + function workflowSpecParseError(filepath: string, error: unknown) { return new Error(`Invalid workflow YAML ${filepath}: ${error instanceof Error ? error.message : String(error)}`) } diff --git a/packages/opencode/test/dag/dag-workflows.test.ts b/packages/opencode/test/dag/dag-workflows.test.ts new file mode 100644 index 0000000000..5ba04a45f5 --- /dev/null +++ b/packages/opencode/test/dag/dag-workflows.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { Effect, Schema } from "effect" +import { DagWorkflows } from "@/dag/workflows" +import { StartSpec } from "@/tool/workflow" +import * as os from "node:os" +import * as path from "node:path" +import * as fs from "node:fs/promises" + +let dir: string +let projectDir: string +let globalDir: string +const originalConfigDir = process.env.OPENCODE_CONFIG_DIR + +const spec = (name: string, nodes: number) => + [ + `title: ${name} title`, + "config:", + ` name: ${name}`, + " nodes:", + ...Array.from({ length: nodes }, (_, index) => ` - id: node-${index}`), + ].join("\n") + +const writeProject = (file: string, content: string) => + fs.mkdir(path.join(projectDir, ".opencode", "workflows"), { recursive: true }).then(() => + fs.writeFile(path.join(projectDir, ".opencode", "workflows", file), content), + ) + +const writeGlobal = (file: string, content: string) => + fs + .mkdir(path.join(globalDir, "workflows"), { recursive: true }) + .then(() => fs.writeFile(path.join(globalDir, "workflows", file), content)) + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "dag-workflows-")) + projectDir = path.join(dir, "project") + globalDir = path.join(dir, "global") + await fs.mkdir(projectDir, { recursive: true }) + // Flag.OPENCODE_CONFIG_DIR reads the env — redirect the global scope so the + // real user config dir is never read or written. + process.env.OPENCODE_CONFIG_DIR = globalDir +}) + +afterEach(async () => { + if (originalConfigDir === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = originalConfigDir + await fs.rm(dir, { recursive: true, force: true }) +}) + +describe("DagWorkflows.isName", () => { + it("treats bare identifiers as names", () => { + expect(DagWorkflows.isName("code-review")).toBe(true) + expect(DagWorkflows.isName("review_v2")).toBe(true) + }) + + it("treats anything path-shaped as a path, so existing spec_path calls keep working", () => { + expect(DagWorkflows.isName("review.yaml")).toBe(false) + expect(DagWorkflows.isName("review.YML")).toBe(false) + expect(DagWorkflows.isName(".opencode/workflows/review.yaml")).toBe(false) + expect(DagWorkflows.isName("./review")).toBe(false) + expect(DagWorkflows.isName("../review")).toBe(false) + expect(DagWorkflows.isName("dir\\review")).toBe(false) + expect(DagWorkflows.isName("/abs/review.yaml")).toBe(false) + expect(DagWorkflows.isName("")).toBe(false) + }) + + it("rejects control characters, which would make the filesystem lookup throw", () => { + expect(DagWorkflows.isName("a\u0000b")).toBe(false) + expect(DagWorkflows.isName("a\nb")).toBe(false) + }) +}) + +describe("DagWorkflows.resolve", () => { + it("resolves a project workflow", async () => { + await writeProject("code-review.yaml", spec("code-review", 3)) + const entry = await Effect.runPromise(DagWorkflows.resolve("code-review", projectDir)) + expect(entry?.scope).toBe("project") + expect(entry?.path).toBe(path.join(projectDir, ".opencode", "workflows", "code-review.yaml")) + expect(entry?.title).toBe("code-review title") + expect(entry?.nodes).toBe(3) + }) + + it("resolves a global workflow when the project has none", async () => { + await writeGlobal("research.yaml", spec("research", 1)) + const entry = await Effect.runPromise(DagWorkflows.resolve("research", projectDir)) + expect(entry?.scope).toBe("global") + expect(entry?.path).toBe(path.join(globalDir, "workflows", "research.yaml")) + }) + + it("lets the project scope shadow a global workflow of the same name", async () => { + await writeGlobal("code-review.yaml", spec("global-version", 1)) + await writeProject("code-review.yaml", spec("project-version", 2)) + const entry = await Effect.runPromise(DagWorkflows.resolve("code-review", projectDir)) + expect(entry?.scope).toBe("project") + expect(entry?.title).toBe("project-version title") + }) + + it("accepts the .yml extension", async () => { + await writeProject("short.yml", spec("short", 1)) + const entry = await Effect.runPromise(DagWorkflows.resolve("short", projectDir)) + expect(entry?.path).toEndWith("short.yml") + }) + + it("prefers .yaml over .yml within the same scope", async () => { + await writeProject("both.yml", spec("yml-version", 1)) + await writeProject("both.yaml", spec("yaml-version", 1)) + const entry = await Effect.runPromise(DagWorkflows.resolve("both", projectDir)) + expect(entry?.title).toBe("yaml-version title") + }) + + it("returns undefined for an unknown name so the caller can report the searched paths", async () => { + expect(await Effect.runPromise(DagWorkflows.resolve("missing", projectDir))).toBeUndefined() + expect(DagWorkflows.searchPaths(projectDir)).toEqual([ + path.join(projectDir, ".opencode", "workflows"), + path.join(globalDir, "workflows"), + ]) + }) + + it("still resolves a spec it cannot parse — the start path reports the real error", async () => { + await writeProject("broken.yaml", "config: [unclosed") + const entry = await Effect.runPromise(DagWorkflows.resolve("broken", projectDir)) + expect(entry?.scope).toBe("project") + expect(entry?.title).toBeUndefined() + expect(entry?.nodes).toBeUndefined() + }) +}) + +describe("DagWorkflows.list", () => { + it("returns nothing when neither scope exists", async () => { + expect(await Effect.runPromise(DagWorkflows.list(projectDir))).toEqual([]) + }) + + it("merges both scopes, sorted by name, project shadowing global", async () => { + await writeGlobal("shared.yaml", spec("global-shared", 1)) + await writeGlobal("only-global.yaml", spec("only-global", 4)) + await writeProject("shared.yaml", spec("project-shared", 2)) + await writeProject("a-first.yaml", spec("a-first", 1)) + + const entries = await Effect.runPromise(DagWorkflows.list(projectDir)) + expect(entries.map((entry) => [entry.name, entry.scope])).toEqual([ + ["a-first", "project"], + ["only-global", "global"], + ["shared", "project"], + ]) + expect(entries.find((entry) => entry.name === "shared")?.title).toBe("project-shared title") + expect(entries.find((entry) => entry.name === "only-global")?.nodes).toBe(4) + }) + + it("ignores non-spec files and directories", async () => { + await writeProject("real.yaml", spec("real", 1)) + await writeProject("notes.md", "not a spec") + await fs.mkdir(path.join(projectDir, ".opencode", "workflows", "nested.yaml"), { recursive: true }) + + const entries = await Effect.runPromise(DagWorkflows.list(projectDir)) + expect(entries.map((entry) => entry.name)).toEqual(["real"]) + }) + + it("falls back to config.name when the spec declares no title", async () => { + await writeProject("untitled.yaml", "config:\n name: from-config\n nodes: []") + const entries = await Effect.runPromise(DagWorkflows.list(projectDir)) + expect(entries[0]?.title).toBe("from-config") + expect(entries[0]?.nodes).toBe(0) + }) +}) + +// The README advertises this committed spec as startable by name, so it has to +// survive the same decode a real start performs. Only the shipped example is +// checked — `.opencode/workflows/` is also where a contributor keeps their own +// specs, and those must not fail the suite. +describe("the repository's own workflow library", () => { + const repoRoot = path.resolve(import.meta.dir, "../../../..") + + it("ships change-review as a valid start spec referencing existing prompt templates", async () => { + const entry = await Effect.runPromise(DagWorkflows.resolve("change-review", repoRoot)) + expect(entry?.scope).toBe("project") + + const spec = Schema.decodeUnknownSync(StartSpec)(Bun.YAML.parse(await Bun.file(entry!.path).text())) + const templates = await fs + .readdir(path.join(repoRoot, ".opencode", "dag-prompts")) + .then((files) => files.map((file) => path.basename(file, ".md"))) + + for (const node of spec.config.nodes) { + if (node.prompt_template.id) expect(templates).toContain(node.prompt_template.id) + for (const dependency of node.depends_on) { + expect(spec.config.nodes.map((other) => other.id)).toContain(dependency) + } + } + }) +}) + diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 8b9191a0bc..02f27135c7 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -289,12 +289,14 @@ function writeWorkflowSpec(name: string, value: unknown) { } describe("workflow tool schema (negative tests)", () => { - it("action field accepts start/extend/control/status", () => { + it("action field accepts start/extend/control/status/list", () => { const decode = Schema.decodeUnknownSync(Parameters) expect(() => decode({ action: "start", spec_path: ".opencode/workflows/test.yaml" })).not.toThrow() expect(() => decode({ action: "extend", workflow_id: "wf-1", spec_path: ".opencode/workflows/extend.yaml" })).not.toThrow() expect(() => decode({ action: "control", workflow_id: "wf-1", operation: "pause" })).not.toThrow() expect(() => decode({ action: "status", workflow_id: "wf-1" })).not.toThrow() + // list browses the saved-spec library and needs no workflow_id. + expect(() => decode({ action: "list" })).not.toThrow() }) it("action field rejects unknown actions", () => { @@ -307,9 +309,8 @@ describe("workflow tool schema (negative tests)", () => { expect(() => decode({ action: "node_complete" })).toThrow() }) - it("no unsupported read-only actions exist (list/history/logs)", () => { + it("no unsupported read-only actions exist (history/logs)", () => { const decode = Schema.decodeUnknownSync(Parameters) - expect(() => decode({ action: "list" })).toThrow() expect(() => decode({ action: "history" })).toThrow() expect(() => decode({ action: "logs" })).toThrow() }) @@ -1033,3 +1034,152 @@ config: }), ) }) + +describe("workflow tool saved workflows", () => { + // The global scope reads Flag.OPENCODE_CONFIG_DIR from the env; point it at a + // fresh directory so the real user config dir is never touched. + const withGlobalConfigDir = (self: (globalDir: string) => Effect.Effect) => + Effect.acquireUseRelease( + Effect.promise(async () => { + const globalDir = await fs.mkdtemp(path.join(os.tmpdir(), "workflow-global-")) + const previous = process.env.OPENCODE_CONFIG_DIR + process.env.OPENCODE_CONFIG_DIR = globalDir + return { globalDir, previous } + }), + (state) => self(state.globalDir), + (state) => + Effect.promise(async () => { + if (state.previous === undefined) delete process.env.OPENCODE_CONFIG_DIR + else process.env.OPENCODE_CONFIG_DIR = state.previous + await fs.rm(state.globalDir, { recursive: true, force: true }) + }), + ) + + const savedSpec = (name: string) => + `title: ${name} title\nconfig:\n name: ${name}\n nodes: []\n` + + const contextWith = (asked: unknown[]) => + ({ + sessionID: SessionID.make("ses_workflow_parent"), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: (input: unknown) => + Effect.sync(() => { + asked.push(input) + }), + }) satisfies Tool.Context + + runtime.effect("start resolves a bare name against the project workflow library", () => + withGlobalConfigDir(() => + Effect.gen(function* () { + published.length = 0 + yield* Effect.promise(() => + Bun.write( + path.join(workflowSpecDirectory, ".opencode", "workflows", "saved-project.yaml"), + savedSpec("saved-project"), + ), + ) + const info = yield* WorkflowTool + const workflow = yield* info.init() + const asked: unknown[] = [] + + const result = yield* workflow.execute({ action: "start", spec_path: "saved-project" }, contextWith(asked)) + + expect(result.output).toContain('state="running"') + expect(result.title).toBe("Workflow started: saved-project") + expect(asked).toHaveLength(0) + }), + ), + ) + + runtime.effect("start resolves a global saved workflow without an external-directory prompt", () => + withGlobalConfigDir((globalDir) => + Effect.gen(function* () { + published.length = 0 + yield* Effect.promise(() => + Bun.write(path.join(globalDir, "workflows", "saved-global.yaml"), savedSpec("saved-global")), + ) + const info = yield* WorkflowTool + const workflow = yield* info.init() + const asked: unknown[] = [] + + const result = yield* workflow.execute({ action: "start", spec_path: "saved-global" }, contextWith(asked)) + + expect(result.title).toBe("Workflow started: saved-global") + // The library's two scopes are curated config, so a resolved name never + // asks for external-directory permission. + expect(asked).toHaveLength(0) + }), + ), + ) + + runtime.effect("an unresolved name fails with the directories that were searched", () => + withGlobalConfigDir((globalDir) => + Effect.gen(function* () { + published.length = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + const exit = yield* workflow + .execute({ action: "start", spec_path: "not-saved" }, contextWith([])) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const message = Cause.pretty(exit.cause) + expect(message).toContain('Saved workflow not found: "not-saved"') + expect(message).toContain(path.join(workflowSpecDirectory, ".opencode", "workflows")) + expect(message).toContain(path.join(globalDir, "workflows")) + } + expect(published).toHaveLength(0) + }), + ), + ) + + runtime.effect("list reports both scopes with the project entry shadowing the global one", () => + withGlobalConfigDir((globalDir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Promise.all([ + Bun.write(path.join(globalDir, "workflows", "shared.yaml"), savedSpec("global-shared")), + Bun.write(path.join(globalDir, "workflows", "global-only.yaml"), savedSpec("global-only")), + Bun.write( + path.join(workflowSpecDirectory, ".opencode", "workflows", "shared.yaml"), + savedSpec("project-shared"), + ), + ]), + ) + const info = yield* WorkflowTool + const workflow = yield* info.init() + + const result = yield* workflow.execute({ action: "list" }, contextWith([])) + + expect(result.output).toContain("shared [project] — project-shared title") + expect(result.output).toContain("global-only [global] — global-only title") + expect(result.output).not.toContain("global-shared") + }), + ), + ) + + runtime.effect("list explains where to save a spec when the library is empty", () => + withGlobalConfigDir((globalDir) => + Effect.gen(function* () { + // A previous case may have seeded the project scope — start clean. + yield* Effect.promise(() => + fs.rm(path.join(workflowSpecDirectory, ".opencode", "workflows"), { recursive: true, force: true }), + ) + const info = yield* WorkflowTool + const workflow = yield* info.init() + + const result = yield* workflow.execute({ action: "list" }, contextWith([])) + + expect(result.title).toBe("No saved workflows") + expect(result.output).toContain(path.join(workflowSpecDirectory, ".opencode", "workflows")) + expect(result.output).toContain(path.join(globalDir, "workflows")) + }), + ), + ) +}) + diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index be128cc500..456fd2afcd 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -86,6 +86,7 @@ describe("skill", () => { expect((yield* skill.all()).filter((item) => item.location === "").map((item) => item.name)).toEqual([ "customize-opencode", "configure-hooks", + "create-dag-workflow", ]) }), { git: true },