Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ jobs:
packages/ui/src/stores/session-metadata.test.ts
packages/ui/src/stores/session-pagination.test.ts
packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts
packages/ui/src/stores/workflows.test.ts
packages/opencode-plugin/plugin/lib/workflows.test.ts

- name: Test restore ownership integration
run: >-
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode-plugin/plugin/codenomad.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client.js"
import { createBackgroundProcessTools } from "./lib/background-process.js"
import { createWorkflowTools } from "./lib/workflows.js"

let voiceModeEnabled = false

export async function CodeNomadPlugin(input: PluginInput): Promise<{
tool: ReturnType<typeof createBackgroundProcessTools>
tool: ReturnType<typeof createBackgroundProcessTools> & ReturnType<typeof createWorkflowTools>
"chat.message": CodeNomadChatMessageHook
event: CodeNomadEventHook
}> {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
const workflowTools = createWorkflowTools(config)

await client.startEvents((event) => {
if (event.type === "codenomad.ping") {
Expand All @@ -33,6 +35,7 @@ export async function CodeNomadPlugin(input: PluginInput): Promise<{
return {
tool: {
...backgroundProcessTools,
...workflowTools,
},
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
if (!voiceModeEnabled) {
Expand Down
35 changes: 35 additions & 0 deletions packages/opencode-plugin/plugin/lib/workflows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from "node:assert/strict"
import test from "node:test"
import { describeWorkflowDetails, describeWorkflowStart } from "./workflows.js"

const run = {
id: "run",
objective: "Ship it",
status: "running" as const,
steps: [{ id: "build", title: "Build", status: "pending" }],
}

test("workflow start message reflects whether a human gate is configured", () => {
assert.match(describeWorkflowStart(run, true), /will pause/)
assert.match(describeWorkflowStart(run, false), /no human approval gate/)
})

test("workflow review messaging handles final and truncated gates", () => {
const waiting = {
...run,
status: "waiting_for_review" as const,
pendingReviewStepId: "build",
steps: [{
id: "build",
title: "Build",
status: "completed",
sessionId: "session-1",
output: "partial",
outputTruncated: true,
}],
}
const details = describeWorkflowDetails(waiting)
assert.match(details, /continue or complete/)
assert.match(details, /truncated/)
assert.match(details, /session-1/)
})
147 changes: 147 additions & 0 deletions packages/opencode-plugin/plugin/lib/workflows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { tool } from "@opencode-ai/plugin/tool"
import { createCodeNomadRequester, type CodeNomadConfig } from "./request.js"

type WorkflowStatus = "running" | "waiting_for_review" | "completed" | "failed" | "cancelled" | "interrupted"

type WorkflowRun = {
id: string
objective: string
status: WorkflowStatus
error?: string
pendingReviewStepId?: string
steps: Array<{
id: string
title: string
status: string
sessionId?: string
output?: unknown
outputTruncated?: boolean
}>
}

function modelConfig(providerID?: string, modelID?: string) {
if (Boolean(providerID) !== Boolean(modelID)) {
throw new Error("Provider ID and model ID must be supplied together.")
}
return providerID && modelID ? { providerID, modelID } : undefined
}

function summarize(run: WorkflowRun) {
const steps = run.steps
.map((step) => `${step.title}: ${step.status}${step.sessionId ? ` (${step.sessionId})` : ""}`)
.join("\n")
const review = run.status === "waiting_for_review"
? "\nHuman review is required in CodeNomad before the workflow can continue or complete."
: ""
return `Workflow ${run.id}\nStatus: ${run.status}\n${steps}${review}${run.error ? `\nError: ${run.error}` : ""}`
}

function details(run: WorkflowRun) {
const reviewed = run.steps.find((step) => step.id === run.pendingReviewStepId)
const output = reviewed?.output === undefined ? "" : `\nPending review:\n${JSON.stringify(reviewed.output, null, 2)}`
const truncated = reviewed?.outputTruncated
? `\nThis output is truncated. Review the full generated session${reviewed.sessionId ? ` ${reviewed.sessionId}` : ""} before approval.`
: ""
return `${summarize(run)}${output}${truncated}`
}

export const describeWorkflowDetails = details

export function describeWorkflowStart(run: WorkflowRun, hasApprovalGate: boolean) {
const gateMessage = hasApprovalGate
? "The workflow will pause at its configured human approval gates."
: "This workflow has no human approval gate."
return `${summarize(run)}\n${gateMessage}`
}

export function createWorkflowTools(config: CodeNomadConfig) {
const requester = createCodeNomadRequester(config)
const request = <T>(path: string, init?: RequestInit) => requester.requestJson<T>(`/workflow-runs${path}`, init)

return {
start_codenomad_workflow: tool({
description:
"Start a host-managed sequential workflow. Stages may require explicit human review before the next stage runs.",
args: {
objective: tool.schema.string().describe("The objective for the workflow"),
stages: tool.schema.array(tool.schema.object({
id: tool.schema.string().describe("Stable stage ID using letters, numbers, underscore, or dash"),
title: tool.schema.string().describe("Human-readable stage title"),
instructions: tool.schema.string().describe("Instructions for this stage"),
agent: tool.schema.string().optional().describe("Optional OpenCode agent"),
provider_id: tool.schema.string().optional().describe("Optional model provider ID"),
model_id: tool.schema.string().optional().describe("Optional model ID"),
requires_approval: tool.schema.boolean().optional().describe("Pause for human review after this stage"),
})).min(1).max(12).optional().describe("Ordered workflow stages; defaults to Planner then Implementer"),
},
async execute(args, context) {
const stages = args.stages?.map((stage) => {
const model = modelConfig(stage.provider_id, stage.model_id)
return {
id: stage.id,
title: stage.title,
instructions: stage.instructions,
...(stage.agent ? { agent: stage.agent } : {}),
...(model ? { model } : {}),
requiresApproval: Boolean(stage.requires_approval),
}
}) ?? [
{
id: "planner",
title: "Planner",
instructions: "Create a concise implementation plan with ordered, verifiable steps.",
requiresApproval: true,
},
{
id: "implementer",
title: "Implementer",
instructions: "Implement the approved plan and run focused validation.",
requiresApproval: false,
},
]
const run = await request<WorkflowRun>("", {
method: "POST",
body: JSON.stringify({
objective: args.objective,
initiatorSessionId: context.sessionID,
stages,
}),
})
return describeWorkflowStart(run, stages.some((stage) => stage.requiresApproval))
},
}),
list_codenomad_workflows: tool({
description: "List workflow runs managed by CodeNomad for this workspace.",
args: {},
async execute() {
const response = await request<{ runs: WorkflowRun[] }>("")
if (response.runs.length === 0) return "No CodeNomad workflow runs found."
return response.runs.map((run) => `${run.id} | ${run.status} | ${run.objective}`).join("\n")
},
}),
get_codenomad_workflow: tool({
description: "Inspect one CodeNomad workflow run and its role sessions.",
args: { run_id: tool.schema.string().describe("Workflow run ID") },
async execute(args) {
return details(await request<WorkflowRun>(`/${encodeURIComponent(args.run_id)}`))
},
}),
approve_codenomad_workflow: tool({
description:
"Show the pending approval details. Only a user in the CodeNomad Workflows panel can approve and continue the workflow.",
args: { run_id: tool.schema.string().describe("Workflow run ID") },
async execute(args) {
const run = await request<WorkflowRun>(`/${encodeURIComponent(args.run_id)}`)
return `${details(run)}\nApproval was not applied. Ask the user to approve this run in the CodeNomad Workflows panel.`
},
}),
cancel_codenomad_workflow: tool({
description: "Cancel a running or review-pending CodeNomad workflow.",
args: { run_id: tool.schema.string().describe("Workflow run ID") },
async execute(args) {
const run = await request<WorkflowRun>(`/${encodeURIComponent(args.run_id)}/cancel`, { method: "POST" })
return summarize(run)
},
}),
}
}
2 changes: 1 addition & 1 deletion packages/opencode-plugin/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@
"types": ["node"]
},
"include": ["plugin/**/*.ts"],
"exclude": ["dist", "node_modules"]
"exclude": ["dist", "node_modules", "plugin/**/*.test.ts"]
}
54 changes: 54 additions & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"

export interface WorkspaceDescriptor {
id: string
/** Stable across desktop restore; distinct for force-created instances of the same path. */
lineageId?: string
/** Correlates creation events with the client request that initiated them. */
requestId?: string
/** Absolute path on the server host. */
Expand All @@ -39,6 +41,7 @@ export interface WorkspaceDescriptor {

export interface WorkspaceCreateRequest {
path: string
lineageId?: string
name?: string
binaryPath?: string
requestId?: string
Expand Down Expand Up @@ -293,6 +296,57 @@ export interface InstanceStreamEvent {
[key: string]: unknown
}

export type WorkflowRunStatus = "running" | "waiting_for_review" | "completed" | "failed" | "cancelled" | "interrupted"
export type WorkflowStepStatus = "pending" | "running" | "completed" | "failed" | "cancelled"

export interface WorkflowModelSelection {
providerID: string
modelID: string
}

export interface WorkflowStageConfig {
id: string
title: string
instructions: string
agent?: string
model?: WorkflowModelSelection
requiresApproval?: boolean
}

export interface WorkflowRunStep extends WorkflowStageConfig {
status: WorkflowStepStatus
sessionId?: string
output?: unknown
outputTruncated?: boolean
error?: string
startedAt?: string
completedAt?: string
}

export interface WorkflowRun {
id: string
workspaceId: string
workspaceLineageId: string
workspacePath: string
initiatorSessionId?: string
objective: string
status: WorkflowRunStatus
rootSessionId?: string
activeStepId?: string
pendingReviewStepId?: string
steps: WorkflowRunStep[]
error?: string
createdAt: string
updatedAt: string
}

export interface WorkflowRunCreateRequest {
workspaceId: string
initiatorSessionId?: string
objective: string
stages: WorkflowStageConfig[]
}

export type SideCarKind = "port"

export type SideCarPrefixMode = "strip" | "preserve"
Expand Down
10 changes: 10 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { createServerShutdownHandler, orchestrateServerShutdown, type ServerShut
import { AutoAcceptManager } from "./permissions/auto-accept-manager"
import { createOpencodePermissionReplier } from "./permissions/opencode-replier"
import { createOpencodeYoloPersistence } from "./permissions/opencode-yolo-metadata"
import { WorkflowManager } from "./workflows/manager"

const require = createRequire(import.meta.url)

Expand Down Expand Up @@ -375,6 +376,12 @@ async function main() {
getServerBaseUrl: () => serverMeta.localUrl,
nodeExtraCaCertsPath,
})
const workflowManager = new WorkflowManager({
workspaceManager,
eventBus,
storageDir: path.join(configDir, "workflow-runs"),
logger: logger.child({ component: "workflows" }),
})
const fileSystemBrowser = new FileSystemBrowser({
rootDir: options.rootDir,
unrestricted: options.unrestrictedRoot,
Expand Down Expand Up @@ -499,6 +506,7 @@ async function main() {
remoteProxySessionManager,
yoloManager,
sessionMetadataPersistence,
workflowManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
Expand Down Expand Up @@ -528,6 +536,7 @@ async function main() {
remoteProxySessionManager,
yoloManager,
sessionMetadataPersistence,
workflowManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,
Expand Down Expand Up @@ -623,6 +632,7 @@ async function main() {
orchestrateServerShutdown(
{
stopInstanceEventBridge: () => instanceEventBridge.shutdown(),
stopWorkflowRuns: () => workflowManager.shutdown(),
stopSidecars: () => sidecarManager.shutdown(),
stopClientConnections: () => clientConnectionManager.shutdown(),
stopRemoteProxySessions: () => remoteProxySessionManager.shutdown(),
Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/server/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import type { SideCarManager } from "../sidecars/manager"
import type { PreviewManager } from "../previews/manager"
import type { RemoteProxySessionManager } from "./remote-proxy"
import { createOpenCodeUpdateService } from "../opencode-update/service"
import type { WorkflowManager } from "../workflows/manager"
import { registerWorkflowRoutes } from "./routes/workflows"

interface HttpServerDeps {
bindHost: string
Expand All @@ -71,6 +73,7 @@ interface HttpServerDeps {
remoteProxySessionManager: RemoteProxySessionManager
yoloManager: AutoAcceptManager
sessionMetadataPersistence: OpencodeYoloPersistence
workflowManager: WorkflowManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
Expand Down Expand Up @@ -326,6 +329,7 @@ export function createHttpServer(deps: HttpServerDeps) {
})
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerYoloRoutes(app, { yoloManager: deps.yoloManager })
registerWorkflowRoutes(app, { workflowManager: deps.workflowManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })


Expand Down
Loading
Loading