Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ebd1aef
feat(workflows): add host-managed workflow runs
pascalandr Jul 20, 2026
437a30e
merge: integrate latest dev into workflow host
pascalandr Jul 28, 2026
4ccf771
feat(workflows): add declarative orchestration runtime
pascalandr Jul 28, 2026
871011b
merge: integrate latest dev into workflow runtime
pascalandr Jul 31, 2026
cb6754e
merge: integrate right panel plugins and launch diagnostics
pascalandr Aug 3, 2026
ba9776d
test(server): keep timeout mock alive in isolation
pascalandr Aug 3, 2026
f6f39fd
feat(workflows): let agents author inherited workflows
pascalandr Aug 3, 2026
8379098
fix(tauri): recheck cross-host legacy markers
pascalandr Aug 4, 2026
087c8b2
feat(workflows): reuse named agent sessions
pascalandr Aug 4, 2026
897c3cd
fix(workflows): close Gatekeeper recovery and ownership gaps
pascalandr Aug 4, 2026
bfe3ad6
fix(gatekeeper): fence cross-host workflow operations
pascalandr Aug 4, 2026
b431f13
fix(gatekeeper): complete cross-host recovery fencing
pascalandr Aug 4, 2026
61da7b2
fix(gatekeeper): fence leases and replay cursors
pascalandr Aug 4, 2026
7ca814e
fix(gatekeeper): close ownership publication races
pascalandr Aug 4, 2026
5120682
fix(gatekeeper): complete replay and migration safety
pascalandr Aug 4, 2026
63e37b5
fix(gatekeeper): remove final lock and authority races
pascalandr Aug 4, 2026
b36da30
fix(gatekeeper): close final recovery interleavings
pascalandr Aug 5, 2026
e347835
fix(gatekeeper): fence host and launch boundaries
pascalandr Aug 5, 2026
6262f0e
fix(gatekeeper): preserve upgrade and endpoint safety
pascalandr Aug 5, 2026
52800aa
fix(workspaces): retain unknown orphan ownership
pascalandr Aug 5, 2026
f3fa969
test(workspaces): make retirement retry portable
pascalandr Aug 5, 2026
09f33a0
test(workspaces): await lease-loss cleanup
pascalandr Aug 5, 2026
6fc61a7
fix(ui): keep conversations mounted during workflow updates
pascalandr Aug 6, 2026
0a77319
test(workspaces): drive lease-loss heartbeat explicitly
pascalandr Aug 6, 2026
4b30521
fix(tauri): keep native event transport opt-in
pascalandr Aug 6, 2026
9747b66
fix(events): keep SSE open through backpressure
pascalandr Aug 6, 2026
00040fc
merge(dev): resolve workflow recovery conflicts
pascalandr Aug 12, 2026
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 @@ -125,6 +125,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
64 changes: 64 additions & 0 deletions packages/opencode-plugin/plugin/lib/request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict"
import { createServer } from "node:http"
import { test } from "node:test"

import { createCodeNomadRequester } from "./request"

test("plugin requests use the distinct callback capability", async () => {
let authorization: string | undefined
const server = createServer((request, response) => {
authorization = request.headers.authorization
response.writeHead(200, { Connection: "close" }).end()
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
try {
const address = server.address()
assert.ok(address && typeof address === "object")
const requester = createCodeNomadRequester({
instanceId: "workspace",
baseUrl: `http://127.0.0.1:${address.port}`,
callbackToken: "workspace-callback",
})

await requester.requestVoid("/event", { method: "POST" })

assert.equal(authorization, "Bearer workspace-callback")
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve())
server.closeAllConnections()
})
}
})

