fix: harden workflow console runtime

This commit is contained in:
lda
2026-07-02 17:35:16 +07:00 Verified
parent bcf31583af
commit ea1276badd
22 changed files with 684 additions and 128 deletions
+17 -1
View File
@@ -11,7 +11,7 @@ const makeExchange = (
operation: "workflow.health",
target: "http://127.0.0.1:8765/rpc",
label: "Health check",
interpreted: { status: "ok", store_root: "/tmp/store" },
interpreted: { status: "ok", storeRoot: "/tmp/store" },
exchange: { request: {}, response: { status: "ok" } },
equivalentCli: "uv run wf status",
durationMs: 12,
@@ -55,6 +55,7 @@ describe("POST /api/connect", () => {
expect(body.connection.status).toBe("connected");
expect(body.connection.target).toBe("http://127.0.0.1:8765/rpc");
expect(body.connection.serverStatus).toBe("ok");
expect(body.connection.storeRoot).toBe("/tmp/store");
expect(okRunner).toHaveBeenCalledWith(
"workflow.health",
"http://127.0.0.1:8000/rpc",
@@ -73,6 +74,21 @@ describe("POST /api/connect", () => {
expect(body.ok).toBe(false);
expect(body.error.code).toBe("invalid_target");
});
it("returns a decode error when health interpretation is malformed", async () => {
const malformedApp = createApp({
runOperation: async () => makeExchange({ interpreted: { status: "ok" } }),
});
const res = await malformedApp.request("/api/connect", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ target: "http://127.0.0.1:8000/rpc" }),
});
expect(res.status).toBe(502);
const body = await res.json();
expect(body.ok).toBe(false);
expect(body.error.code).toBe("rpc_decode_error");
});
});
describe("POST /api/rpc", () => {
+38 -12
View File
@@ -1,7 +1,12 @@
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { OperationExchange, OperationName } from "@lda/workflow-rpc";
import {
listOperations,
type OperationExchange,
type OperationName,
type WorkflowHealthInterpreted,
} from "@lda/workflow-rpc";
import { addStaticRoutes, validateConsoleRoot } from "./static.js";
export type RunOperation = (
@@ -20,10 +25,22 @@ type BrowserErrorCode =
| "rpc_decode_error"
| "response_too_large";
const VALID_OPERATIONS: ReadonlySet<string> = new Set([
"workflow.health",
"workflow.sources.list",
]);
const VALID_OPERATIONS: ReadonlySet<string> = new Set(
listOperations().map((operation) => operation.method),
);
const isOperationName = (value: string): value is OperationName =>
VALID_OPERATIONS.has(value);
const isHealthInterpreted = (
value: unknown,
): value is WorkflowHealthInterpreted =>
typeof value === "object" &&
value !== null &&
"status" in value &&
value.status === "ok" &&
"storeRoot" in value &&
typeof value.storeRoot === "string";
const mapErrorToStatus = (
tag: string,
@@ -92,17 +109,26 @@ export function createApp(dependencies: {
body.target,
{},
);
if (!isHealthInterpreted(exchange.interpreted)) {
return c.json(
{
ok: false,
error: {
code: "rpc_decode_error",
message: "workflow.health returned an unexpected shape",
},
exchange: exchange.exchange,
},
502,
);
}
return c.json({
ok: true,
connection: {
status: "connected",
target: exchange.target,
serverStatus: "ok",
storeRoot: (
exchange.interpreted as { storeRoot?: string; store_root?: string }
).storeRoot ?? (
exchange.interpreted as { storeRoot?: string; store_root?: string }
).store_root ?? "",
storeRoot: exchange.interpreted.storeRoot,
durationMs: exchange.durationMs,
},
exchange: exchange.exchange,
@@ -145,7 +171,7 @@ export function createApp(dependencies: {
if (
!body.operation ||
typeof body.operation !== "string" ||
!VALID_OPERATIONS.has(body.operation)
!isOperationName(body.operation)
) {
return c.json(
{
@@ -173,7 +199,7 @@ export function createApp(dependencies: {
try {
const exchange = await runOperation(
body.operation as OperationName,
body.operation,
body.target,
body.params ?? {},
);
+31 -3
View File
@@ -1,5 +1,7 @@
import { Effect, Layer } from "effect";
import { Effect } from "effect";
import { serve } from "@hono/node-server";
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import {
WorkflowRpc,
@@ -19,6 +21,13 @@ const hostname = process.env.WEB_HOST ?? "127.0.0.1";
const liveLayer = makeWorkflowRpcLayer();
const consoleRoot = fileURLToPath(new URL("../../console/dist", import.meta.url));
const consoleIndex = path.join(consoleRoot, "index.html");
const staticConsoleRoot = fs.existsSync(consoleIndex) ? consoleRoot : undefined;
if (!staticConsoleRoot) {
// In dev, Vite serves the console and proxies /api to this server. A missing
// dist directory should not prevent the API proxy from starting.
console.warn(`console dist not found; serving API only: ${consoleRoot}`);
}
const runOperation: RunOperation = async (
operation: OperationName,
@@ -30,12 +39,31 @@ const runOperation: RunOperation = async (
return yield* execute(operation, target, params);
}).pipe(Effect.provide(liveLayer), Effect.runPromise);
const app = createApp({ runOperation, consoleRoot });
let app: ReturnType<typeof createApp>;
try {
app = createApp({
runOperation,
...(staticConsoleRoot ? { consoleRoot: staticConsoleRoot } : {}),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Failed to start workflow console server: ${message}`);
process.exit(1);
}
serve({
const server = serve({
fetch: app.fetch,
hostname,
port,
});
console.log(`workflow console server listening on http://${hostname}:${port}`);
const shutdown = (signal: NodeJS.Signals) => {
console.log(`received ${signal}, stopping workflow console server`);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 5_000).unref();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);