fix: harden workflow console runtime
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "pnpm --filter @lda/workflow-rpc build",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "tsx src/index.ts",
|
||||
"prebuild": "pnpm --filter @lda/workflow-rpc build",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
|
||||
@@ -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
@@ -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 ?? {},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user