diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 8837d1d37d1..2abf34cde21 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -358,6 +358,7 @@ export const makeOrchestrationIntegrationHarness = ( workflowStatus: () => Effect.die("delamain not available in integration harness") as never, workflowKill: () => Effect.die("delamain not available in integration harness") as never, + runWorkflow: () => Effect.die("delamain not available in integration harness") as never, }), ), ); diff --git a/apps/server/src/gits/Layers/DelamainCliAdapter.test.ts b/apps/server/src/gits/Layers/DelamainCliAdapter.test.ts index 1dd7e64a7eb..7bd65f9624d 100644 --- a/apps/server/src/gits/Layers/DelamainCliAdapter.test.ts +++ b/apps/server/src/gits/Layers/DelamainCliAdapter.test.ts @@ -186,6 +186,87 @@ describe("DelamainCliAdapter", () => { }).pipe(Effect.provide(TestLayer)), ); + it.effect( + "shells the pinned operator `run-workflow --detach` argv with argsJson passthrough", + () => + Effect.gen(function* () { + runMock.mockImplementationOnce((input) => { + expect(input.command).toBe("delamain"); + expect(input.args).toEqual([ + "run-workflow", + "/srv/delamain/workflows/automode-goal.ts", + "--repo", + "/tmp/repo", + "--name", + "Nightly sweep", + "--args-json", + '{"title":"Ship it"}', + "--detach", + ]); + return Effect.succeed({ + stdout: JSON.stringify({ workflow_id: "wf-launch", status: "running", workflow: {} }), + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }); + }); + const adapter = yield* DelamainAdapter; + const result = yield* adapter.runWorkflow({ + script: "/srv/delamain/workflows/automode-goal.ts", + repo: "/tmp/repo", + name: "Nightly sweep", + argsJson: '{"title":"Ship it"}', + }); + expect(result.workflowId).toBe("wf-launch"); + expect(result.status).toBe("running"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("omits --name/--args-json when the operator leaves them unset", () => + Effect.gen(function* () { + runMock.mockImplementationOnce((input) => { + expect(input.args).toEqual([ + "run-workflow", + "/srv/delamain/workflows/automode-goal.ts", + "--repo", + "/tmp/repo", + "--detach", + ]); + return Effect.succeed({ + stdout: JSON.stringify({ workflow_id: "wf-bare", status: "queued" }), + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + }); + }); + const adapter = yield* DelamainAdapter; + const result = yield* adapter.runWorkflow({ + script: "/srv/delamain/workflows/automode-goal.ts", + repo: "/tmp/repo", + }); + expect(result.workflowId).toBe("wf-bare"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("rejects unparseable argsJson before shelling the CLI", () => + Effect.gen(function* () { + const adapter = yield* DelamainAdapter; + const error = yield* adapter + .runWorkflow({ + script: "/srv/delamain/workflows/automode-goal.ts", + repo: "/tmp/repo", + argsJson: "{not json", + }) + .pipe(Effect.flip); + expect(error.message).toBe("run-workflow argsJson is not valid JSON."); + expect(runMock).not.toHaveBeenCalled(); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("reads leaf ids from the nested workflow.agentPeerIds alias", () => Effect.gen(function* () { runMock.mockImplementationOnce((input) => { diff --git a/apps/server/src/gits/Layers/DelamainCliAdapter.ts b/apps/server/src/gits/Layers/DelamainCliAdapter.ts index 82d1831cc9b..600694e5ab3 100644 --- a/apps/server/src/gits/Layers/DelamainCliAdapter.ts +++ b/apps/server/src/gits/Layers/DelamainCliAdapter.ts @@ -22,6 +22,7 @@ import { type DelamainSendMessageResult, type DelamainWorkflowStatus, type DelamainWorkflowKillResult, + type DelamainWorkflowRunResult, type PeerStatus, } from "@t3tools/contracts"; @@ -402,6 +403,17 @@ function runWorkflowArgs(input: Parameters --repo --detach` is valid. --detach stays last (mirrors +// runWorkflowArgs) so the CLI prints the detach JSON envelope. +function workflowRunArgs(input: Parameters[0]): string[] { + const args = ["run-workflow", input.script, "--repo", input.repo]; + if (input.name) args.push("--name", input.name); + if (input.argsJson) args.push("--args-json", input.argsJson); + args.push("--detach"); + return args; +} + function normalizeWorkflowKill(workflowId: string, value: unknown): DelamainWorkflowKillResult { const record = rawRecord(value); return { @@ -571,6 +583,28 @@ export const makeDelamainCliAdapter = Effect.gen(function* () { runJson(processRunner, "workflow.kill", ["workflow", "kill", input.workflowId]).pipe( Effect.map((value) => normalizeWorkflowKill(input.workflowId, value)), ), + runWorkflow: (input) => + Effect.gen(function* () { + // Reject garbage before shelling — never pass an unparseable blob to the CLI. + if (input.argsJson != null) { + yield* Effect.try({ + // @effect-diagnostics-next-line preferSchemaOverJson:off + try: () => JSON.parse(input.argsJson as string) as unknown, + catch: (cause) => toDelamainError("run-workflow argsJson is not valid JSON.", cause), + }); + } + const value = yield* runJson( + processRunner, + "run-workflow", + workflowRunArgs(input), + ); + const record = rawRecord(value); + const workflowId = nullableString(record.workflow_id ?? record.workflowId); + if (workflowId === null) { + return yield* toDelamainError("Delamain run-workflow did not return a workflow_id."); + } + return { workflowId, status: rawStatus(record.status) } satisfies DelamainWorkflowRunResult; + }), }; return adapter; diff --git a/apps/server/src/gits/Services/DelamainAdapter.ts b/apps/server/src/gits/Services/DelamainAdapter.ts index 990a68bc321..06ee44adc78 100644 --- a/apps/server/src/gits/Services/DelamainAdapter.ts +++ b/apps/server/src/gits/Services/DelamainAdapter.ts @@ -25,6 +25,8 @@ import type { DelamainWorkflowStatus, DelamainWorkflowKillInput, DelamainWorkflowKillResult, + DelamainWorkflowRunInput, + DelamainWorkflowRunResult, } from "@t3tools/contracts"; export interface DelamainAdapterShape { @@ -68,6 +70,9 @@ export interface DelamainAdapterShape { readonly workflowKill: ( input: DelamainWorkflowKillInput, ) => Effect.Effect; + readonly runWorkflow: ( + input: DelamainWorkflowRunInput, + ) => Effect.Effect; } export class DelamainAdapter extends Context.Service()( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2bb260f27fe..2435499f18a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -335,6 +335,7 @@ describe("ProviderCommandReactor", () => { sendMessage: delamainDie, workflowStatus: delamainDie, workflowKill: delamainDie, + runWorkflow: delamainDie, }; const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 14fa7456dbb..3a391855499 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4732,6 +4732,49 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes the Delamain run-workflow launch rpc through to the adapter", () => + Effect.gen(function* () { + let launched: { script: string; repo: string; name: string | null | undefined } | null = null; + yield* buildAppUnderTest({ + layers: { + delamainAdapter: { + runWorkflow: (input) => + Effect.sync(() => { + launched = { script: input.script, repo: input.repo, name: input.name }; + return { workflowId: "wf-launch", status: "running" }; + }), + }, + automodeSupervisor: { + getSnapshot: () => + Effect.succeed({ + ...defaultAutomodeSnapshot, + policy: { ...defaultAutomodeSnapshot.policy, killSwitchEnabled: false }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitsDelamainRunWorkflow]({ + script: "/srv/delamain/workflows/automode-goal.ts", + repo: "/tmp/source-repo", + name: "Nightly sweep", + }), + ), + ); + + assert.equal(result.workflowId, "wf-launch"); + assert.equal(result.status, "running"); + assert.deepEqual(launched, { + script: "/srv/delamain/workflows/automode-goal.ts", + repo: "/tmp/source-repo", + name: "Nightly sweep", + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("blocks manual Delamain peer actions when the kill switch is enabled", () => Effect.gen(function* () { let spawnCalled = false; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0b017570058..c7e383ac07b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2091,6 +2091,13 @@ const makeWsRpcLayer = ( withKillSwitchGuard(delamainAdapter.workflowKill(input)), { "rpc.aggregate": "gits" }, ), + // Operator launch — kill-switch guarded like spawn (it dispatches peers). + [WS_METHODS.gitsDelamainRunWorkflow]: (input) => + observeRpcEffect( + WS_METHODS.gitsDelamainRunWorkflow, + withKillSwitchGuard(delamainAdapter.runWorkflow(input)), + { "rpc.aggregate": "gits" }, + ), [WS_METHODS.gitsDelamainReadInbox]: (input) => observeRpcEffect(WS_METHODS.gitsDelamainReadInbox, delamainAdapter.readInbox(input), { "rpc.aggregate": "gits", diff --git a/apps/web/src/components/DelamainSidebar.tsx b/apps/web/src/components/DelamainSidebar.tsx index 63c7f7c1e5f..8137c423187 100644 --- a/apps/web/src/components/DelamainSidebar.tsx +++ b/apps/web/src/components/DelamainSidebar.tsx @@ -14,6 +14,15 @@ import { AlertDialogTitle, } from "./ui/alert-dialog"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Textarea } from "./ui/textarea"; import { BotIcon, ChevronDownIcon, @@ -24,7 +33,9 @@ import { GitMergeIcon, MoreHorizontalIcon, PanelRightCloseIcon, + PlusIcon, ScrollTextIcon, + WorkflowIcon, XCircleIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; @@ -371,6 +382,284 @@ function IntegrateDialog({ ); } +// --- Launch dialog (spawn peer / run workflow) --- + +// ponytail: plain default path. A workflow-script registry (list + pick) is the upgrade +// path when there is more than one script worth launching from the UI. +const DEFAULT_WORKFLOW_SCRIPT = "/srv/gits/repos/delamain/workflows/automode-goal.ts"; +// spawnPeer's contract engine values are codex/cursor/unknown — only the two real engines +// are operator-selectable here. ("pi" is not in the contract's DelamainEngine literals.) +const SPAWN_ENGINES = ["codex", "cursor"] as const; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Unknown error."; +} + +function LaunchDialog({ + open, + onOpenChange, + environmentId, + repo, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + environmentId: EnvironmentId; + repo: string; +}) { + const queryClient = useQueryClient(); + const [mode, set_mode] = useState<"spawn" | "workflow">("spawn"); + + // Spawn-peer form. + const [prompt, set_prompt] = useState(""); + const [spawn_name, set_spawn_name] = useState(""); + const [engine, set_engine] = useState<(typeof SPAWN_ENGINES)[number]>("codex"); + + // Run-workflow form. + const [script, set_script] = useState(DEFAULT_WORKFLOW_SCRIPT); + const [wf_name, set_wf_name] = useState(""); + const [args_json, set_args_json] = useState(""); + + // Client-side JSON validation — inline error, blocks submit before we ever shell the CLI. + const args_json_error = useMemo(() => { + const trimmed = args_json.trim(); + if (!trimmed) return null; + try { + JSON.parse(trimmed); + return null; + } catch (err) { + return errorMessage(err); + } + }, [args_json]); + + const spawn_mutation = useMutation({ + mutationFn: async () => { + const client = readGitsEnvironmentClient(environmentId); + if (!client) throw new Error("No environment client"); + return client.delamain.spawnPeer({ + repo, + prompt: prompt.trim(), + engine, + ...(spawn_name.trim() ? { name: spawn_name.trim() } : {}), + }); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["gits", "delamain", "peers", environmentId], + }); + }, + }); + + const workflow_mutation = useMutation({ + mutationFn: async () => { + const client = readGitsEnvironmentClient(environmentId); + if (!client) throw new Error("No environment client"); + const trimmedArgs = args_json.trim(); + return client.delamain.workflow.run({ + script: script.trim(), + repo, + ...(wf_name.trim() ? { name: wf_name.trim() } : {}), + ...(trimmedArgs ? { argsJson: trimmedArgs } : {}), + }); + }, + }); + + const pending = spawn_mutation.isPending || workflow_mutation.isPending; + + const reset_and_close = useCallback( + (next: boolean) => { + if (!next && !pending) { + spawn_mutation.reset(); + workflow_mutation.reset(); + onOpenChange(false); + } else if (next) { + onOpenChange(true); + } + }, + [pending, spawn_mutation, workflow_mutation, onOpenChange], + ); + + const can_spawn = prompt.trim().length > 0 && !pending; + const can_run = script.trim().length > 0 && args_json_error === null && !pending; + + return ( + + + + Launch + + + {/* mode selector */} +
+ + +
+ + {/* repo (read-only, prefilled from the active project) */} +
+ +
+ {repo} +
+
+ + {mode === "spawn" ? ( + <> +
+ +