test("plugin responses use null bodies when HTTP forbids response content", async () => {
const server = createServer((request, response) => {
const status = Number(new URL(request.url ?? "/", "http://localhost").pathname.slice(1)) || 200
response.writeHead(status, { Connection: "close" }).end("ignored")
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
try {
const address = server.address()
assert.ok(address && typeof address === "object")
const requester = createCodeNomadRequester({
instanceId: "workspace",
baseUrl: `http://127.0.0.1:${address.port}`,
callbackToken: "workspace-callback",
})

for (const status of [204, 205, 304]) {
const response = await requester.fetch(`http://127.0.0.1:${address.port}/${status}`)
assert.equal(response.status, status)
assert.equal(response.body, null)
}
assert.equal(await requester.requestJson(`http://127.0.0.1:${address.port}/205`), undefined)
const head = await requester.fetch(`http://127.0.0.1:${address.port}/200`, { method: "HEAD" })
assert.equal(head.body, null)
assert.equal(await requester.requestJson(`http://127.0.0.1:${address.port}/200`, { method: "HEAD" }), undefined)
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve())
server.closeAllConnections()
})
}
})
21 changes: 9 additions & 12 deletions packages/opencode-plugin/plugin/lib/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,22 @@ export type PluginEvent = {
export type CodeNomadConfig = {
instanceId: string
baseUrl: string
callbackToken: string
}

export function getCodeNomadConfig(): CodeNomadConfig {
return {
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
callbackToken: requireEnv("CODENOMAD_CALLBACK_TOKEN"),
}
}

export function createCodeNomadRequester(config: CodeNomadConfig) {
const rawBaseUrl = (config.baseUrl ?? "").trim()
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
const authorization = buildInstanceAuthorizationHeader()
const authorization = `Bearer ${config.callbackToken}`

const buildUrl = (path: string) => {
if (path.startsWith("http://") || path.startsWith("https://")) {
Expand Down Expand Up @@ -60,7 +62,7 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
throw new Error(message || `Request failed with ${response.status}`)
}

if (response.status === 204) {
if ((init?.method ?? "GET").toUpperCase() === "HEAD" || response.status === 204 || response.status === 205) {
return undefined as T
}

Expand Down Expand Up @@ -117,6 +119,7 @@ async function nodeFetch(
...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}),
},
(res) => {
const status = res.statusCode ?? 0
const responseHeaders = new Headers()
for (const [key, value] of Object.entries(res.headers)) {
if (value === undefined) continue
Expand All @@ -127,9 +130,10 @@ async function nodeFetch(
}
}

// Convert Node stream -> Web ReadableStream for Response.
const webBody = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders }))
const bodyForbidden = method === "HEAD" || status === 204 || status === 205 || status === 304
if (bodyForbidden) res.resume()
const webBody = bodyForbidden ? null : Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
resolve(new Response(webBody, { status, headers: responseHeaders }))
},
)

Expand Down Expand Up @@ -185,13 +189,6 @@ function requireEnv(key: string): string {
return value
}

function buildInstanceAuthorizationHeader(): string {
const username = requireEnv("OPENCODE_SERVER_USERNAME")
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}

function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const output: Record<string, string> = {}
if (!headers) return output
Expand Down
179 changes: 179 additions & 0 deletions packages/opencode-plugin/plugin/lib/workflows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
createWorkflowTools,
describeWorkflowDefinition,
describeWorkflowDefinitions,
describeWorkflowDetails,
parseWorkflowInputs,
} from "./workflows.js"

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

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/)
})

test("dynamic workflow details include execution progress, usage, statuses, and gate guidance", () => {
const dynamic = {
id: "dynamic-run",
objective: "Deploy",
status: "waiting_for_input" as const,
definitionId: "deploy",
definitionRevision: 3,
steps: [],
executionNodes: [
{ instanceKey: "plan", status: "completed", sessionIds: ["session-1"] },
{ instanceKey: "environment", status: "waiting" },
],
pendingGate: {
executionNodeId: "gate-execution-id",
gate: "input" as const,
prompt: "Choose an environment",
inputSchema: { type: "string", enum: ["staging", "production"] },
},
usage: {
cost: 0.25,
tokens: 120,
inputTokens: 70,
outputTokens: 40,
reasoningTokens: 10,
cacheReadTokens: 5,
cacheWriteTokens: 0,
},
}
const message = describeWorkflowDetails(dynamic)
assert.match(message, /Status: waiting_for_input/)
assert.match(message, /Execution nodes: 2 total \(completed: 1, waiting: 1\)/)
assert.match(message, /Usage: 120 tokens/)
assert.match(message, /Choose an environment/)
assert.match(message, /Expected input schema/)
assert.match(message, /human in the CodeNomad UI/)
assert.match(message, /cannot answer this gate/)
assert.match(message, /session-1/)

const approval = describeWorkflowDetails({
...dynamic,
status: "waiting_for_review",
pendingGate: { ...dynamic.pendingGate, gate: "approval" },
})
assert.match(approval, /cannot approve this gate/)

for (const status of ["pausing", "paused", "recovery_required"] as const) {
assert.match(describeWorkflowDetails({ ...dynamic, status, pendingGate: undefined }), new RegExp(`Status: ${status}`))
assert.match(describeWorkflowDetails({ ...dynamic, status, pendingGate: undefined }), /CodeNomad UI/)
}
})

