Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
),
);
Expand Down
81 changes: 81 additions & 0 deletions apps/server/src/gits/Layers/DelamainCliAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/gits/Layers/DelamainCliAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type DelamainSendMessageResult,
type DelamainWorkflowStatus,
type DelamainWorkflowKillResult,
type DelamainWorkflowRunResult,
type PeerStatus,
} from "@t3tools/contracts";

Expand Down Expand Up @@ -402,6 +403,17 @@ function runWorkflowArgs(input: Parameters<DelamainAdapterShape["runGoalWorkflow
];
}

// Operator launch (`gits.delamain.workflow.run`): name/argsJson are optional so a bare
// `run-workflow <script> --repo <repo> --detach` is valid. --detach stays last (mirrors
// runWorkflowArgs) so the CLI prints the detach JSON envelope.
function workflowRunArgs(input: Parameters<DelamainAdapterShape["runWorkflow"]>[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 {
Expand Down Expand Up @@ -571,6 +583,28 @@ export const makeDelamainCliAdapter = Effect.gen(function* () {
runJson<unknown>(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<unknown>(
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;
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/gits/Services/DelamainAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import type {
DelamainWorkflowStatus,
DelamainWorkflowKillInput,
DelamainWorkflowKillResult,
DelamainWorkflowRunInput,
DelamainWorkflowRunResult,
} from "@t3tools/contracts";

export interface DelamainAdapterShape {
Expand Down Expand Up @@ -68,6 +70,9 @@ export interface DelamainAdapterShape {
readonly workflowKill: (
input: DelamainWorkflowKillInput,
) => Effect.Effect<DelamainWorkflowKillResult, DelamainAdapterError>;
readonly runWorkflow: (
input: DelamainWorkflowRunInput,
) => Effect.Effect<DelamainWorkflowRunResult, DelamainAdapterError>;
}

export class DelamainAdapter extends Context.Service<DelamainAdapter, DelamainAdapterShape>()(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1983,11 +1983,11 @@

// initialize → serverInfo
const init = yield* postMcp({ jsonrpc: "2.0", id: 1, method: "initialize" });
assert.equal((init.result?.serverInfo as { name: string }).name, "gits-visual-plan");

Check warning on line 1986 in apps/server/src/server.test.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining

// tools/list → the six visual-plan tools
const list = yield* postMcp({ jsonrpc: "2.0", id: 2, method: "tools/list" });
const toolNames = (list.result?.tools as Array<{ name: string }>).map((t) => t.name);

Check warning on line 1990 in apps/server/src/server.test.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
assert.deepEqual([...toolNames].sort(), [
"create-visual-plan",
"export-visual-plan",
Expand All @@ -2004,7 +2004,7 @@
method: "tools/call",
params: { name: "get-plan-blocks", arguments: {} },
});
const text = (blocks.result?.content as Array<{ text: string }>)[0]?.text ?? "";

Check warning on line 2007 in apps/server/src/server.test.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unsafe-optional-chaining)

Unsafe usage of optional chaining
assert.ok(text.includes("rich-text"), "catalog should list the rich-text block");
assert.ok(text.includes("question-form"), "catalog should list the question-form block");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
Expand Down Expand Up @@ -4732,6 +4732,49 @@
}).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;
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@
// there). NO snapshot frame — the client already has it; overlap between catch-up
// and live is deduped by sequence on the client. Exported so the ordering + filter
// seam is unit-testable without the full RPC layer.
export function resumeThreadStream<E1, E2, R>(

Check warning on line 307 in apps/server/src/ws.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

unicorn(consistent-function-scoping)

Function `toItem` does not capture any variables from its parent scope
catchUpEvents: Stream.Stream<OrchestrationEvent, E1, R>,
liveEvents: Stream.Stream<OrchestrationEvent, E2, R>,
threadId: ThreadId,
Expand Down Expand Up @@ -2091,6 +2091,13 @@
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",
Expand Down
Loading
Loading