diff --git a/.opencode/workflows/code-review.yaml b/.opencode/workflows/code-review.yaml new file mode 100644 index 000000000000..ea653d4fdb20 --- /dev/null +++ b/.opencode/workflows/code-review.yaml @@ -0,0 +1,41 @@ +name: code-review +description: Review code changes for quality, security, and best practices + +agent: build +model: opencode/mimo-v2.5-free + +steps: + - id: analyze + name: Analyze changes + prompt: | + Analyze the current code changes (git diff). Identify: + - Files changed + - Lines added/removed + - Key modifications + + - id: quality + name: Quality review + prompt: | + Review the code changes for quality issues: + - Code style consistency + - Error handling + - Performance concerns + - Readability and maintainability + + - id: security + name: Security review + prompt: | + Review the code changes for security issues: + - Input validation + - Authentication/authorization + - Data exposure risks + - Vulnerability patterns + + - id: summary + name: Summary report + prompt: | + Create a summary report of the code review with: + - Overall assessment + - Key findings + - Recommendations + - Priority items to address diff --git a/packages/app/src/custom-elements.d.ts b/packages/app/src/custom-elements.d.ts index e4ea0d6cebda..336ce12bb910 120000 --- a/packages/app/src/custom-elements.d.ts +++ b/packages/app/src/custom-elements.d.ts @@ -1 +1 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +export {} diff --git a/packages/core/src/config/plugin/workflow.ts b/packages/core/src/config/plugin/workflow.ts new file mode 100644 index 000000000000..f0e4bb6ce726 --- /dev/null +++ b/packages/core/src/config/plugin/workflow.ts @@ -0,0 +1,62 @@ +export * as ConfigWorkflowPlugin from "./workflow" + +import { define } from "../../plugin/internal" +import { Effect } from "effect" +import { CommandV2 } from "../../command" +import { FSUtil } from "../../fs-util" +import { Location } from "../../location" +import matter from "gray-matter" +import * as fs from "fs" +import * as path from "path" + +function parseYaml(content: string) { + const result = matter(`---\n${content}\n---`) + return { data: result.data, content: "" } +} + +function loadWorkflows(directory: string): Array<{ name: string; description?: string }> { + const workflows: Array<{ name: string; description?: string }> = [] + const workflowDir = path.join(directory, ".opencode", "workflows") + + if (!fs.existsSync(workflowDir)) return workflows + + try { + const files = fs.readdirSync(workflowDir) + for (const file of files) { + if (!file.endsWith(".yaml") && !file.endsWith(".yml")) continue + const filepath = path.join(workflowDir, file) + try { + const content = fs.readFileSync(filepath, "utf-8") + const parsed = parseYaml(content) + const name = file.replace(/\.(yaml|yml)$/, "") + const data = parsed.data as Record + workflows.push({ + name, + description: (data.description as string) ?? `Run ${name} workflow`, + }) + } catch { + // Skip invalid YAML files + } + } + } catch { + // Skip if directory read fails + } + + return workflows +} + +export const Plugin = define({ + id: "config-workflow", + effect: Effect.fn(function* (ctx) { + const location = yield* Location.Service + yield* ctx.command.transform((draft) => { + const workflows = loadWorkflows(location.directory) + for (const workflow of workflows) { + draft.update(`workflow/${workflow.name}`, (command) => { + command.template = `[Workflow: ${workflow.name}] ${workflow.description}` + command.description = workflow.description + }) + } + }) + }), +}) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index d4ab71cb6b04..9e5432697633 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -14,6 +14,7 @@ import { ConfigExternalPlugin } from "../config/plugin/external" import { ConfigProviderPlugin } from "../config/plugin/provider" import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigSkillPlugin } from "../config/plugin/skill" +import { ConfigWorkflowPlugin } from "../config/plugin/workflow" import { EventV2 } from "../event" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" @@ -115,6 +116,7 @@ const layer = Layer.effectDiscard( yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin) + yield* add(ConfigWorkflowPlugin.Plugin) for (const item of ProviderPlugins) yield* add(item) yield* add(ConfigExternalPlugin.Plugin) yield* add(ConfigProviderPlugin.Plugin) diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 691f55150aed..3a51cf5cad4d 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -7,6 +7,7 @@ import { ConfigReference } from "../../config/reference" import { ConfigAgentV1 } from "./agent" import { ConfigAttachmentV1 } from "./attachment" import { ConfigCommandV1 } from "./command" +import { ConfigWorkflowV1 } from "./workflow" import { ConfigFormatterV1 } from "./formatter" import { ConfigLayoutV1 } from "./layout" import { ConfigLSPV1 } from "./lsp" @@ -41,6 +42,9 @@ export const Info = Schema.Struct({ command: Schema.optional(Schema.Record(Schema.String, ConfigCommandV1.Info)).annotate({ description: "Command configuration, see https://opencode.ai/docs/commands", }), + workflow: Schema.optional(Schema.Record(Schema.String, ConfigWorkflowV1.Info)).annotate({ + description: "Workflow definitions for multi-step pipelines", + }), skills: Schema.optional(ConfigSkillsV1.Info).annotate({ description: "Additional skill folder paths" }), references: Schema.optional(ConfigReference.Info).annotate({ description: "Named git or local directory references", diff --git a/packages/core/src/v1/config/workflow.ts b/packages/core/src/v1/config/workflow.ts new file mode 100644 index 000000000000..085bae5b7e02 --- /dev/null +++ b/packages/core/src/v1/config/workflow.ts @@ -0,0 +1,30 @@ +export * as ConfigWorkflowV1 from "./workflow" + +import { Schema } from "effect" + +const StepOutput = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), +}) + +const WorkflowStep = Schema.Struct({ + id: Schema.String, + prompt: Schema.String, + depends_on: Schema.optional(Schema.Union([Schema.String, Schema.mutable(Schema.Array(Schema.String))])), + when: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + outputs: Schema.optional(Schema.mutable(Schema.Array(StepOutput))), +}) + +export const Info = Schema.Struct({ + name: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + steps: Schema.mutable(Schema.Array(WorkflowStep)), +}) +export type Info = Schema.Schema.Type + +export type Step = Schema.Schema.Type +export type StepOutput = Schema.Schema.Type diff --git a/packages/enterprise/src/custom-elements.d.ts b/packages/enterprise/src/custom-elements.d.ts index e4ea0d6cebda..336ce12bb910 120000 --- a/packages/enterprise/src/custom-elements.d.ts +++ b/packages/enterprise/src/custom-elements.d.ts @@ -1 +1 @@ -../../ui/src/custom-elements.d.ts \ No newline at end of file +export {} diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 057754cd9ef8..d6dc2cc108de 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -7,6 +7,8 @@ import { Effect, Layer, Context, Schema } from "effect" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" +import { Workflow } from "../workflow" +import { buildWorkflowPrompt, toWorkflowDefinition } from "../workflow/runner" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" import { LegacyEvent } from "@opencode-ai/schema/legacy-event" @@ -31,7 +33,10 @@ export const Info = Schema.Struct({ hints: Schema.Array(Schema.String), }).annotate({ identifier: "Command" }) -export type Info = Omit, "template"> & { template: Promise | string } +export type Info = Omit, "template"> & { + template: Promise | string + resolveTemplate?: (args: string) => Promise | string +} export function hints(template: string) { const result: string[] = [] @@ -61,102 +66,173 @@ const layer = Layer.effect( const config = yield* Config.Service const mcp = yield* MCP.Service const skill = yield* Skill.Service + const workflowService = yield* Workflow.Service + + const state = yield* InstanceState.make( + Effect.fn("Command.state")(function* (ctx: InstanceContext) { + const cfg = yield* config.get() + const bridge = yield* EffectBridge.make() + const commands: Record = {} - const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { - const cfg = yield* config.get() - const bridge = yield* EffectBridge.make() - const commands: Record = {} - - commands[Default.INIT] = { - name: Default.INIT, - description: "guided AGENTS.md setup", - source: "command", - get template() { - return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) - }, - hints: hints(PROMPT_INITIALIZE), - } - commands[Default.REVIEW] = { - name: Default.REVIEW, - description: "review changes [commit|branch|pr], defaults to uncommitted", - source: "command", - get template() { - return PROMPT_REVIEW.replace("${path}", ctx.worktree) - }, - subtask: true, - hints: hints(PROMPT_REVIEW), - } - - for (const [name, command] of Object.entries(cfg.command ?? {})) { - commands[name] = { - name, - agent: command.agent, - model: command.model, - description: command.description, + commands[Default.INIT] = { + name: Default.INIT, + description: "guided AGENTS.md setup", source: "command", get template() { - return command.template + return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) }, - subtask: command.subtask, - hints: hints(command.template), + hints: hints(PROMPT_INITIALIZE), } - } - - for (const [name, prompt] of Object.entries(yield* mcp.prompts())) { - commands[name] = { - name, - source: "mcp", - description: prompt.description, + commands[Default.REVIEW] = { + name: Default.REVIEW, + description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", get template() { - return bridge.promise( - mcp - .getPrompt( - prompt.client, - prompt.name, - prompt.arguments - ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`])) - : {}, - ) - .pipe( - Effect.map( - (template) => - template?.messages - .map((message) => (message.content.type === "text" ? message.content.text : "")) - .join("\n") || "", - ), - ), - ) + return PROMPT_REVIEW.replace("${path}", ctx.worktree) }, - hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + subtask: true, + hints: hints(PROMPT_REVIEW), } - } - - for (const item of yield* skill.all()) { - if (commands[item.name]) continue - const dir = item.location === "" ? undefined : path.dirname(item.location) - commands[item.name] = { - name: item.name, - description: item.description, - source: "skill", - get template() { - if (!dir) return item.content - return [ - item.content, - "", - `Base directory for this skill: ${dir}`, - "Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.", - ].join("\n") + + for (const [name, command] of Object.entries(cfg.command ?? {})) { + commands[name] = { + name, + agent: command.agent, + model: command.model, + description: command.description, + source: "command", + get template() { + return command.template + }, + subtask: command.subtask, + hints: hints(command.template), + } + } + + for (const [name, prompt] of Object.entries(yield* mcp.prompts())) { + commands[name] = { + name, + source: "mcp", + description: prompt.description, + get template() { + return bridge.promise( + mcp + .getPrompt( + prompt.client, + prompt.name, + prompt.arguments + ? Object.fromEntries(prompt.arguments.map((argument, i) => [argument.name, `$${i + 1}`])) + : {}, + ) + .pipe( + Effect.map( + (template) => + template?.messages + .map((message) => (message.content.type === "text" ? message.content.text : "")) + .join("\n") || "", + ), + ), + ) + }, + hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], + } + } + + for (const item of yield* skill.all()) { + if (commands[item.name]) continue + const dir = item.location === "" ? undefined : path.dirname(item.location) + commands[item.name] = { + name: item.name, + description: item.description, + source: "skill", + get template() { + if (!dir) return item.content + return [ + item.content, + "", + `Base directory for this skill: ${dir}`, + "Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.", + ].join("\n") + }, + hints: [], + } + } + + const workflowList = yield* workflowService.list() + const workflowMap = new Map(workflowList.map((w) => [w.name, w])) + + commands["workflow"] = { + name: "workflow", + description: "run a workflow pipeline", + source: "command", + template: "", + resolveTemplate: async (args: string) => { + const workflowName = args.trim().split(/\s+/)[0] + if (!workflowName) { + if (workflowList.length === 0) { + return "No workflows found. Create workflow files in .opencode/workflows/ directory." + } + return [ + "Available workflows:", + ...workflowList.map((w) => ` - ${w.name}: ${w.description ?? "No description"}`), + "", + "Usage: /workflow ", + ].join("\n") + } + const workflow = workflowMap.get(workflowName) + if (!workflow) { + return `Workflow not found: "${workflowName}". Available: ${workflowList.map((w) => w.name).join(", ")}` + } + const definition = toWorkflowDefinition(workflowName, { + name: workflow.name, + description: workflow.description, + agent: workflow.agent, + model: workflow.model, + steps: workflow.steps.map((s) => ({ + ...s, + depends_on: s.depends_on + ? [...(Array.isArray(s.depends_on) ? s.depends_on : [s.depends_on])] + : undefined, + outputs: s.outputs?.map((o) => ({ ...o })), + })), + }) + const inputArgs = args.trim().slice(workflowName.length).trim() + const { prompt } = buildWorkflowPrompt(definition, inputArgs) + return prompt }, - hints: [], + hints: [""], } - } - return { - commands, - } - }) + for (const workflow of workflowList) { + commands[`wf-${workflow.name}`] = { + name: `wf-${workflow.name}`, + description: workflow.description ?? `Run ${workflow.name} workflow`, + source: "command", + template: "", + resolveTemplate: async () => { + const definition = toWorkflowDefinition(workflow.name, { + name: workflow.name, + description: workflow.description, + agent: workflow.agent, + model: workflow.model, + steps: workflow.steps.map((s) => ({ + ...s, + depends_on: s.depends_on + ? [...(Array.isArray(s.depends_on) ? s.depends_on : [s.depends_on])] + : undefined, + outputs: s.outputs?.map((o) => ({ ...o })), + })), + }) + const { prompt } = buildWorkflowPrompt(definition, "") + return prompt + }, + hints: [], + } + } - const state = yield* InstanceState.make((ctx) => init(ctx)) + return { commands } + }), + ) const get = Effect.fn("Command.get")(function* (name: string) { const s = yield* InstanceState.get(state) @@ -172,6 +248,6 @@ const layer = Layer.effect( }), ) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node, MCP.node, Skill.node] }) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node, MCP.node, Skill.node, Workflow.node] }) export * as Command from "." diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 86238f1a844c..b44d2de49073 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -28,6 +28,7 @@ import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" +import { ConfigWorkflow } from "./workflow" import { ConfigManaged } from "./managed" import { ConfigParse } from "./parse" import { ConfigPaths } from "./paths" @@ -457,6 +458,7 @@ const layer = Layer.effect( deps.push(dep) result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir))) + result.workflow = mergeDeep(result.workflow ?? {}, yield* Effect.promise(() => ConfigWorkflow.load(dir))) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir))) result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir))) // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load diff --git a/packages/opencode/src/config/workflow.ts b/packages/opencode/src/config/workflow.ts new file mode 100644 index 000000000000..3a5ea8cfd56d --- /dev/null +++ b/packages/opencode/src/config/workflow.ts @@ -0,0 +1,50 @@ +export * as ConfigWorkflow from "./workflow" + +import path from "path" +import { Cause, Exit, Schema } from "effect" +import { Glob } from "@opencode-ai/core/util/glob" +import { ConfigWorkflowV1 } from "@opencode-ai/core/v1/config/workflow" +import { configEntryNameFromPath } from "./entry-name" +import { InvalidError } from "@opencode-ai/core/v1/config/error" +import matter from "gray-matter" + +const decodeInfo = Schema.decodeUnknownExit(ConfigWorkflowV1.Info) + +function parseYaml(content: string) { + const result = matter(`---\n${content}\n---`) + return { data: result.data, content: "" } +} + +export async function load(dir: string) { + const result: Record = {} + for (const item of await Glob.scan("{workflow,workflows}/**/*.{yaml,yml}", { + cwd: dir, + absolute: true, + dot: true, + symlink: true, + })) { + const raw = await Bun.file(item).text().catch(() => undefined) + if (!raw) continue + + const name = configEntryNameFromPath(path.relative(dir, item), ["workflow/", "workflows/"]) + + let data: Record + try { + const parsed = parseYaml(raw) + data = { name, ...parsed.data } + } catch (err) { + throw new InvalidError( + { path: item, message: `Failed to parse YAML: ${err instanceof Error ? err.message : String(err)}` }, + { cause: err }, + ) + } + + const parsed = decodeInfo(data, { errors: "all", propertyOrder: "original" }) + if (Exit.isSuccess(parsed)) { + result[name] = parsed.value + continue + } + throw new InvalidError({ path: item, message: Cause.pretty(parsed.cause) }, { cause: Cause.squash(parsed.cause) }) + } + return result +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b960f..dcbfbd30da98 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1371,7 +1371,9 @@ const layer = Layer.effect( const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) - const templateCommand = yield* Effect.promise(async () => cmd.template) + const templateCommand = yield* Effect.promise(async () => + cmd.resolveTemplate ? cmd.resolveTemplate(input.arguments) : cmd.template, + ) const placeholders = templateCommand.match(placeholderRegex) ?? [] let last = 0 diff --git a/packages/opencode/src/workflow/index.ts b/packages/opencode/src/workflow/index.ts new file mode 100644 index 000000000000..2f9dc3fd535d --- /dev/null +++ b/packages/opencode/src/workflow/index.ts @@ -0,0 +1,82 @@ +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Layer, Context, Schema } from "effect" +import { InstanceState } from "@/effect/instance-state" +import { Config } from "@/config/config" +import type { ConfigWorkflowV1 } from "@opencode-ai/core/v1/config/workflow" + +export const Info = Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + steps: Schema.Array( + Schema.Struct({ + id: Schema.String, + prompt: Schema.String, + depends_on: Schema.optional(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + when: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + outputs: Schema.optional( + Schema.Array( + Schema.Struct({ + name: Schema.String, + description: Schema.optional(Schema.String), + }), + ), + ), + }), + ), +}) +export type Info = Schema.Schema.Type + +export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Workflow") {} + +function toInfo(name: string, config: ConfigWorkflowV1.Info): Info { + return { + name: config.name ?? name, + description: config.description, + agent: config.agent, + model: config.model, + steps: config.steps, + } +} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + + const init = Effect.fn("Workflow.state")(function* () { + const cfg = yield* config.get() + const workflows: Record = {} + for (const [name, workflow] of Object.entries(cfg.workflow ?? {})) { + workflows[name] = toInfo(name, workflow) + } + return { workflows } + }) + + const state = yield* InstanceState.make(() => init()) + + const get = Effect.fn("Workflow.get")(function* (name: string) { + const s = yield* InstanceState.get(state) + return s.workflows[name] + }) + + const list = Effect.fn("Workflow.list")(function* () { + const s = yield* InstanceState.get(state) + return Object.values(s.workflows) + }) + + return Service.of({ get, list }) + }), +) + +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node] }) + +export * as Workflow from "." diff --git a/packages/opencode/src/workflow/runner.ts b/packages/opencode/src/workflow/runner.ts new file mode 100644 index 000000000000..770f62de1bfd --- /dev/null +++ b/packages/opencode/src/workflow/runner.ts @@ -0,0 +1,159 @@ +import type { ConfigWorkflowV1 } from "@opencode-ai/core/v1/config/workflow" + +export interface WorkflowStep { + readonly id: string + readonly prompt: string + readonly depends_on?: string | string[] + readonly when?: string + readonly agent?: string + readonly model?: string + readonly outputs?: Array<{ name: string; description?: string }> +} + +export interface WorkflowDefinition { + readonly name: string + readonly description?: string + readonly agent?: string + readonly model?: string + readonly steps: WorkflowStep[] +} + +export interface StepResult { + readonly id: string + readonly status: "success" | "error" | "skipped" + readonly output?: string +} + +export function resolveDependencies(steps: WorkflowStep[]): WorkflowStep[][] { + const graph = new Map }>() + for (const step of steps) { + const deps = new Set() + if (step.depends_on) { + const depList = Array.isArray(step.depends_on) ? step.depends_on : [step.depends_on] + for (const dep of depList) deps.add(dep) + } + graph.set(step.id, { step, deps }) + } + + const layers: WorkflowStep[][] = [] + const resolved = new Set() + + while (resolved.size < steps.length) { + const layer: WorkflowStep[] = [] + for (const [id, { step, deps }] of graph) { + if (resolved.has(id)) continue + const allDepsResolved = [...deps].every((d) => resolved.has(d)) + if (allDepsResolved) layer.push(step) + } + if (layer.length === 0) { + const remaining = steps.filter((s) => !resolved.has(s.id)) + throw new Error(`Circular dependency detected in workflow steps: ${remaining.map((s) => s.id).join(", ")}`) + } + layers.push(layer) + for (const step of layer) resolved.add(step.id) + } + + return layers +} + +function evaluateCondition(when: string, results: Map): boolean { + const match = when.match(/^\$\{\{steps\.(\w+)\.output\s+contains\s+"([^"]+)"\}\}$/) + if (match) { + const [, stepId, expected] = match + const result = results.get(stepId) + if (!result || result.status !== "success" || !result.output) return false + return result.output.includes(expected) + } + + const statusMatch = when.match(/^\$\{\{steps\.(\w+)\.status\}\}\s*==\s*"(\w+)"$/) + if (statusMatch) { + const [, stepId, expectedStatus] = statusMatch + const result = results.get(stepId) + if (!result) return false + return result.status === expectedStatus + } + + return when.trim() !== "" +} + +function resolveOutputReferences(prompt: string, results: Map): string { + return prompt.replace(/\$\{\{steps\.(\w+)\.output\}\}/g, (_, stepId) => { + const result = results.get(stepId) + if (!result || !result.output) return `[Output from step "${stepId}" not available]` + return result.output + }) +} + +export function buildWorkflowPrompt( + workflow: WorkflowDefinition, + inputArguments: string, +): { prompt: string; agent?: string; model?: string } { + const layers = resolveDependencies(workflow.steps) + const results = new Map() + + const sections: string[] = [] + + sections.push(`# Workflow: ${workflow.name}`) + if (workflow.description) { + sections.push(`\n${workflow.description}`) + } + if (inputArguments.trim()) { + sections.push(`\nUser input: ${inputArguments}`) + } + + sections.push("\n## Execution Plan") + sections.push("\nExecute each step in order. For each step:") + sections.push("1. Perform the requested action") + sections.push("2. Report the result clearly (success/error and output)") + sections.push("3. Continue to the next step") + + let stepNumber = 0 + for (const layer of layers) { + for (const step of layer) { + stepNumber++ + const resolvedPrompt = resolveOutputReferences(step.prompt, results) + sections.push(`\n### Step ${stepNumber}: ${step.id}`) + if (step.when) { + const conditionMet = evaluateCondition(step.when, results) + if (!conditionMet) { + sections.push(`\n*Skipped (condition not met: ${step.when})*`) + results.set(step.id, { id: step.id, status: "skipped" }) + continue + } + } + sections.push(`\n${resolvedPrompt}`) + sections.push(`\n--- Report result as: [Step ${step.id}: SUCCESS/ERROR] with brief output ---`) + results.set(step.id, { id: step.id, status: "success", output: "" }) + } + } + + sections.push("\n## Summary") + sections.push("\nAfter completing all steps, provide a summary of:") + sections.push("- Which steps succeeded/failed") + sections.push("- Key outputs from each step") + sections.push("- Any issues encountered") + + return { + prompt: sections.join("\n"), + agent: workflow.agent, + model: workflow.model, + } +} + +export function toWorkflowDefinition(name: string, config: ConfigWorkflowV1.Info): WorkflowDefinition { + return { + name: config.name ?? name, + description: config.description, + agent: config.agent, + model: config.model, + steps: config.steps.map((step) => ({ + id: step.id, + prompt: step.prompt, + depends_on: step.depends_on ? (Array.isArray(step.depends_on) ? [...step.depends_on] : step.depends_on) : undefined, + when: step.when, + agent: step.agent, + model: step.model, + outputs: step.outputs?.map((o) => ({ ...o })), + })), + } +}