test("saved workflow definition messages expose current revision and canonical definition", () => {
const definition = {
id: "deploy",
revision: 3,
definition: { name: "Deploy", description: "Deploy safely" },
canonical: '{"version":1,"id":"deploy"}',
}
assert.match(describeWorkflowDefinitions([definition]), /deploy \| revision 3 \| Deploy \| Deploy safely/)
assert.match(describeWorkflowDefinition(definition), /Canonical definition:\n\{"version":1/)
assert.equal(describeWorkflowDefinitions([]), "No saved CodeNomad workflow definitions found.")
})

test("saved workflow inputs require a JSON object", () => {
assert.deepEqual(parseWorkflowInputs('{"environment":"staging"}'), { environment: "staging" })
assert.equal(parseWorkflowInputs(), undefined)
assert.throws(() => parseWorkflowInputs("not-json"), /valid JSON/)
assert.throws(() => parseWorkflowInputs("[]"), /JSON object/)
assert.throws(() => parseWorkflowInputs("null"), /JSON object/)
let nested: unknown = true
for (let depth = 0; depth < 21; depth++) nested = { nested }
assert.throws(() => parseWorkflowInputs(JSON.stringify(nested)), /deeply nested/)
assert.throws(() => parseWorkflowInputs(JSON.stringify({ values: Array(50_001).fill(null) })), /too many values/)
assert.throws(() => parseWorkflowInputs(JSON.stringify({ value: "é".repeat(128_001) })), /too large/)
})

test("saved definition tools create, update, and start the current revision from the calling session", async () => {
const calls: Array<{ path: string; init?: RequestInit }> = []
const definition = {
id: "deploy_flow",
revision: 3,
definition: { name: "Deploy" },
canonical: '{"version":1}',
}
const requester = {
async requestJson<T>(path: string, init?: RequestInit): Promise<T> {
calls.push({ path, init })
if (path.endsWith("/start")) {
return {
id: "run-id",
objective: "Ship it",
status: "running",
definitionId: "deploy_flow",
definitionRevision: 3,
steps: [],
executionNodes: [],
} as T
}
if (path === "/workflow-definitions" && !init) return { definitions: [definition] } as T
return definition as T
},
}
const tools = createWorkflowTools({ instanceId: "workspace", baseUrl: "http://localhost", callbackToken: "callback" }, requester)
assert.equal("start_codenomad_workflow" in tools, false)

await tools.list_codenomad_workflow_definitions.execute({}, {} as never)
await tools.get_codenomad_workflow_definition.execute({ definition_id: "deploy_flow" }, {} as never)
await tools.create_codenomad_workflow_definition.execute({ source: "version: 1" }, {} as never)
await tools.update_codenomad_workflow_definition.execute({
definition_id: "deploy_flow",
expected_revision: 3,
source: "version: 1\nid: deploy_flow",
}, {} as never)
const started = await tools.start_codenomad_workflow_definition.execute({
definition_id: "deploy_flow",
objective: "Ship it",
inputs_json: '{"environment":"production"}',
}, { sessionID: "session-1" } as never)

assert.deepEqual(calls.map((call) => call.path), [
"/workflow-definitions",
"/workflow-definitions/deploy_flow",
"/workflow-definitions",
"/workflow-definitions/deploy_flow",
"/workflow-definitions/deploy_flow/start",
])
assert.equal(calls[2]?.init?.method, "POST")
assert.deepEqual(JSON.parse(String(calls[2]?.init?.body)), { source: "version: 1" })
assert.equal(calls[3]?.init?.method, "PUT")
assert.deepEqual(JSON.parse(String(calls[3]?.init?.body)), {
expectedRevision: 3,
source: "version: 1\nid: deploy_flow",
})
assert.equal(calls[4]?.init?.method, "POST")
assert.deepEqual(JSON.parse(String(calls[4]?.init?.body)), {
initiatorSessionId: "session-1",
objective: "Ship it",
inputs: { environment: "production" },
})
assert.match(started, /current saved definition revision/)
assert.doesNotMatch(String(calls[4]?.init?.body), /definitionRevision/)
})
Loading
Loading