From 9fde97835b27ef932a6971de66a88899c77faff8 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Wed, 10 Jun 2026 21:36:00 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(workflows):=20add=20LLM=20workflow=20t?= =?UTF-8?q?ool=20=E2=80=94=20saved=20multi-step=20agent=20pipelines=20with?= =?UTF-8?q?=20compiled=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Workflows feature to OpenWork: repeatable, multi-step agent runs stored inside the workspace. Server (openwork-server): - New workflows module: workflow definitions as JSON under .opencode/openwork/workflows/.json (git-versioned with the project), run records under .opencode/openwork/workflows/runs/. - Workflows declare input files (context), ordered prompt steps, and a run-scoped output dir in the workspace outbox so results surface as shareable artifacts via the existing artifacts API. - Runs compile a workflow into a single agent prompt (inputs, steps, output contract incl. run-summary.md). - REST: GET/POST/DELETE /workspace/:id/workflows[/:slug], GET/POST /workspace/:id/workflows/:slug/runs, PATCH .../runs/:runId, with write-scope checks, approvals, and audit entries. - workflows capability advertised in /capabilities. - bun tests for CRUD, validation, prompt compilation, run lifecycle. App (renderer): - New Workflows workspace settings tab (sidebar group), with list, create/edit dialog (inputs + reorderable steps), run history dialog, and delete confirm. - Running a workflow creates a run record, opens a new agent session seeded with the compiled prompt, links the session to the run, and navigates to it; run history deep-links back to sessions. - Typed OpenWork server client methods for all new endpoints. --- apps/app/src/app/lib/openwork-server.ts | 88 +++ apps/app/src/app/types.ts | 1 + .../domains/settings/shell/settings-page.tsx | 9 +- .../domains/workflows/workflows-view.tsx | 690 ++++++++++++++++++ .../src/react-app/shell/settings-route.tsx | 38 +- apps/server/src/server.ts | 125 ++++ apps/server/src/types.ts | 1 + apps/server/src/workflows.test.ts | 145 ++++ apps/server/src/workflows.ts | 404 ++++++++++ apps/server/src/workspace-files.ts | 8 + 10 files changed, 1506 insertions(+), 3 deletions(-) create mode 100644 apps/app/src/react-app/domains/workflows/workflows-view.tsx create mode 100644 apps/server/src/workflows.test.ts create mode 100644 apps/server/src/workflows.ts diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index bf8a3ade45..d78857eb9e 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -17,6 +17,7 @@ export type OpenworkServerCapabilities = { plugins: { read: boolean; write: boolean }; mcp: { read: boolean; write: boolean }; commands: { read: boolean; write: boolean }; + workflows?: { read: boolean; write: boolean }; config: { read: boolean; write: boolean }; sandbox?: { enabled: boolean; backend: "none" | "docker" | "container" }; proxy?: { opencode: boolean }; @@ -444,6 +445,45 @@ export type OpenworkInboxUploadResult = { bytes: number; }; +export type OpenworkWorkflowStep = { + name: string; + prompt: string; + agent?: string; + model?: string; +}; + +export type OpenworkWorkflowItem = { + slug: string; + name: string; + description?: string; + inputs: string[]; + steps: OpenworkWorkflowStep[]; + outputDir: string; + createdAt: number; + updatedAt: number; +}; + +export type OpenworkWorkflowRunStatus = "pending" | "running" | "completed" | "failed"; + +export type OpenworkWorkflowRun = { + id: string; + workflowSlug: string; + workflowName: string; + status: OpenworkWorkflowRunStatus; + sessionId?: string; + outputDir: string; + createdAt: number; + updatedAt: number; +}; + +export type OpenworkWorkflowUpsertPayload = { + name: string; + slug?: string; + description?: string; + inputs?: string[]; + steps: Array<{ name?: string; prompt: string; agent?: string; model?: string }>; +}; + export type OpenworkUserEnvItem = { key: string; updatedAt: number; @@ -1430,6 +1470,54 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s hostToken, method: "DELETE", }), + + listWorkflows: (workspaceId: string) => + requestJson<{ items: OpenworkWorkflowItem[] }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/workflows`, + { token, hostToken }, + ), + getWorkflow: (workspaceId: string, slug: string) => + requestJson<{ item: OpenworkWorkflowItem }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/workflows/${encodeURIComponent(slug)}`, + { token, hostToken }, + ), + upsertWorkflow: (workspaceId: string, payload: OpenworkWorkflowUpsertPayload) => + requestJson<{ item: OpenworkWorkflowItem; items: OpenworkWorkflowItem[] }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/workflows`, + { token, hostToken, method: "POST", body: payload }, + ), + deleteWorkflow: (workspaceId: string, slug: string) => + requestJson<{ ok: boolean }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/workflows/${encodeURIComponent(slug)}`, + { token, hostToken, method: "DELETE" }, + ), + listWorkflowRuns: (workspaceId: string, slug: string) => + requestJson<{ items: OpenworkWorkflowRun[] }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/workflows/${encodeURIComponent(slug)}/runs`, + { token, hostToken }, + ), + createWorkflowRun: (workspaceId: string, slug: string) => + requestJson<{ run: OpenworkWorkflowRun; prompt: string }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/workflows/${encodeURIComponent(slug)}/runs`, + { token, hostToken, method: "POST", body: {} }, + ), + updateWorkflowRun: ( + workspaceId: string, + slug: string, + runId: string, + payload: { status?: OpenworkWorkflowRunStatus; sessionId?: string }, + ) => + requestJson<{ run: OpenworkWorkflowRun }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/workflows/${encodeURIComponent(slug)}/runs/${encodeURIComponent(runId)}`, + { token, hostToken, method: "PATCH", body: payload }, + ), uploadInbox: async (workspaceId: string, file: File, options?: { path?: string }) => { const id = workspaceId.trim(); if (!id) throw new Error("workspaceId is required"); diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts index 497b9a9257..707a353b19 100644 --- a/apps/app/src/app/types.ts +++ b/apps/app/src/app/types.ts @@ -187,6 +187,7 @@ export const SETTINGS_TAB_VALUES = [ "cloud-workers", "cloud-providers", "skills", + "workflows", "extensions", "environment", "advanced", diff --git a/apps/app/src/react-app/domains/settings/shell/settings-page.tsx b/apps/app/src/react-app/domains/settings/shell/settings-page.tsx index 3ce31a8452..afad5f16f1 100644 --- a/apps/app/src/react-app/domains/settings/shell/settings-page.tsx +++ b/apps/app/src/react-app/domains/settings/shell/settings-page.tsx @@ -18,6 +18,7 @@ import { Store, Terminal, UserCircle, + Workflow, Wrench, Zap, } from "lucide-react"; @@ -75,6 +76,8 @@ export function getSettingsTabIcon(tab: SettingsTab) { return CloudCog; case "skills": return Sparkles; + case "workflows": + return Workflow; case "extensions": return Puzzle; case "environment": @@ -114,6 +117,8 @@ export function getSettingsTabLabel(tab: SettingsTab) { return t("settings.tab_cloud_providers"); case "skills": return t("settings.tab_skills"); + case "workflows": + return "Workflows"; case "extensions": return t("settings.tab_extensions"); case "environment": @@ -155,6 +160,8 @@ export function getSettingsTabDescription(tab: SettingsTab) { return t("settings.tab_description_cloud_providers"); case "skills": return t("settings.tab_description_skills"); + case "workflows": + return "Repeatable multi-step agent runs with saved prompts and outputs"; case "extensions": return t("settings.tab_description_extensions"); case "environment": @@ -177,7 +184,7 @@ export function getSettingsTabDescription(tab: SettingsTab) { } export function getWorkspaceSettingsTabs(): SettingsTab[] { - return ["preferences", "permissions", "extensions", "advanced"]; + return ["preferences", "permissions", "workflows", "extensions", "advanced"]; } export function getGlobalSettingsTabs(developerMode: boolean): SettingsTab[] { diff --git a/apps/app/src/react-app/domains/workflows/workflows-view.tsx b/apps/app/src/react-app/domains/workflows/workflows-view.tsx new file mode 100644 index 0000000000..22199f074f --- /dev/null +++ b/apps/app/src/react-app/domains/workflows/workflows-view.tsx @@ -0,0 +1,690 @@ +/** @jsxImportSource react */ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowDown, + ArrowUp, + Edit2, + FileText, + History, + ListChecks, + Loader2, + Play, + Plus, + RefreshCw, + Trash2, + Workflow as WorkflowIcon, +} from "lucide-react"; +import { toast } from "@/components/ui/sonner"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ConfirmModal } from "@/react-app/design-system/modals/confirm-modal"; +import { + pillGhostClass, + pillPrimaryClass, + pillSecondaryClass, + tagClass, +} from "@/react-app/domains/workspace/modal-styles"; +import type { + OpenworkServerClient, + OpenworkWorkflowItem, + OpenworkWorkflowRun, + OpenworkWorkflowRunStatus, +} from "@/app/lib/openwork-server"; + +const pageTitleClass = "text-[28px] font-semibold tracking-[-0.5px] text-dls-text"; +const panelCardClass = + "rounded-[20px] border border-dls-border bg-dls-surface p-5 transition-all hover:border-dls-border hover:shadow-[0_2px_12px_-4px_rgba(0,0,0,0.06)]"; +const fieldLabelClass = "text-[12px] font-medium text-dls-secondary"; +const textInputClass = + "w-full rounded-xl border border-dls-border bg-dls-surface px-3 py-2 text-[13px] text-dls-text focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.25)]"; +const textAreaClass = `${textInputClass} min-h-[88px] font-mono text-[12px]`; + +export type WorkflowsViewProps = { + client: OpenworkServerClient | null; + workspaceId: string | null; + busy: boolean; + canWrite: boolean; + /** + * Create an agent session seeded with the compiled run prompt. + * Returns the new session id, or null when launching failed. + */ + onLaunchRun: (input: { prompt: string; title: string }) => Promise; + /** Navigate to an existing session (used from run history and after launch). */ + onOpenSession: (sessionId: string) => void; +}; + +type EditorStep = { + name: string; + prompt: string; +}; + +type EditorState = { + slug: string | null; + name: string; + description: string; + inputsText: string; + steps: EditorStep[]; +}; + +const emptyEditorState: EditorState = { + slug: null, + name: "", + description: "", + inputsText: "", + steps: [{ name: "", prompt: "" }], +}; + +function editorStateFromWorkflow(workflow: OpenworkWorkflowItem): EditorState { + return { + slug: workflow.slug, + name: workflow.name, + description: workflow.description ?? "", + inputsText: workflow.inputs.join("\n"), + steps: workflow.steps.map((step) => ({ name: step.name, prompt: step.prompt })), + }; +} + +function workflowsQueryKey(workspaceId: string | null) { + return ["openwork", "workflows", workspaceId]; +} + +function workflowRunsQueryKey(workspaceId: string | null, slug: string | null) { + return ["openwork", "workflow-runs", workspaceId, slug]; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : "Something went wrong"; +} + +function formatTimestamp(value: number): string { + if (!value) return ""; + try { + return new Date(value).toLocaleString(); + } catch { + return ""; + } +} + +function runStatusBadge(status: OpenworkWorkflowRunStatus) { + switch (status) { + case "running": + return Running; + case "completed": + return Completed; + case "failed": + return Failed; + default: + return Pending; + } +} + +export function WorkflowsView(props: WorkflowsViewProps) { + const { client, workspaceId } = props; + const queryClient = useQueryClient(); + + const [editor, setEditor] = useState(null); + const [editorError, setEditorError] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [runsTarget, setRunsTarget] = useState(null); + const [launchingSlug, setLaunchingSlug] = useState(null); + + const ready = Boolean(client && workspaceId); + + const workflowsQuery = useQuery({ + queryKey: workflowsQueryKey(workspaceId), + queryFn: async () => { + if (!client || !workspaceId) return { items: [] }; + return client.listWorkflows(workspaceId); + }, + enabled: ready, + refetchOnWindowFocus: false, + }); + + const runsQuery = useQuery({ + queryKey: workflowRunsQueryKey(workspaceId, runsTarget?.slug ?? null), + queryFn: async () => { + if (!client || !workspaceId || !runsTarget) return { items: [] }; + return client.listWorkflowRuns(workspaceId, runsTarget.slug); + }, + enabled: ready && Boolean(runsTarget), + refetchOnWindowFocus: false, + }); + + const invalidateWorkflows = () => + queryClient.invalidateQueries({ queryKey: workflowsQueryKey(workspaceId) }); + + const saveMutation = useMutation({ + mutationFn: async (state: EditorState) => { + if (!client || !workspaceId) throw new Error("OpenWork server is not connected."); + const inputs = state.inputsText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const steps = state.steps + .map((step) => ({ name: step.name.trim() || undefined, prompt: step.prompt.trim() })) + .filter((step) => step.prompt.length > 0); + if (!state.name.trim()) throw new Error("Workflow name is required."); + if (steps.length === 0) throw new Error("Add at least one step with a prompt."); + return client.upsertWorkflow(workspaceId, { + name: state.name.trim(), + slug: state.slug ?? undefined, + description: state.description.trim() || undefined, + inputs, + steps, + }); + }, + onSuccess: () => { + setEditor(null); + setEditorError(null); + toast.success("Workflow saved"); + void invalidateWorkflows(); + }, + onError: (error) => { + setEditorError(describeError(error)); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (slug: string) => { + if (!client || !workspaceId) throw new Error("OpenWork server is not connected."); + return client.deleteWorkflow(workspaceId, slug); + }, + onSuccess: () => { + toast.success("Workflow deleted"); + void invalidateWorkflows(); + }, + onError: (error) => { + toast.error(describeError(error)); + }, + }); + + const runWorkflow = async (workflow: OpenworkWorkflowItem) => { + if (!client || !workspaceId || launchingSlug) return; + setLaunchingSlug(workflow.slug); + try { + const { run, prompt } = await client.createWorkflowRun(workspaceId, workflow.slug); + const sessionId = await props.onLaunchRun({ + prompt, + title: `Workflow: ${workflow.name}`, + }); + if (!sessionId) { + await client + .updateWorkflowRun(workspaceId, workflow.slug, run.id, { status: "failed" }) + .catch(() => undefined); + toast.error("Could not start an agent session for this run."); + return; + } + await client + .updateWorkflowRun(workspaceId, workflow.slug, run.id, { status: "running", sessionId }) + .catch(() => undefined); + void queryClient.invalidateQueries({ + queryKey: workflowRunsQueryKey(workspaceId, workflow.slug), + }); + props.onOpenSession(sessionId); + } catch (error) { + toast.error(describeError(error)); + } finally { + setLaunchingSlug(null); + } + }; + + const markRun = async (run: OpenworkWorkflowRun, status: OpenworkWorkflowRunStatus) => { + if (!client || !workspaceId) return; + try { + await client.updateWorkflowRun(workspaceId, run.workflowSlug, run.id, { status }); + void queryClient.invalidateQueries({ + queryKey: workflowRunsQueryKey(workspaceId, run.workflowSlug), + }); + } catch (error) { + toast.error(describeError(error)); + } + }; + + const workflows = workflowsQuery.data?.items ?? []; + const runs = runsQuery.data?.items ?? []; + + const editorValid = useMemo(() => { + if (!editor) return false; + return Boolean(editor.name.trim()) && editor.steps.some((step) => step.prompt.trim()); + }, [editor]); + + const updateEditorStep = (index: number, patch: Partial) => { + setEditor((current) => { + if (!current) return current; + const steps = current.steps.map((step, i) => (i === index ? { ...step, ...patch } : step)); + return { ...current, steps }; + }); + }; + + const moveEditorStep = (index: number, direction: -1 | 1) => { + setEditor((current) => { + if (!current) return current; + const target = index + direction; + if (target < 0 || target >= current.steps.length) return current; + const steps = [...current.steps]; + const [step] = steps.splice(index, 1); + steps.splice(target, 0, step); + return { ...current, steps }; + }); + }; + + const removeEditorStep = (index: number) => { + setEditor((current) => { + if (!current || current.steps.length <= 1) return current; + return { ...current, steps: current.steps.filter((_, i) => i !== index) }; + }); + }; + + return ( +
+
+
+

Workflows

+

+ Repeatable multi-step agent runs. Pick input files, write the step prompts once, and run + them with a coding agent. Each run compiles its results into the workspace outbox so + outputs are saved, versioned, and shareable. +

+
+
+ + +
+
+ + {!ready ? ( +
+ Connect the OpenWork server for this workspace to manage workflows. +
+ ) : null} + + {ready && workflowsQuery.isError ? ( +
+ {describeError(workflowsQuery.error)} +
+ ) : null} + + {ready && !workflowsQuery.isLoading && workflows.length === 0 ? ( +
+ +

No workflows yet

+

+ A workflow is a saved pipeline: context files in, prompt steps in order, build artifacts + out. Create one for any task you find yourself re-prompting. +

+
+ ) : null} + + {workflows.length > 0 ? ( +
+
+ {workflows.map((workflow) => ( +
+
+
+ +
+
+

{workflow.name}

+

+ {workflow.description || "No description"} +

+
+ + + {workflow.steps.length} step{workflow.steps.length === 1 ? "" : "s"} + + + + {workflow.inputs.length} input{workflow.inputs.length === 1 ? "" : "s"} + + + outputs: {workflow.outputDir.replace(".opencode/openwork/outbox/", "outbox/")} + +
+
+
+ +
+ + Updated {formatTimestamp(workflow.updatedAt)} + +
+ + + + +
+
+
+ ))} +
+
+ ) : null} + + { + if (!open) { + setEditor(null); + setEditorError(null); + } + }} + > + + + {editor?.slug ? "Edit workflow" : "New workflow"} + + Steps run in order inside one agent session. Outputs are written to the workspace + outbox as shareable artifacts. + + + + {editor ? ( +
+ {editorError ? ( +
+ {editorError} +
+ ) : null} + +
+ + { + const name = event.currentTarget.value; + setEditor((current) => (current ? { ...current, name } : current)); + }} + placeholder="Weekly research digest" + className={textInputClass} + /> +
+ +
+ + { + const description = event.currentTarget.value; + setEditor((current) => (current ? { ...current, description } : current)); + }} + placeholder="What this workflow produces" + className={textInputClass} + /> +
+ +
+ +