diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index bf8a3ade45..2ecb0acd1a 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,47 @@ 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 }>; + /** Co-editing guard: server rejects with 409 when the workflow changed since this timestamp. */ + baseUpdatedAt?: number | null; +}; + export type OpenworkUserEnvItem = { key: string; updatedAt: number; @@ -1430,6 +1472,60 @@ 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" }, + ), + listAllWorkflowRuns: (workspaceId: string) => + requestJson<{ items: OpenworkWorkflowRun[] }>( + baseUrl, + `/workspace/${encodeURIComponent(workspaceId)}/workflow-runs`, + { token, hostToken }, + ), + 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..40e652cbab --- /dev/null +++ b/apps/app/src/react-app/domains/workflows/workflows-view.tsx @@ -0,0 +1,891 @@ +/** @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, + Sparkles, + Trash2, + Users, + 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 { + OpenworkServerError, + type OpenworkServerClient, + type OpenworkWorkflowItem, + type OpenworkWorkflowRun, + type 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]`; + +/** How often connected clients pick up collaborators' changes. */ +const LIVE_SYNC_INTERVAL_MS = 3_000; + +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[]; + /** updatedAt of the workflow when the editor was opened (co-editing guard). */ + baseUpdatedAt: number | null; +}; + +const emptyEditorState: EditorState = { + slug: null, + name: "", + description: "", + inputsText: "", + steps: [{ name: "", prompt: "" }], + baseUpdatedAt: null, +}; + +const exampleEditorState: EditorState = { + slug: null, + name: "Weekly research digest", + description: "Turn collected notes into a publishable digest with sources.", + inputsText: "notes/\nREADME.md", + steps: [ + { + name: "Collect highlights", + prompt: + "Read every input file. Extract the 5-10 most important findings, each with a one-line summary and the source file it came from.", + }, + { + name: "Draft the digest", + prompt: + "Write a structured digest in Markdown: a short intro, one section per theme, and a sources list. Keep it under 800 words.", + }, + { + name: "Quality pass", + prompt: + "Re-read the draft as a skeptical editor. Fix vague claims, ensure every section cites an input file, and tighten the writing.", + }, + ], + baseUpdatedAt: null, +}; + +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 })), + baseUpdatedAt: workflow.updatedAt, + }; +} + +function workflowsQueryKey(workspaceId: string | null) { + return ["openwork", "workflows", workspaceId]; +} + +function allRunsQueryKey(workspaceId: string | null) { + return ["openwork", "workflow-runs", workspaceId, "all"]; +} + +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 isConflictError(error: unknown): boolean { + return error instanceof OpenworkServerError && error.status === 409; +} + +function formatTimestamp(value: number): string { + if (!value) return ""; + try { + return new Date(value).toLocaleString(); + } catch { + return ""; + } +} + +function formatRelativeTime(value: number): string { + if (!value) return ""; + const deltaMs = Date.now() - value; + if (deltaMs < 45_000) return "just now"; + const minutes = Math.round(deltaMs / 60_000); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +} + +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 [editorConflict, setEditorConflict] = useState(false); + 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, + // Live sync: collaborators' edits (and git pulls) show up within seconds. + refetchInterval: LIVE_SYNC_INTERVAL_MS, + refetchOnWindowFocus: true, + }); + + const allRunsQuery = useQuery({ + queryKey: allRunsQueryKey(workspaceId), + queryFn: async () => { + if (!client || !workspaceId) return { items: [] }; + return client.listAllWorkflowRuns(workspaceId); + }, + enabled: ready, + refetchInterval: LIVE_SYNC_INTERVAL_MS, + refetchOnWindowFocus: true, + }); + + 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), + refetchInterval: LIVE_SYNC_INTERVAL_MS, + refetchOnWindowFocus: true, + }); + + const workflows = useMemo(() => workflowsQuery.data?.items ?? [], [workflowsQuery.data]); + const runs = runsQuery.data?.items ?? []; + + const latestRunBySlug = useMemo(() => { + const map = new Map(); + for (const run of allRunsQuery.data?.items ?? []) { + const current = map.get(run.workflowSlug); + if (!current || run.createdAt > current.createdAt) { + map.set(run.workflowSlug, run); + } + } + return map; + }, [allRunsQuery.data]); + + /** + * Co-editing awareness: if the workflow open in the editor has been saved + * by someone else since we opened it, the live-sync poll will surface a + * newer updatedAt and we warn before the user even hits save. + */ + const remoteUpdate = useMemo(() => { + if (!editor?.slug || editor.baseUpdatedAt === null) return null; + const latest = workflows.find((item) => item.slug === editor.slug); + if (latest && latest.updatedAt > editor.baseUpdatedAt) return latest; + return null; + }, [editor, workflows]); + + const invalidateWorkflows = () => + queryClient.invalidateQueries({ queryKey: workflowsQueryKey(workspaceId) }); + + const openEditor = (state: EditorState) => { + setEditorError(null); + setEditorConflict(false); + setEditor(state); + }; + + const closeEditor = () => { + setEditor(null); + setEditorError(null); + setEditorConflict(false); + }; + + const loadLatestIntoEditor = (latest: OpenworkWorkflowItem) => { + openEditor(editorStateFromWorkflow(latest)); + toast.info("Loaded the latest version"); + }; + + 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, + baseUpdatedAt: state.baseUpdatedAt, + }); + }, + onSuccess: () => { + closeEditor(); + toast.success("Workflow saved"); + void invalidateWorkflows(); + }, + onError: (error) => { + if (isConflictError(error)) { + setEditorConflict(true); + setEditorError(null); + return; + } + 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), + }); + void queryClient.invalidateQueries({ queryKey: allRunsQueryKey(workspaceId) }); + 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), + }); + void queryClient.invalidateQueries({ queryKey: allRunsQueryKey(workspaceId) }); + } catch (error) { + toast.error(describeError(error)); + } + }; + + 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) }; + }); + }; + + const conflictLatest = editorConflict && editor?.slug + ? workflows.find((item) => item.slug === editor.slug) ?? null + : null; + + 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 ? ( +

+ + + + + Synced live + + shared with everyone in this workspace, versioned with the project +

+ ) : null} +
+
+ + +
+
+ + {!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) => { + const latestRun = latestRunBySlug.get(workflow.slug) ?? null; + return ( +
+
+
+ +
+
+
+

{workflow.name}

+ {latestRun ? runStatusBadge(latestRun.status) : null} +
+

+ {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/")} + +
+
+
+ +
+ + {latestRun + ? `Last run ${formatRelativeTime(latestRun.createdAt)} · edited ${formatRelativeTime(workflow.updatedAt)}` + : `Edited ${formatRelativeTime(workflow.updatedAt)} · never run`} + +
+ {latestRun?.sessionId ? ( + + ) : null} + + + + +
+
+
+ ); + })} +
+
+ ) : null} + + { + if (!open) closeEditor(); + }} + > + + + {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 ? ( +
+ {editorConflict ? ( +
+ + Someone saved this workflow while you were editing. Your changes were not saved. + + {conflictLatest ? ( + + ) : null} +
+ ) : null} + + {!editorConflict && remoteUpdate ? ( +
+ + + A collaborator just updated this workflow ({formatRelativeTime(remoteUpdate.updatedAt)}). + Saving now will be blocked unless you load their version first. + + +
+ ) : null} + + {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} + /> +
+ +
+ +